| 1 | // Items: sealed in the browser, opened in the browser, and nowhere else. |
| 2 | // |
| 3 | // An item is one blob. Title, URL, username, password and notes all go |
| 4 | // inside it, because separate fields — even sealed ones — would publish which |
| 5 | // fields an item has and how long each is. The list you see is built from |
| 6 | // items this browser has already opened; the server has never seen one. |
| 7 | // |
| 8 | // The network is passed in rather than reached for, so tests hand in a fake |
| 9 | // and this module has no opinion about fetch. |
| 10 | |
| 11 | import { fromHex, randomBytes, toHex } from "./bytes.js"; |
| 12 | import { aad, open, pad, seal, unpad, OBJECT_ITEM } from "./sealed.js"; |
| 13 | |
| 14 | // FIELDS is the shape of an item body. Everything here is inside the |
| 15 | // ciphertext; adding a field costs nothing at rest and leaks nothing. |
| 16 | export const FIELDS = ["title", "url", "username", "password", "notes"]; |
| 17 | |
| 18 | function body(fields) { |
| 19 | const out = {}; |
| 20 | for (const f of FIELDS) out[f] = typeof fields[f] === "string" ? fields[f] : ""; |
| 21 | return out; |
| 22 | } |
| 23 | |
| 24 | // sealItem quantises into 256-byte buckets before sealing, so the bucket |
| 25 | // cannot be read for item-length fingerprints. The store refuses a ciphertext |
| 26 | // that is not bucket-aligned, so this is a control on both sides rather than |
| 27 | // a convention on one. |
| 28 | export async function sealItem(vaultId, kVault, itemId, version, fields) { |
| 29 | const nonce = randomBytes(12); |
| 30 | const plaintext = pad(new TextEncoder().encode(JSON.stringify(body(fields)))); |
| 31 | const ciphertext = await seal( |
| 32 | kVault, nonce, aad(vaultId, OBJECT_ITEM, itemId, version), plaintext); |
| 33 | return { nonce_hex: toHex(nonce), ciphertext_hex: toHex(ciphertext) }; |
| 34 | } |
| 35 | |
| 36 | export async function openItem(vaultId, kVault, itemId, version, sealed) { |
| 37 | const plaintext = unpad(await open( |
| 38 | kVault, fromHex(sealed.nonce_hex), |
| 39 | aad(vaultId, OBJECT_ITEM, itemId, version), |
| 40 | fromHex(sealed.ciphertext_hex))); |
| 41 | return body(JSON.parse(new TextDecoder().decode(plaintext))); |
| 42 | } |
| 43 | |
| 44 | // StaleItemError is a lost compare-and-swap: somebody wrote this item between |
| 45 | // this client reading it and writing it back. |
| 46 | // |
| 47 | // Its own type because the caller's response is specific and is not "retry": |
| 48 | // the edit in hand was composed against bytes that are no longer current, so |
| 49 | // resending it is exactly the silent erase the CAS exists to stop. Refetch, |
| 50 | // show both, let the person choose. |
| 51 | export class StaleItemError extends Error { |
| 52 | constructor(item, cause) { |
| 53 | super("contra: this item changed somewhere else while you were editing it"); |
| 54 | this.item = item; |
| 55 | this.cause = cause; |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // Items is the vault's item list as the console works with it: opened once on |
| 60 | // load, kept in memory, never written back to disk unsealed. |
| 61 | export function items(net, vault) { |
| 62 | return { |
| 63 | async list() { |
| 64 | const { items: rows } = await net.get(`/api/items?vault=${vault.idHex}`); |
| 65 | const out = []; |
| 66 | for (const row of rows) { |
| 67 | const id = fromHex(row.item_id_hex); |
| 68 | out.push({ |
| 69 | id, |
| 70 | idHex: row.item_id_hex, |
| 71 | version: row.version, |
| 72 | fields: await openItem(vault.id, vault.kVault, id, row.version, row), |
| 73 | }); |
| 74 | } |
| 75 | return out; |
| 76 | }, |
| 77 | |
| 78 | async create(fields) { |
| 79 | const id = randomBytes(16); |
| 80 | const sealed = await sealItem(vault.id, vault.kVault, id, 1, fields); |
| 81 | await net.post("/api/items", { |
| 82 | vault_id_hex: vault.idHex, |
| 83 | item_id_hex: toHex(id), |
| 84 | version: 1, |
| 85 | ...sealed, |
| 86 | }); |
| 87 | return { id, idHex: toHex(id), version: 1, fields: body(fields) }; |
| 88 | }, |
| 89 | |
| 90 | // Editing bumps the version, and the version is in the AAD — so a server |
| 91 | // that served an old ciphertext back under a new number would produce a |
| 92 | // blob that does not open, rather than one that quietly reverts an edit. |
| 93 | // |
| 94 | // expected_version is the compare-and-swap: it says which version this |
| 95 | // edit replaces, and the server refuses with 409 if somebody else got |
| 96 | // there first. Without it the second of two writers silently erases the |
| 97 | // first, which is invisible on one device and is data loss the moment |
| 98 | // there are two — or two members of a shared vault. |
| 99 | async update(item, fields) { |
| 100 | const version = item.version + 1; |
| 101 | const sealed = await sealItem(vault.id, vault.kVault, item.id, version, fields); |
| 102 | try { |
| 103 | await net.put("/api/items", { |
| 104 | vault_id_hex: vault.idHex, |
| 105 | item_id_hex: item.idHex, |
| 106 | expected_version: item.version, |
| 107 | version, |
| 108 | ...sealed, |
| 109 | }); |
| 110 | } catch (err) { |
| 111 | if (err.status === 409) throw new StaleItemError(item, err); |
| 112 | throw err; |
| 113 | } |
| 114 | return { ...item, version, fields: body(fields) }; |
| 115 | }, |
| 116 | |
| 117 | async remove(item) { |
| 118 | await net.post("/api/items/delete", { |
| 119 | vault_id_hex: vault.idHex, |
| 120 | item_id_hex: item.idHex, |
| 121 | }); |
| 122 | }, |
| 123 | }; |
| 124 | } |
| 125 | |