@a2a-exposed/encryption 0.0.0-stage → 0.8.56

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.
@@ -0,0 +1,31 @@
1
+ # Foundation dependency review
2
+
3
+ Snapshot: 2026-11-12. The lockfile pins the complete resolved graph and registry
4
+ integrity hashes. Direct runtime dependencies are hpke-js `@hpke/core` 1.10.1,
5
+ `@hpke/dhkem-x25519` 1.8.1 and `@hpke/chacha20poly1305` 1.8.1. Their repository
6
+ is https://github.com/dajiaji/hpke-js and declared license is MIT. Transitive
7
+ `@hpke/common` is pinned in package-lock.json. No install scripts are needed.
8
+
9
+ The published [nonce-reuse advisory](https://github.com/dajiaji/hpke-js/security/advisories/GHSA-73g8-5h73-26h4)
10
+ affects core <=1.7.4 and is patched in 1.7.5; this package pins 1.10.1 and uses
11
+ one fresh context and one Seal per encrypted copy. `npm audit` reported zero
12
+ known advisories for this lockfile at review time. Neither a clean advisory
13
+ scan nor successful vectors constitutes an independent cryptographic audit.
14
+ No formal audit is asserted for this library or application construction.
15
+
16
+ Tests include the public RFC 9180 X25519/HKDF-SHA256/ChaCha20Poly1305 base
17
+ vector and local malformed/tampered input cases. Manual interop uses PyHPKE
18
+ 0.6.5 over cryptography 50.0.2 to verify the envelope signature, open each copy
19
+ and check its commitment; these are test dependencies only, not package runtime
20
+ dependencies. They do not prove complete transport/trust/history integration.
21
+
22
+ Reproduce manual interop in an isolated Python environment with those versions:
23
+
24
+ ```sh
25
+ node test/cross-language.mjs /private/new-disposable-fixture.json
26
+ python test/cross-language.py /private/new-disposable-fixture.json
27
+ ```
28
+
29
+ The generator refuses existing files and writes disposable keys mode 600,
30
+ without printing them. Remove that generated fixture after the check; retain
31
+ only its result/provenance and these reproducible scripts. Never use real keys.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Telegraphic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,263 @@
1
+ # Local custody/trust API — implementation checkpoint
2
+
3
+ The local-state and delivery-ledger APIs support custody, retained receiving-key
4
+ rotation and local replay/outbox operations. CLI enrollment, encrypted transport
5
+ and the complete CLI/stdio MCP flow remain pending. Privileged caller attestations
6
+ require a reviewed host integration before enabling traffic.
7
+
8
+ `stores()` lists explicit choices without creating credentials. `LocalState`
9
+ opens an existing database read-only by default; `create:true` initializes a new
10
+ owner-only encryption directory and database, while `write:true` is needed for
11
+ existing-state actions. File keys use exclusive mode-600 writes in a 0700 folder.
12
+ Status exposes only public bundles/store/provenance. `loadIdentity` is a privileged
13
+ in-process API, not tool output; it self-checks private/public correspondence
14
+ without network and refuses live traffic use. Lost files never trigger rotation.
15
+
16
+ The optional pinned `@napi-rs/keyring` 2.1.2 adapter is isolated in a bounded child
17
+ process; secrets use stdin/stdout pipes inside the local process, not command
18
+ arguments, environment variables or transcript output. Linux explicitly selects
19
+ Secret Service, never the reboot-volatile kernel fallback. No OS credential entry
20
+ is accessed unless `allowOsConsent:true` is explicitly supplied by the owner-facing
21
+ caller. Native operations may need the owner's OS authorization; file storage is
22
+ the unattended alternative. There is no silent fallback. Native-provider errors
23
+ are fixed local codes; a timed-out operation may require status/recovery review,
24
+ not an assertion that a key was deleted. Windows file protection uses an explicit
25
+ current-user ACL and fails if it cannot establish/check it. Native platform
26
+ operations and Windows ACL behavior have not been exercised on real accounts.
27
+
28
+ Trust proposals bind config/local identities and key bundle, exact endpoint,
29
+ pairing identity, full old record/version, new bundle/policy/verification decision,
30
+ operation and nonce. Approving uses exact compare-and-swap; older approvals fail.
31
+ Prior approved records are retained separately for future archive opening.
32
+ New/pending proposals hold the live state, and losing a key does not change pins.
33
+ `tofu-pinned` is local trust, not evidence a remote token pairing exists; pairing
34
+ is explicitly not checked by this API. Independent verification requires the
35
+ owner's separate-channel decision. The safety code is computed locally.
36
+
37
+ The `ownerApproved`, `independentVerified` and `rePairVerified` arguments are
38
+ privileged caller attestations, not proofs. A CLI/MCP integration must obtain
39
+ human approval of the exact proposal and a separately verified pairing outcome;
40
+ it must never populate these flags from peer messages, hosted assertions or
41
+ untrusted API input. No network route exposes these methods. This checkpoint
42
+ is not approval to use that incomplete integration with real credentials.
43
+
44
+ Current tests use disposable private directories and synthetic in-memory keys.
45
+ They cover restart/own-copy opening, read-only discovery, no private keys in
46
+ status/SQLite, file modes/symlinks/loss, explicit stores/consent, exact approval,
47
+ independent/re-pair holds, concurrent proposals and retained trust history.
48
+ No native keychain entries, server credentials, pairing grants or live resources
49
+ were touched. Public protocol/privacy acceptance remains a later gate.
50
+
51
+ Initialization commits a public recovery intent before the first external write.
52
+ An uncertain outcome blocks new initialization until an explicit exact-digest
53
+ reconciliation; an absent observation retains the old intent for late completion.
54
+ Adoption self-checks the original credential and never replaces an active identity.
55
+ The native broker PID is committed before private pipe input, and reconciliation
56
+ refuses a still-running broker. Timeout handling waits for exit/close, escalates
57
+ termination, and reports an unreaped process as unknown rather than success.
58
+
59
+ Safety-code inspection accepts the exact pending proposal digest before approval;
60
+ without it a pending change refuses to show the old code. Independent approval
61
+ also binds the compared proposal digest. Identity replacement requires both
62
+ separately approved re-pairing and independent re-verification. Literal boolean
63
+ OS consent is checked at both custody and broker boundaries. Schema version,
64
+ structure and integrity are checked without migration or replacement. Public
65
+ status/proposal/history listings have explicit bounded continuation inputs.
66
+
67
+ The isolated backup byte-format primitive uses bounded scrypt and authenticated
68
+ AES-GCM. The local store snapshot API described below uses it; it never enables restored
69
+ live keys. Cross-process tests cover racing initialization and process death after a
70
+ mock credential write; native lifecycle tests use simulated child processes only.
71
+
72
+ Recovery approval is action-specific. Each operation listing includes a monotonic
73
+ revision and `recovery_choices` with the approval digest for adopt or
74
+ abandon-if-absent. Pass the exact listed `approvalDigest` alongside the immutable
75
+ operation digest and explicit human approval. The digest binds configuration,
76
+ operation, state, revision and action; it is invalid after any transition, even
77
+ when the state name is unchanged. After provider reads and key self-checks, the
78
+ final SQLite transaction compares the original state/revision/digest before
79
+ changing it. A stale adoption rolls back any identity insertion. List and obtain
80
+ a new approval rather than reusing an earlier one. The unreleased schema is
81
+ version 6; earlier draft stores are preserved and refused, never auto-migrated.
82
+
83
+
84
+ ## Protected backup and archive-only restore API
85
+
86
+ `state.createBackup({destination,passphrase,allowOsConsent})` takes a separate
87
+ consistent SQLite read transaction, validates bounded static-table rows and all
88
+ private/public key pairs, encrypts them, then writes a new exclusive protected
89
+ file. Passphrase is an in-process Uint8Array from a protected source, never a
90
+ command argument or tool-visible field. Credentials stay out of public output.
91
+ Known lost keys and unresolved credential writes refuse backup. An explicitly
92
+ observed-absent credential remains catalogued and counted as absent; the backup
93
+ cannot recover a credential written after that observation/snapshot.
94
+
95
+ `restoreBackup({source,destination,passphrase,store:"file",
96
+ acknowledgeReplayRollback:true})` requires a new destination, explicit file-store
97
+ selection and replay-rollback acknowledgment. Native restores are unsupported;
98
+ there is no silent store fallback. Validate every decrypted row and available
99
+ key before creating a target directory. Import uses a private staging directory
100
+ with a durably recorded archive-only hold before private writes, then atomically
101
+ installs the canonical encryption directory. A failed/interrupted stage remains
102
+ protected for inspection; never overwrite it on retry. Existing targets are
103
+ never merged. The encrypted source backup remains the recovery source.
104
+
105
+ The restored config gets a fresh config UUID and records its source UUID and
106
+ public operation provenance hash. Private keys, historical pins, proposals,
107
+ replay rows and pending delivery IDs are retained; original provider credentials
108
+ are converted to explicitly selected protected files. The global restore hold
109
+ blocks key initialization, recovery actions and trust writes; identity rows and
110
+ inspection show archive-only, and live-use loads remain refused. No automatic
111
+ resend or delivery happens. A future separate fresh receiving identity and peer
112
+ re-verification flow is required before live enrollment; it is not implemented.
113
+ This is a local API checkpoint, with no working backup/restore CLI command yet.
114
+
115
+ ## Local receiving-key rotation
116
+
117
+ `rotationPlan(id)` lists the exact current key/store and an action-specific
118
+ approval digest bound to config, previous bundle and latest operation revision.
119
+ `rotateLocal({id,approvalDigest,ownerApproved:true,allowOsConsent})` retains the
120
+ Ed25519 identity and old X25519 keys, generates a fresh receiving key, and follows
121
+ the same durable intent/write/commit/reconciliation rules as initialization.
122
+ Concurrent plans produce only one successor. A failed write never retires the
123
+ old current key; uncertain writes hold further rotation until exact recovery.
124
+ Retired rows cannot authorize new trust proposals/approvals or safety-code claims.
125
+ Existing peer bindings stay held after rotation until a new independently
126
+ verified local-key proposal is approved; rotation grants no remote pairing.
127
+ Backups include both old and new private keys, and restores retain them under
128
+ the archive-only hold. There is no key deletion API or live key advertisement.
129
+
130
+ Backup preflight checks the passphrase and destination existence before any
131
+ credential read; a racing exclusive-create failure is backup_destination_exists.
132
+ Restore syncs the SQLite file and staging directory, then the promoted directory
133
+ and its parents where directory fsync is supported. Results distinguish
134
+ file-and-directory-sync-completed from directory-sync-unverified. These record
135
+ completed system calls, not proof of hardware power-loss durability. Unsupported
136
+ platform directory sync remains unverified. A sync/I/O failure after promotion
137
+ returns restore_installed_readback_failed and retains the archive-only target;
138
+ it must not suggest an automatic overwrite/retry. Native/Windows durability and
139
+ real power-loss behavior are untested.
140
+
141
+ ## Durable initial-request receiver API
142
+
143
+ The unreleased local-state schema is version 6 for SDK host-grant staging,
144
+ assignment/provenance and outbound/chain tables; earlier drafts remain preserved/refused, not migrated.
145
+ `DeliveryLedger` is a privileged local API with no HTTP/model callback. Its
146
+ pairingVerified/pairingIdentity/activation arguments are trusted local-transport
147
+ attestations, never peer-controlled fields or permission grants. The real client
148
+ adapter must derive them from existing human-authorized pairing and negotiated
149
+ transport; that adapter/traffic path is not implemented here.
150
+
151
+ `receiveInitial` verifies public signature/bindings before credential access,
152
+ opens the request and prepares its signed assignment, then rechecks the exact
153
+ current pin and atomically commits replay, assignment, ciphertext delivery and
154
+ provenance. No computed receipt escapes a failed commit. Namespace uses the
155
+ stable local signing identity plus peer identity/message ID, surviving X25519
156
+ rotation. Exact duplicates reuse the original receipt/ackId/deliveryId; changed
157
+ context/digest/task or conflicting task assignment refuses. A stable local/peer/task ID binds exactly one initial request/context, enforced both by a transaction check and database uniqueness across contexts. New pending trust or
158
+ revocation during verification prevents commit. SQLite contains ciphertext and
159
+ public provenance, not opened content.
160
+
161
+ Pending receipt publication and model consumption are distinct. Cached receipt
162
+ preparation requires current pairing/trust and never re-signs a new assignment.
163
+ Transport acceptance does not claim peer receipt. Delivery reads are explicit,
164
+ untrusted and at-least-once with a stable ID; only explicit consumed acknowledgment
165
+ closes the local outbox. No exactly-once external-effect guarantee is made.
166
+ Revoked/pending/rotated trust holds live reads and publication, while historical
167
+ rows remain. Restored archive-only state lists those rows but cannot create,
168
+ consume or acknowledge live work. Backups retain new ledger rows as encrypted
169
+ archive state; full semantic/interop restore audit is still an acceptance gate.
170
+ ## Local sender, response and chain API
171
+
172
+ These methods remain isolated SDK algorithms; no CLI/Worker transport is enabled.
173
+ `prepareRequest` commits a self-opened encrypted request before returning its ID;
174
+ `beginTransmission` commits transmitting before releasing the cached ciphertext
175
+ once. A crash or timeout leaves unknown delivery, never permission for automatic
176
+ re-send/new-ID/plaintext fallback. `recordTransport` retains provisional transport
177
+ IDs separately; only a verified immutable peer assignment grants task authority.
178
+ `acceptAssignment` pins that assignment and can hold a co-carried later snapshot
179
+ while complete signed history is obtained; `acceptResponse` requires a previously
180
+ pinned assignment and an inner digest of the exact locally prepared request.
181
+ Acceptance and readback share an answered-request validator: the cached row must
182
+ have user-request provenance (null reference_digest), an attempted state, exact
183
+ current trust/key and digest, and a cached role=user/kind=message envelope whose
184
+ IDs and sender/recipient match. An outgoing agent response is never a request
185
+ being answered, even when both task directions share context/task strings.
186
+ The local adapter must validate actual Task wrappers before attesting their IDs.
187
+
188
+ `receiveFollowup` requires the existing inbound assignment, exact context/task and
189
+ protocol. It queues ciphertext/provenance/replay atomically; duplicate packets keep
190
+ the same local delivery ID. `prepareResponse` binds the exact accepted request
191
+ and commits its ciphertext before transmission. Full snapshots start at revision
192
+ zero, then bind the previous signed envelope digest. Snapshot head and outgoing
193
+ ciphertext share a transaction, and concurrent creators compare the original
194
+ head. Accepted snapshots must be consecutive; gaps refuse as provenance_incomplete,
195
+ forks/terminal resurrection refuse, and older accepted snapshots read history-only.
196
+ Terminal heads reject new follow-ups. These checks do not establish real adapter,
197
+ Task-history ordering, multi-process sender, semantic restore or live traffic
198
+ acceptance; those remain separate gates.
199
+
200
+
201
+ ## Local Task reconstruction
202
+
203
+ `acceptTask` accepts an exact raw Task for an explicitly selected cached initial
204
+ request. It validates any receipt and all public signatures before loading keys,
205
+ opens agent events, and orders snapshots by commitment-checked revisions.
206
+ One transaction binds the immutable assignment and queues the entire verified
207
+ batch with replay/provenance/chain state. An incomplete or forked chain, changed
208
+ trust or failed commit leaves no partial new assignment/delivery/head. Cached
209
+ history user messages must be actual attempted local user requests; response-to-
210
+ response correlation remains refused. Omitted Task artifacts are empty; present
211
+ artifacts must still be an empty array. Routing-only cancellation cannot replace
212
+ currentPeerState, which is derived from signed local chain state. The result
213
+ returns IDs, not plaintext or model consumption. No actual network adapter calls
214
+ this isolated API yet; archive/native/traffic/release gates remain.
215
+
216
+
217
+ Task import does not place ordinary agent messages on the snapshot chain. A new
218
+ unsequenced message is held with task_terminal if the known head or incoming
219
+ verified batch is terminal, regardless of server array order or poll grouping.
220
+ The whole rejected batch rolls back, including any new assignment. Previously
221
+ accepted duplicate IDs remain stable; this does not infer their chronology from
222
+ sentAt. Terminal content belongs inside a signed full snapshot. Separate archive
223
+ opening remains unavailable and cannot authorize live delivery.
224
+
225
+ A locally retained, exact current-pin assignment remains valid for fresh
226
+ follow-ups after the initial envelope's 30-day admission window. The closed
227
+ retained-cache path checks request bytes/digest/purpose/provenance and both request
228
+ and receipt signatures against current bundles; a repeated receipt must equal
229
+ the immutable saved one. It does not backdate now or expose an archive-mode flag.
230
+ First unassigned requests/receipts still use live admission freshness, and every
231
+ incoming agent envelope still passes the current-time live freshness checks.
232
+
233
+
234
+ Transport proposals may include the exact peerEndpoint independently of the key
235
+ bundle cardUrl, on the same canonical HTTPS origin. This endpoint is part of the
236
+ human-approved proposal/record digest; a discovered interface cannot silently
237
+ replace it. Bundle input is synchronously cloned before asynchronous verification.
238
+ Existing omitted-endpoint SDK calls keep cardUrl as their endpoint and require a
239
+ new explicit proposal before using another protocol path.
240
+
241
+
242
+ LocalState.locked may take a trusted authorizationGuard plus withAuthorizationLease.
243
+ Both must be functions and must be provided together. The host lease precedes the
244
+ DB queue, encloses the complete transaction, and must coordinate every actual
245
+ pairing-store writer. The synchronous guard returns literal true before writes
246
+ and after the action await immediately before COMMIT; failure rolls back. The
247
+ Task transport supplies this guard from its fresh local pairing reader, including
248
+ effective credential and endpoint, rather than reusing a captured verified flag.
249
+ No actual cross-process pairing store or public approval proof is added here.
250
+
251
+ The host pairing snapshot includes a nonnegative safe-integer revision. Every
252
+ revocation, restoration or credential-generation change increments it under the
253
+ same host lease, even if token bytes later match again. Final guard comparison
254
+ includes this revision to reject in-flight revoke/restore ABA changes. The real
255
+ store must persist/enforce that monotonicity; a synthetic reader does not prove it.
256
+
257
+
258
+ Schema 6 host-grant rows are internal coordination metadata, not admission. Older
259
+ draft databases are preserved/refused without migration. Nonempty host-grant
260
+ history (including void records) currently refuses backup/restore before private
261
+ credential access or output/destination creation; nothing is silently omitted.
262
+ Empty-host custody snapshots retain archive-only restore. Full host-grant archive
263
+ semantics and live coordinator integration need separate review.
package/README.md CHANGED
@@ -1,3 +1,136 @@
1
- # Temporary Holding Version
1
+ # @a2a-exposed/encryption
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local primitives and key custody for the [sealed A2A extension](../docs/extensions/sealed-v1.md).
4
+ The optional CLI and stdio MCP integration uses this package for local encryption
5
+ and decryption. Install the matching version in the selected config directory;
6
+ see [local keys and pairing](../docs/local-key-pairing.md). Installation does not
7
+ enable traffic: exact `A2A_SEALED_ENABLED=1` requires owner approval separately in
8
+ each local process and inbox Worker. No cryptographic audit is claimed.
9
+
10
+ The package uses pinned hpke-js RFC 9180 primitives and Ed25519 signatures.
11
+ It seals independently to the recipient and sender-history keys and binds both
12
+ copies with a salted commitment. Keys stay in the caller's local process.
13
+ `generateIdentity`, `verifyBundle`, `seal`, `open`, `canonicalJSON` and `parseJSON`
14
+ are the draft API. Callers must provide exact outer bindings and locally pinned
15
+ public bundles, and persist policy/replay records before delivering plaintext.
16
+ `seal` self-opens the sender archive copy before returning. `open` is a primitive,
17
+ not authorization or replay storage: it does not validate the integration
18
+ message/snapshot union or content/header semantics, so it must not deliver to a
19
+ model directly. Object-form `open` cannot recover duplicate JSON names lost by
20
+ an upstream parser; validate bounded raw transport JSON before using that form.
21
+ The top-level primitive does not implement archive/history, durable delivery or
22
+ trust authorization. Invalid caller-supplied clock values are refused.
23
+
24
+ Static keys do not provide forward secrecy. Lost keys can make old history
25
+ unreadable. Host operators still observe routing, timing, state and size. Once
26
+ opened, content is visible to the local agent/model provider.
27
+
28
+ Run `npm ci --ignore-scripts` and `npm test` in this directory. Pack with
29
+ `npm pack --dry-run`; no release or deployment is performed. Node 22.18+.
30
+
31
+ The separate `./local-state` API is an incomplete local custody/trust checkpoint.
32
+ See [local custody contracts](LOCAL-CUSTODY-DRAFT.md) for protected local backup
33
+ and archive-only restore APIs. Local receiving-key rotation retains old keys under exact explicit approval.
34
+ The opt-in CLI uses local custody and pins; privileged local replay/outbox APIs below are separate from that integration.
35
+
36
+
37
+ `./content` validates the exact supported plaintext union and losslessly maps
38
+ supported inline parts between A2A 0.3/1.0 without fetching file URIs.
39
+ `./carriers` checks structural message carriers/bindings only. See packaged
40
+ `schemas/` and the [SDK integration contract](../docs/extensions/sealed-v1-sdk.md).
41
+ Neither API replaces signatures, local
42
+ trust, assignment, replay/chain checks or model-delivery authorization. The CLI
43
+ message path uses them without implementing the separate ledger/assignment APIs below.
44
+
45
+
46
+ `./assignment` verifies request/response correlation and signed task assignment
47
+ primitives. Receipt-bearing codecs preserve the control receipt separately from
48
+ ciphertext. They do not establish durable acceptance/model consumption;
49
+ use the local ledger and reviewed host authorization before publication/delivery.
50
+
51
+
52
+ `./ledger` adds local durable initial-request acceptance: cached assignment,
53
+ replay and ciphertext outbox commit together before a receipt is returned.
54
+ Delivery/publication reads and acknowledgments are separate local operations;
55
+ there is no network/model callback. Pairing/activation are trusted caller
56
+ attestations until a reviewed transport adapter exists. Local outbound preparation,
57
+ one-time transmission authorization, correlated response acceptance, follow-ups
58
+ and consecutive snapshot chains are implemented without network callbacks.
59
+ See [the local sender contract](LOCAL-CUSTODY-DRAFT.md#local-sender-response-and-chain-api).
60
+ Actual messaging, semantic restore and real platform acceptance
61
+ remain gated.
62
+
63
+
64
+ `./tasks` extracts exact raw 0.3/1.0 Task wrappers and history without ordinary
65
+ normalizers. It preserves sealed events and assignment controls, rejects outer
66
+ plaintext/artifacts and conflicting bindings, and marks routing state and history
67
+ order unverified. This parser does not authenticate receipts, reconstruct chains
68
+ or deliver content; the privileged ledger and future transport adapter must do so.
69
+
70
+
71
+ `DeliveryLedger.acceptTask` connects this extraction to the local cryptographic
72
+ and transaction gates: verify receipts/signatures, open content, order snapshots
73
+ by verified revisions, then commit the whole assignment/delivery/chain batch.
74
+ It returns stable delivery IDs and verified chain state separately from untrusted
75
+ routing state. Gaps/forks/failure do not partially publish new work. It remains a
76
+ privileged local API; no Worker/CLI/MCP messaging route enables it yet.
77
+
78
+
79
+ `./transport` provides a privileged sealed-only JSON-RPC client for explicit local
80
+ hosts. It takes a fresh local pairing/credential callback, approved endpoint,
81
+ fixed protocol and a card matching the approved bundle. Send releases a prepared
82
+ ciphertext once; polling uses a locally signed assignment. Activation echo is
83
+ required on every response, redirects are refused, and failures never trigger a
84
+ new ID, version or plaintext retry. Results contain IDs/state and truthful send
85
+ privacy facts, not plaintext or credentials. Tests use synthetic fetch/provider
86
+ fixtures. This privileged transport export is separate from the opt-in CLI/Worker integration; it enables no traffic automatically.
87
+
88
+
89
+ Transport hosts must provide withPairingLease(run) as well as the fresh pairing
90
+ reader. The host coordinates all pairing/credential writers; guarded acceptance
91
+ rechecks authorization before writes and COMMIT. Unsupported tenant routing and
92
+ contradictory legacy interfaces are refused. Concurrent send failures inspect the
93
+ current original-ID state and never label another caller's attempt not-sent.
94
+ The real CLI pairing-store lock remains unimplemented and is not validated by
95
+ synthetic no-op lease fixtures.
96
+
97
+ The host pairing snapshot includes a nonnegative safe-integer revision. Every
98
+ revocation, restoration or credential-generation change increments it under the
99
+ same host lease, even if token bytes later match again. Final guard comparison
100
+ includes this revision to reject in-flight revoke/restore ABA changes. The real
101
+ store must persist/enforce that monotonicity; a synthetic reader does not prove it.
102
+
103
+
104
+ The internal maintenance queue retains in-process SDK ordering across separate
105
+ SQLite commits and stages immutable host-grant metadata. It does not supply a
106
+ cross-process authority lock or make ready records eligible for live traffic.
107
+ Schema 6 refuses older draft stores without migration; nonempty host-grant
108
+ history currently blocks backup/restore. See the [maintenance contract](../docs/extensions/sealed-v1-sdk.md#internal-maintenance-and-staged-grant-records).
109
+
110
+ The disabled continuation storage foundation also has private historical signer-plan
111
+ metadata diagnosis. It authenticates each plan at its original creation time and
112
+ preserves its original epoch, revision and signer identity after planning state
113
+ changes. Freshness in that inventory describes time only; it does not select a
114
+ current signer or make a plan applicable. Normal plan reads still require the
115
+ current authority binding and an unexpired plan. No physical nonempty authority
116
+ reader, enrollment application or command uses this historical inventory yet.
117
+ The private `diagnoseBindings` inventory also compares every original plan to a
118
+ strictly snapshotted caller binding. `bindingMatches` and time freshness are
119
+ independent metadata; equality does not authenticate that caller as the current
120
+ authority, prove approval or make a plan applicable.
121
+ The private `readRetained` lookup preserves exact original planning bytes for
122
+ future uncertainty reconciliation, including expired plans. It authenticates
123
+ only the explicitly selected ID and returns deeply frozen private data, never
124
+ diagnostic output or an applicable plan. Fresh reads keep their original gates;
125
+ no physical authority reader, command or enrollment writer calls this lookup.
126
+
127
+ The private physical `diagnoseRetainedInitialContinuationSignerPlans` reader
128
+ anchors historical plan comparison to the initial suspended schema-4 authority.
129
+ It retains authenticated nonmatching originals as metadata and keeps time status
130
+ independent of `bindingMatches`. Corruption refuses the whole inventory; signer
131
+ history and advanced physical authority still refuse. No enrollment or traffic
132
+ path uses this diagnostic.
133
+
134
+ `local-state.localSafetyCode` computes the deterministic public-bundle comparison
135
+ code used by local CLI pairing. Computing or displaying it grants no approval;
136
+ initial human-approved pairing pins remain TOFU until a separate verification.
package/assignment.mjs ADDED
@@ -0,0 +1,68 @@
1
+ // Signed assignment primitives. Publication requires the later atomic replay/outbox transaction.
2
+ import {randomUUID}from'node:crypto';
3
+ import {canonicalJSON,parseJSON,validateEnvelopeStructure,validatePeerStructure,verifyBundle,open,EncryptionError}from'./index.mjs';
4
+ import {validateContent}from'./content.mjs';
5
+ const fail=code=>{throw new EncryptionError(code||'assignment_invalid');};
6
+ const encoder=new TextEncoder();
7
+ const peer=bundle=>({cardUrl:bundle.cardUrl,identityKey:bundle.identityKey,keyId:bundle.keyId});
8
+ const exact=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).sort().join('\0')===[...keys].sort().join('\0');
9
+ const id=value=>typeof value==='string'&&/^[\x21-\x7e]{1,200}$/.test(value);
10
+ function raw(value,size){if(typeof value!=='string'||!/^[A-Za-z0-9_-]+$/.test(value))fail();const bytes=Buffer.from(value,'base64url');if(bytes.length!==size||bytes.toString('base64url')!==value)fail();return bytes;}
11
+ const digest=async value=>Buffer.from(await crypto.subtle.digest('SHA-256',encoder.encode(canonicalJSON(value)))).toString('base64url');
12
+ const binding=header=>Object.fromEntries(['contextId','messageId','taskId','role','state','kind'].map(key=>[key,header[key]]));
13
+ const same=(left,right)=>canonicalJSON(left)===canonicalJSON(right);
14
+ const snapshotBundle=value=>parseJSON(canonicalJSON(value));
15
+ function snapshotIdentity(value){if(!value||typeof value!=='object')fail();return {bundle:snapshotBundle(value.bundle),signingKey:value.signingKey,encryptionKey:value.encryptionKey};}
16
+ function time(now){if(!Number.isSafeInteger(now)||now<0||now>8640000000000000)fail();}
17
+ export function validateAssignmentStructure(value){
18
+ const receipt=typeof value==='string'?parseJSON(value):parseJSON(canonicalJSON(value));
19
+ if(!exact(receipt,['v','kind','requestDigest','from','to','contextId','requestMessageId','taskId','responseKind','responseDigest','ackId','signature'])||receipt.v!==1||receipt.kind!=='assignment'||receipt.responseKind!=='task'||!id(receipt.contextId)||!id(receipt.requestMessageId)||!id(receipt.taskId)||typeof receipt.ackId!=='string'||!(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/).test(receipt.ackId))fail();
20
+ validatePeerStructure(receipt.from);validatePeerStructure(receipt.to);raw(receipt.requestDigest,32);if(receipt.responseDigest!==null)raw(receipt.responseDigest,32);raw(receipt.signature,64);return receipt;
21
+ }
22
+ export async function envelopeDigest(envelope){return digest(validateEnvelopeStructure(envelope));}
23
+ async function validateContext({requestEnvelope,senderBundle,recipientBundle,now,purpose}){
24
+ time(now);if(!['live','archive'].includes(purpose))fail();const request=validateEnvelopeStructure(requestEnvelope),h=request.header;
25
+ if(h.taskId!==null||h.role!=='user'||h.kind!=='message'||h.state!==null||!same(h.from,peer(senderBundle))||!same(h.to,peer(recipientBundle)))fail();
26
+ if(purpose==='live'&&(Date.parse(h.sentAt)<now-30*86400000||Date.parse(h.sentAt)>now+300000))fail('replay');
27
+ await verifyBundle(senderBundle,{cardUrl:senderBundle.cardUrl,identityKey:senderBundle.identityKey,now});await verifyBundle(recipientBundle,{cardUrl:recipientBundle.cardUrl,identityKey:recipientBundle.identityKey,now});return request;
28
+ }
29
+ export async function verifyAssignment({receipt:input,requestEnvelope,senderBundle,recipientBundle,responseEnvelope=null,expectedTaskId,now=Date.now(),purpose='live'}={}){
30
+ try{
31
+ requestEnvelope=validateEnvelopeStructure(requestEnvelope);senderBundle=snapshotBundle(senderBundle);recipientBundle=snapshotBundle(recipientBundle);responseEnvelope=responseEnvelope===null?null:validateEnvelopeStructure(responseEnvelope);
32
+ const receipt=validateAssignmentStructure(input),request=await validateContext({requestEnvelope,senderBundle,recipientBundle,now,purpose}),h=request.header;
33
+ if(!same(receipt.from,peer(recipientBundle))||!same(receipt.to,peer(senderBundle))||receipt.contextId!==h.contextId||receipt.requestMessageId!==h.messageId||receipt.requestDigest!==await envelopeDigest(request)||(expectedTaskId!==undefined&&receipt.taskId!==expectedTaskId))fail('assignment_binding_mismatch');
34
+ const {signature,...body}=receipt;const key=await crypto.subtle.importKey('raw',raw(recipientBundle.identityKey,32),'Ed25519',false,['verify']);
35
+ if(!await crypto.subtle.verify('Ed25519',key,raw(signature,64),encoder.encode('a2a-exposed sealed assignment v1\n'+canonicalJSON(body))))fail();
36
+ if(responseEnvelope===null){if(receipt.responseDigest!==null)fail('response_binding_required');}
37
+ else{
38
+ const response=validateEnvelopeStructure(responseEnvelope),r=response.header;
39
+ if(receipt.responseDigest===null||receipt.responseDigest!==await envelopeDigest(response)||!same(r.from,peer(recipientBundle))||!same(r.to,peer(senderBundle))||r.contextId!==h.contextId||r.taskId!==receipt.taskId||r.role!=='agent')fail('response_binding_mismatch');
40
+ }
41
+ return receipt;
42
+ }catch(error){if(error instanceof EncryptionError)throw error;fail();}
43
+ }
44
+ export async function createAssignment({identity,senderBundle,requestEnvelope,taskId,responseEnvelope=null,ackId=randomUUID(),now=Date.now()}={}){
45
+ try{
46
+ identity=snapshotIdentity(identity);senderBundle=snapshotBundle(senderBundle);requestEnvelope=validateEnvelopeStructure(requestEnvelope);responseEnvelope=responseEnvelope===null?null:validateEnvelopeStructure(responseEnvelope);
47
+ const request=await validateContext({requestEnvelope,senderBundle,recipientBundle:identity.bundle,now,purpose:'live'});
48
+ const requestContent=validateContent(await open({envelope:request,identity,sender:senderBundle,recipient:identity.bundle,expected:binding(request.header),now}),request.header);
49
+ if(!id(taskId))fail();const requestDigest=await envelopeDigest(request);
50
+ if(responseEnvelope!==null){
51
+ const response=validateEnvelopeStructure(responseEnvelope),h=response.header;
52
+ if(h.contextId!==request.header.contextId||h.taskId!==taskId||h.role!=='agent')fail();
53
+ validateContent(await open({envelope:response,identity,sender:identity.bundle,recipient:senderBundle,expected:binding(h),purpose:'sender',now}),h,{expectedRequestDigest:requestDigest});
54
+ }
55
+ const body={v:1,kind:'assignment',requestDigest,from:peer(identity.bundle),to:peer(senderBundle),contextId:request.header.contextId,requestMessageId:request.header.messageId,taskId,responseKind:'task',responseDigest:responseEnvelope===null?null:await envelopeDigest(responseEnvelope),ackId};
56
+ const receipt={...body,signature:Buffer.from(await crypto.subtle.sign('Ed25519',identity.signingKey,encoder.encode('a2a-exposed sealed assignment v1\n'+canonicalJSON(body)))).toString('base64url')};
57
+ await verifyAssignment({receipt,requestEnvelope:request,senderBundle,recipientBundle:identity.bundle,responseEnvelope,expectedTaskId:taskId,now});
58
+ return {receipt,requestContent,untrusted:true}; // No durable acceptance/model-consumption claim.
59
+ }catch(error){if(error instanceof EncryptionError)throw error;fail();}
60
+ }
61
+ export async function openAssignedResponse({receipt,requestEnvelope,responseEnvelope,identity,recipientBundle,now=Date.now()}={}){
62
+ try{
63
+ receipt=validateAssignmentStructure(receipt);requestEnvelope=validateEnvelopeStructure(requestEnvelope);responseEnvelope=validateEnvelopeStructure(responseEnvelope);identity=snapshotIdentity(identity);recipientBundle=snapshotBundle(recipientBundle);
64
+ const verified=await verifyAssignment({receipt,requestEnvelope,senderBundle:identity.bundle,recipientBundle,responseEnvelope,now});
65
+ const response=validateEnvelopeStructure(responseEnvelope),content=validateContent(await open({envelope:response,identity,sender:recipientBundle,recipient:identity.bundle,expected:binding(response.header),now}),response.header,{expectedRequestDigest:verified.requestDigest});
66
+ return {receipt:verified,content,untrusted:true}; // Ledger/trust approval remains outside this primitive.
67
+ }catch(error){if(error instanceof EncryptionError)throw error;fail();}
68
+ }
package/backup.mjs ADDED
@@ -0,0 +1,63 @@
1
+ // Local encrypted-backup byte format. This module does not import or restore live state.
2
+ import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
3
+ import { canonicalJSON, parseJSON } from "./index.mjs";
4
+ export const MAX_BACKUP_FILE = 32 * 1024 * 1024;
5
+ export const MAX_BACKUP_PLAINTEXT = 16 * 1024 * 1024;
6
+ export class BackupError extends Error {
7
+ constructor(code) { super(code); this.code = code; }
8
+ }
9
+ const fail = (code = "backup_unreadable") => { throw new BackupError(code); };
10
+ function passphraseBytes(input) {
11
+ if (!(input instanceof Uint8Array) || input.length > 4098) fail("passphrase_input");
12
+ let value = Buffer.from(input);
13
+ try { new TextDecoder("utf-8", { fatal: true }).decode(value); } catch { value.fill(0); fail("passphrase_input"); }
14
+ if (value.at(-1) === 10) { const end = value.at(-2) === 13 ? value.length - 2 : value.length - 1; value = value.subarray(0, end); }
15
+ if (value.length < 12) { value.fill(0); fail("passphrase_input"); }
16
+ return value;
17
+ }
18
+ export function validateBackupPassphrase(input) { const value = passphraseBytes(input); value.fill(0); return true; }
19
+ const b64 = value => Buffer.from(value).toString("base64url");
20
+ function raw(value, size, max) {
21
+ if (typeof value !== "string" || value.length > Math.ceil((size || max) * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value)) fail();
22
+ const bytes = Buffer.from(value, "base64url");
23
+ if (b64(bytes) !== value || (size && bytes.length !== size) || (max && bytes.length > max)) fail();
24
+ return bytes;
25
+ }
26
+ function keyFor(passphrase, salt) { return scryptSync(passphrase, salt, 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); }
27
+ export function sealBackup(snapshot, passphrase) {
28
+ let password, key, plaintext;
29
+ try {
30
+ plaintext = Buffer.from(canonicalJSON(snapshot));
31
+ if (plaintext.length > MAX_BACKUP_PLAINTEXT) fail("backup_too_large");
32
+ password = passphraseBytes(passphrase);
33
+ const salt = randomBytes(32), nonce = randomBytes(12);
34
+ const header = { format: "a2a-exposed local backup", v: 1, kdf: "scrypt", N: 32768, r: 8, p: 1, salt: b64(salt), nonce: b64(nonce) };
35
+ key = keyFor(password, salt);
36
+ const cipher = createCipheriv("aes-256-gcm", key, nonce); cipher.setAAD(Buffer.from(canonicalJSON(header)));
37
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); plaintext.fill(0);
38
+ const text = canonicalJSON({ ...header, ct: b64(ciphertext) });
39
+ if (Buffer.byteLength(text) > MAX_BACKUP_FILE) fail("backup_too_large");
40
+ return text + "\n";
41
+ } catch (error) { if (error instanceof BackupError) throw error; fail(); }
42
+ finally { password?.fill(0); key?.fill(0); plaintext?.fill(0); }
43
+ }
44
+ export function openBackup(text, passphrase) {
45
+ let password, key, plaintext;
46
+ try {
47
+ if (typeof text !== "string" || Buffer.byteLength(text) > MAX_BACKUP_FILE) fail("backup_too_large");
48
+ const envelope = parseJSON(text, MAX_BACKUP_FILE);
49
+ if (!envelope || typeof envelope !== "object" || Object.keys(envelope).sort().join() !== ["format","v","kdf","N","r","p","salt","nonce","ct"].sort().join()) fail();
50
+ if (envelope.format !== "a2a-exposed local backup" || envelope.v !== 1 || envelope.kdf !== "scrypt" || envelope.N !== 32768 || envelope.r !== 8 || envelope.p !== 1) fail();
51
+ const salt = raw(envelope.salt, 32), nonce = raw(envelope.nonce, 12), ct = raw(envelope.ct, 0, MAX_BACKUP_PLAINTEXT + 16);
52
+ if (ct.length < 16) fail();
53
+ const { ct: ignored, ...header } = envelope;
54
+ password = passphraseBytes(passphrase); key = keyFor(password, salt);
55
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce); decipher.setAAD(Buffer.from(canonicalJSON(header))); decipher.setAuthTag(ct.subarray(-16));
56
+ plaintext = Buffer.concat([decipher.update(ct.subarray(0, -16)), decipher.final()]);
57
+ if (plaintext.length > MAX_BACKUP_PLAINTEXT) fail("backup_too_large");
58
+ const value = parseJSON(new TextDecoder("utf-8", { fatal: true }).decode(plaintext), MAX_BACKUP_PLAINTEXT);
59
+ if (canonicalJSON(value) !== plaintext.toString("utf8")) fail();
60
+ return value;
61
+ } catch (error) { if (error instanceof BackupError) throw error; fail(); }
62
+ finally { password?.fill(0); key?.fill(0); plaintext?.fill(0); }
63
+ }