| 1 | // Cross-implementation gate for ECIES thread-key wrapping. The native client |
| 2 | // (ios/LChatCore/Sources/LChatCore/Crypto.swift) reimplements unwrapThreadKey in |
| 3 | // CryptoKit; if the web's crypto.js ever changes the wire format (curve, HKDF |
| 4 | // salt/info, key encodings), the two silently drift and threads stop opening on |
| 5 | // one platform. This test pins the format two ways: |
| 6 | // |
| 7 | // 1. a fresh wrap → unwrap round-trips in JS (crypto.js self-consistency), and |
| 8 | // 2. the committed fixture (LChatCore's Tests/…/Fixtures/thread-key-vector.json) |
| 9 | // still unwraps to its expected key — the SAME fixture the Swift unit test |
| 10 | // (`swift test` in ios/LChatCore) consumes. |
| 11 | // |
| 12 | // If (2) fails, crypto.js changed incompatibly: regenerate the fixture (see below) |
| 13 | // and update Crypto.swift to match in the same change. |
| 14 | import { readFileSync, writeFileSync } from "node:fs"; |
| 15 | import { fileURLToPath } from "node:url"; |
| 16 | import { dirname, join } from "node:path"; |
| 17 | import { generateUserKeypair, wrapThreadKey, unwrapThreadKey, bytesToB64url } from "../web/static/crypto.js"; |
| 18 | |
| 19 | const here = dirname(fileURLToPath(import.meta.url)); |
| 20 | const fixturePath = join(here, "..", "ios", "LChatCore", "Tests", "LChatCoreTests", "Fixtures", "thread-key-vector.json"); |
| 21 | |
| 22 | // A known 32-byte thread key (bytes 0..31), so the expected value is stable. |
| 23 | function knownThreadKeyB64() { |
| 24 | const raw = new Uint8Array(32); |
| 25 | for (let i = 0; i < 32; i++) raw[i] = i; |
| 26 | return bytesToB64url(raw); |
| 27 | } |
| 28 | |
| 29 | function assert(cond, msg) { |
| 30 | if (!cond) { |
| 31 | console.error("FAIL:", msg); |
| 32 | process.exit(1); |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // `node test/threadkey.test.mjs --emit` regenerates the committed fixture (the wrap |
| 37 | // uses a random ephemeral key, so the fixture must be frozen, not recomputed). |
| 38 | async function emit() { |
| 39 | const expectedThreadKeyB64 = knownThreadKeyB64(); |
| 40 | const kp = await generateUserKeypair(); |
| 41 | const wrappedB64 = await wrapThreadKey(expectedThreadKeyB64, kp.publicKey); |
| 42 | assert((await unwrapThreadKey(wrappedB64, kp.privateKey)) === expectedThreadKeyB64, "round-trip"); |
| 43 | const fixture = { |
| 44 | _comment: |
| 45 | "ECIES thread-key test vector. Generated by test/threadkey.test.mjs from " + |
| 46 | "web/static/crypto.js. LChatCore's ECIESVectorTests unwraps wrappedB64 with " + |
| 47 | "privPkcs8B64 and must get expectedThreadKeyB64 — a cross-implementation gate " + |
| 48 | "so the native ECIES never drifts from the web's. base64url throughout. " + |
| 49 | "Regenerate with: node test/threadkey.test.mjs --emit", |
| 50 | privPkcs8B64: kp.privateKey, |
| 51 | pubRawB64: kp.publicKey, |
| 52 | wrappedB64, |
| 53 | expectedThreadKeyB64, |
| 54 | }; |
| 55 | writeFileSync(fixturePath, JSON.stringify(fixture, null, 2) + "\n"); |
| 56 | console.log("wrote", fixturePath); |
| 57 | } |
| 58 | |
| 59 | async function test() { |
| 60 | // (1) crypto.js self-consistency. |
| 61 | const k = knownThreadKeyB64(); |
| 62 | const kp = await generateUserKeypair(); |
| 63 | const wrapped = await wrapThreadKey(k, kp.publicKey); |
| 64 | assert((await unwrapThreadKey(wrapped, kp.privateKey)) === k, "fresh wrap/unwrap round-trips"); |
| 65 | |
| 66 | // (2) the committed fixture (shared with the Swift test) still unwraps. |
| 67 | const fx = JSON.parse(readFileSync(fixturePath, "utf8")); |
| 68 | const got = await unwrapThreadKey(fx.wrappedB64, fx.privPkcs8B64); |
| 69 | assert(got === fx.expectedThreadKeyB64, "committed fixture unwraps to expected key"); |
| 70 | |
| 71 | console.log("threadkey.test.mjs OK"); |
| 72 | } |
| 73 | |
| 74 | if (process.argv.includes("--emit")) await emit(); |
| 75 | else await test(); |
| 76 | |