carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The vault console: unlock, then list, create, edit and delete items.
2//
3// Client-shape by force. Sealing has to happen where the keys are, so a
4// server-rendered vault is not a thing that can exist — the server has never
5// seen a title and could not render one if it wanted to.
6//
7// Every node here is built rather than assigned: no innerHTML, ever, on
8// content that came out of a vault. That is not caution about our own data,
9// it is that an item's title is whatever a person or an importer put there.
10
11import { net } from "../crypto/net.js";
12import { items as itemStore } from "../crypto/items.js";
13import { accountKeys, unlock } from "../crypto/vaultkeys.js";
14import { FIELDS } from "../crypto/items.js";
15import { wireSharing } from "./vaultshare.js";
16
17function el(tag, attrs = {}, ...children) {
18 const node = document.createElement(tag);
19 for (const [k, v] of Object.entries(attrs)) {
20 if (k === "class") node.className = v;
21 else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
22 else node.setAttribute(k, v);
23 }
24 for (const c of children) {
25 if (c == null) continue;
26 node.append(typeof c === "string" ? document.createTextNode(c) : c);
27 }
28 return node;
29}
30
31const LABELS = {
32 title: "Name", url: "Website", username: "Username",
33 password: "Password", notes: "Notes",
34};
35
36function fieldRow(name, value) {
37 return el("p", {},
38 el("label", { for: `f-${name}` }, LABELS[name]),
39 name === "notes"
40 ? el("textarea", { id: `f-${name}`, name })
41 : el("input", {
42 id: `f-${name}`, name,
43 type: name === "password" ? "password" : "text",
44 autocomplete: "off",
45 }));
46}
47
48function readForm(form) {
49 const out = {};
50 for (const f of FIELDS) out[f] = form.querySelector(`[name=${f}]`).value;
51 return out;
52}
53
54function fillForm(form, fields) {
55 for (const f of FIELDS) form.querySelector(`[name=${f}]`).value = fields[f] || "";
56}
57
58// view holds the whole screen's state in one place, so there is one render
59// path rather than thirty callers each patching a corner of the DOM. The
60// family's evidence for why: Eleven's hand-rolled shell reached 8,722 lines
61// with one render function called from 31 places.
62function view(root, store) {
63 let list = [];
64 let editing = null;
65
66 const status = el("p", { "data-status": "", "aria-live": "polite" });
67 const form = el("form", { "data-item-form": "" },
68 ...FIELDS.map((f) => fieldRow(f, "")),
69 el("p", {},
70 el("button", { type: "submit", "data-save": "" }, "Save"),
71 " ",
72 el("button", { type: "button", "data-cancel": "" }, "Cancel")));
73 const table = el("ul", { "data-items": "" });
74
75 function say(text) {
76 status.replaceChildren(document.createTextNode(text));
77 }
78
79 function draw() {
80 table.replaceChildren(...list.map((item) =>
81 el("li", { "data-item": item.idHex },
82 el("strong", {}, item.fields.title || "(no name)"),
83 item.fields.username ? el("span", {}, " — " + item.fields.username) : null,
84 " ",
85 el("button", {
86 type: "button", "data-edit": item.idHex,
87 onclick: () => {
88 editing = item;
89 fillForm(form, item.fields);
90 say(`Editing ${item.fields.title || "(no name)"}.`);
91 },
92 }, "Edit"),
93 " ",
94 el("button", {
95 type: "button", "data-delete": item.idHex,
96 onclick: async () => {
97 await store.remove(item);
98 list = list.filter((i) => i.idHex !== item.idHex);
99 if (editing && editing.idHex === item.idHex) editing = null;
100 draw();
101 say("Deleted.");
102 },
103 }, "Delete"))));
104 }
105
106 form.addEventListener("submit", async (e) => {
107 e.preventDefault();
108 const fields = readForm(form);
109 if (editing) {
110 const updated = await store.update(editing, fields);
111 list = list.map((i) => (i.idHex === updated.idHex ? updated : i));
112 editing = null;
113 say("Saved.");
114 } else {
115 list = [...list, await store.create(fields)];
116 say("Added.");
117 }
118 fillForm(form, {});
119 draw();
120 });
121
122 form.querySelector("[data-cancel]").addEventListener("click", () => {
123 editing = null;
124 fillForm(form, {});
125 say("");
126 });
127
128 root.replaceChildren(status, form, table);
129 return {
130 async load() {
131 list = await store.list();
132 draw();
133 say(list.length === 0 ? "No items yet." : `${list.length} item(s).`);
134 },
135 };
136}
137
138// vaultLabel names a vault on the picker. The name comes out of the keyset,
139// so the server has never seen it and could not label this list if it tried.
140function vaultLabel(v) {
141 const name = v.name || "Vault";
142 return v.owned ? name : `${name} (shared with you)`;
143}
144
145// showVaults draws the picker and opens one vault.
146//
147// Before sharing there was only ever vaults[0], and picking it silently was
148// indistinguishable from picking correctly. With a shared vault in the list
149// that stopped being true: a member's own empty vault sorts first, so the
150// vault they were actually given never appeared. A browser walk caught it,
151// which is the argument for having one.
152async function showVaults(root, opened) {
153 const picker = document.querySelector("[data-vault-picker]");
154 const sharingPanel = document.querySelector("[data-sharing]");
155
156 async function openOne(vault) {
157 const store = itemStore(net, vault);
158 await view(root, store).load();
159 // Somebody else's vault prints on different stock — the cloakroom rule,
160 // not a badge saying "shared". Whose sheet you are writing on is the
161 // thing you want to know before you type, not after.
162 root.toggleAttribute("data-shared", !vault.owned);
163 // Sharing needs the vault key — it seals the keyset to somebody, and
164 // removing somebody re-seals every item — so it only appears once a
165 // vault is actually open, and it is rewired per vault.
166 if (sharingPanel) {
167 sharingPanel.hidden = false;
168 wireSharing(document, opened, vault, store);
169 }
170 }
171
172 if (picker) {
173 picker.replaceChildren(...opened.vaults.map((v, i) =>
174 el("option", { value: String(i) }, vaultLabel(v))));
175 // Only worth showing when there is a choice to make.
176 picker.parentElement.hidden = opened.vaults.length < 2;
177 picker.addEventListener("change", async () => {
178 await openOne(opened.vaults[Number(picker.value)]);
179 });
180 }
181 await openOne(opened.vaults[0]);
182}
183
184async function main() {
185 const root = document.querySelector("[data-vault]");
186 if (!root) return;
187
188 const gate = document.querySelector("[data-unlock]");
189 const bootstrap = await net.get("/api/bootstrap");
190 const keys = accountKeys(window.localStorage);
191
192 // Signing in is not unlocking. The session proves who you are; the keys
193 // come from a wrap the server cannot open, so this asks again.
194 gate.addEventListener("submit", async (e) => {
195 e.preventDefault();
196 const passphrase = gate.querySelector("[name=passphrase]").value;
197 const accountKey = keys.get(bootstrap.account_id_hex);
198 const box = gate.querySelector("[data-status]");
199 if (!accountKey) {
200 // Never a dead end. This browser can still get the key three ways, and
201 // the screen that offers all three is one link away.
202 box.replaceChildren(
203 document.createTextNode(
204 "This device does not have your Account Key yet, so it cannot open " +
205 "the vault. "),
206 Object.assign(document.createElement("a"), {
207 href: "/device",
208 textContent: "Give it the key",
209 }),
210 document.createTextNode(
211 " — from another signed-in device, or from your printed card."));
212 return;
213 }
214 box.replaceChildren(document.createTextNode("Unlocking…"));
215 try {
216 const opened = await unlock(bootstrap, passphrase, accountKey);
217 gate.hidden = true;
218 root.hidden = false;
219 await showVaults(root, opened);
220 } catch (err) {
221 box.replaceChildren(document.createTextNode(err.message));
222 }
223 });
224}
225
226main();
227