Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"tweetnacl": "^1.0.3",
"ws": "^8.18.0",
"zod": "^3.25.0"
},
Expand Down
82 changes: 82 additions & 0 deletions packages/client/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from "./protocol.js";
export * from "./frame.js";
export * from "./client.js";
export * from "./crypto.js";
export * from "./pairing.js";
81 changes: 81 additions & 0 deletions packages/client/src/pairing.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PairingPayload>;

// ─── 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<string, unknown> = {
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);
}
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

166 changes: 166 additions & 0 deletions tests/crypto-primitives.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading