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.
- package/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +81 -19
- package/consult-policy.js +427 -33
- package/consult.js +25 -3
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +26 -6
- package/pairing.js +5 -1
- package/phone-source.js +92 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +69 -16
- package/server.js +305 -15
- package/server.json +4 -4
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +153 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- 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
|
+
})();
|