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>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user