| 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 | |
| 26 | import { fromHex, randomBytes, toHex } from "./bytes.js"; |
| 27 | import { ALPHABET, normalize } from "./code.js"; |
| 28 | import { sha256 } from "./kdf.js"; |
| 29 | import { newEphemeral, openFrom, sealTo } from "./ecies.js"; |
| 30 | import { aad, OBJECT_HANDOFF } from "./sealed.js"; |
| 31 | |
| 32 | export const CODE_SYMBOLS = 8; |
| 33 | |
| 34 | export 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. |
| 39 | export 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. |
| 48 | export 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. |
| 54 | export 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. |
| 66 | export 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. |
| 72 | export 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. |
| 85 | export 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. |
| 102 | export 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. |
| 111 | export 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. |
| 128 | export async function cancel(net, normalized) { |
| 129 | await net.post("/api/link/cancel", { code_hash_hex: await codeHash(normalized) }); |
| 130 | } |
| 131 | |
| 132 | |