carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Sharing a vault, and the rotation that taking somebody out of it is.
2//
3// A shared vault's keyset is sealed to each member's account public key by
4// ephemeral-static ECDH — the same ECIES the device handoff uses. Two things
5// follow from that and both are the point:
6//
7// - Sharing needs no unlock on the RECIPIENT's side. Sealing to a public
8// key is free, which is what the agent surface depends on later: a daemon
9// can be handed a vault without anybody typing a passphrase.
10// - The server holds an envelope it has no key for, so "shared" never means
11// "the operator can read it".
12//
13// The AAD is scoped to the VAULT and names the member, where the owner's own
14// copy is scoped to the account and names the vault. Neither blob can be
15// opened as the other, and a member's wrap cannot be moved to another member.
16
17import { concat, fromHex, randomBytes, toHex } from "./bytes.js";
18import { openFrom, sealTo, NONCE_LEN, PUBLIC_KEY_LEN } from "./ecies.js";
19import { sealItem } from "./items.js";
20import { aad, seal } from "./sealed.js";
21import { OBJECT_VAULT_KEYSET } from "./vaultkeys.js";
22
23export class NotTheOwnerError extends Error {}
24
25// keysetBody is what a member receives: the vault's name and its bulk key.
26// The drop-box private half is deliberately NOT in it — a member can read and
27// write items without being able to open captures addressed to the vault.
28function keysetBody(vault) {
29 return new TextEncoder().encode(JSON.stringify({
30 name: vault.name || "",
31 k_vault: toHex(vault.kVault),
32 }));
33}
34
35// splitEnvelope turns an ECIES envelope into the three fields the API stores
36// separately, so the wire shape matches every other sealed row rather than
37// inventing a fourth encoding for this one case.
38function splitEnvelope(envelope) {
39 return {
40 ephemeral_public_key_hex: toHex(envelope.slice(0, PUBLIC_KEY_LEN)),
41 sealed: {
42 nonce_hex: toHex(envelope.slice(PUBLIC_KEY_LEN, PUBLIC_KEY_LEN + NONCE_LEN)),
43 ciphertext_hex: toHex(envelope.slice(PUBLIC_KEY_LEN + NONCE_LEN)),
44 },
45 };
46}
47
48// lookupAccount resolves the public key to seal to. By account id, never by
49// address: an address-to-account endpoint would be an existence oracle for
50// every address anybody cared to try.
51export async function lookupAccount(net, accountIdHex) {
52 return net.get(`/api/account-key?account=${encodeURIComponent(accountIdHex)}`);
53}
54
55export async function members(net, vault) {
56 const { members: rows } = await net.get(`/api/members?vault=${vault.idHex}`);
57 return rows;
58}
59
60// share seals this vault's keyset to one account and posts it.
61export async function share(net, vault, memberIdHex, memberPublicKeyHex) {
62 if (!vault.owned) {
63 throw new NotTheOwnerError(
64 "contra: only the owner of a vault can share it");
65 }
66 const memberId = fromHex(memberIdHex);
67 const envelope = await sealTo(
68 fromHex(memberPublicKeyHex),
69 aad(vault.id, OBJECT_VAULT_KEYSET, memberId, 1),
70 keysetBody(vault));
71
72 await net.post("/api/members", {
73 vault_id_hex: vault.idHex,
74 account_id_hex: memberIdHex,
75 ...splitEnvelope(envelope),
76 });
77}
78
79// openSharedKeyset is the receiving half, used by unlock. Exported so a test
80// can exercise it directly against a wrap the API returned.
81export async function openSharedKeyset(dAcct, acctPublic, accountId, vaultId, row) {
82 const envelope = concat(
83 fromHex(row.ephemeral_public_key_hex),
84 fromHex(row.keyset.nonce_hex),
85 fromHex(row.keyset.ciphertext_hex));
86 return JSON.parse(new TextDecoder().decode(await openFrom(
87 dAcct, acctPublic, aad(vaultId, OBJECT_VAULT_KEYSET, accountId, 1), envelope)));
88}
89
90// rotate is revocation, and it is also how a vault heals after any exposure.
91//
92// It composes the whole new state in the browser and posts it as one body:
93// a new keyset sealed under RK for the owner, every item re-sealed under the
94// new key, and a wrap of the new keyset for each member who remains. Whoever
95// is not in `keep` stops being a member — which is why removal has no call of
96// its own. In a seal-to-public-key model revocation IS rotation, because the
97// removed member may already hold K_vault.
98//
99// What this does NOT do, and no honest implementation can: un-download what
100// somebody already has. PROTOCOL.md says so and the copy must not soften it.
101export async function rotate(net, opened, vault, items, keep, newKVault) {
102 if (!vault.owned) {
103 throw new NotTheOwnerError(
104 "contra: only the owner of a vault can rotate it");
105 }
106 const rotated = { ...vault, kVault: newKVault };
107
108 // The owner's own copy, under RK, with the AAD it has always had.
109 const nonce = randomBytes(12);
110 const keyset = {
111 nonce_hex: toHex(nonce),
112 ciphertext_hex: toHex(await seal(
113 opened.rk, nonce,
114 aad(opened.accountId, OBJECT_VAULT_KEYSET, vault.id, 1),
115 keysetBody(rotated))),
116 };
117
118 // Every item, re-sealed under the new key with its version bumped. Eager
119 // rather than lazy: lazy rotation needs a per-item key-generation marker,
120 // and that marker tells the operator which items changed since a removal.
121 const reSealed = [];
122 for (const item of items) {
123 const version = item.version + 1;
124 reSealed.push({
125 item_id_hex: item.idHex,
126 version,
127 ...await sealItem(vault.id, newKVault, item.id, version, item.fields),
128 });
129 }
130
131 const wraps = [];
132 for (const m of keep) {
133 const envelope = await sealTo(
134 fromHex(m.public_key_hex),
135 aad(vault.id, OBJECT_VAULT_KEYSET, fromHex(m.account_id_hex), 1),
136 keysetBody(rotated));
137 const { ephemeral_public_key_hex: eph, sealed } = splitEnvelope(envelope);
138 wraps.push({
139 account_id_hex: m.account_id_hex,
140 ephemeral_public_key_hex: eph,
141 ...sealed,
142 });
143 }
144
145 const res = await net.post("/api/vault/rotate", {
146 vault_id_hex: vault.idHex,
147 expected_epoch: vault.epoch,
148 keyset,
149 items: reSealed,
150 members: wraps,
151 });
152 return { ...rotated, epoch: res.epoch };
153}
154