cautpartener / client / src / core / pairwise.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
 * 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");
}

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git