carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// The device handoff, from the browser's side. PROTOCOL.md's rung 1.
2//
3// Two roles, one module. The waiting device mints an ephemeral keypair and a
4// short code, shows the code, and polls; an enrolled device takes the code the
5// person carried across, seals the Account Key to the waiting device's point,
6// and posts the envelope. The server holds a hash of the code and a blob it
7// has no key for.
8//
9// # What the code is for
10//
11// Not authentication. Both ends are signed in as the same account before
12// either reaches this module, and every route is scoped to that session — so
13// the code's job is to tell the person WHICH device they are approving, and
14// to carry the lookup from one screen to the other. That is why forty bits
15// and ten minutes are enough, and why it can be short enough to read aloud.
16//
17// # What it does not defend against
18//
19// A server that serves malicious JS. It could swap the waiting device's
20// public key for its own and read the Account Key. PROTOCOL.md already scopes
21// browser clients to at-rest and honest-but-curious "because the server
22// serves the JS", and no arrangement of this module changes that — the fix is
23// the separate integrity-protected origin PROTOCOL.md has adopted and we have
24// not built.
25
26import { fromHex, randomBytes, toHex } from "./bytes.js";
27import { ALPHABET, normalize } from "./code.js";
28import { sha256 } from "./kdf.js";
29import { newEphemeral, openFrom, sealTo } from "./ecies.js";
30import { aad, OBJECT_HANDOFF } from "./sealed.js";
31
32export const CODE_SYMBOLS = 8;
33
34export class HandoffError extends Error {}
35
36// newCode draws eight Crockford symbols. The alphabet is 32 characters, so
37// five bits masked off a random byte is uniform — no rejection loop and no
38// modulo bias, which is the one thing worth fixing in the shape we copied.
39export function newCode() {
40 const b = randomBytes(CODE_SYMBOLS);
41 let out = "";
42 for (const byte of b) out += ALPHABET[byte & 31];
43 return out;
44}
45
46// formatCode is what goes on the screen: XXXX-XXXX. The hyphen is display
47// only — normalize strips it, so a person may type it or not.
48export function formatCode(normalized) {
49 return normalized.slice(0, 4) + "-" + normalized.slice(4);
50}
51
52// readCode normalises what a person typed, applying the same O/0 and I/L/1
53// rules the printed codes use so both screens agree about an ambiguous glyph.
54export function readCode(typed) {
55 const norm = normalize(typed);
56 if (norm.length !== CODE_SYMBOLS) {
57 throw new HandoffError("That code is not eight characters long.");
58 }
59 return norm;
60}
61
62// codeHash is the only form the server ever sees. A plain SHA-256 rather than
63// a slow hash on purpose: forty bits is grindable whatever the hash, so the
64// protection is the ten-minute window and the account scope, and pretending
65// otherwise with a KDF would only be theatre.
66export async function codeHash(normalized) {
67 return toHex(await sha256(new TextEncoder().encode(normalized)));
68}
69
70// begin is the waiting device's first step. The scalar stays in this tab; only
71// the point is posted. Returns the code to show and the state to poll with.
72export async function begin(net, accountIdHex) {
73 const device = await newEphemeral();
74 const code = newCode();
75 const h = await codeHash(code);
76 await net.post("/api/link/start", {
77 code_hash_hex: h,
78 device_public_hex: toHex(device.publicKey),
79 });
80 return { code, hash: h, device, accountIdHex };
81}
82
83// claim polls once. It returns the Account Key when the envelope has arrived,
84// and null while there is nothing yet — "keep waiting" is not an error.
85export async function claim(net, pending) {
86 const res = await net.get(
87 `/api/link/claim?h=${pending.hash}`);
88 if (!res.approved) return null;
89
90 const accountId = fromHex(pending.accountIdHex);
91 const opened = await openFrom(
92 pending.device.scalar, pending.device.publicKey,
93 aad(accountId, OBJECT_HANDOFF, pending.device.publicKey, 1),
94 fromHex(res.sealed_hex));
95 if (opened.length !== 16) {
96 throw new HandoffError("That handoff did not contain an Account Key.");
97 }
98 return opened;
99}
100
101// lookup is the enrolled device asking what a typed code refers to.
102export async function lookup(net, normalized) {
103 return net.get(`/api/link/pending?h=${await codeHash(normalized)}`);
104}
105
106// approve seals the Account Key to the waiting device and posts the envelope.
107//
108// The AAD names that device's own public key, so the envelope is bound to it:
109// a server that relayed it to a second device would hand over something that
110// cannot open.
111export async function approve(net, normalized, accountIdHex, devicePublicHex, accountKey) {
112 const devicePublic = fromHex(devicePublicHex);
113 if (devicePublic.length !== 65 || devicePublic[0] !== 0x04) {
114 throw new HandoffError("That device sent a key this app does not recognise.");
115 }
116 const envelope = await sealTo(
117 devicePublic,
118 aad(fromHex(accountIdHex), OBJECT_HANDOFF, devicePublic, 1),
119 accountKey);
120 await net.post("/api/link/approve", {
121 code_hash_hex: await codeHash(normalized),
122 sealed_hex: toHex(envelope),
123 });
124}
125
126// cancel drops a code the waiting device gave up on, so closing a tab does
127// not leave a live handoff behind.
128export async function cancel(net, normalized) {
129 await net.post("/api/link/cancel", { code_hash_hex: await codeHash(normalized) });
130}
131
132