@a2a-exposed/encryption 0.0.0-stage → 0.8.55
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/DEPENDENCIES.md +31 -0
- package/LICENSE +21 -0
- package/LOCAL-CUSTODY-DRAFT.md +263 -0
- package/README.md +135 -2
- package/assignment.mjs +68 -0
- package/backup.mjs +63 -0
- package/carriers.mjs +45 -0
- package/content.mjs +82 -0
- package/index.mjs +230 -0
- package/keyring-worker.mjs +19 -0
- package/ledger.mjs +330 -0
- package/local-state.mjs +809 -0
- package/native-store.mjs +55 -0
- package/package.json +57 -4
- package/schemas/assigned-carrier.schema.json +271 -0
- package/schemas/assignment.schema.json +73 -0
- package/schemas/capability.schema.json +84 -0
- package/schemas/content.schema.json +546 -0
- package/schemas/envelope.schema.json +188 -0
- package/schemas/message-carrier.schema.json +195 -0
- package/schemas/receipt-carrier.schema.json +197 -0
- package/schemas/task-carrier.schema.json +247 -0
- package/tasks.mjs +55 -0
- package/transport.mjs +96 -0
package/DEPENDENCIES.md
ADDED
|
@@ -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
|
-
#
|
|
1
|
+
# @a2a-exposed/encryption
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|