Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/FORWARD-SECRECY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Wire layout of sealed payloads is unchanged (relays still cannot tell modes apar
| Same QR scanned by two people | SPK only on QR — both deliver; OTKs are exclusive in-band |
| In-flight after SPK rotation | SPK ring retained 6h |
| Trial-decrypt cost | OTK pool ceiling (24) + `OPEN_SECRET_CAP` (28); newest-first |
| Forged prekeys | SPK signature required; bad bundles rejected |
| Forged prekeys | Signature binds SPK **and** the exact OTK list; substituted OTKs fail verify |

## Trial-decrypt cost

Expand Down
25 changes: 25 additions & 0 deletions src/lib/__tests__/forward-secrecy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ describe('prekey bundles', () => {
const code = encodeContactCode(bob.publicId, bundle);
assert.equal(decodeContactCode(code), null);
});

it('rejects substituted OTKs under a valid SPK signature (#48)', () => {
const bobLocal = new LocalPrekeys();
bobLocal.ensureReady();
const honest = bobLocal.updateForPeer(bob, alice.publicId, 1);

const eveLocal = new LocalPrekeys();
eveLocal.ensureReady();
const eveOtk = eveLocal.updateForPeer(eve, alice.publicId, 1).oneTimePublics[0]!;
const forged = { signed: honest.signed, oneTimePublics: [eveOtk] };

assert.equal(verifyReceiveKey(pub(bob), honest.signed, honest.oneTimePublics), true);
assert.equal(verifyReceiveKey(pub(bob), forged.signed, forged.oneTimePublics), false);

const book = new PeerPrekeyBook();
assert.equal(book.absorb(pub(bob), forged), false);

// Control: honest bundle absorbs and seals to Bob, not Eve.
assert.equal(book.absorb(pub(bob), honest), true);
const { public: agreement, kind } = book.takeAgreementPublic(pub(bob));
assert.equal(kind, 'otk');
const sealed = seal(alice, pub(bob), toUtf8('for-bob'), agreement);
assert.ok(open(bob, sealed, bobLocal.secretsForOpen()));
assert.equal(open(eve, sealed, eveLocal.secretsForOpen()), null);
});
});

describe('per-message FS via OTK', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/contact-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function decodeContactCode(raw: string): ContactCode | null {
const identity = parsePublicId(publicId);
if (!identity) return null;
const bundle = decodeBundle(keyPart);
if (!bundle || !verifyReceiveKey(identity, bundle.signed)) return null;
if (!bundle || !verifyReceiveKey(identity, bundle.signed, bundle.oneTimePublics)) return null;
return { identity, bundle };
}

Expand Down
82 changes: 67 additions & 15 deletions src/lib/crypto-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,36 +342,88 @@ export function generateReceiveKey(now = Date.now()): ReceiveKey {
return { secret, public: x25519.getPublicKey(secret), createdAt: now };
}

/** Sign a receive public key so peers can bind it to an identity at intro. */
export function signReceiveKey(owner: Identity, key: ReceiveKey): SignedReceiveKey {
const signature = ed25519.sign(receiveKeyTranscript(key.public, key.createdAt), owner.edSecret);
/**
* Sign a receive public key (and any accompanying one-time publics) so peers
* can bind the whole prekey bundle to an identity.
*
* OTKs MUST be covered by this signature. Verifying the SPK alone and then
* trusting an attacker-supplied OTK list lets Eve decrypt mail Alice sealed
* "to Bob" (issue #48).
*/
export function signReceiveKey(
owner: Identity,
key: ReceiveKey,
oneTimePublics: readonly Uint8Array[] = [],
): SignedReceiveKey {
const signature = ed25519.sign(
receiveKeyTranscript(key.public, key.createdAt, oneTimePublics),
owner.edSecret,
);
return { public: key.public, createdAt: key.createdAt, signature };
}

export function verifyReceiveKey(owner: PublicIdentity, signed: SignedReceiveKey): boolean {
/**
* Verify a signed receive key. When `oneTimePublics` is non-empty, the
* signature must bind those exact OTK publics — substituted OTKs fail.
*
* Empty OTK list: accept the current transcript (count=0) or a legacy
* SPK-only transcript from pre-#48 QR plaques.
*/
export function verifyReceiveKey(
owner: PublicIdentity,
signed: SignedReceiveKey,
oneTimePublics: readonly Uint8Array[] = [],
): boolean {
if (signed.public.length !== X_LEN || signed.signature.length !== SIG_LEN) return false;
if (!Number.isFinite(signed.createdAt) || signed.createdAt <= 0) return false;
for (const p of oneTimePublics) {
if (p.length !== X_LEN) return false;
}
try {
return ed25519.verify(
signed.signature,
receiveKeyTranscript(signed.public, signed.createdAt),
owner.edPublic,
);
const bound = receiveKeyTranscript(signed.public, signed.createdAt, oneTimePublics);
if (ed25519.verify(signed.signature, bound, owner.edPublic)) return true;
// Legacy QR / SPK-only signatures (no OTK binding). Never accept this path
// when OTKs are present — that would reintroduce unsigned-OTK redirect.
if (oneTimePublics.length === 0) {
return ed25519.verify(
signed.signature,
receiveKeyTranscriptLegacy(signed.public, signed.createdAt),
owner.edPublic,
);
}
return false;
} catch {
return false;
}
}

function receiveKeyTranscript(publicKey: Uint8Array, createdAt: number): Uint8Array {
function receiveKeyTimestamp(createdAt: number): Uint8Array {
// 64-bit createdAt so the signature cannot be replayed across rotations with
// the same public (which should not happen with honest CSPRNG, but bind it).
const ts = new Uint8Array(8);
const view = new DataView(ts.buffer);
const hi = Math.floor(createdAt / 0x100000000);
const lo = createdAt >>> 0;
view.setUint32(0, hi);
view.setUint32(4, lo);
return concat(SIG_CONTEXT_RECV, publicKey, ts);
view.setUint32(0, Math.floor(createdAt / 0x100000000));
view.setUint32(4, createdAt >>> 0);
return ts;
}

/** Current transcript: SPK || createdAt || u16be(n) || OTK×n. */
function receiveKeyTranscript(
publicKey: Uint8Array,
createdAt: number,
oneTimePublics: readonly Uint8Array[],
): Uint8Array {
const n = oneTimePublics.length;
if (n > 0xffff) throw new Error('too many one-time prekeys to sign');
const count = new Uint8Array(2);
count[0] = (n >> 8) & 0xff;
count[1] = n & 0xff;
return concat(SIG_CONTEXT_RECV, publicKey, receiveKeyTimestamp(createdAt), count, ...oneTimePublics);
}

/** Pre-#48 transcript: SPK || createdAt only. */
function receiveKeyTranscriptLegacy(publicKey: Uint8Array, createdAt: number): Uint8Array {
return concat(SIG_CONTEXT_RECV, publicKey, receiveKeyTimestamp(createdAt));
}

/** Compact encoding for QR / paste: public(32) || createdAt_be64 || sig(64). */
Expand Down
17 changes: 10 additions & 7 deletions src/lib/prekeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,18 @@ export class LocalPrekeys {
*/
bundleForQr(owner: Identity, _count = QR_OTK_COUNT): PrekeyBundle {
this.ensureReady();
const signed = signReceiveKey(owner, this.spk[0]!);
return { signed, oneTimePublics: [] };
const oneTimePublics: Uint8Array[] = [];
const signed = signReceiveKey(owner, this.spk[0]!, oneTimePublics);
return { signed, oneTimePublics };
}

/** In-band replenishment dedicated to one peer. */
updateForPeer(owner: Identity, peerPublicId: string, count = REPLENISH_OTK_COUNT): PrekeyBundle {
this.ensureReady();
const signed = signReceiveKey(owner, this.spk[0]!);
return { signed, oneTimePublics: this.issueOtks(peerPublicId, count) };
const oneTimePublics = this.issueOtks(peerPublicId, count);
// Sign AFTER issuing so the signature binds this exact OTK list (#48).
const signed = signReceiveKey(owner, this.spk[0]!, oneTimePublics);
return { signed, oneTimePublics };
}

toWire(bundle: PrekeyBundle): PrekeyUpdateWire {
Expand Down Expand Up @@ -253,11 +256,11 @@ export class PeerPrekeyBook {
}

/**
* Merge a verified bundle from `owner`. Invalid SPK signatures are ignored
* entirely (do not partially trust OTKs from an unverified update).
* Merge a verified bundle from `owner`. The signature must bind the SPK and
* the exact OTK list — substituted OTKs fail verification (#48).
*/
absorb(owner: PublicIdentity, bundle: PrekeyBundle): boolean {
if (!verifyReceiveKey(owner, bundle.signed)) return false;
if (!verifyReceiveKey(owner, bundle.signed, bundle.oneTimePublics)) return false;
let row = this.peers.get(owner.publicId);
if (!row) {
row = { spk: null, otks: [] };
Expand Down
Loading