web/5: the fresh-device ladder — handoff, scan, type
A browser that signs in on a new machine could reach the vault and then stop dead: it had a session but no Account Key, and the gate said so and offered nothing. That is the one screen DESIGN.md forbids. This is the ladder that fills it, on /device, offering every rung that can work and never fewer than one. Rung 1, the handoff, follows slopbox's `link` shape as discussion 11 says to, with four deliberate departures: - Every route needs a session. slopbox leaves start and claim open because its waiting device has no account yet; ours does — a fresh browser signs in with the passphrase alone, since the login key comes from S_pw and never from the Account Key, and only then finds it cannot unlock. Both ends being authenticated is what makes a code short enough to read aloud safe: its job is to say WHICH device, not to prove who is asking. - No device label. slopbox stores one and shows it to the approver, which is a plaintext column in a file that replicates whole and, worse, attacker-chosen text displayed at the exact moment somebody is deciding whether to hand over a key. "CONTRA official device" is a free phishing line. The code carried between screens is the confirmation. - The client hashes the code, so the server never receives it at all, only SHA-256 of it. - There is a sweeper. slopbox has none and its rows accumulate forever; they are inert, but the table becomes a log of when each account enrolled a device. It sweeps abandoned signup sessions too — that function existed and had never been called. Also fixed the modulo bias in the code alphabet: 32 Crockford symbols means five bits masked off a random byte, no rejection loop, exactly 40 bits. Rungs 2 and 3 share one path, because the QR on the card encodes exactly the printed line. A mistyped character self-heals: repairCode produces about a thousand candidates and the twenty-bit checksum cannot pick between them, so the KCV does — which needs the passphrase, so the field only appears once the checksum has actually failed. vaultkeys.opener splits the stretch from the test so that is one Argon2id and then an HMAC per candidate, rather than minutes. The camera rung is offered only where BarcodeDetector exists (Chromium, Android). Safari and Firefox have none, and a QR decoder inside the module graph that holds the keys is a great deal of code for a shortcut. Hiding it costs a convenience, not a way in, which is what "all available rungs" means. Tests: eight handoff walks over real HTTP — the shape that caught the wrap-id bug — plus the ladder's own no-dead-end assertion, the account scoping, the single-use claim AND the single-use approval (a second approval must not clobber an envelope the waiting device can still open), the live-link cap, and the sweep. The greps gained the handoff write path and a canary for the code itself, which is what a later "show pending devices with their codes" would put on disk. render() now refuses an unregistered page name instead of dereferencing a nil map: the missing template for this page surfaced as a segfault in a handler rather than as the wiring mistake it was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
22 files changed,
+1741
−11
cmd/contra/main.go+11 −0internal/contra/app.go+12 −0internal/contra/auth/signup.go+14 −1internal/contra/handlers.go+8 −0internal/contra/migrations/0006_create_device_links.sql+8 −0internal/contra/migrations/schema.sql+8 −0internal/contra/model/model.go+45 −0internal/contra/models.go+1 −0internal/contra/render.go+11 −2internal/contra/static/crypto/link.js+131 −0internal/contra/static/crypto/vaultkeys.js+36 −0internal/contra/static/pages/device.js+264 −0internal/contra/static/pages/devicescan.js+96 −0internal/contra/static/pages/vault.js+12 −3internal/contra/store/links.go+189 −0internal/contra/sweep.go+56 −0internal/contra/templates/device.html+130 −0internal/contra/vault/links.go+220 −0internal/contratest/greps_test.go+51 −0internal/contratest/jsmodules_test.go+8 −5internal/contratest/links_test.go+429 −0internal/contratest/storage_test.go+1 −0
diff --git a/cmd/contra/main.go b/cmd/contra/main.go| index bd19fdd..000f1ee 100644 |
| --- a/cmd/contra/main.go |
| +++ b/cmd/contra/main.go |
| @@ -3,6 +3,7 @@ |
| package main |
| import ( |
| + "context" |
| "encoding/hex" |
| "log/slog" |
| "os" |
| @@ -110,6 +111,16 @@ func main() { |
| os.Exit(1) |
| } |
| + // Expired signup sessions and device handoffs. Both tables are already |
| + // correct without this — every query carries its own expiry — so the |
| + // sweep is not about correctness. It is that an unswept table becomes a |
| + // log of who tried to sign up and when, and of when each account enrolled |
| + // a device. Started here rather than inside App so a test gets a server |
| + // with no background goroutine and no clock of its own. |
| + sweepCtx, stopSweeping := context.WithCancel(context.Background()) |
| + defer stopSweeping() |
| + go contra.SweepLoop(sweepCtx, d, logger) |
| + |
| opts.Mux = mux |
| opts.DBPath = "" |
| // The same page a handler's own error path renders, so a panic and a |
diff --git a/internal/contra/app.go b/internal/contra/app.go| index 521b47b..6499817 100644 |
| --- a/internal/contra/app.go |
| +++ b/internal/contra/app.go |
| @@ -118,6 +118,18 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) { |
| r.Put("/api/items", items.UpdateItem) |
| r.Post("/api/items/delete", items.DeleteItem) |
| r.Post("/api/passkey", items.AddPasskey) |
| + |
| + // The device handoff, DESIGN.md's rung 1. Both ends are signed in as |
| + // the same account — a fresh browser signs in with the passphrase |
| + // alone and only then finds it has no Account Key — so every one of |
| + // these is scoped to the session, which is what makes a code short |
| + // enough to read aloud safe to use. |
| + r.Get("/device", a.device) |
| + r.Post("/api/link/start", items.StartLink) |
| + r.Get("/api/link/pending", items.PendingLink) |
| + r.Post("/api/link/approve", items.ApproveLink) |
| + r.Get("/api/link/claim", items.ClaimLink) |
| + r.Post("/api/link/cancel", items.CancelLink) |
| }) |
| mux := http.NewServeMux() |
diff --git a/internal/contra/auth/signup.go b/internal/contra/auth/signup.go| index 284d95f..be78332 100644 |
| --- a/internal/contra/auth/signup.go |
| +++ b/internal/contra/auth/signup.go |
| @@ -298,5 +298,18 @@ func (a *Auth) Complete(w http.ResponseWriter, r *http.Request) { |
| // SweepSignups deletes expired rows. |
| func (a *Auth) SweepSignups(now time.Time) error { |
| - return a.db.Where("expires_at < ?", now.UTC()).Delete(&model.SignupSession{}).Error |
| + _, err := SweepSignupsIn(context.Background(), a.db, now) |
| + return err |
| +} |
| + |
| +// SweepSignupsIn is the same delete without an Auth to hang it on, so the |
| +// background sweep can run without building the whole ceremony to reach it. |
| +// |
| +// A half-finished signup is already inert — every lookup carries the expiry — |
| +// so this is not about correctness. It is that a table of these that |
| +// accumulated would be a log of who tried to sign up and when. |
| +func SweepSignupsIn(ctx context.Context, db *gorm.DB, now time.Time) (int64, error) { |
| + res := db.WithContext(ctx). |
| + Where("expires_at < ?", now.UTC()).Delete(&model.SignupSession{}) |
| + return res.RowsAffected, res.Error |
| } |
diff --git a/internal/contra/handlers.go b/internal/contra/handlers.go| index db567ac..f4ebd16 100644 |
| --- a/internal/contra/handlers.go |
| +++ b/internal/contra/handlers.go |
| @@ -96,3 +96,11 @@ func (a *app) signinPage(w http.ResponseWriter, r *http.Request) { |
| func (a *app) vault(w http.ResponseWriter, r *http.Request) { |
| render(w, "vault", a.pageData(r)) |
| } |
| + |
| +// device is the ladder: the screen a browser reaches when it is signed in but |
| +// has no Account Key, and the screen an enrolled browser reaches to approve |
| +// one that is waiting. Both roles live on one page because a person arriving |
| +// here has to be able to do either without being told which link to click. |
| +func (a *app) device(w http.ResponseWriter, r *http.Request) { |
| + render(w, "device", a.pageData(r)) |
| +} |
diff --git a/internal/contra/migrations/0006_create_device_links.sql b/internal/contra/migrations/0006_create_device_links.sql| new file mode 100644 |
| index 0000000..9eb6732 |
| --- /dev/null |
| +++ b/internal/contra/migrations/0006_create_device_links.sql |
| @@ -0,0 +1,8 @@ |
| +CREATE TABLE `device_links` (`id` blob,`code_hash` blob NOT NULL,`account_id` blob NOT NULL,`device_public` blob NOT NULL,`sealed` blob,`expires_at` datetime NOT NULL,PRIMARY KEY (`id`)); |
| + |
| +CREATE INDEX `idx_device_links_expires_at` ON `device_links`(`expires_at`); |
| + |
| +CREATE INDEX `idx_device_links_account_id` ON `device_links`(`account_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_device_links_code_hash` ON `device_links`(`code_hash`); |
| + |
diff --git a/internal/contra/migrations/schema.sql b/internal/contra/migrations/schema.sql| index 64ab8c9..be7baf1 100644 |
| --- a/internal/contra/migrations/schema.sql |
| +++ b/internal/contra/migrations/schema.sql |
| @@ -3,6 +3,8 @@ |
| CREATE TABLE `accounts` (`id` blob,`email_index` blob NOT NULL,`kdf_salt` blob NOT NULL,`created_at` datetime, `public_key` blob, `keyset_nonce` blob, `keyset_ciphertext` blob,PRIMARY KEY (`id`)); |
| +CREATE TABLE `device_links` (`id` blob,`code_hash` blob NOT NULL,`account_id` blob NOT NULL,`device_public` blob NOT NULL,`sealed` blob,`expires_at` datetime NOT NULL,PRIMARY KEY (`id`)); |
| + |
| CREATE TABLE `items` (`id` blob,`vault_id` blob NOT NULL,`version` integer NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,`updated_at` datetime,PRIMARY KEY (`id`)); |
| CREATE TABLE `login_challenges` (`id` blob,`account_id` blob NOT NULL,`branch` text NOT NULL,`challenge` blob NOT NULL,`expires_at` datetime NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| @@ -19,6 +21,12 @@ CREATE TABLE `wraps` (`id` blob,`account_id` blob NOT NULL,`kind` text NOT NULL, |
| CREATE UNIQUE INDEX `idx_accounts_email_index` ON `accounts`(`email_index`); |
| +CREATE INDEX `idx_device_links_account_id` ON `device_links`(`account_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_device_links_code_hash` ON `device_links`(`code_hash`); |
| + |
| +CREATE INDEX `idx_device_links_expires_at` ON `device_links`(`expires_at`); |
| + |
| CREATE INDEX `idx_items_vault_id` ON `items`(`vault_id`); |
| CREATE INDEX `idx_login_challenges_account_id` ON `login_challenges`(`account_id`); |
diff --git a/internal/contra/model/model.go b/internal/contra/model/model.go| index 383ab94..fbb737e 100644 |
| --- a/internal/contra/model/model.go |
| +++ b/internal/contra/model/model.go |
| @@ -265,3 +265,48 @@ type SignupSession struct { |
| ExpiresAt time.Time `gorm:"index;not null"` |
| CreatedAt time.Time |
| } |
| + |
| +// DeviceLink is one live handoff: a fresh device waiting for an enrolled one |
| +// to seal it a copy of the Account Key. DESIGN.md's rung 1, following |
| +// slopbox's `link` shape rather than inventing another. |
| +// |
| +// The row holds no secret. CodeHash is SHA-256 of the normalised code, so a |
| +// database-plus-bucket leak yields nothing anyone can spend — the code itself |
| +// exists only on the two screens the person is looking at. Sealed is an ECIES |
| +// envelope the server has no key for, and the AAD inside it names |
| +// DevicePublic, so the server cannot relay one envelope to a second device. |
| +// |
| +// AccountID is here, where slopbox's equivalent deliberately has no user id, |
| +// because in CONTRA both ends are already signed in: a fresh device signs in |
| +// with the passphrase alone and only then discovers it has no Account Key. |
| +// Scoping every route to the session's account means an unrelated account |
| +// cannot see that a link exists, let alone spend one — which is what lets the |
| +// code be short enough to read aloud. |
| +// |
| +// Rows are deleted the moment they are claimed and swept when they expire. A |
| +// table of these that accumulated would be a log of when each account |
| +// enrolled a device, and there is no reason to keep one. |
| +type DeviceLink struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + CodeHash []byte `gorm:"uniqueIndex;type:blob;not null"` |
| + |
| + // AccountID scopes the link: both ends must be signed in as this account. |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // DevicePublic is the fresh device's ephemeral P-256 point, SEC1 |
| + // uncompressed. The matching scalar never leaves that browser tab. |
| + DevicePublic []byte `gorm:"type:blob;not null"` |
| + |
| + // There is deliberately no device label, which is where this departs from |
| + // slopbox. A label would be a plaintext column in a file that replicates |
| + // whole — and worse, it is attacker-chosen text shown to the victim at |
| + // the exact moment they are deciding whether to hand over a key. "CONTRA |
| + // official device" is a free phishing line. The code the person carried |
| + // from one screen to the other is the confirmation; a label adds nothing |
| + // to it and takes something away. |
| + |
| + // Sealed is the ECIES envelope, empty until an enrolled device approves. |
| + Sealed []byte `gorm:"type:blob"` |
| + |
| + ExpiresAt time.Time `gorm:"index;not null"` |
| +} |
diff --git a/internal/contra/models.go b/internal/contra/models.go| index 3403ba0..6536969 100644 |
| --- a/internal/contra/models.go |
| +++ b/internal/contra/models.go |
| @@ -23,4 +23,5 @@ var Models = []any{ |
| &model.Item{}, |
| &model.LoginChallenge{}, |
| &model.SignupSession{}, |
| + &model.DeviceLink{}, |
| } |
diff --git a/internal/contra/render.go b/internal/contra/render.go| index 961fc9a..813a297 100644 |
| --- a/internal/contra/render.go |
| +++ b/internal/contra/render.go |
| @@ -26,7 +26,7 @@ var assets = rastrillo.NewAssets(appFS) |
| var pages = map[string]*template.Template{} |
| func init() { |
| - for _, name := range []string{"index", "errors", "signup", "signup_sent", "signup_passphrase", "signup_passkey", "signin", "vault"} { |
| + for _, name := range []string{"index", "errors", "signup", "signup_sent", "signup_passphrase", "signup_passkey", "signin", "vault", "device"} { |
| pages[name] = template.Must( |
| // ui's partials and their helpers first, so every page can |
| // call one without registering anything. WithIcons points |
| @@ -52,8 +52,17 @@ func init() { |
| // appended to a half-written page, and headers (flash cookies, say) |
| // stay settable until the status line is written. |
| func render(w http.ResponseWriter, name string, data any) { |
| + // A name with no template is a wiring mistake, not a runtime one. Without |
| + // this it is a nil-map dereference inside html/template, which panics in |
| + // the handler and reports a segfault rather than the missing page. |
| + page, ok := pages[name] |
| + if !ok { |
| + http.Error(w, "something went wrong", http.StatusInternalServerError) |
| + return |
| + } |
| + |
| var buf bytes.Buffer |
| - if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil { |
| + if err := page.ExecuteTemplate(&buf, "layout", data); err != nil { |
| http.Error(w, "something went wrong", http.StatusInternalServerError) |
| return |
| } |
diff --git a/internal/contra/static/crypto/link.js b/internal/contra/static/crypto/link.js| new file mode 100644 |
| index 0000000..58a078b |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/link.js |
| @@ -0,0 +1,131 @@ |
| +// The device handoff, from the browser's side. DESIGN.md's rung 1. |
| +// |
| +// Two roles, one module. The waiting device mints an ephemeral keypair and a |
| +// short code, shows the code, and polls; an enrolled device takes the code the |
| +// person carried across, seals the Account Key to the waiting device's point, |
| +// and posts the envelope. The server holds a hash of the code and a blob it |
| +// has no key for. |
| +// |
| +// # What the code is for |
| +// |
| +// Not authentication. Both ends are signed in as the same account before |
| +// either reaches this module, and every route is scoped to that session — so |
| +// the code's job is to tell the person WHICH device they are approving, and |
| +// to carry the lookup from one screen to the other. That is why forty bits |
| +// and ten minutes are enough, and why it can be short enough to read aloud. |
| +// |
| +// # What it does not defend against |
| +// |
| +// A server that serves malicious JS. It could swap the waiting device's |
| +// public key for its own and read the Account Key. DESIGN.md already scopes |
| +// browser clients to at-rest and honest-but-curious "because the server |
| +// serves the JS", and no arrangement of this module changes that — the fix is |
| +// the separate integrity-protected origin DESIGN.md has adopted and we have |
| +// not built. |
| + |
| +import { fromHex, randomBytes, toHex } from "./bytes.js"; |
| +import { ALPHABET, normalize } from "./code.js"; |
| +import { sha256 } from "./kdf.js"; |
| +import { newEphemeral, openFrom, sealTo } from "./ecies.js"; |
| +import { aad, OBJECT_HANDOFF } from "./sealed.js"; |
| + |
| +export const CODE_SYMBOLS = 8; |
| + |
| +export class HandoffError extends Error {} |
| + |
| +// newCode draws eight Crockford symbols. The alphabet is 32 characters, so |
| +// five bits masked off a random byte is uniform — no rejection loop and no |
| +// modulo bias, which is the one thing worth fixing in the shape we copied. |
| +export function newCode() { |
| + const b = randomBytes(CODE_SYMBOLS); |
| + let out = ""; |
| + for (const byte of b) out += ALPHABET[byte & 31]; |
| + return out; |
| +} |
| + |
| +// formatCode is what goes on the screen: XXXX-XXXX. The hyphen is display |
| +// only — normalize strips it, so a person may type it or not. |
| +export function formatCode(normalized) { |
| + return normalized.slice(0, 4) + "-" + normalized.slice(4); |
| +} |
| + |
| +// readCode normalises what a person typed, applying the same O/0 and I/L/1 |
| +// rules the printed codes use so both screens agree about an ambiguous glyph. |
| +export function readCode(typed) { |
| + const norm = normalize(typed); |
| + if (norm.length !== CODE_SYMBOLS) { |
| + throw new HandoffError("That code is not eight characters long."); |
| + } |
| + return norm; |
| +} |
| + |
| +// codeHash is the only form the server ever sees. A plain SHA-256 rather than |
| +// a slow hash on purpose: forty bits is grindable whatever the hash, so the |
| +// protection is the ten-minute window and the account scope, and pretending |
| +// otherwise with a KDF would only be theatre. |
| +export async function codeHash(normalized) { |
| + return toHex(await sha256(new TextEncoder().encode(normalized))); |
| +} |
| + |
| +// begin is the waiting device's first step. The scalar stays in this tab; only |
| +// the point is posted. Returns the code to show and the state to poll with. |
| +export async function begin(net, accountIdHex) { |
| + const device = await newEphemeral(); |
| + const code = newCode(); |
| + const h = await codeHash(code); |
| + await net.post("/api/link/start", { |
| + code_hash_hex: h, |
| + device_public_hex: toHex(device.publicKey), |
| + }); |
| + return { code, hash: h, device, accountIdHex }; |
| +} |
| + |
| +// claim polls once. It returns the Account Key when the envelope has arrived, |
| +// and null while there is nothing yet — "keep waiting" is not an error. |
| +export async function claim(net, pending) { |
| + const res = await net.get( |
| + `/api/link/claim?h=${pending.hash}`); |
| + if (!res.approved) return null; |
| + |
| + const accountId = fromHex(pending.accountIdHex); |
| + const opened = await openFrom( |
| + pending.device.scalar, pending.device.publicKey, |
| + aad(accountId, OBJECT_HANDOFF, pending.device.publicKey, 1), |
| + fromHex(res.sealed_hex)); |
| + if (opened.length !== 16) { |
| + throw new HandoffError("That handoff did not contain an Account Key."); |
| + } |
| + return opened; |
| +} |
| + |
| +// lookup is the enrolled device asking what a typed code refers to. |
| +export async function lookup(net, normalized) { |
| + return net.get(`/api/link/pending?h=${await codeHash(normalized)}`); |
| +} |
| + |
| +// approve seals the Account Key to the waiting device and posts the envelope. |
| +// |
| +// The AAD names that device's own public key, so the envelope is bound to it: |
| +// a server that relayed it to a second device would hand over something that |
| +// cannot open. |
| +export async function approve(net, normalized, accountIdHex, devicePublicHex, accountKey) { |
| + const devicePublic = fromHex(devicePublicHex); |
| + if (devicePublic.length !== 65 || devicePublic[0] !== 0x04) { |
| + throw new HandoffError("That device sent a key this app does not recognise."); |
| + } |
| + const envelope = await sealTo( |
| + devicePublic, |
| + aad(fromHex(accountIdHex), OBJECT_HANDOFF, devicePublic, 1), |
| + accountKey); |
| + await net.post("/api/link/approve", { |
| + code_hash_hex: await codeHash(normalized), |
| + sealed_hex: toHex(envelope), |
| + }); |
| +} |
| + |
| +// cancel drops a code the waiting device gave up on, so closing a tab does |
| +// not leave a live handoff behind. |
| +export async function cancel(net, normalized) { |
| + await net.post("/api/link/cancel", { code_hash_hex: await codeHash(normalized) }); |
| +} |
| + |
diff --git a/internal/contra/static/crypto/vaultkeys.js b/internal/contra/static/crypto/vaultkeys.js| index 430c26d..04a8b46 100644 |
| --- a/internal/contra/static/crypto/vaultkeys.js |
| +++ b/internal/contra/static/crypto/vaultkeys.js |
| @@ -58,6 +58,19 @@ export function accountKeys(storage) { |
| // AES-GCM is not key-committing; without this check the two are the same |
| // error, and the unlock paths need to tell them apart constantly. |
| export async function unlock(bootstrap, passphrase, accountKey) { |
| + return (await opener(bootstrap, passphrase)).open(accountKey); |
| +} |
| + |
| +// opener stretches the passphrase ONCE and hands back something that can test |
| +// many Account Keys against it. |
| +// |
| +// That split is what makes the ladder's "type it" rung usable. A mistyped |
| +// character produces about a thousand repair candidates (code.js), and the |
| +// checksum is only twenty bits, so several of them can pass it — the KCV is |
| +// what picks the real one. Re-running Argon2id per candidate would be minutes |
| +// of work at m=64MiB; S_pw does not depend on the Account Key, so it is one |
| +// stretch and then a HMAC per candidate. |
| +export async function opener(bootstrap, passphrase) { |
| const accountId = fromHex(bootstrap.account_id_hex); |
| const wrap = bootstrap.wraps.find((w) => w.kind === OBJECT_WRAP_PW); |
| if (!wrap) throw new LockedError("contra: this account has no passphrase wrap"); |
| @@ -65,6 +78,29 @@ export async function unlock(bootstrap, passphrase, accountKey) { |
| const params = checkParams(JSON.parse(wrap.kdf_params)); |
| const sPW = argon2id( |
| new TextEncoder().encode(passphrase), fromHex(bootstrap.salt_hex), params, 32); |
| + const wantKCV = fromHex(wrap.kcv_hex); |
| + |
| + // accepts is the cheap test: derive the KEK and compare the KCV. Sixteen |
| + // bytes of HMAC output match exactly one KEK, so a candidate that passes |
| + // this is the Account Key, not a near miss. |
| + const accepts = async (accountKey) => |
| + equal(await kcv(await hkdf(sPW, accountKey, LABEL_KEK, 32)), wantKCV); |
| + |
| + // first returns the candidate that opens, or null. Order is the caller's: |
| + // code.js hands back repairs in position-then-alphabet order and refuses to |
| + // choose between them, because choosing on a twenty-bit checksum would |
| + // sometimes choose wrong and say nothing. |
| + const first = async (candidates) => { |
| + for (const c of candidates) if (await accepts(c)) return c; |
| + return null; |
| + }; |
| + |
| + return { accountId, wrap, accepts, first, open: (ak) => openWith(bootstrap, sPW, ak) }; |
| +} |
| + |
| +async function openWith(bootstrap, sPW, accountKey) { |
| + const accountId = fromHex(bootstrap.account_id_hex); |
| + const wrap = bootstrap.wraps.find((w) => w.kind === OBJECT_WRAP_PW); |
| const kekPW = await hkdf(sPW, accountKey, LABEL_KEK, 32); |
| if (!equal(await kcv(kekPW), fromHex(wrap.kcv_hex))) { |
diff --git a/internal/contra/static/pages/device.js b/internal/contra/static/pages/device.js| new file mode 100644 |
| index 0000000..288d411 |
| --- /dev/null |
| +++ b/internal/contra/static/pages/device.js |
| @@ -0,0 +1,264 @@ |
| +// The fresh-device ladder, DESIGN.md's rung 1, 2 and 3. |
| +// |
| +// This page has two jobs and shows both, because a person arriving here knows |
| +// which one they are doing and the app does not: a browser that needs the |
| +// Account Key, and a browser that already has it and is being asked to pass |
| +// it on. The rule this is shaped by is that no screen dead-ends — every rung |
| +// that can work here is offered, and the last one always can. |
| +// |
| +// Everything expensive lives in a crypto module. This file moves text between |
| +// a form and one. |
| + |
| +import { net } from "../crypto/net.js"; |
| +import { parseCode, repairCode, TAG_ACCOUNT_KEY } from "../crypto/code.js"; |
| +import * as link from "../crypto/link.js"; |
| +import { wireScanning } from "./devicescan.js"; |
| +import { accountKeys, opener } from "../crypto/vaultkeys.js"; |
| + |
| +const $ = (sel, root = document) => root.querySelector(sel); |
| + |
| +function say(el, text, kind = "working") { |
| + if (!el) return; |
| + const p = document.createElement("p"); |
| + p.setAttribute("data-kind", kind); |
| + p.append(document.createTextNode(text)); |
| + el.replaceChildren(p); |
| +} |
| + |
| +function busy(form, on) { |
| + for (const c of form.querySelectorAll("input, button")) c.disabled = on; |
| +} |
| + |
| +// arrived is the one exit this page has: the key is on this device now, so |
| +// send them where they were trying to go. |
| +function arrived(keys, accountIdHex, accountKey, status) { |
| + try { |
| + keys.set(accountIdHex, accountKey); |
| + } catch { |
| + // Safari refuses localStorage in some configurations. Refusing to |
| + // continue here would be worse than a device that asks again next time. |
| + say(status, "This device could not remember the key, but it will work " + |
| + "for now. You will be asked for it again next visit.", "error"); |
| + } |
| + window.location.assign("/vault"); |
| +} |
| + |
| +async function main() { |
| + const need = $("[data-need]"); |
| + if (!need) return; |
| + |
| + const bootstrap = await net.get("/api/bootstrap"); |
| + const accountIdHex = bootstrap.account_id_hex; |
| + const keys = accountKeys(window.localStorage); |
| + const status = $("[data-need-status]"); |
| + |
| + // A device that already holds the key is here to give it away, not to get |
| + // it. Both sections stay reachable, because a person may be on the wrong |
| + // one and must not have to guess a URL to fix that. |
| + const cached = keys.get(accountIdHex); |
| + if (cached) { |
| + $("[data-have]").hidden = false; |
| + say(status, "This device already has your Account Key. You can approve " + |
| + "another device below.", "ok"); |
| + } |
| + |
| + wireHandoff(need, accountIdHex, keys, status); |
| + wireTyping(need, bootstrap, accountIdHex, keys, status); |
| + await wireScanning(need, status, say, |
| + (text, el) => useCode(text, bootstrap, el, null), |
| + (accountKey) => arrived(keys, accountIdHex, accountKey, status)); |
| + wireApproval(bootstrap, accountIdHex, keys); |
| +} |
| + |
| +// ------------------------------------------------------------ rung 1: handoff |
| + |
| +function wireHandoff(need, accountIdHex, keys, status) { |
| + const panel = $("[data-handoff]", need); |
| + const state = { pending: null, timer: null }; |
| + |
| + const stop = () => { |
| + if (state.timer) clearTimeout(state.timer); |
| + state.timer = null; |
| + }; |
| + |
| + $("[data-start-handoff]", need).addEventListener("click", async () => { |
| + try { |
| + state.pending = await link.begin(net, accountIdHex); |
| + } catch (err) { |
| + console.error("CONTRA handoff could not start", err); |
| + say(status, err.message || "Could not start a handoff. The other rungs " + |
| + "below still work.", "error"); |
| + return; |
| + } |
| + $("[data-code]", panel).textContent = link.formatCode(state.pending.code); |
| + panel.hidden = false; |
| + poll(); |
| + }); |
| + |
| + // Two seconds, as slopbox polls. The window is ten minutes; a person who |
| + // walks away and comes back finds it expired rather than still open. |
| + async function poll() { |
| + stop(); |
| + try { |
| + const accountKey = await link.claim(net, state.pending); |
| + if (accountKey) { |
| + arrived(keys, accountIdHex, accountKey, status); |
| + return; |
| + } |
| + } catch (err) { |
| + console.error("CONTRA handoff failed while claiming", err); |
| + say($("[data-handoff-status]", panel), |
| + "That handoff could not be completed. Start another, or use your " + |
| + "card below.", "error"); |
| + return; |
| + } |
| + state.timer = setTimeout(poll, 2000); |
| + } |
| + |
| + $("[data-cancel-handoff]", need).addEventListener("click", async () => { |
| + stop(); |
| + panel.hidden = true; |
| + if (state.pending) { |
| + // Best effort: a code left behind expires by itself in ten minutes. |
| + await link.cancel(net, state.pending.code).catch(() => {}); |
| + state.pending = null; |
| + } |
| + }); |
| +} |
| + |
| +// ------------------------------------------------- rungs 2 and 3: card and QR |
| + |
| +// useCode is what both the camera and the text box end at: a string off the |
| +// card, checked and — if a character is wrong — repaired against the KCV. |
| +async function useCode(text, bootstrap, status, form) { |
| + try { |
| + return await parseCode(TAG_ACCOUNT_KEY, text); |
| + } catch (err) { |
| + if (!form) throw err; |
| + } |
| + |
| + // The checksum failed. Twenty bits does not identify the typo, so the |
| + // repair produces candidates and the KCV picks between them — and that |
| + // needs the passphrase, which is why it is only asked for now. |
| + const repair = $("[data-repair]", form); |
| + const passphrase = $("#repair-passphrase", form).value; |
| + if (!passphrase) { |
| + repair.hidden = false; |
| + say(status, "That code did not check out. Add your passphrase and this " + |
| + "can try the single-character corrections.", "error"); |
| + return null; |
| + } |
| + |
| + say(status, "Checking your passphrase…"); |
| + const o = await opener(bootstrap, passphrase); |
| + |
| + say(status, "Trying corrections…"); |
| + const candidates = await repairCode(TAG_ACCOUNT_KEY, text); |
| + for (const candidate of candidates) { |
| + const payload = await parseCode(TAG_ACCOUNT_KEY, candidate); |
| + if (await o.accepts(payload)) return payload; |
| + } |
| + say(status, "That is not a code for this account, even allowing for one " + |
| + "mistyped character. Check it against the card.", "error"); |
| + return null; |
| +} |
| + |
| +function wireTyping(need, bootstrap, accountIdHex, keys, status) { |
| + const form = $("[data-type-form]", need); |
| + form.addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + busy(form, true); |
| + try { |
| + const accountKey = await useCode( |
| + $("#account-key", form).value, bootstrap, status, form); |
| + if (accountKey) arrived(keys, accountIdHex, accountKey, status); |
| + } catch (err) { |
| + console.error("CONTRA could not read a typed Account Key", err); |
| + say(status, "That does not look like an Account Key. It starts with " + |
| + "A1 and has thirty characters after it.", "error"); |
| + } finally { |
| + busy(form, false); |
| + } |
| + }); |
| +} |
| + |
| +// ---------------------------------------------------------------- approving |
| + |
| +function wireApproval(bootstrap, accountIdHex, keys) { |
| + const have = $("[data-have]"); |
| + const form = $("[data-approve-form]", have); |
| + const confirm = $("[data-confirm]", have); |
| + const status = $("[data-approve-status]", have); |
| + const state = { code: null, devicePublicHex: null }; |
| + |
| + form.addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + busy(form, true); |
| + try { |
| + const code = link.readCode($("#link-code", form).value); |
| + const pending = await link.lookup(net, code); |
| + if (pending.approved) { |
| + say(status, "That code has already been approved.", "error"); |
| + return; |
| + } |
| + state.code = code; |
| + state.devicePublicHex = pending.device_public_hex; |
| + confirm.hidden = false; |
| + say(status, ""); |
| + } catch (err) { |
| + console.error("CONTRA could not look up a code", err); |
| + say(status, "No device is waiting with that code. Check it, and that " + |
| + "it has not been more than ten minutes.", "error"); |
| + } finally { |
| + busy(form, false); |
| + } |
| + }); |
| + |
| + $("[data-do-approve]", have).addEventListener("click", async () => { |
| + const passphrase = $("#approve-passphrase", have).value; |
| + if (!passphrase) { |
| + say(status, "Your passphrase is needed to unlock this device's copy.", |
| + "error"); |
| + return; |
| + } |
| + busy(confirm, true); |
| + try { |
| + const accountKey = keys.get(accountIdHex); |
| + if (!accountKey) throw new Error("this device has no Account Key cached"); |
| + |
| + // Prove the passphrase really is this account's before handing a key |
| + // over. Without it, anyone at an unlocked screen could enrol a device. |
| + say(status, "Checking your passphrase…"); |
| + const o = await opener(bootstrap, passphrase); |
| + if (!await o.accepts(accountKey)) { |
| + say(status, "That passphrase does not open this account.", "error"); |
| + return; |
| + } |
| + |
| + say(status, "Sealing your Account Key to that device…"); |
| + await link.approve(net, state.code, accountIdHex, state.devicePublicHex, accountKey); |
| + confirm.hidden = true; |
| + say(status, "Approved. The other device should open in a moment.", "ok"); |
| + } catch (err) { |
| + console.error("CONTRA could not approve a device", err); |
| + say(status, err.message || "Could not approve that device.", "error"); |
| + } finally { |
| + busy(confirm, false); |
| + } |
| + }); |
| + |
| + // Deliberately does not revoke: there is no cancel-someone-else's-code |
| + // route, and saying "we stopped it" when we did not would be worse than |
| + // saying what actually happens. |
| + $("[data-not-me]", have).addEventListener("click", () => { |
| + confirm.hidden = true; |
| + state.code = null; |
| + say(status, "Nothing was sent. That code expires by itself within ten " + |
| + "minutes. If you did not start it, nobody else can use it either — it " + |
| + "only works for someone already signed in to your account.", "ok"); |
| + }); |
| +} |
| + |
| +main().catch((err) => { |
| + console.error("CONTRA device page failed to start", err); |
| +}); |
diff --git a/internal/contra/static/pages/devicescan.js b/internal/contra/static/pages/devicescan.js| new file mode 100644 |
| index 0000000..ca575dd |
| --- /dev/null |
| +++ b/internal/contra/static/pages/devicescan.js |
| @@ -0,0 +1,96 @@ |
| +// Rung 2 of the fresh-device ladder: scan the QR on the Account Key card. |
| +// |
| +// Split out of device.js only because that file hit its line cap. It takes |
| +// everything it needs as arguments — no imports beyond the DOM — so the |
| +// decision about what a scanned string means stays in one place. |
| +// |
| +// # Why this rung can be absent |
| +// |
| +// It is offered only where the browser can actually decode a QR code. |
| +// BarcodeDetector is in Chromium and on Android; Safari and Firefox have |
| +// nothing, and shipping a decoder inside the module graph that holds the keys |
| +// is a great deal of code for a convenience. |
| +// |
| +// DESIGN.md's rule is that every screen offers all AVAILABLE rungs and never |
| +// dead-ends. Hiding this one costs a shortcut, not a way in: typing the same |
| +// code off the same card is the rung below, and it always works. |
| +// |
| +// The QR encodes exactly the printed string — kit.js draws both from the same |
| +// value — so a scan and a typed line reach the identical code path. |
| + |
| +const $ = (sel, root = document) => root.querySelector(sel); |
| + |
| +export async function wireScanning(need, status, say, readCode, arrived) { |
| + const li = $('[data-rung="scan"]', need); |
| + if (!li || !("BarcodeDetector" in window)) return; |
| + |
| + let formats = []; |
| + try { |
| + formats = await window.BarcodeDetector.getSupportedFormats(); |
| + } catch { |
| + return; |
| + } |
| + if (!formats.includes("qr_code")) return; |
| + li.hidden = false; |
| + |
| + const panel = $("[data-scan]", li); |
| + const video = $("[data-video]", li); |
| + const scanStatus = $("[data-scan-status]", li); |
| + const detector = new window.BarcodeDetector({ formats: ["qr_code"] }); |
| + const state = { stream: null, timer: null }; |
| + |
| + function stop() { |
| + if (state.timer) clearTimeout(state.timer); |
| + state.timer = null; |
| + if (state.stream) for (const t of state.stream.getTracks()) t.stop(); |
| + state.stream = null; |
| + panel.hidden = true; |
| + } |
| + |
| + async function look() { |
| + try { |
| + for (const found of await detector.detect(video)) { |
| + // A camera pointed at a card sees other things too — the recovery |
| + // sheet's QR is a different tag and simply will not parse, so a |
| + // failure here means "keep looking", not "something is wrong". |
| + const accountKey = await readCode(found.rawValue, scanStatus) |
| + .catch(() => null); |
| + if (accountKey) { |
| + stop(); |
| + arrived(accountKey); |
| + return; |
| + } |
| + } |
| + } catch (err) { |
| + console.error("CONTRA scan failed", err); |
| + } |
| + state.timer = setTimeout(look, 250); |
| + } |
| + |
| + $("[data-start-scan]", li).addEventListener("click", async () => { |
| + panel.hidden = false; |
| + try { |
| + state.stream = await navigator.mediaDevices.getUserMedia({ |
| + video: { facingMode: "environment" }, |
| + }); |
| + } catch (err) { |
| + // A refused camera is a normal answer, not a failure of the app. |
| + console.error("CONTRA could not open the camera", err); |
| + say(scanStatus, "This browser would not give us the camera. Type the " + |
| + "code instead — it is the same key.", "error"); |
| + return; |
| + } |
| + video.srcObject = state.stream; |
| + await video.play(); |
| + say(scanStatus, "Point it at the QR code on your card."); |
| + look(); |
| + }); |
| + |
| + $("[data-stop-scan]", li).addEventListener("click", stop); |
| + |
| + // A camera left running is a camera left running. Stopping on hide covers |
| + // the tab-switch case that no button press ever reaches. |
| + document.addEventListener("visibilitychange", () => { |
| + if (document.hidden && state.stream) stop(); |
| + }); |
| +} |
diff --git a/internal/contra/static/pages/vault.js b/internal/contra/static/pages/vault.js| index f69c8bb..2d234c9 100644 |
| --- a/internal/contra/static/pages/vault.js |
| +++ b/internal/contra/static/pages/vault.js |
| @@ -150,9 +150,18 @@ async function main() { |
| const accountKey = keys.get(bootstrap.account_id_hex); |
| const box = gate.querySelector("[data-status]"); |
| if (!accountKey) { |
| - box.replaceChildren(document.createTextNode( |
| - "This device does not have your Account Key yet. The ladder that " + |
| - "brings it here — handoff, scan, type — is not built.")); |
| + // Never a dead end. This browser can still get the key three ways, and |
| + // the screen that offers all three is one link away. |
| + box.replaceChildren( |
| + document.createTextNode( |
| + "This device does not have your Account Key yet, so it cannot open " + |
| + "the vault. "), |
| + Object.assign(document.createElement("a"), { |
| + href: "/device", |
| + textContent: "Give it the key", |
| + }), |
| + document.createTextNode( |
| + " — from another signed-in device, or from your printed card.")); |
| return; |
| } |
| box.replaceChildren(document.createTextNode("Unlocking…")); |
diff --git a/internal/contra/store/links.go b/internal/contra/store/links.go| new file mode 100644 |
| index 0000000..2f957d6 |
| --- /dev/null |
| +++ b/internal/contra/store/links.go |
| @@ -0,0 +1,189 @@ |
| +package store |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "errors" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/model" |
| +) |
| + |
| +// LinkTTL is how long a handoff code lives. Ten minutes is DESIGN.md's, and |
| +// it is the number doing most of the work here: the code is short enough to |
| +// read aloud, so its safety comes from the window and from both ends being |
| +// signed in, not from its length. |
| +const LinkTTL = 10 * time.Minute |
| + |
| +// LinkCodeSymbols is the normalised length of a handoff code: eight Crockford |
| +// symbols, drawn from the 32-character alphabet, so exactly 40 bits with no |
| +// modulo bias to reason about. slopbox's 31-character alphabet needed a |
| +// rejection loop it did not have. |
| +const LinkCodeSymbols = 8 |
| + |
| +// MaxLiveLinks caps how many handoffs one account can have open at once. |
| +// Without it, an attacker holding a session could open codes until one of |
| +// them collided with something a person was about to approve. |
| +const MaxLiveLinks = 5 |
| + |
| +var ( |
| + ErrNoLink = errors.New("store: no such link code, or it expired") |
| + ErrLinkUsed = errors.New("store: that link has already been approved") |
| + ErrTooManyLinks = errors.New("store: too many device links open for this account") |
| +) |
| + |
| +// LinkCodeHash is the only form of the code the server ever holds. The |
| +// caller normalises first — the same Crockford normalisation the printed |
| +// codes use, so O/I/L confusions are resolved identically on both screens. |
| +func LinkCodeHash(normalized string) []byte { |
| + sum := sha256.Sum256([]byte(normalized)) |
| + return sum[:] |
| +} |
| + |
| +// StartLink records a fresh device's ephemeral public key against the hash of |
| +// a code that device minted. The code itself is never sent here. |
| +// |
| +// It is the client that mints the code, not the server. A server-chosen code |
| +// would be a value the server knows in full for every live handoff; this way |
| +// the only copies are on the two screens. |
| +func (s *Store) StartLink(ctx context.Context, id, codeHash, accountID, |
| + devicePublic []byte, now time.Time) (*model.DeviceLink, error) { |
| + if len(id) != 16 || len(codeHash) != sha256.Size || len(accountID) != 16 { |
| + return nil, ErrShortField |
| + } |
| + if len(devicePublic) != crypto.PublicKeyLen || devicePublic[0] != 0x04 { |
| + return nil, ErrShortField |
| + } |
| + |
| + var live int64 |
| + if err := s.db.WithContext(ctx).Model(&model.DeviceLink{}). |
| + Where("account_id = ? AND expires_at > ?", accountID, now). |
| + Count(&live).Error; err != nil { |
| + return nil, err |
| + } |
| + if live >= MaxLiveLinks { |
| + return nil, ErrTooManyLinks |
| + } |
| + |
| + link := &model.DeviceLink{ |
| + ID: id, |
| + CodeHash: codeHash, |
| + AccountID: accountID, |
| + DevicePublic: devicePublic, |
| + ExpiresAt: now.Add(LinkTTL), |
| + } |
| + if err := s.db.WithContext(ctx).Create(link).Error; err != nil { |
| + return nil, err |
| + } |
| + return link, nil |
| +} |
| + |
| +// PendingLink resolves a code for the account that owns it. Scoping the |
| +// lookup to the session's account is what makes a short code safe: a stranger |
| +// cannot reach the row at all, so the code is confirming which device rather |
| +// than proving who is asking. |
| +func (s *Store) PendingLink(ctx context.Context, accountID, codeHash []byte, |
| + now time.Time) (*model.DeviceLink, error) { |
| + var link model.DeviceLink |
| + err := s.db.WithContext(ctx). |
| + Where("code_hash = ? AND account_id = ? AND expires_at > ?", |
| + codeHash, accountID, now). |
| + Take(&link).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return nil, ErrNoLink |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &link, nil |
| +} |
| + |
| +// ApproveLink stores the sealed envelope. The `sealed IS NULL OR sealed = x”` |
| +// precondition is what makes approval single-use: a second approval of the |
| +// same code cannot overwrite the first, so a live handoff cannot be spent on |
| +// a payload the waiting device can never open. |
| +func (s *Store) ApproveLink(ctx context.Context, accountID, codeHash, |
| + sealed []byte, now time.Time) error { |
| + if len(sealed) < crypto.PublicKeyLen+crypto.NonceLen { |
| + return ErrShortField |
| + } |
| + res := s.db.WithContext(ctx).Model(&model.DeviceLink{}). |
| + Where("code_hash = ? AND account_id = ? AND expires_at > ?"+ |
| + " AND (sealed IS NULL OR sealed = x'')", codeHash, accountID, now). |
| + Update("sealed", sealed) |
| + if res.Error != nil { |
| + return res.Error |
| + } |
| + if res.RowsAffected == 0 { |
| + // Either there is no such live link, or it is already approved. Both |
| + // are refusals; telling them apart would say whether a code exists. |
| + return ErrNoLink |
| + } |
| + return nil |
| +} |
| + |
| +// ClaimLink hands the envelope to the waiting device and deletes the row in |
| +// the same transaction. Single use: the envelope leaves exactly once, so a |
| +// second tab — or a second visit to a URL somebody pasted — gets nothing. |
| +// |
| +// A link that exists but is not approved yet returns (nil, nil), because |
| +// "keep waiting" is not an error. |
| +func (s *Store) ClaimLink(ctx context.Context, accountID, codeHash []byte, |
| + now time.Time) ([]byte, error) { |
| + var sealed []byte |
| + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + var link model.DeviceLink |
| + err := tx.Where("code_hash = ? AND account_id = ? AND expires_at > ?", |
| + codeHash, accountID, now).Take(&link).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return ErrNoLink |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if len(link.Sealed) == 0 { |
| + return nil |
| + } |
| + if err := tx.Delete(&model.DeviceLink{}, "id = ?", link.ID).Error; err != nil { |
| + return err |
| + } |
| + sealed = link.Sealed |
| + return nil |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return sealed, nil |
| +} |
| + |
| +// CancelLink drops a link the waiting device gave up on, so a person who |
| +// closed the tab does not leave a live code behind them. |
| +func (s *Store) CancelLink(ctx context.Context, accountID, codeHash []byte) error { |
| + return s.db.WithContext(ctx). |
| + Where("code_hash = ? AND account_id = ?", codeHash, accountID). |
| + Delete(&model.DeviceLink{}).Error |
| +} |
| + |
| +// SweepLinks deletes expired links. slopbox has no sweeper and its rows |
| +// accumulate forever; they are inert, because every query carries the expiry, |
| +// but a table of them is a log of when each account enrolled a device and |
| +// there is no reason to keep one. |
| +func (s *Store) SweepLinks(ctx context.Context, now time.Time) (int64, error) { |
| + res := s.db.WithContext(ctx). |
| + Where("expires_at <= ?", now).Delete(&model.DeviceLink{}) |
| + return res.RowsAffected, res.Error |
| +} |
| + |
| +// NewLinkID mints the row's primary key. It is random and carries no meaning, |
| +// like every other id in this schema. |
| +func NewLinkID() ([]byte, error) { |
| + id := make([]byte, 16) |
| + if _, err := rand.Read(id); err != nil { |
| + return nil, err |
| + } |
| + return id, nil |
| +} |
diff --git a/internal/contra/sweep.go b/internal/contra/sweep.go| new file mode 100644 |
| index 0000000..942ef61 |
| --- /dev/null |
| +++ b/internal/contra/sweep.go |
| @@ -0,0 +1,56 @@ |
| +package contra |
| + |
| +import ( |
| + "context" |
| + "log/slog" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + |
| + "github.com/carlosframework/contra/internal/contra/auth" |
| + "github.com/carlosframework/contra/internal/contra/store" |
| +) |
| + |
| +// SweepInterval is how often the expired-row sweep runs. Nothing depends on |
| +// it being prompt: every query already carries its own expiry, so a row that |
| +// outlives its deadline is inert until this deletes it. |
| +const SweepInterval = 5 * time.Minute |
| + |
| +// Sweep deletes rows whose deadline has passed: half-finished signups and |
| +// device handoffs nobody completed. |
| +// |
| +// Both tables are correct without it — the expiry is in every WHERE clause — |
| +// so this is not about correctness. It is that an unswept table becomes a log |
| +// of who tried to sign up and when, and of when each account enrolled a |
| +// device, which is residue we have no reason to keep. slopbox, whose link |
| +// shape this follows, has no sweeper and its rows accumulate forever. |
| +func Sweep(ctx context.Context, d *db.DB, logger *slog.Logger) { |
| + signups, err := auth.SweepSignupsIn(ctx, d.G, time.Now().UTC()) |
| + if err != nil { |
| + logger.Warn("sweep signups", "err", err) |
| + } |
| + links, err := store.New(d.G).SweepLinks(ctx, time.Now().UTC()) |
| + if err != nil { |
| + logger.Warn("sweep device links", "err", err) |
| + } |
| + if signups > 0 || links > 0 { |
| + logger.Info("swept", "signups", signups, "device_links", links) |
| + } |
| +} |
| + |
| +// SweepLoop runs Sweep until the context is cancelled. main starts it; App |
| +// does not, so a test gets a server with no background goroutine and no |
| +// clock of its own. |
| +func SweepLoop(ctx context.Context, d *db.DB, logger *slog.Logger) { |
| + t := time.NewTicker(SweepInterval) |
| + defer t.Stop() |
| + Sweep(ctx, d, logger) |
| + for { |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-t.C: |
| + Sweep(ctx, d, logger) |
| + } |
| + } |
| +} |
diff --git a/internal/contra/templates/device.html b/internal/contra/templates/device.html| new file mode 100644 |
| index 0000000..25987ce |
| --- /dev/null |
| +++ b/internal/contra/templates/device.html |
| @@ -0,0 +1,130 @@ |
| +{{define "title"}}This device — CONTRA{{end}} |
| +{{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}} |
| +{{define "head"}}<script type="module" src="{{asset "static/pages/device.js"}}"></script>{{end}} |
| + |
| +{{define "content"}} |
| +<h1>This device</h1> |
| + |
| +{{/* Two jobs, one page. A person arriving here is either holding a browser |
| + that needs the Account Key, or holding one that already has it and is |
| + being asked to hand it over. Which of those they are is obvious to them |
| + and not to us, so both are offered rather than guessed at. |
| + |
| + DESIGN.md: every screen that needs an Account Key offers all available |
| + rungs and never dead-ends. */}} |
| + |
| +<section data-need> |
| + <h2>Give this device your Account Key</h2> |
| + <p>Your vault is sealed with a key this server has never had. A browser can |
| + only open it once it holds your Account Key, and this one does not yet. |
| + Any of these will do it.</p> |
| + |
| + <ol data-rungs> |
| + <li data-rung="handoff"> |
| + <h3>From another signed-in device</h3> |
| + <p>Easiest, and needs nothing printed. Open CONTRA on a device that |
| + already works, go to <strong>This device</strong>, and type the code |
| + below.</p> |
| + <p><button type="button" data-start-handoff>Show me a code</button></p> |
| + |
| + <div data-handoff hidden> |
| + <p>On your other device, type this code:</p> |
| + <p><output data-code class="code"></output></p> |
| + <p data-handoff-status aria-live="polite">Waiting for the other |
| + device…</p> |
| + <p><button type="button" data-cancel-handoff>Stop waiting</button></p> |
| + </div> |
| + </li> |
| + |
| + <li data-rung="scan" hidden> |
| + <h3>Scan the card</h3> |
| + <p>Point the camera at the QR code on your Account Key card.</p> |
| + <p><button type="button" data-start-scan>Use the camera</button></p> |
| + <div data-scan hidden> |
| + <video data-video playsinline muted></video> |
| + <p data-scan-status aria-live="polite"></p> |
| + <p><button type="button" data-stop-scan>Stop</button></p> |
| + </div> |
| + </li> |
| + |
| + <li data-rung="type"> |
| + <h3>Type it from the card</h3> |
| + <p>The line under the QR code, starting <code>A1</code>. Hyphens are |
| + optional, and it is not case sensitive.</p> |
| + <form data-type-form method="post" action="#"> |
| + <p> |
| + <label for="account-key">Account Key</label> |
| + <input id="account-key" name="account_key" type="text" |
| + autocomplete="off" autocapitalize="characters" |
| + spellcheck="false" required |
| + placeholder="A1-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"> |
| + </p> |
| + |
| + {{/* Only revealed when the checksum fails. Repairing a mistyped |
| + character means testing candidates against the wrap's KCV, and |
| + that needs the passphrase — but the common case is a code typed |
| + correctly, and that case should not be asked for one. */}} |
| + <p data-repair hidden> |
| + <label for="repair-passphrase">Passphrase</label> |
| + <input id="repair-passphrase" name="passphrase" type="password" |
| + autocomplete="current-password"> |
| + <small>That code did not check out. With your passphrase this can |
| + try the single-character corrections and find the right one.</small> |
| + </p> |
| + |
| + <p><button type="submit">Use this key</button></p> |
| + </form> |
| + </li> |
| + </ol> |
| + |
| + <p data-need-status aria-live="polite"></p> |
| + |
| + <details> |
| + <summary>What if I have lost the card and every other device?</summary> |
| + <p>Then the vault cannot be opened, by you or by anyone, including us. |
| + That is the product rather than a fault in it: a vault the operator can |
| + open is a vault the operator can be made to open. If you still have a |
| + working device, print a new card today — it is on |
| + <a href="/vault">your vault</a>.</p> |
| + </details> |
| +</section> |
| + |
| +<section data-have hidden> |
| + <h2>Approve another device</h2> |
| + <p>A device asking to join will show you an eight-character code. Type it |
| + here.</p> |
| + |
| + <form data-approve-form method="post" action="#"> |
| + <p> |
| + <label for="link-code">Code from the other device</label> |
| + <input id="link-code" name="code" type="text" autocomplete="off" |
| + autocapitalize="characters" spellcheck="false" required |
| + placeholder="XXXX-XXXX"> |
| + </p> |
| + <p><button type="submit">Look it up</button></p> |
| + </form> |
| + |
| + <div data-confirm hidden> |
| + {{/* slopbox's wording, and it is the honest version: say what approving |
| + actually hands over, not that a device "would like access". */}} |
| + <p><strong>Only approve this if you started it.</strong> Approving hands |
| + that device a copy of your Account Key. With your passphrase as well, it |
| + will be able to read everything in your vault.</p> |
| + <p> |
| + <label for="approve-passphrase">Your passphrase</label> |
| + <input id="approve-passphrase" type="password" |
| + autocomplete="current-password"> |
| + <small>Needed to unlock this device's copy of the key so it can be |
| + sealed to the other one. It is not sent anywhere.</small> |
| + </p> |
| + <p> |
| + <button type="button" data-do-approve>Approve that device</button> |
| + <button type="button" data-not-me>Not me</button> |
| + </p> |
| + </div> |
| + |
| + <p data-approve-status aria-live="polite"></p> |
| +</section> |
| + |
| +<p><a href="/vault">Back to your vault</a></p> |
| +{{end}} |
diff --git a/internal/contra/vault/links.go b/internal/contra/vault/links.go| new file mode 100644 |
| index 0000000..29f5ece |
| --- /dev/null |
| +++ b/internal/contra/vault/links.go |
| @@ -0,0 +1,220 @@ |
| +// The device handoff: DESIGN.md's rung 1, and the only route in CONTRA that |
| +// carries a secret from one device to another. |
| +// |
| +// It carries it sealed. The waiting device mints an ephemeral P-256 keypair |
| +// and posts only the point; an enrolled device seals the Account Key to that |
| +// point with ECIES; the server stores an envelope it has no key for and hands |
| +// it over once. The AAD inside names the waiting device's own public key, so |
| +// even a server that swapped one envelope for another cannot make a second |
| +// device open it. |
| +// |
| +// # Why every route here needs a session |
| +// |
| +// slopbox's equivalent leaves /start and /claim open, because its waiting |
| +// device has no account yet. CONTRA's does: a fresh browser signs in with the |
| +// email and passphrase alone — the login key comes from S_pw, never from the |
| +// Account Key — and only then finds it cannot unlock. So both ends are |
| +// already authenticated, and scoping every query to the session's account |
| +// means a stranger cannot see that a code exists, let alone spend one. |
| +// |
| +// That is what buys the short code. Its job is to tell the person which |
| +// device they are approving, not to prove who is asking; the session already |
| +// did that. |
| + |
| +package vault |
| + |
| +import ( |
| + "encoding/hex" |
| + "errors" |
| + "net/http" |
| + "time" |
| + |
| + "crypto/sha256" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/store" |
| +) |
| + |
| +// codeHashOf decodes the hash the client sends. |
| +// |
| +// The client hashes; the server never receives the code at all. slopbox posts |
| +// the code and hashes it server-side, which stores nothing but does mean the |
| +// value passes through the server's memory and any request log in front of |
| +// it. Hashing on the client costs one line and makes "the code exists only on |
| +// the two screens the person is looking at" literally true. |
| +// |
| +// Nothing is lost by it: whoever can compute the hash already knows the code. |
| +func codeHashOf(hexHash string) ([]byte, bool) { |
| + h, err := hex.DecodeString(hexHash) |
| + if err != nil || len(h) != sha256.Size { |
| + return nil, false |
| + } |
| + return h, true |
| +} |
| + |
| +// StartLink is the waiting device saying "here is my public key, here is the |
| +// hash of the code I am showing the person". |
| +// |
| +// The server does not choose the code. A server-chosen code would be a value |
| +// it knows in full for every live handoff; this way the only copies are on |
| +// the two screens a person is looking at. |
| +func (v *Vault) StartLink(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req struct { |
| + CodeHashHex string `json:"code_hash_hex"` |
| + DevicePublicHex string `json:"device_public_hex"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + codeHash, ok := codeHashOf(req.CodeHashHex) |
| + if !ok { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed code"}) |
| + return |
| + } |
| + devicePublic, err := hex.DecodeString(req.DevicePublicHex) |
| + if err != nil || len(devicePublic) != crypto.PublicKeyLen { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed device key"}) |
| + return |
| + } |
| + id, err := store.NewLinkID() |
| + if err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start"}) |
| + return |
| + } |
| + |
| + link, err := v.store.StartLink(r.Context(), id, codeHash, acct, devicePublic, time.Now().UTC()) |
| + if errors.Is(err, store.ErrTooManyLinks) { |
| + writeJSON(w, http.StatusTooManyRequests, |
| + map[string]string{"error": "too many handoffs open — finish or cancel one first"}) |
| + return |
| + } |
| + if err != nil { |
| + // A duplicate code hash lands here too. The client draws again. |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not start"}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "expires_at": link.ExpiresAt.Format(time.RFC3339), |
| + }) |
| +} |
| + |
| +// PendingLink is the enrolled device asking what a typed code refers to. It |
| +// answers with the waiting device's public key and nothing else — there is |
| +// nothing else in the row. |
| +func (v *Vault) PendingLink(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + codeHash, ok := codeHashOf(r.URL.Query().Get("h")) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + link, err := v.store.PendingLink(r.Context(), acct, codeHash, time.Now().UTC()) |
| + if err != nil { |
| + notFound(w) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "device_public_hex": hex.EncodeToString(link.DevicePublic), |
| + "approved": len(link.Sealed) > 0, |
| + }) |
| +} |
| + |
| +// ApproveLink takes the sealed envelope from an enrolled device. |
| +// |
| +// The envelope is opaque here and stays opaque. What this handler checks is |
| +// that it is long enough to be one at all — a caller that posted an empty |
| +// body would otherwise spend a live code on nothing, and the waiting device |
| +// would sit there until the code expired with no way to tell why. |
| +func (v *Vault) ApproveLink(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req struct { |
| + CodeHashHex string `json:"code_hash_hex"` |
| + SealedHex string `json:"sealed_hex"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + codeHash, ok := codeHashOf(req.CodeHashHex) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + sealed, err := hex.DecodeString(req.SealedHex) |
| + if err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed envelope"}) |
| + return |
| + } |
| + if err := v.store.ApproveLink(r.Context(), acct, codeHash, sealed, time.Now().UTC()); err != nil { |
| + notFound(w) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| + |
| +// ClaimLink is the waiting device polling. Once there is an envelope it is |
| +// handed over and the row is deleted in the same transaction, so it leaves |
| +// exactly once. |
| +func (v *Vault) ClaimLink(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + codeHash, ok := codeHashOf(r.URL.Query().Get("h")) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + sealed, err := v.store.ClaimLink(r.Context(), acct, codeHash, time.Now().UTC()) |
| + if err != nil { |
| + notFound(w) |
| + return |
| + } |
| + if len(sealed) == 0 { |
| + writeJSON(w, http.StatusOK, map[string]any{"approved": false}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "approved": true, |
| + "sealed_hex": hex.EncodeToString(sealed), |
| + }) |
| +} |
| + |
| +// CancelLink drops a code the waiting device gave up on, so closing the tab |
| +// does not leave a live handoff behind. |
| +func (v *Vault) CancelLink(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req struct { |
| + CodeHashHex string `json:"code_hash_hex"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + codeHash, ok := codeHashOf(req.CodeHashHex) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + if err := v.store.CancelLink(r.Context(), acct, codeHash); err != nil { |
| + notFound(w) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
diff --git a/internal/contratest/greps_test.go b/internal/contratest/greps_test.go| index 4d199b6..b5de733 100644 |
| --- a/internal/contratest/greps_test.go |
| +++ b/internal/contratest/greps_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -85,6 +86,12 @@ var canaries = map[string]string{ |
| "email address": "canary-person-1a7c5e@example.invalid", |
| "second email": "canary-abandoned-6f2b8d@example.invalid", |
| "passphrase": "CANARY-passphrase-correct-horse-2b9d4f", |
| + |
| + // The handoff code a person reads from one screen and types into |
| + // another. The server is given only its SHA-256, so this string must |
| + // never reach the disk — and it is exactly what a later "let's store the |
| + // code so we can show it in a list of pending devices" would put there. |
| + "handoff code": "CANARYHF", |
| } |
| // secrets is key material. None of it may ever reach the disk. |
| @@ -264,6 +271,50 @@ func writeEverything(t *testing.T, s *store.Store) secrets { |
| t.Fatalf("UpdateItem: %v", err) |
| } |
| + // A device handoff, both halves. This is the one write path in the app |
| + // that carries a secret between devices, so it gets the full canary |
| + // treatment: the code the person reads off a screen, and the Account Key |
| + // inside the envelope. |
| + // |
| + // The store is given SHA-256 of the code and never the code, which is |
| + // the property this proves — and the one a later "show a list of pending |
| + // devices with their codes" would quietly take away. |
| + device, err := crypto.DeriveP256(randBytes(t, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + devicePublic := device.PublicKey().Bytes() |
| + linkID, err := store.NewLinkID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + codeHash := store.LinkCodeHash(canaries["handoff code"]) |
| + now := time.Now().UTC() |
| + if _, err := s.StartLink(ctx, linkID, codeHash, acct.ID, devicePublic, now); err != nil { |
| + t.Fatalf("StartLink: %v", err) |
| + } |
| + |
| + envelope, err := crypto.SealTo(devicePublic, |
| + crypto.AAD(acct.ID, crypto.ObjectHandoff, devicePublic, 1), sec.accountK) |
| + if err != nil { |
| + t.Fatalf("SealTo: %v", err) |
| + } |
| + if err := s.ApproveLink(ctx, acct.ID, codeHash, envelope, now); err != nil { |
| + t.Fatalf("ApproveLink: %v", err) |
| + } |
| + |
| + // And a second, left unapproved, because a row that never reaches the |
| + // claim path is a row the sweeper is responsible for and the grep should |
| + // still see clean. |
| + strandedID, err := store.NewLinkID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.StartLink(ctx, strandedID, |
| + store.LinkCodeHash("CANARYH2"), acct.ID, devicePublic, now); err != nil { |
| + t.Fatalf("StartLink(stranded): %v", err) |
| + } |
| + |
| return sec |
| } |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index 50771e7..86fef59 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -34,6 +34,7 @@ var jsHighWater = map[string]int{ |
| "ecies.js": 132, |
| "items.js": 97, |
| "kdf.js": 129, |
| + "link.js": 131, |
| "kit.js": 203, |
| "net.js": 35, |
| "p256.js": 107, |
| @@ -43,14 +44,16 @@ var jsHighWater = map[string]int{ |
| "qrgf.js": 59, |
| "qrtables.js": 44, |
| "sealed.js": 91, |
| - "vaultkeys.js": 148, |
| + "vaultkeys.js": 184, |
| // Page controllers. Thin on purpose: every decision that matters lives in |
| // a crypto module, and these only move text between a form and one. |
| - "pages/passkey.js": 197, |
| - "pages/signin.js": 49, |
| - "pages/signup.js": 258, |
| - "pages/vault.js": 170, |
| + "pages/passkey.js": 197, |
| + "pages/signin.js": 49, |
| + "pages/device.js": 264, |
| + "pages/devicescan.js": 96, |
| + "pages/signup.js": 258, |
| + "pages/vault.js": 179, |
| } |
| func TestJSModulesStaySmall(t *testing.T) { |
diff --git a/internal/contratest/links_test.go b/internal/contratest/links_test.go| new file mode 100644 |
| index 0000000..9874283 |
| --- /dev/null |
| +++ b/internal/contratest/links_test.go |
| @@ -0,0 +1,429 @@ |
| +package contratest |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/store" |
| +) |
| + |
| +// The device handoff, end to end over real HTTP. |
| +// |
| +// This is the shape of test that found the wrap-id bug: a client that seals |
| +// against ids it minted, a server that stores what it is given, and a second |
| +// client that has to open what comes back. Anything that quietly rewrites an |
| +// identifier in the middle breaks the AAD and shows up here rather than in |
| +// production six weeks later. |
| + |
| +// waitingDevice is the fresh browser's half: an ephemeral keypair whose |
| +// scalar never leaves it, and a code it minted itself. |
| +type waitingDevice struct { |
| + scalar []byte |
| + publicKey []byte |
| + code string |
| + hashHex string |
| +} |
| + |
| +func newWaitingDevice(t *testing.T) waitingDevice { |
| + t.Helper() |
| + seed := make([]byte, 32) |
| + if _, err := rand.Read(seed); err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := crypto.DeriveP256(seed) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Eight Crockford symbols, as link.js draws them. |
| + const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" |
| + raw := make([]byte, 8) |
| + if _, err := rand.Read(raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + code := "" |
| + for _, b := range raw { |
| + code += string(alphabet[b&31]) |
| + } |
| + sum := sha256.Sum256([]byte(code)) |
| + return waitingDevice{ |
| + scalar: key.Bytes(), |
| + publicKey: key.PublicKey().Bytes(), |
| + code: code, |
| + hashHex: hex.EncodeToString(sum[:]), |
| + } |
| +} |
| + |
| +func (d waitingDevice) start(t *testing.T, h http.Handler, cookie string) { |
| + t.Helper() |
| + rec := as(t, h, cookie, http.MethodPost, "/api/link/start", map[string]string{ |
| + "code_hash_hex": d.hashHex, |
| + "device_public_hex": hex.EncodeToString(d.publicKey), |
| + }) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("link start: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +// approve is the enrolled device's half: look the code up, seal the Account |
| +// Key to the point that comes back, post the envelope. |
| +func (d waitingDevice) approve(t *testing.T, h http.Handler, cookie string, |
| + accountID, accountKey []byte) { |
| + t.Helper() |
| + var pending struct { |
| + DevicePublicHex string `json:"device_public_hex"` |
| + Approved bool `json:"approved"` |
| + } |
| + rec := as(t, h, cookie, http.MethodGet, "/api/link/pending?h="+d.hashHex, nil) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("link pending: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + decode(t, rec, &pending) |
| + |
| + devicePublic, err := hex.DecodeString(pending.DevicePublicHex) |
| + if err != nil { |
| + t.Fatalf("device key hex: %v", err) |
| + } |
| + envelope, err := crypto.SealTo(devicePublic, |
| + crypto.AAD(accountID, crypto.ObjectHandoff, devicePublic, 1), accountKey) |
| + if err != nil { |
| + t.Fatalf("SealTo: %v", err) |
| + } |
| + rec = as(t, h, cookie, http.MethodPost, "/api/link/approve", map[string]string{ |
| + "code_hash_hex": d.hashHex, |
| + "sealed_hex": hex.EncodeToString(envelope), |
| + }) |
| + if rec.Code != http.StatusNoContent { |
| + t.Fatalf("link approve: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +func (d waitingDevice) claim(t *testing.T, h http.Handler, cookie string) (bool, []byte) { |
| + t.Helper() |
| + var res struct { |
| + Approved bool `json:"approved"` |
| + SealedHex string `json:"sealed_hex"` |
| + } |
| + rec := as(t, h, cookie, http.MethodGet, "/api/link/claim?h="+d.hashHex, nil) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("link claim: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + decode(t, rec, &res) |
| + if !res.Approved { |
| + return false, nil |
| + } |
| + sealed, err := hex.DecodeString(res.SealedHex) |
| + if err != nil { |
| + t.Fatalf("sealed hex: %v", err) |
| + } |
| + return true, sealed |
| +} |
| + |
| +func TestHandoffCarriesTheAccountKey(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + device := newWaitingDevice(t) |
| + device.start(t, h, p.cookie) |
| + |
| + // Nothing to collect before an enrolled device approves, and that is a |
| + // 200 rather than an error: "keep waiting" is not a failure. |
| + if approved, _ := device.claim(t, h, p.cookie); approved { |
| + t.Fatal("a link was claimable before it was approved") |
| + } |
| + |
| + device.approve(t, h, p.cookie, p.id, p.accountKey) |
| + |
| + approved, envelope := device.claim(t, h, p.cookie) |
| + if !approved { |
| + t.Fatal("an approved link did not come back approved") |
| + } |
| + |
| + got, err := crypto.OpenFrom(device.scalar, device.publicKey, |
| + crypto.AAD(p.id, crypto.ObjectHandoff, device.publicKey, 1), envelope) |
| + if err != nil { |
| + t.Fatalf("the waiting device cannot open what the server relayed: %v", err) |
| + } |
| + if hex.EncodeToString(got) != hex.EncodeToString(p.accountKey) { |
| + t.Errorf("handoff delivered the wrong key\n got %x\n want %x", got, p.accountKey) |
| + } |
| +} |
| + |
| +// The envelope leaves exactly once. A second tab, or a reloaded page, must |
| +// get nothing — otherwise a code that was on screen for ten minutes stays |
| +// spendable for as long as anyone kept the tab open. |
| +func TestHandoffIsSingleUse(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + device := newWaitingDevice(t) |
| + device.start(t, h, p.cookie) |
| + device.approve(t, h, p.cookie, p.id, p.accountKey) |
| + |
| + if approved, _ := device.claim(t, h, p.cookie); !approved { |
| + t.Fatal("first claim did not deliver") |
| + } |
| + rec := as(t, h, p.cookie, http.MethodGet, "/api/link/claim?h="+device.hashHex, nil) |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("second claim: status %d, want 404 — the envelope was handed out twice", |
| + rec.Code) |
| + } |
| +} |
| + |
| +// A live code cannot be spent twice on the approving side either. Without |
| +// this, a stranger who somehow learned a code could overwrite the real |
| +// envelope with one the waiting device can never open, and the handoff would |
| +// die silently. |
| +func TestHandoffApprovalIsSingleUse(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + device := newWaitingDevice(t) |
| + device.start(t, h, p.cookie) |
| + device.approve(t, h, p.cookie, p.id, p.accountKey) |
| + |
| + rec := as(t, h, p.cookie, http.MethodPost, "/api/link/approve", map[string]string{ |
| + "code_hash_hex": device.hashHex, |
| + "sealed_hex": hex.EncodeToString(make([]byte, 128)), |
| + }) |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("second approval: status %d, want 404", rec.Code) |
| + } |
| + |
| + // And the original still opens, which is the thing that would actually |
| + // have been lost. |
| + approved, envelope := device.claim(t, h, p.cookie) |
| + if !approved { |
| + t.Fatal("the link stopped being claimable") |
| + } |
| + got, err := crypto.OpenFrom(device.scalar, device.publicKey, |
| + crypto.AAD(p.id, crypto.ObjectHandoff, device.publicKey, 1), envelope) |
| + if err != nil || hex.EncodeToString(got) != hex.EncodeToString(p.accountKey) { |
| + t.Errorf("a second approval clobbered the real envelope: %v", err) |
| + } |
| +} |
| + |
| +// Every link route is scoped to the session's account. This is the test that |
| +// would catch that scope being dropped in a refactor — which would turn a |
| +// forty-bit code into the only thing between a stranger and an Account Key. |
| +func TestHandoffIsInvisibleToAnotherAccount(t *testing.T) { |
| + h := newApp(t) |
| + mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b") |
| + |
| + device := newWaitingDevice(t) |
| + device.start(t, h, mine.cookie) |
| + |
| + for _, c := range []struct { |
| + what string |
| + method string |
| + path string |
| + body any |
| + }{ |
| + {"pending", http.MethodGet, "/api/link/pending?h=" + device.hashHex, nil}, |
| + {"claim", http.MethodGet, "/api/link/claim?h=" + device.hashHex, nil}, |
| + {"approve", http.MethodPost, "/api/link/approve", map[string]string{ |
| + "code_hash_hex": device.hashHex, |
| + "sealed_hex": hex.EncodeToString(make([]byte, 128)), |
| + }}, |
| + } { |
| + rec := as(t, h, theirs.cookie, c.method, c.path, c.body) |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("%s as another account: status %d, want 404", c.what, rec.Code) |
| + } |
| + } |
| + |
| + // And it is still there for the account that owns it. |
| + if rec := as(t, h, mine.cookie, http.MethodGet, |
| + "/api/link/pending?h="+device.hashHex, nil); rec.Code != http.StatusOK { |
| + t.Errorf("the owner lost their own link: status %d", rec.Code) |
| + } |
| +} |
| + |
| +// The whole point of binding the recipient's key into the AAD: an envelope |
| +// relayed to a different device does not open, even by a server that stored |
| +// it and chose who to hand it to. |
| +func TestHandoffEnvelopeIsBoundToOneDevice(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + device := newWaitingDevice(t) |
| + device.start(t, h, p.cookie) |
| + device.approve(t, h, p.cookie, p.id, p.accountKey) |
| + _, envelope := device.claim(t, h, p.cookie) |
| + |
| + other := newWaitingDevice(t) |
| + if _, err := crypto.OpenFrom(other.scalar, other.publicKey, |
| + crypto.AAD(p.id, crypto.ObjectHandoff, other.publicKey, 1), envelope); err == nil { |
| + t.Error("an envelope opened on a device it was not sealed to") |
| + } |
| +} |
| + |
| +// An account cannot leave unbounded codes lying around. Without a cap, a |
| +// stolen session could open codes until one collided with something a person |
| +// was about to approve. |
| +func TestHandoffCapsLiveLinks(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + for i := 0; i < 5; i++ { |
| + newWaitingDevice(t).start(t, h, p.cookie) |
| + } |
| + extra := newWaitingDevice(t) |
| + rec := as(t, h, p.cookie, http.MethodPost, "/api/link/start", map[string]string{ |
| + "code_hash_hex": extra.hashHex, |
| + "device_public_hex": hex.EncodeToString(extra.publicKey), |
| + }) |
| + if rec.Code != http.StatusTooManyRequests { |
| + t.Errorf("sixth live link: status %d, want 429", rec.Code) |
| + } |
| +} |
| + |
| +// A refused device key is worth its own test: a server that accepted anything |
| +// 65 bytes long would let a caller store a point that is not on the curve, |
| +// and the failure would surface as an unopenable envelope much later. |
| +func TestHandoffRefusesAMalformedDeviceKey(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + d := newWaitingDevice(t) |
| + |
| + for _, c := range []struct { |
| + what string |
| + key string |
| + }{ |
| + {"too short", hex.EncodeToString(make([]byte, 33))}, |
| + {"not hex", "nonsense"}, |
| + } { |
| + rec := as(t, h, p.cookie, http.MethodPost, "/api/link/start", map[string]string{ |
| + "code_hash_hex": d.hashHex, |
| + "device_public_hex": c.key, |
| + }) |
| + if rec.Code != http.StatusBadRequest { |
| + t.Errorf("%s device key: status %d, want 400", c.what, rec.Code) |
| + } |
| + } |
| +} |
| + |
| +// Cancelling is what a closed tab should have done. A code left live for ten |
| +// minutes after its device is gone is a code somebody can still be talked |
| +// into approving. |
| +func TestHandoffCancelRemovesTheCode(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + device := newWaitingDevice(t) |
| + device.start(t, h, p.cookie) |
| + if rec := as(t, h, p.cookie, http.MethodPost, "/api/link/cancel", |
| + map[string]string{"code_hash_hex": device.hashHex}); rec.Code != http.StatusNoContent { |
| + t.Fatalf("cancel: status %d", rec.Code) |
| + } |
| + if rec := as(t, h, p.cookie, http.MethodGet, |
| + "/api/link/pending?h="+device.hashHex, nil); rec.Code != http.StatusNotFound { |
| + t.Errorf("after cancel: status %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +// The sweeper. An expired link is already inert — every query carries the |
| +// expiry — so what this protects is not correctness but the table not |
| +// becoming a log of when each account enrolled a device. |
| +// |
| +// The clock is passed in rather than waited for, so this is deterministic |
| +// and takes no time. |
| +func TestSweepRemovesExpiredLinks(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + device := newWaitingDevice(t) |
| + device.start(t, h, p.cookie) |
| + |
| + s := store.New(gdb) |
| + ctx := context.Background() |
| + |
| + // Nothing to do while it is live. |
| + if n, err := s.SweepLinks(ctx, time.Now().UTC()); err != nil || n != 0 { |
| + t.Fatalf("swept %d live links (err %v), want 0", n, err) |
| + } |
| + |
| + // One TTL later, it goes. |
| + later := time.Now().UTC().Add(store.LinkTTL + time.Minute) |
| + n, err := s.SweepLinks(ctx, later) |
| + if err != nil { |
| + t.Fatalf("SweepLinks: %v", err) |
| + } |
| + if n != 1 { |
| + t.Errorf("swept %d expired links, want 1", n) |
| + } |
| + if n, _ := s.SweepLinks(ctx, later); n != 0 { |
| + t.Errorf("a second sweep removed %d rows, want 0", n) |
| + } |
| +} |
| + |
| +// An expired code is refused even before the sweeper reaches it, because the |
| +// expiry is in the query. That ordering matters: if it were only the sweeper, |
| +// a code would stay live for up to five minutes past its deadline. |
| +func TestExpiredLinkIsRefusedBeforeItIsSwept(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + device := newWaitingDevice(t) |
| + device.start(t, h, p.cookie) |
| + |
| + s := store.New(gdb) |
| + later := time.Now().UTC().Add(store.LinkTTL + time.Minute) |
| + codeHash, err := hex.DecodeString(device.hashHex) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.PendingLink(context.Background(), p.id, codeHash, later); err == nil { |
| + t.Error("an expired link was still resolvable") |
| + } |
| +} |
| + |
| +// The ladder's one rule: no screen dead-ends. This asserts the page really |
| +// does offer every rung and a way back, because "we built three rungs" and |
| +// "all three are on the screen" are different facts, and only the second is |
| +// what a person stranded on a new laptop needs. |
| +func TestDeviceLadderOffersEveryRung(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + body := as(t, h, p.cookie, http.MethodGet, "/device", nil).Body.String() |
| + for _, want := range []string{ |
| + `data-rung="handoff"`, // from another signed-in device |
| + `data-rung="scan"`, // from the card's QR, where the browser can |
| + `data-rung="type"`, // typed off the card, which always works |
| + `href="/vault"`, // and a way back |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("the device page is missing %s — a rung, or the way out", want) |
| + } |
| + } |
| +} |
| + |
| +// A signed-out browser must not be able to open a handoff, or the code stops |
| +// being a confirmation and becomes the only thing in the way. |
| +func TestLinkRoutesRefuseAStranger(t *testing.T) { |
| + h := newApp(t) |
| + d := newWaitingDevice(t) |
| + for _, c := range []struct { |
| + method string |
| + path string |
| + }{ |
| + {http.MethodPost, "/api/link/start"}, |
| + {http.MethodGet, "/api/link/pending?h=" + d.hashHex}, |
| + {http.MethodGet, "/api/link/claim?h=" + d.hashHex}, |
| + {http.MethodPost, "/api/link/approve"}, |
| + {http.MethodGet, "/device"}, |
| + } { |
| + rec := as(t, h, "", c.method, c.path, map[string]string{}) |
| + if rec.Code == http.StatusOK || rec.Code == http.StatusNoContent { |
| + t.Errorf("%s %s served a stranger: status %d", c.method, c.path, rec.Code) |
| + } |
| + } |
| +} |
diff --git a/internal/contratest/storage_test.go b/internal/contratest/storage_test.go| index 29ca835..5af04ea 100644 |
| --- a/internal/contratest/storage_test.go |
| +++ b/internal/contratest/storage_test.go |
| @@ -117,6 +117,7 @@ func TestSchemaIsTheDeclaredSet(t *testing.T) { |
| "items": "one opaque blob per item, quantised into buckets", |
| "login_challenges": "live sign-in state: a random nonce, deleted when spent", |
| "signup_sessions": "an address proved but no account yet: a blind index and a token hash", |
| + "device_links": "a live handoff: a code hash, an ephemeral point, and an envelope the server has no key for", |
| } |
| found := map[string]bool{} |