From 023aa90b78b5643726def8563559ea2c52c218f7 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sat, 20 Jun 2026 08:22:36 +0200 Subject: [PATCH 1/2] fix(session): allow processing of prekeys in omemo:2 wire-format `processPreKey` fed the bundle's signed prekey and prekey public keys straight to ECDHE. omemo:2 transfers these as raw 32-byte curve keys, which the version 2.0.1 DH hardening rejects with "Invalid public key". Normalize them to the internal 33-byte 0x05-prefixed form via a new `normalizeRemotePreKey` protocol-profile hook. --- CHANGELOG.md | 11 +++++++ package-lock.json | 4 +-- package.json | 2 +- src/session/builder.ts | 16 ++++++++-- src/session/protocol-profile.ts | 21 +++++++++++++ test/omemo2.ts | 53 +++++++++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5484099..54b306c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGES +## 2.0.2 (Unreleased) + +### Fix: omemo:2 session establishment from a wire-form PreKey bundle + +- `SessionBuilder.processPreKey` now normalises the bundle's signed-pre-key and + pre-key public keys to the internal 33-byte `0x05`-prefixed curve form, via a new + `normalizeRemotePreKey` protocol-profile hook. omemo:2 transfers these keys in + their raw 32-byte curve form, which the 2.0.1 DH hardening rejected with + "Invalid public key", breaking the establishment of every new omemo:2 + session. + ## 2.0.1 (2026-06-19) ### Security / compatibility: eval-free protobuf (strict CSP support) diff --git a/package-lock.json b/package-lock.json index 3efd1f0..5befc6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "libomemo.js", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libomemo.js", - "version": "2.0.1", + "version": "2.0.2", "license": "GPL-3.0", "dependencies": { "protobufjs": "^8.0.0" diff --git a/package.json b/package.json index 9716308..3affd2a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "libomemo.js", "repository": "github:conversejs/libomemo.js", - "version": "2.0.1", + "version": "2.0.2", "license": "GPL-3.0", "type": "module", "main": "./dist/libomemo.umd.js", diff --git a/src/session/builder.ts b/src/session/builder.ts index 3d7e5ca..f22674d 100644 --- a/src/session/builder.ts +++ b/src/session/builder.ts @@ -37,6 +37,7 @@ export class SessionBuilder { // Normalise the remote identity key: for omemo:2 the wire form is // Ed25519 and is converted to its Curve25519 equivalent for DH. const remoteId = await this.#profile.normalizeRemoteIdentityKey(device.identityKey); + // Trust is keyed on the form the consumer published in the bundle // (Ed25519 for omemo:2, Curve25519 for 0.3.0). const trustKey = remoteId.ed ?? remoteId.curve; @@ -51,21 +52,30 @@ export class SessionBuilder { throw new Error("Identity key changed"); } + // Normalise the prekeys, which may be in the raw 32-byte curve + // form, to the internal 33-byte form, since the DH path + // no longer accepts unprefixed keys. + const signedPreKeyPub = this.#profile.normalizeRemotePreKey( + device.signedPreKey.publicKey + ); + const devicePreKey = device.preKey?.publicKey + ? this.#profile.normalizeRemotePreKey(device.preKey.publicKey) + : undefined; + await internalCrypto.Ed25519Verify( remoteId.curve, - this.#profile.signedPreKeySignatureData(device.signedPreKey.publicKey), + this.#profile.signedPreKeySignatureData(signedPreKeyPub), device.signedPreKey.signature ); const baseKey = await internalCrypto.createKeyPair(); - const devicePreKey = device.preKey ? device.preKey.publicKey : undefined; const session = await this.#initSession( true, baseKey, undefined, remoteId.curve, devicePreKey, - device.signedPreKey.publicKey, + signedPreKeyPub, this.#registrationId(device.registrationId), remoteId.ed ); diff --git a/src/session/protocol-profile.ts b/src/session/protocol-profile.ts index 668ad6e..90353be 100644 --- a/src/session/protocol-profile.ts +++ b/src/session/protocol-profile.ts @@ -161,6 +161,15 @@ export interface ProtocolProfile { * libomemo-c. */ signedPreKeySignatureData(publicKey: ArrayBuffer): ArrayBuffer; + + /** + * Normalise a (signed-)pre-key public received on the wire (from a PreKey + * bundle) into the library's internal 33-byte 0x05-prefixed curve form. 0.3.0 + * publishes that form already; omemo:2 transfers the raw 32-byte curve form + * and restores the prefix here. This mirrors the wire→internal normalisation + * the profile already applies to ratchet/base keys on the message paths. + */ + normalizeRemotePreKey(wireKey: ArrayBuffer): ArrayBuffer; } /** @@ -346,6 +355,12 @@ const OMEMO_0_3_0: ProtocolProfile = { signedPreKeySignatureData(publicKey: ArrayBuffer): ArrayBuffer { return publicKey; }, + + normalizeRemotePreKey(wireKey: ArrayBuffer): ArrayBuffer { + // 0.3.0 publishes the 33-byte 0x05-prefixed form; a raw 32-byte key is + // malformed and is left to fail closed on the DH path. + return wireKey; + }, }; /** Profile for OMEMO 2 (urn:xmpp:omemo:2). */ @@ -500,6 +515,12 @@ const OMEMO_2: ProtocolProfile = { // omemo:2 signs the raw 32-byte Curve25519 (Montgomery) form. return stripKeyType(publicKey); }, + + normalizeRemotePreKey(wireKey: ArrayBuffer): ArrayBuffer { + // omemo:2 transfers the raw 32-byte curve form; restore the 0x05 prefix + // to the library's internal form (no-op if already prefixed). + return addKeyType(wireKey); + }, }; /** Render an ArrayBuffer/Uint8Array as a binary string (one char per byte). */ diff --git a/test/omemo2.ts b/test/omemo2.ts index b6c9f7d..9494d5a 100644 --- a/test/omemo2.ts +++ b/test/omemo2.ts @@ -199,6 +199,59 @@ describe("OMEMO 2 trust-key enforcement", function () { }); }); +describe("OMEMO 2 wire-form pre-key bundle", function () { + const ALICE = new OMEMOAddress("alice@example.org", 1); + const BOB = new OMEMOAddress("bob@example.org", 1); + + /** Drop the 0x05 type prefix, yielding the raw 32-byte curve key. */ + function stripPrefix(key: ArrayBuffer): ArrayBuffer { + const bytes = new Uint8Array(key); + assert.strictEqual(bytes.byteLength, 33); + assert.strictEqual(bytes[0], 5); + return key.slice(1); + } + + // omemo:2 (XEP-0384) publishes the signed-pre-key and pre-keys in their raw + // 32-byte curve form, without the 0x05 type prefix. processPreKey must accept + // that wire form (regression: a stricter curve check rejected the unprefixed + // keys with "Invalid public key", breaking all new omemo:2 sessions). + it("builds a session from raw 32-byte (unprefixed) signed-pre-key and pre-key", async function () { + const aliceStore = new InMemoryStore(); + const bobStore = new InMemoryStore(); + await Promise.all([generateIdentity(aliceStore), generateIdentity(bobStore)]); + + const bundle = await makeBundle(bobStore, "urn:xmpp:omemo:2", 1337, 1); + const wireBundle: PreKeyBundle = { + ...bundle, + preKey: { + keyId: bundle.preKey!.keyId, + publicKey: stripPrefix(bundle.preKey!.publicKey!), + }, + signedPreKey: { + ...bundle.signedPreKey, + publicKey: stripPrefix(bundle.signedPreKey.publicKey), + }, + }; + + await new SessionBuilder(aliceStore, BOB, "urn:xmpp:omemo:2").processPreKey(wireBundle); + + // The session is established and a full key-exchange round-trip works. + const aliceCipher = new SessionCipher(aliceStore, BOB, "urn:xmpp:omemo:2"); + const bobCipher = new SessionCipher(bobStore, ALICE, "urn:xmpp:omemo:2"); + const msg = util.toArrayBuffer("wire-form kex") as ArrayBuffer; + const ct = await aliceCipher.encrypt(msg); + assert.strictEqual(ct.type, 3); + const { plaintext } = await bobCipher.decryptPreKeyWhisperMessage(ct.body, "binary"); + assertEqualArrayBuffers(plaintext, msg); + + // The stored remote ratchet seed is the normalised 33-byte 0x05 form. + const record = SessionRecord.deserialize(aliceStore.loadSession(BOB.toString())!); + const lastRemote = record.getOpenSession()!.currentRatchet.lastRemoteEphemeralKey; + assert.strictEqual(lastRemote.byteLength, 33); + assert.strictEqual(new Uint8Array(lastRemote)[0], 5); + }); +}); + describe("0.3.0 registrationId enforcement", function () { it("refuses to send a key-exchange message without a local registrationId", async function () { const BOB = new OMEMOAddress("bob@example.org", 1); From f68c7add424dc6e297703a40ea055828ff559a8e Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sat, 20 Jun 2026 20:43:45 +0200 Subject: [PATCH 2/2] chore: remove unnecessary async Now that protobufs are pre-generated, the functions to load them are sync. Remove unnecessary async from functions calling them. --- src/protobufs.ts | 2 +- src/session/protocol-profile.ts | 41 ++++++++++++++++++--------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/protobufs.ts b/src/protobufs.ts index 0257bef..52aa28e 100644 --- a/src/protobufs.ts +++ b/src/protobufs.ts @@ -19,7 +19,7 @@ interface OMEMOMessages { } /** Load codecs for the Signal protocol messages. */ -export async function loadProtocolMessages(): Promise { +export function loadProtocolMessages(): ProtocolMessages { return { WhisperMessage: textsecure.WhisperMessage, PreKeyWhisperMessage: textsecure.PreKeyWhisperMessage, diff --git a/src/session/protocol-profile.ts b/src/session/protocol-profile.ts index 90353be..93cd5b2 100644 --- a/src/session/protocol-profile.ts +++ b/src/session/protocol-profile.ts @@ -109,13 +109,15 @@ export interface ProtocolProfile { messageKeyInfo: string; /** Serialise the inner ratchet message (WhisperMessage / OMEMOMessage). */ - encodeInner(parts: RatchetMessageParts): Promise; + encodeInner(parts: RatchetMessageParts): Uint8Array; + /** Compute the (already-truncated) MAC over the encoded inner message. */ computeMac( authKey: ArrayBuffer, encodedInner: Uint8Array, ctx: MacContext ): Promise; + /** Verify the MAC over the encoded inner message; throws on mismatch. */ verifyMac( authKey: ArrayBuffer, @@ -123,13 +125,16 @@ export interface ProtocolProfile { ctx: MacContext, mac: ArrayBuffer ): Promise; + /** Frame the final ratchet-message body (version byte + MAC, or OMEMOAuthenticatedMessage). */ - frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Promise; + frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Uint8Array; + /** Parse an incoming ratchet-message body. */ - parseMessage(bytes: ArrayBuffer): Promise; + parseMessage(bytes: ArrayBuffer): ParsedRatchetMessage; /** Wrap an already-framed ratchet message in a key-exchange message body. */ encodeKeyExchange(parts: KeyExchangeParts, framedMessage: Uint8Array): Promise; + /** Parse an incoming key-exchange message body. */ parseKeyExchange(bytes: ArrayBuffer): Promise; @@ -240,8 +245,8 @@ const OMEMO_0_3_0: ProtocolProfile = { rootChainInfo: "WhisperRatchet", messageKeyInfo: "WhisperMessageKeys", - async encodeInner(parts: RatchetMessageParts): Promise { - const { WhisperMessage } = await loadProtocolMessages(); + encodeInner(parts: RatchetMessageParts): Uint8Array { + const { WhisperMessage } = loadProtocolMessages(); const msg = WhisperMessage.create({ ephemeralKey: new Uint8Array(parts.ephemeralKey), counter: parts.counter, @@ -269,7 +274,7 @@ const OMEMO_0_3_0: ProtocolProfile = { await verifyMAC(buildV3MacInput(encodedInner, ctx), authKey, mac, 8); }, - async frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Promise { + frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Uint8Array { const result = new Uint8Array(encodedInner.byteLength + 1 + mac.byteLength); result[0] = V3_VERSION_BYTE; result.set(encodedInner, 1); @@ -277,7 +282,7 @@ const OMEMO_0_3_0: ProtocolProfile = { return result; }, - async parseMessage(bytes: ArrayBuffer): Promise { + parseMessage(bytes: ArrayBuffer): ParsedRatchetMessage { const version = new Uint8Array(bytes)[0]; if ((version & 0xf) > 3 || version >> 4 < 3) { throw new Error("Incompatible version number on WhisperMessage"); @@ -285,7 +290,7 @@ const OMEMO_0_3_0: ProtocolProfile = { const encodedInner = new Uint8Array(bytes.slice(1, bytes.byteLength - 8)); const mac = bytes.slice(bytes.byteLength - 8, bytes.byteLength); - const { WhisperMessage } = await loadProtocolMessages(); + const { WhisperMessage } = loadProtocolMessages(); const message = WhisperMessage.decode(encodedInner) as unknown as WhisperMessageProto; return { ephemeralKey: toExactBuffer(message.ephemeralKey), @@ -301,7 +306,7 @@ const OMEMO_0_3_0: ProtocolProfile = { parts: KeyExchangeParts, framedMessage: Uint8Array ): Promise { - const { PreKeyWhisperMessage } = await loadProtocolMessages(); + const { PreKeyWhisperMessage } = loadProtocolMessages(); const preKeyMsg = PreKeyWhisperMessage.create({ baseKey: new Uint8Array(parts.baseKey), identityKey: new Uint8Array(parts.ourIdentityKey.pubKey), @@ -322,7 +327,7 @@ const OMEMO_0_3_0: ProtocolProfile = { if ((version & 0xf) > 3 || version >> 4 < 3) { throw new Error("Incompatible version number on PreKeyWhisperMessage"); } - const { PreKeyWhisperMessage } = await loadProtocolMessages(); + const { PreKeyWhisperMessage } = loadProtocolMessages(); const proto = PreKeyWhisperMessage.decode( new Uint8Array(bytes.slice(1)) ) as unknown as PreKeyWhisperMessageProto; @@ -372,8 +377,8 @@ const OMEMO_2: ProtocolProfile = { rootChainInfo: "OMEMO Root Chain", messageKeyInfo: "OMEMO Message Key Material", - async encodeInner(parts: RatchetMessageParts): Promise { - const { OMEMOMessage } = await loadOMEMOMessages(); + encodeInner(parts: RatchetMessageParts): Uint8Array { + const { OMEMOMessage } = loadOMEMOMessages(); // dh_pub is the raw 32-byte curve key (RFC 7748), without the 0x05 prefix. const msg = OMEMOMessage.create({ n: parts.counter, @@ -410,8 +415,8 @@ const OMEMO_2: ProtocolProfile = { await verifyMAC(macInput, authKey, mac, 16); }, - async frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Promise { - const { OMEMOAuthenticatedMessage } = await loadOMEMOMessages(); + frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Uint8Array { + const { OMEMOAuthenticatedMessage } = loadOMEMOMessages(); const authMsg = OMEMOAuthenticatedMessage.create({ mac: new Uint8Array(mac), message: encodedInner, @@ -419,8 +424,8 @@ const OMEMO_2: ProtocolProfile = { return OMEMOAuthenticatedMessage.encode(authMsg).finish(); }, - async parseMessage(bytes: ArrayBuffer): Promise { - const { OMEMOMessage, OMEMOAuthenticatedMessage } = await loadOMEMOMessages(); + parseMessage(bytes: ArrayBuffer): ParsedRatchetMessage { + const { OMEMOMessage, OMEMOAuthenticatedMessage } = loadOMEMOMessages(); const authMsg = OMEMOAuthenticatedMessage.decode(new Uint8Array(bytes)) as unknown as { mac: Uint8Array; message: Uint8Array; @@ -441,7 +446,7 @@ const OMEMO_2: ProtocolProfile = { parts: KeyExchangeParts, framedMessage: Uint8Array ): Promise { - const { OMEMOAuthenticatedMessage, OMEMOKeyExchange } = await loadOMEMOMessages(); + const { OMEMOAuthenticatedMessage, OMEMOKeyExchange } = loadOMEMOMessages(); // ik is the 32-byte Ed25519 form of our identity key; ek is the raw 32-byte curve base key. const ik = await internalCrypto.curvePubKeyToEd25519PubKey(parts.ourIdentityKey.pubKey); // The profile interface frames messages as bytes (opaque for 0.3.0), so we @@ -460,7 +465,7 @@ const OMEMO_2: ProtocolProfile = { }, async parseKeyExchange(bytes: ArrayBuffer): Promise { - const { OMEMOAuthenticatedMessage, OMEMOKeyExchange } = await loadOMEMOMessages(); + const { OMEMOAuthenticatedMessage, OMEMOKeyExchange } = loadOMEMOMessages(); const proto = OMEMOKeyExchange.decode( new Uint8Array(bytes) ) as unknown as OMEMOKeyExchangeProto;