privateer-agent 0.12.19 → 0.12.21
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/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +321 -9
- package/src/config/moat.ts +115 -22
- package/src/engine/errors.ts +7 -0
- package/src/mcp/catalog.ts +41 -0
- package/src/providers/phala/aci-verifier/VENDORED.md +54 -30
- package/src/providers/phala/aci-verifier/crypto.ts +35 -27
- package/src/providers/phala/aci-verifier/digest.ts +31 -95
- package/src/providers/phala/aci-verifier/e2ee-channel.ts +3 -1
- package/src/providers/phala/aci-verifier/errors.ts +2 -21
- package/src/providers/phala/aci-verifier/index.ts +29 -35
- package/src/providers/phala/aci-verifier/jcs.ts +8 -3
- package/src/providers/phala/aci-verifier/receipt.ts +96 -88
- package/src/providers/phala/aci-verifier/report.ts +62 -85
- package/src/providers/phala/aci-verifier/session.ts +40 -0
- package/src/providers/phala/aci-verifier/types.ts +90 -80
- package/src/providers/phalaSeal.ts +8 -9
- package/src/providers/phala/reportBinding.ts +0 -193
|
@@ -1,139 +1,149 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wire shapes for the ACI artifacts this verifier reads, plus the result types
|
|
3
|
-
* it returns. These mirror spec/aci.md §
|
|
3
|
+
* it returns. These mirror spec/aci.md §3, §4, §7, §8; only the fields the
|
|
4
4
|
* verifier touches are typed precisely, with an index signature left open so
|
|
5
|
-
*
|
|
5
|
+
* extension fields (Appendix B) are visible to callers.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export interface PublicKey {
|
|
8
|
+
/** A keyed public-key entry (§3.1) — receipt signing and E2EE keys. */
|
|
9
|
+
export interface KeysetKey {
|
|
10
|
+
key_id: string;
|
|
12
11
|
algo: string;
|
|
13
12
|
public_key: string;
|
|
14
|
-
[key: string]:
|
|
13
|
+
[key: string]: unknown;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
export interface
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
[key: string]:
|
|
16
|
+
/** A TLS pin entry (§3.1): the certificate SPKI digest, optionally domain-scoped. */
|
|
17
|
+
export interface TlsKeyPin {
|
|
18
|
+
spki_sha256: string;
|
|
19
|
+
domain?: string;
|
|
20
|
+
[key: string]: unknown;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
[key: string]: JcsValue | undefined;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** The workload keyset (§4.2). Digested with JCS to yield `workload_keyset_digest`. */
|
|
23
|
+
/**
|
|
24
|
+
* The workload keyset (§3.1) — the unit of workload identity. It travels as
|
|
25
|
+
* `workload_keyset`; its digest is the SHA-256 of the JCS form of the
|
|
26
|
+
* parsed object (Appendix A).
|
|
27
|
+
*/
|
|
33
28
|
export interface WorkloadKeyset {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
receipt_signing_keys:
|
|
37
|
-
e2ee_public_keys
|
|
38
|
-
tls_public_keys?:
|
|
39
|
-
[key: string]:
|
|
29
|
+
subject?: string | null;
|
|
30
|
+
not_after: number;
|
|
31
|
+
receipt_signing_keys: KeysetKey[];
|
|
32
|
+
e2ee_public_keys: KeysetKey[];
|
|
33
|
+
tls_public_keys?: TlsKeyPin[];
|
|
34
|
+
[key: string]: unknown;
|
|
40
35
|
}
|
|
41
36
|
|
|
42
|
-
/**
|
|
43
|
-
export interface
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
/** Source provenance (§4.1); each field is `null` when unknown. */
|
|
38
|
+
export interface SourceProvenance {
|
|
39
|
+
repo_url?: string | null;
|
|
40
|
+
repo_commit?: string | null;
|
|
41
|
+
image_digest?: string | null;
|
|
42
|
+
image_provenance?: unknown;
|
|
43
|
+
[key: string]: unknown;
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
/**
|
|
51
|
-
export interface
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
/** The `attestation` object of a report (§4.1). `evidence` is policy-defined (§4.2). */
|
|
47
|
+
export interface Attestation {
|
|
48
|
+
tee_type: string;
|
|
49
|
+
workload_keyset: unknown;
|
|
50
|
+
report_data: string;
|
|
51
|
+
source_provenance?: SourceProvenance | null;
|
|
52
|
+
evidence?: unknown;
|
|
53
|
+
[key: string]: unknown;
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
/** An
|
|
58
|
-
export interface
|
|
56
|
+
/** An attestation report (§4.1). */
|
|
57
|
+
export interface AttestationReport {
|
|
59
58
|
api_version: string;
|
|
60
|
-
receipt_id: string;
|
|
61
|
-
workload_id: string;
|
|
62
59
|
workload_keyset_digest: string;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
[key: string]:
|
|
60
|
+
attestation: Attestation;
|
|
61
|
+
service_capabilities?: { supported_e2ee_versions?: string[]; [key: string]: unknown };
|
|
62
|
+
[key: string]: unknown;
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The receipt document served by `GET /v1/aci/receipts/{id}` (§7.2): the
|
|
67
|
+
* §7.3 payload members plus `key_id` and `signature`. The signature covers
|
|
68
|
+
* JCS(document minus `signature`).
|
|
69
|
+
*/
|
|
70
|
+
export interface ReceiptEnvelope {
|
|
71
|
+
key_id: string;
|
|
72
|
+
signature: string;
|
|
73
|
+
[key: string]: unknown;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/**
|
|
76
|
-
export interface
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
freshness?: { fetched_at?: number; stale_after?: number; [key: string]: JcsValue | undefined };
|
|
81
|
-
[key: string]: JcsValue | undefined;
|
|
76
|
+
/** A receipt event (§7.3): `type` plus type-specific fields; order is array order. */
|
|
77
|
+
export interface ReceiptEvent {
|
|
78
|
+
type: string;
|
|
79
|
+
body_hash?: string;
|
|
80
|
+
[key: string]: unknown;
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
/**
|
|
85
|
-
export interface
|
|
83
|
+
/** The receipt payload the envelope signs (§7.3). */
|
|
84
|
+
export interface ReceiptPayload {
|
|
86
85
|
api_version: string;
|
|
87
|
-
|
|
86
|
+
receipt_id: string;
|
|
87
|
+
chat_id?: string | null;
|
|
88
|
+
model?: string | null;
|
|
88
89
|
workload_keyset_digest: string;
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
endpoint: string;
|
|
91
|
+
method: string;
|
|
92
|
+
served_at: number;
|
|
93
|
+
event_log: ReceiptEvent[];
|
|
94
|
+
[key: string]: unknown;
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
/** A
|
|
97
|
+
/** A session evidence block (§8.2): a base64 data URI plus the digest of its decoded bytes. */
|
|
94
98
|
export interface SessionEvidence {
|
|
95
|
-
digest
|
|
99
|
+
digest: string;
|
|
96
100
|
data?: string;
|
|
97
|
-
[key: string]:
|
|
101
|
+
[key: string]: unknown;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/**
|
|
101
|
-
* An attested session record (§
|
|
102
|
-
*
|
|
103
|
-
* are restored as JSON `null` in the content-addressing material.
|
|
105
|
+
* An attested session record (§8.2). Its id is the SHA-256 of the JCS form
|
|
106
|
+
* of the parsed document ({@link computeSessionId}).
|
|
104
107
|
*/
|
|
105
108
|
export interface SessionRecord {
|
|
109
|
+
api_version: string;
|
|
106
110
|
upstream_name: string;
|
|
107
111
|
endpoint?: string | null;
|
|
108
112
|
verifier_id: string;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
established_at: number;
|
|
114
|
+
expires_at: number;
|
|
115
|
+
identity?: unknown;
|
|
116
|
+
channel_binding: unknown[];
|
|
117
|
+
claims: unknown;
|
|
118
|
+
evidence: SessionEvidence;
|
|
119
|
+
[key: string]: unknown;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
/** Outcome of one named verification check. */
|
|
117
123
|
export interface Check {
|
|
118
|
-
/** Stable machine-readable id, e.g. `signature`, `
|
|
124
|
+
/** Stable machine-readable id, e.g. `signature`, `report_data`. */
|
|
119
125
|
name: string;
|
|
120
126
|
ok: boolean;
|
|
121
127
|
/** Human-readable detail, present when the check fails. */
|
|
122
128
|
detail?: string;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
/** Result of {@link verifyReceipt}: overall pass plus the individual §
|
|
131
|
+
/** Result of {@link verifyReceipt}: overall pass plus the individual §9.3 checks. */
|
|
126
132
|
export interface ReceiptVerification {
|
|
127
133
|
ok: boolean;
|
|
128
134
|
checks: Check[];
|
|
135
|
+
/** The receipt document read as its §7.3 payload members. */
|
|
136
|
+
payload?: ReceiptPayload;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Result of {@link verifyReportBinding}: overall pass, the checks, and the
|
|
141
|
+
* keyset established from the report — the digest is recomputed over the
|
|
142
|
+
* served keyset object's JCS form (§3.1).
|
|
143
|
+
*/
|
|
132
144
|
export interface ReportVerification {
|
|
133
145
|
ok: boolean;
|
|
134
146
|
checks: Check[];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
/** `workload_keyset_digest` recomputed from the report's keyset (§4.2). */
|
|
138
|
-
workloadKeysetDigest: string;
|
|
147
|
+
workloadKeysetDigest?: string;
|
|
148
|
+
keyset?: WorkloadKeyset;
|
|
139
149
|
}
|
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
// (`${server}/api/sealed/phala`, treeview/server/routes/sealed.js) injects PHALA_API_KEY
|
|
9
9
|
// and forwards ciphertext — it can't read prompts or responses.
|
|
10
10
|
//
|
|
11
|
-
// Crypto is the vendored
|
|
11
|
+
// Crypto is the vendored aci-verifier (./phala/aci-verifier), pure Web Crypto
|
|
12
12
|
// (X25519/HKDF/AES-GCM/Ed25519). Node ≥ 22 provides all of it on globalThis.crypto —
|
|
13
13
|
// no polyfills, unlike the RN app.
|
|
14
14
|
//
|
|
15
15
|
// Two-layer attestation, fail-secure:
|
|
16
|
-
// (1)
|
|
17
|
-
//
|
|
16
|
+
// (1) verifyReportBinding — the report's crypto binding (§9.1 checks 2–3): the
|
|
17
|
+
// served keyset canonicalizes to the digest that the attestation statement for
|
|
18
|
+
// OUR nonce hashes into report_data, and the keyset has not expired. Every key
|
|
19
|
+
// we go on to use — the X25519 key we seal to above all — is a member of that
|
|
20
|
+
// one object, so the binding covers them without a separate signature.
|
|
18
21
|
// (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
|
|
19
22
|
// against Intel collateral and binds the quote's report_data to (1)'s statement
|
|
20
23
|
// digest. requireQuote defaults TRUE; PRIVATEER_PHALA_REQUIRE_QUOTE=0 drops it
|
|
@@ -23,17 +26,13 @@
|
|
|
23
26
|
import type { Report } from "@phala/dcap-qvl";
|
|
24
27
|
import {
|
|
25
28
|
openE2eeChannel,
|
|
29
|
+
verifyReportBinding,
|
|
26
30
|
toHex,
|
|
27
31
|
fromHex,
|
|
28
32
|
type AttestationReport,
|
|
29
33
|
type ReportVerification,
|
|
30
34
|
type E2eeChannel,
|
|
31
35
|
} from "./phala/aci-verifier/index.ts";
|
|
32
|
-
// Not the vendored verifyReportBinding directly: the deployed gateway signs its
|
|
33
|
-
// keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
|
|
34
|
-
// refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
|
|
35
|
-
// the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
|
|
36
|
-
import { verifyAciReportBinding } from "./phala/reportBinding.ts";
|
|
37
36
|
import {
|
|
38
37
|
parseEventLog,
|
|
39
38
|
appIdentityFrom,
|
|
@@ -153,7 +152,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
|
|
|
153
152
|
if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
|
|
154
153
|
const report = (await res.json()) as AttestationReport;
|
|
155
154
|
|
|
156
|
-
const verification = await
|
|
155
|
+
const verification = await verifyReportBinding(report, nonce);
|
|
157
156
|
if (!verification.ok) {
|
|
158
157
|
const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
|
|
159
158
|
throw new Error(`phala attestation binding failed: ${failed}`);
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
// Algorithm dispatch for the ACI report-binding checks (§10.1 checks 2–6).
|
|
2
|
-
//
|
|
3
|
-
// The ACI spec (§4.3) allows the keyset endorsement to be signed with EITHER
|
|
4
|
-
// `ed25519` OR `ecdsa-secp256k1` — the former because "every primitive in it is
|
|
5
|
-
// available in the Web Crypto API", the latter for "clients in the EVM/dstack
|
|
6
|
-
// ecosystem". Upstream's reference TS verifier implements only the Web Crypto half:
|
|
7
|
-
// `verifySignature` throws `UnsupportedAlgorithmError` on secp256k1
|
|
8
|
-
// (aci-verifier/crypto.ts), and `verifyReportBinding` propagates that.
|
|
9
|
-
//
|
|
10
|
-
// The deployed gateway (inference.phala.com) signs with `ecdsa-secp256k1`, so the
|
|
11
|
-
// vendored verifier can never attest it — a limit of upstream's CLIENT, not of the
|
|
12
|
-
// spec or the gateway. Verified live 2026-07-31: attestation fetched, endorsement
|
|
13
|
-
// rejected with UnsupportedAlgorithmError.
|
|
14
|
-
//
|
|
15
|
-
// This module owns the dispatch so `aci-verifier/` stays byte-for-byte upstream
|
|
16
|
-
// (see its VENDORED.md — only the `.js`-extension strip diverges, and re-pulls stay
|
|
17
|
-
// mechanical):
|
|
18
|
-
// ed25519 → delegate to the vendored verifyReportBinding, verbatim
|
|
19
|
-
// ecdsa-secp256k1 → the same checks 2–6, with check 5 done over @noble/curves
|
|
20
|
-
// anything else → still throws (never a silent pass)
|
|
21
|
-
//
|
|
22
|
-
// The secp256k1 path deliberately mirrors report.ts check-for-check, in the same
|
|
23
|
-
// order and with the same check names, so a caller cannot tell which path ran.
|
|
24
|
-
|
|
25
|
-
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
26
|
-
import {
|
|
27
|
-
verifyReportBinding,
|
|
28
|
-
computeWorkloadId,
|
|
29
|
-
computeKeysetDigest,
|
|
30
|
-
computeReportData,
|
|
31
|
-
keysetEndorsementPayload,
|
|
32
|
-
sha256,
|
|
33
|
-
fromHex,
|
|
34
|
-
UnsupportedAlgorithmError,
|
|
35
|
-
type AttestationReport,
|
|
36
|
-
type Check,
|
|
37
|
-
type ReportVerification,
|
|
38
|
-
type ReportBindingOptions,
|
|
39
|
-
} from "./aci-verifier/index.ts";
|
|
40
|
-
|
|
41
|
-
const ED25519 = "ed25519";
|
|
42
|
-
const SECP256K1 = "ecdsa-secp256k1";
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Verify a report's cryptographic bindings for `nonce` (§10.1 checks 2–6),
|
|
46
|
-
* dispatching on the algorithm the attested identity key declares. Drop-in
|
|
47
|
-
* replacement for the vendored `verifyReportBinding`: same arguments, same result
|
|
48
|
-
* shape, same "a failed check is `ok: false`, never thrown" contract.
|
|
49
|
-
*
|
|
50
|
-
* Like upstream, this is the crypto-binding half only — compose it with a hardware
|
|
51
|
-
* quote verifier (phalaSeal.ts `verifyHardwareQuote`) for Level 2.
|
|
52
|
-
*/
|
|
53
|
-
export async function verifyAciReportBinding(
|
|
54
|
-
report: AttestationReport,
|
|
55
|
-
nonce: string | null | undefined,
|
|
56
|
-
options: ReportBindingOptions = {},
|
|
57
|
-
): Promise<ReportVerification> {
|
|
58
|
-
const algo = report.attestation.workload_keyset.workload_identity.public_key.algo;
|
|
59
|
-
if (algo === ED25519) return verifyReportBinding(report, nonce, options);
|
|
60
|
-
if (algo !== SECP256K1) {
|
|
61
|
-
// Same fail-closed posture as upstream: an algorithm we cannot check is a
|
|
62
|
-
// refusal, not a pass.
|
|
63
|
-
throw new UnsupportedAlgorithmError(algo, "keyset endorsement (§4.3)");
|
|
64
|
-
}
|
|
65
|
-
return verifySecp256k1ReportBinding(report, nonce, options);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function verifySecp256k1ReportBinding(
|
|
69
|
-
report: AttestationReport,
|
|
70
|
-
nonce: string | null | undefined,
|
|
71
|
-
options: ReportBindingOptions,
|
|
72
|
-
): Promise<ReportVerification> {
|
|
73
|
-
const now = options.now ?? Math.floor(Date.now() / 1000);
|
|
74
|
-
const checks: Check[] = [];
|
|
75
|
-
|
|
76
|
-
const keyset = report.attestation.workload_keyset;
|
|
77
|
-
const identityKey = keyset.workload_identity.public_key;
|
|
78
|
-
|
|
79
|
-
// Check 2: workload_id == digest of the identity public key in the report's keyset.
|
|
80
|
-
const workloadId = await computeWorkloadId(identityKey);
|
|
81
|
-
pushEqual(checks, "workload_id", report.workload_id, workloadId);
|
|
82
|
-
|
|
83
|
-
// Check 3: workload_keyset_digest == digest of the report's keyset.
|
|
84
|
-
const workloadKeysetDigest = await computeKeysetDigest(keyset);
|
|
85
|
-
pushEqual(checks, "workload_keyset_digest", report.workload_keyset_digest, workloadKeysetDigest);
|
|
86
|
-
|
|
87
|
-
// Check 4 (binding half): report_data == the §4.4 statement digest for this nonce.
|
|
88
|
-
// The hardware-evidence-binds-report_data half is verifyHardwareQuote's job.
|
|
89
|
-
const expectedReportData = await computeReportData(workloadId, workloadKeysetDigest, nonce);
|
|
90
|
-
pushEqual(checks, "report_data", report.attestation.report_data, expectedReportData);
|
|
91
|
-
|
|
92
|
-
// Check 5: keyset endorsement verifies under the identity key, algo matching.
|
|
93
|
-
const endorsement = report.attestation.keyset_endorsement;
|
|
94
|
-
if (endorsement.algo !== identityKey.algo) {
|
|
95
|
-
checks.push({
|
|
96
|
-
name: "keyset_endorsement",
|
|
97
|
-
ok: false,
|
|
98
|
-
detail: `endorsement.algo "${endorsement.algo}" != identity key algo "${identityKey.algo}"`,
|
|
99
|
-
});
|
|
100
|
-
} else {
|
|
101
|
-
const ok = await verifySecp256k1(
|
|
102
|
-
identityKey.public_key,
|
|
103
|
-
endorsement.value,
|
|
104
|
-
keysetEndorsementPayload(workloadKeysetDigest),
|
|
105
|
-
);
|
|
106
|
-
checks.push({
|
|
107
|
-
name: "keyset_endorsement",
|
|
108
|
-
ok,
|
|
109
|
-
...(ok ? {} : { detail: "endorsement signature failed under identity key" }),
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Check 6: freshness. Nonce binding is check 4; here bound the epoch and, when
|
|
114
|
-
// the profile trusts it, the declared validity window.
|
|
115
|
-
const notAfter = keyset.keyset_epoch.not_after;
|
|
116
|
-
const epochOk = now < notAfter;
|
|
117
|
-
checks.push({
|
|
118
|
-
name: "keyset_epoch.not_after",
|
|
119
|
-
ok: epochOk,
|
|
120
|
-
...(epochOk ? {} : { detail: `now ${now} >= not_after ${notAfter}` }),
|
|
121
|
-
});
|
|
122
|
-
if (options.trustPlatformClock) {
|
|
123
|
-
const freshness = report.attestation.freshness;
|
|
124
|
-
const fetchedAt = freshness?.fetched_at;
|
|
125
|
-
const staleAfter = freshness?.stale_after;
|
|
126
|
-
const windowOk =
|
|
127
|
-
typeof fetchedAt === "number" &&
|
|
128
|
-
typeof staleAfter === "number" &&
|
|
129
|
-
fetchedAt <= now &&
|
|
130
|
-
now < staleAfter;
|
|
131
|
-
checks.push({
|
|
132
|
-
name: "freshness_window",
|
|
133
|
-
ok: windowOk,
|
|
134
|
-
...(windowOk ? {} : { detail: `now ${now} outside [${fetchedAt}, ${staleAfter})` }),
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return { ok: checks.every((c) => c.ok), checks, workloadId, workloadKeysetDigest };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* §4.3 secp256k1 endorsement: a 64-byte `r || s` signature over
|
|
143
|
-
* `sha256(payload bytes)`. Returns false on malformed input rather than throwing —
|
|
144
|
-
* a bad signature is a failed check, not an exception.
|
|
145
|
-
*
|
|
146
|
-
* NOT the §8.5 *receipt* shape, which is a 65-byte recoverable `r || s || v` and
|
|
147
|
-
* where the spec says 64-byte signatures MUST be rejected. Different shapes; easy
|
|
148
|
-
* to conflate if this ever grows a receipt path.
|
|
149
|
-
*/
|
|
150
|
-
async function verifySecp256k1(
|
|
151
|
-
publicKeyHex: string,
|
|
152
|
-
signatureHex: string,
|
|
153
|
-
payload: Uint8Array,
|
|
154
|
-
): Promise<boolean> {
|
|
155
|
-
try {
|
|
156
|
-
const sig = fromHex(signatureHex);
|
|
157
|
-
if (sig.length !== 64) return false; // r||s only; DER / recoverable forms are not §4.3
|
|
158
|
-
const msgHash = await sha256(payload);
|
|
159
|
-
return secp256k1.verify(sig, msgHash, publicKey(publicKeyHex), {
|
|
160
|
-
prehash: false, // we hand it the sha256 digest, per §4.3
|
|
161
|
-
// Accept high-s as well as low-s. ECDSA malleability is meaningless for a
|
|
162
|
-
// signature over a FIXED payload — an attacker who can flip s already has a
|
|
163
|
-
// valid endorsement and still cannot sign a different keyset digest. Leaving
|
|
164
|
-
// the default on would reject ~half of otherwise-valid endorsements from any
|
|
165
|
-
// signer that doesn't normalize, as an intermittent attestation failure.
|
|
166
|
-
lowS: false,
|
|
167
|
-
});
|
|
168
|
-
} catch {
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Identity key bytes. §7.1 pins secp256k1 public keys as 65-byte uncompressed SEC1
|
|
175
|
-
* and requires that "the 64-byte uncompressed form without the `0x04` prefix MUST be
|
|
176
|
-
* accepted and treated as the same key" — so restore the prefix when it's absent.
|
|
177
|
-
* The live gateway sends the 65-byte form; do NOT prefix that one again.
|
|
178
|
-
*/
|
|
179
|
-
function publicKey(hex: string): Uint8Array {
|
|
180
|
-
const raw = fromHex(hex);
|
|
181
|
-
if (raw.length === 64) {
|
|
182
|
-
const sec1 = new Uint8Array(65);
|
|
183
|
-
sec1[0] = 0x04;
|
|
184
|
-
sec1.set(raw, 1);
|
|
185
|
-
return sec1;
|
|
186
|
-
}
|
|
187
|
-
return raw;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function pushEqual(checks: Check[], name: string, actual: string, expected: string): void {
|
|
191
|
-
const ok = actual === expected;
|
|
192
|
-
checks.push({ name, ok, ...(ok ? {} : { detail: `report ${actual} != recomputed ${expected}` }) });
|
|
193
|
-
}
|