phaedo-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. package/vendored-files.mjs +82 -0
@@ -0,0 +1,217 @@
1
+ // §8.3 signing ceremony — the MCP (server) half of the round-trip.
2
+ //
3
+ // The server holds the mandate; the phone holds the only key that can sign it. This module
4
+ // deposits a SIGN REQUEST for the subject, polls for the signed result, and VERIFIES it —
5
+ // reusing the escalation transport (relay lane, envelope crypto, first-answer-wins,
6
+ // push-to-wake) but NOT its semantics. An escalation answer is approve/deny/modify — a
7
+ // decision internal to the subject's system. A signature is a credential asserted to a
8
+ // third party. Conflating them is the §10.2 action_binding failure mode one layer up, so
9
+ // the request carries a distinct `kind` and the response is a signature or an explicit
10
+ // refusal, never a decision verb.
11
+ //
12
+ // SIGN WHAT YOU SEE. The request carries the mandate OBJECT, not pre-computed bytes. The
13
+ // phone renders that object AND derives the signing input from it, so it cannot be shown
14
+ // one thing and made to sign another. The server re-derives the same bytes here and
15
+ // verifies against them, which independently confirms the phone signed the mandate the
16
+ // server actually built.
17
+
18
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
19
+ import { signingInput, verifyEs256 } from './signing.js';
20
+
21
+ const { Kdf, Envelope } = Phaedo;
22
+ const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
23
+
24
+ const SIGN_REQUEST_KIND = 'sign_request';
25
+ const SIGN_RESPONSE_KIND = 'sign_response';
26
+ // AP2 mandates omit `signature` from the signing input (the field the signature fills).
27
+ const AP2_SIGNATURE_FIELD = 'signature';
28
+
29
+ export function relayBaseFromEnv(env = process.env) {
30
+ // PHAEDO_RELAY is the name every other lane reads (escalation, receipts, capture, delegation).
31
+ // This one read PHAEDO_RELAY_URL alone, so a self-hosted relay silently did NOT cover the §8.3
32
+ // signing ceremony — sign requests kept going to the default host. The old name is still
33
+ // honoured so an existing setup does not break.
34
+ return env.PHAEDO_RELAY || env.PHAEDO_RELAY_URL || DEFAULT_RELAY;
35
+ }
36
+
37
+ function relayAuthHeader(pairing) {
38
+ const id = pairing && pairing.relay_device_id;
39
+ const secret = pairing && pairing.relay_device_secret;
40
+ return id && secret ? { authorization: `Bearer ${id}:${secret}` } : {};
41
+ }
42
+
43
+ function genRequestId() {
44
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
45
+ return `sig-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
46
+ }
47
+
48
+ /**
49
+ * Build the encrypted SIGN REQUEST deposit body. Sealed under fp_sync_key, exactly like an
50
+ * escalation request, and posted to the same relay lane — the phone routes on `kind`.
51
+ * `mandate` is the object to be signed (unsigned; the `signature` field stays null/absent).
52
+ */
53
+ export async function buildSignRequestDeposit(pairKeyB64u, requestId, mandate, { windowMs, now = () => new Date().toISOString() } = {}) {
54
+ const pairKey = b64uDec(pairKeyB64u);
55
+ let fpSyncKey;
56
+ try {
57
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
58
+ } finally {
59
+ pairKey.fill(0);
60
+ }
61
+ try {
62
+ const inner = { kind: SIGN_REQUEST_KIND, request_id: requestId, data: { mandate } };
63
+ const plaintext = new TextEncoder().encode(JSON.stringify(inner));
64
+ const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: SIGN_REQUEST_KIND, created_at: now() });
65
+ plaintext.fill(0);
66
+ return { request_id: requestId, envelope, ...(windowMs ? { window_ms: windowMs } : {}) };
67
+ } finally {
68
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Decrypt the phone's SIGN RESPONSE. Returns the inner data — either
74
+ * `{ signature, encoding, signed_at }` or `{ refused: true, reason? }` — or null on any
75
+ * failure. A malformed or forged response must never crash the agent's resume path.
76
+ */
77
+ export async function unwrapSignResponse(pairKeyB64u, body) {
78
+ if (!body || !body.envelope) return null;
79
+ const pairKey = b64uDec(pairKeyB64u);
80
+ let fpSyncKey;
81
+ try {
82
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
83
+ } catch { pairKey.fill(0); return null; }
84
+ pairKey.fill(0);
85
+ try {
86
+ const plaintext = await Envelope.unwrap(body.envelope, fpSyncKey);
87
+ const inner = JSON.parse(new TextDecoder().decode(plaintext));
88
+ if (inner && inner.kind === SIGN_RESPONSE_KIND && inner.data) return inner.data;
89
+ return null;
90
+ } catch {
91
+ return null;
92
+ } finally {
93
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Verify a signed mandate. The mandate is re-canonicalized here (signature field omitted)
99
+ * and checked against the subject's published signing key — so a valid result means BOTH
100
+ * that the signature is genuine AND that the phone signed the exact mandate the server
101
+ * built. Returns { verified, reason? }.
102
+ *
103
+ * `signingPublicKey` is the base64 X9.63 point from `phaedo_signing_public_key` (published
104
+ * over the trusted pairing, §8.3 step 2). If it is missing, verification cannot proceed —
105
+ * that is distinct from a bad signature and is reported as such.
106
+ */
107
+ export function verifySignedMandate({ mandate, signature, encoding = 'der', signingPublicKey }) {
108
+ if (!signingPublicKey) return { verified: false, reason: 'no-published-signing-key' };
109
+ if (typeof signature !== 'string' || !signature) return { verified: false, reason: 'no-signature' };
110
+ let sigBytes, keyBytes;
111
+ try {
112
+ sigBytes = Buffer.from(signature, 'base64');
113
+ keyBytes = Buffer.from(signingPublicKey, 'base64');
114
+ } catch {
115
+ return { verified: false, reason: 'undecodable' };
116
+ }
117
+ try {
118
+ const verified = verifyEs256({
119
+ payload: mandate,
120
+ signature: sigBytes,
121
+ publicKey: keyBytes,
122
+ encoding,
123
+ field: AP2_SIGNATURE_FIELD,
124
+ });
125
+ return verified ? { verified: true } : { verified: false, reason: 'signature-mismatch' };
126
+ } catch (e) {
127
+ // A malformed KEY (not a bad signature) throws in the core; surface it as its own
128
+ // reason rather than reporting "not the subject's signature".
129
+ return { verified: false, reason: `key-error:${String(e && e.message || e)}` };
130
+ }
131
+ }
132
+
133
+ /** The exact bytes the phone is asked to sign — exported so a caller/test can assert the
134
+ * server and phone agree on them. */
135
+ export function mandateSigningInput(mandate) {
136
+ return signingInput(mandate, { field: AP2_SIGNATURE_FIELD });
137
+ }
138
+
139
+ /**
140
+ * Deposit a SIGN REQUEST and wake the device. Returns
141
+ * { deposited, request_id, window_ms, expires_at } or { deposited:false, reason }.
142
+ */
143
+ export async function depositSignRequest(pairing, mandate, deps = {}) {
144
+ const fetchFn = deps.fetch || globalThis.fetch;
145
+ if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
146
+ if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
147
+ if (!mandate || typeof mandate !== 'object') return { deposited: false, reason: 'no-mandate' };
148
+ // A mandate that already carries a signature must not be re-sent for signing.
149
+ if (mandate.signature != null) return { deposited: false, reason: 'already-signed' };
150
+
151
+ const windowMs = Math.max(1000, Number(deps.windowMs) || 5 * 60 * 1000);
152
+ const relayBase = deps.relayBase || relayBaseFromEnv();
153
+ const requestId = deps.requestId || genRequestId();
154
+ try {
155
+ const body = await buildSignRequestDeposit(pairing.pair_key, requestId, mandate, { windowMs });
156
+ const res = await fetchFn(`${relayBase}/escalation/${encodeURIComponent(pairing.relay_device_id)}`, {
157
+ method: 'POST',
158
+ headers: { 'Content-Type': 'application/json', ...relayAuthHeader(pairing) },
159
+ body: JSON.stringify(body),
160
+ signal: AbortSignal.timeout(deps.depositTimeoutMs || 8000),
161
+ });
162
+ if (!res || !res.ok) return { deposited: false, reason: `deposit-failed-${res && res.status}`, request_id: requestId };
163
+ let info = {};
164
+ try { info = await res.json(); } catch { /* body optional */ }
165
+ return { deposited: true, request_id: requestId, window_ms: windowMs, expires_at: info.expires_at, woke: info.woke };
166
+ } catch (e) {
167
+ return { deposited: false, reason: `deposit-error:${String(e && e.message || e)}`, request_id: requestId };
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Poll ONCE for a signed mandate. Returns:
173
+ * { resolved:true, signed:true, mandate } — signature verified; mandate now carries it
174
+ * { resolved:true, signed:false, reason:'refused' } — the subject declined to sign
175
+ * { resolved:true, signed:false, reason:'verify-failed', detail } — a response arrived but did not verify
176
+ * { resolved:false, status:'pending' | 'expired' }
177
+ * Never throws. A verify failure is resolved-but-unsigned, never silently treated as signed.
178
+ */
179
+ export async function pollSignOnce(pairing, requestId, mandate, deps = {}) {
180
+ const fetchFn = deps.fetch || globalThis.fetch;
181
+ if (!pairing || !pairing.pair_key || !requestId) return { resolved: false, status: 'expired', reason: 'bad-args' };
182
+ if (deps.expiresAt && (deps.now ? deps.now() : Date.now()) > deps.expiresAt) return { resolved: false, status: 'expired' };
183
+ const relayBase = deps.relayBase || relayBaseFromEnv();
184
+ let body = null;
185
+ try {
186
+ const res = await fetchFn(`${relayBase}/escalation/response/${encodeURIComponent(requestId)}`, {
187
+ headers: relayAuthHeader(pairing),
188
+ signal: AbortSignal.timeout(deps.pollTimeoutMs || 8000),
189
+ });
190
+ if (res && res.ok) body = await res.json();
191
+ } catch { /* transient */ }
192
+ if (!body) return { resolved: false, status: 'pending' };
193
+
194
+ const data = await unwrapSignResponse(pairing.pair_key, body);
195
+ if (!data) return { resolved: false, status: 'pending' }; // undecryptable → keep waiting, don't crash
196
+
197
+ if (data.refused === true) {
198
+ return { resolved: true, signed: false, reason: 'refused', request_id: requestId };
199
+ }
200
+
201
+ const check = verifySignedMandate({
202
+ mandate,
203
+ signature: data.signature,
204
+ encoding: data.encoding || 'der',
205
+ signingPublicKey: deps.signingPublicKey,
206
+ });
207
+ if (!check.verified) {
208
+ return { resolved: true, signed: false, reason: 'verify-failed', detail: check.reason, request_id: requestId };
209
+ }
210
+ return {
211
+ resolved: true,
212
+ signed: true,
213
+ request_id: requestId,
214
+ mandate: { ...mandate, signature: data.signature, signature_encoding: data.encoding || 'der',
215
+ signed_at: typeof data.signed_at === 'string' ? data.signed_at : undefined },
216
+ };
217
+ }
package/signing.js ADDED
@@ -0,0 +1,176 @@
1
+ // §8.3 asymmetric signing — the verification half, server-side.
2
+ //
3
+ // The MCP server CANNOT sign: the subject's signing key is non-extractable inside the
4
+ // phone's Secure Enclave (spec §8.3, verified on hardware 2026-07-23). What the server
5
+ // does is (a) compute the exact bytes a signature covers, so the phone can be asked to
6
+ // sign them, and (b) VERIFY a signature once the phone returns one. Signing itself is a
7
+ // ceremony that belongs to the device holding the key, and deliberately stays there.
8
+ //
9
+ // PURE: no I/O, no globals. Crypto is node:crypto only — no third-party dependency is
10
+ // introduced into the verification path.
11
+
12
+ import { createPublicKey, verify as nodeVerify } from 'node:crypto';
13
+
14
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
15
+
16
+ // ── RFC 8785 canonical JSON (spec §6) ────────────────────────────────────────
17
+ //
18
+ // ⚠️ A FAITHFUL PORT. This is the FOURTH copy in the repo — envelope.js (extension),
19
+ // mobile/src/lib/envelope.ts, the mirror in scripts/test_cross_compat.mjs, and now this
20
+ // one. It MUST stay byte-identical to those three: a signature is only verifiable if every
21
+ // implementation agrees on the bytes, so a divergence here silently produces signatures
22
+ // nobody else can check. Locked by the published TC-CANON vectors in
23
+ // mcp/test-signing.mjs — if this drifts, that suite fails.
24
+ //
25
+ // JSON.stringify already emits RFC 8785-conformant scalars in ECMAScript (shortest
26
+ // round-trip numbers, minimal string escaping). What it does NOT do is order keys
27
+ // recursively, and it silently turns non-finite numbers into null. Full RFC 8785 here =
28
+ // UTF-16 code-unit key sort + explicit REJECTION of values JSON cannot express.
29
+ const notJson = (v) => v === undefined || typeof v === 'function' || typeof v === 'symbol';
30
+
31
+ export function canonicalStringify(value) {
32
+ if (typeof value === 'number' && !Number.isFinite(value)) {
33
+ throw new Error('canonicalStringify: non-finite numbers are not expressible in RFC 8785 JSON');
34
+ }
35
+ if (value === null || typeof value !== 'object') {
36
+ const s = JSON.stringify(typeof value === 'string' ? value.normalize('NFC') : value);
37
+ if (s === undefined) throw new Error('canonicalStringify: a ' + typeof value + ' is not expressible in JSON');
38
+ return s;
39
+ }
40
+ if (Array.isArray(value)) {
41
+ return '[' + value.map(v => (notJson(v) ? 'null' : canonicalStringify(v))).join(',') + ']';
42
+ }
43
+ // §6.2 NFC — "Implementations MUST normalize text to NFC before canonicalization to avoid
44
+ // signature breakage from equivalent but byte different strings." It was implemented nowhere.
45
+ // This is the VERIFIER: if the phone ever normalizes and this does not, every signature it
46
+ // produces fails here, over text that looks identical in any log. KEYS too, and a post-NFC
47
+ // collision is REJECTED rather than silently dropping one. Byte-identical to envelope.js and
48
+ // mobile/src/lib/envelope.ts.
49
+ const seen = new Map();
50
+ for (const k of Object.keys(value)) {
51
+ if (notJson(value[k])) continue;
52
+ const nk = k.normalize('NFC');
53
+ if (seen.has(nk)) throw new Error('canonicalStringify: keys collide under NFC: ' + JSON.stringify(k));
54
+ seen.set(nk, value[k]);
55
+ }
56
+ const keys = [...seen.keys()].sort(); // UTF-16 code-unit order (RFC 8785 §3.2.3), post-NFC
57
+ const parts = [];
58
+ for (const k of keys) parts.push(JSON.stringify(k) + ':' + canonicalStringify(seen.get(k)));
59
+ return '{' + parts.join(',') + '}';
60
+ }
61
+
62
+ // ── What a signature covers ──────────────────────────────────────────────────
63
+
64
+ /**
65
+ * The exact bytes a §8.3 signature is computed over: the §6 canonical serialization of
66
+ * the object with the signature field itself OMITTED. Omitting it is what makes the input
67
+ * well defined no matter how many signatures are later appended — the same construction
68
+ * §8.2 bootstrap mode uses for `digest`.
69
+ *
70
+ * @param {object} obj
71
+ * @param {{ field?: string }} [opts] field to omit (default 'signatures'; AP2 uses 'signature')
72
+ * @returns {Buffer} UTF-8 canonical bytes
73
+ */
74
+ export function signingInput(obj, { field = 'signatures' } = {}) {
75
+ if (!isObj(obj)) throw new Error('signingInput: expected an object');
76
+ const rest = {};
77
+ for (const k of Object.keys(obj)) {
78
+ if (k !== field) rest[k] = obj[k];
79
+ }
80
+ return Buffer.from(canonicalStringify(rest), 'utf8');
81
+ }
82
+
83
+ // ── Key + signature encodings ────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Convert an X9.63 uncompressed EC point (0x04 || X || Y, 65 bytes) — what the Secure
87
+ * Enclave exports — into a node:crypto public key.
88
+ */
89
+ export function publicKeyFromRawPoint(raw) {
90
+ const bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
91
+ if (bytes.length !== 65 || bytes[0] !== 0x04) {
92
+ throw new Error('publicKeyFromRawPoint: expected a 65-byte uncompressed P-256 point (0x04 || X || Y)');
93
+ }
94
+ return createPublicKey({
95
+ key: {
96
+ kty: 'EC',
97
+ crv: 'P-256',
98
+ x: bytes.subarray(1, 33).toString('base64url'),
99
+ y: bytes.subarray(33, 65).toString('base64url'),
100
+ },
101
+ format: 'jwk',
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Convert a DER (X9.62) ECDSA signature — what SecKeyCreateSignature emits — into the raw
107
+ * r||s form (64 bytes) that JOSE ES256 and the W3C VC suites use.
108
+ *
109
+ * This conversion is the boundary the spec deferred: the enclave speaks DER, interoperable
110
+ * verifiers expect raw, and something has to translate. Doing it here means the wire form
111
+ * is the interoperable one while the device keeps using its native output.
112
+ */
113
+ export function derToRawSignature(der) {
114
+ const b = Buffer.isBuffer(der) ? der : Buffer.from(der);
115
+ let i = 0;
116
+ if (b[i++] !== 0x30) throw new Error('derToRawSignature: not a DER SEQUENCE');
117
+ let seqLen = b[i++];
118
+ if (seqLen & 0x80) { // long form length
119
+ const n = seqLen & 0x7f;
120
+ if (n < 1 || n > 2) throw new Error('derToRawSignature: unsupported DER length');
121
+ seqLen = 0;
122
+ for (let k = 0; k < n; k++) seqLen = (seqLen << 8) | b[i++];
123
+ }
124
+ if (i + seqLen !== b.length) throw new Error('derToRawSignature: DER length mismatch');
125
+
126
+ const readInt = () => {
127
+ if (b[i++] !== 0x02) throw new Error('derToRawSignature: expected DER INTEGER');
128
+ const len = b[i++];
129
+ if (len & 0x80) throw new Error('derToRawSignature: unsupported INTEGER length');
130
+ let v = b.subarray(i, i + len);
131
+ i += len;
132
+ // DER integers are signed and minimally encoded: strip leading zero padding, then
133
+ // left-pad to the 32-byte field width the raw form requires.
134
+ while (v.length > 1 && v[0] === 0x00) v = v.subarray(1);
135
+ if (v.length > 32) throw new Error('derToRawSignature: integer wider than P-256 field');
136
+ return Buffer.concat([Buffer.alloc(32 - v.length), v]);
137
+ };
138
+
139
+ const r = readInt();
140
+ const s = readInt();
141
+ if (i !== b.length) throw new Error('derToRawSignature: trailing bytes after signature');
142
+ return Buffer.concat([r, s]);
143
+ }
144
+
145
+ // ── Verification ─────────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Verify a §8.3 ES256 signature over an object.
149
+ *
150
+ * Never throws on a bad signature — returns false. A malformed key or an unusable
151
+ * encoding still throws, because that is a caller bug rather than a failed verification,
152
+ * and the two must not be conflated (silently returning false for a broken key would
153
+ * report "not the subject's signature" when the truth is "we could not check").
154
+ *
155
+ * @param {object} args.payload the signed object (signature field may be present; it is omitted)
156
+ * @param {Buffer|Uint8Array} args.signature
157
+ * @param {Buffer|Uint8Array} args.publicKey X9.63 uncompressed point (65 bytes)
158
+ * @param {'der'|'raw'} [args.encoding='raw']
159
+ * @param {string} [args.field='signatures'] which field to omit from the signing input
160
+ * @returns {boolean}
161
+ */
162
+ export function verifyEs256({ payload, signature, publicKey, encoding = 'raw', field = 'signatures' }) {
163
+ if (encoding !== 'der' && encoding !== 'raw') {
164
+ throw new Error(`verifyEs256: unknown signature encoding '${encoding}'`);
165
+ }
166
+ const key = publicKeyFromRawPoint(publicKey); // throws on a malformed key — deliberate
167
+ const data = signingInput(payload, { field });
168
+ const sig = Buffer.isBuffer(signature) ? signature : Buffer.from(signature);
169
+ try {
170
+ return nodeVerify('sha256', data, { key, dsaEncoding: encoding === 'der' ? 'der' : 'ieee-p1363' }, sig);
171
+ } catch {
172
+ // node throws on structurally invalid signature bytes; that is a failed verification,
173
+ // not a caller error.
174
+ return false;
175
+ }
176
+ }
@@ -0,0 +1,250 @@
1
+ // Where authored content comes FROM — the step-3 read path.
2
+ //
3
+ // Authored standing rules live in two places during the migration: the fingerprint A-home
4
+ // (`fp.standing_rules`, Proposal 0007) and the §5 User Configuration (`standing_instructions`,
5
+ // derived from the A-home on every write). The sequencing decision is that the §5 copy is
6
+ // AUTHORITATIVE ON READ where both exist, so that step 3 can later delete the A-home and change
7
+ // nothing about what readers see. Every reader routing through here is what makes that true.
8
+ //
9
+ // WHY A RESOLVER AND NOT `cfg.standing_instructions || fp.standing_rules`:
10
+ //
11
+ // 1. §5.4. A configuration that does not name the fingerprint it arrived with is not this
12
+ // subject's — stale, mis-routed, or hostile. Letting it govern would let one identity's
13
+ // authored rules apply to another. It must fall back, not be trusted.
14
+ // 2. ABSENT and EMPTY are different. No §5 document at all means "this surface has not been
15
+ // migrated, use the A-home". A §5 document with an empty list means "the subject has no
16
+ // rules", and falling back there would RESURRECT rules the subject deleted — the same
17
+ // failure as the union-vs-tombstone bug in §10.5 revocation (#247).
18
+ // 3. The caller needs to know WHICH home answered. A reader that cannot report its provenance
19
+ // cannot be verified on device, and "it works" would be indistinguishable from "it silently
20
+ // read the old home the whole time".
21
+ //
22
+ // Mirrored by mobile/src/lib/authoredSource.ts; kept honest by
23
+ // scripts/test_mobile_authored_source_parity.mjs, which RUNS both over the same inputs.
24
+
25
+ (function () {
26
+ 'use strict';
27
+
28
+ /**
29
+ * WHICH HOME IS AUTHORITATIVE for authored rules — the step-3 cutover switch.
30
+ *
31
+ * It lives HERE, with the resolver, because "where does authored content live" is one question
32
+ * and both the reader and the writer must answer it identically. authored-write.js re-exports
33
+ * it; user-config.js reads it to decide whether a re-derive may overwrite standing_instructions.
34
+ * This module imports nothing, so every consumer can read it without an import cycle.
35
+ *
36
+ * 'fingerprint' — today. Rules live on the A-home; §5 is DERIVED from it on every write.
37
+ * 'config' — after step 3. §5 is the source; the A-home is a mirror, and the derive must
38
+ * stop overwriting §5 or a routine fingerprint write erases authored rules.
39
+ *
40
+ * Flipping this IS the cutover. It ships only once the upgrade gate reads green against real
41
+ * device reports (owner decision 2026-07-27: release check, not runtime interlock).
42
+ *
43
+ * FLIPPED to 'config' on 2026-07-28 and REVERTED the same day, before any device carried it.
44
+ * The revert is the mechanism working: one constant, so a bad answer costs one line.
45
+ *
46
+ * A post-flip review found SIX confirmed paths that lose authored content, every one of them
47
+ * benign while the A-home is authoritative and live the moment it is not:
48
+ * 1. the phone's chokepoint replaces §5 wholesale from the mirror, so any authored save
49
+ * destroys a §5-only rule (mobile/src/lib/authoredWrite.ts)
50
+ * 2. background.js never loads THIS module, so the vault write finalizer throws on every
51
+ * service-worker write and §5 is never maintained there
52
+ * 3. the live /v1/profile sync path carries no §5 at all (popup.js silentSync)
53
+ * 4. popup.js _actOnOverride authors a rule outside the write chokepoint
54
+ * 5. the SW capture re-deposit strips the §5 envelope off the wire, so authored rules and
55
+ * the irreplaceable example_corpus never reach the phone
56
+ * 6. a non-governing §5 document is never cleared, and absorbs authored content into a
57
+ * document that can never govern — and is excluded from §8.2 backup at the same moment
58
+ *
59
+ * WHAT MUST BE TRUE BEFORE FLIPPING AGAIN — all six closed, plus:
60
+ * - every authoring site routes through the chokepoint, enumerated BY BEHAVIOUR
61
+ * - every reader routes through resolveStandingInstructions, likewise
62
+ * - the upgrade gate is actually CONSULTED (it currently has zero production call sites)
63
+ * - the phone advertises config_home_v0.2 only once it genuinely satisfies it
64
+ * - a device pass on a build that carries the phone half
65
+ * See docs/state/current-state.md and scripts/test_cutover_readiness.mjs.
66
+ */
67
+ const AUTHORED_HOME = 'config';
68
+
69
+ // ── The upgrade gate, wired ────────────────────────────────────────────────────────────────
70
+ //
71
+ // `AUTHORED_HOME` ARMS the cutover. It does not perform it. The effective home is `'config'`
72
+ // only when the constant says so AND capabilities.js reports every reader current — so flipping
73
+ // the constant on a fleet where the phone or the agent is behind changes NOTHING until they
74
+ // catch up, instead of retiring the A-home out from under them.
75
+ //
76
+ // This is the wiring the gate was missing. `canRetireAHome` and `retirementBlockers` were
77
+ // correct and well tested and had ZERO production call sites: evidence was collected into
78
+ // `phaedo_peer_capabilities` and never asked. The 2026-07-28 flip shipped without the mechanism
79
+ // designed to authorise it ever being consulted, and six loss paths went with it.
80
+ //
81
+ // WHY A LATCH RATHER THAN A READ. The consumers (user-config.js, authored-write.js) are pure
82
+ // functions over a vault object, and the capability record lives outside the vault on purpose —
83
+ // it is fleet metadata, and the gate must be answerable without unlocking anything. Making
84
+ // those functions async, or handing them a storage handle, would put I/O on the authoring path.
85
+ // So the surface that CAN read storage pushes the record in, and the pure modules read it
86
+ // synchronously.
87
+ //
88
+ // IT FAILS CLOSED IN EVERY DIRECTION. Unset (nobody pushed a record), capabilities.js not
89
+ // loaded, an empty record, a reader that never reported, a reader on an older build, a report
90
+ // too old — all of them resolve to `'fingerprint'`. Silence is not consent; the only path to
91
+ // `'config'` is positive evidence from every reader.
92
+ let _peerCapabilities = null;
93
+
94
+ /**
95
+ * Publish what the fleet last reported. Called by whichever surface can read storage
96
+ * (background.js on startup, popup.js after each mailbox drain).
97
+ */
98
+ function setPeerCapabilities(known) {
99
+ _peerCapabilities = isObj(known) ? known : null;
100
+ }
101
+
102
+ /** What was last published. Exposed so a caller can show the subject the same evidence. */
103
+ function peerCapabilities() { return _peerCapabilities; }
104
+
105
+ /**
106
+ * The home that is actually in force, and why. Returns a REASON so a withheld cutover can be
107
+ * explained rather than merely observed — the same discipline `retirementBlockers` follows.
108
+ *
109
+ * @returns {{ home: string, armed: boolean, blockers: Array, reason: string }}
110
+ */
111
+ function effectiveAuthoredHomeDetail(now) {
112
+ // Read the PUBLISHED value, not the closure constant. `api.AUTHORED_HOME` is what every
113
+ // consumer sees, so reading anything else would let the module disagree with its own exports
114
+ // — and it is the value the cutover suites set to stage post-cutover conditions, which means
115
+ // they exercise this path rather than a parallel one.
116
+ const armed = (api && api.AUTHORED_HOME) || AUTHORED_HOME;
117
+ if (armed !== 'config') {
118
+ return { home: 'fingerprint', armed: false, blockers: [], reason: 'not_armed' };
119
+ }
120
+ const CAP = (typeof globalThis !== 'undefined' && globalThis.PhaedoCapabilities) || null;
121
+ if (!CAP || typeof CAP.retirementBlockers !== 'function') {
122
+ // The gate module is not present, so the fleet's state is unknowable here. Unknowable is
123
+ // not the same as fine — this is the `[].every(...)` trap the gate itself was built around.
124
+ return { home: 'fingerprint', armed: true, blockers: [], reason: 'gate_unavailable' };
125
+ }
126
+ if (!isObj(_peerCapabilities)) {
127
+ return { home: 'fingerprint', armed: true, blockers: [], reason: 'no_capability_record' };
128
+ }
129
+ // NAME THE ASKER. The extension's peers are the phone and the agent; passing nothing would
130
+ // fall back to checking all three including itself, which never reports and would block
131
+ // forever. See readersFor in capabilities.js.
132
+ const blockers = CAP.retirementBlockers(_peerCapabilities, now, { self: 'extension' });
133
+ if (blockers.length) {
134
+ return { home: 'fingerprint', armed: true, blockers, reason: 'readers_behind' };
135
+ }
136
+ return { home: 'config', armed: true, blockers: [], reason: 'fleet_current' };
137
+ }
138
+
139
+ /** The home in force. Use `effectiveAuthoredHomeDetail` when you need to explain a fallback. */
140
+ function effectiveAuthoredHome(now) {
141
+ return effectiveAuthoredHomeDetail(now).home;
142
+ }
143
+
144
+ /** Where an answer came from. Returned, never inferred by the caller. */
145
+ const SOURCE = {
146
+ CONFIG: 'config', // §5 — the post-cutover home
147
+ FINGERPRINT: 'fingerprint', // the A-home — the fallback step 3 removes
148
+ NONE: 'none', // neither home had anything to say
149
+ };
150
+
151
+ function isObj(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
152
+
153
+ /**
154
+ * The configuration's live instructions — those not buried by a §5.2 tombstone.
155
+ *
156
+ * Keyed by the FROZEN identity in sync-merge.js. When that module is absent (a reader that
157
+ * loads only the resolver) the tombstones cannot be evaluated, and the honest answer is to
158
+ * return the rules unchanged rather than guess: dropping rules we cannot key would lose
159
+ * authored content, and that is the worse direction here.
160
+ */
161
+ function withoutRevoked(cfg) {
162
+ const rules = cfg.standing_instructions;
163
+ const tombs = Array.isArray(cfg.revoked_instructions) ? cfg.revoked_instructions : [];
164
+ if (!tombs.length) return rules;
165
+ const SM = (typeof globalThis !== 'undefined' && globalThis.PhaedoSyncMerge) || null;
166
+ if (!SM || typeof SM.instructionKey !== 'function') return rules;
167
+ const dead = new Set(tombs.map((t) => (isObj(t) ? t.key : null)).filter(Boolean));
168
+ if (!dead.size) return rules;
169
+ return rules.filter((r) => !dead.has(SM.instructionKey(r)));
170
+ }
171
+
172
+ /**
173
+ * Does this configuration belong to this fingerprint? Mirrors config-sync.js `pairingHolds`
174
+ * deliberately rather than importing it: this module is loaded by readers that have no reason
175
+ * to pull in the transport, and the rule is three lines. Parity between the two is asserted in
176
+ * scripts/test_authored_source.mjs so the duplication cannot drift.
177
+ */
178
+ function configGoverns(cfg, fp) {
179
+ if (!isObj(cfg) || !isObj(fp)) return false;
180
+ if (typeof fp.fingerprint_id !== 'string' || !fp.fingerprint_id) return false;
181
+ if (cfg.fingerprint_id !== fp.fingerprint_id) return false;
182
+ if (typeof fp.subject_id === 'string' && fp.subject_id &&
183
+ typeof cfg.subject_id === 'string' && cfg.subject_id &&
184
+ cfg.subject_id !== fp.subject_id) return false;
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * The authoritative standing instructions, and where they came from.
190
+ *
191
+ * §5 wins whenever it is present AND governs — including when it is EMPTY, because an empty
192
+ * authored set is a statement ("I have no rules"), not an absence. Only a missing or
193
+ * non-governing configuration falls through to the A-home.
194
+ *
195
+ * @returns {{ rules: Array, source: string, reason?: string }}
196
+ */
197
+ function resolveStandingInstructions(fp, cfg) {
198
+ const aHome = isObj(fp) && Array.isArray(fp.standing_rules) ? fp.standing_rules : null;
199
+
200
+ if (isObj(cfg) && Array.isArray(cfg.standing_instructions)) {
201
+ if (configGoverns(cfg, fp)) {
202
+ // §5.2 tombstones are enforced HERE, not only at the merge. A device that has not merged
203
+ // since the revocation still holds the rule in `standing_instructions`; honouring the
204
+ // tombstone at read time is what makes the revocation take effect immediately rather
205
+ // than on the next sync. §10.5 learned this the hard way — ap2.js honoured its
206
+ // tombstones while the consult resolver did not, so one artifact gave two answers.
207
+ return { rules: withoutRevoked(cfg), source: SOURCE.CONFIG };
208
+ }
209
+ // Present but not this subject's. Fall back rather than trust it, and SAY so — a §5.4
210
+ // rejection that looks identical to "no configuration here" is how a mis-paired document
211
+ // would go unnoticed for months.
212
+ //
213
+ // BUT ITS DELETIONS STILL COUNT. The A-home has no revocation concept, and its copy is
214
+ // union-merged, so a rule the subject deleted comes back from any peer that still holds it
215
+ // — and falling back to that copy put the deleted rule into force again (measured
216
+ // 2026-08-12). Honouring the tombstones on a document we otherwise refuse is safe in a way
217
+ // that honouring its rules would not be: a tombstone can only ever REMOVE an instruction,
218
+ // never add one or change what it says. The worst it can do is withhold a rule the subject
219
+ // wanted; the alternative is obeying one they revoked, which is the direction that cannot
220
+ // be noticed from the outside.
221
+ if (aHome) {
222
+ return { rules: withoutRevoked({ standing_instructions: aHome, revoked_instructions: cfg.revoked_instructions }),
223
+ source: SOURCE.FINGERPRINT, reason: 'config_pairing_failed' };
224
+ }
225
+ return { rules: [], source: SOURCE.NONE, reason: 'config_pairing_failed' };
226
+ }
227
+
228
+ if (aHome) return { rules: aHome, source: SOURCE.FINGERPRINT, reason: 'no_config' };
229
+ return { rules: [], source: SOURCE.NONE, reason: 'no_config' };
230
+ }
231
+
232
+ /**
233
+ * True once this surface would behave identically with the A-home deleted — i.e. §5 answered.
234
+ * This is the evidence a surface needs before it may advertise `config_home_v0.2` to the
235
+ * upgrade gate. Claiming the capability on the strength of "the code is deployed" rather than
236
+ * "the read actually resolved to §5" is exactly how a gate opens on the wrong evidence.
237
+ */
238
+ function readsFromConfigHome(fp, cfg) {
239
+ return resolveStandingInstructions(fp, cfg).source === SOURCE.CONFIG;
240
+ }
241
+
242
+ // Declared before the functions above can run (they only read it at call time), so
243
+ // `effectiveAuthoredHomeDetail` can consult the published value.
244
+ const api = {
245
+ AUTHORED_HOME, SOURCE, configGoverns, resolveStandingInstructions, readsFromConfigHome,
246
+ setPeerCapabilities, peerCapabilities, effectiveAuthoredHome, effectiveAuthoredHomeDetail,
247
+ };
248
+ if (typeof globalThis !== 'undefined') globalThis.PhaedoAuthoredSource = api;
249
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
250
+ })();