p2p-swift / scripts / vectors / generate.mjs
1,882 words
  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
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
#!/usr/bin/env node
// Golden-vector generator for IrdP2PCrypto — a zero-dependency reference
// implementation of PROTOCOL.md on Node's WebCrypto (Node >= 20).
//
//   node scripts/vectors/generate.mjs write  <out-dir>     # regenerate Tests/IrdP2PCryptoTests/Vectors/*.json
//   node scripts/vectors/generate.mjs verify <ref-dir>     # prove this generator against an existing
//                                                          # vector set produced by another implementation
//
// The Swift package must reproduce every value here byte for byte. The
// derivations are deterministic; the sealed envelopes use fixed sealer
// epochs and fixed at-rest IVs so they are reproducible too. Nothing here
// depends on the wall clock or on the machine it runs on.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { webcrypto, createPrivateKey, createPublicKey, sign, createHash } from "node:crypto";

const subtle = webcrypto.subtle;
const te = new TextEncoder();
const utf8 = (s) => te.encode(s);

// ---- bytes -----------------------------------------------------------------
const toB64 = (u8) => Buffer.from(u8).toString("base64");
const fromB64 = (s) => new Uint8Array(Buffer.from(s, "base64"));
const toB64url = (u8) => toB64(u8).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const fromB64url = (s) => fromB64(s.replace(/-/g, "+").replace(/_/g, "/"));
const hex = (u8) => Buffer.from(u8).toString("hex");
const fromHex = (s) => new Uint8Array(Buffer.from(s, "hex"));
const concat = (...parts) => {
  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
  let o = 0;
  for (const p of parts) { out.set(p, o); o += p.length; }
  return out;
};

// ---- HKDF ------------------------------------------------------------------
const importIkm = (secret) => subtle.importKey("raw", secret, "HKDF", false, ["deriveBits", "deriveKey"]);
const deriveBits = async (ikm, salt, info) =>
  new Uint8Array(await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: utf8(info) }, ikm, 256));
const deriveKey = (ikm, salt, info) =>
  subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info: utf8(info) }, ikm,
    { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);

// ---- room crypto (PROTOCOL.md) ---------------------------------------------
async function roomCrypto(secretB64url, appSalt, peerId) {
  const secret = fromB64url(secretB64url);
  if (secret.length < 16) throw new Error("room secret too short");
  const salt = utf8(appSalt);
  const ikm = await importIkm(secret);
  const roomId = toB64url(await deriveBits(ikm, salt, "roomid"));
  return {
    roomId,
    roomTopic: "sueta/2/" + toB64url(await deriveBits(ikm, salt, "topic|room")),
    dbName: toB64url(await deriveBits(ikm, salt, "dbname")),
    mailboxRoomId: toB64url(await deriveBits(ikm, salt, "mailbox|room")),
    peerId,
    key: (info) => deriveKey(ikm, salt, info),
    sigAad: (from, to) => utf8(`v1|${roomId}|${from}|${to}|sig`),
    msgAad: (from) => utf8(`v1|${roomId}|${from}|msg`),
    atRestAad: (suffix) => utf8(`v2|${roomId}|${suffix}`),
  };
}

// Sealer: iv = epoch(4) || counter(8, big-endian). One per key per session.
class Sealer {
  constructor(key, epochHex) { this.key = key; this.epoch = fromHex(epochHex); this.counter = 0n; }
  nextIv() {
    const iv = new Uint8Array(12);
    iv.set(this.epoch, 0);
    new DataView(iv.buffer).setBigUint64(4, this.counter);
    this.counter += 1n;
    return iv;
  }
  async seal(obj, aad) {
    const iv = this.nextIv();
    const ct = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, this.key, utf8(JSON.stringify(obj))));
    return { v: 1, iv: toB64(iv), ct: toB64(ct) };
  }
  async sealBinary(data, aad) {
    const iv = this.nextIv();
    const ct = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, this.key, data));
    return concat(new Uint8Array([0x01]), iv, ct);
  }
}
async function openEnvelope(key, env, aad) {
  try {
    if (env?.v !== 1 || typeof env.iv !== "string" || typeof env.ct !== "string") return null;
    const pt = await subtle.decrypt({ name: "AES-GCM", iv: fromB64(env.iv), additionalData: aad }, key, fromB64(env.ct));
    return JSON.parse(new TextDecoder().decode(pt));
  } catch { return null; }
}
async function openBinary(key, buf, aad, prefix) {
  try {
    if (buf.length < 29 || buf[0] !== prefix) return null;
    return new Uint8Array(await subtle.decrypt({ name: "AES-GCM", iv: buf.subarray(1, 13), additionalData: aad }, key, buf.subarray(13)));
  } catch { return null; }
}
// At rest: room-wide key, random 12-byte IV (fixed here for reproducibility). [0x02 | iv | ct||tag]
async function atRestSeal(key, aad, data, ivHex) {
  const iv = fromHex(ivHex);
  const ct = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, key, data));
  return concat(new Uint8Array([0x02]), iv, ct);
}

// ---- identity (Ed25519 seed) -----------------------------------------------
const PKCS8_PREFIX = fromHex("302e020100300506032b657004220420");
const EMOJI64 = ["🐢","🦊","🐼","🦁","🐸","🐙","🦉","🐝","🐬","🦄","🐞","🦋","🌵","🌲","🍁","🍄",
  "🌻","🌙","⭐","🔥","🌈","⚡","❄️","🌊","🍎","🍋","🍇","🍓","🥑","🌽","🥕","🍩",
  "🍕","🥨","🧀","🍿","☕","🍵","🧊","🎈","🎲","🎯","🎸","🎺","🥁","🚀","🚲","⛵",
  "🚂","🗿","🗝️","🔔","🧲","🧭","⏳","📚","🖍️","📎","✂️","🔍","💎","🛡️","⚙️","🎁"];
function identityFromSeed(seedB64url) {
  const seed = fromB64url(seedB64url);
  if (seed.length !== 32) throw new Error("seed must be 32 bytes");
  const priv = createPrivateKey({ key: Buffer.from(concat(PKCS8_PREFIX, seed)), format: "der", type: "pkcs8" });
  const spki = createPublicKey(priv).export({ format: "der", type: "spki" });
  const pub = new Uint8Array(spki.subarray(spki.length - 32));
  return { priv, pub, pubB64url: toB64url(pub) };
}
function fingerprintOf(pub) {
  const h = createHash("sha256").update(pub).digest();
  const bits = (h[0] << 16) | (h[1] << 8) | h[2];
  const emoji = EMOJI64[(bits >>> 18) & 63] + EMOJI64[(bits >>> 12) & 63] + EMOJI64[(bits >>> 6) & 63] + EMOJI64[bits & 63];
  return { emoji, hex: hex(h) };
}
const bindingBytes = (appSalt, roomId, peerId) => utf8(`sueta-id|v2|${appSalt}|${roomId}|${peerId}`);
const assertSig = (id, appSalt, roomId, peerId) => toB64(sign(null, bindingBytes(appSalt, roomId, peerId), id.priv));
const signRaw = (id, data) => toB64url(sign(null, data, id.priv));

// ---- fixtures ----------------------------------------------------------------
const SECRETS = {
  zero: toB64url(new Uint8Array(32)),
  counting: toB64url(Uint8Array.from({ length: 32 }, (_, i) => i)),
  ones: toB64url(new Uint8Array(32).fill(0xff)),
  short16: toB64url(Uint8Array.from({ length: 16 }, (_, i) => 0x10 + i)),
};
const SALTS = ["ird-swift-tests/v1", "ird-swift-tests/other", ""];
const PEERS = {
  a: "00000000000000000000000000000001",
  b: "0f1e2d3c4b5a69788796a5b4c3d2e1f0",
  c: "ffffffffffffffffffffffffffffffff",
};

async function generate() {
  const bytes = [[], [0], [255], [0, 1, 2], [1, 2, 3, 4], [251, 252, 253, 254, 255], Array.from({ length: 32 }, (_, i) => i)]
    .map((b) => ({ bytes: b, b64: toB64(Uint8Array.from(b)), b64url: toB64url(Uint8Array.from(b)) }));

  const derivations = [];
  for (const [name, secret] of Object.entries(SECRETS)) {
    for (const appSalt of SALTS) {
      const rc = await roomCrypto(secret, appSalt, PEERS.a);
      derivations.push({ secretName: name, secret, appSalt, roomId: rc.roomId, roomTopic: rc.roomTopic, dbName: rc.dbName, mailboxRoomId: rc.mailboxRoomId });
    }
  }

  // sealed envelopes: sender a -> recipient b, fixed epoch
  const rc = await roomCrypto(SECRETS.zero, SALTS[0], PEERS.a);
  const epoch = "6b5c8122";
  const sig = new Sealer(await rc.key(`signal|${PEERS.a}`), epoch);
  const msg = new Sealer(await rc.key(`msg|${PEERS.a}`), epoch);
  const sealerIvs = []; { const s = new Sealer(null, epoch); for (let i = 0; i < 4; i++) sealerIvs.push(hex(s.nextIv())); }
  const helloPt = { kind: "hello", id: { pub: "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik", name: "ana", hue: 200, glyph: "🐢" } };
  const offerPt = { kind: "offer", sdp: "v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\n" };
  const msgPt = { kind: "msg", payload: { t: "chat", text: "salut, lume — ăîșț" } };
  const bin = Uint8Array.from([1, 2, 3, 250, 255]);
  const envelopes = {
    secret: SECRETS.zero, appSalt: SALTS[0], from: PEERS.a, to: PEERS.b, epoch,
    signalHello: { plaintext: helloPt, envelope: await sig.seal(helloPt, rc.sigAad(PEERS.a, PEERS.b)) },
    signalOffer: { plaintext: offerPt, envelope: await sig.seal(offerPt, rc.sigAad(PEERS.a, PEERS.b)) },
    msg: { plaintext: msgPt, envelope: await msg.seal(msgPt, rc.msgAad(PEERS.a)) },
    binary: { plaintext: Array.from(bin), sealedHex: hex(await msg.sealBinary(bin, rc.msgAad(PEERS.a))) },
  };
  const mbxKey = await rc.key("mailbox-blob");
  const mbxPt = utf8(JSON.stringify(msgPt));
  const atRest = {
    mbx: { plaintext: msgPt, ivHex: "2f6152692df2b0e78ccc8c70", sealedHex: hex(await atRestSeal(mbxKey, rc.atRestAad("mbx"), mbxPt, "2f6152692df2b0e78ccc8c70")) },
    entry: { plaintextBytes: [10, 20, 30], ivHex: "fc21be42e50d1a38e72d7a88", sealedHex: hex(await atRestSeal(await rc.key("log-entry"), rc.atRestAad("entry"), Uint8Array.from([10, 20, 30]), "fc21be42e50d1a38e72d7a88")) },
  };
  // tamper table: every case must open to null
  const good = envelopes.signalHello.envelope;
  const flip = (b64) => { const u = fromB64(b64); u[u.length - 1] ^= 1; return toB64(u); };
  const tamper = {
    wrongRecipient: { open: "signal", by: PEERS.c, env: good },
    wrongSenderClaim: { open: "signal", from: PEERS.b, env: good },
    otherSalt: { open: "signal", appSalt: SALTS[1], env: good },
    otherSecret: { open: "signal", secret: SECRETS.counting, env: good },
    tamperedCt: { open: "signal", env: { ...good, ct: flip(good.ct) } },
    tamperedIv: { open: "signal", env: { ...good, iv: flip(good.iv) } },
    wrongVersion: { open: "signal", env: { ...good, v: 2 } },
    malformed: { open: "signal", env: { v: 1, iv: 5, ct: good.ct } },
    msgKeyForSignal: { open: "msg", env: good },
    binaryBadPrefix: { open: "binary", sealedHex: "02" + envelopes.binary.sealedHex.slice(2) },
    binaryTooShort: { open: "binary", sealedHex: envelopes.binary.sealedHex.slice(0, 40) },
    atRestWrongVersion: { open: "atRest", sealedHex: "01" + atRest.mbx.sealedHex.slice(2) },
    atRestTruncated: { open: "atRest", sealedHex: atRest.mbx.sealedHex.slice(0, 50) },
    atRestCrossDomain: { open: "atRest", aadSuffix: "entry", sealedHex: atRest.mbx.sealedHex },
  };
  const roomCryptoVectors = { b64: bytes, tooShort: { secret: toB64url(Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])), error: "room secret too short" },
    derivations, sealerIvs, envelopes, atRest, tamper };

  // identity vectors
  const seeds = { zero: SECRETS.zero, ones: SECRETS.ones, counting: SECRETS.counting, mixed: toB64url(fromHex("668fbdf76ad971c81ac90007 1a1560bce2ca00cac7e67af99348913761434014".replace(/ /g, ""))) };
  const cases = [];
  for (const [name, seed] of Object.entries(seeds)) {
    const id = identityFromSeed(seed);
    const assertions = [];
    for (const [appSalt, roomId, peerId] of [[SALTS[0], rc.roomId, PEERS.a], [SALTS[1], "room-abc", PEERS.b], ["", rc.roomId, PEERS.c]]) {
      assertions.push({ appSalt, roomId, peerId, sigB64: assertSig(id, appSalt, roomId, peerId) });
    }
    cases.push({ name, seed, publicKeyB64url: id.pubB64url, fingerprint: fingerprintOf(id.pub), assertions,
      rawSignatures: [{ dataUtf8: "", sigB64url: signRaw(id, new Uint8Array()) }, { dataUtf8: "hello", sigB64url: signRaw(id, utf8("hello")) }] });
  }
  const z = identityFromSeed(seeds.zero);
  const goodSig = assertSig(z, SALTS[0], rc.roomId, PEERS.a);
  const verifyAssertion = [
    { desc: "good", args: [SALTS[0], rc.roomId, PEERS.a, z.pubB64url, goodSig], ok: true },
    { desc: "good, url-safe signature accepted", args: [SALTS[0], rc.roomId, PEERS.a, z.pubB64url, goodSig.replace(/\+/g, "-").replace(/\//g, "_")], ok: true },
    { desc: "wrong salt", args: ["other-salt", rc.roomId, PEERS.a, z.pubB64url, goodSig], ok: false },
    { desc: "wrong room", args: [SALTS[0], "room-abc", PEERS.a, z.pubB64url, goodSig], ok: false },
    { desc: "wrong peer", args: [SALTS[0], rc.roomId, PEERS.b, z.pubB64url, goodSig], ok: false },
    { desc: "wrong key", args: [SALTS[0], rc.roomId, PEERS.a, identityFromSeed(seeds.ones).pubB64url, goodSig], ok: false },
    { desc: "garbage signature", args: [SALTS[0], rc.roomId, PEERS.a, z.pubB64url, "AAAA"], ok: false },
    { desc: "garbage key", args: [SALTS[0], rc.roomId, PEERS.a, "not-a-key", goodSig], ok: false },
  ];
  return { "room-crypto": roomCryptoVectors, identity: { seeds, cases, verifyAssertion } };
}

// ---- verify against another implementation's vectors -------------------------
async function verify(refDir) {
  let ok = 0, bad = 0;
  const check = (label, a, b) => { if (JSON.stringify(a) === JSON.stringify(b)) ok++; else { bad++; console.log(`MISMATCH ${label}\n  ours: ${JSON.stringify(a)}\n  ref:  ${JSON.stringify(b)}`); } };
  const c = JSON.parse(readFileSync(join(refDir, "crypto.json"), "utf8"));
  for (const d of c.derivations) {
    const rc = await roomCrypto(d.secret, d.appSalt, d.peerId);
    check(`derivation ${d.appSalt}`, [rc.roomId, rc.roomTopic, rc.dbName, rc.mailboxRoomId], [d.roomId, d.roomTopic, d.dbName, d.mailboxRoomId]);
  }
  { const s = new Sealer(null, c.sealerIvs[0].slice(0, 8)); check("sealerIvs", [0, 1, 2, 3].map(() => hex(s.nextIv())), c.sealerIvs); }
  const x = c.crossImpl;
  const rc = await roomCrypto(x.secret, x.appSalt, x.senderPeerId);
  const reseal = async (info, aad, pt, env) => { const s = new Sealer(await rc.key(info), hex(fromB64(env.iv).subarray(0, 4))); s.counter = new DataView(fromB64(env.iv).buffer).getBigUint64(4); return s.seal(pt, aad); };
  check("crossImpl.msg", await reseal(`msg|${x.senderPeerId}`, rc.msgAad(x.senderPeerId), x.msg.plaintext, x.msg.envelope), x.msg.envelope);
  check("crossImpl.signal", await reseal(`signal|${x.senderPeerId}`, rc.sigAad(x.senderPeerId, x.signal.to), x.signal.plaintext, x.signal.envelope), x.signal.envelope);
  { const b = fromHex(x.binary.sealed); const s = new Sealer(await rc.key(`msg|${x.senderPeerId}`), hex(b.subarray(1, 5))); s.counter = new DataView(b.buffer).getBigUint64(5);
    check("crossImpl.binary", hex(await s.sealBinary(Uint8Array.from(x.binary.plaintext), rc.msgAad(x.senderPeerId))), x.binary.sealed); }
  { const b = fromHex(x.atRestEntry.sealed); check("crossImpl.atRestEntry", hex(await atRestSeal(await rc.key("log-entry"), rc.atRestAad("entry"), Uint8Array.from(x.atRestEntry.plaintext), hex(b.subarray(1, 13)))), x.atRestEntry.sealed); }
  // open every committed envelope of the reference file that names its parties
  const opened = await openEnvelope(await rc.key(`signal|${x.senderPeerId}`), x.signal.envelope, rc.sigAad(x.senderPeerId, x.signal.to));
  check("open crossImpl.signal", opened, x.signal.plaintext);
  check("open wrong recipient -> null", await openEnvelope(await rc.key(`signal|${x.senderPeerId}`), x.signal.envelope, rc.sigAad(x.senderPeerId, "someone-else")), null);
  const idv = JSON.parse(readFileSync(join(refDir, "identity.json"), "utf8"));
  for (const cs of idv.cases) {
    const id = identityFromSeed(cs.seed);
    check(`identity ${cs.name} pub`, id.pubB64url, cs.publicKeyB64);
    check(`identity ${cs.name} fingerprint`, fingerprintOf(id.pub), cs.fingerprint);
    for (const a of cs.assertions) check(`identity ${cs.name} assertion ${a.appSalt}`, assertSig(id, a.appSalt, a.roomId, a.peerId), a.sigB64);
    for (const r of cs.rawSigs ?? []) check(`identity ${cs.name} raw`, signRaw(id, utf8(r.data ?? r.dataUtf8 ?? "")), r.sigB64url);
  }
  console.log(`verify: ${ok} ok, ${bad} mismatches`);
  if (bad) process.exit(1);
}

const [mode, dir] = process.argv.slice(2);
if (mode === "write") {
  const v = await generate();
  mkdirSync(dir, { recursive: true });
  for (const [name, data] of Object.entries(v)) { writeFileSync(join(dir, `${name}.json`), JSON.stringify(data, null, 2) + "\n"); console.log(`wrote ${name}.json`); }
} else if (mode === "verify") {
  await verify(dir);
} else {
  console.error("usage: generate.mjs write <out-dir> | verify <ref-dir>"); process.exit(2);
}

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