@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.
@@ -0,0 +1,809 @@
1
+ // Local-only custody/trust foundation. These APIs do not enroll a Worker or send traffic.
2
+ import fs from "node:fs";
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { randomUUID, createHash, randomBytes } from "node:crypto";
8
+ import { CipherSuite, HkdfSha256 } from "@hpke/core";
9
+ import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
10
+ import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
11
+ import { nativeCredentialOperation } from "./native-store.mjs";
12
+ import { sealBackup, openBackup, MAX_BACKUP_FILE, BackupError, validateBackupPassphrase } from "./backup.mjs";
13
+ import { generateIdentity, rotateEncryptionKey, verifyBundle, canonicalJSON, parseJSON, seal } from "./index.mjs";
14
+
15
+ const suite = new CipherSuite({ kem: new DhkemX25519HkdfSha256(), kdf: new HkdfSha256(), aead: new Chacha20Poly1305() });
16
+ const b64 = value => Buffer.from(value).toString("base64url");
17
+ const hash = (label, value) => createHash("sha256").update(label + "\n" + canonicalJSON(value)).digest("base64url");
18
+ const queues = new Map(), queueContexts = new AsyncLocalStorage(), maintenanceOwners = new WeakMap();
19
+ function refuseQueueReentry(root, maintenance = false) {
20
+ if ((queueContexts.getStore() || []).some(c => c.active && (maintenance || c.kind === "maintenance" || c.root === root))) fail("state_queue_reentry");
21
+ }
22
+ async function acquireQueue(root) {
23
+ const previous = queues.get(root) || Promise.resolve(); let release;
24
+ const own = new Promise(resolve => { release = resolve; }), tail = previous.then(() => own); queues.set(root, tail);
25
+ await previous;
26
+ return () => { release(); if (queues.get(root) === tail) queues.delete(root); };
27
+ }
28
+ async function inQueueContext(context, action) {
29
+ try { return await queueContexts.run([...(queueContexts.getStore() || []), context], action); }
30
+ finally { context.active = false; }
31
+ }
32
+ const inflight = new Set();
33
+ const UUID = /^[a-f0-9-]{36}$/;
34
+ const KEY = /^[A-Za-z0-9_-]{43}$/;
35
+ const POLICIES = ["off", "required", "allow-server-readable"];
36
+ export class LocalStateError extends Error {
37
+ constructor(code, next = "Run keys status --json and keys stores --json; no remote settings were changed.") { super(next); this.code = code; this.next = next; }
38
+ }
39
+ const fail = (code, next) => { throw new LocalStateError(code, next); };
40
+ const copy = value => JSON.parse(JSON.stringify(value));
41
+ function stateError(error) {
42
+ if (error instanceof LocalStateError) return error;
43
+ if (error instanceof BackupError) return new LocalStateError(error.code, "The encrypted backup could not be validated. Check the protected passphrase source and use a trusted matching backup; nothing was installed.");
44
+ if ([5, 6].includes(error?.errcode)) return new LocalStateError("state_busy", "Another local process holds the store. Read keys status --json after it finishes; no automatic retry or replacement was made.");
45
+ if (error?.code === "ERR_SQLITE_ERROR" || error instanceof SyntaxError) return new LocalStateError("state_corrupt", "Local state could not be read safely. Preserve it and use a matching version or trusted backup; do not initialize over it.");
46
+ if (["native_outcome_unknown", "native_process_unreaped", "key_store_unavailable", "key_store_consent"].includes(error?.code)) return new LocalStateError(error.code, "Read keys status --json and reconcile the exact pending key operation before retrying; no native write/deletion outcome is assumed.");
47
+ if (["EACCES", "EPERM", "ELOOP"].includes(error?.code)) return new LocalStateError("key_store_permissions");
48
+ return new LocalStateError("state_unavailable");
49
+ }
50
+ function pageInputs(limit, cursor) {
51
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(cursor) || cursor < 0) fail("listing_inputs", "Run keys status --json or peer keys inspect --to ALIAS --json, then use --limit 1..100 and the exact returned nonnegative continuation.");
52
+ }
53
+
54
+ function permissions(file, directory = false) {
55
+ const stat = fs.lstatSync(file);
56
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) fail("key_store_permissions");
57
+ if (process.platform !== "win32") {
58
+ if ((stat.mode & 0o077) || stat.uid !== process.getuid()) fail("key_store_permissions", "Use an owner-only key directory (0700) and files (0600), then run keys status --json.");
59
+ } else {
60
+ // Require a current-user-only ACL. Do not assume chmod protects Windows files.
61
+ const script = "$ErrorActionPreference='Stop';try{$p=[Console]::In.ReadToEnd();$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value;$a=Get-Acl -LiteralPath $p;if($null -eq $a){exit 1};$bad=@($a.Access | Where-Object {$_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -notin @($me,'S-1-5-18')});if($bad.Count -gt 0){exit 1};exit 0}catch{exit 1}";
62
+ try { execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { input: file, stdio: ["pipe", "ignore", "ignore"], timeout: 5000 }); }
63
+ catch { fail("key_store_permissions", "Use a current-user-only Windows ACL; then run keys stores --json. No fallback was selected."); }
64
+ }
65
+ return stat;
66
+ }
67
+ function protect(file) {
68
+ if (process.platform !== "win32") fs.chmodSync(file, fs.statSync(file).isDirectory() ? 0o700 : 0o600);
69
+ else {
70
+ const script = "$ErrorActionPreference='Stop';try{$p=[Console]::In.ReadToEnd();$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$a=Get-Acl -LiteralPath $p;if($null -eq $a){exit 1};$a.SetAccessRuleProtection($true,$false);foreach($r in @($a.Access)){$a.RemoveAccessRuleAll($r)};$a.SetOwner($me);$r=New-Object System.Security.AccessControl.FileSystemAccessRule($me,'FullControl','Allow');$a.AddAccessRule($r);Set-Acl -LiteralPath $p -AclObject $a;exit 0}catch{exit 1}";
71
+ try { execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { input: file, stdio: ["pipe", "ignore", "ignore"], timeout: 5000 }); }
72
+ catch { fail("key_store_permissions"); }
73
+ }
74
+ permissions(file, fs.statSync(file).isDirectory());
75
+ }
76
+ function privateRead(file, max = 16384) {
77
+ const before = permissions(file); const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
78
+ try { const stat = fs.fstatSync(fd); if (!stat.isFile() || stat.size > max || stat.dev !== before.dev || stat.ino !== before.ino || (process.platform !== "win32" && ((stat.mode & 0o077) || stat.uid !== process.getuid()))) fail("key_file"); return new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(fd)); }
79
+ finally { fs.closeSync(fd); }
80
+ }
81
+ function privateWrite(file, value) {
82
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
83
+ try { fs.writeFileSync(fd, value); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
84
+ protect(file);
85
+ // Persist directory entries where the platform supports directory fsync. The recovery intent
86
+ // remains authoritative if the filesystem or native provider has an uncertain outcome.
87
+ return syncDirectory(path.dirname(file));
88
+ }
89
+ function syncDirectory(directory) {
90
+ if (process.platform === "win32") return false; // No tested portable directory fsync on Windows.
91
+ let fd;
92
+ try { fd = fs.openSync(directory, "r"); fs.fsyncSync(fd); return true; }
93
+ catch (error) { if (["EINVAL", "ENOTSUP", "EBADF"].includes(error.code)) return false; throw error; }
94
+ finally { if (fd !== undefined) fs.closeSync(fd); }
95
+
96
+ }
97
+ async function serialize(identity) {
98
+ return JSON.stringify({ v: 1, signing: b64(await crypto.subtle.exportKey("pkcs8", identity.signingKey)), encryption: b64(await suite.kem.serializePrivateKey(identity.encryptionKey)) });
99
+ }
100
+ async function deserialize(value, bundle) {
101
+ try {
102
+ const data = parseJSON(value);
103
+ if (Object.keys(data).sort().join() !== ["v", "signing", "encryption"].sort().join() || data.v !== 1) fail("key_unreadable");
104
+ for (const field of ["signing", "encryption"]) if (typeof data[field] !== "string" || !/^[A-Za-z0-9_-]+$/.test(data[field]) || Buffer.from(data[field], "base64url").toString("base64url") !== data[field]) fail("key_unreadable");
105
+ const signing = Buffer.from(data.signing, "base64url"), encryption = Buffer.from(data.encryption, "base64url");
106
+ if (encryption.length !== 32 || signing.length > 256) fail("key_unreadable");
107
+ return { bundle, signingKey: await crypto.subtle.importKey("pkcs8", signing, "Ed25519", true, ["sign"]), encryptionKey: await suite.kem.deserializePrivateKey(encryption) };
108
+ } catch { fail("key_unreadable", "Private key data failed validation. Preserve it and restore a trusted backup; no key replacement or trust change was made."); }
109
+ }
110
+
111
+ async function nativeStore(input, allowOsConsent, onSpawn) {
112
+ return nativeCredentialOperation(input, { allowOsConsent, onSpawn });
113
+ }
114
+
115
+ export async function stores() {
116
+ let native = false; try { await import("@napi-rs/keyring"); native = ["darwin", "linux", "win32"].includes(process.platform); } catch {}
117
+ return { ok: true, stores: [{ id: "file", available: true, private_keys: "protected local files", unattended: true }, { id: "keychain", available: native, backend: process.platform === "linux" ? "secret-service (no kernel fallback)" : process.platform === "darwin" ? "macOS Keychain" : "Windows Credential Manager", readiness: "not probed; owner authorization may be required", unattended: false }], next: "Select --store file or keychain explicitly; keychain never silently falls back." };
118
+ }
119
+
120
+ export function localSafetyCode(local, other) {
121
+ const tuples = [local, other].map(x => ({ cardUrl: x.cardUrl, identityKey: x.identityKey, keyId: x.keyId })).sort((a, b) => Buffer.compare(Buffer.from(a.identityKey, "base64url"), Buffer.from(b.identityKey, "base64url")) || a.cardUrl.localeCompare(b.cardUrl));
122
+ const bytes = createHash("sha256").update("a2a-exposed sealed safety v1\n" + canonicalJSON(tuples)).digest("hex");
123
+ const digits = (BigInt("0x" + bytes) % (10n ** 60n)).toString().padStart(60, "0");
124
+ return digits.match(/.{5}/g).join(" ");
125
+ }
126
+
127
+ const SCHEMA_VERSION = 6; // Unreleased checkpoint change; older local drafts are preserved, never migrated.
128
+ const recoveryApproval = (configId, row, action) => hash("a2a-exposed local recovery approval v1", { configId, operationId: row.id, operationDigest: row.digest, state: row.state, revision: row.revision, action });
129
+ const hostUUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
130
+ const HOST_FIELDS = ["config_id", "intent_id", "set_id", "epoch", "target_revision", "membership_mac"];
131
+ const READINESS_FIELDS = ["alias", "endpoint", "pairing_identity", "trust_record_digest", "trust_record_version", "local_id", "local_bundle_digest", "peer_bundle_digest", "policy", "verification"];
132
+ function readinessMembers(input) {
133
+ try {
134
+ const members = parseJSON(canonicalJSON(input));
135
+ if (!Array.isArray(members) || !members.length || members.length > 1000) fail("host_readiness_invalid");
136
+ for (const m of members) {
137
+ if (!exact(m, READINESS_FIELDS) || typeof m.alias !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(m.alias) || !hostUUID.test(m.local_id) || !Number.isSafeInteger(m.trust_record_version) || m.trust_record_version < 1 || m.policy !== "required" || m.verification !== "independent") fail("host_readiness_invalid");
138
+ for (const k of ["pairing_identity", "trust_record_digest", "local_bundle_digest", "peer_bundle_digest"]) if (typeof m[k] !== "string" || !KEY.test(m[k]) || Buffer.from(m[k], "base64url").toString("base64url") !== m[k]) fail("host_readiness_invalid");
139
+ const u = new URL(m.endpoint); if (u.href !== m.endpoint || u.protocol !== "https:" || u.username || u.password || u.search || u.hash) fail("host_readiness_invalid");
140
+ }
141
+ for (const k of ["alias", "pairing_identity"]) if (new Set(members.map(m => m[k])).size !== members.length) fail("host_readiness_invalid");
142
+ return members;
143
+ } catch { fail("host_readiness_invalid"); }
144
+ }
145
+ const sqlUUID = c => `length(CAST(${c} AS BLOB))=36 AND length(${c})=36 AND substr(${c},9,1)='-' AND substr(${c},14,1)='-' AND substr(${c},19,1)='-' AND substr(${c},24,1)='-' AND substr(${c},15,1)='4' AND substr(${c},20,1) IN ('8','9','a','b') AND length(replace(${c},'-',''))=32 AND replace(${c},'-','') NOT GLOB '*[^0-9a-f]*'`;
146
+ function hostGrantBinding(value) {
147
+ try {
148
+ const r = parseJSON(canonicalJSON(value));
149
+ if (!r || Array.isArray(r) || Object.keys(r).sort().join() !== [...HOST_FIELDS].sort().join()) fail("host_grant_invalid");
150
+ for (const key of ["config_id", "intent_id", "set_id", "epoch"]) if (typeof r[key] !== "string" || !hostUUID.test(r[key])) fail("host_grant_invalid");
151
+ if (!Number.isSafeInteger(r.target_revision) || r.target_revision < 1 || typeof r.membership_mac !== "string" || !KEY.test(r.membership_mac) || Buffer.from(r.membership_mac,"base64url").toString("base64url") !== r.membership_mac) fail("host_grant_invalid");
152
+ return r;
153
+ } catch { fail("host_grant_invalid"); }
154
+ }
155
+ const SCHEMA = `CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
156
+ CREATE TABLE IF NOT EXISTS key_operations(id TEXT PRIMARY KEY,kind TEXT NOT NULL,public_json TEXT NOT NULL,digest TEXT NOT NULL,state TEXT NOT NULL,created_ms INTEGER NOT NULL,owner_pid INTEGER NOT NULL,broker_pid INTEGER,revision INTEGER NOT NULL DEFAULT 0);
157
+ CREATE TABLE IF NOT EXISTS identities(id TEXT PRIMARY KEY,bundle TEXT NOT NULL,store TEXT NOT NULL,credential_namespace TEXT NOT NULL,live_state TEXT NOT NULL,created_ms INTEGER NOT NULL);
158
+ CREATE TABLE IF NOT EXISTS trust(peer TEXT PRIMARY KEY,version INTEGER NOT NULL,record TEXT NOT NULL);
159
+ CREATE TABLE IF NOT EXISTS trust_history(peer TEXT NOT NULL,version INTEGER NOT NULL,record TEXT NOT NULL,digest TEXT NOT NULL,approved_ms INTEGER NOT NULL,PRIMARY KEY(peer,version));
160
+ CREATE TABLE IF NOT EXISTS proposals(digest TEXT PRIMARY KEY,peer TEXT NOT NULL,expected_digest TEXT NOT NULL,expected_version INTEGER NOT NULL,proposal TEXT NOT NULL,state TEXT NOT NULL);
161
+ CREATE TABLE IF NOT EXISTS replay(local_id TEXT NOT NULL,sender TEXT NOT NULL,message_id TEXT NOT NULL,digest TEXT NOT NULL,context TEXT NOT NULL,task_id TEXT,created_ms INTEGER NOT NULL,PRIMARY KEY(local_id,sender,message_id));
162
+ CREATE TABLE IF NOT EXISTS deliveries(id TEXT PRIMARY KEY,local_id TEXT NOT NULL,sender TEXT NOT NULL,message_id TEXT NOT NULL,envelope TEXT NOT NULL,state TEXT NOT NULL,created_ms INTEGER NOT NULL);
163
+ CREATE TABLE IF NOT EXISTS assignments(local_identity TEXT NOT NULL,peer_identity TEXT NOT NULL,request_message_id TEXT NOT NULL,direction TEXT NOT NULL,ack_id TEXT NOT NULL,peer TEXT NOT NULL,context TEXT NOT NULL,request_digest TEXT NOT NULL,task_id TEXT NOT NULL,receipt TEXT NOT NULL,protocol TEXT NOT NULL,trust_digest TEXT NOT NULL,local_key_id TEXT NOT NULL,ack_state TEXT NOT NULL,PRIMARY KEY(local_identity,peer_identity,request_message_id,direction),UNIQUE(local_identity,peer_identity,task_id,direction));
164
+ CREATE TABLE IF NOT EXISTS delivery_provenance(id TEXT PRIMARY KEY,peer TEXT NOT NULL,trust_digest TEXT NOT NULL,trust_version INTEGER NOT NULL,local_key_id TEXT NOT NULL,envelope_digest TEXT NOT NULL);
165
+ CREATE TABLE IF NOT EXISTS outbound(local_identity TEXT NOT NULL,peer_identity TEXT NOT NULL,message_id TEXT NOT NULL,peer TEXT NOT NULL,context TEXT NOT NULL,task_id TEXT,digest TEXT NOT NULL,envelope TEXT NOT NULL,trust_digest TEXT NOT NULL,local_key_id TEXT NOT NULL,reference_digest TEXT,state TEXT NOT NULL,transport_task_id TEXT,protocol TEXT NOT NULL,created_ms INTEGER NOT NULL,PRIMARY KEY(local_identity,peer_identity,message_id));
166
+ CREATE TABLE IF NOT EXISTS chains(local_identity TEXT NOT NULL,peer_identity TEXT NOT NULL,context TEXT NOT NULL,task_id TEXT NOT NULL,author_identity TEXT NOT NULL,revision INTEGER NOT NULL,digest TEXT NOT NULL,state TEXT NOT NULL,terminal INTEGER NOT NULL,PRIMARY KEY(local_identity,peer_identity,context,task_id,author_identity));
167
+ CREATE TABLE IF NOT EXISTS host_grant_sets(config_id TEXT NOT NULL CHECK(${sqlUUID("config_id")}),intent_id TEXT PRIMARY KEY NOT NULL CHECK(${sqlUUID("intent_id")}),set_id TEXT NOT NULL UNIQUE CHECK(${sqlUUID("set_id")}),epoch TEXT NOT NULL CHECK(${sqlUUID("epoch")}),target_revision INTEGER NOT NULL CHECK(typeof(target_revision)='integer' AND target_revision>=1 AND target_revision<=9007199254740991),membership_mac TEXT NOT NULL CHECK(length(CAST(membership_mac AS BLOB))=43 AND length(membership_mac)=43 AND membership_mac NOT GLOB '*[^A-Za-z0-9_-]*' AND substr(membership_mac,43,1) IN ('A','E','I','M','Q','U','Y','c','g','k','o','s','w','0','4','8')),state TEXT NOT NULL CHECK(state IN ('staged','ready','void')),UNIQUE(config_id,epoch,target_revision));
168
+ CREATE TRIGGER IF NOT EXISTS host_grant_noreplace BEFORE INSERT ON host_grant_sets WHEN EXISTS(SELECT 1 FROM host_grant_sets WHERE intent_id=NEW.intent_id OR set_id=NEW.set_id OR (config_id=NEW.config_id AND epoch=NEW.epoch AND target_revision=NEW.target_revision)) BEGIN SELECT RAISE(ABORT,'host_grant_conflict'); END;
169
+ CREATE TRIGGER IF NOT EXISTS host_grant_initial BEFORE INSERT ON host_grant_sets WHEN NEW.state<>'staged' BEGIN SELECT RAISE(ABORT,'host_grant_initial'); END;
170
+ CREATE TRIGGER IF NOT EXISTS host_grant_config BEFORE INSERT ON host_grant_sets WHEN NEW.config_id IS NOT (SELECT value FROM meta WHERE key='config_id') BEGIN SELECT RAISE(ABORT,'host_grant_config'); END;
171
+ CREATE TRIGGER IF NOT EXISTS host_grant_immutable BEFORE UPDATE OF config_id,intent_id,set_id,epoch,target_revision,membership_mac ON host_grant_sets BEGIN SELECT RAISE(ABORT,'host_grant_immutable'); END;
172
+ CREATE TRIGGER IF NOT EXISTS host_grant_retained BEFORE DELETE ON host_grant_sets BEGIN SELECT RAISE(ABORT,'host_grant_retained'); END;
173
+ CREATE TRIGGER IF NOT EXISTS host_grant_transition BEFORE UPDATE OF state ON host_grant_sets WHEN NOT ((OLD.state='staged' AND NEW.state IN ('ready','void')) OR (OLD.state='ready' AND NEW.state='void')) BEGIN SELECT RAISE(ABORT,'host_grant_transition'); END;
174
+ `;
175
+ const SNAPSHOT_TABLES = ["meta", "key_operations", "identities", "trust", "trust_history", "proposals", "replay", "deliveries", "assignments", "delivery_provenance", "outbound", "chains", "host_grant_sets"];
176
+ const exact = (value, fields) => value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).sort().join() === [...fields].sort().join();
177
+ async function checkPrivate(value, bundle) {
178
+ const identity = await deserialize(value, bundle);
179
+ await seal({ identity, recipient: bundle, binding: { contextId: randomUUID(), messageId: randomUUID(), taskId: null, role: "user", state: null, kind: "message" }, content: { purpose: "backup-key-check" } });
180
+ }
181
+ async function validateSnapshot(snapshot, { checkCredentials = true } = {}) {
182
+ const invalid = () => fail("backup_snapshot_invalid", "The backup failed local schema/key validation. Nothing was installed; preserve the original file and use a trusted matching backup.");
183
+ if (!exact(snapshot, ["format", "v", "schema", "configIdentity", "createdMs", "rows", "credentials"]) || snapshot.format !== "a2a-exposed custody snapshot" || snapshot.v !== 1 || snapshot.schema !== SCHEMA_VERSION || !UUID.test(snapshot.configIdentity || "") || !Number.isSafeInteger(snapshot.createdMs) || snapshot.createdMs < 0 || !exact(snapshot.rows, SNAPSHOT_TABLES) || !Array.isArray(snapshot.credentials) || snapshot.credentials.length > 5000) invalid();
184
+ if (!Array.isArray(snapshot.rows.host_grant_sets)) invalid();
185
+ if (snapshot.rows.host_grant_sets.length) fail("backup_host_grants_unsupported", "Host grant archival is not supported in this draft. Preserve the original store; no output/destination was created and no credential provider was accessed.");
186
+ const db = new DatabaseSync(":memory:");
187
+ try {
188
+ db.exec(SCHEMA);
189
+ let total = 0;
190
+ for (const table of SNAPSHOT_TABLES) {
191
+ const rows = snapshot.rows[table], columns = db.prepare(`PRAGMA table_info(${table})`).all();
192
+ if (!Array.isArray(rows) || rows.length > 5000 || (total += rows.length) > 20000) invalid();
193
+ const insert = db.prepare(`INSERT INTO ${table} VALUES(${columns.map(() => "?").join(",")})`);
194
+ for (const row of rows) {
195
+ if (!exact(row, columns.map(x => x.name))) invalid();
196
+ for (const column of columns) {
197
+ const value = row[column.name];
198
+ if (value === null) { if (column.notnull || column.pk) invalid(); }
199
+ else if (column.type === "INTEGER" ? !Number.isSafeInteger(value) : typeof value !== "string" || Buffer.byteLength(value) > 1024 * 1024) invalid();
200
+ }
201
+ insert.run(...columns.map(column => row[column.name]));
202
+ }
203
+ }
204
+ const rows = snapshot.rows;
205
+ const config = rows.meta.find(row => row.key === "config_id");
206
+ if (config?.value !== snapshot.configIdentity || rows.meta.some(row => !["config_id", "restore_source_config", "restore_paused", "restore_created_ms", "restore_source_operations"].includes(row.key))) invalid();
207
+ const bundles = new Map(), operations = new Map();
208
+ for (const row of rows.key_operations) {
209
+ const op = parseJSON(row.public_json);
210
+ const opFields = ["v", "kind", "id", "identityId", "bundle", "store", "namespace", "account", "fileRef", "created_ms", ...(op.kind === "rotate" ? ["previousId", "previousBundleDigest"] : [])];
211
+ if (!exact(op, opFields) || op.v !== 1 || !["initialize", "rotate"].includes(op.kind) || op.id !== row.id || row.kind !== op.kind || !UUID.test(op.id) || !UUID.test(op.identityId) || !UUID.test(op.namespace) || !["file", "keychain"].includes(op.store) || op.account !== op.identityId + "/" + op.bundle?.keyId || op.fileRef !== op.identityId + ".key" || !Number.isSafeInteger(op.created_ms) || op.created_ms < 0 || !Number.isSafeInteger(row.revision) || row.revision < 0 || !["committed", "orphan-retained", "observed-absent"].includes(row.state) || hash("a2a-exposed local key operation v1", op) !== row.digest || operations.has(op.identityId)) invalid();
212
+ await verifyBundle(op.bundle, { cardUrl: op.bundle.cardUrl, identityKey: op.bundle.identityKey });
213
+ bundles.set(op.identityId, op.bundle); operations.set(op.identityId, row);
214
+ }
215
+ for (const opRow of operations.values()) {
216
+ const op = parseJSON(opRow.public_json);
217
+ if (op.kind === "rotate") {
218
+ const prior = bundles.get(op.previousId);
219
+ if (!prior || prior.identityKey !== op.bundle.identityKey || prior.cardUrl !== op.bundle.cardUrl || prior.keyId === op.bundle.keyId || hash("a2a-exposed sealed bundle v1", prior) !== op.previousBundleDigest) invalid();
220
+ }
221
+ }
222
+ for (const [id, row] of operations) if (row.state === "committed" && !rows.identities.some(identity => identity.id === id)) invalid();
223
+ for (const row of rows.identities) {
224
+ const bundle = parseJSON(row.bundle), opRow = operations.get(row.id), op = opRow && parseJSON(opRow.public_json);
225
+ if (!op || opRow.state !== "committed" || canonicalJSON(bundle) !== canonicalJSON(op.bundle) || row.store !== op.store || row.credential_namespace !== op.namespace || !["local-only", "retired", "archive-only"].includes(row.live_state)) invalid();
226
+ }
227
+ const seen = new Set();
228
+ for (const credential of checkCredentials ? snapshot.credentials : []) {
229
+ if (!exact(credential, ["id", "value"]) || !bundles.has(credential.id) || seen.has(credential.id)) invalid();
230
+ seen.add(credential.id);
231
+ if (credential.value === null) { if (operations.get(credential.id).state !== "observed-absent") invalid(); }
232
+ else { if (typeof credential.value !== "string" || Buffer.byteLength(credential.value) > 16384) invalid(); await checkPrivate(credential.value, bundles.get(credential.id)); }
233
+ }
234
+ if (checkCredentials && seen.size !== operations.size) invalid();
235
+ for (const row of [...rows.trust, ...rows.trust_history]) {
236
+ const record = parseJSON(row.record);
237
+ if (record.peer !== row.peer || record.version !== row.version || !bundles.has(record.localId) || !POLICIES.includes(record.policy)) invalid();
238
+ await verifyBundle(record.bundle, { cardUrl: record.bundle.cardUrl, identityKey: record.bundle.identityKey });
239
+ if (row.digest && hash("a2a-exposed sealed trust record v1", record) !== row.digest) invalid();
240
+ }
241
+ for (const row of rows.proposals) if (hash("a2a-exposed sealed trust proposal v1", parseJSON(row.proposal)) !== row.digest) invalid();
242
+ } catch (error) { if (error instanceof LocalStateError) throw error; invalid(); }
243
+ finally { db.close(); }
244
+ }
245
+
246
+ // Restores only into a newly reserved private directory. No OS credential writes.
247
+ export async function restoreBackup({ source, destination, passphrase, store, acknowledgeReplayRollback = false }) {
248
+ let stage, targetCreated = false, installed = false;
249
+ try {
250
+ if (acknowledgeReplayRollback !== true) fail("restore_ack_required", "Restore can roll back replay history. Explicitly acknowledge this and use a new directory; restored keys remain archive-only.");
251
+ if (store !== "file") fail("restore_store", "This checkpoint supports explicit restore store file only. No native credential write or fallback is performed.");
252
+ if (typeof source !== "string" || !path.isAbsolute(source) || typeof destination !== "string" || !path.isAbsolute(destination)) fail("restore_paths", "Supply protected source-file and new absolute destination-directory paths.");
253
+ if (fs.existsSync(destination)) fail("restore_destination_exists", "Choose a new nonexistent directory; restore never merges or overwrites existing state.");
254
+ const snapshot = openBackup(privateRead(source, MAX_BACKUP_FILE), passphrase);
255
+ await validateSnapshot(snapshot); // Validate every key/row before creating target state.
256
+ fs.mkdirSync(destination, { mode: 0o700 }); targetCreated = true; protect(destination);
257
+ stage = fs.mkdtempSync(path.join(destination, ".restore-")); protect(stage);
258
+ const state = new LocalState(stage, { create: true });
259
+ try {
260
+ // Persist the archive-only hold before any private file is created. A crash
261
+ // rolling back row import must never leave a usable fresh staging store.
262
+ state.db.prepare("INSERT INTO meta VALUES('restore_paused','1')").run();
263
+ state.db.exec("BEGIN IMMEDIATE");
264
+ const newConfig = randomUUID(), rows = snapshot.rows;
265
+ // Operational approvals/pins from a rolled-back copy cannot become live state.
266
+ const meta = [{ key: "config_id", value: newConfig }, { key: "restore_source_config", value: snapshot.configIdentity }, { key: "restore_paused", value: "1" }, { key: "restore_created_ms", value: String(Date.now()) }, { key: "restore_source_operations", value: hash("a2a-exposed restored source operations v1", rows.key_operations) }];
267
+ state.db.exec("DELETE FROM meta");
268
+ for (const table of SNAPSHOT_TABLES) {
269
+ let records = table === "meta" ? meta : rows[table];
270
+ if (table === "identities") records = records.map(row => ({ ...row, store: "file", live_state: "archive-only" }));
271
+ if (table === "key_operations") records = records.map(row => {
272
+ const op = { ...parseJSON(row.public_json), store: "file" };
273
+ return { ...row, public_json: canonicalJSON(op), digest: hash("a2a-exposed local key operation v1", op), revision: row.revision + 1, owner_pid: 0, broker_pid: null };
274
+ });
275
+ const columns = state.db.prepare(`PRAGMA table_info(${table})`).all().map(column => column.name);
276
+ const insert = state.db.prepare(`INSERT INTO ${table} VALUES(${columns.map(() => "?").join(",")})`);
277
+ for (const row of records) insert.run(...columns.map(column => row[column]));
278
+ }
279
+ for (const credential of snapshot.credentials) if (credential.value !== null) privateWrite(path.join(state.root, credential.id + ".key"), credential.value + "\n");
280
+ state.db.exec("COMMIT"); validateSchema(state.db);
281
+ } catch (error) { try { state.db.exec("ROLLBACK"); } catch {} throw error; }
282
+ finally { state.close(); }
283
+ const sqliteFd = fs.openSync(path.join(stage, "encryption/state.sqlite"), "r");
284
+ try { fs.fsyncSync(sqliteFd); } finally { fs.closeSync(sqliteFd); }
285
+ let directorySynced = syncDirectory(path.join(stage, "encryption"));
286
+ // Parent was exclusively reserved above. The canonical encryption path appears
287
+ // only after all rows/credentials have been validated and written successfully.
288
+ fs.renameSync(path.join(stage, "encryption"), path.join(destination, "encryption")); installed = true;
289
+ directorySynced = syncDirectory(destination) && directorySynced;
290
+ fs.rmdirSync(stage); stage = null;
291
+ directorySynced = syncDirectory(destination) && directorySynced;
292
+ directorySynced = syncDirectory(path.dirname(destination)) && directorySynced;
293
+ const readback = new LocalState(destination);
294
+ try { return { ok: true, durability: directorySynced ? "file-and-directory-sync-completed" : "directory-sync-unverified", config_identity: readback.configId(), source_config_identity: snapshot.configIdentity, archive_only: true, replay_rollback_acknowledged: true, identities: snapshot.rows.identities.length, next: "Restored keys and pins are archive-only. Live delivery stays paused; a new receiving identity and independent peer verification are required before later enrollment." }; }
295
+ finally { readback.close(); }
296
+ } catch (error) {
297
+ if (targetCreated) fail(installed ? "restore_installed_readback_failed" : "restore_staging_incomplete", "The new private destination is retained for inspection. Nothing was merged into another configuration; do not overwrite or retry over it.");
298
+ throw stateError(error);
299
+ }
300
+ }
301
+
302
+ function validateSchema(db) {
303
+ const expected = new DatabaseSync(":memory:");
304
+ try {
305
+ expected.exec(SCHEMA);
306
+ const query = "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name";
307
+ if (JSON.stringify(db.prepare(query).all()) !== JSON.stringify(expected.prepare(query).all())) fail("state_corrupt");
308
+ if (db.prepare("PRAGMA quick_check(1)").get().quick_check !== "ok") fail("state_corrupt");
309
+ } finally { expected.close(); }
310
+ }
311
+ export class LocalState {
312
+ constructor(configDir, { create = false, write = create, keyring = nativeStore } = {}) {
313
+ this.db = null; this.writable = write; this.keyring = keyring;
314
+ try {
315
+ this.root = path.join(path.resolve(configDir), "encryption");
316
+ if (create && !write) fail("state_readonly");
317
+ if (!fs.existsSync(this.root)) {
318
+ if (!create) return;
319
+ fs.mkdirSync(path.resolve(configDir), { recursive: true, mode: 0o700 });
320
+ try { fs.mkdirSync(this.root, { mode: 0o700 }); protect(this.root); }
321
+ catch (error) { if (error.code !== "EEXIST") throw error; }
322
+ }
323
+ permissions(this.root, true); this.root = fs.realpathSync(this.root); const file = path.join(this.root, "state.sqlite");
324
+ if (!fs.existsSync(file) && !create) return;
325
+ if (fs.existsSync(file)) permissions(file);
326
+ this.db = new DatabaseSync(file, { readOnly: !write }); if (write) protect(file);
327
+ this.db.exec("PRAGMA busy_timeout=5000;");
328
+ if (create) {
329
+ this.db.exec("BEGIN IMMEDIATE");
330
+ try {
331
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
332
+ const objects = this.db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'").get().n;
333
+ if (version === 0 && objects === 0) {
334
+ this.db.exec(SCHEMA);
335
+ this.db.prepare("INSERT INTO meta VALUES('config_id',?)").run(randomUUID());
336
+ this.db.exec(`PRAGMA user_version=${SCHEMA_VERSION}`);
337
+ } else if (version !== SCHEMA_VERSION) fail("state_version_unsupported", "Use the matching local-state version or restore a trusted backup to a new directory; existing state was not replaced.");
338
+ this.db.exec("COMMIT");
339
+ } catch (error) { try { this.db.exec("ROLLBACK"); } catch {} throw error; }
340
+ }
341
+ if (this.db.prepare("PRAGMA user_version").get().user_version !== SCHEMA_VERSION) fail("state_version_unsupported", "Use the matching CLI/local-state version; no automatic migration or replacement was performed.");
342
+ validateSchema(this.db);
343
+ if (!UUID.test(this.db.prepare("SELECT value FROM meta WHERE key='config_id'").get()?.value || "")) fail("state_corrupt");
344
+ if (write) this.db.exec("PRAGMA foreign_keys=ON;");
345
+ } catch (error) { try { this.db?.close(); } catch {} this.db = null; throw stateError(error); }
346
+ }
347
+
348
+ close() { if (maintenanceOwners.has(this)) fail("maintenance_queue_active"); this.db?.close(); this.db = null; }
349
+ ready() { if (!this.db) fail("keys_unconfigured", "Run keys stores --json, then keys init with an explicit --store and --card-url in this config directory."); }
350
+ configId() { this.ready(); return this.db.prepare("SELECT value FROM meta WHERE key='config_id'").get().value; }
351
+ async locked(action, { authorizationGuard, withAuthorizationLease } = {}) {
352
+ refuseQueueReentry(this.root);
353
+ const guarded = authorizationGuard !== undefined || withAuthorizationLease !== undefined;
354
+ if (guarded && (typeof authorizationGuard !== "function" || typeof withAuthorizationLease !== "function")) fail("authorization_guard_invalid");
355
+ let runs = 0, leaseActive = true, committed = false, finalResult, pending;
356
+ const run = async () => {
357
+ this.ready(); if (!this.writable) fail("state_readonly");
358
+ if (this.db.prepare("SELECT value FROM meta WHERE key='restore_paused'").get()?.value === "1") fail("restore_archive_only", "Restored state cannot authorize live keys or trust. Keep it for archives; create a separate fresh receiving identity and re-verify peers.");
359
+ const root = this.root, db = this.db;
360
+ const check = () => {
361
+ this.ready(); if (this.root !== root || this.db !== db || !this.writable) fail("state_changed");
362
+ if (db.prepare("SELECT value FROM meta WHERE key='restore_paused'").get()?.value === "1") fail("restore_archive_only");
363
+ if (!guarded) return;
364
+ if (!leaseActive || runs !== 1) fail("authorization_guard_invalid");
365
+ const approved = authorizationGuard();
366
+ if (approved !== true) { if (approved instanceof Promise) approved.catch(() => {}); fail("authorization_changed", "The current local authorization changed; no new delivery was committed."); }
367
+ };
368
+ const release = await acquireQueue(root), context = { root, kind: "transaction", active: true };
369
+ try {
370
+ return await inQueueContext(context, async () => {
371
+ try { db.exec("BEGIN IMMEDIATE"); check(); const result = await action(); check(); db.exec("COMMIT"); committed = true; finalResult = result; return result; }
372
+ catch (error) { try { db.exec("ROLLBACK"); } catch {} if (error instanceof LocalStateError) throw error; fail("state_unavailable"); }
373
+ });
374
+ } finally { release(); }
375
+ };
376
+ // Host lock must precede the local DB queue and stay held until run has committed/rolled back.
377
+ const invoke = () => { if (++runs !== 1) fail("authorization_guard_invalid"); pending = run(); return pending; };
378
+ if (!guarded) return invoke();
379
+ try { await withAuthorizationLease(invoke); }
380
+ catch (error) { leaseActive = false; if (pending) await pending.catch(() => {}); throw error; }
381
+ leaseActive = false; if (pending) await pending;
382
+ if (runs !== 1 || !committed) fail("authorization_guard_invalid");
383
+ return finalResult;
384
+ }
385
+ // Internal coordinator mechanism, not an approval/admission API. Host authority must precede this queue.
386
+ async _withMaintenanceQueue({ assertAuthority, withAuthorityLease } = {}, action) {
387
+ try {
388
+ refuseQueueReentry(this.root, true);
389
+ if (typeof assertAuthority !== "function" || typeof withAuthorityLease !== "function" || typeof action !== "function") fail("maintenance_queue_invalid");
390
+ this.ready(); if (!this.writable) fail("state_readonly");
391
+ const root = this.root, db = this.db, config = this.configId();
392
+ let runs = 0, authorityActive = true, pending, completed = false, finalResult;
393
+ const run = () => {
394
+ if (++runs !== 1 || !authorityActive) fail("maintenance_queue_invalid");
395
+ pending = (async () => {
396
+ const release = await acquireQueue(root), context = { root, kind: "maintenance", active: true }; let live = true, transaction = false;
397
+ const state = () => {
398
+ if (!live) fail("maintenance_queue_expired");
399
+ if (this.root !== root || this.db !== db || this.writable !== true || db.prepare("SELECT value FROM meta WHERE key='config_id'").get()?.value !== config) fail("maintenance_state_changed");
400
+ if (db.prepare("SELECT value FROM meta WHERE key='restore_paused'").get()?.value === "1") fail("restore_archive_only");
401
+ };
402
+ const check = (stage = "transaction") => {
403
+ state(); if (!authorityActive || runs !== 1) fail("maintenance_queue_invalid");
404
+ const approved = assertAuthority(stage);
405
+ if (approved !== true) { if (approved instanceof Promise) approved.catch(() => {}); fail("maintenance_authorization_changed"); }
406
+ state();
407
+ };
408
+ const view = Object.freeze({
409
+ begin: () => { check(); if (transaction) fail("maintenance_transaction_state"); try { db.exec("BEGIN IMMEDIATE"); transaction = true; check(); } catch (error) { try { db.exec("ROLLBACK"); } catch {} transaction = false; throw error; } },
410
+ commit: () => { check(); if (!transaction) fail("maintenance_transaction_state"); db.exec("COMMIT"); transaction = false; check(); },
411
+ rollback: () => { state(); if (!transaction) fail("maintenance_transaction_state"); db.exec("ROLLBACK"); transaction = false; },
412
+ transactionState: () => { state(); return transaction ? "open" : "idle"; },
413
+ configurationIdentity: () => { check(); return config; },
414
+ // Private read-only prerequisite, never human provenance or admission.
415
+ // The caller retains BEGIN IMMEDIATE throughout every await. Native
416
+ // credentials remain gated: no OS prompt is initiated by maintenance.
417
+ assertReadiness: async input => {
418
+ if (!live) fail("maintenance_queue_expired"); const members = readinessMembers(input);
419
+ const started = Date.now();
420
+ const inspect = () => {
421
+ check(); if (!transaction || !db.isTransaction) fail("maintenance_transaction_state");
422
+ if (Date.now() < started) fail("host_readiness_clock");
423
+ if (db.prepare("SELECT 1 FROM key_operations WHERE state!='committed' LIMIT 1").get()) fail("host_readiness_pending");
424
+ return members.map(m => {
425
+ if (db.prepare("SELECT 1 FROM proposals WHERE peer=? AND state='pending' LIMIT 1").get(m.alias)) fail("host_readiness_pending");
426
+ const row = db.prepare("SELECT * FROM trust WHERE peer=?").get(m.alias);
427
+ if (!row || row.version !== m.trust_record_version) fail("host_readiness_changed");
428
+ const r = parseJSON(row.record);
429
+ if (!exact(r, ["v", "configIdentity", "localId", "localKeyId", "localBundleDigest", "peer", "peerEndpoint", "pairingIdentity", "bundle", "policy", "verification", "state", "version"]) || r.v !== 1 || r.configIdentity !== config || r.peer !== m.alias || r.version !== row.version || hash("a2a-exposed sealed trust record v1", r) !== m.trust_record_digest || r.localId !== m.local_id || r.peerEndpoint !== m.endpoint || r.pairingIdentity !== m.pairing_identity || r.localBundleDigest !== m.local_bundle_digest || r.policy !== "required" || r.verification !== "independent" || r.state !== "independently-verified" || hash("a2a-exposed sealed bundle v1", r.bundle) !== m.peer_bundle_digest) fail("host_readiness_changed");
430
+ const key = db.prepare("SELECT * FROM identities WHERE id=?").get(m.local_id);
431
+ if (!key || key.live_state !== "local-only") fail("host_readiness_key");
432
+ const bundle = parseJSON(key.bundle);
433
+ if (hash("a2a-exposed sealed bundle v1", bundle) !== m.local_bundle_digest || bundle.keyId !== r.localKeyId || bundle.identityKey === r.bundle.identityKey || new URL(r.bundle.cardUrl).origin !== new URL(m.endpoint).origin) fail("host_readiness_changed");
434
+ if (key.store !== "file") fail("host_readiness_native_gated");
435
+ return { key: { ...key }, bundle, peerBundle: r.bundle };
436
+ });
437
+ };
438
+ const baseline = inspect(), privateValues = [];
439
+ for (const entry of baseline) {
440
+ await verifyBundle(entry.bundle, { cardUrl: entry.bundle.cardUrl, identityKey: entry.bundle.identityKey });
441
+ await verifyBundle(entry.peerBundle, { cardUrl: entry.peerBundle.cardUrl, identityKey: entry.peerBundle.identityKey });
442
+ const privateValue = await this.privateGet(entry.key, false);
443
+ await checkPrivate(privateValue, entry.bundle); privateValues.push(privateValue);
444
+ // Re-read the complete aggregate, not just the peer whose I/O finished.
445
+ if (canonicalJSON(inspect()) !== canonicalJSON(baseline)) fail("host_readiness_changed");
446
+ }
447
+ for (let n = 0; n < baseline.length; n++) if (privateRead(path.join(root, baseline[n].key.id + ".key")) !== privateValues[n]) fail("host_readiness_key");
448
+ inspect(); return true;
449
+ },
450
+ // Exact approved-proposal/current-record lineage and local comparison.
451
+ // This verifies data consistency only; the host separately verifies its
452
+ // privileged human decision signature. No booleans mint approval here.
453
+ compareApproved: (input, proposalDigests) => {
454
+ const members = readinessMembers(input); let digests;
455
+ try { digests = parseJSON(canonicalJSON(proposalDigests)); } catch { fail("host_comparison_invalid"); }
456
+ if (!Array.isArray(digests) || digests.length !== members.length || new Set(digests).size !== digests.length || digests.some(d => typeof d !== "string" || !KEY.test(d) || Buffer.from(d,"base64url").toString("base64url") !== d)) fail("host_comparison_invalid");
457
+ check(); if (!transaction || !db.isTransaction) fail("maintenance_transaction_state");
458
+ return members.map((m, n) => {
459
+ const row = db.prepare("SELECT * FROM proposals WHERE digest=? AND peer=? AND state='approved'").get(digests[n], m.alias), trust = db.prepare("SELECT * FROM trust WHERE peer=?").get(m.alias), key = db.prepare("SELECT * FROM identities WHERE id=?").get(m.local_id);
460
+ if (!row || !trust || !key) fail("host_comparison_changed");
461
+ const p = parseJSON(row.proposal), r = parseJSON(trust.record), local = parseJSON(key.bundle);
462
+ if (!exact(p,["v","configIdentity","localIdentity","localKeyId","localBundleDigest","localId","peer","peerEndpoint","pairingIdentity","currentRecordDigest","currentRecordVersion","newBundleDigest","newBundle","newPolicy","operation","verificationDecision","proposalNonce"]) || p.v !== 1 || hash("a2a-exposed sealed trust proposal v1",p) !== digests[n] || row.expected_digest !== p.currentRecordDigest || row.expected_version !== p.currentRecordVersion || p.currentRecordVersion + 1 !== m.trust_record_version || trust.version !== m.trust_record_version || r.version !== m.trust_record_version || hash("a2a-exposed sealed trust record v1",r) !== m.trust_record_digest || p.configIdentity !== config || p.localId !== m.local_id || p.localIdentity !== local.identityKey || p.localKeyId !== local.keyId || p.localBundleDigest !== m.local_bundle_digest || p.peer !== m.alias || p.peerEndpoint !== m.endpoint || p.pairingIdentity !== m.pairing_identity || p.newBundleDigest !== m.peer_bundle_digest || hash("a2a-exposed sealed bundle v1",p.newBundle) !== m.peer_bundle_digest || canonicalJSON(p.newBundle) !== canonicalJSON(r.bundle) || p.newPolicy !== "required" || p.verificationDecision !== "independent" || !["enroll","rotate","re-pair","policy","verify"].includes(p.operation)) fail("host_comparison_changed");
463
+ return { approved_proposal_digest: digests[n], compared_safety_code: localSafetyCode(local, r.bundle), current_trust_digest: m.trust_record_digest, current_trust_version: m.trust_record_version };
464
+ });
465
+ },
466
+ stageGrant: input => {
467
+ if (!live) fail("maintenance_queue_expired"); const r = hostGrantBinding(input); check(); if (!transaction) fail("maintenance_transaction_state"); if (r.config_id !== config) fail("host_grant_config");
468
+ const existing = db.prepare("SELECT * FROM host_grant_sets WHERE intent_id=?").get(r.intent_id);
469
+ if (existing) { if (existing.state !== "staged" || HOST_FIELDS.some(k => existing[k] !== r[k])) fail("host_grant_conflict"); return { ...existing }; }
470
+ if (db.prepare("SELECT 1 FROM host_grant_sets WHERE set_id=? OR (config_id=? AND epoch=? AND target_revision=?)").get(r.set_id,r.config_id,r.epoch,r.target_revision)) fail("host_grant_conflict");
471
+ db.prepare("INSERT INTO host_grant_sets VALUES(?,?,?,?,?,?,'staged')").run(...HOST_FIELDS.map(k => r[k])); return { ...r, state: "staged" };
472
+ },
473
+ inspectGrant: intentId => {
474
+ if (!live) fail("maintenance_queue_expired"); if (typeof intentId !== "string" || !hostUUID.test(intentId)) fail("host_grant_invalid"); check(); if (!transaction) fail("maintenance_transaction_state");
475
+ const r = db.prepare("SELECT * FROM host_grant_sets WHERE intent_id=?").get(intentId); if (!r) return null;
476
+ const { state: phase, ...binding } = { ...r }; hostGrantBinding(binding); if (binding.config_id !== config || !["staged","ready","void"].includes(phase)) fail("host_grant_invalid"); return { ...r };
477
+ },
478
+ transitionGrant: (input, expected, next) => {
479
+ if (!live) fail("maintenance_queue_expired"); const r = hostGrantBinding(input); check(); if (!transaction) fail("maintenance_transaction_state"); if (r.config_id !== config) fail("host_grant_config");
480
+ if (!(expected === "staged" && ["ready","void"].includes(next) || expected === "ready" && next === "void")) fail("host_grant_transition");
481
+ const changed = db.prepare("UPDATE host_grant_sets SET state=? WHERE config_id=? AND intent_id=? AND set_id=? AND epoch=? AND target_revision=? AND membership_mac=? AND state=?").run(next,...HOST_FIELDS.map(k => r[k]),expected).changes;
482
+ if (changed !== 1) fail("host_grant_stale"); return { ...r, state: next };
483
+ }
484
+ });
485
+ try {
486
+ return await inQueueContext(context, async () => {
487
+ check("entry"); maintenanceOwners.set(this, context);
488
+ const result = await action(view);
489
+ if (transaction) fail("maintenance_transaction_open");
490
+ check("exit");
491
+ completed = true; finalResult = result; return result;
492
+ });
493
+ } finally {
494
+ live = false; try { db.exec("ROLLBACK"); } catch {} maintenanceOwners.delete(this); release();
495
+ }
496
+ })();
497
+ return pending;
498
+ };
499
+ try { await withAuthorityLease(run); }
500
+ catch (error) { authorityActive = false; if (pending) await pending.catch(() => {}); throw error; }
501
+ authorityActive = false; if (pending) await pending;
502
+ if (runs !== 1 || !completed) fail("maintenance_queue_invalid");
503
+ return finalResult;
504
+ } catch (error) { throw stateError(error); }
505
+ }
506
+ status({ limit = 20, identityCursor = 0, operationCursor = 0 } = {}) {
507
+ pageInputs(limit, identityCursor); pageInputs(limit, operationCursor);
508
+ if (!this.db) return { ok: true, configured: false, identities: [], operations: [], next: "Run keys stores --json before keys init." };
509
+ const rows = this.db.prepare("SELECT rowid AS cursor,id,bundle,store,live_state,created_ms FROM identities WHERE rowid>? ORDER BY rowid LIMIT ?").all(identityCursor, limit + 1);
510
+ const identities = rows.slice(0, limit).map(row => {
511
+ // Metadata-only file check: never read private bytes or prompt the OS keychain.
512
+ let private_key_status = "not_checked", next = "OS keychain availability is not checked by status; use explicit --allow-os-consent on the intended send/read.";
513
+ if (row.store === "file") {
514
+ try { permissions(path.join(this.root, row.id + ".key")); private_key_status = "present"; next = "File exists with protected permissions; this listing does not verify its key material."; }
515
+ catch (error) {
516
+ private_key_status = error.code === "ENOENT" ? "key_missing" : "unavailable";
517
+ next = private_key_status === "key_missing" ? "Restore the original private key from its protected backup; do not run keys init or replace the key. No plaintext fallback." : "Check the original key file permissions and store; do not initialize replacement keys.";
518
+ }
519
+ }
520
+ return { id: row.id, bundle: JSON.parse(row.bundle), key_store: row.store, live_state: row.live_state, private_key_status, next, created_ms: row.created_ms };
521
+ });
522
+ const opRows = this.db.prepare("SELECT rowid AS cursor,id,public_json,digest,state,created_ms,broker_pid,revision FROM key_operations WHERE rowid>? ORDER BY rowid LIMIT ?").all(operationCursor, limit + 1);
523
+ const operations = opRows.slice(0, limit).map(row => ({ id: row.id, operation: JSON.parse(row.public_json), digest: row.digest, state: row.state, created_ms: row.created_ms, broker_pid: row.broker_pid, revision: row.revision, recovery_choices: row.state === "committed" ? [] : ["adopt", "abandon-if-absent"].map(action => ({ action, approval_digest: recoveryApproval(this.configId(), row, action) })) }));
524
+ return { ok: true, configured: Boolean(this.db.prepare("SELECT id FROM identities LIMIT 1").get()), config_identity: this.configId(), archive_only: this.db.prepare("SELECT value FROM meta WHERE key='restore_paused'").get()?.value === "1", identities, operations, next_identity_cursor: rows.length > limit ? rows[limit - 1].cursor : null, next_operation_cursor: opRows.length > limit ? opRows[limit - 1].cursor : null, next: "Continue listings with the returned cursors. For pending key creation, reconcile the exact operation/digest; do not repeat initialization." };
525
+ }
526
+
527
+ row(id) { this.ready(); const row = this.db.prepare("SELECT * FROM identities WHERE id=?").get(id); if (!row) fail("key_unknown", "Select an exact identity ID from keys status --json."); return row; }
528
+ async privateGet(row, allowOsConsent) {
529
+ const bundle = JSON.parse(row.bundle), account = row.id + "/" + bundle.keyId;
530
+ if (row.store === "keychain" && allowOsConsent !== true) fail("key_store_consent");
531
+ try {
532
+ const value = row.store === "file" ? privateRead(path.join(this.root, row.id + ".key")) : await this.keyring({ op: "get", namespace: row.credential_namespace, account }, allowOsConsent);
533
+ if (typeof value !== "string") fail("key_lost", "The private key is unavailable. Restore a trusted protected backup or plan an explicit key replacement; do not change peer trust automatically.");
534
+ return value;
535
+ } catch (error) {
536
+ if (error instanceof LocalStateError) throw error;
537
+ if (error.code === "ENOENT") fail("key_lost", "The private key file is missing. Restore a trusted protected backup; old encrypted history may be unreadable.");
538
+ fail("key_store_unavailable");
539
+ }
540
+ }
541
+ rotationPlan(id) {
542
+ const row = this.row(id);
543
+ if (this.db.prepare("SELECT value FROM meta WHERE key='restore_paused'").get()?.value === "1") fail("restore_archive_only");
544
+ if (row.live_state !== "local-only") fail("key_retired", "Select the current local identity from keys status --json; retired keys remain for archives only.");
545
+ if (this.db.prepare("SELECT id FROM key_operations WHERE state IN ('prepared','writing','secret-written','uncertain') LIMIT 1").get()) fail("initialization_pending", "Reconcile the exact pending key operation from keys status --json before planning another key change.");
546
+ const latest = this.db.prepare("SELECT id,digest,state,revision FROM key_operations ORDER BY rowid DESC LIMIT 1").get();
547
+ const plan = { configIdentity: this.configId(), action: "rotate-receiving-key", previousId: id, previousBundleDigest: hash("a2a-exposed sealed bundle v1", parseJSON(row.bundle)), keyStore: row.store, latestOperation: latest ? { ...latest } : null };
548
+ return { ok: true, plan, approval_digest: hash("a2a-exposed local rotation approval v1", plan), next: "Ask the human to approve this exact local key-rotation plan. Old decryption keys are retained; peer trust is not automatically changed." };
549
+ }
550
+ async rotateLocal({ id: previousId, approvalDigest, ownerApproved = false, allowOsConsent = false, now = Date.now() }) {
551
+ if (ownerApproved !== true) fail("owner_approval_required", "List the exact local rotation plan and obtain human approval; no key change was made.");
552
+ if (!Number.isSafeInteger(now) || now < 0) fail("clock_invalid");
553
+ const plan = this.rotationPlan(previousId);
554
+ if (plan.approval_digest !== approvalDigest) fail("approval_stale");
555
+ const previous = this.row(previousId);
556
+ if (previous.store === "keychain" && allowOsConsent !== true) fail("key_store_consent");
557
+ const identity = await rotateEncryptionKey(await this.loadIdentity(previousId, { allowOsConsent }), now);
558
+ const value = await serialize(identity), id = randomUUID(), namespace = randomUUID(), opId = randomUUID();
559
+ const operation = { v: 1, kind: "rotate", id: opId, identityId: id, bundle: identity.bundle, store: previous.store, namespace, account: id + "/" + identity.bundle.keyId, fileRef: id + ".key", created_ms: now, previousId, previousBundleDigest: plan.plan.previousBundleDigest };
560
+ const digest = hash("a2a-exposed local key operation v1", operation);
561
+ await this.locked(async () => {
562
+ if (this.rotationPlan(previousId).approval_digest !== approvalDigest) fail("approval_stale");
563
+ this.db.prepare("INSERT INTO key_operations(id,kind,public_json,digest,state,created_ms,owner_pid) VALUES(?,?,?,?,?,?,?)").run(opId, "rotate", canonicalJSON(operation), digest, "prepared", now, process.pid);
564
+ });
565
+ inflight.add(opId);
566
+ try {
567
+ this.db.prepare("UPDATE key_operations SET state='writing',revision=revision+1 WHERE id=?").run(opId);
568
+ if (previous.store === "file") privateWrite(path.join(this.root, operation.fileRef), value + "\n");
569
+ else await this.keyring({ op: "set", namespace, account: operation.account, value }, allowOsConsent, pid => this.db.prepare("UPDATE key_operations SET broker_pid=?,revision=revision+1 WHERE id=?").run(pid, opId));
570
+ this.db.prepare("UPDATE key_operations SET state='secret-written',revision=revision+1 WHERE id=?").run(opId);
571
+ return await this.locked(async () => {
572
+ const current = this.row(previousId);
573
+ if (current.live_state !== "local-only" || hash("a2a-exposed sealed bundle v1", parseJSON(current.bundle)) !== operation.previousBundleDigest) fail("approval_stale");
574
+ this.db.prepare("UPDATE identities SET live_state='retired' WHERE id=?").run(previousId);
575
+ this.db.prepare("INSERT INTO identities VALUES(?,?,?,?,?,?)").run(id, canonicalJSON(identity.bundle), previous.store, namespace, "local-only", now);
576
+ this.db.prepare("UPDATE key_operations SET state='committed',revision=revision+1 WHERE id=?").run(opId);
577
+ return { ok: true, id, previous_id: previousId, bundle: identity.bundle, key_store: previous.store, old_key_retained: true, next: "The new receiving key is local only. Existing peer pins are held until a new exact proposal is approved; old keys remain available for archive opening." };
578
+ });
579
+ } catch {
580
+ try { this.db.prepare("UPDATE key_operations SET state='uncertain',revision=revision+1 WHERE id=? AND state<>'committed'").run(opId); } catch {}
581
+ const error = new LocalStateError("rotation_uncertain", "The new private key may have been written. Inspect keys status --json and reconcile its exact intent; do not repeat rotation or delete old keys.");
582
+ error.details = { operation_id: opId, operation_digest: digest }; throw error;
583
+ } finally { inflight.delete(opId); }
584
+ }
585
+
586
+ async createBackup({ destination, passphrase, allowOsConsent = false }) {
587
+ this.ready();
588
+ if (typeof destination !== "string" || !path.isAbsolute(destination)) fail("backup_destination", "Choose a new absolute backup file path; existing files are never overwritten.");
589
+ validateBackupPassphrase(passphrase);
590
+ try { fs.lstatSync(destination); fail("backup_destination_exists", "Choose a new backup file path; existing files and symlinks are never overwritten."); }
591
+ catch (error) { if (error.code !== "ENOENT") throw error; }
592
+ let source;
593
+ try {
594
+ // A separate read transaction provides one consistent public-state snapshot even
595
+ // while another process proposes trust changes. Key references are immutable.
596
+ source = new DatabaseSync(path.join(this.root, "state.sqlite"), { readOnly: true });
597
+ source.exec("PRAGMA busy_timeout=5000; BEGIN"); validateSchema(source);
598
+ const rows = {};
599
+ for (const table of SNAPSHOT_TABLES) {
600
+ rows[table] = source.prepare(`SELECT * FROM ${table} LIMIT 5001`).all().map(row => ({ ...row }));
601
+ if (rows[table].length > 5000) fail("backup_too_large");
602
+ }
603
+ if (rows.key_operations.some(row => !["committed", "orphan-retained", "observed-absent"].includes(row.state))) fail("backup_recovery_required", "Run keys status --json and reconcile pending key creation before backing up. No incomplete backup was written.");
604
+ const snapshot = { format: "a2a-exposed custody snapshot", v: 1, schema: SCHEMA_VERSION, configIdentity: rows.meta.find(row => row.key === "config_id")?.value, createdMs: Date.now(), rows, credentials: [] };
605
+ await validateSnapshot(snapshot, { checkCredentials: false }); // Refuse corrupt paths/references before credential access.
606
+ const credentials = snapshot.credentials;
607
+ for (const row of rows.key_operations) {
608
+ const operation = JSON.parse(row.public_json);
609
+ // Committed operations already confirmed broker exit; its historical PID may
610
+ // now name an unrelated process. Retained unresolved outcomes stay fenced.
611
+ if (row.state !== "committed" && row.broker_pid) {
612
+ try { process.kill(row.broker_pid, 0); fail("operation_in_progress"); }
613
+ catch (error) { if (error instanceof LocalStateError || error.code !== "ESRCH") throw new LocalStateError("operation_in_progress"); }
614
+ }
615
+ let value;
616
+ if (operation.store === "keychain") {
617
+ if (allowOsConsent !== true) fail("key_store_consent");
618
+ value = await this.keyring({ op: "get", namespace: operation.namespace, account: operation.account }, allowOsConsent);
619
+ } else {
620
+ try { value = privateRead(path.join(this.root, operation.fileRef)); }
621
+ catch (error) { if (error.code !== "ENOENT") throw error; value = null; }
622
+ }
623
+ if (value == null && row.state !== "observed-absent") fail("key_lost");
624
+ credentials.push({ id: operation.identityId, value: value ?? null });
625
+ }
626
+ await validateSnapshot(snapshot);
627
+ const encrypted = sealBackup(snapshot, passphrase);
628
+ source.exec("COMMIT"); source.close(); source = null;
629
+ let directorySynced;
630
+ try { directorySynced = privateWrite(destination, encrypted); }
631
+ catch (error) { if (error.code === "EEXIST") fail("backup_destination_exists", "The backup destination now exists. Choose a new file path; no existing file was overwritten."); throw error; }
632
+ return { ok: true, durability: directorySynced ? "file-and-directory-sync-completed" : "directory-sync-unverified", config_identity: snapshot.configIdentity, identities: rows.identities.length, archived_credentials: credentials.filter(row => row.value !== null).length, observed_absent_credentials: credentials.filter(row => row.value === null).length, next: "Keep this encrypted file and its passphrase separately. Restore only into a new private directory; restored keys are archive-only." };
633
+ } finally { try { source?.exec("ROLLBACK"); source?.close(); } catch {} }
634
+ }
635
+
636
+ async initialize({ store, cardUrl, allowOsConsent = false, now = Date.now() }) {
637
+ if (!["file", "keychain"].includes(store)) fail("key_store", "Select --store from keys stores --json; no store is chosen automatically.");
638
+ if (store === "keychain" && allowOsConsent !== true) fail("key_store_consent", "Pass --allow-os-consent when a human can answer an OS prompt, or use --store file for unattended operation. Run keys stores --json.");
639
+ const identity = await generateIdentity(cardUrl, now), value = await serialize(identity), id = randomUUID(), namespace = randomUUID(), opId = randomUUID();
640
+ const operation = { v: 1, kind: "initialize", id: opId, identityId: id, bundle: identity.bundle, store, namespace, account: id + "/" + identity.bundle.keyId, fileRef: id + ".key", created_ms: now };
641
+ const digest = hash("a2a-exposed local key operation v1", operation);
642
+ // Commit public intent before a file or OS credential can be written. Keep every intent for recovery.
643
+ await this.locked(async () => {
644
+ if (this.db.prepare("SELECT id FROM identities LIMIT 1").get()) fail("keys_exist", "This config already has keys. Run keys status --json; existing private keys are never overwritten.");
645
+ if (this.db.prepare("SELECT id FROM key_operations WHERE state IN ('prepared','writing','secret-written','uncertain') LIMIT 1").get()) fail("initialization_pending", "Run keys status --json and reconcile the exact recorded initialization intent before retrying; do not create another key.");
646
+ this.db.prepare("INSERT INTO key_operations(id,kind,public_json,digest,state,created_ms,owner_pid) VALUES(?,?,?,?,?,?,?)").run(opId, "initialize", JSON.stringify(operation), digest, "prepared", now, process.pid);
647
+ });
648
+ inflight.add(opId);
649
+ try {
650
+ this.db.prepare("UPDATE key_operations SET state='writing',revision=revision+1 WHERE id=?").run(opId);
651
+ if (store === "file") privateWrite(path.join(this.root, operation.fileRef), value + "\n");
652
+ else await this.keyring({ op: "set", namespace, account: operation.account, value }, allowOsConsent, pid => this.db.prepare("UPDATE key_operations SET broker_pid=?,revision=revision+1 WHERE id=?").run(pid, opId));
653
+ this.db.prepare("UPDATE key_operations SET state='secret-written',revision=revision+1 WHERE id=?").run(opId);
654
+ return await this.locked(async () => {
655
+ this.db.prepare("INSERT INTO identities VALUES(?,?,?,?,?,?)").run(id, JSON.stringify(identity.bundle), store, namespace, "local-only", now);
656
+ this.db.prepare("UPDATE key_operations SET state='committed',revision=revision+1 WHERE id=?").run(opId);
657
+ return { ok: true, id, bundle: identity.bundle, key_store: store, live_state: "local-only", operation_id: opId, next: "Fresh keys remain local; no Worker enrollment or pairing trust was changed." };
658
+ });
659
+ } catch {
660
+ try { this.db.prepare("UPDATE key_operations SET state='uncertain',revision=revision+1 WHERE id=? AND state<>'committed'").run(opId); } catch {}
661
+ const error = new LocalStateError("initialization_uncertain", "Key creation may have written local credentials. Run keys status --json and recover this exact intent; do not repeat initialization or delete keys automatically.");
662
+ error.details = { operation_id: opId, operation_digest: digest, key_store: store }; throw error;
663
+ } finally { inflight.delete(opId); }
664
+ }
665
+ async reconcileInitialization({ operationId, expectedDigest, action, approvalDigest, ownerApproved = false, allowOsConsent = false }) {
666
+ if (ownerApproved !== true || !["adopt", "abandon-if-absent"].includes(action)) fail("owner_approval_required", "List recovery intents with keys status --json, then select an exact operation/digest and explicit recovery action approved by the human.");
667
+ this.ready();
668
+ const row = this.db.prepare("SELECT * FROM key_operations WHERE id=? AND kind IN ('initialize','rotate')").get(operationId);
669
+ if (!row || row.digest !== expectedDigest || row.state === "committed" || !Number.isSafeInteger(row.revision) || recoveryApproval(this.configId(), row, action) !== approvalDigest) fail("approval_stale", "Run keys status --json; the recovery intent or its state changed.");
670
+ if (inflight.has(operationId)) fail("operation_in_progress", "The original initialization is still running; inspect keys status --json after it exits.");
671
+ if (["prepared", "writing", "secret-written"].includes(row.state) && row.owner_pid !== process.pid) {
672
+ try { process.kill(row.owner_pid, 0); fail("operation_in_progress"); } catch (error) { if (error instanceof LocalStateError || error.code !== "ESRCH") throw new LocalStateError("operation_in_progress"); }
673
+ }
674
+ if (row.broker_pid) {
675
+ try { process.kill(row.broker_pid, 0); fail("operation_in_progress", "The recorded credential broker may still be running. Wait for confirmed process exit before reconciliation; no provider outcome is assumed."); }
676
+ catch (error) { if (error instanceof LocalStateError || error.code !== "ESRCH") throw new LocalStateError("operation_in_progress"); }
677
+ }
678
+ const op = JSON.parse(row.public_json);
679
+ if (hash("a2a-exposed local key operation v1", op) !== expectedDigest || !UUID.test(op.identityId) || op.fileRef !== op.identityId + ".key" || !UUID.test(op.namespace) || op.account !== op.identityId + "/" + op.bundle?.keyId || !["file", "keychain"].includes(op.store) || op.kind !== row.kind) fail("state_corrupt");
680
+ if (op.kind === "rotate") {
681
+ const prior = parseJSON(this.row(op.previousId).bundle);
682
+ if (hash("a2a-exposed sealed bundle v1", prior) !== op.previousBundleDigest || prior.identityKey !== op.bundle.identityKey || prior.cardUrl !== op.bundle.cardUrl || prior.keyId === op.bundle.keyId) fail("state_corrupt");
683
+ }
684
+ // All provider reads and key checks occur outside the write transaction. The final
685
+ // mutation must compare the approved state/revision again, including no-op-looking results.
686
+ const transition = state => {
687
+ const result = this.db.prepare("UPDATE key_operations SET state=?,revision=revision+1 WHERE id=? AND digest=? AND state=? AND revision=?").run(state, operationId, expectedDigest, row.state, row.revision);
688
+ if (result.changes !== 1) fail("approval_stale", "The recovery state changed while credentials were inspected. Run keys status --json and obtain approval of a new action-specific recovery digest.");
689
+ };
690
+ let value;
691
+ if (op.store === "keychain") {
692
+ if (allowOsConsent !== true) fail("key_store_consent");
693
+ value = await this.keyring({ op: "get", namespace: op.namespace, account: op.account }, allowOsConsent);
694
+ } else {
695
+ try { value = privateRead(path.join(this.root, op.fileRef)); }
696
+ catch (error) { if (error.code !== "ENOENT") throw error; value = null; }
697
+ }
698
+ if (value === null || value === undefined) {
699
+ if (action !== "abandon-if-absent") fail("key_lost", "The recorded key is absent at this observation. Choose abandon-if-absent explicitly to permit a new initialization; the old intent remains catalogued for late native completion.");
700
+ return this.locked(async () => {
701
+ transition("observed-absent");
702
+ return { ok: true, operation_id: operationId, observed_absent: true, retained_intent: true, next: "No credential was deleted. The retained intent can be inspected again if a late native write appears." };
703
+ });
704
+ }
705
+ if (action !== "adopt") fail("credential_present", "The recorded credential exists. Adopt the exact intent to recover it; it was not deleted or overwritten.");
706
+ const identity = await deserialize(value, op.bundle);
707
+ await seal({ identity, recipient: op.bundle, binding: { contextId: randomUUID(), messageId: randomUUID(), taskId: null, role: "user", state: null, kind: "message" }, content: { purpose: "recover-local-key-check" } });
708
+ return this.locked(async () => {
709
+ const prior = op.kind === "rotate" ? this.row(op.previousId) : null;
710
+ const canRotate = prior && prior.live_state === "local-only" && hash("a2a-exposed sealed bundle v1", parseJSON(prior.bundle)) === op.previousBundleDigest;
711
+ if ((op.kind === "rotate" && !canRotate) || (op.kind !== "rotate" && this.db.prepare("SELECT id FROM identities LIMIT 1").get())) {
712
+ transition("orphan-retained");
713
+ return { ok: true, operation_id: operationId, retained_orphan: true, next: "Another identity is active. This older credential remains tracked for archive/recovery; no active identity or trust was replaced." };
714
+ }
715
+ if (canRotate) this.db.prepare("UPDATE identities SET live_state='retired' WHERE id=?").run(op.previousId);
716
+ this.db.prepare("INSERT INTO identities VALUES(?,?,?,?,?,?)").run(op.identityId, JSON.stringify(op.bundle), op.store, op.namespace, "local-only", op.created_ms);
717
+ transition("committed");
718
+ return { ok: true, id: op.identityId, operation_id: operationId, recovered: true, bundle: op.bundle, next: "The original local key was recovered; no replacement, enrollment or pairing change occurred." };
719
+ });
720
+ }
721
+ async loadIdentity(id, { allowOsConsent = false, purpose = "archive" } = {}) {
722
+ const row = this.row(id);
723
+ if (purpose !== "archive" && !(purpose === "traffic" && process.env.A2A_SEALED_ENABLED === "1" && row.live_state === "local-only" && this.status().archive_only === false)) fail("traffic_disabled", "Encrypted traffic requires the approved local test flag and an active, unrestored identity.");
724
+ const identity = await deserialize(await this.privateGet(row, allowOsConsent), JSON.parse(row.bundle));
725
+ await seal({ identity, recipient: identity.bundle, binding: { contextId: randomUUID(), messageId: randomUUID(), taskId: null, role: "user", state: null, kind: "message" }, content: { purpose: "local-key-pair-check" } });
726
+ return identity;
727
+ }
728
+ inspect(peer, { limit = 20, proposalCursor = 0, historyVersion = 0 } = {}) {
729
+ pageInputs(limit, proposalCursor); pageInputs(limit, historyVersion);
730
+ this.ready(); const row = this.db.prepare("SELECT * FROM trust WHERE peer=?").get(peer);
731
+ const current = row ? JSON.parse(row.record) : null;
732
+ const proposalRows = this.db.prepare("SELECT rowid AS cursor,digest,proposal,state FROM proposals WHERE peer=? AND state='pending' AND rowid>? ORDER BY rowid LIMIT ?").all(peer, proposalCursor, limit + 1);
733
+ const proposals = proposalRows.slice(0, limit).map(x => ({ digest: x.digest, proposal: JSON.parse(x.proposal), state: x.state }));
734
+ const pending = this.db.prepare("SELECT digest FROM proposals WHERE peer=? AND state='pending' LIMIT 1").get(peer);
735
+ const currentKey = current && this.row(current.localId);
736
+ const keyPending = currentKey && (currentKey.live_state !== "local-only" || this.db.prepare("SELECT id FROM key_operations WHERE kind='rotate' AND state IN ('prepared','writing','secret-written','uncertain') AND json_extract(public_json,'$.previousId')=? LIMIT 1").get(current.localId));
737
+ const historyRows = this.db.prepare("SELECT version,record,digest,approved_ms FROM trust_history WHERE peer=? AND version>? ORDER BY version LIMIT ?").all(peer, historyVersion, limit + 1);
738
+ const history = historyRows.slice(0, limit).map(x => ({ version: x.version, record: JSON.parse(x.record), digest: x.digest, approved_ms: x.approved_ms }));
739
+ return { ok: true, config_identity: this.configId(), peer, current, history, next_proposal_cursor: proposalRows.length > limit ? proposalRows[limit - 1].cursor : null, next_history_version: historyRows.length > limit ? historyRows[limit - 1].version : null, live_state: this.db.prepare("SELECT value FROM meta WHERE key='restore_paused'").get()?.value === "1" ? "archive-only" : (pending || keyPending) ? "change-pending" : current?.state || "unpinned", pairing: "not-checked", policies: [...POLICIES], verification_choices: ["tofu", "independent"], current_digest: row ? hash("a2a-exposed sealed trust record v1", current) : "none", current_version: row?.version || 0, proposals };
740
+ }
741
+ async propose({ peer, localId, bundle, peerEndpoint, pairingIdentity, expectedCurrent, policy, verification, operation = "enroll", now = Date.now() }) {
742
+ bundle = parseJSON(canonicalJSON(bundle));if(peerEndpoint===undefined)peerEndpoint=bundle.cardUrl;
743
+ try { if(typeof peerEndpoint!=="string")fail("proposal_endpoint");const endpoint = new URL(peerEndpoint);if(endpoint.href!==peerEndpoint||endpoint.protocol!=="https:"||endpoint.username||endpoint.password||endpoint.hash||endpoint.search||endpoint.origin!==new URL(bundle.cardUrl).origin)fail("proposal_endpoint", "Supply the exact canonical HTTPS JSON-RPC endpoint on the approved key-card origin."); } catch { fail("proposal_endpoint", "Supply the exact canonical HTTPS JSON-RPC endpoint on the approved key-card origin."); }
744
+ if (typeof peer !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(peer) || !KEY.test(pairingIdentity || "") || !POLICIES.includes(policy) || !["tofu", "independent"].includes(verification) || !["enroll", "rotate", "re-pair", "policy", "verify", "revoke"].includes(operation)) fail("proposal_inputs", "Run peer keys inspect --to ALIAS --json, then supply explicit policy, verification, pairing identity and expected current digest.");
745
+ await verifyBundle(bundle, { cardUrl: bundle.cardUrl, identityKey: bundle.identityKey, now });
746
+ return this.locked(async () => {
747
+ const local = this.row(localId), current = this.inspect(peer);
748
+ if (expectedCurrent !== current.current_digest) fail("approval_stale", "Run peer keys inspect --to ALIAS --json; the full trust record changed.");
749
+ if (local.live_state !== "local-only") fail("key_retired");
750
+ const localBundle = JSON.parse(local.bundle);
751
+ const proposal = { v: 1, configIdentity: this.configId(), localIdentity: localBundle.identityKey, localKeyId: localBundle.keyId, localBundleDigest: hash("a2a-exposed sealed bundle v1", localBundle), localId, peer, peerEndpoint, pairingIdentity, currentRecordDigest: current.current_digest, currentRecordVersion: current.current_version, newBundleDigest: hash("a2a-exposed sealed bundle v1", bundle), newBundle: bundle, newPolicy: policy, operation, verificationDecision: verification, proposalNonce: b64(randomBytes(32)) };
752
+ const digest = hash("a2a-exposed sealed trust proposal v1", proposal);
753
+ this.db.prepare("INSERT INTO proposals VALUES(?,?,?,?,?,?)").run(digest, peer, current.current_digest, current.current_version, JSON.stringify(proposal), "pending");
754
+ return { ok: true, peer, digest, proposal, next: "The proposal grants no trust. Ask the human to approve this exact digest after any required independent verification." };
755
+ });
756
+ }
757
+ async approve({ peer, digest, expectedCurrent, ownerApproved = false, independentVerified = false, verifiedProposalDigest, rePairVerified = false }) {
758
+ if (ownerApproved !== true) fail("owner_approval_required", "The human must approve the exact proposal digest; peer messages and hosted mirrors do not authorize it.");
759
+ return this.locked(async () => {
760
+ const row = this.db.prepare("SELECT * FROM proposals WHERE digest=? AND peer=? AND state='pending'").get(digest, peer), current = this.inspect(peer);
761
+ if (!row || expectedCurrent !== current.current_digest || row.expected_digest !== current.current_digest || row.expected_version !== current.current_version) fail("approval_stale", "Run peer keys inspect --to ALIAS --json and approve the current exact proposal.");
762
+ const proposal = JSON.parse(row.proposal);
763
+ if (this.row(proposal.localId).live_state !== "local-only") fail("key_retired");
764
+ if (hash("a2a-exposed sealed trust proposal v1", proposal) !== digest || hash("a2a-exposed sealed bundle v1", JSON.parse(this.row(proposal.localId).bundle)) !== proposal.localBundleDigest) fail("approval_stale");
765
+ if (proposal.verificationDecision === "independent" && (independentVerified !== true || verifiedProposalDigest !== digest)) fail("independent_verification_required", "Compare the locally computed safety code through another trusted channel; a hosted comparison is not independent verification.");
766
+ const identityChanged = current.current && (current.current.bundle.identityKey !== proposal.newBundle.identityKey || current.current.pairingIdentity !== proposal.pairingIdentity || parseJSON(this.row(current.current.localId).bundle).identityKey !== proposal.localIdentity);
767
+ const localKeyChanged = current.current && current.current.localKeyId !== proposal.localKeyId;
768
+ if (identityChanged && rePairVerified !== true) fail("re_pair_required", "Complete a separately human-approved pairing and re-verify the identity before approving this proposal.");
769
+ if ((identityChanged || localKeyChanged) && (proposal.verificationDecision !== "independent" || independentVerified !== true || verifiedProposalDigest !== digest)) fail("independent_verification_required", "Identity changes require a new independent-verification proposal and comparison of that exact proposal's safety code after human-approved re-pairing.");
770
+ const version = current.current_version + 1;
771
+ const record = { v: 1, configIdentity: this.configId(), localId: proposal.localId, localKeyId: proposal.localKeyId, localBundleDigest: proposal.localBundleDigest, peer, peerEndpoint: proposal.peerEndpoint, pairingIdentity: proposal.pairingIdentity, bundle: proposal.newBundle, policy: proposal.newPolicy, verification: proposal.verificationDecision, state: proposal.operation === "revoke" ? "revoked" : proposal.verificationDecision === "independent" ? "independently-verified" : "tofu-pinned", version };
772
+ this.db.prepare("INSERT INTO trust_history VALUES(?,?,?,?,?)").run(peer, version, JSON.stringify(record), hash("a2a-exposed sealed trust record v1", record), Date.now());
773
+ this.db.prepare("INSERT INTO trust VALUES(?,?,?) ON CONFLICT(peer) DO UPDATE SET version=excluded.version,record=excluded.record").run(peer, version, JSON.stringify(record));
774
+ this.db.prepare("UPDATE proposals SET state='superseded' WHERE peer=? AND state='pending' AND digest<>?").run(peer, digest);
775
+ this.db.prepare("UPDATE proposals SET state='approved' WHERE digest=?").run(digest);
776
+ return { ok: true, peer, record, digest: hash("a2a-exposed sealed trust record v1", record), next: "Local trust only; no server pairing or access was changed." };
777
+ });
778
+ }
779
+ async reject({ peer, digest }) { return this.locked(async () => { const result = this.db.prepare("UPDATE proposals SET state='rejected' WHERE peer=? AND digest=? AND state='pending'").run(peer, digest); if (!result.changes) fail("approval_stale"); return { ok: true, peer, digest, rejected: true }; }); }
780
+ safetyCode(localId, peer, { proposalDigest } = {}) {
781
+ const localRow = this.row(localId);
782
+ if (localRow.live_state !== "local-only") fail("key_retired");
783
+ const local = JSON.parse(localRow.bundle), inspection = this.inspect(peer);
784
+ let other, verification, source;
785
+ if (proposalDigest) {
786
+ const row = this.db.prepare("SELECT proposal,expected_digest FROM proposals WHERE digest=? AND peer=? AND state='pending'").get(proposalDigest, peer);
787
+ if (!row) fail("approval_stale", "Select an exact pending proposal digest from peer keys inspect --to ALIAS --json.");
788
+ const proposal = JSON.parse(row.proposal);
789
+ if (hash("a2a-exposed sealed trust proposal v1", proposal) !== proposalDigest || proposal.localId !== localId || proposal.configIdentity !== this.configId() || row.expected_digest !== inspection.current_digest || proposal.localBundleDigest !== hash("a2a-exposed sealed bundle v1", local)) fail("approval_stale");
790
+ other = proposal.newBundle; verification = "proposed-unapproved"; source = "proposal";
791
+ } else {
792
+ if (inspection.live_state === "change-pending") fail("proposal_required", "A key change is pending. Select its exact proposal digest from peer keys inspect --to ALIAS --json before comparing a code.");
793
+ const current = inspection.current;
794
+ if (!current || current.localId !== localId || current.localBundleDigest !== hash("a2a-exposed sealed bundle v1", local)) fail("key_unknown", "Select a locally approved peer from peer keys inspect --to ALIAS --json.");
795
+ other = current.bundle; verification = current.verification; source = "current";
796
+ }
797
+ if (local.identityKey === other.identityKey) fail("same_identity");
798
+ const code = localSafetyCode(local, other);
799
+ return { ok: true, config_identity: this.configId(), local_id: localId, peer, source, ...(proposalDigest ? { proposal_digest: proposalDigest } : {}), code, verification, next: "Compare every group through another trusted channel. This read-only code grants no trust; approve only its exact proposal digest." };
800
+ }
801
+ }
802
+
803
+ // Public surface returns fixed recovery errors. Private provider/SQLite/JSON strings never escape.
804
+ for (const name of ["status", "rotationPlan", "rotateLocal", "createBackup", "initialize", "reconcileInitialization", "loadIdentity", "inspect", "propose", "approve", "reject", "safetyCode"]) {
805
+ const action = LocalState.prototype[name];
806
+ LocalState.prototype[name] = action.constructor.name === "AsyncFunction"
807
+ ? async function (...args) { try { return await action.apply(this, args); } catch (error) { throw stateError(error); } }
808
+ : function (...args) { try { return action.apply(this, args); } catch (error) { throw stateError(error); } };
809
+ }