web: the vault console — items sealed and opened in the browser, nowhere else
Signing in is not unlocking, and the difference is the whole design. A session proves who you are; the keys come from a wrap the server cannot open. So the vault asks for the passphrase again and does the work in the browser, against the Account Key this device has cached — DESIGN.md's everyday case, where a device that already holds the card needs only what you remember. The unlock chain is the one from "The shape", end to end: Argon2id to S_pw, HKDF salted with the Account Key to KEK_pw, the KCV checked *before* any decryption is attempted, then RK, then the account keyset, then K_vault. Checking the KCV first is what lets a wrong Account Key be reported as a wrong Account Key rather than as a corrupted vault; AES-GCM is not key-committing, so without it those two are the same error, and the unlock paths need to tell them apart constantly. An item is one blob. Title, URL, username, password and notes go inside it, and every field is present whether or not it was filled in — so the ciphertext does not publish which fields somebody used. Sizes are quantised into 256-byte buckets, enforced now at three places: the client pads, the API refuses, and the store refuses. The server side is a sealed-blob store with an owner filter and nothing else. The filter lives in the SQL rather than in a check beside it, so a later refactor cannot turn a read into an IDOR by dropping a line, and a vault that is not yours answers 404 — a 403 would confirm the row, which is a fact about somebody else's vault and therefore a fact we do not have. It does not use sessions.UserID. That returns an int64 and CONTRA's subject is a hex account id, so it would answer (0, false) — and dropping its ok, which is exactly the mistake the framework's own notes warn about, would scope every query to a nonexistent account and quietly return nothing. Three layers of test, each proving something the others cannot. The node suite runs the item lifecycle against a fake network and asserts that nothing readable reaches it — storage and network are passed in, never reached for, which is what makes that assertion possible at all. The Go suite gives two accounts and proves neither can list, write into or delete from the other's vault. And the browser walk drives the real thing in a real engine: sign up, unlock, add, reload, watch the item survive, edit, delete — the only test that proves the two halves agree over a real session with the real localStorage the Account Key cache depends on. Every node is built rather than assigned. No innerHTML on anything that came out of a vault: an item's title is whatever a person or an importer put there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 files changed,
+1613
−44
Makefile+1 −1internal/contra/app.go+12 −0internal/contra/migrations/0003_alter_accounts.sql+10 −0internal/contra/migrations/schema.sql+2 −2internal/contra/model/model.go+25 −0internal/contra/static/crypto/account.js+22 −6internal/contra/static/crypto/items.js+97 −0internal/contra/static/crypto/net.js+35 −0internal/contra/static/crypto/p256.js+13 −0internal/contra/static/crypto/vaultkeys.js+148 −0internal/contra/static/pages/auth.js+4 −1internal/contra/static/pages/vault.js+170 −0internal/contra/store/store.go+107 −16internal/contra/templates/vault.html+19 −5internal/contra/vault/vault.go+343 −0internal/contratest/browser_test.go+81 −0internal/contratest/greps_test.go+22 −4internal/contratest/jsmodules_test.go+14 −9internal/contratest/vault_test.go+306 −0test/items.test.mjs+182 −0
Intent
diff --git a/Makefile b/Makefile| index 87c8413..88f413b 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -96,7 +96,7 @@ test-js: |
| echo " This gate is not optional -- it is what proves the browser's"; \ |
| echo " crypto matches the server's. Install node, do not skip it."; \ |
| exit 1; } |
| - node --test test/crypto.test.mjs |
| + node --test test/*.test.mjs |
| vet: |
| go vet ./... |
diff --git a/internal/contra/app.go b/internal/contra/app.go| index fc55bfd..841b07b 100644 |
| --- a/internal/contra/app.go |
| +++ b/internal/contra/app.go |
| @@ -13,6 +13,7 @@ import ( |
| "github.com/carlosframework/rastrillo/sessions" |
| "github.com/carlosframework/contra/internal/contra/auth" |
| + "github.com/carlosframework/contra/internal/contra/vault" |
| ) |
| // Config is what App needs from the outside. Pepper is separated from the |
| @@ -87,9 +88,20 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) { |
| r.Post("/auth/verify", ident.Verify) |
| r.Post("/auth/signout", ident.SignOut) |
| + items := vault.New(d.G) |
| r.Group(func(r chi.Router) { |
| r.Use(sess.Require) |
| r.Get("/vault", a.vault) |
| + |
| + // The item API. Every one of these hands out or takes in ciphertext, |
| + // scoped to the session's account inside the SQL rather than beside |
| + // it, and answers 404 for a row that is not yours. |
| + r.Get("/api/bootstrap", items.Bootstrap) |
| + r.Post("/api/provision", items.Provision) |
| + r.Get("/api/items", items.Items) |
| + r.Post("/api/items", items.CreateItem) |
| + r.Put("/api/items", items.UpdateItem) |
| + r.Post("/api/items/delete", items.DeleteItem) |
| }) |
| mux := http.NewServeMux() |
diff --git a/internal/contra/migrations/0003_alter_accounts.sql b/internal/contra/migrations/0003_alter_accounts.sql| new file mode 100644 |
| index 0000000..81dfee1 |
| --- /dev/null |
| +++ b/internal/contra/migrations/0003_alter_accounts.sql |
| @@ -0,0 +1,10 @@ |
| +ALTER TABLE `accounts` ADD `public_key` blob; |
| + |
| +ALTER TABLE `accounts` ADD `keyset_nonce` blob; |
| + |
| +ALTER TABLE `accounts` ADD `keyset_ciphertext` blob; |
| + |
| +ALTER TABLE `vaults` ADD `keyset_nonce` blob; |
| + |
| +ALTER TABLE `vaults` ADD `keyset_ciphertext` blob; |
| + |
diff --git a/internal/contra/migrations/schema.sql b/internal/contra/migrations/schema.sql| index d386bed..020e3d2 100644 |
| --- a/internal/contra/migrations/schema.sql |
| +++ b/internal/contra/migrations/schema.sql |
| @@ -1,7 +1,7 @@ |
| -- Generated by rastrillo migration generate; DO NOT EDIT. |
| -- The schema every migration in this directory adds up to. |
| -CREATE TABLE `accounts` (`id` blob,`email_index` blob NOT NULL,`kdf_salt` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| +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 `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`)); |
| @@ -11,7 +11,7 @@ CREATE TABLE `login_keys` (`id` blob,`account_id` blob NOT NULL,`branch` text NO |
| CREATE TABLE `vault_members` (`id` blob,`vault_id` blob NOT NULL,`account_id` blob NOT NULL,`ephemeral_public_key` blob NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| -CREATE TABLE `vaults` (`id` blob,`account_id` blob NOT NULL,`drop_box_public_key` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| +CREATE TABLE `vaults` (`id` blob,`account_id` blob NOT NULL,`drop_box_public_key` blob NOT NULL,`created_at` datetime, `keyset_nonce` blob, `keyset_ciphertext` blob,PRIMARY KEY (`id`)); |
| CREATE TABLE `wraps` (`id` blob,`account_id` blob NOT NULL,`kind` text NOT NULL,`format_version` integer NOT NULL,`version` integer NOT NULL,`kdf_params` text NOT NULL,`cred_salt` blob,`kcv` blob NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
diff --git a/internal/contra/model/model.go b/internal/contra/model/model.go| index 1dd122d..a5e5ca9 100644 |
| --- a/internal/contra/model/model.go |
| +++ b/internal/contra/model/model.go |
| @@ -50,6 +50,18 @@ type Account struct { |
| // with a deterministic fake so account existence does not leak from it. |
| KDFSalt []byte `gorm:"type:blob;not null"` |
| + // PublicKey is the account's P-256 public half — what another member |
| + // seals a shared vault keyset to. The private half lives inside the |
| + // account keyset below and never reaches the server. |
| + PublicKey []byte `gorm:"type:blob"` |
| + |
| + // The account keyset — d_acct and K_meta — sealed under RK, with AAD |
| + // (account_id, "account.keyset", account_id, version). It is the layer |
| + // between the root key and everything a vault needs, so that rotating a |
| + // credential does not mean re-encrypting every item. |
| + KeysetNonce []byte `gorm:"type:blob"` |
| + KeysetCiphertext []byte `gorm:"type:blob"` |
| + |
| CreatedAt time.Time |
| } |
| @@ -132,6 +144,19 @@ type Vault struct { |
| // key in memory. The private half never leaves the members. |
| DropBoxPublicKey []byte `gorm:"type:blob;not null"` |
| + // The vault keyset — K_vault and the drop-box private half — sealed for |
| + // the owner, AAD (account_id, "vault.keyset", vault_id, version). |
| + // |
| + // Sealed directly under RK, which is the right shape for a vault with one |
| + // member and the wrong shape for a shared one. Sharing wraps the same |
| + // keyset to each member's account public key by ephemeral-static ECDH, |
| + // into the vault_members rows that already exist for it — that is web/8 |
| + // (discussion 14), and it will decide whether the owner's copy moves |
| + // there too. Recorded here so the next person meets a decision rather |
| + // than an inconsistency. |
| + KeysetNonce []byte `gorm:"type:blob"` |
| + KeysetCiphertext []byte `gorm:"type:blob"` |
| + |
| CreatedAt time.Time |
| } |
diff --git a/internal/contra/static/crypto/account.js b/internal/contra/static/crypto/account.js| index bbe3195..c465908 100644 |
| --- a/internal/contra/static/crypto/account.js |
| +++ b/internal/contra/static/crypto/account.js |
| @@ -14,6 +14,7 @@ import { |
| } from "./kdf.js"; |
| import { deriveP256, signChallenge } from "./p256.js"; |
| import { aad, seal, OBJECT_WRAP_PW, OBJECT_WRAP_RC } from "./sealed.js"; |
| +import { accountKeys, provision } from "./vaultkeys.js"; |
| const CHALLENGE_CONTEXT = "contra/v1/login-challenge"; |
| @@ -57,7 +58,7 @@ async function makeWrap(kind, kek, rk, accountId, kdfParams, credSalt) { |
| // Emergency Kit — the ONLY time either is ever available. The recovery code |
| // is not stored anywhere, by anyone, including us: only its wrap is, so this |
| // return value is the single moment it exists outside the user's hands. |
| -export async function signup(email, passphrase, onProgress = () => {}) { |
| +export async function signup(email, passphrase, storage, net, onProgress = () => {}) { |
| const { salt_hex: saltHex, kdf_params: kdfParams } = |
| await postJSON("/auth/salt", { email }); |
| @@ -112,7 +113,18 @@ export async function signup(email, passphrase, onProgress = () => {}) { |
| wraps, |
| }); |
| - return { accountId, accountKey, recoveryCode, rk }; |
| + // Sign in to get a session, then provision the account keyset and the first |
| + // vault — both sealed here, both posted as ciphertext. |
| + onProgress("opening your vault"); |
| + await signIn(email, passphrase, () => {}, sPW); |
| + const vault = await provision(net, accountId, rk); |
| + |
| + // Cache the Account Key for this device. From here the passphrase alone |
| + // unlocks on this browser; a fresh one asks for the card. DESIGN.md's |
| + // ladder — handoff, scan, type — is web/5. |
| + accountKeys(storage).set(toHex(accountId), accountKey); |
| + |
| + return { accountId, accountKey, recoveryCode, rk, vault }; |
| } |
| // signIn runs the ceremony: ask for a challenge, stretch, derive, sign. |
| @@ -120,15 +132,19 @@ export async function signup(email, passphrase, onProgress = () => {}) { |
| // The passphrase is never sent. What crosses the wire is a signature over |
| // bytes the server chose, which is why a compromised server learns nothing it |
| // could replay. |
| -export async function signIn(email, passphrase, onProgress = () => {}) { |
| +export async function signIn(email, passphrase, onProgress = () => {}, knownSPW = null) { |
| const { challenge_hex: challengeHex, salt_hex: saltHex, kdf_params: kdfParams } = |
| await postJSON("/auth/challenge", { email, branch: "pw" }); |
| const params = checkParams(JSON.parse(kdfParams)); |
| - onProgress("stretching your passphrase"); |
| - const sPW = argon2id( |
| - new TextEncoder().encode(passphrase), fromHex(saltHex), params, 32); |
| + // Signup has already paid for this once; making it pay twice would double |
| + // the slowest thing in the product for no security at all. |
| + let sPW = knownSPW; |
| + if (!sPW) { |
| + onProgress("stretching your passphrase"); |
| + sPW = argon2id(new TextEncoder().encode(passphrase), fromHex(saltHex), params, 32); |
| + } |
| onProgress("proving it is you"); |
| const lkPW = await deriveP256(await hkdf(sPW, null, LABEL_LOGIN, 32)); |
diff --git a/internal/contra/static/crypto/items.js b/internal/contra/static/crypto/items.js| new file mode 100644 |
| index 0000000..da66539 |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/items.js |
| @@ -0,0 +1,97 @@ |
| +// Items: sealed in the browser, opened in the browser, and nowhere else. |
| +// |
| +// An item is one blob. Title, URL, username, password and notes all go |
| +// inside it, because separate fields — even sealed ones — would publish which |
| +// fields an item has and how long each is. The list you see is built from |
| +// items this browser has already opened; the server has never seen one. |
| +// |
| +// The network is passed in rather than reached for, so tests hand in a fake |
| +// and this module has no opinion about fetch. |
| + |
| +import { fromHex, randomBytes, toHex } from "./bytes.js"; |
| +import { aad, open, pad, seal, unpad, OBJECT_ITEM } from "./sealed.js"; |
| + |
| +// FIELDS is the shape of an item body. Everything here is inside the |
| +// ciphertext; adding a field costs nothing at rest and leaks nothing. |
| +export const FIELDS = ["title", "url", "username", "password", "notes"]; |
| + |
| +function body(fields) { |
| + const out = {}; |
| + for (const f of FIELDS) out[f] = typeof fields[f] === "string" ? fields[f] : ""; |
| + return out; |
| +} |
| + |
| +// sealItem quantises into 256-byte buckets before sealing, so the bucket |
| +// cannot be read for item-length fingerprints. The store refuses a ciphertext |
| +// that is not bucket-aligned, so this is a control on both sides rather than |
| +// a convention on one. |
| +export async function sealItem(vaultId, kVault, itemId, version, fields) { |
| + const nonce = randomBytes(12); |
| + const plaintext = pad(new TextEncoder().encode(JSON.stringify(body(fields)))); |
| + const ciphertext = await seal( |
| + kVault, nonce, aad(vaultId, OBJECT_ITEM, itemId, version), plaintext); |
| + return { nonce_hex: toHex(nonce), ciphertext_hex: toHex(ciphertext) }; |
| +} |
| + |
| +export async function openItem(vaultId, kVault, itemId, version, sealed) { |
| + const plaintext = unpad(await open( |
| + kVault, fromHex(sealed.nonce_hex), |
| + aad(vaultId, OBJECT_ITEM, itemId, version), |
| + fromHex(sealed.ciphertext_hex))); |
| + return body(JSON.parse(new TextDecoder().decode(plaintext))); |
| +} |
| + |
| +// Items is the vault's item list as the console works with it: opened once on |
| +// load, kept in memory, never written back to disk unsealed. |
| +export function items(net, vault) { |
| + return { |
| + async list() { |
| + const { items: rows } = await net.get(`/api/items?vault=${vault.idHex}`); |
| + const out = []; |
| + for (const row of rows) { |
| + const id = fromHex(row.item_id_hex); |
| + out.push({ |
| + id, |
| + idHex: row.item_id_hex, |
| + version: row.version, |
| + fields: await openItem(vault.id, vault.kVault, id, row.version, row), |
| + }); |
| + } |
| + return out; |
| + }, |
| + |
| + async create(fields) { |
| + const id = randomBytes(16); |
| + const sealed = await sealItem(vault.id, vault.kVault, id, 1, fields); |
| + await net.post("/api/items", { |
| + vault_id_hex: vault.idHex, |
| + item_id_hex: toHex(id), |
| + version: 1, |
| + ...sealed, |
| + }); |
| + return { id, idHex: toHex(id), version: 1, fields: body(fields) }; |
| + }, |
| + |
| + // Editing bumps the version, and the version is in the AAD — so a server |
| + // that served an old ciphertext back under a new number would produce a |
| + // blob that does not open, rather than one that quietly reverts an edit. |
| + async update(item, fields) { |
| + const version = item.version + 1; |
| + const sealed = await sealItem(vault.id, vault.kVault, item.id, version, fields); |
| + await net.put("/api/items", { |
| + vault_id_hex: vault.idHex, |
| + item_id_hex: item.idHex, |
| + version, |
| + ...sealed, |
| + }); |
| + return { ...item, version, fields: body(fields) }; |
| + }, |
| + |
| + async remove(item) { |
| + await net.post("/api/items/delete", { |
| + vault_id_hex: vault.idHex, |
| + item_id_hex: item.idHex, |
| + }); |
| + }, |
| + }; |
| +} |
diff --git a/internal/contra/static/crypto/net.js b/internal/contra/static/crypto/net.js| new file mode 100644 |
| index 0000000..481ea94 |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/net.js |
| @@ -0,0 +1,35 @@ |
| +// The network, as one small thing that can be replaced. |
| +// |
| +// Every module that talks to the server takes one of these rather than |
| +// calling fetch, so a unit test hands in a fake and never opens a socket. |
| +// That is the JS discipline this repo enforces: storage and network passed |
| +// in, never reached for. |
| + |
| +export class RequestError extends Error { |
| + constructor(message, status) { |
| + super(message); |
| + this.status = status; |
| + } |
| +} |
| + |
| +async function send(method, path, body) { |
| + const init = { method, headers: {} }; |
| + if (body !== undefined) { |
| + init.headers["Content-Type"] = "application/json"; |
| + init.body = JSON.stringify(body); |
| + } |
| + const res = await fetch(path, init); |
| + if (res.status === 204) return null; |
| + |
| + const data = await res.json().catch(() => ({})); |
| + if (!res.ok) { |
| + throw new RequestError(data.error || `${method} ${path} failed`, res.status); |
| + } |
| + return data; |
| +} |
| + |
| +export const net = { |
| + get: (path) => send("GET", path), |
| + post: (path, body) => send("POST", path, body ?? {}), |
| + put: (path, body) => send("PUT", path, body ?? {}), |
| +}; |
diff --git a/internal/contra/static/crypto/p256.js b/internal/contra/static/crypto/p256.js| index 63458e8..1c26b78 100644 |
| --- a/internal/contra/static/crypto/p256.js |
| +++ b/internal/contra/static/crypto/p256.js |
| @@ -84,6 +84,19 @@ export async function deriveP256(seed) { |
| throw new Error("contra: no valid P-256 scalar in 256 candidates"); |
| } |
| +// newP256 generates a fresh keypair and returns the raw scalar alongside the |
| +// SEC1 public point — for d_acct and for a vault's drop box, both of which |
| +// are random rather than derived from anything. |
| +export async function newP256() { |
| + const pair = await crypto.subtle.generateKey( |
| + { name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); |
| + const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey); |
| + return { |
| + scalar: b64urlToBytes(jwk.d), |
| + publicKey: concat(new Uint8Array([0x04]), b64urlToBytes(jwk.x), b64urlToBytes(jwk.y)), |
| + }; |
| +} |
| + |
| // signChallenge produces the ECDSA-SHA-256 signature the server verifies |
| // against the stored public key. WebCrypto emits the raw r||s form, which is |
| // what the Go side expects — not the ASN.1 wrapper. |
diff --git a/internal/contra/static/crypto/vaultkeys.js b/internal/contra/static/crypto/vaultkeys.js| new file mode 100644 |
| index 0000000..430c26d |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/vaultkeys.js |
| @@ -0,0 +1,148 @@ |
| +// Unlocking: passphrase and Account Key in, K_vault out. |
| +// |
| +// Signing in and unlocking are different things, and the difference is the |
| +// whole design. Sign-in is a signature the server can verify; unlocking is |
| +// opening a wrap the server cannot. A session proves who you are. It does |
| +// not, and must never, hand you a key. |
| +// |
| +// The chain, from DESIGN.md's "The shape": |
| +// |
| +// passphrase --Argon2id(salt_pw)--> S_pw |
| +// S_pw --HKDF(salt=account_key, "contra/v1/kek")--> KEK_pw |
| +// KEK_pw --AES-GCM--> RK |
| +// RK --AES-GCM--> account keyset { d_acct, K_meta } |
| +// RK --AES-GCM--> vault keyset { K_vault, drop-box private half } |
| +// |
| +// Storage and network are passed in, never reached for, so a test can hand |
| +// in fakes and so this module has no opinion about localStorage. |
| + |
| +import { equal, fromHex, randomBytes, toHex } from "./bytes.js"; |
| +import { argon2id } from "./argon2id.js"; |
| +import { checkParams, hkdf, kcv, LABEL_KEK } from "./kdf.js"; |
| +import { newP256 } from "./p256.js"; |
| +import { aad, open, seal, OBJECT_WRAP_PW } from "./sealed.js"; |
| + |
| +export const OBJECT_ACCOUNT_KEYSET = "account.keyset"; |
| +export const OBJECT_VAULT_KEYSET = "vault.keyset"; |
| + |
| +export class LockedError extends Error {} |
| +export class NoAccountKeyError extends LockedError {} |
| + |
| +// accountKeyStore wraps whatever key-value store the caller hands in, so the |
| +// Account Key's cache is one named thing rather than localStorage calls |
| +// scattered through the app. |
| +// |
| +// DESIGN.md: once a device holds the Account Key it is cached locally, and |
| +// from then on the passphrase alone unlocks that device. This is that cache, |
| +// and it is per-device by construction — nothing syncs it. |
| +export function accountKeys(storage) { |
| + const key = (accountIdHex) => `contra/v1/account-key/${accountIdHex}`; |
| + return { |
| + get(accountIdHex) { |
| + const v = storage.getItem(key(accountIdHex)); |
| + return v ? fromHex(v) : null; |
| + }, |
| + set(accountIdHex, accountKey) { |
| + storage.setItem(key(accountIdHex), toHex(accountKey)); |
| + }, |
| + forget(accountIdHex) { |
| + storage.removeItem(key(accountIdHex)); |
| + }, |
| + }; |
| +} |
| + |
| +// unlock opens the passphrase wrap and returns the keys the session needs. |
| +// |
| +// The KCV is checked before any decryption is attempted, so a wrong Account |
| +// Key is reported as a wrong Account Key rather than as a corrupted vault. |
| +// 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) { |
| + 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"); |
| + |
| + const params = checkParams(JSON.parse(wrap.kdf_params)); |
| + const sPW = argon2id( |
| + new TextEncoder().encode(passphrase), fromHex(bootstrap.salt_hex), params, 32); |
| + const kekPW = await hkdf(sPW, accountKey, LABEL_KEK, 32); |
| + |
| + if (!equal(await kcv(kekPW), fromHex(wrap.kcv_hex))) { |
| + throw new LockedError( |
| + "contra: that passphrase and Account Key do not open this account"); |
| + } |
| + |
| + const rk = await open( |
| + kekPW, fromHex(wrap.nonce_hex), |
| + aad(accountId, wrap.kind, fromHex(wrap.wrap_id_hex), wrap.version), |
| + fromHex(wrap.ciphertext_hex)); |
| + |
| + const accountKeyset = JSON.parse(new TextDecoder().decode(await open( |
| + rk, fromHex(bootstrap.keyset.nonce_hex), |
| + aad(accountId, OBJECT_ACCOUNT_KEYSET, accountId, 1), |
| + fromHex(bootstrap.keyset.ciphertext_hex)))); |
| + |
| + const vaults = []; |
| + for (const v of bootstrap.vaults) { |
| + const vaultId = fromHex(v.vault_id_hex); |
| + const keyset = JSON.parse(new TextDecoder().decode(await open( |
| + rk, fromHex(v.keyset.nonce_hex), |
| + aad(accountId, OBJECT_VAULT_KEYSET, vaultId, 1), |
| + fromHex(v.keyset.ciphertext_hex)))); |
| + vaults.push({ id: vaultId, idHex: v.vault_id_hex, kVault: fromHex(keyset.k_vault) }); |
| + } |
| + |
| + return { accountId, rk, accountKeyset, vaults }; |
| +} |
| + |
| +// newAccountKeyset and newVaultKeyset are what signup seals. Both are sealed |
| +// under RK with an AAD naming what they are, so one can never be opened as |
| +// the other. |
| +export async function newAccountKeyset(accountId, rk, dAcct, kMeta) { |
| + const nonce = randomBytes(12); |
| + const body = new TextEncoder().encode( |
| + JSON.stringify({ d_acct: toHex(dAcct), k_meta: toHex(kMeta) })); |
| + return { |
| + nonce_hex: toHex(nonce), |
| + ciphertext_hex: toHex(await seal( |
| + rk, nonce, aad(accountId, OBJECT_ACCOUNT_KEYSET, accountId, 1), body)), |
| + }; |
| +} |
| + |
| +export async function newVaultKeyset(accountId, vaultId, rk, kVault, dropBoxPrivate) { |
| + const nonce = randomBytes(12); |
| + const body = new TextEncoder().encode(JSON.stringify({ |
| + name: "Personal", |
| + k_vault: toHex(kVault), |
| + drop_box_private: toHex(dropBoxPrivate), |
| + })); |
| + return { |
| + nonce_hex: toHex(nonce), |
| + ciphertext_hex: toHex(await seal( |
| + rk, nonce, aad(accountId, OBJECT_VAULT_KEYSET, vaultId, 1), body)), |
| + }; |
| +} |
| + |
| +// provision is the second half of signup: the account keyset and the first |
| +// vault, both sealed under RK in the browser and posted as ciphertext. |
| +// |
| +// It is separate from signup because it needs a session, and signup ends |
| +// before one exists — a keyset posted without one would be a write nobody had |
| +// proved they were entitled to make. |
| +export async function provision(net, accountId, rk) { |
| + const acct = await newP256(); // d_acct, and the public half others seal to |
| + const dropBox = await newP256(); // vP_j, for captures with no key in memory |
| + const kMeta = randomBytes(32); |
| + const kVault = randomBytes(32); |
| + const vaultId = randomBytes(16); |
| + |
| + await net.post("/api/provision", { |
| + public_key_hex: toHex(acct.publicKey), |
| + account_keyset: await newAccountKeyset(accountId, rk, acct.scalar, kMeta), |
| + vault_id_hex: toHex(vaultId), |
| + drop_box_public_key_hex: toHex(dropBox.publicKey), |
| + vault_keyset: await newVaultKeyset(accountId, vaultId, rk, kVault, dropBox.scalar), |
| + }); |
| + |
| + return { id: vaultId, idHex: toHex(vaultId), kVault }; |
| +} |
diff --git a/internal/contra/static/pages/auth.js b/internal/contra/static/pages/auth.js| index ede27e5..1349cd2 100644 |
| --- a/internal/contra/static/pages/auth.js |
| +++ b/internal/contra/static/pages/auth.js |
| @@ -7,6 +7,7 @@ |
| import { signIn, signup } from "../crypto/account.js"; |
| import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js"; |
| +import { net } from "../crypto/net.js"; |
| function el(tag, attrs = {}, ...children) { |
| const node = document.createElement(tag); |
| @@ -58,6 +59,7 @@ function showKit(form, accountKeyPrinted, recoveryPrinted) { |
| "passphrase, the data is gone — there is no reset link and no way for " + |
| "us to let you back in."), |
| el("button", { type: "button", "data-print": "" }, "Print both"), |
| + el("p", {}, el("a", { href: "/vault" }, "Continue to your vault")), |
| ); |
| box.querySelector("[data-print]").addEventListener("click", () => window.print()); |
| } |
| @@ -75,7 +77,8 @@ function wireSignup(form) { |
| } |
| busy(form, true); |
| try { |
| - const kit = await signup(email, passphrase, (m) => status(form, m + "…")); |
| + const kit = await signup(email, passphrase, window.localStorage, net, |
| + (m) => status(form, m + "…")); |
| showKit(form, |
| await formatCode(TAG_ACCOUNT_KEY, kit.accountKey), |
| await formatCode(TAG_RECOVERY, kit.recoveryCode)); |
diff --git a/internal/contra/static/pages/vault.js b/internal/contra/static/pages/vault.js| new file mode 100644 |
| index 0000000..f69c8bb |
| --- /dev/null |
| +++ b/internal/contra/static/pages/vault.js |
| @@ -0,0 +1,170 @@ |
| +// The vault console: unlock, then list, create, edit and delete items. |
| +// |
| +// Client-shape by force. Sealing has to happen where the keys are, so a |
| +// server-rendered vault is not a thing that can exist — the server has never |
| +// seen a title and could not render one if it wanted to. |
| +// |
| +// Every node here is built rather than assigned: no innerHTML, ever, on |
| +// content that came out of a vault. That is not caution about our own data, |
| +// it is that an item's title is whatever a person or an importer put there. |
| + |
| +import { net } from "../crypto/net.js"; |
| +import { items as itemStore } from "../crypto/items.js"; |
| +import { accountKeys, unlock } from "../crypto/vaultkeys.js"; |
| +import { FIELDS } from "../crypto/items.js"; |
| + |
| +function el(tag, attrs = {}, ...children) { |
| + const node = document.createElement(tag); |
| + for (const [k, v] of Object.entries(attrs)) { |
| + if (k === "class") node.className = v; |
| + else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); |
| + else node.setAttribute(k, v); |
| + } |
| + for (const c of children) { |
| + if (c == null) continue; |
| + node.append(typeof c === "string" ? document.createTextNode(c) : c); |
| + } |
| + return node; |
| +} |
| + |
| +const LABELS = { |
| + title: "Name", url: "Website", username: "Username", |
| + password: "Password", notes: "Notes", |
| +}; |
| + |
| +function fieldRow(name, value) { |
| + return el("p", {}, |
| + el("label", { for: `f-${name}` }, LABELS[name]), |
| + name === "notes" |
| + ? el("textarea", { id: `f-${name}`, name }) |
| + : el("input", { |
| + id: `f-${name}`, name, |
| + type: name === "password" ? "password" : "text", |
| + autocomplete: "off", |
| + })); |
| +} |
| + |
| +function readForm(form) { |
| + const out = {}; |
| + for (const f of FIELDS) out[f] = form.querySelector(`[name=${f}]`).value; |
| + return out; |
| +} |
| + |
| +function fillForm(form, fields) { |
| + for (const f of FIELDS) form.querySelector(`[name=${f}]`).value = fields[f] || ""; |
| +} |
| + |
| +// view holds the whole screen's state in one place, so there is one render |
| +// path rather than thirty callers each patching a corner of the DOM. The |
| +// family's evidence for why: Eleven's hand-rolled shell reached 8,722 lines |
| +// with one render function called from 31 places. |
| +function view(root, store) { |
| + let list = []; |
| + let editing = null; |
| + |
| + const status = el("p", { "data-status": "", "aria-live": "polite" }); |
| + const form = el("form", { "data-item-form": "" }, |
| + ...FIELDS.map((f) => fieldRow(f, "")), |
| + el("p", {}, |
| + el("button", { type: "submit", "data-save": "" }, "Save"), |
| + " ", |
| + el("button", { type: "button", "data-cancel": "" }, "Cancel"))); |
| + const table = el("ul", { "data-items": "" }); |
| + |
| + function say(text) { |
| + status.replaceChildren(document.createTextNode(text)); |
| + } |
| + |
| + function draw() { |
| + table.replaceChildren(...list.map((item) => |
| + el("li", { "data-item": item.idHex }, |
| + el("strong", {}, item.fields.title || "(no name)"), |
| + item.fields.username ? el("span", {}, " — " + item.fields.username) : null, |
| + " ", |
| + el("button", { |
| + type: "button", "data-edit": item.idHex, |
| + onclick: () => { |
| + editing = item; |
| + fillForm(form, item.fields); |
| + say(`Editing ${item.fields.title || "(no name)"}.`); |
| + }, |
| + }, "Edit"), |
| + " ", |
| + el("button", { |
| + type: "button", "data-delete": item.idHex, |
| + onclick: async () => { |
| + await store.remove(item); |
| + list = list.filter((i) => i.idHex !== item.idHex); |
| + if (editing && editing.idHex === item.idHex) editing = null; |
| + draw(); |
| + say("Deleted."); |
| + }, |
| + }, "Delete")))); |
| + } |
| + |
| + form.addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + const fields = readForm(form); |
| + if (editing) { |
| + const updated = await store.update(editing, fields); |
| + list = list.map((i) => (i.idHex === updated.idHex ? updated : i)); |
| + editing = null; |
| + say("Saved."); |
| + } else { |
| + list = [...list, await store.create(fields)]; |
| + say("Added."); |
| + } |
| + fillForm(form, {}); |
| + draw(); |
| + }); |
| + |
| + form.querySelector("[data-cancel]").addEventListener("click", () => { |
| + editing = null; |
| + fillForm(form, {}); |
| + say(""); |
| + }); |
| + |
| + root.replaceChildren(status, form, table); |
| + return { |
| + async load() { |
| + list = await store.list(); |
| + draw(); |
| + say(list.length === 0 ? "No items yet." : `${list.length} item(s).`); |
| + }, |
| + }; |
| +} |
| + |
| +async function main() { |
| + const root = document.querySelector("[data-vault]"); |
| + if (!root) return; |
| + |
| + const gate = document.querySelector("[data-unlock]"); |
| + const bootstrap = await net.get("/api/bootstrap"); |
| + const keys = accountKeys(window.localStorage); |
| + |
| + // Signing in is not unlocking. The session proves who you are; the keys |
| + // come from a wrap the server cannot open, so this asks again. |
| + gate.addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + const passphrase = gate.querySelector("[name=passphrase]").value; |
| + 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.")); |
| + return; |
| + } |
| + box.replaceChildren(document.createTextNode("Unlocking…")); |
| + try { |
| + const opened = await unlock(bootstrap, passphrase, accountKey); |
| + gate.hidden = true; |
| + root.hidden = false; |
| + await view(root, itemStore(net, opened.vaults[0])).load(); |
| + } catch (err) { |
| + box.replaceChildren(document.createTextNode(err.message)); |
| + } |
| + }); |
| +} |
| + |
| +main(); |
diff --git a/internal/contra/store/store.go b/internal/contra/store/store.go| index 0e59374..26a4ec8 100644 |
| --- a/internal/contra/store/store.go |
| +++ b/internal/contra/store/store.go |
| @@ -147,26 +147,113 @@ func (s *Store) PutWrap(ctx context.Context, accountID, wrapID []byte, kind, kdf |
| } |
| // CreateVault records a vault. Its name is not a parameter: a vault name is |
| -// metadata, metadata is a secret, and it seals into the keyset the members |
| -// hold. dropBoxPublicKey is vP_j — what a daemon seals a captured item to |
| -// with no key in memory. |
| -func (s *Store) CreateVault(ctx context.Context, accountID, dropBoxPublicKey []byte) (*model.Vault, error) { |
| - if len(dropBoxPublicKey) != 65 { |
| +// metadata, metadata is a secret, and it seals into the keyset. The id is the |
| +// client's, for the reason PutWrap's is — it is bound into the keyset's AAD |
| +// before the server sees the ciphertext. |
| +// |
| +// dropBoxPublicKey is vP_j: what a daemon seals a captured item to with no |
| +// key in memory. |
| +func (s *Store) CreateVault(ctx context.Context, id, accountID, dropBoxPublicKey []byte, |
| + keyset Sealed) (*model.Vault, error) { |
| + if len(id) != 16 || len(dropBoxPublicKey) != 65 || !keyset.valid() { |
| return nil, ErrShortField |
| } |
| - id, err := NewID() |
| - if err != nil { |
| - return nil, err |
| - } |
| v := &model.Vault{ |
| ID: id, |
| AccountID: accountID, |
| DropBoxPublicKey: dropBoxPublicKey, |
| + KeysetNonce: keyset.Nonce, |
| + KeysetCiphertext: keyset.Ciphertext, |
| CreatedAt: time.Now().UTC(), |
| } |
| return v, s.db.WithContext(ctx).Create(v).Error |
| } |
| +// SetAccountKeyset stores the account keyset — d_acct and K_meta sealed under |
| +// RK — and the public half that other members seal shared vaults to. |
| +func (s *Store) SetAccountKeyset(ctx context.Context, accountID, publicKey []byte, |
| + keyset Sealed) error { |
| + if len(publicKey) != 65 || !keyset.valid() { |
| + return ErrShortField |
| + } |
| + return s.db.WithContext(ctx).Model(&model.Account{}). |
| + Where("id = ?", accountID). |
| + Select("PublicKey", "KeysetNonce", "KeysetCiphertext"). |
| + Updates(map[string]any{ |
| + "PublicKey": publicKey, |
| + "KeysetNonce": keyset.Nonce, |
| + "KeysetCiphertext": keyset.Ciphertext, |
| + }).Error |
| +} |
| + |
| +// ownedVault is the one seam every vault-scoped read and write goes through. |
| +// |
| +// rastrillo's scope.Owned filters on an int64 user_id, and CONTRA's owner is |
| +// a 16-byte account id carried on the session Subject — so this is written |
| +// out rather than borrowed. The rule it enforces is the same one: never a |
| +// query on an owned row without the owner filter, on writes as much as |
| +// reads, so a later refactor cannot quietly turn an update into an IDOR. |
| +func (s *Store) ownedVault(ctx context.Context, accountID, vaultID []byte) (*model.Vault, error) { |
| + var v model.Vault |
| + err := s.db.WithContext(ctx). |
| + Where("id = ? AND account_id = ?", vaultID, accountID).First(&v).Error |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &v, nil |
| +} |
| + |
| +// Vaults lists what this account owns. Nothing here is readable without the |
| +// keys: the rows carry sealed keysets and opaque ids. |
| +func (s *Store) Vaults(ctx context.Context, accountID []byte) ([]model.Vault, error) { |
| + var out []model.Vault |
| + err := s.db.WithContext(ctx). |
| + Where("account_id = ?", accountID).Order("created_at").Find(&out).Error |
| + return out, err |
| +} |
| + |
| +// Account returns one account row by id. |
| +func (s *Store) Account(ctx context.Context, accountID []byte) (*model.Account, error) { |
| + var a model.Account |
| + if err := s.db.WithContext(ctx).Where("id = ?", accountID).First(&a).Error; err != nil { |
| + return nil, err |
| + } |
| + return &a, nil |
| +} |
| + |
| +// Wraps returns an account's peer wraps of RK. |
| +func (s *Store) Wraps(ctx context.Context, accountID []byte) ([]model.Wrap, error) { |
| + var out []model.Wrap |
| + err := s.db.WithContext(ctx).Where("account_id = ?", accountID).Find(&out).Error |
| + return out, err |
| +} |
| + |
| +// Items lists a vault's sealed items — after proving the vault is this |
| +// account's. A vault that is not yours is a vault that does not exist. |
| +func (s *Store) Items(ctx context.Context, accountID, vaultID []byte) ([]model.Item, error) { |
| + if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + return nil, err |
| + } |
| + var out []model.Item |
| + err := s.db.WithContext(ctx). |
| + Where("vault_id = ?", vaultID).Order("created_at").Find(&out).Error |
| + return out, err |
| +} |
| + |
| +// DeleteItem removes one item, scoped through its vault. |
| +// |
| +// The scope is in the SQL rather than in a check beside it: the statement |
| +// carries the owner filter itself, so a foreign id deletes nothing rather |
| +// than relying on a lookup somebody might later drop. |
| +func (s *Store) DeleteItem(ctx context.Context, accountID, vaultID, itemID []byte) error { |
| + if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + return err |
| + } |
| + return s.db.WithContext(ctx). |
| + Where("id = ? AND vault_id = ?", itemID, vaultID). |
| + Delete(&model.Item{}).Error |
| +} |
| + |
| // PutVaultMember stores the vault keyset sealed to one member's account |
| // public key. These rows are the sharing graph — at-rest metadata A1 |
| // tolerates, and published in the model rather than hidden. |
| @@ -198,20 +285,20 @@ func (s *Store) PutVaultMember(ctx context.Context, vaultID, accountID, |
| // padding is the client's job, but "the client pads" is a hope; refusing |
| // here is a control. The check is scheme-independent — it says the plaintext |
| // was quantised, not how — so it holds for the JS and Swift clients too. |
| -func (s *Store) PutItem(ctx context.Context, vaultID []byte, version uint64, sealed Sealed) (*model.Item, error) { |
| - if !sealed.valid() { |
| +func (s *Store) PutItem(ctx context.Context, accountID, vaultID, itemID []byte, |
| + version uint64, sealed Sealed) (*model.Item, error) { |
| + if !sealed.valid() || len(itemID) != 16 { |
| return nil, ErrShortField |
| } |
| if !crypto.IsBucketAligned(sealed.Ciphertext) { |
| return nil, ErrNotAligned |
| } |
| - id, err := NewID() |
| - if err != nil { |
| + if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| return nil, err |
| } |
| now := time.Now().UTC() |
| it := &model.Item{ |
| - ID: id, |
| + ID: itemID, |
| VaultID: vaultID, |
| Version: version, |
| Nonce: sealed.Nonce, |
| @@ -226,15 +313,19 @@ func (s *Store) PutItem(ctx context.Context, vaultID []byte, version uint64, sea |
| // version is bound into the client's AAD, so a server that served an old |
| // ciphertext back under a new version number would produce a blob that does |
| // not open. |
| -func (s *Store) UpdateItem(ctx context.Context, itemID []byte, version uint64, sealed Sealed) error { |
| +func (s *Store) UpdateItem(ctx context.Context, accountID, vaultID, itemID []byte, |
| + version uint64, sealed Sealed) error { |
| if !sealed.valid() { |
| return ErrShortField |
| } |
| if !crypto.IsBucketAligned(sealed.Ciphertext) { |
| return ErrNotAligned |
| } |
| + if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + return err |
| + } |
| return s.db.WithContext(ctx).Model(&model.Item{}). |
| - Where("id = ?", itemID). |
| + Where("id = ? AND vault_id = ?", itemID, vaultID). |
| Select("Version", "Nonce", "Ciphertext", "UpdatedAt"). |
| Updates(map[string]any{ |
| "Version": version, |
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html| index e096729..f15908c 100644 |
| --- a/internal/contra/templates/vault.html |
| +++ b/internal/contra/templates/vault.html |
| @@ -1,14 +1,28 @@ |
| {{define "title"}}Vault — CONTRA{{end}} |
| {{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}} |
| +{{define "head"}}<script type="module" src="{{asset "static/pages/vault.js"}}"></script>{{end}} |
| {{define "content"}} |
| -<h1>Signed in</h1> |
| +<h1>Your vault</h1> |
| -<p>The ceremony worked: your browser proved possession of a key derived from |
| -your passphrase, and the server verified a signature against the public half |
| -it stores. It still holds nothing that can open a vault.</p> |
| +{{/* Signing in is not unlocking. The session proved who you are; the keys |
| + come from a wrap this server cannot open, so the page asks again and |
| + does the work in the browser. */}} |
| +<form data-unlock method="post" action="#"> |
| + <p>Signing in proved who you are. Opening the vault needs your passphrase |
| + again, because the key comes from something this server cannot read.</p> |
| + <p> |
| + <label for="passphrase">Passphrase</label> |
| + <input id="passphrase" name="passphrase" type="password" |
| + autocomplete="current-password" required> |
| + </p> |
| + <p><button type="submit">Unlock</button></p> |
| + <div data-status aria-live="polite"></div> |
| +</form> |
| + |
| +<div data-vault hidden></div> |
| -<p>Items land here in a later slice. Build <code>{{.Version}}</code>.</p> |
| +<p>Build <code>{{.Version}}</code>.</p> |
| <form method="post" action="/auth/signout"> |
| <button type="submit">Sign out</button> |
diff --git a/internal/contra/vault/vault.go b/internal/contra/vault/vault.go| new file mode 100644 |
| index 0000000..33b7ed8 |
| --- /dev/null |
| +++ b/internal/contra/vault/vault.go |
| @@ -0,0 +1,343 @@ |
| +// Package vault is the item API: a sealed-blob store with an owner filter. |
| +// |
| +// Every handler here hands out or takes in ciphertext. None of them can read |
| +// an item, and none of them is one refactor away from being able to — there |
| +// is no key on this side and no parameter that could carry one. |
| +// |
| +// # Not yours is not found |
| +// |
| +// A vault or item belonging to somebody else answers 404, never 403. A 403 |
| +// confirms the row exists, which is a fact about another account's vault and |
| +// therefore a fact we do not have. The owner filter lives in the SQL rather |
| +// than in a check beside it, so a later refactor cannot turn a read into an |
| +// IDOR by dropping a line. |
| +package vault |
| + |
| +import ( |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + |
| + "gorm.io/gorm" |
| + |
| + "github.com/carlosframework/rastrillo/sessions" |
| + |
| + "github.com/carlosframework/contra/internal/contra/store" |
| +) |
| + |
| +type Vault struct{ store *store.Store } |
| + |
| +func New(db *gorm.DB) *Vault { return &Vault{store: store.New(db)} } |
| + |
| +// accountID reads the viewer from the session. |
| +// |
| +// Not sessions.UserID: that returns an int64 and only works for a plugin |
| +// whose Subject is a numeric user id. CONTRA's subject is a hex account id, |
| +// so UserID would answer (0, false) and — if its ok were dropped, which is |
| +// exactly the mistake the framework's own notes warn about — scope every |
| +// query to a nonexistent account and quietly return nothing. |
| +func accountID(r *http.Request) ([]byte, bool) { |
| + sess, ok := sessions.Current(r) |
| + if !ok { |
| + return nil, false |
| + } |
| + id, err := hex.DecodeString(sess.Subject) |
| + if err != nil || len(id) != 16 { |
| + return nil, false |
| + } |
| + return id, true |
| +} |
| + |
| +func writeJSON(w http.ResponseWriter, status int, v any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(status) |
| + _ = json.NewEncoder(w).Encode(v) |
| +} |
| + |
| +func notFound(w http.ResponseWriter) { |
| + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) |
| +} |
| + |
| +func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { |
| + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) |
| + dec.DisallowUnknownFields() |
| + if err := dec.Decode(v); err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed request"}) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +type sealedJSON struct { |
| + NonceHex string `json:"nonce_hex"` |
| + CiphertextHex string `json:"ciphertext_hex"` |
| +} |
| + |
| +func (s sealedJSON) decode() (store.Sealed, error) { |
| + nonce, err := hex.DecodeString(s.NonceHex) |
| + if err != nil { |
| + return store.Sealed{}, err |
| + } |
| + ct, err := hex.DecodeString(s.CiphertextHex) |
| + if err != nil { |
| + return store.Sealed{}, err |
| + } |
| + return store.Sealed{Nonce: nonce, Ciphertext: ct}, nil |
| +} |
| + |
| +func sealedOf(nonce, ct []byte) sealedJSON { |
| + return sealedJSON{NonceHex: hex.EncodeToString(nonce), CiphertextHex: hex.EncodeToString(ct)} |
| +} |
| + |
| +// Bootstrap is everything a freshly signed-in browser needs to unlock: the |
| +// wraps of RK, the account keyset, and the vaults with their sealed keysets. |
| +// |
| +// All of it is ciphertext. Handing it over to a signed-in session is not a |
| +// disclosure — the same bytes are in the bucket, and the whole design assumes |
| +// an attacker who has them. What it saves is a round trip per unlock. |
| +func (v *Vault) Bootstrap(w http.ResponseWriter, r *http.Request) { |
| + id, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + ctx := r.Context() |
| + |
| + acct, err := v.store.Account(ctx, id) |
| + if err != nil { |
| + notFound(w) |
| + return |
| + } |
| + wraps, err := v.store.Wraps(ctx, id) |
| + if err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| + return |
| + } |
| + vaults, err := v.store.Vaults(ctx, id) |
| + if err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| + return |
| + } |
| + |
| + type wrapOut struct { |
| + Kind string `json:"kind"` |
| + WrapIDHex string `json:"wrap_id_hex"` |
| + Version uint64 `json:"version"` |
| + KDFParams string `json:"kdf_params"` |
| + KCVHex string `json:"kcv_hex"` |
| + sealedJSON |
| + } |
| + type vaultOut struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + Keyset sealedJSON `json:"keyset"` |
| + } |
| + |
| + out := struct { |
| + AccountIDHex string `json:"account_id_hex"` |
| + SaltHex string `json:"salt_hex"` |
| + Keyset sealedJSON `json:"keyset"` |
| + Wraps []wrapOut `json:"wraps"` |
| + Vaults []vaultOut `json:"vaults"` |
| + }{ |
| + AccountIDHex: hex.EncodeToString(acct.ID), |
| + SaltHex: hex.EncodeToString(acct.KDFSalt), |
| + Keyset: sealedOf(acct.KeysetNonce, acct.KeysetCiphertext), |
| + Wraps: []wrapOut{}, |
| + Vaults: []vaultOut{}, |
| + } |
| + for _, wr := range wraps { |
| + out.Wraps = append(out.Wraps, wrapOut{ |
| + Kind: wr.Kind, WrapIDHex: hex.EncodeToString(wr.ID), Version: wr.Version, |
| + KDFParams: wr.KDFParams, KCVHex: hex.EncodeToString(wr.KCV), |
| + sealedJSON: sealedOf(wr.Nonce, wr.Ciphertext), |
| + }) |
| + } |
| + for _, vt := range vaults { |
| + out.Vaults = append(out.Vaults, vaultOut{ |
| + VaultIDHex: hex.EncodeToString(vt.ID), |
| + Keyset: sealedOf(vt.KeysetNonce, vt.KeysetCiphertext), |
| + }) |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| +} |
| + |
| +type itemOut struct { |
| + ItemIDHex string `json:"item_id_hex"` |
| + Version uint64 `json:"version"` |
| + sealedJSON |
| +} |
| + |
| +// Items lists a vault's sealed items. |
| +func (v *Vault) Items(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + vaultID, err := hex.DecodeString(r.URL.Query().Get("vault")) |
| + if err != nil || len(vaultID) != 16 { |
| + notFound(w) |
| + return |
| + } |
| + items, err := v.store.Items(r.Context(), acct, vaultID) |
| + if err != nil { |
| + // Including "the vault is not yours": not found is the honest answer |
| + // and the only one that says nothing about another account. |
| + notFound(w) |
| + return |
| + } |
| + out := []itemOut{} |
| + for _, it := range items { |
| + out = append(out, itemOut{ |
| + ItemIDHex: hex.EncodeToString(it.ID), Version: it.Version, |
| + sealedJSON: sealedOf(it.Nonce, it.Ciphertext), |
| + }) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"items": out}) |
| +} |
| + |
| +type putItemRequest struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + ItemIDHex string `json:"item_id_hex"` |
| + Version uint64 `json:"version"` |
| + sealedJSON |
| +} |
| + |
| +// CreateItem stores a new sealed item. The item id is the client's, because |
| +// it is bound into the AAD before the server sees the ciphertext. |
| +func (v *Vault) CreateItem(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req putItemRequest |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + vaultID, e1 := hex.DecodeString(req.VaultIDHex) |
| + itemID, e2 := hex.DecodeString(req.ItemIDHex) |
| + sealed, e3 := req.sealedJSON.decode() |
| + if e1 != nil || e2 != nil || e3 != nil || len(vaultID) != 16 || len(itemID) != 16 { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad item"}) |
| + return |
| + } |
| + it, err := v.store.PutItem(r.Context(), acct, vaultID, itemID, req.Version, sealed) |
| + if err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + notFound(w) |
| + return |
| + } |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + return |
| + } |
| + writeJSON(w, http.StatusCreated, itemOut{ |
| + ItemIDHex: hex.EncodeToString(it.ID), Version: it.Version, |
| + sealedJSON: sealedOf(it.Nonce, it.Ciphertext), |
| + }) |
| +} |
| + |
| +// UpdateItem replaces an item's sealed body and bumps its version. The |
| +// version is in the client's AAD, so a server serving an old ciphertext back |
| +// under a new number produces a blob that does not open. |
| +func (v *Vault) UpdateItem(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req putItemRequest |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + vaultID, e1 := hex.DecodeString(req.VaultIDHex) |
| + itemID, e2 := hex.DecodeString(req.ItemIDHex) |
| + sealed, e3 := req.sealedJSON.decode() |
| + if e1 != nil || e2 != nil || e3 != nil || len(vaultID) != 16 || len(itemID) != 16 { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad item"}) |
| + return |
| + } |
| + if err := v.store.UpdateItem(r.Context(), acct, vaultID, itemID, req.Version, sealed); err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + notFound(w) |
| + return |
| + } |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]string{"item_id_hex": req.ItemIDHex}) |
| +} |
| + |
| +type deleteItemRequest struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + ItemIDHex string `json:"item_id_hex"` |
| +} |
| + |
| +func (v *Vault) DeleteItem(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req deleteItemRequest |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + vaultID, e1 := hex.DecodeString(req.VaultIDHex) |
| + itemID, e2 := hex.DecodeString(req.ItemIDHex) |
| + if e1 != nil || e2 != nil { |
| + notFound(w) |
| + return |
| + } |
| + if err := v.store.DeleteItem(r.Context(), acct, vaultID, itemID); err != nil { |
| + notFound(w) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| + |
| +// Provision is the one write that is not an item: it records the account |
| +// keyset and the first vault, both sealed in the browser at signup. |
| +// |
| +// Separate from signup because it needs a session — signup ends before one |
| +// exists, and a keyset posted without one would be a write nobody had proved |
| +// they were entitled to make. |
| +func (v *Vault) Provision(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req struct { |
| + PublicKeyHex string `json:"public_key_hex"` |
| + AccountKeyset sealedJSON `json:"account_keyset"` |
| + VaultIDHex string `json:"vault_id_hex"` |
| + DropBoxPublicKeyHex string `json:"drop_box_public_key_hex"` |
| + VaultKeyset sealedJSON `json:"vault_keyset"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + pub, e1 := hex.DecodeString(req.PublicKeyHex) |
| + acctKeyset, e2 := req.AccountKeyset.decode() |
| + vaultID, e3 := hex.DecodeString(req.VaultIDHex) |
| + dropBox, e4 := hex.DecodeString(req.DropBoxPublicKeyHex) |
| + vaultKeyset, e5 := req.VaultKeyset.decode() |
| + if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad provisioning"}) |
| + return |
| + } |
| + |
| + ctx := r.Context() |
| + if err := v.store.SetAccountKeyset(ctx, acct, pub, acctKeyset); err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + return |
| + } |
| + if _, err := v.store.CreateVault(ctx, vaultID, acct, dropBox, vaultKeyset); err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + return |
| + } |
| + writeJSON(w, http.StatusCreated, map[string]string{"vault_id_hex": req.VaultIDHex}) |
| +} |
diff --git a/internal/contratest/browser_test.go b/internal/contratest/browser_test.go| index 31605fa..f3cb71f 100644 |
| --- a/internal/contratest/browser_test.go |
| +++ b/internal/contratest/browser_test.go |
| @@ -25,6 +25,7 @@ import ( |
| "log/slog" |
| "net/http" |
| "path/filepath" |
| + "strings" |
| "testing" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -118,3 +119,83 @@ func TestBrowserSignupAndSignIn(t *testing.T) { |
| ) |
| rig.Screen("body", "vault") |
| } |
| + |
| +// TestBrowserVaultRoundTrip is the whole product in one walk: sign up, unlock |
| +// with the passphrase and the cached Account Key, add an item, watch it |
| +// survive a reload, edit it, delete it. |
| +// |
| +// It is the check nothing else makes. The node suite proves the crypto is |
| +// right and the Go suite proves the API is scoped; only this proves the two |
| +// halves agree in a real engine, over a real session, with the real |
| +// localStorage the Account Key cache depends on. |
| +func TestBrowserVaultRoundTrip(t *testing.T) { |
| + rig := newRig(t) |
| + |
| + const email = "vault-walk@example.invalid" |
| + const passphrase = "a generated test passphrase 8f3a1c" |
| + |
| + // Signup provisions the account keyset and the first vault, and caches |
| + // the Account Key for this browser. |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+"/signup"), |
| + chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery), |
| + chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| + chromedp.SendKeys(`input[name=passphrase_again]`, passphrase, chromedp.ByQuery), |
| + chromedp.Click(`button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery), |
| + ) |
| + |
| + unlockVault := []chromedp.Action{ |
| + chromedp.Navigate(rig.Origin + "/vault"), |
| + chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| + chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| + chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| + } |
| + |
| + // Signing in is not unlocking: the vault asks for the passphrase again, |
| + // because the key comes from a wrap the server cannot open. |
| + rig.Run(append(unlockVault, chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery))...) |
| + rig.Screen("[data-vault]", "unlocked vault") |
| + |
| + rig.Run( |
| + chromedp.SendKeys(`[data-item-form] input[name=title]`, "Jamaica Bakery", chromedp.ByQuery), |
| + chromedp.SendKeys(`[data-item-form] input[name=username]`, "me@example.invalid", chromedp.ByQuery), |
| + chromedp.SendKeys(`[data-item-form] input[name=password]`, "generated-not-real-8f3a", chromedp.ByQuery), |
| + chromedp.Click(`[data-item-form] [data-save]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-items] li`, chromedp.ByQuery), |
| + ) |
| + |
| + var listed string |
| + rig.Run(chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery)) |
| + if !strings.Contains(listed, "Jamaica Bakery") { |
| + t.Fatalf("the item did not appear in the list: %q", listed) |
| + } |
| + rig.Screen("[data-vault]", "one item") |
| + |
| + // It survives a reload, which means it really went to the server sealed, |
| + // came back, and opened — rather than sitting in a JS variable. |
| + rig.Run(append(unlockVault, |
| + chromedp.WaitVisible(`[data-items] li`, chromedp.ByQuery), |
| + chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery))...) |
| + if !strings.Contains(listed, "Jamaica Bakery") { |
| + t.Fatalf("the item did not survive a reload: %q", listed) |
| + } |
| + |
| + // Saving is asynchronous — it seals, posts, and only then redraws — so |
| + // this waits for the list to actually carry the edit rather than reading |
| + // it a millisecond too early. |
| + rig.Run( |
| + chromedp.Click(`[data-items] [data-edit]`, chromedp.ByQuery), |
| + chromedp.SetValue(`[data-item-form] input[name=title]`, "Jamaica Bakery (edited)", chromedp.ByQuery), |
| + chromedp.Click(`[data-item-form] [data-save]`, chromedp.ByQuery), |
| + chromedp.Poll( |
| + `document.querySelector('[data-items]').textContent.includes('(edited)')`, |
| + nil), |
| + ) |
| + |
| + rig.Run( |
| + chromedp.Click(`[data-items] [data-delete]`, chromedp.ByQuery), |
| + chromedp.WaitNotPresent(`[data-items] li`, chromedp.ByQuery), |
| + ) |
| + rig.Screen("[data-vault]", "empty vault") |
| +} |
diff --git a/internal/contratest/greps_test.go b/internal/contratest/greps_test.go| index 0375104..bfbf14b 100644 |
| --- a/internal/contratest/greps_test.go |
| +++ b/internal/contratest/greps_test.go |
| @@ -180,7 +180,22 @@ func writeEverything(t *testing.T, s *store.Store) secrets { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - vault, err := s.CreateVault(ctx, acct.ID, dropBox.PublicKey().Bytes()) |
| + vaultID, err := store.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The vault keyset carries the vault's NAME and its bulk key, which is |
| + // where both are supposed to live — so both are canaries here. |
| + keysetNonce := randBytes(t, 12) |
| + keysetCT, err := crypto.Seal(sec.rk, keysetNonce, |
| + crypto.AAD(acct.ID, "vault.keyset", vaultID, 1), |
| + []byte(fmt.Sprintf(`{"name":%q,"k_vault":%q}`, |
| + canaries["vault name"], hex.EncodeToString(sec.kVault)))) |
| + if err != nil { |
| + t.Fatalf("Seal(vault keyset): %v", err) |
| + } |
| + vault, err := s.CreateVault(ctx, vaultID, acct.ID, dropBox.PublicKey().Bytes(), |
| + store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}) |
| if err != nil { |
| t.Fatalf("CreateVault: %v", err) |
| } |
| @@ -225,7 +240,8 @@ func writeEverything(t *testing.T, s *store.Store) secrets { |
| if err != nil { |
| t.Fatalf("Seal(item): %v", err) |
| } |
| - item, err := s.PutItem(ctx, vault.ID, 1, store.Sealed{Nonce: itemNonce, Ciphertext: itemCT}) |
| + item, err := s.PutItem(ctx, acct.ID, vault.ID, itemID, 1, |
| + store.Sealed{Nonce: itemNonce, Ciphertext: itemCT}) |
| if err != nil { |
| t.Fatalf("PutItem: %v", err) |
| } |
| @@ -238,7 +254,8 @@ func writeEverything(t *testing.T, s *store.Store) secrets { |
| if err != nil { |
| t.Fatalf("Seal(edit): %v", err) |
| } |
| - if err := s.UpdateItem(ctx, item.ID, 2, store.Sealed{Nonce: editNonce, Ciphertext: editCT}); err != nil { |
| + if err := s.UpdateItem(ctx, acct.ID, vault.ID, item.ID, 2, |
| + store.Sealed{Nonce: editNonce, Ciphertext: editCT}); err != nil { |
| t.Fatalf("UpdateItem: %v", err) |
| } |
| @@ -494,7 +511,8 @@ func TestStoreRefusesUnquantisedItem(t *testing.T) { |
| } |
| // 100 bytes plus a tag: a real ciphertext of an unpadded plaintext. |
| unpadded := store.Sealed{Nonce: randBytes(t, 12), Ciphertext: randBytes(t, 116)} |
| - if _, err := s.PutItem(context.Background(), vaultID, 1, unpadded); err == nil { |
| + if _, err := s.PutItem(context.Background(), randBytes(t, 16), vaultID, |
| + randBytes(t, 16), 1, unpadded); err == nil { |
| t.Error("PutItem accepted an item that leaks its own length") |
| } |
| } |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index e99e286..6598b02 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -25,18 +25,23 @@ const jsModuleCap = 300 |
| // cap — a new module in the crypto path is exactly the thing that should not |
| // arrive without somebody noticing. |
| var jsHighWater = map[string]int{ |
| - "account.js": 152, |
| - "argon2id.js": 292, |
| - "blake2b.js": 99, |
| - "bytes.js": 61, |
| - "code.js": 165, |
| - "kdf.js": 117, |
| - "p256.js": 101, |
| - "sealed.js": 90, |
| + // The crypto core. |
| + "account.js": 168, |
| + "argon2id.js": 292, |
| + "blake2b.js": 99, |
| + "bytes.js": 61, |
| + "code.js": 165, |
| + "items.js": 97, |
| + "kdf.js": 117, |
| + "net.js": 35, |
| + "p256.js": 114, |
| + "sealed.js": 90, |
| + "vaultkeys.js": 148, |
| // 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/auth.js": 113, |
| + "pages/auth.js": 116, |
| + "pages/vault.js": 170, |
| } |
| func TestJSModulesStaySmall(t *testing.T) { |
diff --git a/internal/contratest/vault_test.go b/internal/contratest/vault_test.go| new file mode 100644 |
| index 0000000..792efd5 |
| --- /dev/null |
| +++ b/internal/contratest/vault_test.go |
| @@ -0,0 +1,306 @@ |
| +package contratest |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| +) |
| + |
| +// The item API: a sealed-blob store with an owner filter. |
| +// |
| +// Nothing here decrypts anything, so what these tests are really about is the |
| +// filter. A vault that is not yours must be a vault that does not exist — |
| +// answered 404, never 403, because a 403 confirms the row and that is a fact |
| +// about somebody else's vault. |
| + |
| +// session signs an account in and returns the cookie, so a test can act as |
| +// that account. |
| +func session(t *testing.T, h http.Handler, a account) string { |
| + t.Helper() |
| + rec := doSignIn(t, h, a.email, a.passphrase) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("sign in: status %d", rec.Code) |
| + } |
| + cookie := rec.Header().Get("Set-Cookie") |
| + if cookie == "" { |
| + t.Fatal("no session cookie") |
| + } |
| + return strings.SplitN(cookie, ";", 2)[0] |
| +} |
| + |
| +func as(t *testing.T, h http.Handler, cookie, method, path string, body any) *httptest.ResponseRecorder { |
| + t.Helper() |
| + var req *http.Request |
| + if body != nil { |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req = httptest.NewRequest(method, path, strings.NewReader(string(raw))) |
| + req.Header.Set("Content-Type", "application/json") |
| + } else { |
| + req = httptest.NewRequest(method, path, nil) |
| + } |
| + req.Header.Set("Origin", testOrigin) |
| + req.Header.Set("Cookie", cookie) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +// provisioned is a signed-in account with its account keyset and first vault |
| +// in place — the state the browser reaches at the end of signup. |
| +type provisioned struct { |
| + account |
| + cookie string |
| + vaultID []byte |
| + kVault []byte |
| +} |
| + |
| +func provision(t *testing.T, h http.Handler, email, passphrase string) provisioned { |
| + t.Helper() |
| + a := doSignup(t, h, email, passphrase) |
| + cookie := session(t, h, a) |
| + |
| + p := provisioned{account: a, cookie: cookie} |
| + p.vaultID = make([]byte, 16) |
| + p.kVault = make([]byte, 32) |
| + kMeta := make([]byte, 32) |
| + dropBoxSeed := make([]byte, 32) |
| + acctSeed := make([]byte, 32) |
| + for _, b := range [][]byte{p.vaultID, p.kVault, kMeta, dropBoxSeed, acctSeed} { |
| + if _, err := rand.Read(b); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + dropBox, err := crypto.DeriveP256(dropBoxSeed) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + acctKey, err := crypto.DeriveP256(acctSeed) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + sealUnder := func(objectType string, objectID []byte, plaintext string) map[string]string { |
| + nonce := make([]byte, 12) |
| + if _, err := rand.Read(nonce); err != nil { |
| + t.Fatal(err) |
| + } |
| + ct, err := crypto.Seal(a.rk, nonce, |
| + crypto.AAD(a.id, objectType, objectID, 1), []byte(plaintext)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return map[string]string{ |
| + "nonce_hex": hex.EncodeToString(nonce), "ciphertext_hex": hex.EncodeToString(ct)} |
| + } |
| + |
| + rec := as(t, h, cookie, http.MethodPost, "/api/provision", map[string]any{ |
| + "public_key_hex": hex.EncodeToString(acctKey.PublicKey().Bytes()), |
| + "account_keyset": sealUnder("account.keyset", a.id, |
| + `{"d_acct":"00","k_meta":"`+hex.EncodeToString(kMeta)+`"}`), |
| + "vault_id_hex": hex.EncodeToString(p.vaultID), |
| + "drop_box_public_key_hex": hex.EncodeToString(dropBox.PublicKey().Bytes()), |
| + "vault_keyset": sealUnder("vault.keyset", p.vaultID, |
| + `{"name":"Personal","k_vault":"`+hex.EncodeToString(p.kVault)+`"}`), |
| + }) |
| + if rec.Code != http.StatusCreated { |
| + t.Fatalf("provision: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + return p |
| +} |
| + |
| +// sealItem is the client half: pad into buckets, then seal under K_vault with |
| +// the AAD binding (vault_id, "item", item_id, version). |
| +func sealItem(t *testing.T, p provisioned, itemID []byte, version uint64, body string) map[string]any { |
| + t.Helper() |
| + nonce := make([]byte, 12) |
| + if _, err := rand.Read(nonce); err != nil { |
| + t.Fatal(err) |
| + } |
| + ct, err := crypto.Seal(p.kVault, nonce, |
| + crypto.AAD(p.vaultID, crypto.ObjectItem, itemID, version), crypto.Pad([]byte(body))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return map[string]any{ |
| + "vault_id_hex": hex.EncodeToString(p.vaultID), |
| + "item_id_hex": hex.EncodeToString(itemID), |
| + "version": version, |
| + "nonce_hex": hex.EncodeToString(nonce), |
| + "ciphertext_hex": hex.EncodeToString(ct), |
| + } |
| +} |
| + |
| +func newItemID(t *testing.T) []byte { |
| + t.Helper() |
| + id := make([]byte, 16) |
| + if _, err := rand.Read(id); err != nil { |
| + t.Fatal(err) |
| + } |
| + return id |
| +} |
| + |
| +func TestItemsRoundTrip(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + const body = `{"title":"Bakery","username":"me","password":"generated-not-real"}` |
| + itemID := newItemID(t) |
| + if rec := as(t, h, p.cookie, http.MethodPost, "/api/items", |
| + sealItem(t, p, itemID, 1, body)); rec.Code != http.StatusCreated { |
| + t.Fatalf("create: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + |
| + var listed struct { |
| + Items []struct { |
| + ItemIDHex string `json:"item_id_hex"` |
| + Version uint64 `json:"version"` |
| + NonceHex string `json:"nonce_hex"` |
| + CiphertextHex string `json:"ciphertext_hex"` |
| + } `json:"items"` |
| + } |
| + rec := as(t, h, p.cookie, http.MethodGet, |
| + "/api/items?vault="+hex.EncodeToString(p.vaultID), nil) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("list: status %d", rec.Code) |
| + } |
| + decode(t, rec, &listed) |
| + if len(listed.Items) != 1 { |
| + t.Fatalf("listed %d items, want 1", len(listed.Items)) |
| + } |
| + |
| + // And it opens, back in the client, to exactly what went in. |
| + row := listed.Items[0] |
| + nonce, _ := hex.DecodeString(row.NonceHex) |
| + ct, _ := hex.DecodeString(row.CiphertextHex) |
| + id, _ := hex.DecodeString(row.ItemIDHex) |
| + plain, err := crypto.Open(p.kVault, nonce, |
| + crypto.AAD(p.vaultID, crypto.ObjectItem, id, row.Version), ct) |
| + if err != nil { |
| + t.Fatalf("the stored item does not open: %v", err) |
| + } |
| + unpadded, err := crypto.Unpad(plain) |
| + if err != nil { |
| + t.Fatalf("unpad: %v", err) |
| + } |
| + if string(unpadded) != body { |
| + t.Errorf("round trip\n got %s\n want %s", unpadded, body) |
| + } |
| + |
| + // Update bumps the version, which is in the AAD. |
| + const edited = `{"title":"Bakery","username":"me2","password":"generated-not-real"}` |
| + if rec := as(t, h, p.cookie, http.MethodPut, "/api/items", |
| + sealItem(t, p, itemID, 2, edited)); rec.Code != http.StatusOK { |
| + t.Fatalf("update: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + |
| + // Delete leaves nothing. |
| + if rec := as(t, h, p.cookie, http.MethodPost, "/api/items/delete", map[string]string{ |
| + "vault_id_hex": hex.EncodeToString(p.vaultID), |
| + "item_id_hex": hex.EncodeToString(itemID), |
| + }); rec.Code != http.StatusNoContent { |
| + t.Fatalf("delete: status %d", rec.Code) |
| + } |
| + rec = as(t, h, p.cookie, http.MethodGet, |
| + "/api/items?vault="+hex.EncodeToString(p.vaultID), nil) |
| + decode(t, rec, &listed) |
| + if len(listed.Items) != 0 { |
| + t.Errorf("after delete, %d items remain", len(listed.Items)) |
| + } |
| +} |
| + |
| +// Two accounts, and neither can see or touch the other's vault. This is the |
| +// test that would catch the owner filter being dropped in a refactor. |
| +func TestAnotherAccountsVaultDoesNotExist(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") |
| + |
| + itemID := newItemID(t) |
| + if rec := as(t, h, mine.cookie, http.MethodPost, "/api/items", |
| + sealItem(t, mine, itemID, 1, `{"title":"Mine"}`)); rec.Code != http.StatusCreated { |
| + t.Fatalf("create: status %d", rec.Code) |
| + } |
| + |
| + // Listing somebody else's vault: not found, not forbidden. |
| + rec := as(t, h, theirs.cookie, http.MethodGet, |
| + "/api/items?vault="+hex.EncodeToString(mine.vaultID), nil) |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("listing another account's vault: status %d, want 404", rec.Code) |
| + } |
| + |
| + // Writing into it: also not found. |
| + body := sealItem(t, theirs, newItemID(t), 1, `{"title":"Theirs"}`) |
| + body["vault_id_hex"] = hex.EncodeToString(mine.vaultID) |
| + if rec := as(t, h, theirs.cookie, http.MethodPost, "/api/items", body); rec.Code != http.StatusNotFound { |
| + t.Errorf("writing into another account's vault: status %d, want 404", rec.Code) |
| + } |
| + |
| + // Deleting from it: not found, and the item survives. |
| + if rec := as(t, h, theirs.cookie, http.MethodPost, "/api/items/delete", map[string]string{ |
| + "vault_id_hex": hex.EncodeToString(mine.vaultID), |
| + "item_id_hex": hex.EncodeToString(itemID), |
| + }); rec.Code != http.StatusNotFound { |
| + t.Errorf("deleting from another account's vault: status %d, want 404", rec.Code) |
| + } |
| + var listed struct { |
| + Items []json.RawMessage `json:"items"` |
| + } |
| + decode(t, as(t, h, mine.cookie, http.MethodGet, |
| + "/api/items?vault="+hex.EncodeToString(mine.vaultID), nil), &listed) |
| + if len(listed.Items) != 1 { |
| + t.Errorf("the item did not survive a foreign delete: %d left", len(listed.Items)) |
| + } |
| +} |
| + |
| +func TestVaultAPIRefusesAStranger(t *testing.T) { |
| + h := newApp(t) |
| + for _, path := range []string{"/api/bootstrap", "/api/items?vault=00"} { |
| + rec := get(t, h, path) |
| + if rec.Code == http.StatusOK { |
| + t.Errorf("GET %s served a signed-out request", path) |
| + } |
| + } |
| +} |
| + |
| +// The quantisation rule holds at the API boundary, not only in the store. |
| +func TestAPIRefusesAnUnquantisedItem(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + body := sealItem(t, p, newItemID(t), 1, `{"title":"x"}`) |
| + // 116 bytes: a real ciphertext of an unpadded plaintext. |
| + body["ciphertext_hex"] = strings.Repeat("ab", 116) |
| + if rec := as(t, h, p.cookie, http.MethodPost, "/api/items", body); rec.Code != http.StatusBadRequest { |
| + t.Errorf("unquantised item: status %d, want 400", rec.Code) |
| + } |
| +} |
| + |
| +// Bootstrap hands back ciphertext and nothing else. If a key ever appears in |
| +// this response, it appears here first. |
| +func TestBootstrapCarriesNoKeys(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + rec := as(t, h, p.cookie, http.MethodGet, "/api/bootstrap", nil) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("bootstrap: status %d", rec.Code) |
| + } |
| + raw := rec.Body.String() |
| + for name, secret := range map[string][]byte{ |
| + "RK": p.rk, "K_vault": p.kVault, |
| + "the Account Key": p.accountKey, "the recovery code": p.recovery, |
| + } { |
| + if strings.Contains(raw, hex.EncodeToString(secret)) { |
| + t.Errorf("/api/bootstrap returned %s", name) |
| + } |
| + } |
| +} |
diff --git a/test/items.test.mjs b/test/items.test.mjs| new file mode 100644 |
| index 0000000..9390a93 |
| --- /dev/null |
| +++ b/test/items.test.mjs |
| @@ -0,0 +1,182 @@ |
| +// Items, against a fake network and a fake store. |
| +// |
| +// #12's discipline says storage and network are passed in, never reached for, |
| +// so that unit tests hand in fakes. This is what that buys: the whole item |
| +// lifecycle exercised with no socket, no browser, and no server — and every |
| +// byte the "server" received available to assert on. |
| +// |
| +// The assertion that matters most is the negative one: nothing the fake |
| +// network was handed contains any plaintext. |
| + |
| +import { test } from "node:test"; |
| +import assert from "node:assert/strict"; |
| +import { fileURLToPath } from "node:url"; |
| +import { dirname, join } from "node:path"; |
| + |
| +const here = dirname(fileURLToPath(import.meta.url)); |
| +const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name); |
| + |
| +const { fromHex, toHex } = await import(mod("bytes.js")); |
| +const { items, openItem, sealItem, FIELDS } = await import(mod("items.js")); |
| +const { accountKeys } = await import(mod("vaultkeys.js")); |
| + |
| +// fakeNet records everything sent, and answers from what it holds. |
| +function fakeNet() { |
| + const rows = new Map(); |
| + const sent = []; |
| + return { |
| + sent, |
| + rows, |
| + async get(path) { |
| + sent.push({ method: "GET", path }); |
| + return { items: [...rows.values()] }; |
| + }, |
| + async post(path, body) { |
| + sent.push({ method: "POST", path, body }); |
| + if (path === "/api/items") { |
| + rows.set(body.item_id_hex, { |
| + item_id_hex: body.item_id_hex, version: body.version, |
| + nonce_hex: body.nonce_hex, ciphertext_hex: body.ciphertext_hex, |
| + }); |
| + return {}; |
| + } |
| + if (path === "/api/items/delete") { |
| + rows.delete(body.item_id_hex); |
| + return null; |
| + } |
| + throw new Error("unexpected POST " + path); |
| + }, |
| + async put(path, body) { |
| + sent.push({ method: "PUT", path, body }); |
| + rows.set(body.item_id_hex, { |
| + item_id_hex: body.item_id_hex, version: body.version, |
| + nonce_hex: body.nonce_hex, ciphertext_hex: body.ciphertext_hex, |
| + }); |
| + return {}; |
| + }, |
| + }; |
| +} |
| + |
| +const vault = { |
| + id: fromHex("d7331f78b34eb0cc299252e2624070db"), |
| + idHex: "d7331f78b34eb0cc299252e2624070db", |
| + kVault: fromHex("576894008c1a94f71704db728d37bfa9f0023cca4fd8d2ae48f6af81a4b959a5"), |
| +}; |
| + |
| +const CANARIES = { |
| + title: "CANARY-title-Jamaica-Bakery-8f3a1c", |
| + url: "https://CANARY-host-4d2e9b.example.invalid/login", |
| + username: "CANARY-user-4d2e9b@example.invalid", |
| + password: "CANARY-password-Q7wz-2m4K-9xLp", |
| + notes: "CANARY-notes-the-spare-key-is-under-the-pot", |
| +}; |
| + |
| +test("an item round-trips through create, list and update", async () => { |
| + const net = fakeNet(); |
| + const store = items(net, vault); |
| + |
| + const created = await store.create(CANARIES); |
| + assert.equal(created.version, 1); |
| + |
| + let list = await store.list(); |
| + assert.equal(list.length, 1); |
| + assert.deepEqual(list[0].fields, CANARIES); |
| + |
| + const edited = { ...CANARIES, username: "CANARY-user-changed@example.invalid" }; |
| + await store.update(list[0], edited); |
| + |
| + list = await store.list(); |
| + assert.equal(list.length, 1); |
| + assert.equal(list[0].version, 2, "an edit must bump the version, which is in the AAD"); |
| + assert.deepEqual(list[0].fields, edited); |
| + |
| + await store.remove(list[0]); |
| + assert.equal((await store.list()).length, 0); |
| +}); |
| + |
| +// The one that matters: everything the network was handed is opaque. |
| +test("nothing readable reaches the network", async () => { |
| + const net = fakeNet(); |
| + const store = items(net, vault); |
| + const created = await store.create(CANARIES); |
| + await store.update(created, { ...CANARIES, title: "CANARY-title-second-1a7c5e" }); |
| + |
| + const wire = JSON.stringify(net.sent); |
| + for (const [field, value] of Object.entries(CANARIES)) { |
| + assert.ok(!wire.includes(value), `${field} reached the network in plaintext`); |
| + } |
| + assert.ok(!wire.includes("CANARY-title-second-1a7c5e"), "the edit reached the network"); |
| + assert.ok(!wire.includes(toHex(vault.kVault)), "K_vault reached the network"); |
| +}); |
| + |
| +// Sizes are quantised, so the bucket cannot be read for item-length |
| +// fingerprints. A short item and a long one must be indistinguishable by |
| +// length until they cross a bucket boundary. |
| +test("item ciphertexts are quantised into 256-byte buckets", async () => { |
| + const net = fakeNet(); |
| + const store = items(net, vault); |
| + |
| + await store.create({ title: "a" }); |
| + await store.create({ title: "a".repeat(120), notes: "b".repeat(60) }); |
| + |
| + const lengths = new Set(); |
| + for (const s of net.sent) { |
| + if (!s.body || !s.body.ciphertext_hex) continue; |
| + const bytes = s.body.ciphertext_hex.length / 2; |
| + assert.equal((bytes - 16) % 256, 0, `ciphertext is ${bytes} bytes, not bucket-aligned`); |
| + lengths.add(bytes); |
| + } |
| + assert.equal(lengths.size, 1, "two very different items produced two different lengths"); |
| +}); |
| + |
| +// The AAD binds the vault, the item and the version, so a ciphertext moved to |
| +// another slot does not open. |
| +test("an item does not open in another slot", async () => { |
| + const itemId = fromHex("4580c14ff986936588b14e66e247978d"); |
| + const sealed = await sealItem(vault.id, vault.kVault, itemId, 1, CANARIES); |
| + |
| + assert.deepEqual(await openItem(vault.id, vault.kVault, itemId, 1, sealed), { |
| + ...CANARIES, |
| + }); |
| + |
| + const otherItem = fromHex("4580c14ff986936588b14e66e247978e"); |
| + const otherVault = fromHex("d7331f78b34eb0cc299252e2624070dc"); |
| + await assert.rejects(() => openItem(vault.id, vault.kVault, otherItem, 1, sealed), |
| + undefined, "moved to another item id and still opened"); |
| + await assert.rejects(() => openItem(otherVault, vault.kVault, itemId, 1, sealed), |
| + undefined, "moved to another vault and still opened"); |
| + await assert.rejects(() => openItem(vault.id, vault.kVault, itemId, 2, sealed), |
| + undefined, "rolled to another version and still opened"); |
| +}); |
| + |
| +// Fields absent from an item are still present in its body, so the ciphertext |
| +// does not publish which fields somebody filled in. |
| +test("every field is present whether or not it was filled in", async () => { |
| + const itemId = fromHex("4580c14ff986936588b14e66e247978d"); |
| + const sealed = await sealItem(vault.id, vault.kVault, itemId, 1, { title: "only" }); |
| + const opened = await openItem(vault.id, vault.kVault, itemId, 1, sealed); |
| + assert.deepEqual(Object.keys(opened).sort(), [...FIELDS].sort()); |
| +}); |
| + |
| +// The Account Key cache is per-device and is one named thing, not |
| +// localStorage calls scattered through the app. |
| +test("the Account Key cache is storage handed in, not reached for", () => { |
| + const backing = new Map(); |
| + const storage = { |
| + getItem: (k) => backing.get(k) ?? null, |
| + setItem: (k, v) => backing.set(k, v), |
| + removeItem: (k) => backing.delete(k), |
| + }; |
| + const keys = accountKeys(storage); |
| + const id = "b22c6a5eae1be9d6a773f474fd0532b0"; |
| + |
| + assert.equal(keys.get(id), null); |
| + const accountKey = fromHex("f60fb281e8c72f6725add4cbd40e7b08"); |
| + keys.set(id, accountKey); |
| + assert.equal(toHex(keys.get(id)), toHex(accountKey)); |
| + |
| + // Nothing global was touched. |
| + assert.equal(backing.size, 1); |
| + keys.forget(id); |
| + assert.equal(keys.get(id), null); |
| +}); |