From c53e52d0a0ab9f45154010080ee6c7c422442c1c Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 31 Jul 2026 00:30:13 -0700 Subject: [PATCH 1/2] Add encrypted team handoff bundles --- SKILL.md | 21 ++- docs/remote-setup.md | 23 +-- scripts/drivers/types/antigravity/template.md | 12 +- scripts/drivers/types/claude-code/template.md | 12 +- scripts/drivers/types/codex/template.md | 12 +- scripts/drivers/types/copilot/template.md | 12 +- scripts/drivers/types/cursor/template.md | 12 +- scripts/drivers/types/gemini/template.md | 12 +- scripts/drivers/types/grok-build/template.md | 12 +- scripts/drivers/types/hermes/template.md | 12 +- scripts/drivers/types/opencode/template.md | 12 +- scripts/internal/remote-sync.mjs | 119 +++++++++++++++- scripts/key.sh | 24 +++- scripts/remote.sh | 133 +++++++++++++----- tests/remote_sync_engine.test.mjs | 28 +++- tests/test_key.bats | 33 +++++ tests/test_remote.bats | 10 +- 17 files changed, 412 insertions(+), 87 deletions(-) diff --git a/SKILL.md b/SKILL.md index 5ca4871f..18d9d995 100644 --- a/SKILL.md +++ b/SKILL.md @@ -192,16 +192,16 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of - `--identity ` or `--identity-stdin`, and optional - `--confirm-digest `. -2. Run: `bash ~/.agents/skills/agmsg/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/agmsg/scripts/remote.sh unlock --bundle --confirm-digest ` 3. The snapshot digest must be compared over a separate live channel. Never - infer or auto-confirm it. Raw identity material is a permanent secret; when - `--identity-stdin` is needed, tell the user to run the command in their own - terminal rather than asking them to paste the identity into agent chat. + infer or auto-confirm it. The bundle is permanent secret key material; tell + the user to transfer and handle it only through their own trusted channel, + never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or + `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -226,6 +226,13 @@ If argument starts with "key show": agent mode. Tell the user to run it directly in their own terminal. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: + `bash ~/.agents/skills/agmsg/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent + secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. Do not ask the user to paste the private identity into this chat, and do not run the command yourself. Tell the user to run this in their own terminal: diff --git a/docs/remote-setup.md b/docs/remote-setup.md index f3538fd2..def7686d 100644 --- a/docs/remote-setup.md +++ b/docs/remote-setup.md @@ -97,23 +97,28 @@ bash ~/.agents/skills/agmsg/scripts/remote.sh connect \ ``` If the team has no key yet, connect creates one and prints the mandatory backup -notice. It also shows how to export the compact public epoch snapshot. Transfer -that snapshot and the private key to machine B through a separate trusted -channel. Machine B still imports the key explicitly and live-confirms the -displayed snapshot digest; the message server never distributes key material. -After `pull` reports that the team is locked, machine B runs one command with -the handed files and the digest verified over that separate live channel: +notice. On machine A, export one secret handoff bundle containing the confirmed +snapshot chain and every epoch identity: + +```sh +bash ~/.agents/skills/agmsg/scripts/key.sh handoff --out +``` + +Transfer that bundle to machine B through a separate trusted channel. The +message server never distributes key material. Compare the displayed snapshot +digest over a separate live channel. After `pull` reports that the team is +locked, machine B runs: ```sh bash ~/.agents/skills/agmsg/scripts/remote.sh unlock \ - --snapshot [--snapshot ...] \ - --identity \ + --bundle \ --confirm-digest ``` `unlock` imports the identity, records the trust anchor, reprocesses quarantined envelopes, and starts the encrypted sync engine. It is safe to repeat with the -same confirmed files. +same confirmed bundle. The bundle contains private keys: keep it secret and +delete the transferred copy when it is no longer needed. ## Reference diff --git a/scripts/drivers/types/antigravity/template.md b/scripts/drivers/types/antigravity/template.md index 43e9d24e..d55a588b 100644 --- a/scripts/drivers/types/antigravity/template.md +++ b/scripts/drivers/types/antigravity/template.md @@ -154,10 +154,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -179,6 +180,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/drivers/types/claude-code/template.md b/scripts/drivers/types/claude-code/template.md index 131b3fb9..81b3d3e2 100644 --- a/scripts/drivers/types/claude-code/template.md +++ b/scripts/drivers/types/claude-code/template.md @@ -234,10 +234,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -259,6 +260,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 68ad4852..0cc90632 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -182,10 +182,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -207,6 +208,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/drivers/types/copilot/template.md b/scripts/drivers/types/copilot/template.md index 97cf404a..4ff9a640 100644 --- a/scripts/drivers/types/copilot/template.md +++ b/scripts/drivers/types/copilot/template.md @@ -154,10 +154,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -179,6 +180,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/drivers/types/cursor/template.md b/scripts/drivers/types/cursor/template.md index 2ac3c11d..b3784d92 100644 --- a/scripts/drivers/types/cursor/template.md +++ b/scripts/drivers/types/cursor/template.md @@ -157,10 +157,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -182,6 +183,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/drivers/types/gemini/template.md b/scripts/drivers/types/gemini/template.md index 69c37ea2..f82c18ba 100644 --- a/scripts/drivers/types/gemini/template.md +++ b/scripts/drivers/types/gemini/template.md @@ -154,10 +154,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -179,6 +180,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/drivers/types/grok-build/template.md b/scripts/drivers/types/grok-build/template.md index 30853f33..3c5ae041 100644 --- a/scripts/drivers/types/grok-build/template.md +++ b/scripts/drivers/types/grok-build/template.md @@ -185,10 +185,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -210,6 +211,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/drivers/types/hermes/template.md b/scripts/drivers/types/hermes/template.md index 39d105ff..d0954797 100644 --- a/scripts/drivers/types/hermes/template.md +++ b/scripts/drivers/types/hermes/template.md @@ -142,10 +142,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -167,6 +168,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/drivers/types/opencode/template.md b/scripts/drivers/types/opencode/template.md index 45eac855..19bbc9b0 100644 --- a/scripts/drivers/types/opencode/template.md +++ b/scripts/drivers/types/opencode/template.md @@ -157,10 +157,11 @@ If argument starts with "remote pull": 4. Show the output to the user. If argument starts with "remote unlock": -1. Parse ``, one or more `--snapshot ` arguments in ascending revision order, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --snapshot [--snapshot ...] (--identity |--identity-stdin) [--confirm-digest ]` -3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. Raw identity material is a permanent secret; when `--identity-stdin` is needed, tell the user to run the command in their own terminal rather than asking them to paste the identity into agent chat. +1. Parse ``, `--bundle `, and `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --bundle --confirm-digest ` +3. The snapshot digest must be compared over a separate live channel. Never infer or auto-confirm it. The bundle is permanent secret key material; tell the user to transfer and handle it only through their own trusted channel, never by pasting it into agent chat. 4. Show the complete result, including the imported-envelope count and engine PID. +5. The advanced form with repeatable `--snapshot` plus `--identity` or `--identity-stdin` remains available when explicitly requested. If argument starts with "remote status": 1. Parse an optional `` and `--json`. @@ -182,6 +183,11 @@ If argument starts with "key show": 3. `--reveal-secret` requires a real interactive terminal and is refused in agent mode — if the user wants to reveal a secret, tell them to run it themselves directly in their own terminal rather than through you. 4. Show the output to the user. +If argument starts with "key handoff" followed by a team name: +1. Parse optional `--out ` and run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/key.sh handoff [--out ]` +2. The output bundle contains every epoch identity and is itself permanent secret key material. Never read it into agent chat or display its contents. +3. Show the bundle path, latest snapshot digest, and full secrecy warning. + If argument starts with "key import" followed by a team name: 1. **Do not ask the user to paste the private identity into this chat, and do not run this command yourself.** This identity is a permanent secret. Tell the user to run this directly in their own terminal: ``` diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index faa72551..059922d4 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -33,6 +33,8 @@ function usage() { [--age-identity KEY_ID=FILE ...] remote-sync.sh export-age-snapshot --team NAME [--out FILE] remote-sync.sh verify-age-snapshot --team NAME --age-snapshot FILE + remote-sync.sh export-age-handoff --team NAME --out FILE + remote-sync.sh verify-age-handoff --team NAME --bundle FILE --out-dir DIRECTORY remote-sync.sh once --team NAME [--limit N] remote-sync.sh run --team NAME [--limit N] [--interval SECONDS] remote-sync.sh reprocess --team NAME [--limit N] @@ -534,6 +536,118 @@ export async function exportAgeSnapshot(args) { process.stderr.write(`Snapshot SHA-256: ${ageSnapshotDigest(snapshot)}\n`); } +export async function exportAgeHandoff(args) { + const team = requireName(args.team, "team"); + if (!args.out) throw new Error("export-age-handoff requires --out"); + const config = await readStoredSyncConfig(team); + if (config.cipher_profile !== "age-v1") throw new Error("team is not configured for age-v1"); + validateAgeConfiguration(config); + await validateRetainedAgeCheckpoint(config); + validateConfiguredAgeIdentities(config); + const snapshots = ageSnapshotChain(config.age_v1); + const latest = snapshots.at(-1); + const identities = []; + const seen = new Set(); + for (const epoch of latest.history) { + if (seen.has(epoch.key_id)) continue; + seen.add(epoch.key_id); + const path = config.age_v1.identity_files[epoch.key_id]; + if (!path) throw new Error(`local identity is missing for ${epoch.key_id}`); + const identity = (await readFile(path, "utf8")).trim(); + identities.push({ key_id: epoch.key_id, identity }); + } + const bundle = { + format_version: 1, + type: "agmsg_age_v1_handoff", + snapshots, + identities, + }; + const canonical = canonicalJson(bundle); + const outputPath = resolve(args.out); + const directory = dirname(outputPath); + await mkdir(directory, { recursive: true }); + const temporary = join(directory, `.${basename(outputPath)}.${process.pid}.tmp`); + await writeFile(temporary, canonical, { mode: 0o600, flag: "wx" }); + await rename(temporary, outputPath); + process.stderr.write(`Snapshot SHA-256: ${ageSnapshotDigest(latest)}\n`); +} + +export async function verifyAgeHandoff(args) { + const team = requireName(args.team, "team"); + if (!args.bundle || !args["out-dir"]) { + throw new Error("verify-age-handoff requires --bundle and --out-dir"); + } + const text = await readFile(resolve(args.bundle), "utf8"); + const bundle = parseStrictJson(text); + if (text.trim() !== canonicalJson(bundle)) { + throw new Error("age handoff bundle must be RFC 8785 JCS without duplicate or noncanonical fields"); + } + if (!bundle || bundle.format_version !== 1 || bundle.type !== "agmsg_age_v1_handoff" || + !Array.isArray(bundle.snapshots) || bundle.snapshots.length < 1 || + !Array.isArray(bundle.identities) || + Object.keys(bundle).sort().join(",") !== "format_version,identities,snapshots,type") { + throw new Error("age handoff bundle is invalid"); + } + const outputDirectory = resolve(args["out-dir"]); + await mkdir(outputDirectory, { recursive: true, mode: 0o700 }); + const snapshotPaths = []; + for (let index = 0; index < bundle.snapshots.length; index += 1) { + const path = join(outputDirectory, `snapshot-${String(index).padStart(4, "0")}.json`); + await writeFile(path, canonicalJson(bundle.snapshots[index]), { mode: 0o600, flag: "wx" }); + snapshotPaths.push(path); + } + const identityMappings = []; + const seen = new Set(); + for (const entry of bundle.identities) { + if (!entry || Object.keys(entry).sort().join(",") !== "identity,key_id" || + typeof entry.key_id !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(entry.key_id) || + typeof entry.identity !== "string" || seen.has(entry.key_id)) { + throw new Error("age handoff identity is invalid"); + } + seen.add(entry.key_id); + const path = join(outputDirectory, `identity-${entry.key_id}.key`); + await writeFile(path, `${entry.identity.trim()}\n`, { mode: 0o600, flag: "wx" }); + identityMappings.push({ key_id: entry.key_id, path }); + } + const binding = await readConnectedBinding(team); + const latest = bundle.snapshots.at(-1); + const digest = ageSnapshotDigest(latest); + const config = { + format_version: 1, + local_team: team, + server_url: binding.endpoint, + server_instance_id: binding.server_instance_id, + remote_team_id: binding.remote_team_id, + protocol_version: binding.protocol_version, + cipher_profile: "age-v1", + local_security_history: [{ local_security_revision: "0", effective_from_seq: "1", + minimum_security_mode: "e2ee-required" }], + age_v1: { + epoch_snapshots: bundle.snapshots, + checkpoint: { epoch_revision: latest.epoch_revision, snapshot_sha256: digest, + writer_generation: latest.writer_generation, confirmed_at: new Date().toISOString() }, + identity_files: Object.fromEntries(identityMappings.map(({ key_id, path }) => [key_id, path])), + age_version: "verification-only", + }, + }; + validateAgeConfiguration(config); + const expectedKeyIds = [...new Set(latest.history.map((epoch) => epoch.key_id))]; + if (expectedKeyIds.length !== identityMappings.length || + expectedKeyIds.some((keyId) => !seen.has(keyId))) { + throw new Error("age handoff bundle does not contain every epoch identity exactly once"); + } + validateConfiguredAgeIdentities(config); + const result = { + type: "age_handoff_verified", + snapshot_sha256: digest, + epoch_revision: latest.epoch_revision, + snapshot_paths: snapshotPaths, + identities: identityMappings, + }; + process.stdout.write(`${JSON.stringify(result)}\n`); + return result; +} + export async function verifyAgeSnapshot(args) { const team = requireName(args.team, "team"); if (!args["age-snapshot"]) throw new Error("age-snapshot is required"); @@ -2442,13 +2556,16 @@ async function publicSnapshot(serverUrl, teamId) { async function main() { const [command, ...rest] = process.argv.slice(2); const args = options(rest); - if (!["configure", "export-age-snapshot", "verify-age-snapshot", "once", "run", "reprocess", "resync", + if (!["configure", "export-age-snapshot", "verify-age-snapshot", "export-age-handoff", + "verify-age-handoff", "once", "run", "reprocess", "resync", "unblock-read", "pull-bootstrap", "resolve-team"].includes(command)) { throw new Error(usage()); } if (command === "configure") { await configure(args); return; } if (command === "export-age-snapshot") { await exportAgeSnapshot(args); return; } if (command === "verify-age-snapshot") { await verifyAgeSnapshot(args); return; } + if (command === "export-age-handoff") { await exportAgeHandoff(args); return; } + if (command === "verify-age-handoff") { await verifyAgeHandoff(args); return; } // Before any local team exists, so neither can go through loadConfig. if (command === "resolve-team") { await resolveTeam(args); return; } if (command === "pull-bootstrap") { await pullBootstrap(args); return; } diff --git a/scripts/key.sh b/scripts/key.sh index 4c734317..61bac98d 100755 --- a/scripts/key.sh +++ b/scripts/key.sh @@ -5,6 +5,7 @@ set -euo pipefail # key.sh generate [] # key.sh show [] [--key-id ] [--reveal-secret] # key.sh show [] --snapshot [--out ] +# key.sh handoff [--out ] # key.sh import [] [--identity-stdin] # key.sh rotate [] # @@ -310,6 +311,26 @@ cmd_show() { grep '^AGE-SECRET-KEY-' "$identity_file" } +cmd_handoff() { + local team="" out="" + while [ $# -gt 0 ]; do + case "$1" in + --out) out="${2:?--out requires a value}"; shift 2 ;; + --out=*) out="${1#--out=}"; shift ;; + --*) echo "agmsg: unknown handoff option: $1" >&2; exit 1 ;; + *) [ -z "$team" ] || { echo "agmsg: handoff accepts one team" >&2; exit 1; } + team="$1"; shift ;; + esac + done + : "${team:?Usage: key.sh handoff [--out ]}" + agmsg_validate_team_name "$team" || exit 1 + [ -n "$out" ] || out="./$team-age-handoff.json" + _key_require_age || exit 1 + bash "$SCRIPT_DIR/remote-sync.sh" export-age-handoff --team "$team" --out "$out" + echo "Handoff bundle written to: $out" + echo "KEEP SECRET — this file IS the key. Transfer it only through a trusted channel." +} + cmd_import() { local identity_stdin=0 requested_key_id="" positional=() while [ $# -gt 0 ]; do @@ -630,9 +651,10 @@ EOF case "${1:-}" in generate) shift; cmd_generate "$@" ;; show) shift; cmd_show "$@" ;; + handoff) shift; cmd_handoff "$@" ;; import) shift; cmd_import "$@" ;; rotate) shift; cmd_rotate "$@" ;; *) - echo "Usage: key.sh ..." >&2 + echo "Usage: key.sh ..." >&2 exit 1 ;; esac diff --git a/scripts/remote.sh b/scripts/remote.sh index ca47b64f..2adc6fda 100644 --- a/scripts/remote.sh +++ b/scripts/remote.sh @@ -4,6 +4,7 @@ set -euo pipefail # Usage: # remote.sh connect --endpoint [--e2ee] # remote.sh pull --endpoint [--team-id ] +# remote.sh unlock --bundle --confirm-digest # remote.sh unlock --snapshot (--identity |--identity-stdin) # [--confirm-digest ] # remote.sh status [] [--json] @@ -764,6 +765,12 @@ _remote_json_field() { agmsg_sqlite_mem "SELECT COALESCE(json_extract('$escaped', '$path'), '');" } +_remote_json_array_length() { + local doc="$1" path="$2" escaped + escaped=$(printf '%s' "$doc" | sed "s/'/''/g") + agmsg_sqlite_mem "SELECT COALESCE(json_array_length(json_extract('$escaped', '$path')), 0);" +} + # Writes the local team for a pull. Unlike _remote_ensure_team this does NOT # mint a team_id: the id came from the server and is recorded as it arrived. # Minting here would give one team two identities, which is the whole reason @@ -957,7 +964,7 @@ cmd_pull() { echo "Pulled '$pulled_name' into local team '$team' ($imported message(s)). Sync engine running." fi if [ "$pulled_age_v1" -gt 0 ]; then - echo "This team is local but locked. Run remote.sh unlock with the snapshot and identity you were handed." + echo "This team is local but locked. Run remote.sh unlock --bundle with the secret handoff bundle you were given." else echo "This team is now local and ready for normal use." echo "Open your agent and invoke its installed '$cmd_name' command, then join with a new agent name." @@ -965,11 +972,13 @@ cmd_pull() { } cmd_unlock() { - local team="" identity_file="" identity_stdin=0 confirm_digest="" snapshots=() + local team="" identity_file="" identity_stdin=0 confirm_digest="" bundle="" snapshots=() while [ $# -gt 0 ]; do case "$1" in --snapshot) snapshots+=("${2:?--snapshot requires a value}"); shift 2 ;; --snapshot=*) snapshots+=("${1#--snapshot=}"); shift ;; + --bundle) bundle="${2:?--bundle requires a value}"; shift 2 ;; + --bundle=*) bundle="${1#--bundle=}"; shift ;; --identity) identity_file="${2:?--identity requires a value}"; shift 2 ;; --identity=*) identity_file="${1#--identity=}"; shift ;; --identity-stdin) identity_stdin=1; shift ;; @@ -980,13 +989,24 @@ cmd_unlock() { team="$1"; shift ;; esac done - : "${team:?Usage: remote.sh unlock --snapshot (--identity |--identity-stdin) [--confirm-digest ]}" - [ "${#snapshots[@]}" -gt 0 ] || { echo "agmsg: --snapshot is required" >&2; exit 1; } + : "${team:?Usage: remote.sh unlock (--bundle | --snapshot (--identity |--identity-stdin)) [--confirm-digest ]}" agmsg_validate_team_name "$team" || exit 1 - if { [ -n "$identity_file" ] && [ "$identity_stdin" -eq 1 ]; } || - { [ -z "$identity_file" ] && [ "$identity_stdin" -eq 0 ]; }; then - echo "agmsg: unlock requires exactly one of --identity or --identity-stdin" >&2 - exit 1 + if [ -n "$bundle" ]; then + if [ "${#snapshots[@]}" -gt 0 ] || [ -n "$identity_file" ] || [ "$identity_stdin" -eq 1 ]; then + echo "agmsg: --bundle cannot be combined with --snapshot or --identity" >&2 + exit 1 + fi + [ -n "$confirm_digest" ] || { + echo "agmsg: --bundle requires --confirm-digest verified over a separate live channel" >&2 + exit 1 + } + else + [ "${#snapshots[@]}" -gt 0 ] || { echo "agmsg: --snapshot or --bundle is required" >&2; exit 1; } + if { [ -n "$identity_file" ] && [ "$identity_stdin" -eq 1 ]; } || + { [ -z "$identity_file" ] && [ "$identity_stdin" -eq 0 ]; }; then + echo "agmsg: unlock requires exactly one of --identity or --identity-stdin" >&2 + exit 1 + fi fi local cfg binding_cipher metadata digest epoch_revision key_id recipient @@ -997,7 +1017,29 @@ cmd_unlock() { echo "agmsg: team '$team' is not an encrypted pulled team awaiting unlock" >&2 exit 1 } - local snapshot_args=() snapshot + local snapshot_args=() identity_args=() snapshot handoff_tmp="" handoff_metadata="" + if [ -n "$bundle" ]; then + handoff_tmp="$(mktemp -d "${TMPDIR:-/tmp}/agmsg-handoff.XXXXXX")" + chmod 700 "$handoff_tmp" + trap 'rm -r "${handoff_tmp:-}" 2>/dev/null || true' EXIT INT TERM HUP + handoff_metadata="$(bash "$SCRIPT_DIR/remote-sync.sh" verify-age-handoff \ + --team "$team" --bundle "$bundle" --out-dir "$handoff_tmp")" || exit 1 + local snapshot_count identity_count index mapped_key mapped_path + snapshot_count="$(_remote_json_array_length "$handoff_metadata" '$.snapshot_paths')" + identity_count="$(_remote_json_array_length "$handoff_metadata" '$.identities')" + index=0 + while [ "$index" -lt "$snapshot_count" ]; do + snapshots+=("$(_remote_json_field "$handoff_metadata" "\$.snapshot_paths[$index]")") + index=$((index + 1)) + done + index=0 + while [ "$index" -lt "$identity_count" ]; do + mapped_key="$(_remote_json_field "$handoff_metadata" "\$.identities[$index].key_id")" + mapped_path="$(_remote_json_field "$handoff_metadata" "\$.identities[$index].path")" + identity_args+=("$mapped_key=$mapped_path") + index=$((index + 1)) + done + fi for snapshot in "${snapshots[@]}"; do snapshot_args+=(--age-snapshot "$snapshot"); done metadata="$(bash "$SCRIPT_DIR/remote-sync.sh" verify-age-snapshot \ --team "$team" "${snapshot_args[@]}")" || exit 1 @@ -1010,6 +1052,15 @@ cmd_unlock() { echo "Snapshot SHA-256: $digest" echo "Snapshot key_id: $key_id" + if [ -n "$bundle" ]; then + local bundle_digest + bundle_digest="$(_remote_json_field "$handoff_metadata" '$.snapshot_sha256')" + [ "$bundle_digest" = "$digest" ] || { + echo "agmsg: handoff bundle verification disagrees with the snapshot chain" >&2 + exit 1 + } + fi + if [ -z "$confirm_digest" ]; then if [ "$identity_stdin" -eq 1 ] && { [ ! -r /dev/tty ] || [ ! -w /dev/tty ]; }; then echo "agmsg: --identity-stdin without a terminal also requires --confirm-digest " >&2 @@ -1023,33 +1074,43 @@ cmd_unlock() { exit 1 fi - local identity_tmp derived_recipient identity_dest - identity_tmp="$(mktemp "${TMPDIR:-/tmp}/agmsg-unlock-identity.XXXXXX")" - chmod 600 "$identity_tmp" - trap 'rm -f "${identity_tmp:-}"' EXIT INT TERM HUP - if [ "$identity_stdin" -eq 1 ]; then - cat > "$identity_tmp" + local identity_tmp="" derived_recipient identity_dest mapping mapping_key mapping_path + if [ -n "$bundle" ]; then + local configured_identity_args=() + for mapping in "${identity_args[@]}"; do + mapping_key="${mapping%%=*}" + mapping_path="${mapping#*=}" + grep '^AGE-SECRET-KEY-' "$mapping_path" | + bash "$SCRIPT_DIR/key.sh" import "$team" --key-id "$mapping_key" \ + --identity-stdin || exit 1 + identity_dest="$CONNECTION_ROOT/run/remote-credentials/$team/keys/$mapping_key.key" + configured_identity_args+=(--age-identity "$mapping_key=$identity_dest") + done else - cat "$identity_file" > "$identity_tmp" - fi - derived_recipient="$(age-keygen -y "$identity_tmp" 2>/dev/null)" || { - echo "agmsg: handed identity is not a valid age identity" >&2 - exit 1 - } - if [ "$derived_recipient" != "$recipient" ]; then - echo "agmsg: handed identity does not match the authority-confirmed snapshot" >&2 - exit 1 + identity_tmp="$(mktemp "${TMPDIR:-/tmp}/agmsg-unlock-identity.XXXXXX")" + chmod 600 "$identity_tmp" + trap 'rm -f "${identity_tmp:-}"; [ -z "${handoff_tmp:-}" ] || rm -r "$handoff_tmp" 2>/dev/null || true' EXIT INT TERM HUP + if [ "$identity_stdin" -eq 1 ]; then + cat > "$identity_tmp" + else + cat "$identity_file" > "$identity_tmp" + fi + derived_recipient="$(age-keygen -y "$identity_tmp" 2>/dev/null)" || { + echo "agmsg: handed identity is not a valid age identity" >&2 + exit 1 + } + if [ "$derived_recipient" != "$recipient" ]; then + echo "agmsg: handed identity does not match the authority-confirmed snapshot" >&2 + exit 1 + fi + grep '^AGE-SECRET-KEY-' "$identity_tmp" | + bash "$SCRIPT_DIR/key.sh" import "$team" --key-id "$key_id" \ + --identity-stdin || exit 1 + identity_dest="$CONNECTION_ROOT/run/remote-credentials/$team/keys/$key_id.key" + configured_identity_args=(--age-identity "$key_id=$identity_dest") + rm -f "$identity_tmp" + identity_tmp="" fi - # age-keygen writes a native identity file with comments followed by the - # secret line. key.sh import intentionally accepts the raw secret on stdin, - # so pass only that line after age-keygen -y has validated the whole file and - # matched its recipient to the authority snapshot above. - grep '^AGE-SECRET-KEY-' "$identity_tmp" | - bash "$SCRIPT_DIR/key.sh" import "$team" --key-id "$key_id" \ - --identity-stdin || exit 1 - identity_dest="$CONNECTION_ROOT/run/remote-credentials/$team/keys/$key_id.key" - rm -f "$identity_tmp" - trap - EXIT INT TERM HUP local endpoint remote_team_id configure_out reprocess_out reprocess_result imported blocking endpoint="$(_remote_read_config_field "$cfg" '$.remote_binding.endpoint')" @@ -1063,7 +1124,7 @@ cmd_unlock() { "${snapshot_args[@]}" \ --age-checkpoint "$epoch_revision:$digest" \ --age-confirmation operator-live \ - --age-identity "$key_id=$identity_dest")" || exit 1 + "${configured_identity_args[@]}")" || exit 1 [ -n "$configure_out" ] && printf '%s\n' "$configure_out" reprocess_out="$(bash "$SCRIPT_DIR/remote-sync.sh" reprocess --team "$team")" || exit 1 reprocess_result="$(printf '%s\n' "$reprocess_out" | @@ -1110,6 +1171,8 @@ cmd_unlock() { fi echo "Unlocked '$team': imported $imported envelope(s); engine running (pid $pid)." echo "This team is now local and ready for normal use." + [ -z "$handoff_tmp" ] || rm -r "$handoff_tmp" + trap - EXIT INT TERM HUP } # The remote-sync engine runs as a background daemon: one per connected team, diff --git a/tests/remote_sync_engine.test.mjs b/tests/remote_sync_engine.test.mjs index 39ccd82d..e8f73338 100644 --- a/tests/remote_sync_engine.test.mjs +++ b/tests/remote_sync_engine.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, unlink, +import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -14,6 +14,7 @@ import { configure, cycle, driver, + exportAgeHandoff, exportAgeSnapshot, isRetryable, initialAgeSnapshot, @@ -43,6 +44,7 @@ import { validateResyncResult, validateResyncStatus, verifyAgeSnapshot, + verifyAgeHandoff, } from "../scripts/internal/remote-sync.mjs"; const config = { @@ -109,7 +111,8 @@ test("a rotator provisions its confirmed snapshot at the server boundary", async epoch_snapshots: [initial], checkpoint: { epoch_revision: "0", writer_generation: "0", snapshot_sha256: ageSnapshotDigest(initial), confirmed_at: "2026-07-29T00:00:00Z" }, - identity_files: {}, + identity_files: { [oldKeyId]: join(root, "run", "remote-credentials", "demo", + "keys", `${oldKeyId}.key`) }, age_version: "v1.3.1", }, }; @@ -121,7 +124,14 @@ test("a rotator provisions its confirmed snapshot at the server boundary", async const keyDir = join(root, "run", "remote-credentials", "demo", "keys"); await mkdir(teamDir, { recursive: true }); await mkdir(keyDir, { recursive: true }); - await writeFile(join(teamDir, "config.json"), `${JSON.stringify(teamConfig)}\n`); + await writeFile(join(teamDir, "config.json"), `${JSON.stringify({ ...teamConfig, + remote_binding: { endpoint: "https://sync.example.test", + server_instance_id: config.server_instance_id, + remote_team_id: config.remote_team_id, protocol_version: 1, + capabilities: { write_allowed_ciphers: ["none", "age-v1"] }, + cipher_profile: "age-v1", connected_at: "2026-07-29T00:00:00Z", + disconnected_at: null }, + })}\n`); await writeFile(join(teamDir, "roster.jsonl"), [ JSON.stringify({ type: "key_rotated", ...rotation, at: "2026-07-30T00:00:00.000000Z", server_seq: undefined }), @@ -131,6 +141,7 @@ test("a rotator provisions its confirmed snapshot at the server boundary", async server_instance_id: config.server_instance_id, remote_team_id: config.remote_team_id }), "", ].join("\n")); + await writeFile(join(keyDir, `${oldKeyId}.key`), `${identity}\n`, { mode: 0o600 }); await writeFile(join(keyDir, `${newKeyId}.key`), `${identity}\n`, { mode: 0o600 }); const snapshot = nextLocalAgeSnapshot(rotationConfig, teamConfig, rotation); assert.equal(snapshot.epoch_revision, "1"); @@ -154,6 +165,17 @@ test("a rotator provisions its confirmed snapshot at the server boundary", async const exported = JSON.parse(await readFile(exportedPath, "utf8")); assert.equal(exported.epoch_revision, "1"); assert.equal(exported.history.length, 2); + const handoffPath = join(root, "age-handoff.json"); + await exportAgeHandoff({ team: "demo", out: handoffPath }); + const handoff = JSON.parse(await readFile(handoffPath, "utf8")); + assert.equal(handoff.snapshots.length, 2); + assert.deepEqual(handoff.identities.map((entry) => entry.key_id), [oldKeyId, newKeyId]); + assert.equal((await stat(handoffPath)).mode & 0o077, 0); + const extracted = await verifyAgeHandoff({ team: "demo", bundle: handoffPath, + "out-dir": join(root, "handoff-extracted") }); + assert.equal(extracted.snapshot_sha256, ageSnapshotDigest(snapshot)); + assert.equal(extracted.snapshot_paths.length, 2); + assert.equal(extracted.identities.length, 2); const confirmedStored = structuredClone(stored); const rolledBack = structuredClone(confirmedStored); rolledBack.age_v1.epoch_snapshots = [initial]; diff --git a/tests/test_key.bats b/tests/test_key.bats index 851a9a9f..e19f7028 100644 --- a/tests/test_key.bats +++ b/tests/test_key.bats @@ -57,6 +57,27 @@ EOF chmod +x "$SCRIPTS/remote-sync.sh" } +stub_age_handoff() { + cat > "$SCRIPTS/remote-sync.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ "$1" = "export-age-handoff" ] +shift +out="" +while [ $# -gt 0 ]; do + case "$1" in + --out) out="$2"; shift 2 ;; + *) shift ;; + esac +done +[ -n "$out" ] +printf '%s' '{"format_version":1,"identities":[],"snapshots":[],"type":"agmsg_age_v1_handoff"}' > "$out" +chmod 600 "$out" +echo "Snapshot SHA-256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" >&2 +EOF + chmod +x "$SCRIPTS/remote-sync.sh" +} + # --- generate -------------------------------------------------------------- @test "key generate: creates a first epoch and prints the backup notice" { @@ -171,6 +192,18 @@ EOF [ "$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$first")') AS TEXT), '\$.authorized_writers[0]');")" = "$key_id" ] } +@test "key handoff writes a secret bundle and prints its digest and warning" { + skip_if_no_age + stub_age_handoff + local bundle="$TEST_SKILL_DIR/handoff.json" + run bash "$SCRIPTS/key.sh" handoff testteam --out "$bundle" + [ "$status" -eq 0 ] + [ -f "$bundle" ] + [[ "$output" == *"Snapshot SHA-256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"* ]] + [[ "$output" == *"KEEP SECRET — this file IS the key"* ]] + [[ "$output" == *"Handoff bundle written to: $bundle"* ]] +} + # --- import -------------------------------------------------------------- @test "key import --identity-stdin: establishes the first epoch for a team with no key yet" { diff --git a/tests/test_remote.bats b/tests/test_remote.bats index 48fa5b59..b16d8f9c 100644 --- a/tests/test_remote.bats +++ b/tests/test_remote.bats @@ -833,7 +833,7 @@ PULL_TEAM_ID=018f3f7e-2222-7000-8000-000000000002 run bash "$SCRIPTS/remote.sh" pull --endpoint "$ENDPOINT" --team-id "$PULL_TEAM_ID" encrypted [ "$status" -eq 0 ] [[ "$output" == *"This team is encrypted"* ]] - [[ "$output" == *"Run remote.sh unlock with the snapshot and identity you were handed."* ]] + [[ "$output" == *"Run remote.sh unlock --bundle with the secret handoff bundle you were given."* ]] local cfg before after cfg="$TEST_SKILL_DIR/teams/encrypted/config.json" @@ -853,11 +853,13 @@ PULL_TEAM_ID=018f3f7e-2222-7000-8000-000000000002 skip_if_no_age bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" --e2ee testteam >/dev/null - local source_cfg snapshot key_id recipient identity team_id envelope digest + local source_cfg snapshot bundle key_id recipient identity team_id envelope digest source_cfg="$TEST_SKILL_DIR/teams/testteam/config.json" snapshot="$TEST_SKILL_DIR/handed-snapshot.json" + bundle="$TEST_SKILL_DIR/handed-bundle.json" envelope="$TEST_SKILL_DIR/handed-envelope.json" bash "$SCRIPTS/key.sh" show testteam --snapshot --out "$snapshot" 2>/dev/null + bash "$SCRIPTS/key.sh" handoff testteam --out "$bundle" >/dev/null 2>&1 key_id="$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$source_cfg")') AS TEXT), '\$.remote_key.current.key_id');")" recipient="$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$source_cfg")') AS TEXT), '\$.remote_key.current.recipient');")" team_id="$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$source_cfg")') AS TEXT), '\$.team_id');")" @@ -919,7 +921,7 @@ PULL_TEAM_ID=018f3f7e-2222-7000-8000-000000000002 [ ! -d "$PEER_SKILL_DIR/run/remote-trust" ] run bash "$peer_scripts/remote.sh" unlock encrypted \ - --snapshot "$snapshot" --identity "$identity" --confirm-digest "$digest" + --bundle "$bundle" --confirm-digest "$digest" [ "$status" -eq 0 ] [[ "$output" == *"imported 1 envelope(s); engine running (pid "* ]] local pidfile first_pid second_pid @@ -928,7 +930,7 @@ PULL_TEAM_ID=018f3f7e-2222-7000-8000-000000000002 first_pid="$(cat "$pidfile")" [ -d "$PEER_SKILL_DIR/run/remote-trust" ] run bash "$peer_scripts/remote.sh" unlock encrypted \ - --snapshot "$snapshot" --identity "$identity" --confirm-digest "$digest" + --bundle "$bundle" --confirm-digest "$digest" [ "$status" -eq 0 ] [[ "$output" == *"imported 0 envelope(s); engine running (pid "* ]] second_pid="$(cat "$pidfile")" From 06bf128669b7983d9252b1baa540c153dc229301 Mon Sep 17 00:00:00 2001 From: fujibee Date: Fri, 31 Jul 2026 02:01:07 -0700 Subject: [PATCH 2/2] Keep default handoff bundles outside projects --- scripts/key.sh | 7 ++++++- tests/test_key.bats | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/key.sh b/scripts/key.sh index 61bac98d..1de98200 100755 --- a/scripts/key.sh +++ b/scripts/key.sh @@ -324,7 +324,12 @@ cmd_handoff() { done : "${team:?Usage: key.sh handoff [--out ]}" agmsg_validate_team_name "$team" || exit 1 - [ -n "$out" ] || out="./$team-age-handoff.json" + if [ -z "$out" ]; then + local handoff_dir="$CRED_ROOT/$team/handoff" + mkdir -p "$handoff_dir" + chmod 700 "$handoff_dir" 2>/dev/null || true + out="$handoff_dir/$team-age-handoff.json" + fi _key_require_age || exit 1 bash "$SCRIPT_DIR/remote-sync.sh" export-age-handoff --team "$team" --out "$out" echo "Handoff bundle written to: $out" diff --git a/tests/test_key.bats b/tests/test_key.bats index e19f7028..688b0b40 100644 --- a/tests/test_key.bats +++ b/tests/test_key.bats @@ -204,6 +204,23 @@ EOF [[ "$output" == *"Handoff bundle written to: $bundle"* ]] } +@test "key handoff without --out never writes the secret bundle under cwd" { + skip_if_no_age + stub_age_handoff + local project="$TEST_SKILL_DIR/project-checkout" + mkdir -p "$project" + cd "$project" + run bash "$SCRIPTS/key.sh" handoff testteam + [ "$status" -eq 0 ] + [ ! -e "$project/testteam-age-handoff.json" ] + local private_bundle="$TEST_SKILL_DIR/run/remote-credentials/testteam/handoff/testteam-age-handoff.json" + [ -f "$private_bundle" ] + [[ "$output" == *"Handoff bundle written to: $private_bundle"* ]] + local perms + perms=$(stat -f "%Lp" "$(dirname "$private_bundle")" 2>/dev/null || stat -c "%a" "$(dirname "$private_bundle")") + [ "$perms" = "700" ] +} + # --- import -------------------------------------------------------------- @test "key import --identity-stdin: establishes the first epoch for a team with no key yet" {