feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
|
|
|
import { createShade, type Shade } from '../src/index.js';
|
|
|
|
|
import {
|
|
|
|
|
createPrekeyServer,
|
|
|
|
|
MemoryPrekeyStore,
|
|
|
|
|
PrekeyServerEvents,
|
|
|
|
|
} from '@shade/server';
|
2026-05-05 17:58:45 +02:00
|
|
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
|
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Spin up a real prekey server on a random port and return its URL
|
|
|
|
|
* + a teardown function.
|
|
|
|
|
*/
|
|
|
|
|
async function startPrekeyServer(): Promise<{
|
|
|
|
|
url: string;
|
|
|
|
|
stop: () => void;
|
|
|
|
|
events: PrekeyServerEvents;
|
|
|
|
|
}> {
|
|
|
|
|
const events = new PrekeyServerEvents();
|
|
|
|
|
const server = createPrekeyServer({
|
|
|
|
|
crypto,
|
|
|
|
|
store: new MemoryPrekeyStore(),
|
|
|
|
|
disableRateLimit: true,
|
|
|
|
|
events,
|
|
|
|
|
});
|
test: stop guessing port numbers
Twenty test servers picked a port with `NNNN + Math.floor(Math.random() * 500)`
and hoped it was free. Several files guessed inside the *same* range —
sdk.test.ts and gates.test.ts both on 19500, three files on 22000, and
webrtc-integration.test.ts used 22000 twice within itself — so with test files
serving in parallel the collision was not unlikely, it was scheduled.
Found from the outside, in Nova, where Shade is vendored: across eight runs of
an unchanged tree, two had failures and six were clean. The visible error was
Failed to start server. Is port 24287 in use? EADDRINUSE
and then a second, louder one: `teardownRig` ran on a rig that setup had never
finished building and died on `rig.alice`, so the TypeError from cleanup is
what you read first and the real cause scrolled past above it.
`Bun.serve({ port: 0 })` lets the OS hand out a free port and `server.port`
reads it back. There is no range to collide inside.
teardownRig now tolerates a rig that was never built. Cleanup must never be the
loudest thing in a failing test.
A suite that fails a quarter of the time is worse than a suite that fails: it
teaches everyone to re-run it, and then a real regression looks like the flake.
Verified: 1198 pass / 0 fail here, and the same fix is going into Nova's
vendored copy so the next sync does not bring the guessing back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014489bKUtUEY1Zgs9xN9mt7
2026-09-08 16:33:40 +02:00
|
|
|
// Port 0 lets the OS hand out a free one. This guessed inside a 500-wide
|
|
|
|
|
// range and hoped — and several files here guessed inside the SAME range,
|
|
|
|
|
// so with test files serving in parallel the collision arrived about a
|
|
|
|
|
// quarter of the time as EADDRINUSE.
|
|
|
|
|
const handle = Bun.serve({ port: 0, fetch: server.fetch });
|
|
|
|
|
const port = handle.port;
|
feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
return {
|
|
|
|
|
url: `http://localhost:${port}`,
|
|
|
|
|
stop: () => handle.stop(),
|
|
|
|
|
events,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('createShade — happy path', () => {
|
|
|
|
|
let server: Awaited<ReturnType<typeof startPrekeyServer>>;
|
|
|
|
|
let alice: Shade;
|
|
|
|
|
let bob: Shade;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
server = await startPrekeyServer();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await alice?.shutdown();
|
|
|
|
|
await bob?.shutdown();
|
|
|
|
|
server.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('one-liner creation and initialization', async () => {
|
|
|
|
|
alice = await createShade({
|
|
|
|
|
prekeyServer: server.url,
|
|
|
|
|
address: 'alice',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(alice.myAddress).toBe('alice');
|
|
|
|
|
const fp = await alice.fingerprint;
|
|
|
|
|
expect(fp.split(' ').length).toBe(12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('auto-publishes bundle on init', async () => {
|
|
|
|
|
const registered: string[] = [];
|
|
|
|
|
server.events.on((e) => {
|
|
|
|
|
if (e.name === 'server.identity_registered') {
|
|
|
|
|
registered.push(e.data.address);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
alice = await createShade({
|
|
|
|
|
prekeyServer: server.url,
|
|
|
|
|
address: 'alice',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(registered).toContain('alice');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('two-process conversation: Alice ↔ Bob via SDK only', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
// Alice sends to Bob — SDK auto-establishes session
|
|
|
|
|
const env1 = await alice.send('bob', 'hello Bob');
|
|
|
|
|
const plain1 = await bob.receive('alice', env1);
|
|
|
|
|
expect(plain1).toBe('hello Bob');
|
|
|
|
|
|
|
|
|
|
// Bob replies (DH ratchet triggers)
|
|
|
|
|
const env2 = await bob.send('alice', 'hi Alice');
|
|
|
|
|
const plain2 = await alice.receive('bob', env2);
|
|
|
|
|
expect(plain2).toBe('hi Alice');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('onMessage handler fires on receive', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
const received: Array<{ from: string; msg: string }> = [];
|
|
|
|
|
bob.onMessage((from, msg) => received.push({ from, msg }));
|
|
|
|
|
|
|
|
|
|
const env = await alice.send('bob', 'callback test');
|
|
|
|
|
await bob.receive('alice', env);
|
|
|
|
|
|
|
|
|
|
expect(received.length).toBe(1);
|
|
|
|
|
expect(received[0]!.from).toBe('alice');
|
|
|
|
|
expect(received[0]!.msg).toBe('callback test');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('concurrent sends to same new peer establish exactly one session', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
// Fire 3 parallel sends to Bob
|
|
|
|
|
const results = await Promise.all([
|
|
|
|
|
alice.send('bob', 'msg1'),
|
|
|
|
|
alice.send('bob', 'msg2'),
|
|
|
|
|
alice.send('bob', 'msg3'),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// All 3 should succeed and be decryptable in order
|
|
|
|
|
expect(results.length).toBe(3);
|
|
|
|
|
const decrypted: string[] = [];
|
|
|
|
|
for (const env of results) {
|
|
|
|
|
decrypted.push(await bob.receive('alice', env));
|
|
|
|
|
}
|
|
|
|
|
expect(decrypted.sort()).toEqual(['msg1', 'msg2', 'msg3']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('send to unknown address throws clear error', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
await expect(alice.send('nobody', 'ghost')).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed.
1. `Inbox.acceptBridgeFrame(blob)` + shared 4096-entry msgId LRU.
The relay durably stores blobs and pushes them to every active
delivery channel; without a cross-channel ack the bridge frame
ran first and the next inbox-poll re-dispatched the same blob
~30 s later, tripping on consumed prekeys. Bridge consumers now
plumb pushed frames through `acceptBridgeFrame`, which shares
the dedup gate + ack path with `pollOnce`. Whichever channel
delivers first wins; the other acks-and-skips. Inbox records
the msgId before the ack so a parallel poll can't observe an
in-flight ack window.
2. `Shade.aliasSession(oldLabel, newLabel)`. First-contact forces
the receiver to label the new session by the relay's sender
fingerprint hint (`fp:<senderfp>`); the post-decrypt plaintext
typically announces the peer's real address. Aliasing moves
session, trusted identity, peer-verification, and identity-
version under the canonical label. Holds the per-peer mutex on
both labels (lexicographic order) so concurrent crypto ops can't
observe a half-moved state. Refuses to overwrite an existing
session at the new label.
Wire change: `IncomingMessage.expiresAt?` now surfaces the relay's
expiry so receivers can pass bridge frames straight to
`acceptBridgeFrame` without inventing a TTL.
Tests cover bridge-then-poll, poll-then-bridge, aliasSession happy
path, refuse-to-overwrite, and same-label no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:49:36 +02:00
|
|
|
test('aliasSession migrates a session from fp:<hex> to a canonical address label (V4.8.3)', async () => {
|
|
|
|
|
// Reproduces the Prism FR `session-label-asymmetry-v4.8.2`. Bob
|
|
|
|
|
// initiates X3DH against Alice using Alice's prekey-server
|
|
|
|
|
// address. Alice receives the prekey envelope under the relay's
|
|
|
|
|
// sender-fingerprint hint (`fp:<bobfp>`), because that's the only
|
|
|
|
|
// sender label the bridge surfaces at first contact. The
|
|
|
|
|
// post-decrypt plaintext announces Bob's real address; Alice then
|
|
|
|
|
// canonicalizes the session by aliasing `fp:<bobfp>` → `bob` and
|
|
|
|
|
// every subsequent send/receive operates symmetrically.
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
// First contact — Bob sends, Alice receives under the fp-label.
|
|
|
|
|
const env1 = await bob.send('alice', 'hello, my address is bob');
|
|
|
|
|
const fpLabel = 'fp:bobfingerprint16';
|
|
|
|
|
expect(await alice.receive(fpLabel, env1)).toBe('hello, my address is bob');
|
|
|
|
|
|
|
|
|
|
// Alice canonicalizes: move the session from the fp-label to bob's
|
|
|
|
|
// real address.
|
|
|
|
|
await alice.aliasSession(fpLabel, 'bob');
|
|
|
|
|
|
|
|
|
|
// Subsequent ratchet messages flow under the canonical label both
|
|
|
|
|
// directions. Bob's session for Alice is keyed under `alice`
|
|
|
|
|
// (Bob's send target); Alice's session for Bob is now keyed under
|
|
|
|
|
// `bob` (post-alias). Symmetry restored.
|
|
|
|
|
const env2 = await bob.send('alice', 'reply 1');
|
|
|
|
|
expect(await alice.receive('bob', env2)).toBe('reply 1');
|
|
|
|
|
|
|
|
|
|
const env3 = await alice.send('bob', 'reply 2');
|
|
|
|
|
expect(await bob.receive('alice', env3)).toBe('reply 2');
|
|
|
|
|
|
fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.
First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.
Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.
StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.
A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.
The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.
Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.
Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
|
|
|
// V4.12: the fp-label is no longer a dead end. `aliasSession` leaves
|
|
|
|
|
// a durable binding behind, so a peer that keeps sending under the
|
|
|
|
|
// first-contact label — which is exactly what a fingerprint-hinted
|
|
|
|
|
// relay makes it do — still reaches the canonicalized session.
|
|
|
|
|
// See `shade-core/tests/session-aliases.test.ts` for the restart
|
|
|
|
|
// behaviour this binding exists to protect.
|
|
|
|
|
expect(await alice.resolveSessionLabel(fpLabel)).toBe('bob');
|
|
|
|
|
const env4 = await bob.send('alice', 'reply 3');
|
|
|
|
|
expect(await alice.receive(fpLabel, env4)).toBe('reply 3');
|
release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed.
1. `Inbox.acceptBridgeFrame(blob)` + shared 4096-entry msgId LRU.
The relay durably stores blobs and pushes them to every active
delivery channel; without a cross-channel ack the bridge frame
ran first and the next inbox-poll re-dispatched the same blob
~30 s later, tripping on consumed prekeys. Bridge consumers now
plumb pushed frames through `acceptBridgeFrame`, which shares
the dedup gate + ack path with `pollOnce`. Whichever channel
delivers first wins; the other acks-and-skips. Inbox records
the msgId before the ack so a parallel poll can't observe an
in-flight ack window.
2. `Shade.aliasSession(oldLabel, newLabel)`. First-contact forces
the receiver to label the new session by the relay's sender
fingerprint hint (`fp:<senderfp>`); the post-decrypt plaintext
typically announces the peer's real address. Aliasing moves
session, trusted identity, peer-verification, and identity-
version under the canonical label. Holds the per-peer mutex on
both labels (lexicographic order) so concurrent crypto ops can't
observe a half-moved state. Refuses to overwrite an existing
session at the new label.
Wire change: `IncomingMessage.expiresAt?` now surfaces the relay's
expiry so receivers can pass bridge frames straight to
`acceptBridgeFrame` without inventing a TTL.
Tests cover bridge-then-poll, poll-then-bridge, aliasSession happy
path, refuse-to-overwrite, and same-label no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:49:36 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('aliasSession refuses to overwrite an existing session', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
const carol = await createShade({ prekeyServer: server.url, address: 'carol' });
|
|
|
|
|
try {
|
|
|
|
|
// Two distinct first-contact prekey envelopes — one from Bob,
|
|
|
|
|
// one from Carol — let Alice end up with two real sessions in
|
|
|
|
|
// storage at two different labels.
|
|
|
|
|
const env1 = await bob.send('alice', 'one');
|
|
|
|
|
await alice.receive('fp:bobfp', env1);
|
|
|
|
|
const env2 = await carol.send('alice', 'two');
|
|
|
|
|
await alice.receive('fp:carolfp', env2);
|
|
|
|
|
|
|
|
|
|
await expect(alice.aliasSession('fp:carolfp', 'fp:bobfp')).rejects.toThrow(
|
|
|
|
|
/refusing to overwrite/i,
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
await carol.shutdown();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('aliasSession is a no-op when oldLabel === newLabel', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
const env = await bob.send('alice', 'hi');
|
|
|
|
|
await alice.receive('fp:bobfp', env);
|
|
|
|
|
// Same-label alias is a no-op; session must still decrypt the next message.
|
|
|
|
|
await alice.aliasSession('fp:bobfp', 'fp:bobfp');
|
|
|
|
|
const env2 = await bob.send('alice', 'hi again');
|
|
|
|
|
expect(await alice.receive('fp:bobfp', env2)).toBe('hi again');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 12:13:46 +02:00
|
|
|
test('concurrent receive(from, env) for same `from` does not race the ratchet (V4.8.2)', async () => {
|
|
|
|
|
// Reproduces the Prism FR scenario: a single PUT is fanned out
|
|
|
|
|
// multiple times by the relay (or any duplicating transport), the
|
|
|
|
|
// receiver dispatches several `shade.receive(from, env)` in
|
|
|
|
|
// parallel, and the underlying SessionManager + StorageProvider
|
|
|
|
|
// would race on the ratchet (and on storage writes — sqlite throws
|
|
|
|
|
// "database is locked", IDB throws transaction conflicts) without
|
|
|
|
|
// per-`from` serialization. We pre-establish a session, then fire
|
|
|
|
|
// the same envelope at `bob.receive` from many concurrent callers
|
|
|
|
|
// and verify all of them either decrypt to the same plaintext or
|
|
|
|
|
// surface a benign "already-consumed" error. Crucially: no
|
|
|
|
|
// unhandled storage races, no ratchet corruption, and the next
|
|
|
|
|
// legitimate message still decrypts.
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
const env1 = await alice.send('bob', 'first');
|
|
|
|
|
expect(await bob.receive('alice', env1)).toBe('first');
|
|
|
|
|
|
|
|
|
|
const env2 = await alice.send('bob', 'second');
|
|
|
|
|
// Fan the same envelope out to 8 concurrent receives — exactly the
|
|
|
|
|
// shape of the relay duplicate fan-out described in the FR.
|
|
|
|
|
const dispatches = await Promise.allSettled(
|
|
|
|
|
Array.from({ length: 8 }, () => bob.receive('alice', env2)),
|
|
|
|
|
);
|
|
|
|
|
// At least one must have succeeded with the right plaintext; the
|
|
|
|
|
// others may legitimately reject (replay protection / OTPK
|
|
|
|
|
// already-consumed) but MUST NOT corrupt the ratchet or throw
|
|
|
|
|
// "database is locked".
|
|
|
|
|
const fulfilled = dispatches.filter((d) => d.status === 'fulfilled') as Array<
|
|
|
|
|
PromiseFulfilledResult<string>
|
|
|
|
|
>;
|
|
|
|
|
expect(fulfilled.length).toBeGreaterThan(0);
|
|
|
|
|
expect(fulfilled[0]!.value).toBe('second');
|
|
|
|
|
|
|
|
|
|
for (const d of dispatches) {
|
|
|
|
|
if (d.status === 'rejected') {
|
|
|
|
|
const msg = String((d.reason as Error)?.message ?? d.reason);
|
|
|
|
|
expect(msg).not.toMatch(/database is locked/i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ratchet must still advance — the next legitimate message decrypts.
|
|
|
|
|
const env3 = await alice.send('bob', 'third');
|
|
|
|
|
expect(await bob.receive('alice', env3)).toBe('third');
|
|
|
|
|
});
|
|
|
|
|
|
feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
test('verify fingerprint matches pinned identity', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
// Establish session
|
|
|
|
|
const env = await alice.send('bob', 'init');
|
|
|
|
|
await bob.receive('alice', env);
|
|
|
|
|
|
|
|
|
|
const bobFp = await bob.fingerprint;
|
|
|
|
|
// Alice knows Bob via session; but remote fingerprint is derived from
|
|
|
|
|
// stored DH key only (not full identity), so we just check it's returned
|
|
|
|
|
const remoteFp = await alice.getFingerprintFor('bob');
|
|
|
|
|
expect(remoteFp.split(' ').length).toBe(12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('shutdown clears background timers and closes storage', async () => {
|
|
|
|
|
alice = await createShade({
|
|
|
|
|
prekeyServer: server.url,
|
|
|
|
|
address: 'alice',
|
|
|
|
|
autoReplenish: { min: 5, target: 20, intervalMs: 100 },
|
|
|
|
|
});
|
|
|
|
|
await alice.shutdown();
|
|
|
|
|
// If background timer wasn't cleared, this test would hang after the
|
|
|
|
|
// final afterEach. We rely on bun test's cleanup to catch that.
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('manual replenish is callable', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
// Initially has 20 prekeys, so replenish is a no-op
|
|
|
|
|
const n = await alice.replenish();
|
|
|
|
|
expect(n).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('auto-replenish is disabled when set to false', async () => {
|
|
|
|
|
alice = await createShade({
|
|
|
|
|
prekeyServer: server.url,
|
|
|
|
|
address: 'alice',
|
|
|
|
|
autoReplenish: false,
|
|
|
|
|
});
|
|
|
|
|
expect(alice.myAddress).toBe('alice');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rotate regenerates identity', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
const oldFp = await alice.fingerprint;
|
|
|
|
|
|
|
|
|
|
await alice.rotate();
|
|
|
|
|
|
|
|
|
|
const newFp = await alice.fingerprint;
|
|
|
|
|
expect(newFp).not.toBe(oldFp);
|
|
|
|
|
});
|
2026-05-05 17:58:45 +02:00
|
|
|
|
|
|
|
|
test('identityPublicKey exposes the device Ed25519 key and tracks rotation', async () => {
|
|
|
|
|
const storage = new MemoryStorage();
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice', storage });
|
|
|
|
|
|
|
|
|
|
const pk = await alice.identityPublicKey;
|
|
|
|
|
expect(pk).toBeInstanceOf(Uint8Array);
|
|
|
|
|
expect(pk.length).toBe(32);
|
|
|
|
|
|
|
|
|
|
// Matches what the underlying storage holds
|
|
|
|
|
const stored = await storage.getIdentityKeyPair();
|
|
|
|
|
expect(stored).not.toBeNull();
|
|
|
|
|
expect(pk).toEqual(stored!.signingPublicKey);
|
|
|
|
|
|
|
|
|
|
// Reflects the new key after rotate (acceptance criteria #3)
|
|
|
|
|
await alice.rotate();
|
|
|
|
|
const pkAfter = await alice.identityPublicKey;
|
|
|
|
|
expect(pkAfter.length).toBe(32);
|
|
|
|
|
expect(pkAfter).not.toEqual(pk);
|
|
|
|
|
});
|
feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createShade — validation', () => {
|
|
|
|
|
test('throws when prekeyServer is missing', async () => {
|
|
|
|
|
await expect(createShade({} as any)).rejects.toThrow(/prekeyServer is required/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('throws when observer token is too short', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
createShade({
|
|
|
|
|
prekeyServer: 'http://localhost:9999',
|
|
|
|
|
observer: { token: 'short' },
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(/at least 16/);
|
|
|
|
|
});
|
|
|
|
|
});
|