diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 04d315f8..82650ba9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -251,8 +251,10 @@ jobs: if: needs.changes.outputs.docs_only != 'true' run: | go install filippo.io/age/cmd/age@v1.3.1 + go install filippo.io/age/cmd/age-keygen@v1.3.1 npm config set prefix "$HOME/.npm-global" npm install -g bats + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" - name: Run age-v1 shared vectors @@ -264,6 +266,13 @@ jobs: AGMSG_AGE_BIN="$age_bin" bats --print-output-on-failure tests/test_jsonl_remote_sync.bats AGE_BIN="$age_bin" node docs/spec/vectors/verify-age-v1-vectors.mjs + - name: Run encrypted onboarding CLI roundtrip + if: needs.changes.outputs.docs_only != 'true' + run: | + command -v age >/dev/null + command -v age-keygen >/dev/null + bats --print-output-on-failure --filter 'remote unlock:' tests/test_remote.bats + # Continuously verify that the storage contract is backend-portable: run the # SAME driver-agnostic contract suite against the jsonl driver (the sqlite run # is covered by the `bats` job above). This catches silent rot in jsonl as the diff --git a/SKILL.md b/SKILL.md index 5dda123d..35ec1446 100644 --- a/SKILL.md +++ b/SKILL.md @@ -174,9 +174,11 @@ Do NOT manually edit config files. Always use join.sh. If the name was recently Remote setup is no-auth. Do not ask for a token or create one. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/agmsg/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/agmsg/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only + when the user explicitly requests end-to-end encryption. The choice is + fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/agmsg/scripts/remote.sh pull --endpoint ` @@ -189,6 +191,18 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/agmsg/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of + `--identity ` or `--identity-stdin`, and optional + `--confirm-digest `. +2. Run: `bash ~/.agents/skills/agmsg/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine + PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/agmsg/scripts/remote.sh status [] [--json]` diff --git a/docs/remote-setup.md b/docs/remote-setup.md index c16b5267..1bdecb96 100644 --- a/docs/remote-setup.md +++ b/docs/remote-setup.md @@ -3,6 +3,8 @@ This walkthrough connects an existing team on machine A to the reference server, then pulls it into a normal agmsg install on machine B. Your local agents handle the client commands. This setup uses plaintext sync. +For encrypted sync, read +[Extra: end-to-end encryption](#extra-end-to-end-encryption). ## Requirements @@ -82,6 +84,37 @@ The history should contain: : hello from machine A ``` +## Extra: end-to-end encryption + +The remote team's encryption choice is fixed by its first connect and cannot +be changed later. If you need an encrypted team, connect it with `--e2ee`: + +```sh +bash ~/.agents/skills/agmsg/scripts/remote.sh connect \ + --endpoint https:// \ + --e2ee \ + +``` + +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: + +```sh +bash ~/.agents/skills/agmsg/scripts/remote.sh unlock \ + --snapshot \ + --identity \ + --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. + ## Reference ### Install on machine B diff --git a/scripts/drivers/types/antigravity/template.md b/scripts/drivers/types/antigravity/template.md index 2f5e533d..16d825ae 100644 --- a/scripts/drivers/types/antigravity/template.md +++ b/scripts/drivers/types/antigravity/template.md @@ -142,9 +142,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -153,6 +153,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/drivers/types/claude-code/template.md b/scripts/drivers/types/claude-code/template.md index 7c84aceb..5fad2be5 100644 --- a/scripts/drivers/types/claude-code/template.md +++ b/scripts/drivers/types/claude-code/template.md @@ -222,9 +222,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -233,6 +233,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 697218ca..98ac4087 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -170,9 +170,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -181,6 +181,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/drivers/types/copilot/template.md b/scripts/drivers/types/copilot/template.md index 4bd26ec8..2340cbcf 100644 --- a/scripts/drivers/types/copilot/template.md +++ b/scripts/drivers/types/copilot/template.md @@ -142,9 +142,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -153,6 +153,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/drivers/types/cursor/template.md b/scripts/drivers/types/cursor/template.md index 56a9f867..2ed80f07 100644 --- a/scripts/drivers/types/cursor/template.md +++ b/scripts/drivers/types/cursor/template.md @@ -145,9 +145,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -156,6 +156,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/drivers/types/gemini/template.md b/scripts/drivers/types/gemini/template.md index 016cce5e..ddf98775 100644 --- a/scripts/drivers/types/gemini/template.md +++ b/scripts/drivers/types/gemini/template.md @@ -142,9 +142,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -153,6 +153,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/drivers/types/grok-build/template.md b/scripts/drivers/types/grok-build/template.md index 7cffd57c..f5828d3c 100644 --- a/scripts/drivers/types/grok-build/template.md +++ b/scripts/drivers/types/grok-build/template.md @@ -173,9 +173,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -184,6 +184,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/drivers/types/hermes/template.md b/scripts/drivers/types/hermes/template.md index a6e471a5..541fe9db 100644 --- a/scripts/drivers/types/hermes/template.md +++ b/scripts/drivers/types/hermes/template.md @@ -130,9 +130,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -141,6 +141,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/drivers/types/opencode/template.md b/scripts/drivers/types/opencode/template.md index 33ffa3f2..af0eb439 100644 --- a/scripts/drivers/types/opencode/template.md +++ b/scripts/drivers/types/opencode/template.md @@ -145,9 +145,9 @@ If argument is "reset": 2. Tell the user the result. If argument starts with "remote connect": -1. Parse the required `--endpoint ` and ``. -2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint ` -3. Show the output to the user. No token or credential is required. +1. Parse the required `--endpoint ` and ``, plus optional `--e2ee`. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh connect --endpoint [--e2ee] ` +3. Show the output to the user. Plain sync is the default; pass `--e2ee` only when the user explicitly requests end-to-end encryption. The choice is fixed by the first connect. 4. End by showing this copy-paste command for the other machine, with the actual endpoint and team substituted: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint ` If argument starts with "remote pull": @@ -156,6 +156,12 @@ If argument starts with "remote pull": 3. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh pull --endpoint [--team-id ] ` 4. Show the output to the user. +If argument starts with "remote unlock": +1. Parse ``, required `--snapshot `, exactly one of `--identity ` or `--identity-stdin`, and optional `--confirm-digest `. +2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh unlock --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. +4. Show the complete result, including the imported-envelope count and engine PID. + If argument starts with "remote status": 1. Parse an optional `` and `--json`. 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh status [] [--json]` diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 457d0a1c..0dd4eb29 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -31,6 +31,8 @@ function usage() { --age-checkpoint REVISION:SHA256 \\ --age-confirmation operator-live \\ [--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 once --team NAME [--limit N] remote-sync.sh run --team NAME [--limit N] [--interval SECONDS] remote-sync.sh reprocess --team NAME [--limit N] @@ -285,11 +287,19 @@ async function readStoredSyncConfig(team) { export async function loadConfig(team) { const binding = await readConnectedBinding(team); + const selectedCipher = binding.cipher_profile ?? "none"; + if (!["none", "age-v1"].includes(selectedCipher)) { + throw new Error("connected team binding selects an unsupported cipher profile"); + } let value; try { value = await readStoredSyncConfig(team); } catch (error) { if (error?.code !== "ENOENT") throw error; + if (selectedCipher !== "none") { + throw new Error( + `connected team selected ${selectedCipher} but its authenticated sync configuration is missing`); + } if (!binding.capabilities.write_allowed_ciphers.includes("none")) { throw new Error("connected team requires an authenticated age-v1 sync configuration"); } @@ -305,6 +315,10 @@ export async function loadConfig(team) { minimum_security_mode: "plaintext-allowed" }], }; } + value.cipher_profile ??= "none"; + if (binding.cipher_profile !== undefined && value.cipher_profile !== selectedCipher) { + throw new Error("sync configuration cipher does not match the connected team binding"); + } if (value.server_url !== binding.endpoint || value.server_instance_id !== binding.server_instance_id || value.remote_team_id !== binding.remote_team_id || @@ -315,7 +329,6 @@ export async function loadConfig(team) { !UUID_V7.test(value.server_instance_id) || !UUID_V7.test(value.remote_team_id)) { throw new Error("sync config binding is invalid"); } - value.cipher_profile ??= "none"; validateLocalSecurityHistory(value.local_security_history); if (value.cipher_profile === "age-v1") { validateAgeConfiguration(value); @@ -357,7 +370,7 @@ function requireUnicodeScalars(value, label) { } } -function canonicalJson(value) { +export function canonicalJson(value) { if (value === null || typeof value === "boolean") return JSON.stringify(value); if (typeof value === "string") { requireUnicodeScalars(value, "age snapshot string"); @@ -381,6 +394,114 @@ export function ageSnapshotDigest(value) { return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex"); } +export function initialAgeSnapshot(teamConfig, team = teamConfig?.name) { + const binding = connectedBinding(teamConfig, team); + const current = teamConfig?.remote_key?.current; + const epochs = teamConfig?.remote_key?.epochs; + if (!current || !Array.isArray(epochs) || epochs.length !== 1 || + (current !== epochs[0] && canonicalJson(current) !== canonicalJson(epochs[0])) || + current.epoch_revision !== 0 || current.writer_generation !== 0 || + typeof current.key_id !== "string" || + !/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(current.key_id) || + typeof current.recipient !== "string" || + !/^age1[0-9a-z]{58}$/u.test(current.recipient) || + current.previous_snapshot_sha256 !== null) { + throw new Error("team does not have one canonical initial age epoch"); + } + return { + profile: "age-v1", + server_instance_id: binding.server_instance_id, + team_id: binding.remote_team_id, + epoch_revision: "0", + writer_generation: "0", + authorized_writers: [current.key_id], + previous_snapshot_sha256: null, + history: [{ + epoch_revision: "0", + effective_from_seq: "1", + cipher: "age-v1", + key_id: current.key_id, + recipients: [current.recipient], + }], + }; +} + +async function exportAgeSnapshot(args) { + const team = requireName(args.team, "team"); + const bytes = await readBoundedAuthorityFile( + teamConfigPath(team), MAX_CONNECTION_CONFIG_BYTES, false); + const teamConfig = parseStrictJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + const snapshot = initialAgeSnapshot(teamConfig, team); + const canonical = canonicalJson(snapshot); + const outputPath = args.out ? resolve(args.out) : null; + if (outputPath) { + 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); + } else { + process.stdout.write(`${canonical}\n`); + } + process.stderr.write(`Snapshot SHA-256: ${ageSnapshotDigest(snapshot)}\n`); +} + +export async function verifyAgeSnapshot(args) { + const team = requireName(args.team, "team"); + if (!args["age-snapshot"]) throw new Error("age-snapshot is required"); + const snapshotPaths = Array.isArray(args["age-snapshot"]) ? + args["age-snapshot"] : [args["age-snapshot"]]; + if (snapshotPaths.length !== 1 || typeof snapshotPaths[0] !== "string") { + throw new Error("verify-age-snapshot requires exactly one age-snapshot"); + } + const snapshotText = await readFile(resolve(snapshotPaths[0]), "utf8"); + const snapshot = parseStrictJson(snapshotText); + if (snapshotText.trim() !== canonicalJson(snapshot)) { + throw new Error("age snapshot must be RFC 8785 JCS without duplicate or noncanonical fields"); + } + const binding = await readConnectedBinding(team); + const digest = ageSnapshotDigest(snapshot); + 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: [snapshot], + checkpoint: { + epoch_revision: snapshot.epoch_revision, + snapshot_sha256: digest, + writer_generation: snapshot.writer_generation, + confirmed_at: new Date().toISOString(), + }, + identity_files: {}, + age_version: "verification-only", + }, + }; + validateAgeConfiguration(config); + const epoch = snapshot.history.at(-1); + if (epoch.recipients.length !== 1) { + throw new Error("unlock requires an epoch with exactly one handed recipient"); + } + const result = { + type: "age_snapshot_verified", + epoch_revision: snapshot.epoch_revision, + snapshot_sha256: digest, + key_id: epoch.key_id, + recipient: epoch.recipients[0], + }; + process.stdout.write(`${JSON.stringify(result)}\n`); + return result; +} + function ageSnapshotChain(age) { if (Array.isArray(age?.epoch_snapshots)) return age.epoch_snapshots; return age?.epoch_snapshot ? [age.epoch_snapshot] : []; @@ -1852,6 +1973,7 @@ export async function reprocessCycle(config, limit, dependencies = {}) { let after = null; let stableState = null; let total = 0; + let imported = 0; let pageCount = 0n; const retentionFloor = BigInt(capabilities.min_available_seq); // Locally retained quarantine may cover both the server-retained suffix and @@ -1902,6 +2024,11 @@ export async function reprocessCycle(config, limit, dependencies = {}) { records.push({ type: "sync_pull_cursor", next_after: state.transport_cursor }); const applied = await driverCall("apply", config, records); await logApplyCall(config, records, applied); + const candidateIds = new Set(candidates.map((candidate) => candidate.id)); + imported += applied.filter((record) => + record.type === "sync_apply_outcome" && + candidateIds.has(record.id) && + ["imported", "reconciled"].includes(record.status)).length; total += candidates.length; } if (!page.has_more) break; @@ -1910,8 +2037,16 @@ export async function reprocessCycle(config, limit, dependencies = {}) { } after = page.next_after; } - await eventCall("reprocess.complete", { count: total, - transport_cursor: stableState.transport_cursor }); + const remaining = validateReprocessDriverPage( + await driverCall("reprocess", config, [], ["1"]), 1, null); + const result = { + count: total, + imported_count: imported, + blocking_remaining: remaining.candidates.length > 0, + transport_cursor: stableState.transport_cursor, + }; + await eventCall("reprocess.complete", result); + return result; } function resultFromResyncAudit(status) { @@ -2024,11 +2159,13 @@ export async function pullBootstrap(args, dependencies = {}) { let cursor = String(teamSnapshot.min_available_seq ?? "0"); let imported = 0; + let ageV1Envelopes = 0; for (;;) { const page = await requestPublicCall(config, `/v1/teams/${teamId}/messages?after=${cursor}&limit=${limit}`); const records = []; for (const message of page.messages) { + if (message.envelope?.cipher === "age-v1") ageV1Envelopes += 1; records.push({ type: "sync_pull_message", ...message, ...(await evaluateCall(config, teamSnapshot, message)) }); } @@ -2052,7 +2189,7 @@ export async function pullBootstrap(args, dependencies = {}) { cursor = page.next_after; if (!page.has_more) break; } - process.stdout.write(`${JSON.stringify({ + const result = { type: "pull_bootstrap_result", team: config.local_team, team_id: config.remote_team_id, team_name: teamSnapshot.team_name, server_instance_id: config.server_instance_id, @@ -2063,7 +2200,10 @@ export async function pullBootstrap(args, dependencies = {}) { protocol_version: config.protocol_version, capabilities: teamSnapshot, imported, - })}\n`); + age_v1_envelopes: ageV1Envelopes, + }; + process.stdout.write(`${JSON.stringify(result)}\n`); + return result; } // Resolve a team name to the teams carrying it. Like publicSnapshot this runs @@ -2142,11 +2282,13 @@ async function publicSnapshot(serverUrl, teamId) { async function main() { const [command, ...rest] = process.argv.slice(2); const args = options(rest); - if (!["configure", "once", "run", "reprocess", "resync", "unblock-read", - "pull-bootstrap", "resolve-team"].includes(command)) { + if (!["configure", "export-age-snapshot", "verify-age-snapshot", "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; } // 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 379ef991..17a3658f 100755 --- a/scripts/key.sh +++ b/scripts/key.sh @@ -4,6 +4,7 @@ set -euo pipefail # Usage: # key.sh generate [] # key.sh show [] [--key-id ] [--reveal-secret] +# key.sh show [] --snapshot [--out ] # key.sh import [] [--identity-stdin] # key.sh rotate [] # @@ -155,7 +156,6 @@ _key_write_identity_atomic() { cmd_generate() { local team="${1:?Usage: key.sh generate []}" agmsg_validate_team_name "$team" || exit 1 - _key_require_age || exit 1 local cfg cfg="$(_key_team_config "$team")" @@ -163,6 +163,18 @@ cmd_generate() { echo "agmsg: team not found: $team" >&2 exit 1 fi + local connected_at disconnected_at binding_cipher + connected_at="$(_key_read_config_field "$cfg" '$.remote_binding.connected_at')" + disconnected_at="$(_key_read_config_field "$cfg" '$.remote_binding.disconnected_at')" + binding_cipher="$(_key_read_config_field "$cfg" '$.remote_binding.cipher_profile')" + if [ -n "$connected_at" ] && [ "$connected_at" != "null" ] && + [ "$binding_cipher" != "age-v1" ] && + { [ -z "$disconnected_at" ] || [ "$disconnected_at" = "null" ]; }; then + echo "agmsg: team '$team' already has a plaintext remote binding; its encryption choice cannot be changed later." >&2 + echo "Create a new team, generate its key, then connect that new team with --e2ee." >&2 + exit 1 + fi + _key_require_age || exit 1 local cred_dir cred_dir="$(_key_cred_dir "$team")" @@ -216,10 +228,15 @@ cmd_generate() { } cmd_show() { - local team="" requested_key_id="" reveal=0 + local team="" requested_key_id="" reveal=0 snapshot=0 out="" while [ $# -gt 0 ]; do case "$1" in --reveal-secret) reveal=1 ;; + --snapshot) snapshot=1 ;; + --out) + shift + out="${1:?Missing value for --out}" + ;; --key-id) shift requested_key_id="${1:?Missing value for --key-id}" @@ -228,7 +245,7 @@ cmd_show() { esac shift done - : "${team:?Usage: key.sh show [] [--reveal-secret]}" + : "${team:?Usage: key.sh show [] [--reveal-secret] | key.sh show [] --snapshot [--out ]}" agmsg_validate_team_name "$team" || exit 1 local cfg key_id recipient identity_file @@ -238,6 +255,22 @@ cmd_show() { echo "agmsg: team '$team' has no key yet — run 'key.sh generate $team' or 'key.sh import $team'." >&2 exit 1 fi + + if [ "$snapshot" -eq 1 ]; then + if [ "$reveal" -eq 1 ] || [ -n "$requested_key_id" ]; then + echo "agmsg: --snapshot cannot be combined with --reveal-secret or --key-id." >&2 + exit 1 + fi + if [ -n "$out" ]; then + exec bash "$SCRIPT_DIR/remote-sync.sh" export-age-snapshot \ + --team "$team" --out "$out" + fi + exec bash "$SCRIPT_DIR/remote-sync.sh" export-age-snapshot --team "$team" + elif [ -n "$out" ]; then + echo "agmsg: --out requires --snapshot." >&2 + exit 1 + fi + identity_file="$(_key_cred_dir "$team")/$key_id.key" if [ -n "$requested_key_id" ]; then _key_require_age || exit 1 @@ -278,15 +311,24 @@ cmd_show() { } cmd_import() { - local identity_stdin=0 positional=() + local identity_stdin=0 requested_key_id="" positional=() while [ $# -gt 0 ]; do case "$1" in --identity-stdin) identity_stdin=1; shift ;; + --key-id) requested_key_id="${2:?--key-id requires a value}"; shift 2 ;; + --key-id=*) requested_key_id="${1#--key-id=}"; shift ;; *) positional+=("$1"); shift ;; esac done local team="${positional[0]:?Usage: key.sh import [] [--identity-stdin]}" agmsg_validate_team_name "$team" || exit 1 + if [ -n "$requested_key_id" ]; then + printf '%s\n' "$requested_key_id" | + grep -Eq '^[a-z0-9][a-z0-9._-]{0,63}$' || { + echo "agmsg: --key-id is not a valid age key id." >&2 + exit 1 + } + fi local identity if [ "$identity_stdin" -eq 1 ]; then @@ -338,6 +380,11 @@ cmd_import() { cur_recipient="$(_key_read_config_field "$cfg" '$.remote_key.current.recipient')" if [ -n "$cur_key_id" ] && [ "$cur_key_id" != "null" ]; then + if [ -n "$requested_key_id" ] && [ "$requested_key_id" != "$cur_key_id" ]; then + agmsg_lock_release + echo "agmsg: imported authority key id does not match the team's current key." >&2 + exit 1 + fi if [ "$cur_recipient" != "$recipient" ]; then local journal journal_sql fingerprint staged_key_id journal="$(agmsg_roster_journal_path "$TEAMS_DIR/$team")" @@ -382,7 +429,7 @@ cmd_import() { else # No epoch yet for this team: importing establishes the first one. local key_id created_at - key_id="$(_key_new_key_id)" + key_id="${requested_key_id:-$(_key_new_key_id)}" created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" _key_write_identity_atomic "$cred_dir/$key_id.key" "$identity" _key_write_epoch_locked "$cfg" "$(_key_epoch_json "$key_id" 0 0 "$recipient" null "$created_at")" diff --git a/scripts/remote-sync.sh b/scripts/remote-sync.sh index cf607bd2..c7420788 100755 --- a/scripts/remote-sync.sh +++ b/scripts/remote-sync.sh @@ -10,6 +10,7 @@ export SKILL_DIR # shellcheck disable=SC1091 . "$SCRIPT_DIR/lib/node.sh" export AGMSG_SYNC_STORAGE_DIR="$(agmsg_storage_dir)" +export AGMSG_SYNC_TRUST_DIR="${AGMSG_SYNC_TRUST_DIR:-${AGMSG_SYNC_CONNECTION_DIR:-$SKILL_DIR}/run/remote-trust}" export AGMSG_SYNC_DRIVER="$SCRIPT_DIR/internal/storage-sync-driver.sh" NODE_BIN="$(agmsg_resolve_node)" export AGMSG_SYNC_NODE_BIN="$NODE_BIN" diff --git a/scripts/remote.sh b/scripts/remote.sh index 96855cff..cdc6381d 100644 --- a/scripts/remote.sh +++ b/scripts/remote.sh @@ -2,15 +2,18 @@ set -euo pipefail # Usage: -# remote.sh connect --endpoint +# remote.sh connect --endpoint [--e2ee] # remote.sh pull --endpoint [--team-id ] +# remote.sh unlock --snapshot (--identity |--identity-stdin) +# [--confirm-digest ] # remote.sh status [] [--json] # remote.sh disconnect # # Team-scoped cloud/self-hosted sync connection. The OSS CLI never assumes or # defaults to a server, so is always required. `connect` registers a # local team directly; reaching the server is the permission. `pull` clones a -# remote team into an empty local team. Both start the background sync engine. +# remote team into an empty local team. An encrypted pull remains locked until +# `unlock` confirms the handed authority and starts the background sync engine. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -888,7 +891,8 @@ cmd_pull() { # retries reuse it, and the successful projection is never overwritten. _remote_write_pulled_team "$team" "$team_id" || exit 1 - local result pulled_id pulled_name imported pulled_sid pulled_protocol pulled_caps + local result pulled_id pulled_name imported pulled_sid pulled_protocol pulled_caps \ + pulled_age_v1 binding_cipher result="$(AGMSG_SYNC_CONNECTION_DIR="$CONNECTION_ROOT" \ AGMSG_SYNC_LOCAL_ROSTER_FILE="$cfg" \ "$SCRIPT_DIR/remote-sync.sh" pull-bootstrap \ @@ -903,8 +907,13 @@ cmd_pull() { pulled_sid="$(_remote_json_field "$result" '$.server_instance_id')" pulled_protocol="$(_remote_json_field "$result" '$.protocol_version')" pulled_caps="$(_remote_json_field "$result" '$.capabilities')" + pulled_age_v1="$(_remote_json_field "$result" '$.age_v1_envelopes')" [ "$pulled_id" = "$team_id" ] || { echo "agmsg: server answered with a different team id" >&2; exit 1; } + case "$pulled_age_v1" in ''|*[!0-9]*) + echo "agmsg: pull returned an invalid encrypted-envelope count" >&2; exit 1 ;; esac + binding_cipher="none" + [ "$pulled_age_v1" -gt 0 ] && binding_cipher="age-v1" # Bind AFTER the bootstrap, and by updating the config in place: the roster # driver has been projecting identity events into this file while the @@ -925,6 +934,7 @@ cmd_pull() { 'remote_team_id', '$(_agmsg_sqlesc "$pulled_id")', 'remote_team_name', '$(_agmsg_sqlesc "$pulled_name")', 'protocol_version', $pulled_protocol, + 'cipher_profile', '$binding_cipher', 'capabilities', json('$caps_escaped'), 'connected_at', '$bind_at', 'disconnected_at', null));") @@ -936,13 +946,167 @@ cmd_pull() { # without it a send on this machine reports success, stays local, and nothing # says this team has an upstream it never reached. Found by the first real # second machine, whose pulled team answered "connected" while running nothing. - _remote_sync_engine_start "$team" - local cmd_name cmd_name="$(basename "$SKILL_DIR")" - echo "Pulled '$pulled_name' into local team '$team' ($imported message(s)). Sync engine running." + if [ "$pulled_age_v1" -gt 0 ]; then + echo "Pulled '$pulled_name' into local team '$team' ($imported message(s))." + echo "This team is encrypted; its sync engine is halted until the handed key material is imported and confirmed." + else + _remote_sync_engine_start "$team" + 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." + 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." + fi +} + +cmd_unlock() { + local team="" snapshot="" identity_file="" identity_stdin=0 confirm_digest="" + while [ $# -gt 0 ]; do + case "$1" in + --snapshot) snapshot="${2:?--snapshot requires a value}"; shift 2 ;; + --snapshot=*) snapshot="${1#--snapshot=}"; shift ;; + --identity) identity_file="${2:?--identity requires a value}"; shift 2 ;; + --identity=*) identity_file="${1#--identity=}"; shift ;; + --identity-stdin) identity_stdin=1; shift ;; + --confirm-digest) confirm_digest="${2:?--confirm-digest requires a value}"; shift 2 ;; + --confirm-digest=*) confirm_digest="${1#--confirm-digest=}"; shift ;; + --*) echo "agmsg: unknown unlock option: $1" >&2; exit 1 ;; + *) [ -z "$team" ] || { echo "agmsg: unlock accepts one team" >&2; exit 1; } + team="$1"; shift ;; + esac + done + : "${team:?Usage: remote.sh unlock --snapshot (--identity |--identity-stdin) [--confirm-digest ]}" + : "${snapshot:?--snapshot is required}" + 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 + fi + + local cfg binding_cipher metadata digest epoch_revision key_id recipient + cfg="$(_remote_team_config "$team")" + [ -f "$cfg" ] || { echo "agmsg: team not found: $team" >&2; exit 1; } + binding_cipher="$(_remote_read_config_field "$cfg" '$.remote_binding.cipher_profile')" + [ "$binding_cipher" = "age-v1" ] || { + echo "agmsg: team '$team' is not an encrypted pulled team awaiting unlock" >&2 + exit 1 + } + metadata="$(bash "$SCRIPT_DIR/remote-sync.sh" verify-age-snapshot \ + --team "$team" --age-snapshot "$snapshot")" || exit 1 + metadata="$(printf '%s\n' "$metadata" | grep '"age_snapshot_verified"' | tail -1)" + [ -n "$metadata" ] || { echo "agmsg: snapshot verification produced no result" >&2; exit 1; } + digest="$(_remote_json_field "$metadata" '$.snapshot_sha256')" + epoch_revision="$(_remote_json_field "$metadata" '$.epoch_revision')" + key_id="$(_remote_json_field "$metadata" '$.key_id')" + recipient="$(_remote_json_field "$metadata" '$.recipient')" + echo "Snapshot SHA-256: $digest" + echo "Snapshot key_id: $key_id" + + 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 + exit 1 + fi + _remote_prompt_read confirm_digest \ + "Type the snapshot SHA-256 you verified over a separate live channel: " || exit 1 + fi + if [ "$confirm_digest" != "$digest" ]; then + echo "agmsg: confirmed snapshot digest does not match; refusing to import trust or key material" >&2 + 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" + 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 + # 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')" + remote_team_id="$(_remote_read_config_field "$cfg" '$.remote_binding.remote_team_id')" + configure_out="$(bash "$SCRIPT_DIR/remote-sync.sh" configure \ + --team "$team" \ + --server "$endpoint" \ + --team-id "$remote_team_id" \ + --minimum-security e2ee-required \ + --cipher age-v1 \ + --age-snapshot "$snapshot" \ + --age-checkpoint "$epoch_revision:$digest" \ + --age-confirmation operator-live \ + --age-identity "$key_id=$identity_dest")" || 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" | + grep '"event":"reprocess.complete"' | tail -1)" + [ -n "$reprocess_result" ] || { + echo "agmsg: reprocess produced no completion result" >&2 + exit 1 + } + imported="$(_remote_json_field "$reprocess_result" '$.imported_count')" + blocking="$(_remote_json_field "$reprocess_result" '$.blocking_remaining')" + if [ "$blocking" != "0" ]; then + echo "agmsg: encrypted envelopes remain blocked after reprocessing; no sync engine was started" >&2 + exit 1 + fi + + _remote_sync_engine_stop "$team" || { + echo "agmsg: the previous sync engine did not stop; unlock cannot safely restart it" >&2 + exit 1 + } + local engine_log="$CONNECTION_ROOT/run/remote-sync.$team.log" log_offset=1 + [ -f "$engine_log" ] && + log_offset=$(( $(wc -c < "$engine_log" | tr -d ' ') + 1 )) + _remote_sync_engine_start "$team" + local pid="${REMOTE_SYNC_ENGINE_PID:-}" ready=0 attempts=0 + while [ "$attempts" -lt 50 ]; do + if [ -z "$pid" ] || ! _agmsg_pid_alive "$pid"; then + break + fi + if tail -c "+$log_offset" "$engine_log" 2>/dev/null | + grep -q '"event":"capabilities"'; then + ready=1 + break + fi + attempts=$((attempts + 1)) + sleep 0.1 + done + local pidfile="$(_remote_sync_engine_pidfile "$team")" recorded_pid="" + [ -f "$pidfile" ] && recorded_pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ "$ready" -ne 1 ] || [ "$recorded_pid" != "$pid" ] || + ! _agmsg_pid_alive "$pid"; then + _remote_sync_engine_stop "$team" + echo "agmsg: encrypted sync was configured, but the sync engine did not become ready" >&2 + exit 1 + fi + echo "Unlocked '$team': imported $imported envelope(s); engine running (pid $pid)." 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." } # The remote-sync engine runs as a background daemon: one per connected team, @@ -973,16 +1137,27 @@ _remote_sync_engine_start() { # the last-ok-then-orphan hang this repo has met before, this time spawned by # production code rather than a test. nohup bash "$SCRIPT_DIR/remote-sync.sh" run --team "$team" >> "$logfile" 2>&1 3>&- 4>&- & - echo $! > "$pidfile" + REMOTE_SYNC_ENGINE_PID=$! + echo "$REMOTE_SYNC_ENGINE_PID" > "$pidfile" disown 2>/dev/null || true } _remote_sync_engine_stop() { - local team="$1" pidfile pid + local team="$1" pidfile pid attempts=0 pidfile="$(_remote_sync_engine_pidfile "$team")" [ -f "$pidfile" ] || return 0 pid="$(cat "$pidfile" 2>/dev/null || true)" - [ -n "$pid" ] && _agmsg_pid_alive "$pid" && kill "$pid" 2>/dev/null || true + if [ -n "$pid" ] && _agmsg_pid_alive "$pid"; then + kill "$pid" 2>/dev/null || true + while _agmsg_pid_alive "$pid" && [ "$attempts" -lt 50 ]; do + attempts=$((attempts + 1)) + sleep 0.1 + done + if _agmsg_pid_alive "$pid"; then + echo "agmsg: sync engine pid $pid did not stop" >&2 + return 1 + fi + fi rm -f "$pidfile" } @@ -1011,27 +1186,86 @@ EOF_MINT_NAMES agmsg_lock_release } +_remote_binding_allows_cipher() { + local cfg="$1" cipher="$2" cfg_escaped + cfg_escaped="$(sed "s/'/''/g" "$cfg")" + [ "$(agmsg_sqlite_mem \ + "SELECT EXISTS( + SELECT 1 + FROM json_each(json_extract('$cfg_escaped', + '\$.remote_binding.capabilities.write_allowed_ciphers')) + WHERE value = '$(_agmsg_sqlesc "$cipher")' + );")" = "1" ] +} + +_remote_configure_keyed_team() { + local team="$1" cfg="$2" key_id endpoint remote_team_id \ + identity_file snapshot_file snapshot_sha + key_id="$(_remote_read_config_field "$cfg" '$.remote_key.current.key_id')" + if [ -z "$key_id" ] || [ "$key_id" = "null" ]; then + return 0 + fi + + if ! _remote_binding_allows_cipher "$cfg" age-v1; then + echo "agmsg: team '$team' has an encryption key, but this remote does not allow age-v1; refusing to fall back to plaintext." >&2 + return 1 + fi + + endpoint="$(_remote_read_config_field "$cfg" '$.remote_binding.endpoint')" + remote_team_id="$(_remote_read_config_field "$cfg" '$.remote_binding.remote_team_id')" + identity_file="$CONNECTION_ROOT/run/remote-credentials/$team/keys/$key_id.key" + if [ ! -f "$identity_file" ]; then + echo "agmsg: team '$team' has key_id=$key_id but its local identity file is missing; refusing to start plaintext sync." >&2 + return 1 + fi + + snapshot_file="$(mktemp "${TMPDIR:-/tmp}/agmsg-age-snapshot.XXXXXX")" + if ! bash "$SCRIPT_DIR/remote-sync.sh" export-age-snapshot \ + --team "$team" --out "$snapshot_file"; then + rm -f "$snapshot_file" + echo "agmsg: could not export the initial age-v1 snapshot for team '$team'; sync was not started." >&2 + return 1 + fi + snapshot_sha="$(shasum -a 256 "$snapshot_file" | awk '{print $1}')" + if ! bash "$SCRIPT_DIR/remote-sync.sh" configure \ + --team "$team" \ + --server "$endpoint" \ + --team-id "$remote_team_id" \ + --minimum-security e2ee-required \ + --cipher age-v1 \ + --age-snapshot "$snapshot_file" \ + --age-checkpoint "0:$snapshot_sha" \ + --age-confirmation operator-live \ + --age-identity "$key_id=$identity_file"; then + rm -f "$snapshot_file" + echo "agmsg: age-v1 setup failed for team '$team'; refusing to start plaintext sync." >&2 + return 1 + fi + rm -f "$snapshot_file" +} + cmd_connect() { # Register a team you already own with a remote, then move it to its own # store and start syncing. No token, no credential: reaching the server is # the permission (docs/design/remote-sync.md). The team_id and every # member_id were minted locally at team creation; the server records what it # is sent and never originates a team. - local endpoint="" team="" positional=() + local endpoint="" team="" e2ee=0 positional=() while [ $# -gt 0 ]; do case "$1" in --endpoint) endpoint="${2:?--endpoint requires a value}"; shift 2 ;; --endpoint=*) endpoint="${1#--endpoint=}"; shift ;; + --e2ee) e2ee=1; shift ;; *) positional+=("$1"); shift ;; esac done - : "${endpoint:?Usage: remote.sh connect --endpoint }" + : "${endpoint:?Usage: remote.sh connect --endpoint [--e2ee] }" _remote_validate_endpoint "$endpoint" || exit 1 endpoint="${endpoint%/}" team="${positional[0]:-}" - [ -n "$team" ] || { echo "agmsg: connect requires a team: remote.sh connect --endpoint " >&2; exit 1; } + [ -n "$team" ] || { echo "agmsg: connect requires a team: remote.sh connect --endpoint [--e2ee] " >&2; exit 1; } - local cfg team_id team_name + local cfg team_id team_name key_id binding_cipher cfg="$(_remote_team_config "$team")" [ -f "$cfg" ] || { echo "agmsg: team '$team' is not a local team" >&2; exit 1; } team_id="$(_remote_read_config_field "$cfg" '$.team_id')" @@ -1045,6 +1279,18 @@ cmd_connect() { team_id="$(_remote_read_config_field "$cfg" '$.team_id')" ;; esac + key_id="$(_remote_read_config_field "$cfg" '$.remote_key.current.key_id')" + if [ "$e2ee" -eq 1 ] && { [ -z "$key_id" ] || [ "$key_id" = "null" ]; }; then + bash "$SCRIPT_DIR/key.sh" generate "$team" || exit 1 + key_id="$(_remote_read_config_field "$cfg" '$.remote_key.current.key_id')" + elif [ "$e2ee" -eq 0 ] && [ -n "$key_id" ] && [ "$key_id" != "null" ]; then + echo "Note: this team has a key, but plain sync was selected. The key will not be used; pass --e2ee to seal remote messages." >&2 + fi + if [ "$e2ee" -eq 1 ]; then + binding_cipher="age-v1" + else + binding_cipher="none" + fi # The body: the team's id and name, plus the roster from .agents — each # agent's key is its name and its minted member_id comes with it. @@ -1111,12 +1357,28 @@ cmd_connect() { 'remote_team_id', '$(_agmsg_sqlesc "$remote_team_id")', 'remote_team_name', '$(_agmsg_sqlesc "$remote_team_name")', 'protocol_version', $protocol_version, + 'cipher_profile', '$binding_cipher', 'capabilities', json('$resp_escaped'), 'connected_at', '$(_agmsg_sqlesc "$connected_at")', 'disconnected_at', null ));") agmsg_write_atomic "$cfg" "$updated" + if [ "$e2ee" -eq 0 ] && ! _remote_binding_allows_cipher "$cfg" none; then + echo "agmsg: this remote does not allow $binding_cipher; no sync engine was started." >&2 + exit 1 + fi + + if [ "$e2ee" -eq 1 ]; then + # The explicit switch, not key presence, selects E2EE. Configure before + # migration or engine startup so a capability/trust failure cannot fall + # back to plaintext. + if ! _remote_configure_keyed_team "$team" "$cfg"; then + echo "agmsg: the remote binding was recorded, but no sync engine was started." >&2 + exit 1 + fi + fi + # Move the team out of the shared store into its own before the engine runs: # a connected team's rows carry ids, and one column cannot hold both those and # a local team's names. The copy is verified and the shared rows are dropped @@ -1132,13 +1394,19 @@ cmd_connect() { # they are written. Stop it with 'remote.sh disconnect '. _remote_sync_engine_start "$team" - echo "Connected: team '$team'${remote_team_name:+ (org '$remote_team_name')}. Sync engine running." + local connection_security="plain" + [ "$binding_cipher" = "age-v1" ] && connection_security="age-v1 encrypted" + echo "Connected: team '$team'${remote_team_name:+ (org '$remote_team_name')} ($connection_security). Sync engine running." + if [ "$e2ee" -eq 1 ]; then + echo "Export the public epoch snapshot with: key.sh show $team --snapshot --out " + echo "Transfer that snapshot and the key out of band; the other machine must import and live-confirm them before syncing." + fi } # --- status -------------------------------------------------------------- _remote_status_one() { - local team="$1" cfg connected_at disconnected_at write_allowed_ciphers key_id + local team="$1" cfg connected_at disconnected_at write_allowed_ciphers key_id binding_cipher cfg="$(_remote_team_config "$team")" connected_at="$(_remote_read_config_field "$cfg" '$.remote_binding.connected_at')" disconnected_at="$(_remote_read_config_field "$cfg" '$.remote_binding.disconnected_at')" @@ -1152,20 +1420,19 @@ _remote_status_one() { write_allowed_ciphers="$(_remote_read_config_field "$cfg" '$.remote_binding.capabilities.write_allowed_ciphers')" key_id="$(_remote_read_config_field "$cfg" '$.remote_key.current.key_id')" - - local needs_encryption=0 - case "$write_allowed_ciphers" in - *none*) needs_encryption=0 ;; - '['*']') [ "$write_allowed_ciphers" != "[]" ] && needs_encryption=1 ;; - esac + binding_cipher="$(_remote_read_config_field "$cfg" '$.remote_binding.cipher_profile')" echo "$team connected (since $connected_at)" - if [ "$needs_encryption" -eq 1 ]; then - if [ -z "$key_id" ] || [ "$key_id" = "null" ]; then - echo " encryption: required, no local key — run 'key.sh generate $team' or 'key.sh import $team'" - else + if [ "$binding_cipher" = "age-v1" ]; then + if [ -n "$key_id" ] && [ "$key_id" != "null" ]; then echo " encryption: age-v1, key present" + else + echo " encryption: age-v1, local key missing" fi + elif [ -n "$key_id" ] && [ "$key_id" != "null" ]; then + echo " encryption: none (local key is not used by this binding)" + elif [[ "$write_allowed_ciphers" != *none* ]]; then + echo " encryption: required, no local key" else echo " encryption: none" fi @@ -1347,11 +1614,12 @@ cmd_disconnect() { case "${1:-}" in connect) shift; agmsg_require_python3 "remote connect" || exit 1; cmd_connect "$@" ;; pull) shift; agmsg_require_python3 "remote pull" || exit 1; cmd_pull "$@" ;; + unlock) shift; agmsg_require_python3 "remote unlock" || exit 1; cmd_unlock "$@" ;; status) shift; agmsg_require_python3 "remote status" || exit 1; cmd_status "$@" ;; disconnect) shift; agmsg_require_python3 "remote disconnect" || exit 1; cmd_disconnect "$@" ;; doctor) shift; cmd_doctor "$@" ;; pending) shift; agmsg_require_python3 "remote pending" || exit 1; cmd_pending "$@" ;; *) - echo "Usage: remote.sh ..." >&2 + echo "Usage: remote.sh ..." >&2 exit 1 ;; esac diff --git a/tests/helpers/mock_remote_server.py b/tests/helpers/mock_remote_server.py index 1447b30f..64c4d6df 100644 --- a/tests/helpers/mock_remote_server.py +++ b/tests/helpers/mock_remote_server.py @@ -34,6 +34,10 @@ REVOKE_BAD_BODY = os.environ.get("MOCK_REVOKE_BAD_BODY") == "1" REVOKE_LARGE_BODY = os.environ.get("MOCK_REVOKE_LARGE_BODY") == "1" PULL_MIXED = os.environ.get("MOCK_PULL_MIXED") == "1" +PULL_AGE = os.environ.get("MOCK_PULL_AGE") == "1" +PULL_AGE_ENVELOPE_FILE = os.environ.get("MOCK_PULL_AGE_ENVELOPE_FILE", "") +CONNECT_CIPHERS = (["none"] if os.environ.get("MOCK_CONNECT_NO_AGE") == "1" + else ["none", "age-v1"]) ISSUED_CREDENTIAL_IDS = set() REVOKED_CREDENTIAL_IDS = [] @@ -44,13 +48,14 @@ REGISTERED_TEAM_IDS = set() -PULL_SERVER_ID = "018f3f7e-2222-7000-8000-000000000001" -PULL_TEAM_ID = "018f3f7e-2222-7000-8000-000000000002" +PULL_SERVER_ID = CONNECT_SERVER_ID +PULL_TEAM_ID = ( + os.environ.get("MOCK_PULL_TEAM_ID") + or "018f3f7e-2222-7000-8000-000000000002" +) PULL_MEMBERS = [ - {"member_id": "018f3f7e-2222-7000-8000-000000000010", - "name": "alice", "registrations": []}, - {"member_id": "018f3f7e-2222-7000-8000-000000000011", - "name": "bob", "registrations": []}, + {"member_id": "018f3f7e-4444-7000-8000-000000000001", + "name": "member-1", "registrations": []}, ] # cipher "none" carries the message as the base64 of its canonical JSON, which # is what the client decodes on import. @@ -83,7 +88,25 @@ def _roster_blob(index): "blob": _blob("bob", "alice", "history two", "2026-01-02T00:00:00.000000Z")}}, ] -if PULL_MIXED: +if PULL_AGE: + age_envelope = ( + json.load(open(PULL_AGE_ENVELOPE_FILE, encoding="utf-8")) + if PULL_AGE_ENVELOPE_FILE else + {"v": 1, "cipher": "age-v1", "key_id": "epoch-0", + "blob": "ZW5jcnlwdGVk"} + ) + PULL_MESSAGES = [ + {"id": "10000000-0000-4000-8000-000000000001", + "server_seq": "1", + "server_received_at": "2026-01-01T00:00:00.000000Z", + "envelope": {"v": 1, "cipher": "none", "key_id": None, + "blob": _roster_blob(0)}}, + {"id": "20000000-0000-4000-8000-000000000001", + "server_seq": "2", + "server_received_at": "2026-01-02T00:00:00.000000Z", + "envelope": age_envelope}, + ] +elif PULL_MIXED: PULL_MESSAGES = [ {"id": "10000000-0000-4000-8000-%012d" % (index + 1), "server_seq": str(index + 1), @@ -103,6 +126,7 @@ def _roster_blob(index): ] else: PULL_MESSAGES = BASE_PULL_MESSAGES +PUSHED_MESSAGES = [] class LoopbackHTTPServer(HTTPServer): @@ -135,15 +159,75 @@ def _send_raw(self, code, text, protocol="1", oversized_header=False): self.wfile.write(body) def do_GET(self): + if self.path == "/v1/health": + self._send_json(200, { + "status": "ok", + "database": "ok", + "server_instance_id": CONNECT_SERVER_ID, + }) + return + if self.path == "/v1/capabilities": + team_id = self.headers.get("Agmsg-Team-ID", "") + current_seq = (len(PULL_MESSAGES) + len(PUSHED_MESSAGES) + if team_id == PULL_TEAM_ID else 0) + self._send_json(200, { + "protocol_version": 1, + "server_instance_id": CONNECT_SERVER_ID, + "team_id": team_id, + "current_seq": str(current_seq), + "next_sequence_boundary": str(current_seq + 1), + "min_available_seq": "0", + "accepted_envelope_versions": [1], + "write_allowed_ciphers": CONNECT_CIPHERS, + "policy_revision": "0", + "effective_from_seq": "1", + "max_blob_bytes": "1048576", + "policy_history": [{ + "policy_revision": "0", + "effective_from_seq": "1", + "accepted_envelope_versions": [1], + "write_allowed_ciphers": CONNECT_CIPHERS, + }], + }) + return if self.path == "/_test/revoked": self._send_json(200, {"revoked": REVOKED_CREDENTIAL_IDS}) return - # The pull side: a machine that has none of this asking for a team by - # id. No credential, matching /v1/connect -- reaching the server is the - # permission. + if self.path == "/_test/pushed": + self._send_json(200, {"messages": PUSHED_MESSAGES}) + return parts = self.path.split("?", 1) route = parts[0] query = parts[1] if len(parts) > 1 else "" + if route == "/v1/members": + self._send_json(200, { + "protocol_version": 1, + "server_instance_id": PULL_SERVER_ID, + "team_id": PULL_TEAM_ID, + "min_available_seq": "0", + "members_revision": "0", + "members": PULL_MEMBERS, + }) + return + if route == "/v1/messages": + after = 0 + for pair in query.split("&"): + if pair.startswith("after="): + after = int(pair[len("after="):]) + page = [m for m in PULL_MESSAGES + PUSHED_MESSAGES + if int(m["server_seq"]) > after] + self._send_json(200, { + "protocol_version": 1, + "server_instance_id": PULL_SERVER_ID, + "team_id": PULL_TEAM_ID, + "messages": page, + "next_after": page[-1]["server_seq"] if page else str(after), + "has_more": False, + }) + return + # The pull side: a machine that has none of this asking for a team by + # id. No credential, matching /v1/connect -- reaching the server is the + # permission. if route == "/v1/teams": # MOCK_DUPLICATE_NAME makes the lookup answer with two teams sharing # the requested name, which is the branch the client cannot resolve @@ -230,10 +314,10 @@ def do_GET(self): "team_id": PULL_TEAM_ID, "team_name": "pulled-team", "min_available_seq": "0", - "current_seq": str(len(PULL_MESSAGES)), + "current_seq": str(len(PULL_MESSAGES) + len(PUSHED_MESSAGES)), "policy_revision": "0", "accepted_envelope_versions": [1], - "write_allowed_ciphers": ["none", "age-v1"], + "write_allowed_ciphers": CONNECT_CIPHERS, "policy_history": [{ "policy_revision": "0", "effective_from_seq": "1", "accepted_envelope_versions": [1], @@ -269,6 +353,47 @@ def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"" + if self.path == "/v1/messages": + try: + messages = json.loads(raw).get("messages", []) + except Exception: + self._send_json(400, {"error": "bad json"}) + return + acks = [] + for message in messages: + stored = { + "id": message.get("id"), + "server_seq": str(len(PULL_MESSAGES) + len(PUSHED_MESSAGES) + 1), + "server_received_at": "2026-01-03T00:00:00.000000Z", + "envelope": message.get("envelope"), + } + PUSHED_MESSAGES.append(stored) + acks.append({ + "id": message.get("id"), + "server_seq": stored["server_seq"], + "disposition": "stored", + }) + self._send_json(200, {"acks": acks}) + return + + if self.path == "/v1/read-state/sync": + current = str(len(PULL_MESSAGES) + len(PUSHED_MESSAGES)) + self._send_json(200, { + "protocol_version": 1, + "server_instance_id": PULL_SERVER_ID, + "team_id": PULL_TEAM_ID, + "min_available_seq": "0", + "current_seq": current, + "items": [ + {"kind": "frontier", "member_id": member["member_id"], + "server_seq": "0"} + for member in PULL_MEMBERS + ], + "next_page_after": None, + "has_more": False, + }) + return + if self.path == "/v1/connect": try: data = json.loads(raw) if raw else {} @@ -298,7 +423,7 @@ def do_POST(self): "policy_history": [{"policy_revision": "0", "effective_from_seq": "1", "accepted_envelope_versions": [1], - "write_allowed_ciphers": ["none", "age-v1"]}], + "write_allowed_ciphers": CONNECT_CIPHERS}], }) return diff --git a/tests/remote_sync_engine.test.mjs b/tests/remote_sync_engine.test.mjs index 835d01f5..a1f05a88 100644 --- a/tests/remote_sync_engine.test.mjs +++ b/tests/remote_sync_engine.test.mjs @@ -8,11 +8,13 @@ import test from "node:test"; import { ageSnapshotDigest, activateKeyRotations, + canonicalJson, consistentReadStateContext, configure, cycle, driver, isRetryable, + initialAgeSnapshot, runLoop, loadConfig, plaintextWriteEligible, @@ -37,6 +39,7 @@ import { validateReadStatePage, validateResyncResult, validateResyncStatus, + verifyAgeSnapshot, } from "../scripts/internal/remote-sync.mjs"; const config = { @@ -301,6 +304,82 @@ test("connected binding is a bounded non-writable nofollow authority", async () }); }); +test("an age-selected binding never synthesizes a plaintext config", async () => { + await withConnectedCredential(async (root) => { + const previousStorage = process.env.AGMSG_SYNC_STORAGE_DIR; + process.env.AGMSG_SYNC_STORAGE_DIR = join(root, "db"); + await writeConnectedTeam(root, { cipher_profile: "age-v1" }); + try { + await assert.rejects(loadConfig("demo"), + /selected age-v1.*authenticated sync configuration is missing/u); + await mkdir(join(root, "db", "remote-sync"), { recursive: true }); + await writeFile(join(root, "db", "remote-sync", "demo.json"), + `${JSON.stringify({ + format_version: 1, + local_team: "demo", + server_url: "https://sync.example", + server_instance_id: config.server_instance_id, + remote_team_id: config.remote_team_id, + protocol_version: 1, + cipher_profile: "none", + local_security_history: [{ + local_security_revision: "0", + effective_from_seq: "1", + minimum_security_mode: "plaintext-allowed", + }], + })}\n`, + { mode: 0o600 }); + await assert.rejects(loadConfig("demo"), + /sync configuration cipher does not match.*binding/u); + } finally { + if (previousStorage === undefined) delete process.env.AGMSG_SYNC_STORAGE_DIR; + else process.env.AGMSG_SYNC_STORAGE_DIR = previousStorage; + } + }); +}); + +test("unlock snapshot verification is canonical and bound to the pulled team", async () => { + await withConnectedCredential(async (root) => { + await writeConnectedTeam(root, { cipher_profile: "age-v1" }); + const snapshot = { + profile: "age-v1", + server_instance_id: config.server_instance_id, + team_id: config.remote_team_id, + epoch_revision: "0", + writer_generation: "0", + authorized_writers: ["epoch-initial"], + previous_snapshot_sha256: null, + history: [{ + epoch_revision: "0", + effective_from_seq: "1", + cipher: "age-v1", + key_id: "epoch-initial", + recipients: ["age1mmqjrejftea4f6xh47lhpc0jn4vw0yuhz349sw2e3sfl22k5gcjsv6xcvp"], + }], + }; + const path = join(root, "snapshot.json"); + await writeFile(path, canonicalJson(snapshot), { mode: 0o600 }); + const result = await verifyAgeSnapshot({ + team: "demo", + // CLI option parsing represents repeatable options as arrays even when + // exactly one value was supplied. + "age-snapshot": [path], + }); + assert.equal(result.snapshot_sha256, ageSnapshotDigest(snapshot)); + assert.equal(result.key_id, "epoch-initial"); + assert.equal(result.recipient, snapshot.history[0].recipients[0]); + await writeFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }); + await assert.rejects(verifyAgeSnapshot({ + team: "demo", + "age-snapshot": [path], + }), /RFC 8785 JCS/u); + await assert.rejects(verifyAgeSnapshot({ + team: "demo", + "age-snapshot": [path, path], + }), /exactly one age-snapshot/u); + }); +}); + test("ack mapping rejects reversed and duplicate server sequences", () => { assert.throws(() => validateAckMapping(candidates, [ { id: candidates[0].id, server_seq: "2", disposition: "stored" }, @@ -331,9 +410,17 @@ test("explicit reprocess walks past a permanent first keyset page", async () => const applied = []; const driverCall = async (operation, _config, input, extra) => { if (operation === "apply") { - applied.push(...input.filter((record) => record.type === "sync_pull_message") - .map((record) => record.id)); - return [{ type: "sync_apply_result", transport_cursor: "2", corrupt_count: 0 }]; + const messages = input.filter((record) => record.type === "sync_pull_message"); + applied.push(...messages.map((record) => record.id)); + return [ + { type: "sync_apply_result", transport_cursor: "2", corrupt_count: 0 }, + ...messages.map((record) => ({ + type: "sync_apply_outcome", + id: record.id, + server_seq: record.server_seq, + status: "authentication_failed", + })), + ]; } assert.equal(operation, "reprocess"); const pageIndex = extra.length === 1 ? 0 : 1; @@ -350,7 +437,7 @@ test("explicit reprocess walks past a permanent first keyset page", async () => has_more: pageIndex === 0 }, ]; }; - await reprocessCycle(config, 1, { + const result = await reprocessCycle(config, 1, { healthCall: async () => ({ server_instance_id: config.server_instance_id }), requestCall: async () => capabilities, driverCall, @@ -360,6 +447,55 @@ test("explicit reprocess walks past a permanent first keyset page", async () => logApplyCall: async () => {}, }); assert.deepEqual(applied, ids); + assert.equal(result.imported_count, 0); + assert.equal(result.blocking_remaining, true); +}); + +test("reprocess completion counts imported outcomes and requires empty blocking quarantine", async () => { + const capabilities = { + protocol_version: 1, server_instance_id: config.server_instance_id, + team_id: config.remote_team_id, team_name: "demo", min_available_seq: "0", + current_seq: "1", next_sequence_boundary: "2", accepted_envelope_versions: [1], + write_allowed_ciphers: ["none"], policy_revision: "0", effective_from_seq: "1", + max_blob_bytes: "1048576", policy_history: [{ policy_revision: "0", + effective_from_seq: "1", accepted_envelope_versions: [1], + write_allowed_ciphers: ["none"] }], + }; + const id = "550e8400-e29b-41d4-a716-446655440001"; + let imported = false; + const result = await reprocessCycle(config, 100, { + healthCall: async () => ({ server_instance_id: config.server_instance_id }), + requestCall: async () => capabilities, + driverCall: async (operation, _config, input) => { + if (operation === "apply") { + imported = true; + return [ + { type: "sync_apply_result", transport_cursor: "1", corrupt_count: 0 }, + { type: "sync_apply_outcome", id, server_seq: "1", status: "imported" }, + ]; + } + assert.equal(operation, "reprocess"); + return [ + { type: "sync_state", driver_generation: "generation-1", transport_cursor: "1" }, + ...(!imported ? [{ + type: "sync_reprocess_candidate", server_seq: "1", id, + server_received_at: "2026-07-22T11:00:00.000000Z", + envelope: { v: 1, cipher: "none", key_id: null, blob: "e30=" }, + prior_status: "authentication_failed", + }] : []), + { type: "sync_reprocess_page", next_after: null, has_more: false }, + ]; + }, + evaluateCall: async () => ({ status: "importable", projection: { + body: "recovered", created_at: "2026-07-22T11:00:00.000000Z", + from_agent: "alice", to_agent: "bob", + }, policy_revision: "0", local_security_revision: "0" }), + eventCall: async () => {}, + logApplyCall: async () => {}, + }); + assert.equal(result.count, 1); + assert.equal(result.imported_count, 1); + assert.equal(result.blocking_remaining, false); }); test("explicit reprocess rejects an unbounded walk through duplicate server sequences", async () => { @@ -1133,6 +1269,60 @@ test("age-v1 configuration verifies the complete epoch snapshot hash chain", () } }), /missing revision/u); }); +test("initial age snapshot uses the key id as its sole writer and stable JCS", () => { + const keyId = "epoch-initial"; + const recipient = "age1mmqjrejftea4f6xh47lhpc0jn4vw0yuhz349sw2e3sfl22k5gcjsv6xcvp"; + const epoch = { + key_id: keyId, + epoch_revision: 0, + writer_generation: 0, + recipient, + previous_snapshot_sha256: null, + created_at: "2026-07-30T00:00:00Z", + }; + const teamConfig = { + name: "demo", + agents: {}, + remote_key: { current: epoch, epochs: [epoch] }, + remote_binding: { + endpoint: "https://sync.example.test", + server_instance_id: config.server_instance_id, + remote_team_id: config.remote_team_id, + remote_team_name: "demo", + protocol_version: 1, + capabilities: { write_allowed_ciphers: ["none", "age-v1"] }, + connected_at: "2026-07-30T00:00:00Z", + disconnected_at: null, + }, + }; + const first = initialAgeSnapshot(teamConfig); + const second = initialAgeSnapshot(JSON.parse(JSON.stringify(teamConfig))); + assert.deepEqual(first.authorized_writers, [keyId]); + assert.equal(canonicalJson(first), canonicalJson(second)); + assert.equal(ageSnapshotDigest(first), ageSnapshotDigest(second)); + assert.match(ageSnapshotDigest(first), /^[0-9a-f]{64}$/u); + assert.doesNotThrow(() => validateAgeConfiguration({ + ...config, + cipher_profile: "age-v1", + local_security_history: [{ + local_security_revision: "0", + effective_from_seq: "1", + minimum_security_mode: "e2ee-required", + }], + age_v1: { + epoch_snapshot: first, + checkpoint: { + epoch_revision: "0", + writer_generation: "0", + snapshot_sha256: ageSnapshotDigest(first), + confirmed_at: "2026-07-30T00:00:00Z", + }, + identity_files: {}, + age_version: "v1.3.1", + }, + })); +}); + test("retained age checkpoint survives sync config reset and rejects same-revision conflict", async () => { const root = await mkdtemp(join(tmpdir(), "agmsg-age-trust-")); const previousTrust = process.env.AGMSG_SYNC_TRUST_DIR; @@ -1581,9 +1771,12 @@ test("pull bootstrap dispatches a real mixed roster and message page", async () to_agent: "member-2", }, })); + messages[0].envelope = { + v: 1, cipher: "age-v1", key_id: "epoch-0", blob: "encrypted", + }; const storageInputs = []; const rosterInputs = []; - await pullBootstrap({ + const result = await pullBootstrap({ team: "clone", "team-id": teamId, endpoint: "http://127.0.0.1:8787", }, { publicSnapshotCall: async () => ({ @@ -1617,6 +1810,7 @@ test("pull bootstrap dispatches a real mixed roster and message page", async () assert.equal(storageInputs.length, 1); assert.equal(storageInputs[0].filter((record) => record.type === "sync_pull_message").length, 73); assert.deepEqual(storageInputs[0].at(-1), { type: "sync_pull_cursor", next_after: "80" }); + assert.equal(result.age_v1_envelopes, 1); }); test("pull bootstrap rejects unsupported projection kinds before either cursor advances", async () => { diff --git a/tests/test_ci_workflow.bats b/tests/test_ci_workflow.bats index 1696bbb8..028c6301 100644 --- a/tests/test_ci_workflow.bats +++ b/tests/test_ci_workflow.bats @@ -14,3 +14,18 @@ run grep -F 'tests/test_remote*.bats' "$workflow" [ "$status" -eq 0 ] } + +@test "age-v1 CI exercises the encrypted onboarding CLI with pinned tools" { + local workflow="$BATS_TEST_DIRNAME/../.github/workflows/tests.yml" + + run grep -F 'filippo.io/age/cmd/age-keygen@v1.3.1' "$workflow" + [ "$status" -eq 0 ] + run grep -F 'echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"' "$workflow" + [ "$status" -eq 0 ] + run grep -F 'command -v age >/dev/null' "$workflow" + [ "$status" -eq 0 ] + run grep -F 'command -v age-keygen >/dev/null' "$workflow" + [ "$status" -eq 0 ] + run grep -F "bats --print-output-on-failure --filter 'remote unlock:' tests/test_remote.bats" "$workflow" + [ "$status" -eq 0 ] +} diff --git a/tests/test_key.bats b/tests/test_key.bats index c0658d7e..bfa1d1ef 100644 --- a/tests/test_key.bats +++ b/tests/test_key.bats @@ -18,6 +18,26 @@ skip_if_no_age() { command -v age >/dev/null 2>&1 && command -v age-keygen >/dev/null 2>&1 || skip "age/age-keygen not installed" } +bind_testteam() { + local config="$SCRIPTS/../teams/testteam/config.json" + python3 -c " +import json +p = '$config' +d = json.load(open(p)) +d['remote_binding'] = { + 'endpoint': 'https://sync.example.test', + 'server_instance_id': '018f3f7e-0000-7000-8000-000000000000', + 'remote_team_id': d['team_id'], + 'remote_team_name': d['name'], + 'protocol_version': 1, + 'capabilities': {'write_allowed_ciphers': ['none', 'age-v1']}, + 'connected_at': '2026-07-30T00:00:00Z', + 'disconnected_at': None, +} +open(p, 'w').write(json.dumps(d) + '\\n') +" +} + # --- generate -------------------------------------------------------------- @test "key generate: creates a first epoch and prints the backup notice" { @@ -72,6 +92,16 @@ skip_if_no_age() { [[ "$output" == *"invalid team name"* ]] } +@test "key generate refuses to turn a connected plaintext history into E2EE" { + bind_testteam + run bash "$SCRIPTS/key.sh" generate testteam + [ "$status" -ne 0 ] + [[ "$output" == *"plaintext remote binding"* ]] + [[ "$output" == *"cannot be changed later"* ]] + [[ "$output" == *"Create a new team"* ]] + [ "$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$SCRIPTS/../teams/testteam/config.json")') AS TEXT), '\$.remote_key.current.key_id');")" = "" ] +} + # --- show -------------------------------------------------------------- @test "key show: default prints only public recipient and fingerprint" { @@ -100,6 +130,28 @@ skip_if_no_age() { [[ "$output" != *"AGE-SECRET-KEY"* ]] } +@test "key show --snapshot exports stable compact JCS and lowercase digest" { + skip_if_no_age + bash "$SCRIPTS/key.sh" generate testteam + config="$SCRIPTS/../teams/testteam/config.json" + bind_testteam + first="$TEST_SKILL_DIR/first-snapshot.json" + second="$TEST_SKILL_DIR/second-snapshot.json" + + run bash "$SCRIPTS/key.sh" show testteam --snapshot --out "$first" + [ "$status" -eq 0 ] + first_output="$output" + [[ "$first_output" =~ Snapshot\ SHA-256:\ [0-9a-f]{64} ]] + run bash "$SCRIPTS/key.sh" show testteam --snapshot --out "$second" + [ "$status" -eq 0 ] + [ "$output" = "$first_output" ] + cmp "$first" "$second" + [ "$(wc -l < "$first" | tr -d ' ')" -eq 0 ] + [ "$(sqlite_mem "SELECT json_valid(CAST(readfile('$(rf "$first")') AS TEXT));")" = "1" ] + key_id="$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$config")') AS TEXT), '\$.remote_key.current.key_id');")" + [ "$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$first")') AS TEXT), '\$.authorized_writers[0]');")" = "$key_id" ] +} + # --- import -------------------------------------------------------------- @test "key import --identity-stdin: establishes the first epoch for a team with no key yet" { @@ -110,6 +162,18 @@ skip_if_no_age() { [[ "$output" == *"Imported key for team 'testteam'"* ]] } +@test "key import --key-id: preserves the authority key id and is idempotent" { + skip_if_no_age + secret=$(age-keygen 2>/dev/null | grep '^AGE-SECRET-KEY-') + run bash -c "printf '%s' '$secret' | bash '$SCRIPTS/key.sh' import testteam --key-id epoch-handed --identity-stdin" + [ "$status" -eq 0 ] + config="$SCRIPTS/../teams/testteam/config.json" + [ "$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$config")') AS TEXT), '\$.remote_key.current.key_id');")" = "epoch-handed" ] + run bash -c "printf '%s' '$secret' | bash '$SCRIPTS/key.sh' import testteam --key-id epoch-handed --identity-stdin" + [ "$status" -eq 0 ] + [ "$(sqlite_mem "SELECT json_array_length(json_extract(CAST(readfile('$(rf "$config")') AS TEXT), '\$.remote_key.epochs'));")" -eq 1 ] +} + @test "key import: legacy positional identity warns on stderr; --identity-stdin does not" { skip_if_no_age secret=$(age-keygen 2>/dev/null | grep '^AGE-SECRET-KEY-') diff --git a/tests/test_remote.bats b/tests/test_remote.bats index cbe02f35..f9b0ff4f 100644 --- a/tests/test_remote.bats +++ b/tests/test_remote.bats @@ -4,6 +4,7 @@ load test_helper setup() { setup_test_env + export PEER_SKILL_DIR="" # Some cases deliberately remove python3 from PATH to verify the control-plane # gate. Resolve the fixture interpreter in each test process before that # system under test changes its environment. @@ -16,6 +17,10 @@ setup() { MOCK_REVOKE_BAD_BODY="${MOCK_REVOKE_BAD_BODY:-}" \ MOCK_REVOKE_LARGE_BODY="${MOCK_REVOKE_LARGE_BODY:-}" \ MOCK_PULL_MIXED="${MOCK_PULL_MIXED:-}" \ + MOCK_PULL_AGE="${MOCK_PULL_AGE:-}" \ + MOCK_PULL_AGE_ENVELOPE_FILE="${MOCK_PULL_AGE_ENVELOPE_FILE:-}" \ + MOCK_PULL_TEAM_ID="${MOCK_PULL_TEAM_ID:-}" \ + MOCK_CONNECT_NO_AGE="${MOCK_CONNECT_NO_AGE:-}" \ "$MOCK_PYTHON3" "$BATS_TEST_DIRNAME/helpers/mock_remote_server.py" 0 \ "$TEST_SKILL_DIR/server.port" 2>"$TEST_SKILL_DIR/server.log" 3>&- & MOCK_SERVER_PID=$! @@ -24,9 +29,49 @@ setup() { ENDPOINT="http://127.0.0.1:$MOCK_PORT" } +cleanup_sync_engines() { + local root="$1" label="$2" cleanup_status=0 pidfile pid + [ -d "$root" ] || return 0 + for pidfile in "$root"/run/remote-sync.*.pid; do + [ -f "$pidfile" ] || continue + pid="$(cat "$pidfile" 2>/dev/null || true)" + if ! [[ "$pid" =~ ^[1-9][0-9]{0,9}$ ]]; then + echo "invalid $label sync engine PID in $pidfile: $pid" >&2 + cleanup_status=1 + continue + fi + kill "$pid" 2>/dev/null || true + if ! wait_for_pid_exit "$pid"; then + echo "$label sync engine $pid did not exit after TERM; sending KILL" >&2 + kill -KILL "$pid" 2>/dev/null || true + if ! wait_for_pid_exit "$pid"; then + echo "$label sync engine $pid survived KILL; preserving $root" >&2 + cleanup_status=1 + fi + fi + done + return "$cleanup_status" +} + teardown() { kill "$MOCK_SERVER_PID" 2>/dev/null || true - teardown_test_env + wait "$MOCK_SERVER_PID" 2>/dev/null || true + + local cleanup_status=0 + if ! cleanup_sync_engines "$TEST_SKILL_DIR" "primary"; then + cleanup_status=1 + fi + if [ -n "${PEER_SKILL_DIR:-}" ] && [ -d "$PEER_SKILL_DIR" ]; then + if cleanup_sync_engines "$PEER_SKILL_DIR" "peer"; then + rm -rf "$PEER_SKILL_DIR" + else + cleanup_status=1 + fi + fi + if [ "$cleanup_status" -eq 0 ]; then + teardown_test_env + fi + return "$cleanup_status" } restart_mock_server() { @@ -38,6 +83,10 @@ restart_mock_server() { MOCK_REVOKE_BAD_BODY="${MOCK_REVOKE_BAD_BODY:-}" \ MOCK_REVOKE_LARGE_BODY="${MOCK_REVOKE_LARGE_BODY:-}" \ MOCK_PULL_MIXED="${MOCK_PULL_MIXED:-}" \ + MOCK_PULL_AGE="${MOCK_PULL_AGE:-}" \ + MOCK_PULL_AGE_ENVELOPE_FILE="${MOCK_PULL_AGE_ENVELOPE_FILE:-}" \ + MOCK_PULL_TEAM_ID="${MOCK_PULL_TEAM_ID:-}" \ + MOCK_CONNECT_NO_AGE="${MOCK_CONNECT_NO_AGE:-}" \ "$MOCK_PYTHON3" "$BATS_TEST_DIRNAME/helpers/mock_remote_server.py" 0 \ "$TEST_SKILL_DIR/server.port" 2>"$TEST_SKILL_DIR/server.log" 3>&- & MOCK_SERVER_PID=$! @@ -46,6 +95,11 @@ restart_mock_server() { ENDPOINT="http://127.0.0.1:$MOCK_PORT" } +skip_if_no_age() { + command -v age >/dev/null 2>&1 && command -v age-keygen >/dev/null 2>&1 || + skip "age/age-keygen not installed" +} + # --- doctor ------------------------------------------------------------ @test "remote doctor: passes when age is installed" { @@ -132,13 +186,59 @@ restart_mock_server() { @test "connect: registers a client-owned team (happy path, Done-when 1)" { run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam [ "$status" -eq 0 ] - [[ "$output" == *"Connected: team 'testteam'"* ]] + [[ "$output" == *"Connected: team 'testteam' (org 'testteam') (plain)."* ]] # A binding is recorded on the team config, and it carries no credential: # the register model writes none and none is fetched back. [ "$(sqlite_mem "SELECT json_extract(CAST(readfile('$SCRIPTS/../teams/testteam/config.json') AS TEXT), '\$.remote_binding.connected_at');")" != "" ] [ ! -f "$SCRIPTS/../run/remote-credentials/testteam.json" ] } +@test "connect --e2ee generates a key and establishes age-v1 before engine start" { + skip_if_no_age + + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" --e2ee testteam + [ "$status" -eq 0 ] + [[ "$output" == *"Connected: team 'testteam' (org 'testteam') (age-v1 encrypted)."* ]] + [[ "$output" == *"Back this up now"* ]] + [[ "$output" == *"key.sh show testteam --snapshot --out "* ]] + sync_config="$TEST_SKILL_DIR/db/remote-sync/testteam.json" + [ -f "$sync_config" ] + [ "$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$sync_config")') AS TEXT), '\$.cipher_profile');")" = "age-v1" ] + [ "$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$sync_config")') AS TEXT), '\$.local_security_history[0].minimum_security_mode');")" = "e2ee-required" ] + [ -f "$TEST_SKILL_DIR/run/remote-sync.testteam.pid" ] + run bash "$SCRIPTS/remote.sh" status testteam + [ "$status" -eq 0 ] + [[ "$output" == *"encryption: age-v1, key present"* ]] +} + +@test "connect defaults to plain even when the team already has a key" { + skip_if_no_age + bash "$SCRIPTS/key.sh" generate testteam + + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + [[ "$output" == *"plain sync was selected"* ]] + [[ "$output" == *"pass --e2ee"* ]] + [ ! -f "$TEST_SKILL_DIR/db/remote-sync/testteam.json" ] + [ -f "$TEST_SKILL_DIR/run/remote-sync.testteam.pid" ] + run bash "$SCRIPTS/remote.sh" status testteam + [ "$status" -eq 0 ] + [[ "$output" == *"encryption: none (local key is not used by this binding)"* ]] +} + +@test "connect: a keyed team fails closed when the remote disallows age-v1" { + skip_if_no_age + MOCK_CONNECT_NO_AGE=1 + restart_mock_server + + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" --e2ee testteam + [ "$status" -ne 0 ] + [[ "$output" == *"does not allow age-v1"* ]] + [[ "$output" == *"refusing to fall back to plaintext"* ]] + [ ! -f "$TEST_SKILL_DIR/db/remote-sync/testteam.json" ] + [ ! -f "$TEST_SKILL_DIR/run/remote-sync.testteam.pid" ] +} + @test "connect: moves the team into its own per-team store (Done-when 2)" { run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam [ "$status" -eq 0 ] @@ -702,6 +802,134 @@ PULL_TEAM_ID=018f3f7e-2222-7000-8000-000000000002 [ "$(storage_history mixed | jq -s 'length')" -eq 73 ] } +@test "remote pull: an observed age envelope prevents plaintext push" { + MOCK_PULL_AGE=1 + restart_mock_server + + 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."* ]] + + local cfg before after + cfg="$TEST_SKILL_DIR/teams/encrypted/config.json" + [ "$(sqlite_mem "SELECT json_extract(CAST(readfile('$(rf "$cfg")') AS TEXT), '\$.remote_binding.cipher_profile');")" = "age-v1" ] + [ ! -f "$TEST_SKILL_DIR/run/remote-sync.encrypted.pid" ] + + before="$(curl -sS "$ENDPOINT/v1/teams/$PULL_TEAM_ID" | jq -r '.current_seq')" + bash "$SCRIPTS/send.sh" encrypted member-1 member-1 "stays local" >/dev/null + run bash "$SCRIPTS/remote-sync.sh" once --team encrypted + [ "$status" -ne 0 ] + [[ "$output" == *"selected age-v1"* ]] + after="$(curl -sS "$ENDPOINT/v1/teams/$PULL_TEAM_ID" | jq -r '.current_seq')" + [ "$after" = "$before" ] +} + +@test "remote unlock: confirms handed authority, reprocesses, and resumes age-v1 sync" { + 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 + source_cfg="$TEST_SKILL_DIR/teams/testteam/config.json" + snapshot="$TEST_SKILL_DIR/handed-snapshot.json" + envelope="$TEST_SKILL_DIR/handed-envelope.json" + bash "$SCRIPTS/key.sh" show testteam --snapshot --out "$snapshot" 2>/dev/null + 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');")" + identity="$TEST_SKILL_DIR/run/remote-credentials/testteam/keys/$key_id.key" + jq -nc \ + --arg key_id "$key_id" \ + --arg recipient "$recipient" \ + --arg team_id "$team_id" \ + '{ + type:"sync_seal", envelope_v:1, cipher:"age-v1", + key_id:$key_id, recipients:[$recipient], max_blob_bytes:1048576, + wire_id:"20000000-0000-4000-8000-000000000001", + team_id:$team_id, protocol_version:1, + projection:{ + body:"handed ciphertext", created_at:"2026-01-02T00:00:00.000000Z", + from_agent:"member-1", to_agent:"member-1" + } + }' | node "$SCRIPTS/internal/sync-cipher.mjs" seal > "$envelope" + bash "$SCRIPTS/remote.sh" disconnect testteam >/dev/null 2>&1 || true + + MOCK_PULL_AGE=1 + MOCK_PULL_AGE_ENVELOPE_FILE="$envelope" + MOCK_PULL_TEAM_ID="$team_id" + restart_mock_server + + # Machine B gets an independent install root. Reusing Machine A's root would + # reuse its retained checkpoint and would not test a first trust import. + PEER_SKILL_DIR="$(mktemp -d)" + export PEER_SKILL_DIR + mkdir -p "$PEER_SKILL_DIR"/{scripts,db,teams} + cp -R "$BATS_TEST_DIRNAME"/../scripts/. "$PEER_SKILL_DIR/scripts/" + chmod +x "$PEER_SKILL_DIR/scripts/"*.sh + chmod +x "$PEER_SKILL_DIR/scripts/"*.js 2>/dev/null || true + chmod +x "$PEER_SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true + bash "$PEER_SKILL_DIR/scripts/internal/init-db.sh" + local peer_scripts="$PEER_SKILL_DIR/scripts" + + run bash "$peer_scripts/remote.sh" pull --endpoint "$ENDPOINT" --team-id "$team_id" encrypted + [ "$status" -eq 0 ] + [[ "$output" == *"local but locked"* ]] + + digest="$(shasum -a 256 "$snapshot" | awk '{print $1}')" + run bash "$peer_scripts/remote.sh" unlock encrypted \ + --snapshot "$snapshot" --identity "$identity" \ + --confirm-digest "0000000000000000000000000000000000000000000000000000000000000000" + [ "$status" -ne 0 ] + [[ "$output" == *"does not match"* ]] + [ "$(sqlite_mem "SELECT json_type(json_extract(CAST(readfile('$(rf "$PEER_SKILL_DIR/teams/encrypted/config.json")') AS TEXT), '\$.remote_key'));")" = "" ] + + local wrong_identity="$TEST_SKILL_DIR/wrong-identity.key" + age-keygen -o "$wrong_identity" >/dev/null 2>&1 + run bash "$peer_scripts/remote.sh" unlock encrypted \ + --snapshot "$snapshot" --identity "$wrong_identity" --confirm-digest "$digest" + [ "$status" -ne 0 ] + [[ "$output" == *"does not match the authority-confirmed snapshot"* ]] + [ "$(sqlite_mem "SELECT json_type(json_extract(CAST(readfile('$(rf "$PEER_SKILL_DIR/teams/encrypted/config.json")') AS TEXT), '\$.remote_key'));")" = "" ] + [ ! -e "$PEER_SKILL_DIR/run/remote-credentials/encrypted" ] + [ ! -e "$PEER_SKILL_DIR/db/remote-sync/encrypted.json" ] + [ ! -d "$PEER_SKILL_DIR/run/remote-trust" ] + + run bash "$peer_scripts/remote.sh" unlock encrypted \ + --snapshot "$snapshot" --identity "$identity" --confirm-digest "$digest" + [ "$status" -eq 0 ] + [[ "$output" == *"imported 1 envelope(s); engine running (pid "* ]] + local pidfile first_pid second_pid + pidfile="$PEER_SKILL_DIR/run/remote-sync.encrypted.pid" + wait_for_file "$pidfile" + 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" + [ "$status" -eq 0 ] + [[ "$output" == *"imported 0 envelope(s); engine running (pid "* ]] + second_pid="$(cat "$pidfile")" + [ "$second_pid" != "$first_pid" ] + ! kill -0 "$first_pid" 2>/dev/null + kill -0 "$second_pid" 2>/dev/null + + run bash "$peer_scripts/history.sh" encrypted member-1 + [ "$status" -eq 0 ] + [[ "$output" == *"handed ciphertext"* ]] + + local before after pushed_cipher + kill "$second_pid" 2>/dev/null || true + wait_for_pid_exit "$second_pid" + rm -f "$pidfile" + before="$(curl -sS "$ENDPOINT/v1/teams/$team_id" | jq -r '.current_seq')" + bash "$peer_scripts/send.sh" encrypted member-1 member-1 "encrypted outbound" >/dev/null + bash "$peer_scripts/remote-sync.sh" once --team encrypted >/dev/null 2>&1 || true + after="$(curl -sS "$ENDPOINT/v1/teams/$team_id" | jq -r '.current_seq')" + [ "$after" -gt "$before" ] + pushed_cipher="$(curl -sS "$ENDPOINT/_test/pushed" | + jq -r '.messages[-1].envelope.cipher')" + [ "$pushed_cipher" = "age-v1" ] +} + @test "remote doctor: age is optional — its absence does not fail the run" { # cipher "none" is the base and e2ee is available rather than required, so a # new user running doctor must not be told they are missing something they