@sentropic/h2a 0.85.24 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -3
- package/.codex-plugin/plugin.json +1 -1
- package/dist/bin.js +51 -2
- package/dist/bin.js.map +1 -1
- package/dist/cli-command-map.d.ts +119 -0
- package/dist/cli-command-map.d.ts.map +1 -0
- package/dist/cli-command-map.js +396 -0
- package/dist/cli-command-map.js.map +1 -0
- package/dist/cli-contract.d.ts.map +1 -1
- package/dist/cli-contract.js +38 -0
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli.d.ts +25 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +356 -7
- package/dist/cli.js.map +1 -1
- package/dist/identity.d.ts +37 -3
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +0 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/enrollment/ceremony.d.ts +542 -0
- package/dist/runtime/enrollment/ceremony.d.ts.map +1 -0
- package/dist/runtime/enrollment/ceremony.js +777 -0
- package/dist/runtime/enrollment/ceremony.js.map +1 -0
- package/dist/runtime/enrollment/index.d.ts +16 -0
- package/dist/runtime/enrollment/index.d.ts.map +1 -0
- package/dist/runtime/enrollment/index.js +16 -0
- package/dist/runtime/enrollment/index.js.map +1 -0
- package/dist/runtime/feed/descriptors.d.ts +261 -0
- package/dist/runtime/feed/descriptors.d.ts.map +1 -0
- package/dist/runtime/feed/descriptors.js +425 -0
- package/dist/runtime/feed/descriptors.js.map +1 -0
- package/dist/runtime/feed/index.d.ts +12 -0
- package/dist/runtime/feed/index.d.ts.map +1 -0
- package/dist/runtime/feed/index.js +12 -0
- package/dist/runtime/feed/index.js.map +1 -0
- package/dist/runtime/identity/index.d.ts +3 -3
- package/dist/runtime/identity/index.d.ts.map +1 -1
- package/dist/runtime/identity/index.js +2 -2
- package/dist/runtime/identity/index.js.map +1 -1
- package/dist/runtime/identity/live.d.ts +68 -0
- package/dist/runtime/identity/live.d.ts.map +1 -1
- package/dist/runtime/identity/live.js +78 -2
- package/dist/runtime/identity/live.js.map +1 -1
- package/dist/runtime/identity/readers.d.ts +89 -5
- package/dist/runtime/identity/readers.d.ts.map +1 -1
- package/dist/runtime/identity/readers.js +242 -33
- package/dist/runtime/identity/readers.js.map +1 -1
- package/dist/runtime/local-files/presence.d.ts +16 -1
- package/dist/runtime/local-files/presence.d.ts.map +1 -1
- package/dist/runtime/local-files/presence.js +32 -20
- package/dist/runtime/local-files/presence.js.map +1 -1
- package/dist/runtime/loop/index.d.ts +5 -0
- package/dist/runtime/loop/index.d.ts.map +1 -1
- package/dist/runtime/loop/index.js +48 -5
- package/dist/runtime/loop/index.js.map +1 -1
- package/dist/runtime/mcp/agent-launch.d.ts +15 -1
- package/dist/runtime/mcp/agent-launch.d.ts.map +1 -1
- package/dist/runtime/mcp/agent-launch.js +45 -4
- package/dist/runtime/mcp/agent-launch.js.map +1 -1
- package/dist/runtime/mcp/index.d.ts +1 -1
- package/dist/runtime/mcp/index.d.ts.map +1 -1
- package/dist/runtime/mcp/index.js +1 -1
- package/dist/runtime/mcp/index.js.map +1 -1
- package/dist/runtime/mcp/server.d.ts +3 -1
- package/dist/runtime/mcp/server.d.ts.map +1 -1
- package/dist/runtime/mcp/server.js +7 -2
- package/dist/runtime/mcp/server.js.map +1 -1
- package/dist/runtime/mcp/sessions.d.ts +19 -0
- package/dist/runtime/mcp/sessions.d.ts.map +1 -1
- package/dist/runtime/mcp/sessions.js +44 -2
- package/dist/runtime/mcp/sessions.js.map +1 -1
- package/dist/runtime/mcp/stdio.d.ts +8 -0
- package/dist/runtime/mcp/stdio.d.ts.map +1 -1
- package/dist/runtime/mcp/stdio.js +42 -0
- package/dist/runtime/mcp/stdio.js.map +1 -1
- package/dist/runtime/mcp-registry/broker.d.ts +175 -0
- package/dist/runtime/mcp-registry/broker.d.ts.map +1 -0
- package/dist/runtime/mcp-registry/broker.js +203 -0
- package/dist/runtime/mcp-registry/broker.js.map +1 -0
- package/dist/runtime/mcp-registry/broker.typecheck.d.ts +2 -0
- package/dist/runtime/mcp-registry/broker.typecheck.d.ts.map +1 -0
- package/dist/runtime/mcp-registry/broker.typecheck.js +48 -0
- package/dist/runtime/mcp-registry/broker.typecheck.js.map +1 -0
- package/dist/runtime/mirror/accept.d.ts +43 -5
- package/dist/runtime/mirror/accept.d.ts.map +1 -1
- package/dist/runtime/mirror/accept.js +55 -7
- package/dist/runtime/mirror/accept.js.map +1 -1
- package/dist/runtime/mirror/build.d.ts +23 -4
- package/dist/runtime/mirror/build.d.ts.map +1 -1
- package/dist/runtime/mirror/build.js +34 -4
- package/dist/runtime/mirror/build.js.map +1 -1
- package/dist/runtime/mirror/index.d.ts +3 -0
- package/dist/runtime/mirror/index.d.ts.map +1 -1
- package/dist/runtime/mirror/index.js +3 -0
- package/dist/runtime/mirror/index.js.map +1 -1
- package/dist/runtime/mirror/ingest.d.ts +109 -0
- package/dist/runtime/mirror/ingest.d.ts.map +1 -0
- package/dist/runtime/mirror/ingest.js +117 -0
- package/dist/runtime/mirror/ingest.js.map +1 -0
- package/dist/runtime/mirror/push-daemon.d.ts +376 -0
- package/dist/runtime/mirror/push-daemon.d.ts.map +1 -0
- package/dist/runtime/mirror/push-daemon.js +644 -0
- package/dist/runtime/mirror/push-daemon.js.map +1 -0
- package/dist/runtime/mirror/sanitize.d.ts +477 -0
- package/dist/runtime/mirror/sanitize.d.ts.map +1 -0
- package/dist/runtime/mirror/sanitize.js +625 -0
- package/dist/runtime/mirror/sanitize.js.map +1 -0
- package/dist/runtime/mirror/serve.d.ts.map +1 -1
- package/dist/runtime/mirror/serve.js +104 -24
- package/dist/runtime/mirror/serve.js.map +1 -1
- package/dist/runtime/reporting/report-ai.d.ts.map +1 -1
- package/dist/runtime/reporting/report-ai.js +1 -0
- package/dist/runtime/reporting/report-ai.js.map +1 -1
- package/dist/runtime/reporting/stdin.d.ts +2 -0
- package/dist/runtime/reporting/stdin.d.ts.map +1 -0
- package/dist/runtime/reporting/stdin.js +8 -0
- package/dist/runtime/reporting/stdin.js.map +1 -0
- package/dist/status-surface.d.ts +167 -0
- package/dist/status-surface.d.ts.map +1 -0
- package/dist/status-surface.js +670 -0
- package/dist/status-surface.js.map +1 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -1
- package/focus-app/client/_app/immutable/assets/{0.BdzHFb9q.css → 0.CgEVz9YW.css} +1 -1
- package/focus-app/client/_app/immutable/assets/0.CgEVz9YW.css.br +0 -0
- package/focus-app/client/_app/immutable/assets/0.CgEVz9YW.css.gz +0 -0
- package/focus-app/client/_app/immutable/assets/2.DaTmt6o7.css +1 -0
- package/focus-app/client/_app/immutable/assets/2.DaTmt6o7.css.br +0 -0
- package/focus-app/client/_app/immutable/assets/2.DaTmt6o7.css.gz +0 -0
- package/focus-app/client/_app/immutable/assets/3.vWQUAHjz.css +1 -0
- package/focus-app/client/_app/immutable/assets/3.vWQUAHjz.css.br +0 -0
- package/focus-app/client/_app/immutable/assets/3.vWQUAHjz.css.gz +0 -0
- package/focus-app/client/_app/immutable/assets/AppShell.CDPwj3zU.css +1 -0
- package/focus-app/client/_app/immutable/assets/AppShell.CDPwj3zU.css.br +0 -0
- package/focus-app/client/_app/immutable/assets/AppShell.CDPwj3zU.css.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/{Cj6wo28J.js → 1NK_OeRJ.js} +1 -1
- package/focus-app/client/_app/immutable/chunks/1NK_OeRJ.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/1NK_OeRJ.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/{Bs3f1UbD.js → B1veta3r.js} +2 -2
- package/focus-app/client/_app/immutable/chunks/B1veta3r.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/B1veta3r.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/{DEmooz0s.js → BZ4lcC0v.js} +1 -1
- package/focus-app/client/_app/immutable/chunks/BZ4lcC0v.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/BZ4lcC0v.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/Bu7F9NOv.js +2 -0
- package/focus-app/client/_app/immutable/chunks/Bu7F9NOv.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/Bu7F9NOv.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/C6KhNEX3.js +1 -0
- package/focus-app/client/_app/immutable/chunks/C6KhNEX3.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/C6KhNEX3.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/DIljKPJN.js +1 -0
- package/focus-app/client/_app/immutable/chunks/DIljKPJN.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/DIljKPJN.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/iXQate3J.js +2 -0
- package/focus-app/client/_app/immutable/chunks/iXQate3J.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/iXQate3J.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/{ZxFklh9T.js → kCE9wz0g.js} +1 -1
- package/focus-app/client/_app/immutable/chunks/kCE9wz0g.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/kCE9wz0g.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/x-hq5fDG.js +1 -0
- package/focus-app/client/_app/immutable/chunks/x-hq5fDG.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/x-hq5fDG.js.gz +0 -0
- package/focus-app/client/_app/immutable/entry/app.C1sDdCg5.js +2 -0
- package/focus-app/client/_app/immutable/entry/app.C1sDdCg5.js.br +0 -0
- package/focus-app/client/_app/immutable/entry/app.C1sDdCg5.js.gz +0 -0
- package/focus-app/client/_app/immutable/entry/start.X3j_A5RN.js +1 -0
- package/focus-app/client/_app/immutable/entry/start.X3j_A5RN.js.br +2 -0
- package/focus-app/client/_app/immutable/entry/start.X3j_A5RN.js.gz +0 -0
- package/focus-app/client/_app/immutable/nodes/0.gLl44WzG.js +5 -0
- package/focus-app/client/_app/immutable/nodes/0.gLl44WzG.js.br +0 -0
- package/focus-app/client/_app/immutable/nodes/0.gLl44WzG.js.gz +0 -0
- package/focus-app/client/_app/immutable/nodes/1.DUrghq2x.js +1 -0
- package/focus-app/client/_app/immutable/nodes/1.DUrghq2x.js.br +0 -0
- package/focus-app/client/_app/immutable/nodes/1.DUrghq2x.js.gz +0 -0
- package/focus-app/client/_app/immutable/nodes/2.Bp7JE7Pz.js +1 -0
- package/focus-app/client/_app/immutable/nodes/2.Bp7JE7Pz.js.br +0 -0
- package/focus-app/client/_app/immutable/nodes/2.Bp7JE7Pz.js.gz +0 -0
- package/focus-app/client/_app/immutable/nodes/3.Cml8enOG.js +12 -0
- package/focus-app/client/_app/immutable/nodes/3.Cml8enOG.js.br +0 -0
- package/focus-app/client/_app/immutable/nodes/3.Cml8enOG.js.gz +0 -0
- package/focus-app/client/_app/version.json +1 -1
- package/focus-app/client/_app/version.json.br +0 -0
- package/focus-app/client/_app/version.json.gz +0 -0
- package/focus-app/h2a-focus.json +108 -80
- package/focus-app/handler.js +7 -7
- package/focus-app/index.js +7 -7
- package/focus-app/server/chunks/chunks/AppShell.js-6Pi87dO0.js +1353 -0
- package/focus-app/server/chunks/chunks/AppShell.js-6Pi87dO0.js.map +1 -0
- package/focus-app/server/chunks/chunks/agent-memory-dossier.js-nmFG_rgx.js +1036 -0
- package/focus-app/server/chunks/chunks/agent-memory-dossier.js-nmFG_rgx.js.map +1 -0
- package/focus-app/server/chunks/chunks/{exports.js-DUTkELZq.js → exports.js-CdqPbjm1.js} +3 -3
- package/focus-app/server/chunks/chunks/{exports.js-DUTkELZq.js.map → exports.js-CdqPbjm1.js.map} +1 -1
- package/focus-app/server/chunks/chunks/h2a-bus.js-bLEJ_Adi.js +233 -0
- package/focus-app/server/chunks/chunks/h2a-bus.js-bLEJ_Adi.js.map +1 -0
- package/focus-app/server/chunks/chunks/{index.js-Di21TltP.js → index.js-laGHLarB.js} +5 -2
- package/focus-app/server/chunks/chunks/index.js-laGHLarB.js.map +1 -0
- package/focus-app/server/chunks/chunks/{internal.js-33vbd70h.js → internal.js-C6T8nHkl.js} +4 -4
- package/focus-app/server/chunks/chunks/{internal.js-33vbd70h.js.map → internal.js-C6T8nHkl.js.map} +1 -1
- package/focus-app/server/chunks/chunks/{root.js-DO1m-gJA.js → root.js-CtVQOgDw.js} +2 -2
- package/focus-app/server/chunks/chunks/{root.js-DO1m-gJA.js.map → root.js-CtVQOgDw.js.map} +1 -1
- package/focus-app/server/chunks/entries/endpoints/api/decisions/inject/{_server.ts.js-j0NNcOSZ.js → _server.ts.js-hrbMnsV0.js} +13 -42
- package/focus-app/server/chunks/entries/endpoints/api/decisions/inject/_server.ts.js-hrbMnsV0.js.map +1 -0
- package/focus-app/server/chunks/entries/endpoints/api/dossiers/agent-memory/include/_server.ts.js-Dw4odGdV.js +138 -0
- package/focus-app/server/chunks/entries/endpoints/api/dossiers/agent-memory/include/_server.ts.js-Dw4odGdV.js.map +1 -0
- package/focus-app/server/chunks/entries/endpoints/api/h2a/targets/_server.ts.js-BLjYv7vg.js +41 -0
- package/focus-app/server/chunks/entries/endpoints/api/h2a/targets/_server.ts.js-BLjYv7vg.js.map +1 -0
- package/focus-app/server/chunks/entries/fallbacks/{error.svelte.js-Dj3sETSX.js → error.svelte.js-RbeaNF5a.js} +4 -4
- package/focus-app/server/chunks/entries/fallbacks/{error.svelte.js-Dj3sETSX.js.map → error.svelte.js-RbeaNF5a.js.map} +1 -1
- package/focus-app/server/chunks/entries/pages/{_layout.svelte.js-BoLsbuC2.js → _layout.svelte.js-e1E_I04z.js} +2 -2
- package/focus-app/server/chunks/entries/pages/{_layout.svelte.js-BoLsbuC2.js.map → _layout.svelte.js-e1E_I04z.js.map} +1 -1
- package/focus-app/server/chunks/entries/pages/{_page.svelte.js-QDldMYhk.js → _page.svelte.js-DbaEG23x.js} +31 -1373
- package/focus-app/server/chunks/entries/pages/_page.svelte.js-DbaEG23x.js.map +1 -0
- package/focus-app/server/chunks/entries/pages/dossier/agent-memory/_page.server.ts.js-CFC-wn6U.js +88 -0
- package/focus-app/server/chunks/entries/pages/dossier/agent-memory/_page.server.ts.js-CFC-wn6U.js.map +1 -0
- package/focus-app/server/chunks/entries/pages/dossier/agent-memory/_page.svelte.js-BpR26NMa.js +1574 -0
- package/focus-app/server/chunks/entries/pages/dossier/agent-memory/_page.svelte.js-BpR26NMa.js.map +1 -0
- package/focus-app/server/chunks/{handler-Cp_5hLyY.js → handler-Be3dyNi0.js} +3 -3
- package/focus-app/server/chunks/{handler-Cp_5hLyY.js.map → handler-Be3dyNi0.js.map} +1 -1
- package/focus-app/server/chunks/{index.js-CBh7Wjqp.js → index.js-siUxMMnK.js} +3 -3
- package/focus-app/server/chunks/{index.js-CBh7Wjqp.js.map → index.js-siUxMMnK.js.map} +1 -1
- package/focus-app/server/chunks/manifest.js-CXJqSHHn.js +78 -0
- package/focus-app/server/chunks/manifest.js-CXJqSHHn.js.map +1 -0
- package/focus-app/server/chunks/nodes/0.js-leU6sfRR.js +9 -0
- package/focus-app/server/chunks/nodes/{0.js-4wlmdsk8.js.map → 0.js-leU6sfRR.js.map} +1 -1
- package/focus-app/server/chunks/nodes/1.js-os6QXm6B.js +9 -0
- package/focus-app/server/chunks/nodes/{1.js-B-LpY-lC.js.map → 1.js-os6QXm6B.js.map} +1 -1
- package/focus-app/server/chunks/nodes/2.js-DAaLjh93.js +19 -0
- package/focus-app/server/chunks/nodes/2.js-DAaLjh93.js.map +1 -0
- package/focus-app/server/chunks/nodes/3.js-Cmgi6Uy0.js +18 -0
- package/focus-app/server/chunks/nodes/3.js-Cmgi6Uy0.js.map +1 -0
- package/package.json +2 -2
- package/skills/h2a-run/SKILL.md +100 -0
- package/focus-app/client/_app/immutable/assets/0.BdzHFb9q.css.br +0 -2
- package/focus-app/client/_app/immutable/assets/0.BdzHFb9q.css.gz +0 -0
- package/focus-app/client/_app/immutable/assets/2.Ddxq02y4.css +0 -1
- package/focus-app/client/_app/immutable/assets/2.Ddxq02y4.css.br +0 -0
- package/focus-app/client/_app/immutable/assets/2.Ddxq02y4.css.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/Bs3f1UbD.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/Bs3f1UbD.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/Bv8OL8ib.js +0 -1
- package/focus-app/client/_app/immutable/chunks/Bv8OL8ib.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/Bv8OL8ib.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/CVq_IFFr.js +0 -2
- package/focus-app/client/_app/immutable/chunks/CVq_IFFr.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/CVq_IFFr.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/Cj6wo28J.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/Cj6wo28J.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/DEmooz0s.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/DEmooz0s.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/DQIRXgdC.js +0 -2
- package/focus-app/client/_app/immutable/chunks/DQIRXgdC.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/DQIRXgdC.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/ZxFklh9T.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/ZxFklh9T.js.gz +0 -0
- package/focus-app/client/_app/immutable/chunks/_wRuVGPs.js +0 -1
- package/focus-app/client/_app/immutable/chunks/_wRuVGPs.js.br +0 -0
- package/focus-app/client/_app/immutable/chunks/_wRuVGPs.js.gz +0 -0
- package/focus-app/client/_app/immutable/entry/app.6DKouWRt.js +0 -2
- package/focus-app/client/_app/immutable/entry/app.6DKouWRt.js.br +0 -0
- package/focus-app/client/_app/immutable/entry/app.6DKouWRt.js.gz +0 -0
- package/focus-app/client/_app/immutable/entry/start.DlLmkUb7.js +0 -1
- package/focus-app/client/_app/immutable/entry/start.DlLmkUb7.js.br +0 -2
- package/focus-app/client/_app/immutable/entry/start.DlLmkUb7.js.gz +0 -0
- package/focus-app/client/_app/immutable/nodes/0.CuSOF0Of.js +0 -5
- package/focus-app/client/_app/immutable/nodes/0.CuSOF0Of.js.br +0 -0
- package/focus-app/client/_app/immutable/nodes/0.CuSOF0Of.js.gz +0 -0
- package/focus-app/client/_app/immutable/nodes/1.D39jdnqm.js +0 -1
- package/focus-app/client/_app/immutable/nodes/1.D39jdnqm.js.br +0 -0
- package/focus-app/client/_app/immutable/nodes/1.D39jdnqm.js.gz +0 -0
- package/focus-app/client/_app/immutable/nodes/2.CnU8ifRl.js +0 -1
- package/focus-app/client/_app/immutable/nodes/2.CnU8ifRl.js.br +0 -0
- package/focus-app/client/_app/immutable/nodes/2.CnU8ifRl.js.gz +0 -0
- package/focus-app/server/chunks/chunks/index.js-Di21TltP.js.map +0 -1
- package/focus-app/server/chunks/entries/endpoints/api/decisions/inject/_server.ts.js-j0NNcOSZ.js.map +0 -1
- package/focus-app/server/chunks/entries/pages/_page.svelte.js-QDldMYhk.js.map +0 -1
- package/focus-app/server/chunks/manifest.js-Cm6cC7mC.js +0 -56
- package/focus-app/server/chunks/manifest.js-Cm6cC7mC.js.map +0 -1
- package/focus-app/server/chunks/nodes/0.js-4wlmdsk8.js +0 -9
- package/focus-app/server/chunks/nodes/1.js-B-LpY-lC.js +0 -9
- package/focus-app/server/chunks/nodes/2.js-F6jp5eD9.js +0 -19
- package/focus-app/server/chunks/nodes/2.js-F6jp5eD9.js.map +0 -1
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* h2a's side of the PRINCIPAL↔AGENT enrollment ceremony (P1, step 3).
|
|
3
|
+
*
|
|
4
|
+
* Implements Part B of the ratified contract
|
|
5
|
+
* `docs/superpowers/specs/2026-07-24-h2a-feed-contract-for-sentropic.md`
|
|
6
|
+
* (RATIFIED by the sentropic architect 2026-07-24, amended 2026-07-25 and again
|
|
7
|
+
* 2026-07-25 by the SIGNED-COMPOSITE amendment recorded in Part B), and step 3 of
|
|
8
|
+
* `docs/specs/2026-07-25-p1-joint-plan-h2a-sessions-in-sentropic-ui.md`.
|
|
9
|
+
*
|
|
10
|
+
* AUTHORITY MODEL — read this before touching anything below.
|
|
11
|
+
* ----------------------------------------------------------
|
|
12
|
+
* **The 39-auth PRINCIPAL is the authorizing authority. This module only
|
|
13
|
+
* proves that the local agent controls its ed25519 key.** A valid signature
|
|
14
|
+
* proves *authorship* (this key produced this payload); it NEVER proves
|
|
15
|
+
* *authorization* (this key may appear in this principal's feed). Those are two
|
|
16
|
+
* different checks and the code keeps them structurally separate:
|
|
17
|
+
*
|
|
18
|
+
* - h2a produces a proof of key control. That is the whole of h2a's part.
|
|
19
|
+
* - sentropic decides what the proof authorizes, by looking up an ACTIVE
|
|
20
|
+
* `(principalSub, agentPubKey)` binding row in ITS OWN store.
|
|
21
|
+
*
|
|
22
|
+
* **SIGNED ≠ AUTHORIZED.** The signature now covers `instance` as well as the
|
|
23
|
+
* nonce and the key (see {@link enrollmentProofSignedPayload}). That makes the
|
|
24
|
+
* *provenance* of `instance` trustworthy — it does **NOT** make `instance` an
|
|
25
|
+
* authorization input, and nothing downstream may start treating it as one.
|
|
26
|
+
* Authorization stays the active-binding lookup on
|
|
27
|
+
* `(principalSub, agentPubKey)`, with the instance re-resolved live at read time
|
|
28
|
+
* (Part B: `agentInstanceIdAtBinding` is "provenance only — NEVER re-used as
|
|
29
|
+
* authority at read time"). This is the same line as authorship ≠ authorization,
|
|
30
|
+
* one field further in: a signature widens what you may *believe about the
|
|
31
|
+
* payload*, never what the payload may *reach*.
|
|
32
|
+
*
|
|
33
|
+
* Four consequences that are load-bearing, not stylistic:
|
|
34
|
+
*
|
|
35
|
+
* 1. **No binding store here.** h2a has no concept of a 39-auth principal, so
|
|
36
|
+
* it cannot own the binding record (`H2APrincipalAgentBinding` — Part B,
|
|
37
|
+
* "Binding record": owned and stored by sentropic). Nothing in this module
|
|
38
|
+
* writes, caches or reads a binding.
|
|
39
|
+
* 2. **The agent never receives a principal identifier at all.** The challenge
|
|
40
|
+
* type has no `principalSub`, and the CLI refuses a challenge document that
|
|
41
|
+
* carries one. The agent signs a nonce; it has no functional need for the
|
|
42
|
+
* principal's id, and putting one into an agent process and context window
|
|
43
|
+
* buys nothing. *Minimal disclosure beats verified non-retention* — so this
|
|
44
|
+
* is enforced at receipt, not merely proven absent from the output.
|
|
45
|
+
* 3. **Every field the proof carries is signed.** A proof must attest to
|
|
46
|
+
* everything it carries: a reader sees a signature and reasonably infers it
|
|
47
|
+
* covers the payload, so an unsigned-but-present field is a claim wider than
|
|
48
|
+
* its evidence — in the one artifact whose whole job is to be exactly as wide
|
|
49
|
+
* as its evidence. If a field does not deserve signing, it must be REMOVED
|
|
50
|
+
* from the proof rather than carried unsigned. Pinned by a test that derives
|
|
51
|
+
* the covered set from the proof's own keys.
|
|
52
|
+
* 4. **No outward transport.** This module never opens a socket. Not behind a
|
|
53
|
+
* flag, not behind a default-off flag. The proof is returned to the caller;
|
|
54
|
+
* delivering it to the gateway is the gateway lane's step, and the seam for
|
|
55
|
+
* it ({@link EnrollmentProofSubmitter}) has NO default implementation — an
|
|
56
|
+
* unsupplied submitter is reported as such, never silently attempted.
|
|
57
|
+
*
|
|
58
|
+
* Per the amendment accepted after ratification (joint plan, finding 2),
|
|
59
|
+
* enrollment on the AUTH side requires an active **first-party session** and is
|
|
60
|
+
* deliberately unreachable by bearer tokens — otherwise any relying party the
|
|
61
|
+
* owner ever consented to could mint a durable binding for its own key,
|
|
62
|
+
* escalating "read the owner's data" into "permanently be the owner's agent".
|
|
63
|
+
* That gate is the auth lane's. Nothing here assumes, carries or accepts a
|
|
64
|
+
* bearer token; there is no credential input to this module at all.
|
|
65
|
+
*
|
|
66
|
+
* REUSE, not new crypto: `signCanonical` / `verifyCanonical`
|
|
67
|
+
* (`packages/h2a/src/signature.ts`) over the existing identity keypair at
|
|
68
|
+
* `<root>/keys/<instance>.key.pem` — the same primitive already used for reclaim
|
|
69
|
+
* proof-of-possession (`runtime/identity/live.ts` `provesLocalKey`,
|
|
70
|
+
* `runtime/identity/bindings.ts` `verifyReclaimProof`). `signCanonical` already
|
|
71
|
+
* takes `unknown`, so signing a composite is the same primitive with a different
|
|
72
|
+
* argument: no new key, no new algorithm, no new file, no extra round-trip.
|
|
73
|
+
*
|
|
74
|
+
* ONE EXCEPTION CONSIDERED AND REFUSED — recorded so nobody re-litigates it.
|
|
75
|
+
* -------------------------------------------------------------------------
|
|
76
|
+
* A natural-looking proposal is to WARN when a challenge has an inherited
|
|
77
|
+
* `expiresAt` it does not carry —
|
|
78
|
+
* `!Object.hasOwn(c, "expiresAt") && "expiresAt" in c` — on the grounds that it is
|
|
79
|
+
* diagnostic only and never control flow. **It was considered and REFUSED.**
|
|
80
|
+
*
|
|
81
|
+
* A diagnostic-only prototype-chain read is exactly the shape of harmless-looking
|
|
82
|
+
* thing that a later refactor promotes into control flow, and the module's rule is
|
|
83
|
+
* stronger as an ABSOLUTE — *this module never reads through the prototype chain* —
|
|
84
|
+
* than as *"never, except for warnings"*. An absolute is verifiable by reading the
|
|
85
|
+
* file; an exception has to be policed forever. Refusing the exception once is
|
|
86
|
+
* cheaper than guarding it indefinitely, so it is refused here rather than
|
|
87
|
+
* admitted and watched. Do not add it back without reopening this with the
|
|
88
|
+
* approver.
|
|
89
|
+
*/
|
|
90
|
+
import { createPrivateKey } from "node:crypto";
|
|
91
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
92
|
+
import { join } from "node:path";
|
|
93
|
+
// Siblings and leaf primitives are imported RELATIVELY. `index.ts` re-exports
|
|
94
|
+
// this barrel, so importing `@sentropic/h2a` here would close a self-referential
|
|
95
|
+
// cycle through the package root.
|
|
96
|
+
import { signCanonical, verifyCanonical } from "../../signature.js";
|
|
97
|
+
import { H2A_CLI_DECLARED_CAPABILITIES, publicKeyFingerprint, resolveLiveIdentity } from "../identity/live.js";
|
|
98
|
+
import { createLocalStore } from "../local-files/store.js";
|
|
99
|
+
/**
|
|
100
|
+
* THE NONCE BRACKET — four bounds, each labelled with what it is FOR.
|
|
101
|
+
* =================================================================
|
|
102
|
+
*
|
|
103
|
+
* The general form, which is the correction to the first draft of this contract:
|
|
104
|
+
* **a positive specification does not mean a single value — it means every
|
|
105
|
+
* accepted-set boundary is stated, and each one says what it is for.** A floor
|
|
106
|
+
* that protects strength and a ceiling that protects against blobs are different
|
|
107
|
+
* parameters and must not be described in the same breath. The first attempt
|
|
108
|
+
* specified `fixed ~43`, which pinned an issuer that DOES NOT EXIST YET to an
|
|
109
|
+
* entropy choice made on its behalf: if the auth lane later picks 384 or 512 bits
|
|
110
|
+
* — the *safer* choice — a fixed verifier turns their improvement into our
|
|
111
|
+
* outage.
|
|
112
|
+
*
|
|
113
|
+
* 1. {@link H2A_ENROLLMENT_NONCE_PATTERN} — alphabet base64url.
|
|
114
|
+
* **POSITIVE, SECURITY-BEARING.**
|
|
115
|
+
* 2. {@link H2A_ENROLLMENT_NONCE_MIN_BITS} / {@link H2A_ENROLLMENT_NONCE_MIN_LENGTH}
|
|
116
|
+
* — minimum 256 bits. **POSITIVE, SECURITY-BEARING.**
|
|
117
|
+
* 3. {@link H2A_ENROLLMENT_NONCE_MAX_BITS} / {@link H2A_ENROLLMENT_NONCE_MAX_LENGTH}
|
|
118
|
+
* — maximum 1024 bits. **SANITY CEILING, EXPLICITLY NOT A SECURITY
|
|
119
|
+
* PARAMETER.** It means *"beyond this it is not a nonce"*. It must NEVER be
|
|
120
|
+
* read as *"this much entropy is enough"* — the floor is the only bound that
|
|
121
|
+
* speaks about strength.
|
|
122
|
+
* 4. {@link H2A_ENROLLMENT_MAX_NONCE_LENGTH} — 4096 chars, pre-parse DoS guard
|
|
123
|
+
* only. Not a definition of anything.
|
|
124
|
+
*/
|
|
125
|
+
/**
|
|
126
|
+
* Minimum entropy a gateway nonce must carry. **POSITIVE, SECURITY-BEARING.**
|
|
127
|
+
* Below this a "nonce" is guessable, and a guessable challenge is one an attacker
|
|
128
|
+
* can pre-compute a proof for.
|
|
129
|
+
*/
|
|
130
|
+
export const H2A_ENROLLMENT_NONCE_MIN_BITS = 256;
|
|
131
|
+
/**
|
|
132
|
+
* Upper end of the bracket. **SANITY CEILING — NOT A SECURITY PARAMETER.**
|
|
133
|
+
*
|
|
134
|
+
* It says "beyond this it is not a nonce", nothing about sufficiency.
|
|
135
|
+
*
|
|
136
|
+
* Set at 1024 rather than 512 for HEADROOM — and the arithmetic has to be stated
|
|
137
|
+
* correctly, because an earlier draft of this comment said "88 base64url
|
|
138
|
+
* characters" and then drew a conclusion that only the wrong number supports.
|
|
139
|
+
* Base64url packs 6 bits per character, so a perfectly reasonable 64-byte nonce is
|
|
140
|
+
* 512 bits / **86** characters — and a 512-bit ceiling derives to `ceil(512 / 6) =
|
|
141
|
+
* 86` as well. It would therefore land EXACTLY on such a nonce and ACCEPT it, not
|
|
142
|
+
* reject it. So "512 would reject a 64-byte nonce" was never true; the real
|
|
143
|
+
* objection is that it leaves ZERO margin. A bound never meant to bound entropy
|
|
144
|
+
* must not sit flush against a legitimate value, where one more byte of issuer
|
|
145
|
+
* entropy — or base64 padding the issuer does not strip — turns a sanity bound
|
|
146
|
+
* into an outage. 1024 buys that margin. The corrected number does not weaken the
|
|
147
|
+
* choice; it removes a justification that misreported its own arithmetic, which is
|
|
148
|
+
* the defect class this module exists to argue against.
|
|
149
|
+
*/
|
|
150
|
+
export const H2A_ENROLLMENT_NONCE_MAX_BITS = 1024;
|
|
151
|
+
/**
|
|
152
|
+
* The nonce's accepted alphabet, stated POSITIVELY: base64url characters only.
|
|
153
|
+
* **POSITIVE, SECURITY-BEARING** — it is what refuses free text, JSON, a URL, or
|
|
154
|
+
* a message borrowed from some other protocol, rather than enumerating those.
|
|
155
|
+
*/
|
|
156
|
+
export const H2A_ENROLLMENT_NONCE_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
157
|
+
/**
|
|
158
|
+
* Minimum nonce length, DERIVED not asserted: base64url packs 6 bits per
|
|
159
|
+
* character, so `ceil(256 / 6) = 43` characters is the 256-bit floor.
|
|
160
|
+
* **POSITIVE, SECURITY-BEARING.**
|
|
161
|
+
*
|
|
162
|
+
* A MINIMUM, never an exact length — see the bracket note above for why pinning
|
|
163
|
+
* an as-yet-unbuilt issuer to one value is the error this replaces.
|
|
164
|
+
*/
|
|
165
|
+
export const H2A_ENROLLMENT_NONCE_MIN_LENGTH = Math.ceil(H2A_ENROLLMENT_NONCE_MIN_BITS / 6);
|
|
166
|
+
/**
|
|
167
|
+
* Maximum nonce length, derived the same way: `ceil(1024 / 6) = 171` characters.
|
|
168
|
+
* **SANITY CEILING, NOT A SECURITY PARAMETER** — see
|
|
169
|
+
* {@link H2A_ENROLLMENT_NONCE_MAX_BITS}.
|
|
170
|
+
*/
|
|
171
|
+
export const H2A_ENROLLMENT_NONCE_MAX_LENGTH = Math.ceil(H2A_ENROLLMENT_NONCE_MAX_BITS / 6);
|
|
172
|
+
/**
|
|
173
|
+
* Cheap PRE-PARSE cap, and nothing more.
|
|
174
|
+
*
|
|
175
|
+
* A DoS sanity bound, not a definition of a nonce — the definition is the
|
|
176
|
+
* alphabet plus the floor, with the ceiling as a separate sanity bound. Kept
|
|
177
|
+
* distinct so none of the four is mistaken for another.
|
|
178
|
+
*/
|
|
179
|
+
export const H2A_ENROLLMENT_MAX_NONCE_LENGTH = 4096;
|
|
180
|
+
/**
|
|
181
|
+
* The ONLY keys a challenge document may carry. An ALLOWLIST, not a blocklist.
|
|
182
|
+
*
|
|
183
|
+
* The nonce was specified positively while the challenge *object* was left
|
|
184
|
+
* specified negatively — a blocklist of one key, `principalSub`. That control did
|
|
185
|
+
* not cover the harm it was written for: the harm is *"a principal id reaching an
|
|
186
|
+
* agent process and context window"*, and `{ nonce, meta: { principalSub } }` or
|
|
187
|
+
* `{ nonce, "__proto__": { principalSub } }` both do exactly that while passing a
|
|
188
|
+
* top-level `"principalSub" in challenge` check. A blocklist of one key stops one
|
|
189
|
+
* spelling of one field; nesting is a different spelling.
|
|
190
|
+
*
|
|
191
|
+
* So the same positive-specification move is applied one level up: only `nonce`
|
|
192
|
+
* and `expiresAt` may appear, both must be strings, and anything else is refused
|
|
193
|
+
* without needing to know what it means. No nesting is reachable, so no
|
|
194
|
+
* deep scan for a forbidden name is needed — that would be the blocklist again,
|
|
195
|
+
* one level deeper.
|
|
196
|
+
*/
|
|
197
|
+
export const H2A_ENROLLMENT_CHALLENGE_KEYS = ["nonce", "expiresAt"];
|
|
198
|
+
/**
|
|
199
|
+
* The domain-separation tag carried and signed by every proof — see
|
|
200
|
+
* {@link H2AEnrollmentProof.type}.
|
|
201
|
+
*
|
|
202
|
+
* Versioned so a format change is distinguishable rather than silently
|
|
203
|
+
* reinterpreted. Bump this and the verifier stops accepting the old shape, which
|
|
204
|
+
* is the entire point of it being here.
|
|
205
|
+
*/
|
|
206
|
+
export const H2A_ENROLLMENT_PROOF_TYPE = "h2a-enrollment-proof-v1";
|
|
207
|
+
/**
|
|
208
|
+
* The exact object the signature covers: **every field the proof carries except
|
|
209
|
+
* the signature itself**.
|
|
210
|
+
*
|
|
211
|
+
* Single definition on purpose. The gateway's verification (Part B flow step 5b,
|
|
212
|
+
* as amended) is `verifyCanonical(enrollmentProofSignedPayload(proof),
|
|
213
|
+
* proof.signature, proof.publicKeyPem)`, and field ordering is irrelevant
|
|
214
|
+
* because `canonicalize` normalizes it — so an independent implementation cannot
|
|
215
|
+
* disagree about what was signed by guessing at key order.
|
|
216
|
+
*
|
|
217
|
+
* The rule is STRUCTURAL, not asserted. Coverage is a rest-spread that removes
|
|
218
|
+
* exactly one field, so "every field except the signature" is what the code
|
|
219
|
+
* *does* rather than a list that has to be kept in step — the same shape as
|
|
220
|
+
* `envelope.ts` `envelopeSigningView` (`const { signatures: _omit, ...rest }`).
|
|
221
|
+
* Three things follow, and the third is why this beats an enumeration:
|
|
222
|
+
*
|
|
223
|
+
* 1. `Omit<…, "signature">` on the parameter makes **tsc** reject a caller that
|
|
224
|
+
* does not hold every non-signature field, so a new required field on
|
|
225
|
+
* {@link H2AEnrollmentProof} is a compile error, not a test failure.
|
|
226
|
+
* 2. A new field flows into the signature automatically. There is no second list
|
|
227
|
+
* to forget.
|
|
228
|
+
* 3. A field carried on the proof but NOT signed becomes impossible to emit:
|
|
229
|
+
* signing sees the unsigned view, verification re-derives it from the finished
|
|
230
|
+
* proof, so any extra field makes the two disagree and
|
|
231
|
+
* {@link signEnrollmentChallenge}'s self-verification throws. The rule holds
|
|
232
|
+
* even with every test deleted.
|
|
233
|
+
*
|
|
234
|
+
* The keys test is kept anyway — it documents the intent and costs nothing.
|
|
235
|
+
*/
|
|
236
|
+
export function enrollmentProofSignedPayload(
|
|
237
|
+
// `Omit<…, "signature">` states the rule in the type itself: the signature is
|
|
238
|
+
// the ONE field a signature cannot cover, and the only field this function is
|
|
239
|
+
// allowed not to see. A full proof satisfies this parameter too, which is why
|
|
240
|
+
// `signature` is accepted-and-stripped rather than merely absent: verification
|
|
241
|
+
// hands in the finished proof.
|
|
242
|
+
proof) {
|
|
243
|
+
const { signature: _omit, ...rest } = proof;
|
|
244
|
+
return rest;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Verify a proof the way the gateway must: the signature over the whole
|
|
248
|
+
* composite, checked against the key the proof ships.
|
|
249
|
+
*
|
|
250
|
+
* **This answers "did this key produce this payload", and NOTHING else.** It is
|
|
251
|
+
* not an authorization check and must never be used as one: a `true` here on a
|
|
252
|
+
* key with no active binding row still authorizes nothing (Part B, fail-closed
|
|
253
|
+
* item 3 — "verifying the signature must never, by itself, cause any row to be
|
|
254
|
+
* returned"). Provided so the two lanes verify the same bytes, not so a caller
|
|
255
|
+
* can shortcut a binding lookup.
|
|
256
|
+
*/
|
|
257
|
+
export function verifyEnrollmentProof(proof) {
|
|
258
|
+
// CHECK THE TAG, or it is decoration. A signature over a payload whose `type`
|
|
259
|
+
// says something else is a valid signature over a DIFFERENT message, and this
|
|
260
|
+
// verifier speaks v1 only — a `-v2` proof must fail here rather than be
|
|
261
|
+
// reinterpreted as v1. That is what versioning the tag is for.
|
|
262
|
+
if (proof.type !== H2A_ENROLLMENT_PROOF_TYPE)
|
|
263
|
+
return false;
|
|
264
|
+
return verifyCanonical(enrollmentProofSignedPayload(proof), proof.signature, proof.publicKeyPem);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Refuse to prove control of a key h2a itself does not list as ACTIVE.
|
|
268
|
+
*
|
|
269
|
+
* h2a-side key validity is *necessary but not sufficient* for exposure — the
|
|
270
|
+
* binding governs that (Part B, fail-closed item 4) — but a key h2a considers
|
|
271
|
+
* revoked must never be offered up for a NEW binding.
|
|
272
|
+
*
|
|
273
|
+
* This is DEFENCE IN DEPTH, and its call site is unreachable in practice: when
|
|
274
|
+
* the live key has been revoked, `resolveLiveIdentity`'s reclaim proof
|
|
275
|
+
* (`provesLocalKey`, which requires at least one active key) fails, so it MINTS
|
|
276
|
+
* a fresh identity instead of returning the revoked one — verified by the
|
|
277
|
+
* "a locally revoked key is never the key proved" test. The guard exists so that
|
|
278
|
+
* remains a CHECKED fact rather than a relied-upon one, and it is exported so it
|
|
279
|
+
* can be exercised directly instead of sitting untested behind a path that
|
|
280
|
+
* cannot reach it.
|
|
281
|
+
*
|
|
282
|
+
* An empty key list is an ERROR here, never a silent pass: "no active keys" is
|
|
283
|
+
* not the fact "this key is fine".
|
|
284
|
+
*/
|
|
285
|
+
export function assertKeyIsLocallyActive(input) {
|
|
286
|
+
if (input.activeKeys.length === 0) {
|
|
287
|
+
throw new Error(`h2a enrollment: instance "${input.instance}" has NO active key in the local registry — ` +
|
|
288
|
+
"refusing to prove control of a key h2a itself does not list as active");
|
|
289
|
+
}
|
|
290
|
+
if (!input.activeKeys.includes(input.publicKeyPem)) {
|
|
291
|
+
throw new Error(`h2a enrollment: the resolved public key of "${input.instance}" is not active in the local ` +
|
|
292
|
+
"registry (revoked, or superseded) — re-run `h2a connect` before enrolling");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Resolve the CURRENT live identity and load its keypair.
|
|
297
|
+
*
|
|
298
|
+
* Why this resolves live, every single time, and takes no instance id:
|
|
299
|
+
* **a memory that names an instance-id ROTS.** That is a documented failure in
|
|
300
|
+
* this project (a stale recorded mapping sent a consultation to the wrong
|
|
301
|
+
* instance), and it is the same failure the identity re-anchor of 2026-06-07
|
|
302
|
+
* created for enrollment: the stability unit moved from `(host, workspaceId)`
|
|
303
|
+
* to the provider conversation UUID, so an agent enrolled before it now
|
|
304
|
+
* presents a DIFFERENT instance handle and a DIFFERENT keypair (Part B,
|
|
305
|
+
* "Re-enrollment of a post-re-anchor key"). The previously enrolled
|
|
306
|
+
* `agentPubKey` is simply no longer produced by any live agent — which is why a
|
|
307
|
+
* push today is rejected 401.
|
|
308
|
+
*
|
|
309
|
+
* So there is deliberately **no `instance` parameter anywhere in this module's
|
|
310
|
+
* public surface**. The id cannot be passed in, therefore a stale one cannot be
|
|
311
|
+
* used. `resolveLiveIdentity` is called without `explicitInstance` (which would
|
|
312
|
+
* short-circuit before any keypair exists — `identity/live.ts`), and the key is
|
|
313
|
+
* then read from the paths THAT resolution returned.
|
|
314
|
+
*
|
|
315
|
+
* WHAT ACTUALLY HAPPENS WHEN THE LOCAL KEY IS UNUSABLE — stated because the
|
|
316
|
+
* obvious reading ("it fails closed") is wrong, and an overstated guard comment
|
|
317
|
+
* is worse than none:
|
|
318
|
+
*
|
|
319
|
+
* - **Corrupt, truncated or passphrase-protected private key** → `provesLocalKey`
|
|
320
|
+
* catches its own failure and returns `false`, so `resolveLiveIdentity` MINTS a
|
|
321
|
+
* fresh identity. The ceremony then succeeds, with exit 0, proving control of a
|
|
322
|
+
* BRAND-NEW key — the corrupt file is left on disk and the old instance stays
|
|
323
|
+
* listed active. It does not fail, and it does not prove the damaged key. The
|
|
324
|
+
* mint is the same self-healing mechanism as the revoked-key case, but here it
|
|
325
|
+
* would MASK TAMPERING, so silence is the defect: the CLI names any unusable
|
|
326
|
+
* key file on stderr before this runs (see {@link listUnusablePrivateKeys}),
|
|
327
|
+
* and `identityAction` reports the mint. Pinned by an OBSERVED BEHAVIOUR test.
|
|
328
|
+
* - **Unreadable private key (EACCES)** → `readKeypair` reads the same file
|
|
329
|
+
* earlier, inside `resolveLiveIdentity` and outside any `try`, so the raw errno
|
|
330
|
+
* escapes from there rather than from the guard below. That is why the whole
|
|
331
|
+
* resolution call is wrapped: the failure is re-thrown with the root named,
|
|
332
|
+
* instead of surfacing as a bare `EACCES` with no indication of which store it
|
|
333
|
+
* came from.
|
|
334
|
+
* - **The two guards below are NARROWING, not behaviour.** The
|
|
335
|
+
* `privateKeyPath === undefined` branch exists because the type is optional
|
|
336
|
+
* (`explicitInstance` is the only resolution path that omits the paths, and
|
|
337
|
+
* this function never passes one), and the `readFileSync` branch is belt and
|
|
338
|
+
* braces behind the earlier read. Neither is reachable on this path; they are
|
|
339
|
+
* not the reason a broken key is safe. {@link assertKeyIsLocallyActive} is in
|
|
340
|
+
* the same position and says so itself.
|
|
341
|
+
*
|
|
342
|
+
* DOCUMENTED LIMIT on what the returned `instance` contains: an instance id is
|
|
343
|
+
* `<host>:<label>:<uuid>` and its label is the host-native session name or the
|
|
344
|
+
* workspace directory's BASENAME (`identity/live.ts` `deriveInstanceId`). So the
|
|
345
|
+
* proof carries a workspace *label*, which Part A's opacity boundary explicitly
|
|
346
|
+
* permits ("never a filesystem path beyond a human label") — but never a path.
|
|
347
|
+
* It is free text the owner controls, so a consumer must escape it like any
|
|
348
|
+
* user content; see joint plan §9.
|
|
349
|
+
*/
|
|
350
|
+
export function resolveEnrollmentIdentity(input) {
|
|
351
|
+
let identity;
|
|
352
|
+
try {
|
|
353
|
+
identity = resolveLiveIdentity({
|
|
354
|
+
root: input.root,
|
|
355
|
+
host: input.host,
|
|
356
|
+
cwd: input.cwd,
|
|
357
|
+
// Display-only list, exactly as `h2a connect` declares it. Never an
|
|
358
|
+
// authorization input (feed contract ratification condition #3).
|
|
359
|
+
declaredCapabilities: H2A_CLI_DECLARED_CAPABILITIES
|
|
360
|
+
// No `explicitInstance`: see the doc comment. A named id is the rot.
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
// `resolveLiveIdentity` reads the keypair internally, outside any try, so an
|
|
365
|
+
// unreadable key surfaces HERE as a bare errno. Name the source rather than
|
|
366
|
+
// letting an `EACCES` escape with no indication of which store produced it.
|
|
367
|
+
throw new Error(`h2a enrollment: live identity resolution failed under root "${input.root}" ` +
|
|
368
|
+
`(${error.message})`);
|
|
369
|
+
}
|
|
370
|
+
// NARROWING, not a fail-closed behaviour: the paths are optional on the type
|
|
371
|
+
// because `explicitInstance` resolution omits them, and this function never
|
|
372
|
+
// passes one. See the doc comment for what actually happens to a broken key.
|
|
373
|
+
if (identity.privateKeyPath === undefined || identity.publicKeyPath === undefined) {
|
|
374
|
+
throw new Error(`h2a enrollment: live identity resolution for "${identity.instance}" returned no keypair ` +
|
|
375
|
+
`(action=${identity.action})`);
|
|
376
|
+
}
|
|
377
|
+
let privateKeyPem;
|
|
378
|
+
let publicKeyPem;
|
|
379
|
+
try {
|
|
380
|
+
privateKeyPem = readFileSync(identity.privateKeyPath, "utf8");
|
|
381
|
+
publicKeyPem = readFileSync(identity.publicKeyPath, "utf8");
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
// Belt and braces behind the read that already happened inside the
|
|
385
|
+
// resolution above; unreachable on this path.
|
|
386
|
+
throw new Error(`h2a enrollment: cannot read the identity keypair of "${identity.instance}" ` +
|
|
387
|
+
`(${error.message})`);
|
|
388
|
+
}
|
|
389
|
+
assertKeyIsLocallyActive({
|
|
390
|
+
instance: identity.instance,
|
|
391
|
+
publicKeyPem,
|
|
392
|
+
activeKeys: createLocalStore({ root: input.root }).listInstanceKeys(identity.instance)
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
instance: identity.instance,
|
|
396
|
+
privateKeyPem,
|
|
397
|
+
publicKeyPem,
|
|
398
|
+
identityAction: identity.action
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Every private key file under `<root>/keys` that exists but cannot be loaded as
|
|
403
|
+
* a private key — corrupt, truncated, or passphrase-protected.
|
|
404
|
+
*
|
|
405
|
+
* This exists because of what does NOT happen when the live key is one of those:
|
|
406
|
+
* live resolution's reclaim proof fails silently and MINTS a fresh identity, so
|
|
407
|
+
* the ceremony succeeds with exit 0 on a brand-new key while a damaged file sits
|
|
408
|
+
* on disk. The mint is defensible; doing it silently is not, because it is
|
|
409
|
+
* indistinguishable from tampering. So the caller can name the file.
|
|
410
|
+
*
|
|
411
|
+
* Read-only and total: it never throws, and a missing `keys` directory yields an
|
|
412
|
+
* empty list from an EXPLICIT branch — "there is no key directory" is an
|
|
413
|
+
* established fact, not a default.
|
|
414
|
+
*/
|
|
415
|
+
export function listUnusablePrivateKeys(root) {
|
|
416
|
+
const keysDir = join(root, "keys");
|
|
417
|
+
if (!existsSync(keysDir))
|
|
418
|
+
return [];
|
|
419
|
+
let entries;
|
|
420
|
+
try {
|
|
421
|
+
entries = readdirSync(keysDir);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// The directory exists but cannot be listed: report nothing rather than
|
|
425
|
+
// claim health. The caller's real check is the ceremony itself.
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
const unusable = [];
|
|
429
|
+
for (const entry of entries) {
|
|
430
|
+
if (!entry.endsWith(".key.pem"))
|
|
431
|
+
continue;
|
|
432
|
+
const path = join(keysDir, entry);
|
|
433
|
+
try {
|
|
434
|
+
createPrivateKey({ key: readFileSync(path, "utf8"), format: "pem" });
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
unusable.push(path);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return unusable.sort();
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* What a challenge document actually CARRIES: its allowlisted OWN properties,
|
|
444
|
+
* copied once into a fresh null-prototype object.
|
|
445
|
+
*
|
|
446
|
+
* WHY OWN-NESS IS ESTABLISHED HERE AND NOT AT EACH CONSUMER — this is the
|
|
447
|
+
* generalisation of a defect that was first fixed one field at a time, and the
|
|
448
|
+
* reason it is now structural.
|
|
449
|
+
*
|
|
450
|
+
* `Object.keys` (the allowlist) cannot see an inherited field, but plain property
|
|
451
|
+
* access (`challenge.expiresAt`) walks the prototype chain. So the allowlist and
|
|
452
|
+
* the consumers can disagree about what the document says. Guarding each consumer
|
|
453
|
+
* with `Object.hasOwn` only fixes the consumers you remembered: the first version
|
|
454
|
+
* of this code remembered `nonce` and forgot `expiresAt`, so an own `nonce` plus an
|
|
455
|
+
* INHERITED `expiresAt` was read by the validator *and* copied in as an own field
|
|
456
|
+
* by the sanitizer. **An allowlist is only as strong as the accessor the consumer
|
|
457
|
+
* uses afterwards** — and a rule applied to one of two allowlisted fields is a
|
|
458
|
+
* habit, not a guarantee.
|
|
459
|
+
*
|
|
460
|
+
* The durable fix is therefore not a second `hasOwn` at the second consumer. It is
|
|
461
|
+
* to make own-ness a property of the OBJECT everything downstream reads. The two
|
|
462
|
+
* reads below are the only prototype-chain-capable reads of a challenge left in
|
|
463
|
+
* this module, each immediately gated by the `Object.hasOwn` above it; after this
|
|
464
|
+
* function returns, its result has no prototype, so no later read — including one
|
|
465
|
+
* a future edit adds — can walk a chain. Validation and consumption cannot diverge
|
|
466
|
+
* again because there is only one object left for them to disagree about.
|
|
467
|
+
*
|
|
468
|
+
* Refuses rather than trims: an unexpected key, or a `nonce` that is not the
|
|
469
|
+
* document's own, throws here. Field VALUES are not checked yet — that is
|
|
470
|
+
* {@link assertCarriedChallengeIsSignable}, which reads only this copy.
|
|
471
|
+
*/
|
|
472
|
+
function carriedChallengeFields(challenge) {
|
|
473
|
+
// ALLOWLIST FIRST, over OWN ENUMERABLE KEYS — `Object.keys`, never `in`.
|
|
474
|
+
//
|
|
475
|
+
// `in` walks the prototype chain, so an allowlist built on it would ask the
|
|
476
|
+
// wrong question twice over: it would miss an own `"__proto__"` key (which
|
|
477
|
+
// `JSON.parse` DEFINES as an own property rather than reassigning the
|
|
478
|
+
// prototype) while being confused by inherited names. `Object.keys` sees
|
|
479
|
+
// exactly what a parsed document actually carries. That makes the
|
|
480
|
+
// `__proto__` case — a prototype-pollution vector on a `JSON.parse` result, not
|
|
481
|
+
// merely a disclosure one — refused rather than ignored.
|
|
482
|
+
//
|
|
483
|
+
// REFUSE-THE-REST MEANS REFUSE, NOT IGNORE. Silently dropping unknown fields
|
|
484
|
+
// would let a document that says something we do not understand be treated as
|
|
485
|
+
// one that says nothing.
|
|
486
|
+
//
|
|
487
|
+
// This runs on the LIBRARY path, not only in the CLI: a TS caller cannot add an
|
|
488
|
+
// excess key to a typed literal, but a parsed document can, and every entry
|
|
489
|
+
// point validates its own input rather than assuming an upstream check.
|
|
490
|
+
const unexpected = Object.keys(challenge).filter((key) => !H2A_ENROLLMENT_CHALLENGE_KEYS.includes(key));
|
|
491
|
+
if (unexpected.length > 0) {
|
|
492
|
+
const named = unexpected.map((key) => `"${key}"`).join(", ");
|
|
493
|
+
throw new Error(`h2a enrollment: challenge carries unexpected field(s) ${named} — only ` +
|
|
494
|
+
`${H2A_ENROLLMENT_CHALLENGE_KEYS.join(" and ")} are accepted. The agent signs a nonce; ` +
|
|
495
|
+
"any other field is refused so that a principal identifier (or anything else) cannot ride " +
|
|
496
|
+
"in nested inside one, which is what a top-level principalSub check alone would miss" +
|
|
497
|
+
(unexpected.includes("principalSub")
|
|
498
|
+
? ". principalSub in particular MUST NOT be sent to the agent: the gateway already knows " +
|
|
499
|
+
"which session it issued the nonce to"
|
|
500
|
+
: ""));
|
|
501
|
+
}
|
|
502
|
+
// An inherited nonce carries nothing of its own: it sails through the allowlist
|
|
503
|
+
// (`Object.keys` is empty) and would then be signed anyway. Refused by name
|
|
504
|
+
// here, before the copy, so the message says what is actually wrong rather than
|
|
505
|
+
// surfacing later as "no nonce" on a copy that never received one.
|
|
506
|
+
if (!Object.hasOwn(challenge, "nonce")) {
|
|
507
|
+
throw new Error("h2a enrollment: the challenge carries no nonce of its own — an inherited value is not a " +
|
|
508
|
+
"carried field, and only carried fields are signed");
|
|
509
|
+
}
|
|
510
|
+
const carried = Object.create(null);
|
|
511
|
+
carried.nonce = challenge.nonce;
|
|
512
|
+
// THE SAME QUESTION, asked of the other allowlisted field — this is the field the
|
|
513
|
+
// one-at-a-time fix missed. An inherited `expiresAt` is not carried, so it is not
|
|
514
|
+
// copied, so nothing downstream can see it. The extra `!== undefined` keeps an
|
|
515
|
+
// own-but-undefined `expiresAt` from becoming a key, preserving the "absent
|
|
516
|
+
// optional stays absent" property of the sanitized object.
|
|
517
|
+
//
|
|
518
|
+
// THE FRAMING THIS BEHAVIOUR MUST BE READ IN. Two inputs now pass that once threw:
|
|
519
|
+
// an inherited PAST `expiresAt` (once refused as expired) and an inherited
|
|
520
|
+
// non-string `expiresAt` (once refused as not an instant). This is the line a
|
|
521
|
+
// future auditor will try to "restore", so the correct description is recorded at
|
|
522
|
+
// the field rather than left to be rediscovered. It is NOT *"used to refuse, now
|
|
523
|
+
// accepts"*. It is: **an inherited field is not carried, therefore not present; the
|
|
524
|
+
// behaviour follows from the carriage rule and is not a special case for
|
|
525
|
+
// `expiresAt`.** Nothing was relaxed about expiry — the set of fields that exist
|
|
526
|
+
// got smaller, and every rule about expiry still applies in full to every expiry
|
|
527
|
+
// that is actually there.
|
|
528
|
+
//
|
|
529
|
+
// THE CONSEQUENCE OF RESTORING THE REFUSAL, stated explicitly because it is the
|
|
530
|
+
// whole cost: **restoring the refusal means reintroducing a second reader.** To
|
|
531
|
+
// refuse an inherited value you must first look at it, and looking at it is a
|
|
532
|
+
// prototype-chain read of the caller's object — precisely the divergence between
|
|
533
|
+
// the allowlist and its consumers that this function exists to remove. You cannot
|
|
534
|
+
// say *"I never read inherited fields, except to reject them"* without being two
|
|
535
|
+
// readers again. The refusal and the guarantee cannot both be had; this module
|
|
536
|
+
// keeps the guarantee.
|
|
537
|
+
//
|
|
538
|
+
// THE SECURITY CHECK, on the record because this flip is TOWARD ACCEPTANCE and
|
|
539
|
+
// that direction earns one. The agent-side expiry check is ADVISORY by the
|
|
540
|
+
// architect's own ruling, and the GATEWAY REMAINS THE TTL AUTHORITY (Part B flow
|
|
541
|
+
// step 5a). An attacker who suppresses the advisory check by hanging `expiresAt`
|
|
542
|
+
// on a prototype must ALREADY CONTROL the challenge object, and the authoritative
|
|
543
|
+
// check is server-side and wholly unaffected by anything reachable from here. So
|
|
544
|
+
// the trade is: one bypassable defence-in-depth layer, against a party who already
|
|
545
|
+
// owns the input, in exchange for eliminating an entire defect class — validator
|
|
546
|
+
// and consumer disagreeing about what a document says.
|
|
547
|
+
//
|
|
548
|
+
// THE ASYMMETRY WITH `nonce` IS PRINCIPLED, NOT INCONSISTENT. An inherited `nonce`
|
|
549
|
+
// throws just above; an inherited `expiresAt` is ignored here. Both fields collapse
|
|
550
|
+
// *inherited* to *absent* — that is one rule, applied identically. The only thing
|
|
551
|
+
// that differs is REQUIREDNESS, never the rule: `nonce` is required, so absence is
|
|
552
|
+
// an error however it arose; `expiresAt` is optional, so absence is legal. Read the
|
|
553
|
+
// two branches as a single carriage rule meeting two different obligations.
|
|
554
|
+
if (Object.hasOwn(challenge, "expiresAt") && challenge.expiresAt !== undefined) {
|
|
555
|
+
carried.expiresAt = challenge.expiresAt;
|
|
556
|
+
}
|
|
557
|
+
return carried;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Reject a challenge we must not sign, reading ONLY the carried copy. Narrowing
|
|
561
|
+
* only — this can refuse a signature, never authorize one.
|
|
562
|
+
*
|
|
563
|
+
* The parameter is deliberately {@link CarriedChallengeFields} and not
|
|
564
|
+
* `H2AEnrollmentChallenge`: every read below is then own-by-construction, and the
|
|
565
|
+
* validator has no access to the caller's object to accidentally read through.
|
|
566
|
+
*/
|
|
567
|
+
function assertCarriedChallengeIsSignable(carried, nowMs) {
|
|
568
|
+
if (carried.expiresAt !== undefined && typeof carried.expiresAt !== "string") {
|
|
569
|
+
throw new Error("h2a enrollment: challenge expiresAt must be an ISO-8601 STRING — a non-string cannot be an " +
|
|
570
|
+
"instant, and would be a place for structure to hide");
|
|
571
|
+
}
|
|
572
|
+
if (typeof carried.nonce !== "string" || carried.nonce.length === 0) {
|
|
573
|
+
throw new Error("h2a enrollment: the challenge carries no nonce — nothing to prove key control over");
|
|
574
|
+
}
|
|
575
|
+
// Cheap pre-parse bound first, so a hostile blob is dropped before any regex
|
|
576
|
+
// walks it. This is a DoS cap, NOT the nonce's definition.
|
|
577
|
+
if (carried.nonce.length > H2A_ENROLLMENT_MAX_NONCE_LENGTH) {
|
|
578
|
+
throw new Error(`h2a enrollment: challenge nonce is ${carried.nonce.length} chars, over the ` +
|
|
579
|
+
`${H2A_ENROLLMENT_MAX_NONCE_LENGTH}-char pre-parse cap — refusing to read an unbounded blob`);
|
|
580
|
+
}
|
|
581
|
+
// The definition, stated positively: base64url of at least 256 bits. Free
|
|
582
|
+
// text, JSON, a URL, or a message borrowed from another protocol all fail here
|
|
583
|
+
// because they are not the specified shape — not because they were enumerated.
|
|
584
|
+
if (!H2A_ENROLLMENT_NONCE_PATTERN.test(carried.nonce)) {
|
|
585
|
+
throw new Error("h2a enrollment: challenge nonce is not base64url ([A-Za-z0-9_-]) — a nonce is a random " +
|
|
586
|
+
"value of a specified shape, and anything else is refused rather than signed");
|
|
587
|
+
}
|
|
588
|
+
// FLOOR — the only bound here that speaks about strength.
|
|
589
|
+
if (carried.nonce.length < H2A_ENROLLMENT_NONCE_MIN_LENGTH) {
|
|
590
|
+
throw new Error(`h2a enrollment: challenge nonce is ${carried.nonce.length} base64url chars, under the ` +
|
|
591
|
+
`${H2A_ENROLLMENT_NONCE_MIN_LENGTH} needed for ${H2A_ENROLLMENT_NONCE_MIN_BITS} bits — ` +
|
|
592
|
+
"a guessable challenge is one an attacker can pre-compute a proof for");
|
|
593
|
+
}
|
|
594
|
+
// CEILING — a SANITY bound, not a security one. It says "beyond this it is not
|
|
595
|
+
// a nonce"; it says nothing about how much entropy is enough.
|
|
596
|
+
if (carried.nonce.length > H2A_ENROLLMENT_NONCE_MAX_LENGTH) {
|
|
597
|
+
throw new Error(`h2a enrollment: challenge nonce is ${carried.nonce.length} base64url chars, over the ` +
|
|
598
|
+
`${H2A_ENROLLMENT_NONCE_MAX_LENGTH}-char sanity ceiling (${H2A_ENROLLMENT_NONCE_MAX_BITS} ` +
|
|
599
|
+
"bits) — beyond this it is not a nonce. This is NOT a statement that less entropy is enough");
|
|
600
|
+
}
|
|
601
|
+
if (carried.expiresAt !== undefined) {
|
|
602
|
+
const expiresAtMs = Date.parse(carried.expiresAt);
|
|
603
|
+
if (Number.isNaN(expiresAtMs)) {
|
|
604
|
+
throw new Error(`h2a enrollment: challenge expiresAt "${carried.expiresAt}" is not an ISO-8601 instant`);
|
|
605
|
+
}
|
|
606
|
+
if (expiresAtMs <= nowMs) {
|
|
607
|
+
throw new Error(`h2a enrollment: challenge expired at ${carried.expiresAt} — ask the gateway for a new one`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Reject a challenge we must not sign. Narrowing only — this can refuse a
|
|
613
|
+
* signature, never authorize one. Throws with a message naming the reason.
|
|
614
|
+
*
|
|
615
|
+
* Exported so a caller that classifies errors (the CLI, which owes a distinct
|
|
616
|
+
* exit code for "your challenge is bad" vs "my local key state is bad") can
|
|
617
|
+
* check the input it was handed BEFORE touching any identity.
|
|
618
|
+
* {@link signEnrollmentChallenge} validates again regardless: each entry point
|
|
619
|
+
* validates its own input, and an upstream check is never assumed.
|
|
620
|
+
*
|
|
621
|
+
* IMPLEMENTED AS the sanitize path with the result discarded, deliberately. The
|
|
622
|
+
* validator and the sanitizer used to be two independent readers of the caller's
|
|
623
|
+
* object, which is precisely how `expiresAt` came to be validated *through the
|
|
624
|
+
* prototype chain* while the allowlist looked only at own keys. One reader means
|
|
625
|
+
* they cannot disagree.
|
|
626
|
+
*/
|
|
627
|
+
export function assertSignableEnrollmentChallenge(challenge, nowMs) {
|
|
628
|
+
sanitizeEnrollmentChallenge(challenge, nowMs);
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Validate a parsed challenge document and return a FRESH, NULL-PROTOTYPE object
|
|
632
|
+
* carrying only the allowlisted keys.
|
|
633
|
+
*
|
|
634
|
+
* Three distinct jobs, all load-bearing:
|
|
635
|
+
*
|
|
636
|
+
* 1. It takes the OWN-ONLY view first ({@link carriedChallengeFields}), so what is
|
|
637
|
+
* validated and what is returned are the same object. An inherited field is
|
|
638
|
+
* invisible to both.
|
|
639
|
+
* 2. It refuses ({@link assertCarriedChallengeIsSignable}). Refuse-the-rest means
|
|
640
|
+
* refuse, not ignore.
|
|
641
|
+
* 3. What flows onward is a **new object with `null` prototype**, so the
|
|
642
|
+
* `JSON.parse` result — which may carry an own `"__proto__"` key, a
|
|
643
|
+
* prototype-pollution vector the moment anything spreads or assigns it into
|
|
644
|
+
* another object — never propagates past this boundary. Even though such a
|
|
645
|
+
* document is already refused above, nothing downstream has to depend on that
|
|
646
|
+
* having happened.
|
|
647
|
+
*
|
|
648
|
+
* Order matters and is the point: sanitize-then-validate, never validate-then-copy.
|
|
649
|
+
* The copy is what removes the second accessor, so validating the caller's object
|
|
650
|
+
* first would put the bug back.
|
|
651
|
+
*
|
|
652
|
+
* Use this at any boundary where the challenge came from parsed input. The CLI
|
|
653
|
+
* does, and so does {@link signEnrollmentChallenge}.
|
|
654
|
+
*/
|
|
655
|
+
export function sanitizeEnrollmentChallenge(challenge, nowMs) {
|
|
656
|
+
const carried = carriedChallengeFields(challenge);
|
|
657
|
+
assertCarriedChallengeIsSignable(carried, nowMs);
|
|
658
|
+
return carried;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Sign a gateway-issued challenge with the agent's identity key and return the
|
|
662
|
+
* Part B proof payload. PURE apart from the injected clock: no I/O, no network,
|
|
663
|
+
* no store.
|
|
664
|
+
*
|
|
665
|
+
* The signed message is the CANONICAL COMPOSITE
|
|
666
|
+
* {@link enrollmentProofSignedPayload} — `{ type, nonce, instance, publicKeyPem }`
|
|
667
|
+
* — per the signed-composite amendment. The earlier shape signed the bare nonce
|
|
668
|
+
* (Part B flow steps 3 and 5b as originally written), which was safe only
|
|
669
|
+
* because nothing yet consumed `instance`: safety derived from the *absence* of a
|
|
670
|
+
* consumer, which expires the moment someone adds one.
|
|
671
|
+
*
|
|
672
|
+
* The rule, in its amended form: **a proof must attest to everything it carries
|
|
673
|
+
* AND to what it is.** Content-completeness plus context-binding — the first
|
|
674
|
+
* without the second leaves the interpretation attacker-chosen.
|
|
675
|
+
*
|
|
676
|
+
* A structural consequence worth knowing, because it removes a whole finding
|
|
677
|
+
* rather than guarding against it: the reclaim proof-of-possession signs a
|
|
678
|
+
* STRING (`identity-reclaim:<instance>:<fingerprint>`), and this signs an
|
|
679
|
+
* OBJECT. `canonicalize` type-tags the two differently, so no enrollment
|
|
680
|
+
* signature can ever satisfy `verifyReclaimProof` — the signing-oracle collision
|
|
681
|
+
* is impossible by construction, not by refusal. There is deliberately no guard
|
|
682
|
+
* against it: a guard that cannot fire is the defect this PR spent its time
|
|
683
|
+
* finding. The property is pinned by a regression test instead.
|
|
684
|
+
*
|
|
685
|
+
* Note that that separation was ACCIDENTAL — it held because the two payload
|
|
686
|
+
* types differ, not because either said what it was. The signed `type` field is
|
|
687
|
+
* what makes it deliberate, and it generalizes: no h2a signing site can collide
|
|
688
|
+
* with this one now, whatever shape it later adopts.
|
|
689
|
+
*
|
|
690
|
+
* Before returning, the proof is VERIFIED against the public key it ships. A
|
|
691
|
+
* proof we cannot verify ourselves is never emitted: that is what catches a
|
|
692
|
+
* mismatched keypair locally instead of at the gateway, where the failure is
|
|
693
|
+
* indistinguishable from an attack.
|
|
694
|
+
*/
|
|
695
|
+
export function signEnrollmentChallenge(input) {
|
|
696
|
+
const now = input.now ?? Date.now;
|
|
697
|
+
// Validate and take the own-only view in ONE step, then sign what THAT view says.
|
|
698
|
+
// Reading `input.challenge.nonce` below instead would be another accessor on the
|
|
699
|
+
// caller's object — own today only because the validator happens to have rejected
|
|
700
|
+
// an inherited nonce first, i.e. correct by ordering rather than by construction.
|
|
701
|
+
// The signed nonce comes off the sanitized copy so the ordering cannot matter.
|
|
702
|
+
const challenge = sanitizeEnrollmentChallenge(input.challenge, now());
|
|
703
|
+
const { instance, privateKeyPem, publicKeyPem } = input.identity;
|
|
704
|
+
// Build the unsigned proof first, then sign the payload DERIVED from it, so the
|
|
705
|
+
// signed bytes and the shipped fields cannot drift apart.
|
|
706
|
+
//
|
|
707
|
+
// `type` sits INSIDE this object — the spread SOURCE — so it is covered by the
|
|
708
|
+
// very same mechanism as every other field. A tag added after the spread, or
|
|
709
|
+
// carried on the proof but excluded from it, would be an UNSIGNED field
|
|
710
|
+
// asserting the message's own identity: the worst possible field to leave
|
|
711
|
+
// unsigned, and worse than having no tag at all.
|
|
712
|
+
const unsigned = {
|
|
713
|
+
type: H2A_ENROLLMENT_PROOF_TYPE,
|
|
714
|
+
nonce: challenge.nonce,
|
|
715
|
+
instance,
|
|
716
|
+
publicKeyPem
|
|
717
|
+
};
|
|
718
|
+
const signature = signCanonical(enrollmentProofSignedPayload(unsigned), {
|
|
719
|
+
by: instance,
|
|
720
|
+
privateKeyPem
|
|
721
|
+
});
|
|
722
|
+
const proof = { ...unsigned, signature };
|
|
723
|
+
if (!verifyEnrollmentProof(proof)) {
|
|
724
|
+
throw new Error(`h2a enrollment: the proof for "${instance}" does not verify against its own public key ` +
|
|
725
|
+
"(private/public key mismatch) — refusing to emit an unverifiable proof");
|
|
726
|
+
}
|
|
727
|
+
return proof;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Resolve the live identity, sign the challenge, return the proof.
|
|
731
|
+
*
|
|
732
|
+
* SYNCHRONOUS and network-free by construction: there is no transport in this
|
|
733
|
+
* function at all, so the CLI path (`h2a keys prove-control`) cannot reach one.
|
|
734
|
+
* Re-enrollment needs no separate entry point — this IS the re-enrollment path,
|
|
735
|
+
* because it resolves the current identity every time and can therefore only
|
|
736
|
+
* ever produce a proof for the key that is live now. A changed controlling key
|
|
737
|
+
* means the owner re-runs this and the gateway mints a NEW binding, revoking the
|
|
738
|
+
* old row (Part B, "Re-enrollment of a post-re-anchor key"); nothing here
|
|
739
|
+
* reuses or rotates anything.
|
|
740
|
+
*/
|
|
741
|
+
export function buildEnrollmentProof(options) {
|
|
742
|
+
const resolveIdentity = options.resolveIdentityImpl ?? resolveEnrollmentIdentity;
|
|
743
|
+
const identity = resolveIdentity({
|
|
744
|
+
root: options.root,
|
|
745
|
+
host: options.host,
|
|
746
|
+
cwd: options.cwd
|
|
747
|
+
});
|
|
748
|
+
const proof = signEnrollmentChallenge({
|
|
749
|
+
challenge: options.challenge,
|
|
750
|
+
identity,
|
|
751
|
+
...(options.now !== undefined ? { now: options.now } : {})
|
|
752
|
+
});
|
|
753
|
+
return {
|
|
754
|
+
proof,
|
|
755
|
+
instance: proof.instance,
|
|
756
|
+
publicKeyFingerprint: publicKeyFingerprint(proof.publicKeyPem),
|
|
757
|
+
...(identity.identityAction !== undefined
|
|
758
|
+
? { identityAction: identity.identityAction }
|
|
759
|
+
: {})
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Run the ceremony end to end: resolve the live identity, sign the challenge,
|
|
764
|
+
* and — only if a transport was injected — hand the proof over.
|
|
765
|
+
*
|
|
766
|
+
* With no `submitImpl` this makes NO network call of any kind. That is not a
|
|
767
|
+
* default-off flag; there is no hosted endpoint in this file to turn on.
|
|
768
|
+
*/
|
|
769
|
+
export async function runEnrollmentCeremony(options) {
|
|
770
|
+
const built = buildEnrollmentProof(options);
|
|
771
|
+
if (options.submitImpl === undefined) {
|
|
772
|
+
return { ...built, submission: { attempted: false, reason: "no-transport-configured" } };
|
|
773
|
+
}
|
|
774
|
+
const response = await options.submitImpl(built.proof);
|
|
775
|
+
return { ...built, submission: { attempted: true, response } };
|
|
776
|
+
}
|
|
777
|
+
//# sourceMappingURL=ceremony.js.map
|