Skip to content

Commit d225b79

Browse files
TheWayWithinclaude
andcommitted
feat: seal in the browser — the owner journey is now terminal-free (EF-ISS-7)
Creating and maintaining a register were already terminal-free; sealing a new one was the last wall. scripts/edit.sh now runs the whole create flow through the local editor server: fill the form, Save (writes estate.yaml to the folder, no Downloads shuffle), then Seal my file — the server runs the proven scripts/setup.sh in a new machine-emit mode (EXECUTOR_FILE_EMIT) and the browser walks the ceremony (shares one at a time, confirm and clear, passphrase, checklist), all on 127.0.0.1. - setup.sh: EXECUTOR_FILE_EMIT emits the proof-verified secrets as a parseable block and skips the human display; normal setup.sh unchanged. - edit-server.py: --mode create|review; create-mode save keeps the server up; new GET /validate and POST /seal (localhost only; secrets never written to disk beyond the .age; /seal reuses setup.sh, no crypto reimplemented). - editor.html: create titling, a Seal button, and an in-browser ceremony. - edit.sh: launches the editor in create mode via the server (falls back to the static download editor when python3 is absent). - SECURITY.md: states the localhost trust boundary of the browser seal honestly (secrets cross 127.0.0.1 and show in the tab; same boundary as the terminal ceremony; terminal path remains for the smallest surface). Security-critical test added: drive create -> save -> seal through the server and assert the returned shares reconstruct the returned passphrase on a setup.sh-proof-verified file. Suite 116 green batchpass / 114 expect, both mechanisms; review.sh's terminal + browser paths unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8f6e574 commit d225b79

8 files changed

Lines changed: 280 additions & 27 deletions

File tree

ISSUES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ ID or another repo's. Raise issues here with `python3 ~/shared/scripts/repo-issu
1616

1717
| ID | Title | Status | Severity | Detail | MC-SYNC |
1818
|----|-------|--------|----------|--------|---------|
19-
| EF-ISS-7 | Sealing a NEW register still needs a Terminal command (scripts/setup.sh): creating and maintaining register content are now terminal-free (browser editor + review-in-browser), but the first encrypt+split+prove-chain step isn't. Last terminal wall for a non-technical owner — consider a guided seal (double-click launcher wrapping setup.sh) or an editor 'seal' action shelling out to age/ssss locally. | Open | medium || pending |
2019
| EF-ISS-5 | Register editor + schema help is thin on jurisdiction for cross-border users: editor v1 has no domicile/residence fields (schema supports them), and the 'jurisdictions' help doesn't explain it means every place you hold assets, nor domicile vs residence, nor 'if UK/US/FR-style cross-border, see a specialist solicitor'. Real UAT case: UK citizen, NY resident on E-2, assets in UK/US/FR — user could not tell what to enter. | Open | medium || pending |
2120
| EF-ISS-3 | Onboarding requires holding instructions across multiple pages — violates the product's own 'you don't have to hold it in your head' principle; non-technical owners should be funnelled to executorfile.com/get-started, not raw GitHub | Open | high || pending |
2221
| EF-ISS-2 | GitHub release page is a dead-end for non-technical users: no 'download this / do this next', assets collapsed under Assets, and competing with GitHub's auto Source-code downloads; install instructions are stranded back on the README (can't be recalled on the release page) | Open | high || pending |
@@ -25,6 +24,7 @@ ID or another repo's. Raise issues here with `python3 ~/shared/scripts/repo-issu
2524

2625
| ID | Title | Status | Commit | Detail |
2726
|----|-------|--------|--------|--------|
27+
| EF-ISS-7 | Sealing a NEW register still needs a Terminal command (scripts/setup.sh): creating and maintaining register content are now terminal-free (browser editor + review-in-browser), but the first encrypt+split+prove-chain step isn't. Last terminal wall for a non-technical owner — consider a guided seal (double-click launcher wrapping setup.sh) or an editor 'seal' action shelling out to age/ssss locally. | Done | browser seal | scripts/edit.sh now creates AND seals in the browser: fill the form, Save, Seal, and an in-browser ceremony writes down the shares. edit-server.py gained mode-aware save + /validate + /seal (runs setup.sh in EXECUTOR_FILE_EMIT mode, reusing the proven crypto). Owner journey is now terminal-free after launch; setup.sh remains the terminal path. |
2828
| EF-ISS-6 | Yearly-review (maintenance) still hits the terminal wall: browser editor only handles plaintext to Downloads, but the kept file is estate.yaml.age and must be re-encrypted with the SAME passphrase (shares stay valid) — review.sh still uses a terminal editor. Build 'review in the browser': review.sh decrypts to a temp file, opens it in a local-only editor that reads/writes that file directly, then re-encrypts+verifies+shreds. Also make the editor loader handle folded '>' block scalars so example-style/hand-authored files open too. | Done | 7ca3113, f769193 | review-in-browser (edit-server.py + editor server mode + review.sh browser default) and block-scalar loader shipped; yearly review no longer needs a terminal editor. |
2929
| EF-ISS-4 | Editing the register in a terminal editor (nano) is a hard wall for non-technical owners: dry-run tester could not use nano, accidental ^V wrote junk, could not exit. This is the dry-run evidence the parked GUI/editing-interface decision (SPEC-v1 §9) was explicitly waiting for. Needs a humane edit path (GUI or form/web editor) before the tool is usable by its target audience. | Done | fcfabda, 7ca3113 | humane edit path shipped: browser form editor for creating (editor.html) and for maintaining (review-in-browser). The parked GUI decision (SPEC-v1 §9) is resolved. Remaining terminal step (sealing) tracked as EF-ISS-7. |
3030
| EF-ISS-1 | README quickstart assumes the repo is already on disk: no download/unpack/cd step for tarball users | Done | 8887f5d | README 'Get the tool' section added: tarball download + unpack + cd, git clone alternative, terminal-opening, Homebrew fallback. |

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ cd executor-file
5858

5959
Either way you are now "in the folder" — every command below is typed into that same Terminal window, and the `scripts/...` paths will just work.
6060

61-
**Prefer not to hand-edit a text file?** Double-click **`Edit-Executor-File.command`** in the folder (or run `scripts/edit.sh`) to fill your register in as a form in your browser — dropdowns, plain-English help, no YAML. It runs entirely on your machine, saves `estate.yaml` to your Downloads, and you still seal it once with `scripts/setup.sh`.
61+
**Prefer not to touch the Terminal at all?** Run `scripts/edit.sh` (once you are in the folder) to fill your register in as a form in your browser — dropdowns, plain-English help, no YAML — then click **Save** and **Seal my file**, and it encrypts the file, splits the key, and walks you through writing the shares down, all in the browser. It runs entirely on your machine; nothing is sent anywhere. The one-command Terminal path (`scripts/setup.sh`) below still works if you prefer it.
6262

6363
**Two small tools to install once** (these do the encryption and the share-splitting; both are free, open-source, and standard):
6464

@@ -98,7 +98,7 @@ nano estate.yaml
9898
scripts/validate.sh
9999
```
100100

101-
**3. Seal it** — validate → encrypt → split → **prove the chain**, one command. It generates a strong passphrase, encrypts, splits the passphrase 2-of-3, then reconstructs it from two of the just-issued shares and test-decrypts back to a byte-identical copy before reporting success:
101+
**3. Seal it** — validate → encrypt → split → **prove the chain**. If you built the register with `scripts/edit.sh`, you already sealed it there in the browser and can skip this. Otherwise, one command does it: it generates a strong passphrase, encrypts, splits the passphrase 2-of-3, then reconstructs it from two of the just-issued shares and test-decrypts back to a byte-identical copy before reporting success:
102102

103103
```bash
104104
scripts/setup.sh

SECURITY.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ Explicitly NOT defended — stated so nobody relies on it:
4343
say so honestly: scrollback, terminal logs, and session recorders
4444
are outside their control — closing the terminal window is part of
4545
the ceremony.
46+
- **The optional browser flows (`scripts/edit.sh`, `scripts/review.sh`).**
47+
These run a web server bound to `127.0.0.1` only, on your machine, and
48+
the browser talks only to it — nothing reaches the network. But during
49+
the browser seal the passphrase and shares travel over that localhost
50+
connection and are shown in the browser tab, which is the same local
51+
trust boundary as the terminal ceremony (same-user processes, browser
52+
extensions, and a screen recorder can see them). They are never written
53+
to disk beyond the `.age` file, never stored in the browser, and shown
54+
one at a time; closing the tab when done is part of the ceremony. If you
55+
want the smallest possible surface, use the terminal path (`setup.sh`).
4656
- **Two colluding share holders.** Any two shares open the file by
4757
design. Choose holders whose collusion you would consider
4858
authorised access.

scripts/edit.sh

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
#!/bin/sh
2-
# edit.sh — open the friendly register editor in your web browser.
2+
# edit.sh — fill in your register as a browser form, then seal it, all on
3+
# this machine. Opens a local, private editor in your web browser: no
4+
# server on the network, no account, nothing sent anywhere. You fill in
5+
# the form, click Save, then Seal, and it encrypts the file and splits the
6+
# key, walking you through writing the shares down. Owner-side tooling
7+
# (Python is fine here); your executor's recovery path never uses it.
38
#
4-
# Fill your register in as a form (dropdowns, plain-English help) instead
5-
# of hand-editing YAML. It runs entirely in your browser on this machine:
6-
# no server, no network, nothing sent anywhere. When you click Save it
7-
# writes estate.yaml to your Downloads folder; then seal it once with
8-
# scripts/setup.sh.
9-
#
10-
# Usage: scripts/edit.sh
9+
# Usage: scripts/edit.sh [FILE] (default: estate.yaml)
1110
set -u
1211
DIR=$(cd "$(dirname "$0")/.." && pwd)
12+
SERVER="$DIR/web/edit-server.py"
13+
TARGET="${1:-estate.yaml}"
14+
15+
if command -v python3 >/dev/null 2>&1 && [ -f "$SERVER" ]; then
16+
echo "Opening the register editor in your browser."
17+
echo " (it runs only on this machine; nothing you type is sent anywhere)"
18+
exec python3 "$SERVER" "$TARGET" --mode create --open
19+
fi
20+
21+
# Fallback when python3 is unavailable: the static editor (download flow,
22+
# and you seal separately with scripts/setup.sh).
1323
PAGE="$DIR/web/editor.html"
1424
[ -f "$PAGE" ] || { echo "error: editor not found at $PAGE" >&2; exit 1; }
15-
echo "Opening the register editor in your browser…"
16-
echo " (it runs locally — nothing you type is sent anywhere)"
25+
echo "python3 was not found, so opening the basic editor."
26+
echo "Fill it in and Save; then seal with: scripts/setup.sh"
1727
if command -v open >/dev/null 2>&1; then open "$PAGE"
1828
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$PAGE"
1929
else echo "Open this file in your browser: $PAGE"; fi

scripts/setup.sh

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,26 @@ else
310310
fi
311311
FINALISED=1
312312

313+
# Machine-emit mode (EXECUTOR_FILE_EMIT=1): a caller that will run its
314+
# own ceremony (scripts/edit.sh's browser seal, via web/edit-server.py)
315+
# asks for the secrets in a parseable block instead of the human display.
316+
# The block goes to stdout between clear markers; nothing is written to
317+
# disk beyond the .age and its sidecar. We still did the full proof
318+
# chain above, so this is the same sealed, verified file.
319+
if [ -n "${EXECUTOR_FILE_EMIT:-}" ]; then
320+
SHA="$(cut -d' ' -f1 < "$OUT.sha256")"
321+
printf '===SEAL-BEGIN===\n'
322+
printf 'passphrase\t%s\n' "$PASS"
323+
printf 'share\t%s\n' "$S1"
324+
printf 'share\t%s\n' "$S2"
325+
printf 'share\t%s\n' "$S3"
326+
printf 'sha256\t%s\n' "$SHA"
327+
printf 'out\t%s\n' "$OUT"
328+
printf 'own\t%s\n' "$OWN"
329+
printf '===SEAL-END===\n'
330+
exit 0
331+
fi
332+
313333
echo "Step 6/6 — done. Your Executor File is sealed."
314334
echo
315335
echo " Encrypted register: $OUT"

tests/test-edit-server.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Exit 0 = all good; prints a diagnostic and exits 1 on any failure.
1111
"""
1212

13+
import json
1314
import os
1415
import subprocess
1516
import sys
@@ -54,11 +55,13 @@ def fail(msg, proc=None):
5455
sys.exit(1)
5556

5657

57-
def start(target):
58+
def start(target, mode=None):
5859
"""Launch the server on an OS-assigned port; return (proc, base_url)."""
60+
cmd = [sys.executable, SERVER, target, "--port", "0"]
61+
if mode:
62+
cmd += ["--mode", mode]
5963
proc = subprocess.Popen(
60-
[sys.executable, SERVER, target, "--port", "0"],
61-
stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True,
64+
cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True,
6265
)
6366
deadline = time.time() + READY_TIMEOUT
6467
while time.time() < deadline:
@@ -126,6 +129,51 @@ def main():
126129
if open(target2).read() != original:
127130
fail("cancel changed the file")
128131

132+
# ---- create + seal path (needs age + ssss) ----
133+
import shutil
134+
if shutil.which("age") and shutil.which("ssss-split") and shutil.which("ssss-combine"):
135+
target3 = os.path.join(d, "estate3.yaml")
136+
with open(EXAMPLE) as f:
137+
open(target3, "w").write(f.read())
138+
proc3, url3 = start(target3, mode="create")
139+
# /validate reports the register as valid
140+
with urllib.request.urlopen(url3 + "validate", timeout=15) as r:
141+
if not json.loads(r.read()).get("ok"):
142+
fail("/validate said the example register is invalid", proc3)
143+
# save keeps the server up in create mode
144+
post(url3 + "save", open(target3).read())
145+
if proc3.poll() is not None:
146+
fail("create-mode save should NOT exit the server")
147+
# seal it
148+
sealed = json.loads(post(url3 + "seal", json.dumps({"overwrite": False})))
149+
if not sealed.get("ok"):
150+
fail(f"/seal failed: {sealed.get('error')}", proc3)
151+
shares = sealed["shares"]
152+
passphrase = sealed["passphrase"]
153+
if len(shares) != 3:
154+
fail("seal did not return three shares", proc3)
155+
if not os.path.exists(target3 + ".age"):
156+
fail("seal did not produce the .age file", proc3)
157+
# SECURITY-CRITICAL: two of the returned shares must reconstruct
158+
# the returned passphrase (the file setup.sh made was already
159+
# proof-chain verified before it emitted).
160+
combine = subprocess.run(
161+
["ssss-combine", "-t", "2"],
162+
input=f"{shares[0]}\n{shares[1]}\n", capture_output=True, text=True,
163+
)
164+
recovered = ""
165+
for line in combine.stderr.splitlines():
166+
if line.startswith("Resulting secret: "):
167+
recovered = line[len("Resulting secret: "):]
168+
if recovered != passphrase:
169+
fail("the shares from /seal do NOT reconstruct the passphrase")
170+
# finish
171+
post(url3 + "done")
172+
wait_exit(proc3, 0)
173+
print("ok: create+seal produced a valid file whose shares reconstruct the passphrase")
174+
else:
175+
print("note: create+seal path skipped (age/ssss not installed)")
176+
129177
print("ok: edit-server load/save(exit0)/cancel(exit2) all correct")
130178
return 0
131179

web/edit-server.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"""
1919

2020
import http.server
21+
import json
2122
import os
23+
import subprocess
2224
import sys
2325
import threading
2426
import webbrowser
@@ -36,21 +38,67 @@ def main() -> int:
3638
target = os.path.abspath(args[0])
3739
port = 8765
3840
do_open = False
41+
mode = "review" # review: save then exit (review.sh re-encrypts).
42+
# create: save keeps the server up so the owner can seal.
3943
i = 1
4044
while i < len(args):
4145
if args[i] == "--port" and i + 1 < len(args):
4246
port = int(args[i + 1]); i += 2
4347
elif args[i] == "--open":
4448
do_open = True; i += 1
49+
elif args[i] == "--mode" and i + 1 < len(args):
50+
mode = args[i + 1]; i += 2
4551
else:
4652
i += 1
4753

4854
if not os.path.isfile(EDITOR_HTML):
4955
sys.stderr.write(f"error: editor not found at {EDITOR_HTML}\n")
5056
return 1
5157

58+
REPO_ROOT = os.path.dirname(HERE)
5259
state = {"result": None}
5360

61+
def run_validate():
62+
p = subprocess.run(
63+
["sh", os.path.join(REPO_ROOT, "scripts", "validate.sh"), target],
64+
stdin=subprocess.DEVNULL, capture_output=True, text=True,
65+
)
66+
return {"ok": p.returncode == 0, "output": (p.stdout + p.stderr)[-4000:]}
67+
68+
def run_seal(overwrite):
69+
# Seal via the proven scripts/setup.sh in machine-emit mode. Secrets
70+
# come back in the response (localhost only, same trust boundary as
71+
# the terminal ceremony) and are never written to disk here.
72+
out = target + ".age"
73+
if os.path.exists(out):
74+
if not overwrite:
75+
return {"ok": False, "error": "exists"}
76+
os.remove(out)
77+
if os.path.exists(out + ".sha256"):
78+
os.remove(out + ".sha256")
79+
env = dict(os.environ, EXECUTOR_FILE_EMIT="1")
80+
p = subprocess.run(
81+
["sh", os.path.join(REPO_ROOT, "scripts", "setup.sh"), target],
82+
stdin=subprocess.DEVNULL, capture_output=True, text=True, env=env,
83+
)
84+
if p.returncode != 0:
85+
msg = (p.stderr or p.stdout or "seal failed").strip()
86+
return {"ok": False, "error": msg[-800:]}
87+
block, shares, inblk = {}, [], False
88+
for line in p.stdout.splitlines():
89+
if line == "===SEAL-BEGIN===":
90+
inblk = True; continue
91+
if line == "===SEAL-END===":
92+
break
93+
if inblk and "\t" in line:
94+
k, v = line.split("\t", 1)
95+
(shares.append(v) if k == "share" else block.__setitem__(k, v))
96+
if len(shares) != 3 or "passphrase" not in block:
97+
return {"ok": False, "error": "could not read the sealed result"}
98+
return {"ok": True, "passphrase": block["passphrase"], "shares": shares,
99+
"sha256": block.get("sha256", ""), "out": block.get("out", ""),
100+
"own": block.get("own", "0")}
101+
54102
class Handler(http.server.BaseHTTPRequestHandler):
55103
def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
56104
self.send_response(code)
@@ -60,16 +108,26 @@ def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
60108
if body:
61109
self.wfile.write(body)
62110

111+
def _json(self, obj, code=200):
112+
self._send(code, json.dumps(obj).encode(), "application/json")
113+
114+
def _stop(self, result):
115+
state["result"] = result
116+
threading.Thread(target=httpd.shutdown, daemon=True).start()
117+
63118
def do_GET(self):
64119
if self.path in ("/", "/index.html", "/editor.html"):
65120
with open(EDITOR_HTML, "rb") as f:
66-
self._send(200, f.read(), "text/html; charset=utf-8")
121+
body = f.read().replace(b"__MODE__", mode.encode())
122+
self._send(200, body, "text/html; charset=utf-8")
67123
elif self.path == "/load":
68124
data = b""
69125
if os.path.isfile(target):
70126
with open(target, "rb") as f:
71127
data = f.read()
72128
self._send(200, data)
129+
elif self.path == "/validate":
130+
self._json(run_validate())
73131
else:
74132
self._send(404, b"not found")
75133

@@ -84,13 +142,22 @@ def do_POST(self):
84142
os.replace(tmp, target)
85143
except OSError as e:
86144
self._send(500, str(e).encode()); return
87-
self._send(200, b"saved")
88-
state["result"] = "saved"
89-
threading.Thread(target=httpd.shutdown, daemon=True).start()
145+
# In create mode the server stays up so the owner can seal;
146+
# in review mode saving is the end and review.sh takes over.
147+
if mode == "create":
148+
self._send(200, b"saved")
149+
else:
150+
self._send(200, b"saved"); self._stop("saved")
151+
elif self.path == "/seal" and mode == "create":
152+
try:
153+
params = json.loads(body) if body else {}
154+
except ValueError:
155+
params = {}
156+
self._json(run_seal(bool(params.get("overwrite"))))
157+
elif self.path == "/done":
158+
self._send(200, b"done"); self._stop("saved")
90159
elif self.path == "/cancel":
91-
self._send(200, b"cancelled")
92-
state["result"] = "cancel"
93-
threading.Thread(target=httpd.shutdown, daemon=True).start()
160+
self._send(200, b"cancelled"); self._stop("cancel")
94161
else:
95162
self._send(404, b"not found")
96163

0 commit comments

Comments
 (0)