From 42c792daa152098d50817c0293b38ccc19d48095 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 19 Apr 2026 14:20:03 -0400 Subject: [PATCH] feat(client): NaCl-box crypto primitives + pairing payload codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds pure crypto layer usable by both daemon and client for the ARC v3 E2E-encrypted relay flow (Phase 10 of the v3 daemon plan). - `packages/client/src/crypto.ts`: `generateKeypair`, `box`, `unbox` (returns null on auth failure), and URL-safe base64 helpers. Wraps `tweetnacl` for zero-native-build crypto_box (Curve25519 + XSalsa20 + Poly1305). - `packages/client/src/pairing.ts`: Zod schema + encode/decode for the compact QR pairing payload (v, relayUrl, daemonPub, pairCode, label). JSON is canonicalized before base64url so identical payloads always encode to the same string. - `tests/crypto-primitives.test.ts`: 15 vitest cases covering keypair shape, box/unbox roundtrips, tamper/wrong-key/wrong-nonce rejection, base64url safety, and pairing payload encode/decode including malformed inputs and schema violations. Primitives only — relay server and daemon pairing flow are follow-up batches. --- packages/client/package.json | 1 + packages/client/src/crypto.ts | 82 ++++++++++++++++ packages/client/src/index.ts | 2 + packages/client/src/pairing.ts | 81 ++++++++++++++++ pnpm-lock.yaml | 8 ++ tests/crypto-primitives.test.ts | 166 ++++++++++++++++++++++++++++++++ 6 files changed, 340 insertions(+) create mode 100644 packages/client/src/crypto.ts create mode 100644 packages/client/src/pairing.ts create mode 100644 tests/crypto-primitives.test.ts diff --git a/packages/client/package.json b/packages/client/package.json index 6c70f22..088b4a1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "tweetnacl": "^1.0.3", "ws": "^8.18.0", "zod": "^3.25.0" }, diff --git a/packages/client/src/crypto.ts b/packages/client/src/crypto.ts new file mode 100644 index 0000000..be8186d --- /dev/null +++ b/packages/client/src/crypto.ts @@ -0,0 +1,82 @@ +/** + * NaCl-box crypto primitives used by the ARC v3 pairing / relay flow. + * + * Wraps `tweetnacl` so that both the daemon (seal/unseal) and the client + * (seal/unseal) can share a single implementation. Uses Curve25519 for + * key agreement, XSalsa20 for encryption, and Poly1305 for authentication + * (i.e. `crypto_box` / `crypto_box_open`). + * + * This module is **primitives only** — it is deliberately decoupled from + * the daemon WS handshake and from the relay server so it can be audited + * and reused in follow-up batches. + */ + +import nacl from "tweetnacl"; + +// ─── Keypair ───────────────────────────────────────────────────────── + +export interface KeyPair { + readonly publicKey: Uint8Array; + readonly secretKey: Uint8Array; +} + +/** Generate a fresh Curve25519 keypair. */ +export function generateKeypair(): KeyPair { + const kp = nacl.box.keyPair(); + return { publicKey: kp.publicKey, secretKey: kp.secretKey }; +} + +// ─── Sealing ───────────────────────────────────────────────────────── + +export interface SealedMessage { + readonly nonce: Uint8Array; + readonly ciphertext: Uint8Array; +} + +/** + * Encrypt + authenticate `plaintext` for `theirPublic` using `mySecret`. + * Returns the nonce alongside the ciphertext; both must be transmitted. + */ +export function box( + plaintext: Uint8Array, + theirPublic: Uint8Array, + mySecret: Uint8Array, +): SealedMessage { + const nonce = nacl.randomBytes(nacl.box.nonceLength); + const ciphertext = nacl.box(plaintext, nonce, theirPublic, mySecret); + return { nonce, ciphertext }; +} + +/** + * Decrypt + verify `ciphertext` sealed with `box`. + * Returns `null` on authentication failure (tampered or wrong keys). + */ +export function unbox( + ciphertext: Uint8Array, + nonce: Uint8Array, + theirPublic: Uint8Array, + mySecret: Uint8Array, +): Uint8Array | null { + const opened = nacl.box.open(ciphertext, nonce, theirPublic, mySecret); + return opened ?? null; +} + +// ─── URL-safe base64 ──────────────────────────────────────────────── +// +// RFC 4648 §5 (base64url, no padding). Used for keys, nonces, and the +// compact pairing payload so it can ride inside QR codes / URLs +// without percent-encoding. + +export function encodeB64(u8: Uint8Array): string { + const bin = Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength).toString( + "base64", + ); + return bin.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function decodeB64(s: string): Uint8Array { + const padded = s.replace(/-/g, "+").replace(/_/g, "/"); + const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); + const buf = Buffer.from(padded + pad, "base64"); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a7e6421..0caa170 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,5 @@ export * from "./protocol.js"; export * from "./frame.js"; export * from "./client.js"; +export * from "./crypto.js"; +export * from "./pairing.js"; diff --git a/packages/client/src/pairing.ts b/packages/client/src/pairing.ts new file mode 100644 index 0000000..46821d6 --- /dev/null +++ b/packages/client/src/pairing.ts @@ -0,0 +1,81 @@ +/** + * Pairing payload schema used by ARC v3 QR-based device linking. + * + * The daemon renders a QR (or displays a URL) containing the encoded + * payload. The client scans/decodes it, derives the relay URL + daemon + * public key, and initiates an E2E-encrypted session via the relay. + * + * This module is **schema + codec only** — it does not generate QR + * images or talk to the relay. Those are built in follow-up batches. + */ + +import { z } from "zod"; +import { decodeB64, encodeB64 } from "./crypto.js"; + +// ─── Schema ─────────────────────────────────────────────────────────── + +export const PairingPayload = z.object({ + /** Schema version — bump when the shape changes incompatibly. */ + v: z.literal(1), + /** Relay WebSocket URL the client should dial (e.g. wss://relay.example.com). */ + relayUrl: z.string(), + /** Daemon's long-lived NaCl-box public key, base64url. */ + daemonPub: z.string(), + /** Short random code scoped to this pairing attempt (5-minute TTL on the relay). */ + pairCode: z.string(), + /** Optional human-readable label for the daemon (hostname, nickname, …). */ + label: z.string().optional(), +}); + +export type PairingPayload = z.infer; + +// ─── Codec ──────────────────────────────────────────────────────────── +// +// The on-wire form is a single URL-safe base64 string wrapping the +// compact JSON. We keep the JSON canonical (sorted keys) so the same +// payload always encodes to the same string, which makes pairing codes +// diff-able and cache-friendly. + +function canonicalize(p: PairingPayload): string { + const ordered: Record = { + v: p.v, + relayUrl: p.relayUrl, + daemonPub: p.daemonPub, + pairCode: p.pairCode, + }; + if (p.label !== undefined) ordered.label = p.label; + return JSON.stringify(ordered); +} + +/** Encode a validated pairing payload as a single URL-safe string. */ +export function encodePairingPayload(p: PairingPayload): string { + const parsed = PairingPayload.parse(p); + const json = canonicalize(parsed); + return encodeB64(new TextEncoder().encode(json)); +} + +/** + * Decode + validate a pairing payload string. + * Throws on malformed base64, malformed JSON, or schema violations. + */ +export function decodePairingPayload(s: string): PairingPayload { + let json: string; + try { + json = new TextDecoder().decode(decodeB64(s)); + } catch (err) { + throw new Error( + `pairing payload: invalid base64url (${(err as Error).message})`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (err) { + throw new Error( + `pairing payload: invalid JSON (${(err as Error).message})`, + ); + } + + return PairingPayload.parse(parsed); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bc0959..bc36439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: packages/client: dependencies: + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 ws: specifier: ^8.18.0 version: 8.20.0 @@ -2982,6 +2985,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-fest@5.5.0: resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} @@ -5912,6 +5918,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tweetnacl@1.0.3: {} + type-fest@5.5.0: dependencies: tagged-tag: 1.0.0 diff --git a/tests/crypto-primitives.test.ts b/tests/crypto-primitives.test.ts new file mode 100644 index 0000000..3214633 --- /dev/null +++ b/tests/crypto-primitives.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { + generateKeypair, + box, + unbox, + encodeB64, + decodeB64, + encodePairingPayload, + decodePairingPayload, + PairingPayload, +} from "../packages/client/src/index.js"; + +// ─── Keypair ───────────────────────────────────────────────────────── + +describe("generateKeypair", () => { + it("produces 32-byte public and secret keys", () => { + const kp = generateKeypair(); + expect(kp.publicKey).toBeInstanceOf(Uint8Array); + expect(kp.secretKey).toBeInstanceOf(Uint8Array); + expect(kp.publicKey.length).toBe(32); + expect(kp.secretKey.length).toBe(32); + }); + + it("returns a distinct keypair on each call", () => { + const a = generateKeypair(); + const b = generateKeypair(); + expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false); + expect(Buffer.from(a.secretKey).equals(Buffer.from(b.secretKey))).toBe(false); + }); +}); + +// ─── box / unbox ───────────────────────────────────────────────────── + +describe("box / unbox", () => { + it("roundtrips a plaintext between two parties", () => { + const alice = generateKeypair(); + const bob = generateKeypair(); + const plaintext = new TextEncoder().encode("hello from alice"); + + const { nonce, ciphertext } = box(plaintext, bob.publicKey, alice.secretKey); + const opened = unbox(ciphertext, nonce, alice.publicKey, bob.secretKey); + + expect(opened).not.toBeNull(); + expect(new TextDecoder().decode(opened!)).toBe("hello from alice"); + }); + + it("uses a fresh random nonce on each call", () => { + const alice = generateKeypair(); + const bob = generateKeypair(); + const msg = new TextEncoder().encode("same message"); + + const a = box(msg, bob.publicKey, alice.secretKey); + const b = box(msg, bob.publicKey, alice.secretKey); + + expect(Buffer.from(a.nonce).equals(Buffer.from(b.nonce))).toBe(false); + expect(Buffer.from(a.ciphertext).equals(Buffer.from(b.ciphertext))).toBe(false); + }); + + it("returns null for tampered ciphertext", () => { + const alice = generateKeypair(); + const bob = generateKeypair(); + const plaintext = new TextEncoder().encode("please don't tamper"); + + const { nonce, ciphertext } = box(plaintext, bob.publicKey, alice.secretKey); + const tampered = new Uint8Array(ciphertext); + tampered[tampered.length - 1] ^= 0x01; + + const opened = unbox(tampered, nonce, alice.publicKey, bob.secretKey); + expect(opened).toBeNull(); + }); + + it("returns null when the nonce is wrong", () => { + const alice = generateKeypair(); + const bob = generateKeypair(); + const plaintext = new TextEncoder().encode("wrong nonce"); + + const { ciphertext } = box(plaintext, bob.publicKey, alice.secretKey); + const badNonce = new Uint8Array(24); // all zeroes + + const opened = unbox(ciphertext, badNonce, alice.publicKey, bob.secretKey); + expect(opened).toBeNull(); + }); + + it("returns null when the recipient key is wrong", () => { + const alice = generateKeypair(); + const bob = generateKeypair(); + const mallory = generateKeypair(); + const plaintext = new TextEncoder().encode("not for mallory"); + + const { nonce, ciphertext } = box(plaintext, bob.publicKey, alice.secretKey); + const opened = unbox(ciphertext, nonce, alice.publicKey, mallory.secretKey); + expect(opened).toBeNull(); + }); +}); + +// ─── base64url ─────────────────────────────────────────────────────── + +describe("encodeB64 / decodeB64", () => { + it("roundtrips arbitrary bytes", () => { + const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]); + const encoded = encodeB64(bytes); + const decoded = decodeB64(encoded); + expect(Buffer.from(decoded).equals(Buffer.from(bytes))).toBe(true); + }); + + it("is URL-safe (no '+', '/', or '=' in output)", () => { + // 0xFB,0xFF,0xBF encodes to "+/+/" in standard base64 → forces replacements + const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xfb, 0xef]); + const encoded = encodeB64(bytes); + expect(encoded).not.toMatch(/[+/=]/); + }); +}); + +// ─── Pairing payload ───────────────────────────────────────────────── + +describe("pairing payload", () => { + const sample: PairingPayload = { + v: 1, + relayUrl: "wss://relay.example.com", + daemonPub: encodeB64(generateKeypair().publicKey), + pairCode: "A1B2C3", + label: "bailey-laptop", + }; + + it("encode/decode roundtrip preserves fields", () => { + const encoded = encodePairingPayload(sample); + expect(typeof encoded).toBe("string"); + expect(encoded).not.toMatch(/[+/=]/); + + const decoded = decodePairingPayload(encoded); + expect(decoded).toEqual(sample); + }); + + it("omits the optional label when absent", () => { + const { label: _omit, ...rest } = sample; + const payload: PairingPayload = { ...rest }; + const decoded = decodePairingPayload(encodePairingPayload(payload)); + expect(decoded.label).toBeUndefined(); + expect(decoded).toEqual(payload); + }); + + it("throws on malformed base64", () => { + expect(() => decodePairingPayload("!!!not-base64!!!")).toThrow(); + }); + + it("throws on base64 that decodes to non-JSON", () => { + const junk = encodeB64(new TextEncoder().encode("not json at all")); + expect(() => decodePairingPayload(junk)).toThrow(/invalid JSON/); + }); + + it("throws on schema violations (missing fields)", () => { + const bad = encodeB64( + new TextEncoder().encode(JSON.stringify({ v: 1, relayUrl: "wss://x" })), + ); + expect(() => decodePairingPayload(bad)).toThrow(); + }); + + it("throws on wrong schema version", () => { + const bad = encodeB64( + new TextEncoder().encode( + JSON.stringify({ ...sample, v: 2 }), + ), + ); + expect(() => decodePairingPayload(bad)).toThrow(); + }); +});