Skip to content

Commit 4e042cc

Browse files
committed
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.
1 parent 192800b commit 4e042cc

6 files changed

Lines changed: 101 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# CHANGES
22

3+
## 2.0.2 (Unreleased)
4+
5+
### Fix: omemo:2 session establishment from a wire-form PreKey bundle
6+
7+
- `SessionBuilder.processPreKey` now normalises the bundle's signed-pre-key and
8+
pre-key public keys to the internal 33-byte `0x05`-prefixed curve form, via a new
9+
`normalizeRemotePreKey` protocol-profile hook. omemo:2 transfers these keys in
10+
their raw 32-byte curve form, which the 2.0.1 DH hardening rejected with
11+
"Invalid public key", breaking the establishment of every new omemo:2
12+
session.
13+
314
## 2.0.1 (2026-06-19)
415

516
### Security / compatibility: eval-free protobuf (strict CSP support)

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "libomemo.js",
33
"repository": "github:conversejs/libomemo.js",
4-
"version": "2.0.1",
4+
"version": "2.0.2",
55
"license": "GPL-3.0",
66
"type": "module",
77
"main": "./dist/libomemo.umd.js",

src/session/builder.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class SessionBuilder {
3737
// Normalise the remote identity key: for omemo:2 the wire form is
3838
// Ed25519 and is converted to its Curve25519 equivalent for DH.
3939
const remoteId = await this.#profile.normalizeRemoteIdentityKey(device.identityKey);
40+
4041
// Trust is keyed on the form the consumer published in the bundle
4142
// (Ed25519 for omemo:2, Curve25519 for 0.3.0).
4243
const trustKey = remoteId.ed ?? remoteId.curve;
@@ -51,21 +52,30 @@ export class SessionBuilder {
5152
throw new Error("Identity key changed");
5253
}
5354

55+
// Normalise the prekeys, which may be in the raw 32-byte curve
56+
// form, to the internal 33-byte form, since the DH path
57+
// no longer accepts unprefixed keys.
58+
const signedPreKeyPub = this.#profile.normalizeRemotePreKey(
59+
device.signedPreKey.publicKey
60+
);
61+
const devicePreKey = device.preKey?.publicKey
62+
? this.#profile.normalizeRemotePreKey(device.preKey.publicKey)
63+
: undefined;
64+
5465
await internalCrypto.Ed25519Verify(
5566
remoteId.curve,
56-
this.#profile.signedPreKeySignatureData(device.signedPreKey.publicKey),
67+
this.#profile.signedPreKeySignatureData(signedPreKeyPub),
5768
device.signedPreKey.signature
5869
);
5970

6071
const baseKey = await internalCrypto.createKeyPair();
61-
const devicePreKey = device.preKey ? device.preKey.publicKey : undefined;
6272
const session = await this.#initSession(
6373
true,
6474
baseKey,
6575
undefined,
6676
remoteId.curve,
6777
devicePreKey,
68-
device.signedPreKey.publicKey,
78+
signedPreKeyPub,
6979
this.#registrationId(device.registrationId),
7080
remoteId.ed
7181
);

src/session/protocol-profile.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@ export interface ProtocolProfile {
161161
* libomemo-c.
162162
*/
163163
signedPreKeySignatureData(publicKey: ArrayBuffer): ArrayBuffer;
164+
165+
/**
166+
* Normalise a (signed-)pre-key public received on the wire (from a PreKey
167+
* bundle) into the library's internal 33-byte 0x05-prefixed curve form. 0.3.0
168+
* publishes that form already; omemo:2 transfers the raw 32-byte curve form
169+
* and restores the prefix here. This mirrors the wire→internal normalisation
170+
* the profile already applies to ratchet/base keys on the message paths.
171+
*/
172+
normalizeRemotePreKey(wireKey: ArrayBuffer): ArrayBuffer;
164173
}
165174

166175
/**
@@ -346,6 +355,12 @@ const OMEMO_0_3_0: ProtocolProfile = {
346355
signedPreKeySignatureData(publicKey: ArrayBuffer): ArrayBuffer {
347356
return publicKey;
348357
},
358+
359+
normalizeRemotePreKey(wireKey: ArrayBuffer): ArrayBuffer {
360+
// 0.3.0 publishes the 33-byte 0x05-prefixed form; a raw 32-byte key is
361+
// malformed and is left to fail closed on the DH path.
362+
return wireKey;
363+
},
349364
};
350365

351366
/** Profile for OMEMO 2 (urn:xmpp:omemo:2). */
@@ -500,6 +515,12 @@ const OMEMO_2: ProtocolProfile = {
500515
// omemo:2 signs the raw 32-byte Curve25519 (Montgomery) form.
501516
return stripKeyType(publicKey);
502517
},
518+
519+
normalizeRemotePreKey(wireKey: ArrayBuffer): ArrayBuffer {
520+
// omemo:2 transfers the raw 32-byte curve form; restore the 0x05 prefix
521+
// to the library's internal form (no-op if already prefixed).
522+
return addKeyType(wireKey);
523+
},
503524
};
504525

505526
/** Render an ArrayBuffer/Uint8Array as a binary string (one char per byte). */

test/omemo2.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,59 @@ describe("OMEMO 2 trust-key enforcement", function () {
199199
});
200200
});
201201

202+
describe("OMEMO 2 wire-form pre-key bundle", function () {
203+
const ALICE = new OMEMOAddress("alice@example.org", 1);
204+
const BOB = new OMEMOAddress("bob@example.org", 1);
205+
206+
/** Drop the 0x05 type prefix, yielding the raw 32-byte curve key. */
207+
function stripPrefix(key: ArrayBuffer): ArrayBuffer {
208+
const bytes = new Uint8Array(key);
209+
assert.strictEqual(bytes.byteLength, 33);
210+
assert.strictEqual(bytes[0], 5);
211+
return key.slice(1);
212+
}
213+
214+
// omemo:2 (XEP-0384) publishes the signed-pre-key and pre-keys in their raw
215+
// 32-byte curve form, without the 0x05 type prefix. processPreKey must accept
216+
// that wire form (regression: a stricter curve check rejected the unprefixed
217+
// keys with "Invalid public key", breaking all new omemo:2 sessions).
218+
it("builds a session from raw 32-byte (unprefixed) signed-pre-key and pre-key", async function () {
219+
const aliceStore = new InMemoryStore();
220+
const bobStore = new InMemoryStore();
221+
await Promise.all([generateIdentity(aliceStore), generateIdentity(bobStore)]);
222+
223+
const bundle = await makeBundle(bobStore, "urn:xmpp:omemo:2", 1337, 1);
224+
const wireBundle: PreKeyBundle = {
225+
...bundle,
226+
preKey: {
227+
keyId: bundle.preKey!.keyId,
228+
publicKey: stripPrefix(bundle.preKey!.publicKey!),
229+
},
230+
signedPreKey: {
231+
...bundle.signedPreKey,
232+
publicKey: stripPrefix(bundle.signedPreKey.publicKey),
233+
},
234+
};
235+
236+
await new SessionBuilder(aliceStore, BOB, "urn:xmpp:omemo:2").processPreKey(wireBundle);
237+
238+
// The session is established and a full key-exchange round-trip works.
239+
const aliceCipher = new SessionCipher(aliceStore, BOB, "urn:xmpp:omemo:2");
240+
const bobCipher = new SessionCipher(bobStore, ALICE, "urn:xmpp:omemo:2");
241+
const msg = util.toArrayBuffer("wire-form kex") as ArrayBuffer;
242+
const ct = await aliceCipher.encrypt(msg);
243+
assert.strictEqual(ct.type, 3);
244+
const { plaintext } = await bobCipher.decryptPreKeyWhisperMessage(ct.body, "binary");
245+
assertEqualArrayBuffers(plaintext, msg);
246+
247+
// The stored remote ratchet seed is the normalised 33-byte 0x05 form.
248+
const record = SessionRecord.deserialize(aliceStore.loadSession(BOB.toString())!);
249+
const lastRemote = record.getOpenSession()!.currentRatchet.lastRemoteEphemeralKey;
250+
assert.strictEqual(lastRemote.byteLength, 33);
251+
assert.strictEqual(new Uint8Array(lastRemote)[0], 5);
252+
});
253+
});
254+
202255
describe("0.3.0 registrationId enforcement", function () {
203256
it("refuses to send a key-exchange message without a local registrationId", async function () {
204257
const BOB = new OMEMOAddress("bob@example.org", 1);

0 commit comments

Comments
 (0)