phaedo-mcp 0.2.2 → 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 +50 -6
- package/consult-policy.js +427 -33
- package/consult.js +8 -1
- 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 +25 -5
- package/phone-source.js +79 -11
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +33 -2
- package/server.js +305 -15
- package/server.json +3 -3
- 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
package/signing-audit.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// §8.3 signing audit — "what did I sign, and when."
|
|
2
|
+
//
|
|
3
|
+
// A signed mandate is a CREDENTIAL asserted to a third party, so minting one is at least as
|
|
4
|
+
// audit-worthy as a consultation. But it is NOT a §10.6 consultation receipt: it has no
|
|
5
|
+
// signal, no deference level, and it must not feed the act-as-me learning loop or the
|
|
6
|
+
// agreement-rate metric (which would corrupt both). So this is its OWN small append-only,
|
|
7
|
+
// encrypted-at-rest log — the same atomic-write + domain-separated-key discipline as
|
|
8
|
+
// receipts.js, but a separate artifact.
|
|
9
|
+
//
|
|
10
|
+
// It records a DIGEST of what was signed plus coarse metadata (grant count, expiry,
|
|
11
|
+
// requesting client) — never the full mandate, never the signature bytes. The digest is the
|
|
12
|
+
// stable reference: given the mandate later, an auditor recomputes it and confirms it is the
|
|
13
|
+
// object that was signed.
|
|
14
|
+
|
|
15
|
+
import { mkdir, writeFile, readFile, rename, chmod } from 'fs/promises';
|
|
16
|
+
import { existsSync } from 'fs';
|
|
17
|
+
import { createHash, randomUUID } from 'crypto';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { Phaedo } from './lib/phaedo-crypto.js';
|
|
20
|
+
import { getOrCreateCacheKey } from './cache.js';
|
|
21
|
+
import { signingInput } from './signing.js';
|
|
22
|
+
|
|
23
|
+
const { Envelope } = Phaedo;
|
|
24
|
+
|
|
25
|
+
export const SIGNING_AUDIT_CONTEXT = 'phaedo:signing-audit:enc:v0.1';
|
|
26
|
+
export const DEFAULT_CAP = 5000;
|
|
27
|
+
const FILE = 'signing-audit.enc';
|
|
28
|
+
|
|
29
|
+
function auditPath(stateDir) { return join(stateDir, FILE); }
|
|
30
|
+
|
|
31
|
+
async function deriveKey(stateDir) {
|
|
32
|
+
const root = await getOrCreateCacheKey(stateDir);
|
|
33
|
+
const ikm = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveBits']);
|
|
34
|
+
const bits = await crypto.subtle.deriveBits(
|
|
35
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(SIGNING_AUDIT_CONTEXT) },
|
|
36
|
+
ikm, 32 * 8,
|
|
37
|
+
);
|
|
38
|
+
return new Uint8Array(bits);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readStore(stateDir) {
|
|
42
|
+
const p = auditPath(stateDir);
|
|
43
|
+
if (!existsSync(p)) return { records: [] };
|
|
44
|
+
const key = await deriveKey(stateDir);
|
|
45
|
+
const { envelope } = JSON.parse(await readFile(p, 'utf8'));
|
|
46
|
+
const plaintext = await Envelope.unwrap(envelope, key);
|
|
47
|
+
try {
|
|
48
|
+
const store = JSON.parse(new TextDecoder().decode(plaintext));
|
|
49
|
+
return Array.isArray(store?.records) ? store : { records: [] };
|
|
50
|
+
} finally {
|
|
51
|
+
plaintext.fill(0);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function writeStore(stateDir, store, now) {
|
|
56
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
57
|
+
const key = await deriveKey(stateDir);
|
|
58
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(store));
|
|
59
|
+
const envelope = await Envelope.wrap(plaintext, key, {
|
|
60
|
+
artifact_kind: 'signing_audit',
|
|
61
|
+
count: store.records.length,
|
|
62
|
+
created_at: new Date(now).toISOString(),
|
|
63
|
+
});
|
|
64
|
+
plaintext.fill(0);
|
|
65
|
+
const p = auditPath(stateDir);
|
|
66
|
+
const tmp = `${p}.tmp`;
|
|
67
|
+
await writeFile(tmp, JSON.stringify({ envelope }), { mode: 0o600 });
|
|
68
|
+
await chmod(tmp, 0o600);
|
|
69
|
+
await rename(tmp, p); // atomic on POSIX
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a signing-audit record from a signed mandate. Records the digest of the SIGNED bytes
|
|
74
|
+
* (§6 canonical form, `signature` omitted) — not the mandate content and not the signature.
|
|
75
|
+
*/
|
|
76
|
+
export function buildSigningAuditRecord({ requestId, mandate, clientId, agentId, now = Date.now() }) {
|
|
77
|
+
const digest = createHash('sha256').update(signingInput(mandate, { field: 'signature' })).digest('base64url');
|
|
78
|
+
const grants = Array.isArray(mandate?.grants) ? mandate.grants.length : 0;
|
|
79
|
+
return {
|
|
80
|
+
audit_id: randomUUID(),
|
|
81
|
+
signed_at: typeof mandate?.signed_at === 'string' ? mandate.signed_at : new Date(now).toISOString(),
|
|
82
|
+
request_id: typeof requestId === 'string' ? requestId : null,
|
|
83
|
+
// WHO the credential was minted for: the paired client that served the ceremony.
|
|
84
|
+
client_id: typeof clientId === 'string' && clientId ? clientId : null,
|
|
85
|
+
// WHO claimed to ask (self-asserted, audit label only — mirrors §10.6).
|
|
86
|
+
agent_id: typeof agentId === 'string' && agentId ? agentId : null,
|
|
87
|
+
mandate_digest: digest,
|
|
88
|
+
grant_count: grants,
|
|
89
|
+
mandate_expires_at: typeof mandate?.expires_at === 'string' ? mandate.expires_at : null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let _chain = Promise.resolve();
|
|
94
|
+
/**
|
|
95
|
+
* Append a signing-audit record. Serialized per process (like receipts) so two concurrent
|
|
96
|
+
* signs can't clobber the store. Best-effort by contract at the call site: a failed audit
|
|
97
|
+
* write must never make a real signature look unsigned — the caller logs and continues.
|
|
98
|
+
*/
|
|
99
|
+
export async function appendSigningAudit(stateDir, record, { cap = DEFAULT_CAP, now = Date.now() } = {}) {
|
|
100
|
+
_chain = _chain.then(async () => {
|
|
101
|
+
const store = await readStore(stateDir).catch(() => ({ records: [] }));
|
|
102
|
+
store.records.push(record);
|
|
103
|
+
if (store.records.length > cap) store.records = store.records.slice(-cap);
|
|
104
|
+
await writeStore(stateDir, store, now);
|
|
105
|
+
});
|
|
106
|
+
return _chain;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function readSigningAudit(stateDir) {
|
|
110
|
+
return (await readStore(stateDir).catch(() => ({ records: [] }))).records;
|
|
111
|
+
}
|
|
@@ -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
|
+
}
|