release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled

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>
This commit is contained in:
2026-05-08 15:49:36 +02:00
parent 8c606ad498
commit d47774ef1c
33 changed files with 519 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/core",
"version": "4.8.2",
"version": "4.8.3",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -26,6 +26,8 @@ export interface ShadeEventMap {
'identity.rotated': { newFingerprint: string };
'session.created': { address: string; remoteIdentityKeyHash: string };
'session.removed': { address: string };
/** V4.8.3 — emitted when `aliasSession` moves a peer's per-peer state. */
'session.aliased': { oldLabel: string; newLabel: string };
'message.encrypted': { address: string; counter: number; ciphertextSize: number };
'message.decrypted': { address: string; counter: number; plaintextSize: number };
'ratchet.dh_step': { address: string };

View File

@@ -265,6 +265,73 @@ export class ShadeSessionManager {
// Note: we keep the trusted identity; new session will verify against it.
}
/**
* Move every per-peer storage row for `oldLabel` (session, trusted
* identity, peer-verification, identity-version counter) to
* `newLabel`. Used to canonicalize sessions when first-contact
* forces the receiver to label by sender-fingerprint hint
* (`fp:<hex>`) and a later in-band announcement reveals the peer's
* canonical address.
*
* Holds the per-peer mutex on **both** labels for the whole
* migration so concurrent encrypt/decrypt for either label can't
* observe a half-moved state. Locks are taken in lexicographic
* order to avoid deadlocks if two callers alias in opposite
* directions.
*
* Throws if no session exists for `oldLabel`. Throws (refuses to
* overwrite) if a session already exists for `newLabel`. No-ops
* when `oldLabel === newLabel`.
*
* V4.8.3 — Prism FR `session-label-asymmetry-v4.8.2.md`.
*/
async aliasSession(oldLabel: string, newLabel: string): Promise<void> {
if (oldLabel === newLabel) return;
const [first, second] = oldLabel < newLabel ? [oldLabel, newLabel] : [newLabel, oldLabel];
await this.runUnderPeerLock(first, () =>
this.runUnderPeerLock(second, () => this.aliasUnderLock(oldLabel, newLabel)),
);
}
private async aliasUnderLock(oldLabel: string, newLabel: string): Promise<void> {
const session = await this.storage.getSession(oldLabel);
if (!session) throw new NoSessionError(oldLabel);
const collision = await this.storage.getSession(newLabel);
if (collision) {
throw new Error(
`aliasSession: refusing to overwrite an existing session for "${newLabel}". ` +
`If you want to replace it, call resetSession("${newLabel}") first.`,
);
}
// Move the session.
await this.storage.saveSession(newLabel, session);
// Re-pin trust under the new label using the session's stored DH
// identity key — `saveTrustedIdentity` is the same primitive that
// the X3DH initiator/responder uses, and the DH key in `session`
// is the value that was pinned at session-establish time. The old
// pin under `oldLabel` is harmless leftover (the storage interface
// has no remove for trust pins) and would only be re-checked if a
// fresh X3DH against `oldLabel` somehow happened later.
await this.storage.saveTrustedIdentity(newLabel, session.remoteIdentityKey);
// Migrate the peer-verification record if present.
const verification = await this.storage.getPeerVerification(oldLabel);
if (verification) {
await this.storage.savePeerVerification({
...verification,
peerAddress: newLabel,
});
await this.storage.removePeerVerification(oldLabel);
}
// Carry the identity-version counter forward so peer rotation
// history is preserved.
const oldVersion = await this.storage.getPeerIdentityVersion(oldLabel);
for (let i = 1; i < oldVersion; i++) {
await this.storage.bumpPeerIdentityVersion(newLabel);
}
await this.storage.removeSession(oldLabel);
this.events?.emit('session.aliased', { oldLabel, newLabel });
}
/**
* Accept a changed remote identity. This should only be called after
* verifying the new identity out-of-band (e.g., comparing fingerprints).