carlos / contra Public

web/8: the sharing UI, and the vault picker it turned out to need

The panel on the vault page: your own account id to give out, a form to
share by somebody else's, the member list, and removal.

**The browser walk found a real gap.** The vault page had only ever opened
`vaults[0]`, which was indistinguishable from opening the right one while
there was exactly one. With a shared vault in the list it stopped being
true: a member's own empty vault sorts first, so the vault they had
actually been given never appeared and the feature was invisible to the
person it was for. There is now a picker, shown only when there is a choice
to make, labelled from names that come out of the keysets — so the list is
built from strings the server has never seen.

The copy carries DESIGN.md's revocation answer in full, before the button
rather than after it, including the part every product in this category
quietly omits: **anything they already downloaded is still theirs.** The
removal is gated on a checkbox acknowledging exactly that.

A member sees that the vault is shared with them and no more — who else is
in it is the owner's business — and is told so rather than shown controls
that would fail.

After a rotation the page reloads rather than patching itself. Every key
the tab is holding is the old one at that point, and a stale K_vault in a
live tab is what produces items nobody can open.

The browser walk is two accounts in one profile, sharing only the session
between them: the guest reads an item the owner sealed, and stops being
able to after the owner removes them. One account's browser seals, another
account's browser opens — which is the only arrangement that proves this
works rather than that rows moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 84f891e3ea18ca194da5ee371394a136f58f810b parent f0ec676
5 files changed, +393 −2
  • internal/contra/static/pages/vault.js +44 −1
  • internal/contra/static/pages/vaultshare.js +139 −0
  • internal/contra/templates/vault.html +52 −0
  • internal/contratest/browser_sharing_test.go +156 −0
  • internal/contratest/jsmodules_test.go +2 −1

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading github.com/carlosframework/rastrillo v0.23.0
go: downloading github.com/go-chi/chi/v5 v5.3.2
go: downloading gorm.io/gorm v1.31.2
go: downloading golang.org/x/crypto v0.55.0
go: downloading github.com/jinzhu/now v1.1.5
go: downloading github.com/jinzhu/inflection v1.0.0
go: downloading golang.org/x/text v0.41.0
go: downloading modernc.org/sqlite v1.55.0
go: downloading gorm.io/plugin/dbresolver v1.6.2
go: downloading golang.org/x/sys v0.47.0
go: downloading modernc.org/libc v1.74.1
go: downloading github.com/google/uuid v1.6.0
go: downloading modernc.org/mathutil v1.7.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
=== 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	0.421s
?   	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.497s
=== 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.228711
  type: 'test'
  ...
# Subtest: KCV
ok 2 - KCV
  ---
  duration_ms: 2.205396
  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.520409
  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: 5.120359
  type: 'test'
  ...
# Subtest: Crockford: alphabet, encoding, normalisation, strict decoding
ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding
  ---
  duration_ms: 1.01326
  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.216928
  type: 'test'
  ...
# Subtest: repair recovers one substituted character
ok 7 - repair recovers one substituted character
  ---
  duration_ms: 49.18481
  type: 'test'
  ...
# Subtest: AEAD: the AAD binding and every wrap
ok 8 - AEAD: the AAD binding and every wrap
  ---
  duration_ms: 2.610876
  type: 'test'
  ...
# Subtest: AEAD: the item, scoped to its vault
ok 9 - AEAD: the item, scoped to its vault
  ---
  duration_ms: 1.112387
  type: 'test'
  ...
# Subtest: a transplanted ciphertext does not open
ok 10 - a transplanted ciphertext does not open
  ---
  duration_ms: 2.72976
  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.897944
  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: 0.580097
  type: 'test'
  ...
# Subtest: Argon2id matches the published PHC reference vector
ok 13 - Argon2id matches the published PHC reference vector
  ---
  duration_ms: 1794.014271
  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: 221.26787
  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: 2582.961805
  type: 'test'
  ...
# Subtest: the account chain, end to end
ok 16 - the account chain, end to end
  ---
  duration_ms: 2589.885341
  type: 'test'
  ...
# Subtest: handoff: both keypairs derive from their published seeds
ok 17 - handoff: both keypairs derive from their published seeds
  ---
  duration_ms: 16.045436
  type: 'test'
  ...
# Subtest: handoff: ECDH agrees from both sides
ok 18 - handoff: ECDH agrees from both sides
  ---
  duration_ms: 8.066536
  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.386306
  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.645454
  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: 8.19983
  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.542717
  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: 10.969125
  type: 'test'
  ...
# Subtest: nothing readable reaches the network
ok 24 - nothing readable reaches the network
  ---
  duration_ms: 2.601324
  type: 'test'
  ...
# Subtest: item ciphertexts are quantised into 256-byte buckets
ok 25 - item ciphertexts are quantised into 256-byte buckets
  ---
  duration_ms: 1.653904
  type: 'test'
  ...
# Subtest: an item does not open in another slot
ok 26 - an item does not open in another slot
  ---
  duration_ms: 2.793387
  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.25216
  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.352856
  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.454225
  type: 'test'
  ...
# Subtest: nobody else can open a member's wrap
ok 30 - nobody else can open a member's wrap
  ---
  duration_ms: 15.536726
  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.751453
  type: 'test'
  ...
# Subtest: a member cannot share or rotate
ok 32 - a member cannot share or rotate
  ---
  duration_ms: 1.609465
  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.593226
  type: 'test'
  ...
1..33
# tests 33
# suites 0
# pass 33
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 7347.293639
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
go: downloading github.com/BurntSushi/toml v1.6.0
rastrillo migration check: models and migrations agree
diff --git a/internal/contra/static/pages/vault.js b/internal/contra/static/pages/vault.js
index 2d234c9..878d7ec 100644
--- a/internal/contra/static/pages/vault.js
+++ b/internal/contra/static/pages/vault.js
@@ -12,6 +12,7 @@ import { net } from "../crypto/net.js";
import { items as itemStore } from "../crypto/items.js";
import { accountKeys, unlock } from "../crypto/vaultkeys.js";
import { FIELDS } from "../crypto/items.js";
+import { wireSharing } from "./vaultshare.js";
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
@@ -134,6 +135,48 @@ function view(root, store) {
};
}
+// vaultLabel names a vault on the picker. The name comes out of the keyset,
+// so the server has never seen it and could not label this list if it tried.
+function vaultLabel(v) {
+ const name = v.name || "Vault";
+ return v.owned ? name : `${name} (shared with you)`;
+}
+
+// showVaults draws the picker and opens one vault.
+//
+// Before sharing there was only ever vaults[0], and picking it silently was
+// indistinguishable from picking correctly. With a shared vault in the list
+// that stopped being true: a member's own empty vault sorts first, so the
+// vault they were actually given never appeared. A browser walk caught it,
+// which is the argument for having one.
+async function showVaults(root, opened) {
+ const picker = document.querySelector("[data-vault-picker]");
+ const sharingPanel = document.querySelector("[data-sharing]");
+
+ async function openOne(vault) {
+ const store = itemStore(net, vault);
+ await view(root, store).load();
+ // Sharing needs the vault key — it seals the keyset to somebody, and
+ // removing somebody re-seals every item — so it only appears once a
+ // vault is actually open, and it is rewired per vault.
+ if (sharingPanel) {
+ sharingPanel.hidden = false;
+ wireSharing(document, opened, vault, store);
+ }
+ }
+
+ if (picker) {
+ picker.replaceChildren(...opened.vaults.map((v, i) =>
+ el("option", { value: String(i) }, vaultLabel(v))));
+ // Only worth showing when there is a choice to make.
+ picker.parentElement.hidden = opened.vaults.length < 2;
+ picker.addEventListener("change", async () => {
+ await openOne(opened.vaults[Number(picker.value)]);
+ });
+ }
+ await openOne(opened.vaults[0]);
+}
+
async function main() {
const root = document.querySelector("[data-vault]");
if (!root) return;
@@ -169,7 +212,7 @@ async function main() {
const opened = await unlock(bootstrap, passphrase, accountKey);
gate.hidden = true;
root.hidden = false;
- await view(root, itemStore(net, opened.vaults[0])).load();
+ await showVaults(root, opened);
} catch (err) {
box.replaceChildren(document.createTextNode(err.message));
}
diff --git a/internal/contra/static/pages/vaultshare.js b/internal/contra/static/pages/vaultshare.js
new file mode 100644
index 0000000..bf80043
--- /dev/null
+++ b/internal/contra/static/pages/vaultshare.js
@@ -0,0 +1,139 @@
+// The sharing panel on the vault page.
+//
+// Split out of vault.js because that file has a line budget and this is a
+// second concern. Everything that decides anything lives in
+// crypto/sharing.js; this moves text between a form and it.
+//
+// # The copy rule this file exists to hold
+//
+// DESIGN.md's revocation answer has three parts and the screen must not
+// soften any of them: access dies immediately, the key rotates forward, and
+// **nothing already downloaded is un-downloaded**. The third is the one every
+// product in this category quietly omits. It is said here, before the button,
+// in those words.
+
+import { randomBytes, toHex } from "../crypto/bytes.js";
+import { net } from "../crypto/net.js";
+import * as sharing from "../crypto/sharing.js";
+
+const $ = (sel, root = document) => root.querySelector(sel);
+
+function el(tag, attrs = {}, ...kids) {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
+ node.append(...kids.filter((k) => k !== null && k !== undefined));
+ return node;
+}
+
+function say(box, text, kind = "working") {
+ box.replaceChildren(el("p", { "data-kind": kind }, text));
+}
+
+// wireSharing takes the opened vault and a way to re-read the item list,
+// because rotation has to re-seal every item and cannot do that from a
+// summary.
+export function wireSharing(root, opened, vault, store) {
+ const panel = $("[data-sharing]", root);
+ if (!panel) return;
+
+ // Your own account id, so you can give it to somebody who wants to share
+ // with you. It is not a secret — it is a random 128-bit label — but it is
+ // the only handle there is, because there is no directory to look you up
+ // in and deliberately so.
+ $("[data-my-account]", panel).textContent = toHex(opened.accountId);
+
+ if (!vault.owned) {
+ // A member sees that it is shared with them and nothing else. Who else is
+ // in it is the owner's business.
+ panel.setAttribute("data-role", "member");
+ say($("[data-share-status]", panel),
+ "This vault is shared with you by somebody else. Only its owner can " +
+ "change who can see it.", "ok");
+ $("[data-share-form]", panel).hidden = true;
+ return;
+ }
+
+ const status = $("[data-share-status]", panel);
+ const list = $("[data-members]", panel);
+ let current = vault;
+
+ async function draw() {
+ const rows = await sharing.members(net, current);
+ if (rows.length === 0) {
+ list.replaceChildren(el("li", {}, "Not shared with anybody."));
+ return;
+ }
+ list.replaceChildren(...rows.map((m) => el("li", { "data-member": m.account_id_hex },
+ el("code", {}, m.account_id_hex),
+ el("button", { type: "button", "data-remove": m.account_id_hex }, "Remove"))));
+ }
+
+ $("[data-share-form]", panel).addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const idHex = $("[name=account_id]", panel).value.trim().toLowerCase();
+ try {
+ say(status, "Looking them up…");
+ const who = await sharing.lookupAccount(net, idHex);
+
+ say(status, "Sealing the vault key to them…");
+ await sharing.share(net, current, who.account_id_hex, who.public_key_hex);
+
+ $("[name=account_id]", panel).value = "";
+ await draw();
+ say(status, "Shared. They will see it the next time they unlock.", "ok");
+ } catch (err) {
+ console.error("CONTRA could not share the vault", err);
+ say(status, err.status === 404
+ ? "No account with that id, or they have not finished signing up."
+ : "Could not share this vault. The browser console has the detail.",
+ "error");
+ }
+ });
+
+ list.addEventListener("click", async (e) => {
+ const idHex = e.target.getAttribute?.("data-remove");
+ if (!idHex) return;
+ if (!$("[data-understood]", panel).checked) {
+ say(status, "Tick the box first: removing somebody cannot take back " +
+ "what they have already downloaded.", "error");
+ return;
+ }
+ try {
+ // Revocation IS rotation. The whole vault is re-sealed under a new key
+ // and every remaining member is re-wrapped, in one transaction — so
+ // there is no moment where the removed member is still holding a key
+ // that opens anything current.
+ say(status, "Re-keying the vault…");
+ const rows = await sharing.members(net, current);
+ const keep = [];
+ for (const m of rows) {
+ if (m.account_id_hex === idHex) continue;
+ const who = await sharing.lookupAccount(net, m.account_id_hex);
+ keep.push({
+ account_id_hex: who.account_id_hex,
+ public_key_hex: who.public_key_hex,
+ });
+ }
+
+ say(status, "Re-sealing every item…");
+ const items = await store.list();
+ await sharing.rotate(net, opened, current, items, keep, randomBytes(32));
+
+ // Every key this page is holding is now the old one, so the page is
+ // reloaded rather than patched. Rotation is rare and a stale K_vault in
+ // a live tab is the kind of state that produces items nobody can open.
+ say(status, "Removed, and the vault has a new key. What they already " +
+ "downloaded is still theirs — nothing can take that back. " +
+ "Reloading…", "ok");
+ setTimeout(() => window.location.reload(), 1500);
+ } catch (err) {
+ console.error("CONTRA could not remove the member", err);
+ say(status, err.status === 409
+ ? "Somebody changed this vault while that was being prepared. " +
+ "Reload and try again."
+ : "Could not remove them. Nothing was changed.", "error");
+ }
+ });
+
+ draw().catch((err) => console.error("CONTRA could not list members", err));
+}
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html
index ad73cf6..f186d6b 100644
--- a/internal/contra/templates/vault.html
+++ b/internal/contra/templates/vault.html
@@ -20,8 +20,60 @@
<div data-status aria-live="polite"></div>
</form>
+{{/* Shown only when there is more than one vault to choose between. The
+ labels come out of the keysets, so this list is built from names the
+ server has never seen. */}}
+<p data-vault-choice hidden>
+ <label for="vault-picker">Vault</label>
+ <select id="vault-picker" data-vault-picker></select>
+</p>
+
<div data-vault hidden></div>
+{{/* Sharing. Hidden until the vault is open, because everything in it needs
+ the vault key: sharing seals the keyset to somebody, and removing
+ somebody re-seals every item under a new one. */}}
+<section data-sharing hidden>
+ <h2>Sharing</h2>
+
+ <p>Your account id is <code data-my-account></code>. Give it to somebody who
+ wants to share a vault with you — there is no directory to look you up in,
+ and deliberately so: an address-to-account lookup would let anyone test
+ whether an address has an account here.</p>
+
+ <form data-share-form method="post" action="#">
+ <p>
+ <label for="account_id">Share with this account id</label>
+ <input id="account_id" name="account_id" type="text" autocomplete="off"
+ spellcheck="false" required
+ placeholder="32 hexadecimal characters">
+ </p>
+ <p><button type="submit">Share this vault</button></p>
+ </form>
+
+ <h3>Members</h3>
+ <ul data-members></ul>
+
+ {{/* DESIGN.md's revocation answer, all three parts, before the button
+ rather than after it. The third is the one every product in this
+ category quietly omits. */}}
+ <p data-kind="warning">Removing somebody does three things, and only two of
+ them are what people expect. Their access stops immediately. The vault gets
+ a new key, and every item is re-encrypted under it. But
+ <strong>anything they already downloaded is still theirs</strong> — no
+ design can take that back, and ours does not pretend to.</p>
+
+ <p>
+ <label>
+ <input type="checkbox" data-understood>
+ I understand that removing somebody cannot un-download what they already
+ have.
+ </label>
+ </p>
+
+ <p data-share-status aria-live="polite"></p>
+</section>
+
{{/* DESIGN.md: the reprint flow must be obvious rather than buried, and
the nag to enrol a second device lives beside it. Both are the
mitigation for the one loss mode the Account Key introduced. */}}
diff --git a/internal/contratest/browser_sharing_test.go b/internal/contratest/browser_sharing_test.go
new file mode 100644
index 0000000..8d58e98
--- /dev/null
+++ b/internal/contratest/browser_sharing_test.go
@@ -0,0 +1,156 @@
+//go:build browser
+
+package contratest
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/harness"
+ "github.com/chromedp/chromedp"
+)
+
+// Two accounts sharing a vault, in a real engine.
+//
+// The Go and node suites each prove a half. This is the only test where one
+// account's browser seals a vault keyset and ANOTHER account's browser opens
+// it, through the real API, with the real ECDH, and reads a real item out of
+// it. It is the walk a person actually does.
+//
+// One browser profile, two accounts. Only the SESSION changes between them —
+// localStorage is left alone, because the Account Key cache is keyed by
+// account id (`contra/v1/account-key/<id>`) and the two coexist. That is not
+// a shortcut around the test; it is what a shared family machine looks like.
+
+func signOut(t *testing.T, rig *harness.Rig) {
+ t.Helper()
+ rig.Run(
+ chromedp.Navigate(rig.Origin+"/vault"),
+ chromedp.Click(`form[action="/auth/signout"] button`, chromedp.ByQuery),
+ chromedp.WaitVisible(`body`, chromedp.ByQuery),
+ )
+}
+
+func signIn(t *testing.T, rig *harness.Rig, email, passphrase string) {
+ t.Helper()
+ rig.Run(
+ chromedp.Navigate(rig.Origin+"/signin"),
+ chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery),
+ chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery),
+ chromedp.Click(`button[type=submit]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`form[action="/auth/signout"]`, chromedp.ByQuery),
+ )
+}
+
+func unlockActions(rig *harness.Rig, passphrase string) []chromedp.Action {
+ return []chromedp.Action{
+ chromedp.Navigate(rig.Origin + "/vault"),
+ chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery),
+ chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery),
+ chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery),
+ }
+}
+
+func TestBrowserTwoAccountsShareAVault(t *testing.T) {
+ rig, box := newRig(t)
+
+ const ownerEmail = "share-owner@example.invalid"
+ const ownerPass = "a generated test passphrase 8f3a1c"
+ const guestEmail = "share-guest@example.invalid"
+ const guestPass = "a different generated one 4d2e9b"
+
+ // The guest signs up first, so their account id exists to be shared with.
+ signUpInBrowser(t, rig, box, guestEmail, guestPass)
+ rig.Run(unlockActions(rig, guestPass)...)
+
+ var guestID string
+ rig.Run(chromedp.Text(`[data-my-account]`, &guestID, chromedp.ByQuery))
+ guestID = strings.TrimSpace(guestID)
+ if len(guestID) != 32 {
+ t.Fatalf("the guest's account id is not 32 hex characters: %q", guestID)
+ }
+ rig.Screen("[data-sharing]", "the sharing panel, before anything is shared")
+ signOut(t, rig)
+
+ // The owner signs up, adds an item, and shares with the id the guest read
+ // off their own screen — which is the only handle there is, because there
+ // is no directory to look anybody up in.
+ signUpInBrowser(t, rig, box, ownerEmail, ownerPass)
+ rig.Run(unlockActions(rig, ownerPass)...)
+ rig.Run(
+ chromedp.SendKeys(`[data-item-form] input[name=title]`, "Jamaica Bakery", chromedp.ByQuery),
+ chromedp.SendKeys(`[data-item-form] input[name=password]`, "generated-not-real-8f3a", chromedp.ByQuery),
+ chromedp.Click(`[data-item-form] [data-save]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`[data-items] li`, chromedp.ByQuery),
+ chromedp.SendKeys(`[data-share-form] input[name=account_id]`, guestID, chromedp.ByQuery),
+ chromedp.Click(`[data-share-form] button[type=submit]`, chromedp.ByQuery),
+ chromedp.Poll(
+ `document.querySelector('[data-members]').textContent.includes('`+guestID+`')`,
+ nil),
+ )
+ rig.Screen("[data-sharing]", "shared with one member")
+ signOut(t, rig)
+
+ // The guest opens the owner's item. This is the assertion the whole
+ // feature exists for: sealed by one account's browser under one set of
+ // keys, opened by another under theirs.
+ signIn(t, rig, guestEmail, guestPass)
+ rig.Run(unlockActions(rig, guestPass)...)
+
+ // The guest has two vaults now — their own, and the one shared with them
+ // — so the picker has to be used. Their own sorts first.
+ rig.Run(
+ chromedp.WaitVisible(`[data-vault-picker]`, chromedp.ByQuery),
+ chromedp.SetValue(`[data-vault-picker]`, "1", chromedp.ByQuery),
+ chromedp.Evaluate(`document.querySelector('[data-vault-picker]')`+
+ `.dispatchEvent(new Event('change'))`, nil),
+ chromedp.Poll(
+ `document.querySelector('[data-items]').textContent.includes('Jamaica')`,
+ nil),
+ )
+
+ var listed string
+ rig.Run(chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery))
+ if !strings.Contains(listed, "Jamaica Bakery") {
+ t.Fatalf("the member cannot see the shared item: %q", listed)
+ }
+ rig.Screen("[data-vault]", "a vault opened by the account it was shared with")
+
+ // And a member is told they cannot change who else can see it, rather
+ // than being shown a control that fails.
+ var panel string
+ rig.Run(chromedp.Text(`[data-sharing]`, &panel, chromedp.ByQuery))
+ if !strings.Contains(panel, "shared with you by somebody else") {
+ t.Errorf("a member is not told whose vault this is: %q", panel)
+ }
+
+ // The owner removes them, which rotates the vault, and the member loses
+ // it. Nothing already downloaded is un-downloaded — which is exactly why
+ // the screen says so before the button.
+ signOut(t, rig)
+ signIn(t, rig, ownerEmail, ownerPass)
+ rig.Run(unlockActions(rig, ownerPass)...)
+ rig.Run(
+ chromedp.Click(`[data-understood]`, chromedp.ByQuery),
+ chromedp.Click(`[data-members] [data-remove]`, chromedp.ByQuery),
+ chromedp.Poll(
+ `document.querySelector('[data-share-status]').textContent.includes('new key')`,
+ nil),
+ )
+ rig.Screen("[data-sharing]", "after removing a member")
+
+ signOut(t, rig)
+ signIn(t, rig, guestEmail, guestPass)
+ rig.Run(
+ chromedp.Navigate(rig.Origin+"/vault"),
+ chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery),
+ chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, guestPass, chromedp.ByQuery),
+ chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery),
+ )
+ rig.Run(chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery))
+ if strings.Contains(listed, "Jamaica Bakery") {
+ t.Error("a removed member can still read the vault")
+ }
+}
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go
index b4397c8..2fe8b54 100644
--- a/internal/contratest/jsmodules_test.go
+++ b/internal/contratest/jsmodules_test.go
@@ -56,7 +56,8 @@ var jsHighWater = map[string]int{
"pages/devicescan.js": 96,
"pages/kit.js": 156,
"pages/signup.js": 258,
- "pages/vault.js": 179,
+ "pages/vault.js": 222,
+ "pages/vaultshare.js": 139,
}
func TestJSModulesStaySmall(t *testing.T) {