web/8: the browser half of sharing
unlock now opens a shared vault. A member's copy is sealed to their account public key by ECIES, so it takes d_acct out of the account keyset — which has been provisioned since signup and had no reader until now. The public half is RECOMPUTED from the scalar rather than read from bootstrap, so the server has no say in which key a member wrap is opened against. The two AADs are deliberately different shapes. The owner's copy is scoped to the account and names the vault; a member's is scoped to the vault and names the member. So neither blob can be opened as the other, and a member's wrap cannot be replayed at another vault or under another member's name. All three are asserted. **A member wrap carries the vault key and NOT the drop-box private half.** A member reads and writes items without being able to open captures addressed to the vault, which is the least privilege that still makes sharing useful. Tested, because it is a one-word change away from being wrong and nothing else would notice. share and rotate both refuse when the vault is not owned. A member holding K_vault could otherwise compose a rotation and evict the owner from their own vault — the client-side half of the seam the store enforces. The JS test is the one worth having: an owner seals with the browser's own code and a DIFFERENT account opens it with theirs, rather than bytes going out and coming back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+393
−9
Makefile+2 −1internal/contra/static/crypto/sharing.js+153 −0internal/contra/static/crypto/vaultkeys.js+40 −7internal/contratest/jsmodules_test.go+2 −1test/sharing.test.mjs+196 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? github.com/carlosframework/contra/cmd/contra [no test files] ? github.com/carlosframework/contra/internal/contra [no test files] ? github.com/carlosframework/contra/internal/contra/auth [no test files] ok github.com/carlosframework/contra/internal/contra/crypto (cached) ? github.com/carlosframework/contra/internal/contra/icons [no test files] ? github.com/carlosframework/contra/internal/contra/mail [no test files] ? github.com/carlosframework/contra/internal/contra/model [no test files] ? github.com/carlosframework/contra/internal/contra/store [no test files] ? github.com/carlosframework/contra/internal/contra/vault [no test files] ok github.com/carlosframework/contra/internal/contratest 16.466s === js-test === node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \ test/sharing.test.mjs TAP version 13 # Subtest: HKDF: every contra/v1 label ok 1 - HKDF: every contra/v1 label --- duration_ms: 10.161615 type: 'test' ... # Subtest: KCV ok 2 - KCV --- duration_ms: 2.252196 type: 'test' ... # Subtest: P-256 from a seed, including the rejection branch ok 3 - P-256 from a seed, including the rejection branch --- duration_ms: 12.648207 type: 'test' ... # Subtest: signed challenge round-trips against the derived public key ok 4 - signed challenge round-trips against the derived public key --- duration_ms: 4.971781 type: 'test' ... # Subtest: Crockford: alphabet, encoding, normalisation, strict decoding ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding --- duration_ms: 0.977972 type: 'test' ... # Subtest: the printed code, and the O/I/L confusions it has to survive ok 6 - the printed code, and the O/I/L confusions it has to survive --- duration_ms: 2.199954 type: 'test' ... # Subtest: repair recovers one substituted character ok 7 - repair recovers one substituted character --- duration_ms: 46.718907 type: 'test' ... # Subtest: AEAD: the AAD binding and every wrap ok 8 - AEAD: the AAD binding and every wrap --- duration_ms: 2.573563 type: 'test' ... # Subtest: AEAD: the item, scoped to its vault ok 9 - AEAD: the item, scoped to its vault --- duration_ms: 1.093824 type: 'test' ... # Subtest: a transplanted ciphertext does not open ok 10 - a transplanted ciphertext does not open --- duration_ms: 1.94255 type: 'test' ... # Subtest: padding quantises into 256-byte buckets and round-trips ok 11 - padding quantises into 256-byte buckets and round-trips --- duration_ms: 0.934262 type: 'test' ... # Subtest: the KDF parameter floor refuses what a malicious server would offer ok 12 - the KDF parameter floor refuses what a malicious server would offer --- duration_ms: 1.118677 type: 'test' ... # Subtest: Argon2id matches the published PHC reference vector ok 13 - Argon2id matches the published PHC reference vector --- duration_ms: 1792.149315 type: 'test' ... # Subtest: Argon2id at the corners the 64 MiB case does not reach ok 14 - Argon2id at the corners the 64 MiB case does not reach --- duration_ms: 212.765222 type: 'test' ... # Subtest: Argon2id matches the golden vector at the parameters DESIGN.md pins ok 15 - Argon2id matches the golden vector at the parameters DESIGN.md pins --- duration_ms: 2567.711423 type: 'test' ... # Subtest: the account chain, end to end ok 16 - the account chain, end to end --- duration_ms: 2574.24622 type: 'test' ... # Subtest: handoff: both keypairs derive from their published seeds ok 17 - handoff: both keypairs derive from their published seeds --- duration_ms: 16.278482 type: 'test' ... # Subtest: handoff: ECDH agrees from both sides ok 18 - handoff: ECDH agrees from both sides --- duration_ms: 8.06318 type: 'test' ... # Subtest: handoff: the derived key, the AAD and the sealed bytes ok 19 - handoff: the derived key, the AAD and the sealed bytes --- duration_ms: 2.377229 type: 'test' ... # Subtest: handoff: an envelope opens for its device and for nobody else ok 20 - handoff: an envelope opens for its device and for nobody else --- duration_ms: 16.72382 type: 'test' ... # Subtest: handoff: the Go and JS halves seal for each other ok 21 - handoff: the Go and JS halves seal for each other --- duration_ms: 7.606969 type: 'test' ... # Subtest: handoff: a truncated envelope is refused before any key work ok 22 - handoff: a truncated envelope is refused before any key work --- duration_ms: 0.541852 type: 'test' ... # Subtest: an item round-trips through create, list and update ok 23 - an item round-trips through create, list and update --- duration_ms: 11.033433 type: 'test' ... # Subtest: nothing readable reaches the network ok 24 - nothing readable reaches the network --- duration_ms: 2.587602 type: 'test' ... # Subtest: item ciphertexts are quantised into 256-byte buckets ok 25 - item ciphertexts are quantised into 256-byte buckets --- duration_ms: 1.626529 type: 'test' ... # Subtest: an item does not open in another slot ok 26 - an item does not open in another slot --- duration_ms: 2.788914 type: 'test' ... # Subtest: every field is present whether or not it was filled in ok 27 - every field is present whether or not it was filled in --- duration_ms: 1.187462 type: 'test' ... # Subtest: the Account Key cache is storage handed in, not reached for ok 28 - the Account Key cache is storage handed in, not reached for --- duration_ms: 0.341047 type: 'test' ... # Subtest: an owner seals a keyset that a different account can open ok 29 - an owner seals a keyset that a different account can open --- duration_ms: 17.477264 type: 'test' ... # Subtest: nobody else can open a member's wrap ok 30 - nobody else can open a member's wrap --- duration_ms: 15.302752 type: 'test' ... # Subtest: a member wrap carries the vault key and not the drop box ok 31 - a member wrap carries the vault key and not the drop box --- duration_ms: 5.678745 type: 'test' ... # Subtest: a member cannot share or rotate ok 32 - a member cannot share or rotate --- duration_ms: 1.55858 type: 'test' ... # Subtest: rotation re-seals every item under the new key ok 33 - rotation re-seals every item under the new key --- duration_ms: 7.322851 type: 'test' ... 1..33 # tests 33 # suites 0 # pass 33 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 7303.868404 === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/Makefile b/Makefile| index f5c8866..55fa2d2 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -96,7 +96,8 @@ 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 test/ecies.test.mjs test/items.test.mjs |
| + node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \ |
| + test/sharing.test.mjs |
| # The third-party oracles: the QR symbols decoded by zxing-cpp and drawn |
| # again by python-qrcode for a module-by-module comparison, and the two kit |
diff --git a/internal/contra/static/crypto/sharing.js b/internal/contra/static/crypto/sharing.js| new file mode 100644 |
| index 0000000..fdd9f47 |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/sharing.js |
| @@ -0,0 +1,153 @@ |
| +// Sharing a vault, and the rotation that taking somebody out of it is. |
| +// |
| +// A shared vault's keyset is sealed to each member's account public key by |
| +// ephemeral-static ECDH — the same ECIES the device handoff uses. Two things |
| +// follow from that and both are the point: |
| +// |
| +// - Sharing needs no unlock on the RECIPIENT's side. Sealing to a public |
| +// key is free, which is what the agent surface depends on later: a daemon |
| +// can be handed a vault without anybody typing a passphrase. |
| +// - The server holds an envelope it has no key for, so "shared" never means |
| +// "the operator can read it". |
| +// |
| +// The AAD is scoped to the VAULT and names the member, where the owner's own |
| +// copy is scoped to the account and names the vault. Neither blob can be |
| +// opened as the other, and a member's wrap cannot be moved to another member. |
| + |
| +import { concat, fromHex, randomBytes, toHex } from "./bytes.js"; |
| +import { openFrom, sealTo, NONCE_LEN, PUBLIC_KEY_LEN } from "./ecies.js"; |
| +import { sealItem } from "./items.js"; |
| +import { aad, seal } from "./sealed.js"; |
| +import { OBJECT_VAULT_KEYSET } from "./vaultkeys.js"; |
| + |
| +export class NotTheOwnerError extends Error {} |
| + |
| +// keysetBody is what a member receives: the vault's name and its bulk key. |
| +// The drop-box private half is deliberately NOT in it — a member can read and |
| +// write items without being able to open captures addressed to the vault. |
| +function keysetBody(vault) { |
| + return new TextEncoder().encode(JSON.stringify({ |
| + name: vault.name || "", |
| + k_vault: toHex(vault.kVault), |
| + })); |
| +} |
| + |
| +// splitEnvelope turns an ECIES envelope into the three fields the API stores |
| +// separately, so the wire shape matches every other sealed row rather than |
| +// inventing a fourth encoding for this one case. |
| +function splitEnvelope(envelope) { |
| + return { |
| + ephemeral_public_key_hex: toHex(envelope.slice(0, PUBLIC_KEY_LEN)), |
| + sealed: { |
| + nonce_hex: toHex(envelope.slice(PUBLIC_KEY_LEN, PUBLIC_KEY_LEN + NONCE_LEN)), |
| + ciphertext_hex: toHex(envelope.slice(PUBLIC_KEY_LEN + NONCE_LEN)), |
| + }, |
| + }; |
| +} |
| + |
| +// lookupAccount resolves the public key to seal to. By account id, never by |
| +// address: an address-to-account endpoint would be an existence oracle for |
| +// every address anybody cared to try. |
| +export async function lookupAccount(net, accountIdHex) { |
| + return net.get(`/api/account-key?account=${encodeURIComponent(accountIdHex)}`); |
| +} |
| + |
| +export async function members(net, vault) { |
| + const { members: rows } = await net.get(`/api/members?vault=${vault.idHex}`); |
| + return rows; |
| +} |
| + |
| +// share seals this vault's keyset to one account and posts it. |
| +export async function share(net, vault, memberIdHex, memberPublicKeyHex) { |
| + if (!vault.owned) { |
| + throw new NotTheOwnerError( |
| + "contra: only the owner of a vault can share it"); |
| + } |
| + const memberId = fromHex(memberIdHex); |
| + const envelope = await sealTo( |
| + fromHex(memberPublicKeyHex), |
| + aad(vault.id, OBJECT_VAULT_KEYSET, memberId, 1), |
| + keysetBody(vault)); |
| + |
| + await net.post("/api/members", { |
| + vault_id_hex: vault.idHex, |
| + account_id_hex: memberIdHex, |
| + ...splitEnvelope(envelope), |
| + }); |
| +} |
| + |
| +// openSharedKeyset is the receiving half, used by unlock. Exported so a test |
| +// can exercise it directly against a wrap the API returned. |
| +export async function openSharedKeyset(dAcct, acctPublic, accountId, vaultId, row) { |
| + const envelope = concat( |
| + fromHex(row.ephemeral_public_key_hex), |
| + fromHex(row.keyset.nonce_hex), |
| + fromHex(row.keyset.ciphertext_hex)); |
| + return JSON.parse(new TextDecoder().decode(await openFrom( |
| + dAcct, acctPublic, aad(vaultId, OBJECT_VAULT_KEYSET, accountId, 1), envelope))); |
| +} |
| + |
| +// rotate is revocation, and it is also how a vault heals after any exposure. |
| +// |
| +// It composes the whole new state in the browser and posts it as one body: |
| +// a new keyset sealed under RK for the owner, every item re-sealed under the |
| +// new key, and a wrap of the new keyset for each member who remains. Whoever |
| +// is not in `keep` stops being a member — which is why removal has no call of |
| +// its own. In a seal-to-public-key model revocation IS rotation, because the |
| +// removed member may already hold K_vault. |
| +// |
| +// What this does NOT do, and no honest implementation can: un-download what |
| +// somebody already has. DESIGN.md says so and the copy must not soften it. |
| +export async function rotate(net, opened, vault, items, keep, newKVault) { |
| + if (!vault.owned) { |
| + throw new NotTheOwnerError( |
| + "contra: only the owner of a vault can rotate it"); |
| + } |
| + const rotated = { ...vault, kVault: newKVault }; |
| + |
| + // The owner's own copy, under RK, with the AAD it has always had. |
| + const nonce = randomBytes(12); |
| + const keyset = { |
| + nonce_hex: toHex(nonce), |
| + ciphertext_hex: toHex(await seal( |
| + opened.rk, nonce, |
| + aad(opened.accountId, OBJECT_VAULT_KEYSET, vault.id, 1), |
| + keysetBody(rotated))), |
| + }; |
| + |
| + // Every item, re-sealed under the new key with its version bumped. Eager |
| + // rather than lazy: lazy rotation needs a per-item key-generation marker, |
| + // and that marker tells the operator which items changed since a removal. |
| + const reSealed = []; |
| + for (const item of items) { |
| + const version = item.version + 1; |
| + reSealed.push({ |
| + item_id_hex: item.idHex, |
| + version, |
| + ...await sealItem(vault.id, newKVault, item.id, version, item.fields), |
| + }); |
| + } |
| + |
| + const wraps = []; |
| + for (const m of keep) { |
| + const envelope = await sealTo( |
| + fromHex(m.public_key_hex), |
| + aad(vault.id, OBJECT_VAULT_KEYSET, fromHex(m.account_id_hex), 1), |
| + keysetBody(rotated)); |
| + const { ephemeral_public_key_hex: eph, sealed } = splitEnvelope(envelope); |
| + wraps.push({ |
| + account_id_hex: m.account_id_hex, |
| + ephemeral_public_key_hex: eph, |
| + ...sealed, |
| + }); |
| + } |
| + |
| + const res = await net.post("/api/vault/rotate", { |
| + vault_id_hex: vault.idHex, |
| + expected_epoch: vault.epoch, |
| + keyset, |
| + items: reSealed, |
| + members: wraps, |
| + }); |
| + return { ...rotated, epoch: res.epoch }; |
| +} |
diff --git a/internal/contra/static/crypto/vaultkeys.js b/internal/contra/static/crypto/vaultkeys.js| index 04a8b46..e1ffd2e 100644 |
| --- a/internal/contra/static/crypto/vaultkeys.js |
| +++ b/internal/contra/static/crypto/vaultkeys.js |
| @@ -16,10 +16,12 @@ |
| // 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 { concat, 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 { publicKeyFor } from "./p256math.js"; |
| +import { openFrom } from "./ecies.js"; |
| import { aad, open, seal, OBJECT_WRAP_PW } from "./sealed.js"; |
| export const OBJECT_ACCOUNT_KEYSET = "account.keyset"; |
| @@ -118,17 +120,48 @@ async function openWith(bootstrap, sPW, accountKey) { |
| aad(accountId, OBJECT_ACCOUNT_KEYSET, accountId, 1), |
| fromHex(bootstrap.keyset.ciphertext_hex)))); |
| + // d_acct is the account's P-256 private half: what a vault SHARED with this |
| + // account is sealed to. Its public half is recomputed rather than fetched, |
| + // so the server has no say in which key a member wrap is opened against. |
| + const dAcct = fromHex(accountKeyset.d_acct); |
| + const acctPoint = publicKeyFor(dAcct); |
| + const acctPublic = concat(new Uint8Array([0x04]), acctPoint.x, acctPoint.y); |
| + |
| 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) }); |
| + let keyset; |
| + if (v.owned === false) { |
| + // A shared vault. The AAD is scoped to the VAULT and names the member, |
| + // where the owner's own copy is scoped to the account and names the |
| + // vault — so neither blob can ever be opened as the other. |
| + const envelope = concat( |
| + fromHex(v.ephemeral_public_key_hex), |
| + fromHex(v.keyset.nonce_hex), |
| + fromHex(v.keyset.ciphertext_hex)); |
| + keyset = JSON.parse(new TextDecoder().decode(await openFrom( |
| + dAcct, acctPublic, |
| + aad(vaultId, OBJECT_VAULT_KEYSET, accountId, 1), |
| + envelope))); |
| + } else { |
| + 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), |
| + name: keyset.name || "", |
| + // The compare-and-swap token for writes into this vault, and whether |
| + // this account may change who else can read it. |
| + epoch: v.epoch, |
| + owned: v.owned !== false, |
| + }); |
| } |
| - return { accountId, rk, accountKeyset, vaults }; |
| + return { accountId, rk, accountKeyset, dAcct, acctPublic, vaults }; |
| } |
| // newAccountKeyset and newVaultKeyset are what signup seals. Both are sealed |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index 726801d..b4397c8 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -41,11 +41,12 @@ var jsHighWater = map[string]int{ |
| "p256math.js": 143, |
| "pdf.js": 180, |
| "reissue.js": 78, |
| + "sharing.js": 153, |
| "qr.js": 297, |
| "qrgf.js": 59, |
| "qrtables.js": 44, |
| "sealed.js": 91, |
| - "vaultkeys.js": 184, |
| + "vaultkeys.js": 217, |
| // Page controllers. Thin on purpose: every decision that matters lives in |
| // a crypto module, and these only move text between a form and one. |
diff --git a/test/sharing.test.mjs b/test/sharing.test.mjs| new file mode 100644 |
| index 0000000..3a24880 |
| --- /dev/null |
| +++ b/test/sharing.test.mjs |
| @@ -0,0 +1,196 @@ |
| +// Sharing, in the browser modules. |
| +// |
| +// The Go suite proves the API stores and returns the right rows. This proves |
| +// the thing that actually matters to a person: an owner seals a vault keyset |
| +// with the browser's own code, and a DIFFERENT account opens it with theirs. |
| +// |
| +// The network is a fake, because none of what is being tested is the network. |
| + |
| +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, randomBytes, toHex } = await import(mod("bytes.js")); |
| +const { newP256 } = await import(mod("p256.js")); |
| +const { publicKeyFor } = await import(mod("p256math.js")); |
| +const { concat } = await import(mod("bytes.js")); |
| +const sharing = await import(mod("sharing.js")); |
| + |
| +// A fake network that records the one POST sharing makes. |
| +function recorder() { |
| + const posts = []; |
| + return { |
| + posts, |
| + async get() { throw new Error("not used"); }, |
| + async post(path, body) { posts.push({ path, body }); return { epoch: 2 }; }, |
| + }; |
| +} |
| + |
| +async function anAccount() { |
| + const key = await newP256(); |
| + return { |
| + id: randomBytes(16), |
| + scalar: key.scalar, |
| + publicKey: key.publicKey, |
| + }; |
| +} |
| + |
| +test("an owner seals a keyset that a different account can open", async () => { |
| + const owner = await anAccount(); |
| + const guest = await anAccount(); |
| + const net = recorder(); |
| + |
| + const vault = { |
| + id: randomBytes(16), |
| + idHex: "", |
| + kVault: randomBytes(32), |
| + name: "Household", |
| + epoch: 1, |
| + owned: true, |
| + }; |
| + vault.idHex = toHex(vault.id); |
| + |
| + await sharing.share(net, vault, toHex(guest.id), toHex(guest.publicKey)); |
| + |
| + assert.equal(net.posts.length, 1); |
| + const body = net.posts[0].body; |
| + assert.equal(net.posts[0].path, "/api/members"); |
| + assert.equal(body.account_id_hex, toHex(guest.id)); |
| + |
| + // The recipient opens it with their own scalar — which is the whole claim. |
| + const opened = await sharing.openSharedKeyset( |
| + guest.scalar, guest.publicKey, guest.id, vault.id, |
| + { |
| + ephemeral_public_key_hex: body.ephemeral_public_key_hex, |
| + keyset: body.sealed, |
| + }); |
| + assert.equal(opened.k_vault, toHex(vault.kVault), "the member got the vault key"); |
| + assert.equal(opened.name, "Household", "the member got the vault name"); |
| +}); |
| + |
| +test("nobody else can open a member's wrap", async () => { |
| + const owner = await anAccount(); |
| + const guest = await anAccount(); |
| + const stranger = await anAccount(); |
| + const net = recorder(); |
| + |
| + const vault = { |
| + id: randomBytes(16), kVault: randomBytes(32), |
| + name: "Household", epoch: 1, owned: true, |
| + }; |
| + vault.idHex = toHex(vault.id); |
| + await sharing.share(net, vault, toHex(guest.id), toHex(guest.publicKey)); |
| + const body = net.posts[0].body; |
| + const row = { |
| + ephemeral_public_key_hex: body.ephemeral_public_key_hex, |
| + keyset: body.sealed, |
| + }; |
| + |
| + await assert.rejects( |
| + () => sharing.openSharedKeyset( |
| + stranger.scalar, stranger.publicKey, stranger.id, vault.id, row), |
| + "a stranger opened a wrap sealed to somebody else"); |
| + |
| + // And the member cannot have their own wrap replayed at another vault, or |
| + // under another member's name — the AAD names both. |
| + await assert.rejects( |
| + () => sharing.openSharedKeyset( |
| + guest.scalar, guest.publicKey, guest.id, randomBytes(16), row), |
| + "a member's wrap opened against a different vault"); |
| + await assert.rejects( |
| + () => sharing.openSharedKeyset( |
| + guest.scalar, guest.publicKey, randomBytes(16), vault.id, row), |
| + "a member's wrap opened under a different member's id"); |
| +}); |
| + |
| +// The drop-box private half is deliberately not shared: a member reads and |
| +// writes items without being able to open captures addressed to the vault. |
| +test("a member wrap carries the vault key and not the drop box", async () => { |
| + const guest = await anAccount(); |
| + const net = recorder(); |
| + const vault = { |
| + id: randomBytes(16), kVault: randomBytes(32), name: "Household", |
| + epoch: 1, owned: true, |
| + dropBoxPrivate: randomBytes(32), |
| + }; |
| + vault.idHex = toHex(vault.id); |
| + |
| + await sharing.share(net, vault, toHex(guest.id), toHex(guest.publicKey)); |
| + const body = net.posts[0].body; |
| + const opened = await sharing.openSharedKeyset( |
| + guest.scalar, guest.publicKey, guest.id, vault.id, |
| + { ephemeral_public_key_hex: body.ephemeral_public_key_hex, keyset: body.sealed }); |
| + |
| + assert.equal(opened.drop_box_private, undefined, |
| + "the drop-box private half was shared with a member"); |
| +}); |
| + |
| +// Only the owner. A member holding K_vault could otherwise compose a rotation |
| +// and evict the owner from their own vault. |
| +test("a member cannot share or rotate", async () => { |
| + const guest = await anAccount(); |
| + const net = recorder(); |
| + const notMine = { |
| + id: randomBytes(16), kVault: randomBytes(32), name: "Theirs", |
| + epoch: 1, owned: false, |
| + }; |
| + notMine.idHex = toHex(notMine.id); |
| + |
| + await assert.rejects( |
| + () => sharing.share(net, notMine, toHex(guest.id), toHex(guest.publicKey)), |
| + /only the owner/); |
| + await assert.rejects( |
| + () => sharing.rotate(net, {}, notMine, [], [], randomBytes(32)), |
| + /only the owner/); |
| + assert.equal(net.posts.length, 0, "a refused call still reached the network"); |
| +}); |
| + |
| +// Rotation re-seals every item under the new key and bumps every version, so |
| +// the old key opens nothing that remains. |
| +test("rotation re-seals every item under the new key", async () => { |
| + const owner = await anAccount(); |
| + const guest = await anAccount(); |
| + const net = recorder(); |
| + |
| + const vault = { |
| + id: randomBytes(16), kVault: randomBytes(32), name: "Household", |
| + epoch: 1, owned: true, |
| + }; |
| + vault.idHex = toHex(vault.id); |
| + const opened = { rk: randomBytes(32), accountId: owner.id }; |
| + |
| + const items = [ |
| + { id: randomBytes(16), idHex: "", version: 3, fields: { title: "One" } }, |
| + { id: randomBytes(16), idHex: "", version: 1, fields: { title: "Two" } }, |
| + ]; |
| + for (const it of items) it.idHex = toHex(it.id); |
| + |
| + const newKVault = randomBytes(32); |
| + const after = await sharing.rotate( |
| + net, opened, vault, items, |
| + [{ account_id_hex: toHex(guest.id), public_key_hex: toHex(guest.publicKey) }], |
| + newKVault); |
| + |
| + const body = net.posts[0].body; |
| + assert.equal(net.posts[0].path, "/api/vault/rotate"); |
| + assert.equal(body.expected_epoch, 1, "the rotation did not carry its CAS token"); |
| + assert.equal(body.items.length, 2, "not every item was re-sealed"); |
| + assert.equal(body.items[0].version, 4, "the version did not bump"); |
| + assert.equal(body.items[1].version, 2); |
| + assert.equal(body.members.length, 1); |
| + assert.equal(after.epoch, 2); |
| + |
| + // The retained member's new wrap opens, and carries the NEW key. |
| + const m = body.members[0]; |
| + const got = await sharing.openSharedKeyset( |
| + guest.scalar, guest.publicKey, guest.id, vault.id, |
| + { |
| + ephemeral_public_key_hex: m.ephemeral_public_key_hex, |
| + keyset: { nonce_hex: m.nonce_hex, ciphertext_hex: m.ciphertext_hex }, |
| + }); |
| + assert.equal(got.k_vault, toHex(newKVault), "a retained member got the old key"); |
| +}); |