/**
* Pairwise 1:1 rooms (docs/PROTOCOL.md §2):
*
* pairSecret(A,B) = HKDF-SHA256(
* ikm = X25519(xA, xB), — raw DH shared secret
* salt = UTF8(APP_SALT),
* info = "cp-pair|v1|<pubLo>|<pubHi>") — Ed25519 IDENTITY pubs,
* lexicographically sorted b64url
*
* Deterministic and symmetric: both sides derive the same 32-byte room
* secret with no exchange — my X25519 private key comes from my seed (lib
* wrapbox deriveXKeyPair, info "id-x25519|v1"), the peer's X25519 pub from
* their VERIFIED contact record (cert checked at addContact time, §3).
* Binding the identity pubs into the info ties the room to WHO the parties
* are, not just their encryption keys. Consequence (by design): accepting a
* TOFU key change (new xPub) derives a NEW room — history under the old key
* stays under the old room.
*
* Conversation registry: 1:1 conversations are not stored anywhere — every
* ACTIVE contact (whose xPub is verified by construction) IS an available
* conversation, materialized lazily on first open/first message. The
* conversation id is the peer's Ed25519 pub.
*/
import { x25519 } from "@noble/curves/ed25519";
import { APP_SALT } from "../config";
import { fromB64url, toB64url, type Bytes } from "../lib/crypto";
import { Identity } from "../lib/identity";
import { deriveXKeyPair } from "../lib/wrapbox";
import { activeContacts } from "./contacts";
import type { ContactOp, HomeState } from "./home-log";
const utf8 = (s: string): Bytes => new TextEncoder().encode(s) as Bytes;
/** §2 pairSecret — see the module docstring. Throws on malformed inputs. */
export async function pairSecret(
my: { seed: string; pub: string },
peer: { pub: string; xPub: string },
): Promise<string> {
const myX = await deriveXKeyPair(my.seed, APP_SALT);
const peerXPub = fromB64url(peer.xPub);
if (peerXPub.length !== 32) throw new Error("bad peer xPub");
const ss = x25519.getSharedSecret(myX.priv, peerXPub) as Bytes;
// lexicographic (code-unit) sort of the two b64url identity pubs — the
// same total order on both sides with no locale dependence
const [lo, hi] = my.pub < peer.pub ? [my.pub, peer.pub] : [peer.pub, my.pub];
const ikm = await crypto.subtle.importKey("raw", ss as BufferSource, "HKDF", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: utf8(APP_SALT) as BufferSource,
info: utf8(`cp-pair|v1|${lo}|${hi}`) as BufferSource,
},
ikm,
256,
);
return toB64url(new Uint8Array(bits));
}
/** Active contacts = available 1:1 conversations (id = peer pub). */
export function pairConversations(state: HomeState): ContactOp[] {
return activeContacts(state);
}
/**
* Dev-only app-plane self-test (wired next to lib's runSelfTest in main.ts):
* two throwaway identities must derive the SAME pair secret from either
* side — the symmetry every 1:1 room depends on.
*/
export async function pairSecretSelfTest(): Promise<void> {
const mk = async (): Promise<{ seed: string; pub: string; xPub: string }> => {
const seed = Identity.newSeed();
const id = await Identity.fromSeed(seed);
const x = await deriveXKeyPair(seed, APP_SALT);
return { seed, pub: id.publicKeyB64, xPub: x.pubB64 };
};
const [a, b] = await Promise.all([mk(), mk()]);
const ab = await pairSecret({ seed: a.seed, pub: a.pub }, { pub: b.pub, xPub: b.xPub });
const ba = await pairSecret({ seed: b.seed, pub: b.pub }, { pub: a.pub, xPub: a.xPub });
if (ab !== ba) throw new Error("pairSecret asymmetric");
if (!/^[A-Za-z0-9_-]{43}$/.test(ab)) throw new Error("pairSecret not a 32-byte b64url secret");
// and a third party must land elsewhere (info binding sanity)
const c = await mk();
const ac = await pairSecret({ seed: a.seed, pub: a.pub }, { pub: c.pub, xPub: c.xPub });
if (ac === ab) throw new Error("pairSecret collision across pairs");
}