@suveren/gateway 0.19.4 → 0.20.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/content/guides/1-interview.md +6 -0
- package/content/guides/2-package.md +4 -1
- package/content/guides/3-mandates.md +3 -0
- package/content/guides/6-risks.md +2 -0
- package/content/integrations/crm.json +1 -1
- package/content/integrations/deploy-github.json +1 -1
- package/content/integrations/erp.json +1 -1
- package/content/integrations/mail.json +1 -1
- package/content/integrations/records.json +1 -1
- package/dist/control-plane/index.mjs +21 -13
- package/dist/mcp-server/{chunk-ZRRZFGRL.mjs → chunk-APIQIEUA.mjs} +39 -27
- package/dist/mcp-server/http.mjs +382 -97
- package/dist/mcp-server/report-verify-cli.mjs +1 -1
- package/dist/ui/assets/index-1gCMlbLl.js +116 -0
- package/dist/ui/assets/{index-RCFQUjaF.css → index-blnpvM2g.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/node_modules/@hap/core/README.md +92 -29
- package/node_modules/@hap/core/dist/index.d.mts +614 -593
- package/node_modules/@hap/core/dist/index.d.ts +614 -593
- package/node_modules/@hap/core/dist/index.js +774 -749
- package/node_modules/@hap/core/dist/index.mjs +744 -724
- package/node_modules/@hap/core/package.json +1 -1
- package/node_modules/@hap/core/src/base64url.ts +39 -0
- package/node_modules/@hap/core/src/commitment-modes.ts +0 -21
- package/node_modules/@hap/core/src/errors.ts +76 -0
- package/node_modules/@hap/core/src/frame.ts +47 -178
- package/node_modules/@hap/core/src/gatekeeper.ts +219 -600
- package/node_modules/@hap/core/src/identity.ts +9 -12
- package/node_modules/@hap/core/src/index.ts +8 -4
- package/node_modules/@hap/core/src/mandate.ts +177 -166
- package/node_modules/@hap/core/src/owner-signature.ts +282 -0
- package/node_modules/@hap/core/src/profile.ts +234 -0
- package/node_modules/@hap/core/src/ticket.ts +148 -0
- package/node_modules/@hap/core/src/types.ts +191 -344
- package/node_modules/@hap/core/src/versions.ts +47 -0
- package/package.json +2 -2
- package/profiles/calendar/0.6.profile.json +191 -0
- package/profiles/charge/0.6.profile.json +211 -0
- package/profiles/customers/0.9.profile.json +182 -0
- package/profiles/delegation/0.3.profile.json +118 -0
- package/profiles/deploy/0.11.profile.json +177 -0
- package/profiles/email/0.8.profile.json +226 -0
- package/profiles/index.json +12 -1
- package/profiles/publish/0.6.profile.json +187 -0
- package/profiles/purchase/0.7.profile.json +235 -0
- package/profiles/records/0.6.profile.json +118 -0
- package/profiles/reporting/0.3.profile.json +98 -0
- package/profiles/sales/0.4.profile.json +291 -0
- package/dist/ui/assets/index-BseWI4iJ.js +0 -116
- package/node_modules/@hap/core/src/attestation.ts +0 -173
- package/node_modules/@hap/core/src/receipt.ts +0 -74
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner Signatures (v0.7) — the `HAP-mandate-projection` object, the
|
|
3
|
+
* `HAP-approval` object, and their verification.
|
|
4
|
+
*
|
|
5
|
+
* Renamed from `mandate.ts` (protocol.md → *Migration from v0.6*: the
|
|
6
|
+
* owner-signed projection's `typ` moves from `"HAP-mandate"` to
|
|
7
|
+
* `"HAP-mandate-projection"`, disambiguating it from the AS-signed mandate
|
|
8
|
+
* itself, which now legitimately owns the name `"HAP-mandate"`).
|
|
9
|
+
*
|
|
10
|
+
* The human signs BEFORE the AS does, so they cannot sign the finished
|
|
11
|
+
* mandate (`mandate_id`/`issued_at` do not exist yet). They sign a mandate
|
|
12
|
+
* PROJECTION: a canonical object every field of which is known at approval
|
|
13
|
+
* time and reconstructible from the finished mandate — so a verifier
|
|
14
|
+
* rebuilds it from the mandate it already holds and checks the signature
|
|
15
|
+
* with the key carried in the owner's DID. No side channel, no second
|
|
16
|
+
* fetch, no key directory. See protocol.md → "Owner Signatures".
|
|
17
|
+
*
|
|
18
|
+
* Verification here requires NO trust in the AS. What it cannot do is tell
|
|
19
|
+
* the verifier WHOSE key signed — confirming the DID belongs to the
|
|
20
|
+
* expected person is the out-of-band step (protocol.md → "Verification
|
|
21
|
+
* procedure" step 5), and it is the honest cost of cold verification.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as ed from '@noble/ed25519';
|
|
25
|
+
import type { Mandate, MandatePayload, MandateOwnerEntry, OwnerSignatureBinding, ProfileOwnerSignatureFloor } from './types';
|
|
26
|
+
import { canonicalize } from './canonicalize';
|
|
27
|
+
import { decodeDidKey } from './did-key';
|
|
28
|
+
import { toBase64Url, fromBase64Url } from './base64url';
|
|
29
|
+
import type { HapErrorCode } from './errors';
|
|
30
|
+
|
|
31
|
+
/** The object the owner signs — a canonical projection of the mandate.
|
|
32
|
+
* Field absence is defined, not incidental (protocol.md → *The signed
|
|
33
|
+
* object: HAP-mandate-projection*): each conditional field is included
|
|
34
|
+
* exactly when the mandate carries it and omitted otherwise. */
|
|
35
|
+
export interface MandateProjection {
|
|
36
|
+
typ: 'HAP-mandate-projection';
|
|
37
|
+
/** Projection/canonicalization version. A verifier MUST pin it. */
|
|
38
|
+
version: '0.7';
|
|
39
|
+
profile_id: string;
|
|
40
|
+
/** Covered deliberately: the owner signs the profile's BYTES, not only its id. */
|
|
41
|
+
profile_hash: string;
|
|
42
|
+
/** The signing owner's own DID. MUST equal the carrying mandate's `mandate_owners[0].did`. */
|
|
43
|
+
owner_did: string;
|
|
44
|
+
bounds_hash: string;
|
|
45
|
+
scope_hash: string;
|
|
46
|
+
execution_context_hash: string;
|
|
47
|
+
gate_content_hashes: Record<string, string>;
|
|
48
|
+
/** Included iff the mandate carries one — binds ciphertext AND the
|
|
49
|
+
* frozen approver set, the swap this mechanism exists to stop. */
|
|
50
|
+
intent_disclosure_hash?: string;
|
|
51
|
+
commitment_mode: string;
|
|
52
|
+
/** Included together, iff `commitment_mode === "review_above_cap"`. */
|
|
53
|
+
above_cap_caps?: Record<string, number>;
|
|
54
|
+
above_cap_approvers?: string[];
|
|
55
|
+
/** Included iff the mandate narrows the profile's disclose_fields. */
|
|
56
|
+
disclose_fields?: string[];
|
|
57
|
+
/** The replay defence: the human signs how long the authority lives. */
|
|
58
|
+
expires_at: number;
|
|
59
|
+
nonce: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Per-action approval, signed by the owner in `review` mode. Signing a
|
|
63
|
+
* `reject` matters as much as a `commit`: a rejection the AS can discard is
|
|
64
|
+
* a rejection that never happened. */
|
|
65
|
+
export interface ApprovalObject {
|
|
66
|
+
typ: 'HAP-approval';
|
|
67
|
+
version: '0.7';
|
|
68
|
+
proposal_id: string;
|
|
69
|
+
/** Renamed from `attestation_id` in v0.7. */
|
|
70
|
+
mandate_id: string;
|
|
71
|
+
decision: 'commit' | 'reject';
|
|
72
|
+
/** What was approved: the ticket's contentHash where the profile binds
|
|
73
|
+
* content; otherwise sha256 over the JCS of the proposal's argument set. */
|
|
74
|
+
content_hash: string;
|
|
75
|
+
decided_at: number;
|
|
76
|
+
nonce: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Renamed from `MandateError` in v0.7 — carries a canonical {@link HapErrorCode}. */
|
|
80
|
+
export class OwnerSignatureError extends Error {
|
|
81
|
+
constructor(public code: HapErrorCode, message: string) {
|
|
82
|
+
super(`${code}: ${message}`);
|
|
83
|
+
this.name = 'OwnerSignatureError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Decode a signature, refusing non-base64url input with THIS artifact's code
|
|
88
|
+
* — decoding sits outside the `.catch(() => false)` of the verify call, so
|
|
89
|
+
* without this a malformed owner or approval signature escaped with no code. */
|
|
90
|
+
function decodeOrThrow(signature: string, code: HapErrorCode): Uint8Array {
|
|
91
|
+
try {
|
|
92
|
+
return fromBase64Url(signature);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new OwnerSignatureError(code, (err as Error).message);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Rebuild the projection a given `mandate_owners` entry signed, from the
|
|
100
|
+
* mandate's own signed fields. Field absence is defined, not incidental:
|
|
101
|
+
* `intent_disclosure_hash`, `above_cap_caps`/`above_cap_approvers`, and
|
|
102
|
+
* `disclose_fields` are included iff the mandate carries them.
|
|
103
|
+
*/
|
|
104
|
+
export function buildMandateProjection(
|
|
105
|
+
payload: MandatePayload,
|
|
106
|
+
entry: Pick<MandateOwnerEntry, 'did' | 'nonce'>,
|
|
107
|
+
): MandateProjection {
|
|
108
|
+
const {
|
|
109
|
+
profile_id, profile_hash, bounds_hash, scope_hash, execution_context_hash,
|
|
110
|
+
gate_content_hashes, commitment_mode, expires_at,
|
|
111
|
+
} = payload;
|
|
112
|
+
if (!profile_hash || !bounds_hash || !scope_hash || !commitment_mode) {
|
|
113
|
+
throw new OwnerSignatureError(
|
|
114
|
+
'MALFORMED_MANDATE',
|
|
115
|
+
'mandate projection requires profile_hash, bounds_hash, scope_hash and commitment_mode',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const projection: MandateProjection = {
|
|
119
|
+
typ: 'HAP-mandate-projection',
|
|
120
|
+
version: '0.7',
|
|
121
|
+
profile_id,
|
|
122
|
+
profile_hash,
|
|
123
|
+
owner_did: entry.did,
|
|
124
|
+
bounds_hash,
|
|
125
|
+
scope_hash,
|
|
126
|
+
execution_context_hash,
|
|
127
|
+
gate_content_hashes,
|
|
128
|
+
commitment_mode,
|
|
129
|
+
expires_at,
|
|
130
|
+
nonce: entry.nonce!,
|
|
131
|
+
};
|
|
132
|
+
if (payload.intent_disclosure_hash !== undefined) {
|
|
133
|
+
projection.intent_disclosure_hash = payload.intent_disclosure_hash;
|
|
134
|
+
}
|
|
135
|
+
if (payload.commitment_mode === 'review_above_cap') {
|
|
136
|
+
projection.above_cap_caps = payload.above_cap_caps;
|
|
137
|
+
projection.above_cap_approvers = payload.above_cap_approvers;
|
|
138
|
+
}
|
|
139
|
+
if (payload.disclose_fields !== undefined) {
|
|
140
|
+
projection.disclose_fields = payload.disclose_fields;
|
|
141
|
+
}
|
|
142
|
+
return projection;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The exact bytes an owner signs: RFC 8785 (JCS) canonical UTF-8. Renamed
|
|
146
|
+
* from `mandateSigningBytes` in v0.7. */
|
|
147
|
+
export function projectionSigningBytes(projection: MandateProjection): Uint8Array {
|
|
148
|
+
return new TextEncoder().encode(canonicalize(projection));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The exact bytes an owner signs for a per-action approval. */
|
|
152
|
+
export function approvalSigningBytes(approval: ApprovalObject): Uint8Array {
|
|
153
|
+
return new TextEncoder().encode(canonicalize(approval));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sign a mandate projection with a raw Ed25519 private key — the `raw`
|
|
158
|
+
* binding: tests and CI only, no custody claim. WebAuthn (`webauthn`) and
|
|
159
|
+
* wallet (`eudi`) bindings sign the same bytes through their own custody;
|
|
160
|
+
* they are implemented by the platforms that hold those keys, not here.
|
|
161
|
+
*/
|
|
162
|
+
export async function signMandateProjection(
|
|
163
|
+
projection: MandateProjection,
|
|
164
|
+
privateKey: Uint8Array,
|
|
165
|
+
): Promise<string> {
|
|
166
|
+
return toBase64Url(await ed.signAsync(projectionSigningBytes(projection), privateKey));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Sign an approval object with a raw Ed25519 private key (`raw` binding). */
|
|
170
|
+
export async function signApproval(approval: ApprovalObject, privateKey: Uint8Array): Promise<string> {
|
|
171
|
+
return toBase64Url(await ed.signAsync(approvalSigningBytes(approval), privateKey));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Verify ONE `mandate_owners` entry against the mandate that carries it.
|
|
176
|
+
*
|
|
177
|
+
* Steps (protocol.md → "Verification procedure", step 4 of 6): the entry
|
|
178
|
+
* names this mandate's sole owner, its signing DID is key-bearing,
|
|
179
|
+
* projection reconstruction, Ed25519 over JCS bytes. The DID is
|
|
180
|
+
* authoritative over `alg` — a disagreement is `OWNER_SIGNATURE_INVALID`,
|
|
181
|
+
* never a fallback to the claimed algorithm.
|
|
182
|
+
*
|
|
183
|
+
* What this deliberately does NOT verify: that the DID belongs to the
|
|
184
|
+
* person the verifier expects (out-of-band, step 5) and the AS's own
|
|
185
|
+
* signature over the mandate (`verifyMandateSignature`, step 1).
|
|
186
|
+
*/
|
|
187
|
+
export async function verifyOwnerSignature(mandate: Mandate, entry: MandateOwnerEntry): Promise<void> {
|
|
188
|
+
const owners = mandate.payload.mandate_owners ?? [];
|
|
189
|
+
if (!owners.some((o) => o.did === entry.did)) {
|
|
190
|
+
throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', `signing DID ${entry.did} is not this mandate's mandate_owners`);
|
|
191
|
+
}
|
|
192
|
+
if (entry.alg !== undefined && entry.alg !== 'EdDSA') {
|
|
193
|
+
// The only key type a did:key carries in this protocol version is Ed25519;
|
|
194
|
+
// an entry claiming otherwise disagrees with its own DID.
|
|
195
|
+
throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', `alg ${entry.alg} disagrees with the DID's key type (DID is authoritative)`);
|
|
196
|
+
}
|
|
197
|
+
if (!entry.signature || !entry.nonce) {
|
|
198
|
+
throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', `mandate_owners entry for ${entry.did} carries no signature to verify`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let publicKey: Uint8Array;
|
|
202
|
+
try {
|
|
203
|
+
publicKey = decodeDidKey(entry.did);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', (err as Error).message);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const projection = buildMandateProjection(mandate.payload, entry);
|
|
209
|
+
const ok = await ed
|
|
210
|
+
.verifyAsync(decodeOrThrow(entry.signature, 'OWNER_SIGNATURE_INVALID'), projectionSigningBytes(projection), publicKey)
|
|
211
|
+
.catch(() => false);
|
|
212
|
+
if (!ok) {
|
|
213
|
+
throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', `owner signature by ${entry.did} does not verify`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Verify every co-signing `mandate_owners` entry a mandate carries (i.e.
|
|
218
|
+
* every entry that carries a signature). Resolves to the verified entries;
|
|
219
|
+
* a mandate with none resolves to `[]` — nothing to check, nothing claimed. */
|
|
220
|
+
export async function verifyOwnerSignatures(mandate: Mandate): Promise<MandateOwnerEntry[]> {
|
|
221
|
+
const entries = (mandate.payload.mandate_owners ?? []).filter((e) => e.signature !== undefined);
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
await verifyOwnerSignature(mandate, entry);
|
|
224
|
+
}
|
|
225
|
+
return entries;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Verify an approval signature against a signer's key-bearing DID. */
|
|
229
|
+
export async function verifyApproval(approval: ApprovalObject, signature: string, signerDid: string): Promise<void> {
|
|
230
|
+
let publicKey: Uint8Array;
|
|
231
|
+
try {
|
|
232
|
+
publicKey = decodeDidKey(signerDid);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', (err as Error).message);
|
|
235
|
+
}
|
|
236
|
+
const ok = await ed.verifyAsync(decodeOrThrow(signature, 'APPROVAL_SIGNATURE_INVALID'), approvalSigningBytes(approval), publicKey).catch(() => false);
|
|
237
|
+
if (!ok) {
|
|
238
|
+
throw new OwnerSignatureError('APPROVAL_SIGNATURE_INVALID', `approval signature by ${signerDid} does not verify`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Signature-assurance ranking for `OwnerSignatureBinding`, weakest first —
|
|
243
|
+
* protocol.md → *Owner Signatures* → "binding" table ordering (raw < webauthn < eudi). */
|
|
244
|
+
const BINDING_RANK: Record<OwnerSignatureBinding, number> = { raw: 0, webauthn: 1, eudi: 2 };
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Enforce a profile's `ownerSignature` floor (protocol.md → *Owner
|
|
248
|
+
* Signatures* → "Where the requirement lives" — the profile-floor tier,
|
|
249
|
+
* checkable without trusting the AS: `profile_id`/`profile_hash` are in the
|
|
250
|
+
* signed payload AND in the owner-signed projection).
|
|
251
|
+
*
|
|
252
|
+
* An unknown `binding` value is treated as rank 0 (weakest) rather than
|
|
253
|
+
* rejected outright — "an AS MUST NOT reject a mandate_owners entry solely
|
|
254
|
+
* because it does not recognize its binding value" extends naturally to a
|
|
255
|
+
* verifier ranking it, since crediting an unrecognized binding with
|
|
256
|
+
* unearned strength would be the more dangerous failure mode.
|
|
257
|
+
*
|
|
258
|
+
* @throws OwnerSignatureError `OWNER_SIGNATURE_REQUIRED` when the floor is
|
|
259
|
+
* unmet — "MUST fail, not warn" (protocol.md, same section).
|
|
260
|
+
*/
|
|
261
|
+
export function checkOwnerSignatureRequirement(
|
|
262
|
+
payload: MandatePayload,
|
|
263
|
+
floor: ProfileOwnerSignatureFloor | undefined,
|
|
264
|
+
): void {
|
|
265
|
+
if (!floor?.required) return;
|
|
266
|
+
const entry = payload.mandate_owners[0];
|
|
267
|
+
const signed = entry?.signature !== undefined && entry?.binding !== undefined;
|
|
268
|
+
if (!signed) {
|
|
269
|
+
throw new OwnerSignatureError(
|
|
270
|
+
'OWNER_SIGNATURE_REQUIRED',
|
|
271
|
+
'this profile requires an owner signature and mandate_owners[0] carries none',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const minRank = floor.minBinding ? BINDING_RANK[floor.minBinding] : 0;
|
|
275
|
+
const haveRank = BINDING_RANK[entry!.binding as OwnerSignatureBinding] ?? 0;
|
|
276
|
+
if (haveRank < minRank) {
|
|
277
|
+
throw new OwnerSignatureError(
|
|
278
|
+
'OWNER_SIGNATURE_REQUIRED',
|
|
279
|
+
`this profile requires binding >= "${floor.minBinding}", mandate_owners[0] carries "${entry!.binding}"`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile hashing and authoring-time validation (new in v0.7).
|
|
3
|
+
*
|
|
4
|
+
* `computeProfileHash` is the AS-side and Gatekeeper-side half of
|
|
5
|
+
* `profile_hash` (protocol.md → *Profile hash*): "sha256 of the RFC 8785
|
|
6
|
+
* (JCS) serialization of the profile JSON" — over the PARSED document, never
|
|
7
|
+
* file bytes, so two parties who provisioned the same profile through
|
|
8
|
+
* different channels (a git checkout, an npm tarball, a copy with a
|
|
9
|
+
* trailing newline) always agree.
|
|
10
|
+
*
|
|
11
|
+
* `validateProfile` folds together the authoring-time checks that used to
|
|
12
|
+
* live in three places (`frame.ts`'s `validateBoundsRequiredFor`,
|
|
13
|
+
* `commitment-modes.ts`'s `validateCommitmentModes`, and nothing at all for
|
|
14
|
+
* the v0.7 structural rules) into one function returning the canonical
|
|
15
|
+
* `PROFILE_INVALID` shape an AS can emit directly. Like its predecessors,
|
|
16
|
+
* it is NOT wired into `registerProfile` — nothing in hap-core validates
|
|
17
|
+
* profile JSON at load time; call it explicitly from an authoring tool, a
|
|
18
|
+
* hap-profiles CI check, or a test.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from 'crypto';
|
|
21
|
+
import { canonicalize } from './canonicalize';
|
|
22
|
+
import { allowedCommitmentModes, SIGNABLE_COMMITMENT_MODES } from './commitment-modes';
|
|
23
|
+
import type { AgentProfile, BoundType } from './types';
|
|
24
|
+
import { HapError, type HapErrorCode } from './errors';
|
|
25
|
+
|
|
26
|
+
/** One authoring-time finding. `code` is always a canonical v0.7 code — in
|
|
27
|
+
* practice always `PROFILE_INVALID`, since that is the only mandate-request
|
|
28
|
+
* refusal the protocol attributes to "the profile fails validation"
|
|
29
|
+
* (protocol.md → Error Codes). */
|
|
30
|
+
export interface ProfileValidationError {
|
|
31
|
+
code: HapErrorCode;
|
|
32
|
+
field?: string;
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `profile_hash` — `"sha256:" + sha256(JCS(parsed))`, over the PARSED
|
|
38
|
+
* profile document. Formatting, key order, and a trailing newline on disk
|
|
39
|
+
* never change it (protocol.md → *Profile hash*; conformance vector set
|
|
40
|
+
* `profile-hash.json`).
|
|
41
|
+
*/
|
|
42
|
+
export function computeProfileHash(parsed: unknown): string {
|
|
43
|
+
const hash = createHash('sha256').update(canonicalize(parsed), 'utf8').digest('hex');
|
|
44
|
+
return `sha256:${hash}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Keys retired by earlier versions. A profile authored against an old
|
|
48
|
+
* vocabulary fails loudly here instead of silently losing the field's
|
|
49
|
+
* effect (protocol.md → *Migration from v0.6*, "Signed-payload, profile, and
|
|
50
|
+
* wire renames"). */
|
|
51
|
+
const RETIRED_PROFILE_KEYS: Record<string, string> = {
|
|
52
|
+
contextSchema: 'scopeSchema',
|
|
53
|
+
ownerMandate: 'ownerSignature',
|
|
54
|
+
receipt_lookup: 'ticket_lookup',
|
|
55
|
+
frameSchema: '(removed — v0.3 execution frames are retired)',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Authoring-time structural validation (protocol.md → *Bounds Schema*,
|
|
60
|
+
* *Owner Signatures* → "Where the requirement lives", *Migration from
|
|
61
|
+
* v0.6*). Returns `[]` for a clean profile; never throws.
|
|
62
|
+
*/
|
|
63
|
+
export function validateProfile(profile: AgentProfile): ProfileValidationError[] {
|
|
64
|
+
const errors: ProfileValidationError[] = [];
|
|
65
|
+
const p = profile as AgentProfile & Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
// ── Retired keys (v0.7 rename table) ────────────────────────────────────
|
|
68
|
+
for (const [retired, current] of Object.entries(RETIRED_PROFILE_KEYS)) {
|
|
69
|
+
if (retired in p) {
|
|
70
|
+
errors.push({
|
|
71
|
+
code: 'PROFILE_INVALID',
|
|
72
|
+
field: retired,
|
|
73
|
+
message: `"${retired}" is retired in v0.7 — use "${current}" instead.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(p.requiredGates) && (p.requiredGates as unknown[]).includes('decision_owner')) {
|
|
78
|
+
errors.push({
|
|
79
|
+
code: 'PROFILE_INVALID',
|
|
80
|
+
field: 'requiredGates',
|
|
81
|
+
message: '"decision_owner" is retired in v0.7 — the required gate is "mandate_owner".',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Bounds schema ────────────────────────────────────────────────────────
|
|
86
|
+
const boundsSchema = profile.boundsSchema;
|
|
87
|
+
if (boundsSchema) {
|
|
88
|
+
const actionTypes = boundsSchema.actionTypes ? [...boundsSchema.actionTypes] : undefined;
|
|
89
|
+
|
|
90
|
+
for (const [fieldName, fieldDef] of Object.entries(boundsSchema.fields)) {
|
|
91
|
+
if (fieldName === 'profile') continue;
|
|
92
|
+
|
|
93
|
+
// Bounds Schema rule 6: no action-routing arrays. v0.4 retired execution
|
|
94
|
+
// paths; routing is the job of actionType + the tool-gating manifest.
|
|
95
|
+
const routing = ['path', 'paths'].filter((k) => k in (fieldDef as object));
|
|
96
|
+
if (routing.length > 0) {
|
|
97
|
+
errors.push({
|
|
98
|
+
code: 'PROFILE_INVALID',
|
|
99
|
+
field: fieldName,
|
|
100
|
+
message: `Bounds field "${fieldName}" declares ${routing.map((k) => `"${k}"`).join(' and ')} — action-routing arrays are forbidden (Bounds Schema rule 6); use actionType and appliesTo.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!fieldDef.boundType) {
|
|
105
|
+
errors.push({
|
|
106
|
+
code: 'PROFILE_INVALID',
|
|
107
|
+
field: fieldName,
|
|
108
|
+
message: `Bounds field "${fieldName}" has no boundType — enforcement semantics undefined (Bounds Schema rule 2).`,
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const bt = fieldDef.boundType as BoundType & { requiredFor?: unknown };
|
|
113
|
+
|
|
114
|
+
// appliesTo on a per_transaction or enum bound (new in v0.7).
|
|
115
|
+
if (fieldDef.appliesTo && (bt.kind === 'per_transaction' || bt.kind === 'enum')) {
|
|
116
|
+
errors.push({
|
|
117
|
+
code: 'PROFILE_INVALID',
|
|
118
|
+
field: fieldName,
|
|
119
|
+
message: `Bounds field "${fieldName}": appliesTo MUST NOT be declared on a ${bt.kind} bound (Bounds Schema rule 7).`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// cumulative_count on a v0.7+ profile MUST declare appliesTo.
|
|
124
|
+
if (bt.kind === 'cumulative_count' && !fieldDef.appliesTo?.length) {
|
|
125
|
+
errors.push({
|
|
126
|
+
code: 'PROFILE_INVALID',
|
|
127
|
+
field: fieldName,
|
|
128
|
+
message: `Bounds field "${fieldName}": a cumulative_count bound on a v0.7+ profile MUST declare appliesTo (Bounds Schema rule 7) — absence would silently throttle every action type.`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// appliesTo members MUST be drawn from the actionTypes registry.
|
|
133
|
+
if (fieldDef.appliesTo && actionTypes) {
|
|
134
|
+
for (const at of fieldDef.appliesTo) {
|
|
135
|
+
if (!actionTypes.includes(at)) {
|
|
136
|
+
errors.push({
|
|
137
|
+
code: 'PROFILE_INVALID',
|
|
138
|
+
field: fieldName,
|
|
139
|
+
message: `Bounds field "${fieldName}": appliesTo names "${at}", which is not in boundsSchema.actionTypes [${actionTypes.join(', ')}].`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// requiredFor is only meaningful on a per_transaction bound — every
|
|
146
|
+
// enforcement point ignores it on any other kind.
|
|
147
|
+
if (bt.kind !== 'per_transaction' && (bt as { requiredFor?: unknown }).requiredFor !== undefined) {
|
|
148
|
+
errors.push({
|
|
149
|
+
code: 'PROFILE_INVALID',
|
|
150
|
+
field: fieldName,
|
|
151
|
+
message: `Bounds field "${fieldName}": requiredFor is only meaningful on a per_transaction bound (found on kind "${bt.kind}") — every enforcement point ignores it there. Remove it.`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// per_transaction.requiredFor members MUST also be registered.
|
|
156
|
+
if (bt.kind === 'per_transaction' && Array.isArray(bt.requiredFor) && actionTypes) {
|
|
157
|
+
for (const at of bt.requiredFor as string[]) {
|
|
158
|
+
if (!actionTypes.includes(at)) {
|
|
159
|
+
errors.push({
|
|
160
|
+
code: 'PROFILE_INVALID',
|
|
161
|
+
field: fieldName,
|
|
162
|
+
message: `Bounds field "${fieldName}": requiredFor names "${at}", which is not in boundsSchema.actionTypes [${actionTypes.join(', ')}].`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Commitment modes ─────────────────────────────────────────────────────
|
|
171
|
+
const declaredModes = (p as { commitment_modes?: unknown }).commitment_modes;
|
|
172
|
+
if (declaredModes !== undefined) {
|
|
173
|
+
if (!Array.isArray(declaredModes)) {
|
|
174
|
+
errors.push({ code: 'PROFILE_INVALID', field: 'commitment_modes', message: 'commitment_modes must be a list of modes.' });
|
|
175
|
+
} else {
|
|
176
|
+
if (declaredModes.length === 0) {
|
|
177
|
+
errors.push({
|
|
178
|
+
code: 'PROFILE_INVALID',
|
|
179
|
+
field: 'commitment_modes',
|
|
180
|
+
message: 'commitment_modes is empty — no mandate could ever be signed under this profile.',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const seen = new Set<unknown>();
|
|
184
|
+
for (const m of declaredModes) {
|
|
185
|
+
if (!(SIGNABLE_COMMITMENT_MODES as readonly string[]).includes(m as string)) {
|
|
186
|
+
errors.push({
|
|
187
|
+
code: 'PROFILE_INVALID',
|
|
188
|
+
field: 'commitment_modes',
|
|
189
|
+
message: `commitment_modes: "${String(m)}" is not a mode a signer can choose (${SIGNABLE_COMMITMENT_MODES.join(', ')}).`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (seen.has(m)) {
|
|
193
|
+
errors.push({ code: 'PROFILE_INVALID', field: 'commitment_modes', message: `commitment_modes: "${String(m)}" appears twice.` });
|
|
194
|
+
}
|
|
195
|
+
seen.add(m);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// allowedCommitmentModes is the fail-closed reader; an always-empty result
|
|
200
|
+
// for a profile that DID declare modes means the declaration is unusable.
|
|
201
|
+
if (Array.isArray(declaredModes) && declaredModes.length > 0 && allowedCommitmentModes(profile).length === 0) {
|
|
202
|
+
errors.push({
|
|
203
|
+
code: 'PROFILE_INVALID',
|
|
204
|
+
field: 'commitment_modes',
|
|
205
|
+
message: 'commitment_modes declares no mode this profile can actually use.',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return errors;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A mandate's `disclose_fields` MUST be a subset of the profile's own list
|
|
214
|
+
* (protocol.md → *Mandate Payload*, conditional field `disclose_fields`: "a
|
|
215
|
+
* subset of the profile's `disclose_fields` list"). A mandate may narrow
|
|
216
|
+
* disclosure, never widen it.
|
|
217
|
+
*
|
|
218
|
+
* @throws Error prefixed `MALFORMED_MANDATE:` when it is not a subset.
|
|
219
|
+
*/
|
|
220
|
+
export function checkDiscloseSubset(
|
|
221
|
+
mandateDiscloseFields: readonly string[] | undefined,
|
|
222
|
+
profileDiscloseFields: readonly string[] | undefined,
|
|
223
|
+
): void {
|
|
224
|
+
if (!mandateDiscloseFields || mandateDiscloseFields.length === 0) return;
|
|
225
|
+
const allowed = new Set(profileDiscloseFields ?? []);
|
|
226
|
+
const widened = mandateDiscloseFields.filter((f) => !allowed.has(f));
|
|
227
|
+
if (widened.length > 0) {
|
|
228
|
+
throw new HapError(
|
|
229
|
+
'MALFORMED_MANDATE',
|
|
230
|
+
`disclose_fields [${widened.join(', ')}] is not a subset of the profile's ` +
|
|
231
|
+
`disclose_fields [${[...allowed].join(', ')}] — a mandate may narrow disclosure, never widen it.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandate tickets (v0.7) — the wire type, signing, and holder-side
|
|
3
|
+
* verification. Renamed from `receipt.ts` (protocol.md → *Mandate Tickets*:
|
|
4
|
+
* "v0.4 introduced the object under the name execution receipt; v0.7 renames
|
|
5
|
+
* it because the tense was wrong — a receipt follows payment, and the ticket
|
|
6
|
+
* must come first").
|
|
7
|
+
*
|
|
8
|
+
* Ticket payloads are camelCase on the wire (unlike the snake_case mandate
|
|
9
|
+
* payload) — a shipped inconsistency the spec documents rather than breaks
|
|
10
|
+
* (protocol.md → *Migration from v0.6*, "Unchanged on purpose").
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { HapError } from './errors';
|
|
14
|
+
import * as ed from '@noble/ed25519';
|
|
15
|
+
import type { ContentBinding, Subject } from './types';
|
|
16
|
+
import { canonicalize } from './canonicalize';
|
|
17
|
+
import { toBase64Url, fromBase64Url } from './base64url';
|
|
18
|
+
import { decodeDidKey } from './did-key';
|
|
19
|
+
|
|
20
|
+
/** Signed mandate ticket — protocol.md → *Ticket Payload Schema*. */
|
|
21
|
+
export interface TicketPayload {
|
|
22
|
+
id: string;
|
|
23
|
+
/** The mandate this ticket was issued under (new in v0.7). Audit and
|
|
24
|
+
* chain-of-custody only — `boundsHash` remains the AS's lookup key. */
|
|
25
|
+
mandateId: string;
|
|
26
|
+
groupId: string | null;
|
|
27
|
+
userId?: string;
|
|
28
|
+
boundsHash: string;
|
|
29
|
+
profileId: string;
|
|
30
|
+
/** Downstream tool name. Audit metadata only — never a dispatch key. */
|
|
31
|
+
action: string;
|
|
32
|
+
/** Semantic category — drives cumulative bucketing and bounds dispatch. */
|
|
33
|
+
actionType: string;
|
|
34
|
+
executionContext: Record<string, unknown>;
|
|
35
|
+
cumulativeState?: Record<string, unknown>;
|
|
36
|
+
/** The mandate's plaintext bounds object, exactly as the AS enforced them
|
|
37
|
+
* for this ticket (new in v0.7 — required). */
|
|
38
|
+
limits: Record<string, unknown>;
|
|
39
|
+
/** Protocol version of the ticket — the version of the mandate it was
|
|
40
|
+
* issued under (new in v0.7). */
|
|
41
|
+
version: string;
|
|
42
|
+
/** The AS DID, as in the mandate (new in v0.7). */
|
|
43
|
+
issuer: string;
|
|
44
|
+
timestamp: number;
|
|
45
|
+
/** Hash of the bound content, computed by the Gatekeeper, copied
|
|
46
|
+
* verbatim by the AS. The AS never sees the content. */
|
|
47
|
+
contentHash?: string;
|
|
48
|
+
/** The binding declaration echoed into the signed ticket, so a
|
|
49
|
+
* verifier knows exactly what `contentHash` covers. */
|
|
50
|
+
contentBinding?: Pick<ContentBinding, 'version' | 'kind' | 'fields' | 'required_fields' | 'appliesTo'>;
|
|
51
|
+
/** Disclosed subset of the mandate's identity overlay. */
|
|
52
|
+
subjects?: Subject[];
|
|
53
|
+
/** Review path: the proposal this ticket executed. */
|
|
54
|
+
proposalId?: string;
|
|
55
|
+
/** Review path: the owner's HAP-approval signature for the executed
|
|
56
|
+
* proposal, as the object itself — never a hash of it, which a holder
|
|
57
|
+
* could check only by asking the AS. */
|
|
58
|
+
approvalSignature?: { alg: 'EdDSA' | 'ES256'; signature: string; nonce: string; decided_at: number };
|
|
59
|
+
/** Ed25519 over the JCS-canonical payload (this field excluded), base64url. */
|
|
60
|
+
signature: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sign a ticket payload with the Authority Server's Ed25519 private key —
|
|
65
|
+
* the same key used for mandates.
|
|
66
|
+
*/
|
|
67
|
+
export async function signTicket(
|
|
68
|
+
payload: Omit<TicketPayload, 'signature'>,
|
|
69
|
+
privateKey: Uint8Array,
|
|
70
|
+
): Promise<TicketPayload> {
|
|
71
|
+
const signature = toBase64Url(await ed.signAsync(new TextEncoder().encode(canonicalize(payload)), privateKey));
|
|
72
|
+
return { ...payload, signature };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Verify a ticket's AS signature: strip `signature`, JCS-canonicalize the
|
|
77
|
+
* rest, Ed25519-verify against the key resolved from `issuer` — never
|
|
78
|
+
* against a key offered alongside the ticket (protocol.md → *Ticket
|
|
79
|
+
* Verification* step 1). This is holder-side verification — it needs the
|
|
80
|
+
* COMPLETE ticket; a redacted public view cannot be re-verified this way
|
|
81
|
+
* (see {@link publicTicketView}).
|
|
82
|
+
*
|
|
83
|
+
* Content is checked separately: where `contentHash` is present, recompute
|
|
84
|
+
* it from the held artifact via the content-binding module and compare.
|
|
85
|
+
*
|
|
86
|
+
* @param opts.trustedIssuers When supplied, an `issuer` not in this list is
|
|
87
|
+
* rejected before any cryptography runs.
|
|
88
|
+
* @throws Error prefixed `INVALID_SIGNATURE:` when verification fails.
|
|
89
|
+
*/
|
|
90
|
+
export async function verifyTicketSignature(
|
|
91
|
+
ticket: TicketPayload,
|
|
92
|
+
opts?: { trustedIssuers?: readonly string[] },
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const { signature, ...unsigned } = ticket;
|
|
95
|
+
if (!signature) {
|
|
96
|
+
throw new HapError('INVALID_SIGNATURE', 'ticket carries no signature');
|
|
97
|
+
}
|
|
98
|
+
if (opts?.trustedIssuers && !opts.trustedIssuers.includes(ticket.issuer)) {
|
|
99
|
+
throw new HapError('INVALID_SIGNATURE', `issuer ${JSON.stringify(ticket.issuer)} is not a trusted Authority Server`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let publicKey: Uint8Array;
|
|
103
|
+
try {
|
|
104
|
+
publicKey = decodeDidKey(ticket.issuer);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new HapError('INVALID_SIGNATURE', `cannot resolve a key from issuer ${JSON.stringify(ticket.issuer)}: ${(err as Error).message}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const bytes = new TextEncoder().encode(canonicalize(unsigned));
|
|
110
|
+
let sigBytes: Uint8Array;
|
|
111
|
+
try {
|
|
112
|
+
sigBytes = fromBase64Url(signature);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new HapError('INVALID_SIGNATURE', `${(err as Error).message}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ok = await ed.verifyAsync(sigBytes, bytes, publicKey).catch(() => false);
|
|
118
|
+
if (!ok) {
|
|
119
|
+
throw new HapError('INVALID_SIGNATURE', 'ticket signature verification failed');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A redacted public view of a ticket (new in v0.7 — protocol.md → *Ticket
|
|
125
|
+
* Disclosure Is Declared*, *Ticket Verification*). Hides `userId`,
|
|
126
|
+
* `cumulativeState`, and `limits` unconditionally, and narrows
|
|
127
|
+
* `executionContext` to exactly the keys named by `governingDiscloseFields`
|
|
128
|
+
* (the mandate's `disclose_fields` where present, otherwise the profile's).
|
|
129
|
+
*
|
|
130
|
+
* Disclose NOTHING by default: an empty or absent `governingDiscloseFields`
|
|
131
|
+
* strips `executionContext` entirely. "The holder is the verifier" — this
|
|
132
|
+
* view is NOT independently re-verifiable against `signature` (it is missing
|
|
133
|
+
* signed fields); a "signature valid" indicator on a page showing only this
|
|
134
|
+
* view is the AS re-verifying its own signature, and implementations MUST
|
|
135
|
+
* NOT present it as independently verified.
|
|
136
|
+
*/
|
|
137
|
+
export function publicTicketView(
|
|
138
|
+
ticket: TicketPayload,
|
|
139
|
+
governingDiscloseFields: readonly string[] = [],
|
|
140
|
+
): Omit<TicketPayload, 'userId' | 'cumulativeState' | 'limits'> {
|
|
141
|
+
const { userId: _userId, cumulativeState: _cumulativeState, limits: _limits, executionContext, ...rest } = ticket;
|
|
142
|
+
const allowed = new Set(governingDiscloseFields);
|
|
143
|
+
const disclosedExecutionContext: Record<string, unknown> = {};
|
|
144
|
+
for (const [key, value] of Object.entries(executionContext)) {
|
|
145
|
+
if (allowed.has(key)) disclosedExecutionContext[key] = value;
|
|
146
|
+
}
|
|
147
|
+
return { ...rest, executionContext: disclosedExecutionContext };
|
|
148
|
+
}
|