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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/protobufs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface OMEMOMessages {
}

/** Load codecs for the Signal protocol messages. */
export async function loadProtocolMessages(): Promise<ProtocolMessages> {
export function loadProtocolMessages(): ProtocolMessages {
return {
WhisperMessage: textsecure.WhisperMessage,
PreKeyWhisperMessage: textsecure.PreKeyWhisperMessage,
Expand Down
16 changes: 13 additions & 3 deletions src/session/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
);
Expand Down
62 changes: 44 additions & 18 deletions src/session/protocol-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,27 +109,32 @@ export interface ProtocolProfile {
messageKeyInfo: string;

/** Serialise the inner ratchet message (WhisperMessage / OMEMOMessage). */
encodeInner(parts: RatchetMessageParts): Promise<Uint8Array>;
encodeInner(parts: RatchetMessageParts): Uint8Array;

/** Compute the (already-truncated) MAC over the encoded inner message. */
computeMac(
authKey: ArrayBuffer,
encodedInner: Uint8Array,
ctx: MacContext
): Promise<ArrayBuffer>;

/** Verify the MAC over the encoded inner message; throws on mismatch. */
verifyMac(
authKey: ArrayBuffer,
encodedInner: Uint8Array,
ctx: MacContext,
mac: ArrayBuffer
): Promise<void>;

/** Frame the final ratchet-message body (version byte + MAC, or OMEMOAuthenticatedMessage). */
frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Promise<Uint8Array>;
frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Uint8Array;

/** Parse an incoming ratchet-message body. */
parseMessage(bytes: ArrayBuffer): Promise<ParsedRatchetMessage>;
parseMessage(bytes: ArrayBuffer): ParsedRatchetMessage;

/** Wrap an already-framed ratchet message in a key-exchange message body. */
encodeKeyExchange(parts: KeyExchangeParts, framedMessage: Uint8Array): Promise<Uint8Array>;

/** Parse an incoming key-exchange message body. */
parseKeyExchange(bytes: ArrayBuffer): Promise<ParsedKeyExchange>;

Expand Down Expand Up @@ -161,6 +166,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;
}

/**
Expand Down Expand Up @@ -231,8 +245,8 @@ const OMEMO_0_3_0: ProtocolProfile = {
rootChainInfo: "WhisperRatchet",
messageKeyInfo: "WhisperMessageKeys",

async encodeInner(parts: RatchetMessageParts): Promise<Uint8Array> {
const { WhisperMessage } = await loadProtocolMessages();
encodeInner(parts: RatchetMessageParts): Uint8Array {
const { WhisperMessage } = loadProtocolMessages();
const msg = WhisperMessage.create({
ephemeralKey: new Uint8Array(parts.ephemeralKey),
counter: parts.counter,
Expand Down Expand Up @@ -260,23 +274,23 @@ const OMEMO_0_3_0: ProtocolProfile = {
await verifyMAC(buildV3MacInput(encodedInner, ctx), authKey, mac, 8);
},

async frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Promise<Uint8Array> {
frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Uint8Array {
const result = new Uint8Array(encodedInner.byteLength + 1 + mac.byteLength);
result[0] = V3_VERSION_BYTE;
result.set(encodedInner, 1);
result.set(new Uint8Array(mac), encodedInner.byteLength + 1);
return result;
},

async parseMessage(bytes: ArrayBuffer): Promise<ParsedRatchetMessage> {
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");
}
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),
Expand All @@ -292,7 +306,7 @@ const OMEMO_0_3_0: ProtocolProfile = {
parts: KeyExchangeParts,
framedMessage: Uint8Array
): Promise<Uint8Array> {
const { PreKeyWhisperMessage } = await loadProtocolMessages();
const { PreKeyWhisperMessage } = loadProtocolMessages();
const preKeyMsg = PreKeyWhisperMessage.create({
baseKey: new Uint8Array(parts.baseKey),
identityKey: new Uint8Array(parts.ourIdentityKey.pubKey),
Expand All @@ -313,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;
Expand Down Expand Up @@ -346,6 +360,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). */
Expand All @@ -357,8 +377,8 @@ const OMEMO_2: ProtocolProfile = {
rootChainInfo: "OMEMO Root Chain",
messageKeyInfo: "OMEMO Message Key Material",

async encodeInner(parts: RatchetMessageParts): Promise<Uint8Array> {
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,
Expand Down Expand Up @@ -395,17 +415,17 @@ const OMEMO_2: ProtocolProfile = {
await verifyMAC(macInput, authKey, mac, 16);
},

async frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Promise<Uint8Array> {
const { OMEMOAuthenticatedMessage } = await loadOMEMOMessages();
frameMessage(encodedInner: Uint8Array, mac: ArrayBuffer): Uint8Array {
const { OMEMOAuthenticatedMessage } = loadOMEMOMessages();
const authMsg = OMEMOAuthenticatedMessage.create({
mac: new Uint8Array(mac),
message: encodedInner,
});
return OMEMOAuthenticatedMessage.encode(authMsg).finish();
},

async parseMessage(bytes: ArrayBuffer): Promise<ParsedRatchetMessage> {
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;
Expand All @@ -426,7 +446,7 @@ const OMEMO_2: ProtocolProfile = {
parts: KeyExchangeParts,
framedMessage: Uint8Array
): Promise<Uint8Array> {
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
Expand All @@ -445,7 +465,7 @@ const OMEMO_2: ProtocolProfile = {
},

async parseKeyExchange(bytes: ArrayBuffer): Promise<ParsedKeyExchange> {
const { OMEMOAuthenticatedMessage, OMEMOKeyExchange } = await loadOMEMOMessages();
const { OMEMOAuthenticatedMessage, OMEMOKeyExchange } = loadOMEMOMessages();
const proto = OMEMOKeyExchange.decode(
new Uint8Array(bytes)
) as unknown as OMEMOKeyExchangeProto;
Expand Down Expand Up @@ -500,6 +520,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). */
Expand Down
53 changes: 53 additions & 0 deletions test/omemo2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading