carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

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
11import { fromHex, randomBytes, toHex } from "./bytes.js";
12import { 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.
16export const FIELDS = ["title", "url", "username", "password", "notes"];
17
18function 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.
28export 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
36export 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.
51export 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.
61export 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