@humanagencyp/hap-core 0.12.1 → 0.13.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/README.md +3 -3
- package/dist/index.d.mts +567 -123
- package/dist/index.d.ts +567 -123
- package/dist/index.js +869 -23
- package/dist/index.mjs +829 -22
- package/package.json +2 -1
- package/src/canonicalize.ts +9 -0
- package/src/commitment-modes.ts +21 -9
- package/src/errors.ts +24 -8
- package/src/gatekeeper.ts +52 -8
- package/src/index.ts +5 -1
- package/src/issuance.ts +250 -0
- package/src/mandate.ts +52 -14
- package/src/owner-signature.ts +86 -39
- package/src/profile.ts +45 -0
- package/src/proposal-content.ts +219 -0
- package/src/ticket-projection.ts +167 -0
- package/src/ticket.ts +254 -20
- package/src/types.ts +33 -5
- package/src/verifier-policy.ts +178 -0
- package/src/versions.ts +50 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanagencyp/hap-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"release:major": "pnpm version major && git push --follow-tags"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@hpke/core": "1.9.0",
|
|
36
37
|
"@noble/ed25519": "^2.1.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
package/src/canonicalize.ts
CHANGED
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
* conformance test pins a (payload → canonical bytes) vector that any other
|
|
32
32
|
* implementation can check against.
|
|
33
33
|
*/
|
|
34
|
+
/** A high surrogate not followed by a low one, or a low one not preceded by a high one. */
|
|
35
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
36
|
+
|
|
34
37
|
export function canonicalize(value: unknown): string {
|
|
35
38
|
if (value === undefined) {
|
|
36
39
|
throw new Error('canonicalize: undefined is not serializable');
|
|
@@ -41,6 +44,12 @@ export function canonicalize(value: unknown): string {
|
|
|
41
44
|
|
|
42
45
|
// Primitives: string, finite number, boolean, null — JSON.stringify is already
|
|
43
46
|
// canonical (RFC 8259 string escaping, ECMAScript number formatting).
|
|
47
|
+
if (typeof value === 'string' && LONE_SURROGATE.test(value)) {
|
|
48
|
+
// RFC 8785 requires I-JSON, which forbids a lone surrogate. JSON.stringify
|
|
49
|
+
// would escape it as \udXXX and sign bytes no other serializer produces;
|
|
50
|
+
// the protocol says refuse, never sign (Signing Canonicalization, rule 5).
|
|
51
|
+
throw new Error('canonicalize: string contains a lone surrogate and cannot be signed');
|
|
52
|
+
}
|
|
44
53
|
if (value === null || typeof value !== 'object') {
|
|
45
54
|
return JSON.stringify(value);
|
|
46
55
|
}
|
package/src/commitment-modes.ts
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which commitment modes a profile allows its mandates to be signed with
|
|
3
|
-
* (`AgentProfile.commitment_modes
|
|
4
|
-
*
|
|
3
|
+
* (`AgentProfile.commitment_modes`; protocol.md → *Commitment Modes*, rule
|
|
4
|
+
* 8). One source for the Authority Server's refusal and the mandate screen's
|
|
5
|
+
* choice, so the two cannot disagree.
|
|
5
6
|
*/
|
|
6
|
-
import type { AgentProfile, SignableCommitmentMode } from './types';
|
|
7
|
+
import type { AgentProfile, CommitmentMode, SignableCommitmentMode } from './types';
|
|
7
8
|
|
|
8
9
|
/** Every mode a signer can choose, in the order a mandate screen shows them. */
|
|
9
10
|
export const SIGNABLE_COMMITMENT_MODES: readonly SignableCommitmentMode[] = ['review', 'automatic'];
|
|
10
11
|
|
|
12
|
+
/** Every mode a signed mandate can carry. */
|
|
13
|
+
export const COMMITMENT_MODES: readonly CommitmentMode[] = ['automatic', 'review', 'review_above_cap'];
|
|
14
|
+
|
|
11
15
|
const isSignable = (v: unknown): v is SignableCommitmentMode =>
|
|
12
16
|
typeof v === 'string' && (SIGNABLE_COMMITMENT_MODES as readonly string[]).includes(v);
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
|
-
* The modes a mandate under this profile may carry, in display
|
|
16
|
-
* No declaration → all of them. A declaration is read fail-closed:
|
|
17
|
-
* that are not a signable mode are dropped
|
|
18
|
-
*
|
|
19
|
+
* The signable modes a mandate under this profile may carry, in display
|
|
20
|
+
* order. No declaration → all of them. A declaration is read fail-closed:
|
|
21
|
+
* entries that are not a signable mode are dropped (`review_above_cap` is
|
|
22
|
+
* ignored by rule 8 — it follows from `automatic`), so a malformed list can
|
|
23
|
+
* only narrow — `[]`, `"review"` (not a list) or `["reveiw"]` allow nothing.
|
|
19
24
|
*/
|
|
20
25
|
export function allowedCommitmentModes(profile: AgentProfile): readonly SignableCommitmentMode[] {
|
|
21
26
|
const declared = (profile as { commitment_modes?: unknown }).commitment_modes;
|
|
@@ -24,7 +29,14 @@ export function allowedCommitmentModes(profile: AgentProfile): readonly Signable
|
|
|
24
29
|
return SIGNABLE_COMMITMENT_MODES.filter((m) => declared.includes(m));
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Whether a mandate under this profile may be SIGNED with `mode` (rule 8):
|
|
34
|
+
* `automatic` and `review` where listed; `review_above_cap` if and only if
|
|
35
|
+
* `automatic` is admitted. The Authority Server refuses any other with
|
|
36
|
+
* `COMMITMENT_MODE_NOT_ALLOWED`.
|
|
37
|
+
*/
|
|
28
38
|
export function isCommitmentModeAllowed(profile: AgentProfile, mode: string): boolean {
|
|
29
|
-
|
|
39
|
+
const allowed = allowedCommitmentModes(profile);
|
|
40
|
+
if (mode === 'review_above_cap') return allowed.includes('automatic');
|
|
41
|
+
return isSignable(mode) && allowed.includes(mode);
|
|
30
42
|
}
|
package/src/errors.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical v0.
|
|
2
|
+
* Canonical v0.8 error codes — protocol.md → *Error Codes*.
|
|
3
3
|
*
|
|
4
4
|
* "Error codes are canonical across the protocol. Implementations MUST emit
|
|
5
5
|
* exactly these codes and MUST NOT alias them under different names." This
|
|
6
6
|
* module is the single list both the Gatekeeper (local checks) and any AS
|
|
7
7
|
* client built on this library read from, so the two cannot drift.
|
|
8
8
|
*
|
|
9
|
-
* v0.
|
|
10
|
-
* context → scope, receipt → ticket) — see protocol.md → *Migration from
|
|
11
|
-
* v0.6* → "Error-code renames" for the old→new map. There is no alias: a v0.6
|
|
12
|
-
* code name is simply gone from this list, by design (no backward
|
|
13
|
-
* compatibility — CLAUDE.md "Decided by the owner").
|
|
9
|
+
* v0.8 adds codes and retires none; every v0.7 code keeps its name.
|
|
14
10
|
*/
|
|
15
11
|
|
|
16
12
|
/** Codes an Authority Server (or a local Gatekeeper check) emits refusing a mandate request. */
|
|
@@ -30,11 +26,14 @@ export const MANDATE_ERROR_CODES = [
|
|
|
30
26
|
'BOUNDS_INVALID_VALUE',
|
|
31
27
|
'SCOPE_INVALID_VALUE',
|
|
32
28
|
'ABOVE_CAP_CONFIG_INVALID',
|
|
29
|
+
'COMMITMENT_MODE_NOT_ALLOWED', // v0.8
|
|
30
|
+
'GROUP_CAP_EXCEEDED', // v0.8
|
|
33
31
|
'OWNER_SIGNATURE_REQUIRED',
|
|
34
32
|
'OWNER_SIGNATURE_INVALID',
|
|
35
33
|
] as const;
|
|
36
34
|
|
|
37
|
-
/** Codes an Authority Server emits refusing a ticket request
|
|
35
|
+
/** Codes an Authority Server emits refusing a ticket request, plus the
|
|
36
|
+
* Gatekeeper-local and verifier codes of the same table. */
|
|
38
37
|
export const TICKET_ERROR_CODES = [
|
|
39
38
|
'MANDATE_NOT_FOUND',
|
|
40
39
|
'MANDATE_EXPIRED',
|
|
@@ -45,6 +44,8 @@ export const TICKET_ERROR_CODES = [
|
|
|
45
44
|
'PROFILE_NOT_FOUND',
|
|
46
45
|
'INVALID_ACTION_TYPE',
|
|
47
46
|
'APPROVAL_REQUIRED',
|
|
47
|
+
'APPROVAL_IMPOSSIBLE', // v0.8
|
|
48
|
+
'PROPOSAL_CONTENT_MISMATCH', // v0.8
|
|
48
49
|
'PROPOSAL_REQUIRED',
|
|
49
50
|
'PROPOSAL_NOT_FOUND',
|
|
50
51
|
'PROPOSAL_NOT_APPROVED',
|
|
@@ -53,15 +54,30 @@ export const TICKET_ERROR_CODES = [
|
|
|
53
54
|
'PROPOSAL_MISMATCH',
|
|
54
55
|
'PROPOSAL_MANDATE_MISMATCH',
|
|
55
56
|
'PROPOSAL_ALREADY_EXECUTED',
|
|
57
|
+
'PROPOSAL_WITHDRAWN', // v0.8
|
|
56
58
|
'MALFORMED_TICKET_REQUEST',
|
|
59
|
+
'CONTENT_BINDING_INCOMPLETE', // v0.8, Gatekeeper · local
|
|
57
60
|
'IDEMPOTENCY_KEY_REQUIRED',
|
|
58
61
|
'IDEMPOTENCY_MISMATCH',
|
|
59
62
|
'APPROVAL_SIGNATURE_REQUIRED',
|
|
60
63
|
'APPROVAL_SIGNATURE_INVALID',
|
|
64
|
+
'TICKET_REQUEST_MISMATCH', // v0.8, Gatekeeper · local
|
|
65
|
+
'POLICY_UNMET', // v0.8, Verifier
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
/** Codes an Authority Server emits refusing a proposal submission or a decision (v0.8). */
|
|
69
|
+
export const PROPOSAL_ERROR_CODES = [
|
|
70
|
+
'PROPOSAL_NOT_ENCRYPTED',
|
|
71
|
+
'DECIDER_INVALID',
|
|
72
|
+
'PROPOSAL_ALREADY_DECIDED',
|
|
73
|
+
'PROPOSAL_APPROVERS_INVALID',
|
|
61
74
|
] as const;
|
|
62
75
|
|
|
63
76
|
/** The full canonical surface — every code a conformant implementation may emit. */
|
|
64
|
-
export type HapErrorCode =
|
|
77
|
+
export type HapErrorCode =
|
|
78
|
+
| (typeof MANDATE_ERROR_CODES)[number]
|
|
79
|
+
| (typeof TICKET_ERROR_CODES)[number]
|
|
80
|
+
| (typeof PROPOSAL_ERROR_CODES)[number];
|
|
65
81
|
|
|
66
82
|
/**
|
|
67
83
|
* A refusal carrying one of the canonical codes above. Thrown by local
|
package/src/gatekeeper.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Gatekeeper — local (Phase 1) verification for bounded execution (v0.
|
|
2
|
+
* Gatekeeper — local (Phase 1) verification for bounded execution (v0.8;
|
|
3
|
+
* v0.7 mandates verified under their own rules).
|
|
3
4
|
*
|
|
4
5
|
* protocol.md → *Validation Steps* (Phase 1, never reaches the wire):
|
|
5
6
|
* 1. Resolve profile from bounds.profile
|
|
@@ -24,12 +25,13 @@
|
|
|
24
25
|
* frame-based path is retired entirely (CLAUDE.md "Decided by the owner").
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
|
-
import { decodeMandateBlob, verifyMandateSignature, checkMandateExpiry, validateMandateOwners } from './mandate';
|
|
28
|
+
import { decodeMandateBlob, verifyMandateSignature, checkMandateExpiry, validateMandateOwners, validateAboveCapFields } from './mandate';
|
|
29
|
+
import { checkExecutionValues, missingSumFields } from './issuance';
|
|
28
30
|
import { canonicalBounds, canonicalScope, computeBoundsHash, computeScopeHash, CanonicalValueError } from './frame';
|
|
29
31
|
import { computeProfileHash } from './profile';
|
|
30
32
|
import { getProfile } from './profiles';
|
|
31
33
|
import { HapError, type HapErrorCode } from './errors';
|
|
32
|
-
import {
|
|
34
|
+
import { isSupportedVersion, SUPPORTED_VERSIONS } from './versions';
|
|
33
35
|
import type {
|
|
34
36
|
GatekeeperRequest,
|
|
35
37
|
GatekeeperResult,
|
|
@@ -112,8 +114,9 @@ export async function verify(request: GatekeeperRequest, opts: VerifyOptions = {
|
|
|
112
114
|
|
|
113
115
|
// 3. Verify every mandate blob.
|
|
114
116
|
const errors: GatekeeperError[] = [];
|
|
117
|
+
const verifiedPayloads: MandatePayload[] = [];
|
|
115
118
|
for (const blob of request.mandates) {
|
|
116
|
-
const mandateErrors = await verifyOneMandate(blob, {
|
|
119
|
+
const { errors: mandateErrors, payload } = await verifyOneMandate(blob, {
|
|
117
120
|
expectedBoundsHash,
|
|
118
121
|
expectedScopeHash,
|
|
119
122
|
expectedProfileHash,
|
|
@@ -121,6 +124,7 @@ export async function verify(request: GatekeeperRequest, opts: VerifyOptions = {
|
|
|
121
124
|
now,
|
|
122
125
|
});
|
|
123
126
|
errors.push(...mandateErrors);
|
|
127
|
+
if (payload) verifiedPayloads.push(payload);
|
|
124
128
|
}
|
|
125
129
|
if (errors.length > 0) {
|
|
126
130
|
return { approved: false, errors };
|
|
@@ -144,7 +148,37 @@ export async function verify(request: GatekeeperRequest, opts: VerifyOptions = {
|
|
|
144
148
|
}
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
// 5.
|
|
151
|
+
// 5. Every value a bound reads is a finite, non-negative number
|
|
152
|
+
// (Validation Steps, step 8; v0.8) — before any bound is compared.
|
|
153
|
+
try {
|
|
154
|
+
checkExecutionValues(request.execution, profile);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return { approved: false, errors: [{ code: codeFromThrown(err), message: (err as Error).message }] };
|
|
157
|
+
}
|
|
158
|
+
// A cumulative_sum bound that governs this action type and reads an absent
|
|
159
|
+
// field is refused locally too — unless an above-cap approval is in play:
|
|
160
|
+
// where a mandate caps that field, an absent value is "above the cap" and
|
|
161
|
+
// the AS routes it to approval (Ticket Issuance, stage 7).
|
|
162
|
+
if (actionType !== undefined) {
|
|
163
|
+
const capped = new Set<string>();
|
|
164
|
+
for (const p of verifiedPayloads) {
|
|
165
|
+
for (const name of Object.keys(p.above_cap_caps ?? {})) {
|
|
166
|
+
const bt = profile.boundsSchema?.fields[name]?.boundType;
|
|
167
|
+
if (bt && bt.kind === 'per_transaction') capped.add(bt.of);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const missing = missingSumFields(request.execution, profile, actionType).filter((m) => !capped.has(m.field));
|
|
171
|
+
if (missing.length > 0) {
|
|
172
|
+
return {
|
|
173
|
+
approved: false,
|
|
174
|
+
errors: missing.map(({ bound, field }) => ({
|
|
175
|
+
code: 'BOUND_EXCEEDED' as const,
|
|
176
|
+
field,
|
|
177
|
+
message: `Bound "${bound}" governs "${actionType}" and reads "${field}", but this call exposes no ${field}. Refusing: an action without an amount would consume nothing of the limit (v0.8: a missing amount fails safe).`,
|
|
178
|
+
})),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
148
182
|
const boundsErrors = checkBounds(request, profile, actionType);
|
|
149
183
|
if (boundsErrors.length > 0) {
|
|
150
184
|
return { approved: false, errors: boundsErrors };
|
|
@@ -197,7 +231,16 @@ function codeFromThrown(err: unknown): HapErrorCode {
|
|
|
197
231
|
return 'MALFORMED_MANDATE';
|
|
198
232
|
}
|
|
199
233
|
|
|
200
|
-
async function verifyOneMandate(
|
|
234
|
+
async function verifyOneMandate(
|
|
235
|
+
blob: string,
|
|
236
|
+
opts: MandateCheckOptions,
|
|
237
|
+
): Promise<{ errors: GatekeeperError[]; payload?: MandatePayload }> {
|
|
238
|
+
const errors = await verifyOneMandateErrors(blob, opts);
|
|
239
|
+
if (errors.length > 0) return { errors };
|
|
240
|
+
return { errors, payload: decodeMandateBlob(blob).payload };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function verifyOneMandateErrors(blob: string, opts: MandateCheckOptions): Promise<GatekeeperError[]> {
|
|
201
244
|
let mandate: Mandate;
|
|
202
245
|
try {
|
|
203
246
|
mandate = decodeMandateBlob(blob);
|
|
@@ -205,10 +248,10 @@ async function verifyOneMandate(blob: string, opts: MandateCheckOptions): Promis
|
|
|
205
248
|
return [{ code: 'MALFORMED_MANDATE', message: (err as Error).message }];
|
|
206
249
|
}
|
|
207
250
|
|
|
208
|
-
if (mandate.payload.version
|
|
251
|
+
if (!isSupportedVersion(mandate.payload.version)) {
|
|
209
252
|
return [{
|
|
210
253
|
code: 'VERSION_UNSUPPORTED',
|
|
211
|
-
message: `mandate version ${JSON.stringify(mandate.payload.version)} is not verifiable by this Gatekeeper (verifies ${
|
|
254
|
+
message: `mandate version ${JSON.stringify(mandate.payload.version)} is not verifiable by this Gatekeeper (verifies [${SUPPORTED_VERSIONS.join(', ')}])`,
|
|
212
255
|
}];
|
|
213
256
|
}
|
|
214
257
|
|
|
@@ -226,6 +269,7 @@ async function verifyOneMandate(blob: string, opts: MandateCheckOptions): Promis
|
|
|
226
269
|
|
|
227
270
|
try {
|
|
228
271
|
validateMandateOwners(mandate.payload);
|
|
272
|
+
validateAboveCapFields(mandate.payload);
|
|
229
273
|
} catch (err) {
|
|
230
274
|
return [{ code: codeFromThrown(err), message: (err as Error).message }];
|
|
231
275
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HAP Core — v0.
|
|
2
|
+
* HAP Core — v0.8 (issues 0.8; verifies 0.8 and 0.7, each under its own rules)
|
|
3
3
|
*
|
|
4
4
|
* Shared protocol logic for agent-oriented bounded execution.
|
|
5
5
|
*/
|
|
@@ -18,6 +18,10 @@ export * from './profile';
|
|
|
18
18
|
export * from './mandate';
|
|
19
19
|
export * from './owner-signature';
|
|
20
20
|
export * from './ticket';
|
|
21
|
+
export * from './ticket-projection';
|
|
22
|
+
export * from './verifier-policy';
|
|
23
|
+
export * from './proposal-content';
|
|
24
|
+
export * from './issuance';
|
|
21
25
|
export * from './gatekeeper';
|
|
22
26
|
export * from './profiles';
|
|
23
27
|
export * from './did-key';
|
package/src/issuance.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure checks both enforcement points apply around issuance and execution
|
|
3
|
+
* (v0.8). An Authority Server composes them into its stage order
|
|
4
|
+
* (protocol.md → *Mandate Issuance & Validation*, *Ticket Issuance*); a
|
|
5
|
+
* Gatekeeper applies the execution-side ones locally (*Validation Steps*).
|
|
6
|
+
* They live here so the two cannot disagree about what a value means.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'crypto';
|
|
10
|
+
import { canonicalize } from './canonicalize';
|
|
11
|
+
import { HapError } from './errors';
|
|
12
|
+
import { isKeyBearingDid } from './did-key';
|
|
13
|
+
import type { AgentProfile, AgentBoundsParams, MandatePayload, ProfileBoundsField } from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `execution_context_hash` — sha256 over the JCS of the profile's
|
|
17
|
+
* `executionContextSchema`, or of `{}` when the profile has none (protocol.md
|
|
18
|
+
* → *Execution Context Schema*, stated in v0.8).
|
|
19
|
+
*/
|
|
20
|
+
export function computeExecutionContextHash(profile: Pick<AgentProfile, 'executionContextSchema'>): string {
|
|
21
|
+
const schema = profile.executionContextSchema ?? {};
|
|
22
|
+
return `sha256:${createHash('sha256').update(canonicalize(schema), 'utf8').digest('hex')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The execution-context fields some bound of the profile reads
|
|
26
|
+
* (`boundType.of` of every `per_transaction` and `cumulative_sum` bound). */
|
|
27
|
+
export function boundReadFields(profile: AgentProfile): Set<string> {
|
|
28
|
+
const fields = new Set<string>();
|
|
29
|
+
for (const def of Object.values(profile.boundsSchema?.fields ?? {})) {
|
|
30
|
+
const bt = def.boundType;
|
|
31
|
+
if (bt && (bt.kind === 'per_transaction' || bt.kind === 'cumulative_sum')) fields.add(bt.of);
|
|
32
|
+
}
|
|
33
|
+
return fields;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the wire execution context (*Validation Steps*, step 11; *Ticket
|
|
38
|
+
* Request Schema*, rules 7–8): only what a bound reads travels. `action_type`
|
|
39
|
+
* travels as the request's own `actionType`, scope values never travel,
|
|
40
|
+
* `_`-prefixed keys are advisory metadata. A `null` value is no value.
|
|
41
|
+
*/
|
|
42
|
+
export function wireExecutionContext(
|
|
43
|
+
execution: Record<string, unknown>,
|
|
44
|
+
profile: AgentProfile,
|
|
45
|
+
): Record<string, unknown> {
|
|
46
|
+
const allowed = boundReadFields(profile);
|
|
47
|
+
const out: Record<string, unknown> = {};
|
|
48
|
+
for (const [k, v] of Object.entries(execution)) {
|
|
49
|
+
if (!allowed.has(k) || k.startsWith('_') || v === undefined || v === null) continue;
|
|
50
|
+
out[k] = v;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every value present for a field some bound reads MUST be a finite,
|
|
57
|
+
* non-negative number (*Ticket Issuance*, stage 7; *Validation Steps*, step
|
|
58
|
+
* 8). `null` counts as absent. A `_`-prefixed key is refused.
|
|
59
|
+
* @throws HapError `MALFORMED_TICKET_REQUEST`
|
|
60
|
+
*/
|
|
61
|
+
export function checkExecutionValues(execution: Record<string, unknown>, profile: AgentProfile): void {
|
|
62
|
+
for (const k of Object.keys(execution)) {
|
|
63
|
+
if (k.startsWith('_')) {
|
|
64
|
+
throw new HapError('MALFORMED_TICKET_REQUEST', `executionContext key "${k}" is reserved and MUST NOT travel`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const field of boundReadFields(profile)) {
|
|
68
|
+
const v = execution[field];
|
|
69
|
+
if (v === undefined || v === null) continue;
|
|
70
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
|
|
71
|
+
throw new HapError(
|
|
72
|
+
'MALFORMED_TICKET_REQUEST',
|
|
73
|
+
`executionContext.${field} MUST be a finite, non-negative number, got ${JSON.stringify(v)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a cumulative bound governs this action type: its `appliesTo`
|
|
80
|
+
* names it, or it has none (Bounds Schema rule 7). */
|
|
81
|
+
export function boundGoverns(def: ProfileBoundsField, actionType: string): boolean {
|
|
82
|
+
return !def.appliesTo || def.appliesTo.includes(actionType);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The `cumulative_sum` bounds that govern this action type and whose field is
|
|
87
|
+
* absent from the execution context (*Ticket Issuance*, stage 7): each is a
|
|
88
|
+
* `BOUND_EXCEEDED`, unless a committed above-cap approval covers the action.
|
|
89
|
+
*/
|
|
90
|
+
export function missingSumFields(
|
|
91
|
+
execution: Record<string, unknown>,
|
|
92
|
+
profile: AgentProfile,
|
|
93
|
+
actionType: string,
|
|
94
|
+
): Array<{ bound: string; field: string }> {
|
|
95
|
+
const missing: Array<{ bound: string; field: string }> = [];
|
|
96
|
+
for (const [name, def] of Object.entries(profile.boundsSchema?.fields ?? {})) {
|
|
97
|
+
const bt = def.boundType;
|
|
98
|
+
if (!bt || bt.kind !== 'cumulative_sum' || !boundGoverns(def, actionType)) continue;
|
|
99
|
+
const v = execution[bt.of];
|
|
100
|
+
if (v === undefined || v === null) missing.push({ bound: name, field: bt.of });
|
|
101
|
+
}
|
|
102
|
+
return missing;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bounds values against the profile at issuance (*Mandate Issuance*, stage
|
|
107
|
+
* 4): a number where the field is numeric (`"100"` is refused), no higher
|
|
108
|
+
* than a declared `maximum`, and an `enum` value in its `values`.
|
|
109
|
+
* @throws HapError `BOUNDS_INVALID_VALUE`
|
|
110
|
+
*/
|
|
111
|
+
export function checkBoundsValues(bounds: AgentBoundsParams, profile: AgentProfile): void {
|
|
112
|
+
for (const [name, def] of Object.entries(profile.boundsSchema?.fields ?? {})) {
|
|
113
|
+
if (name === 'profile') continue;
|
|
114
|
+
const v = bounds[name];
|
|
115
|
+
if (v === undefined || v === null) continue;
|
|
116
|
+
if (def.type === 'number' || def.maximum !== undefined) {
|
|
117
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
118
|
+
throw new HapError('BOUNDS_INVALID_VALUE', `bound "${name}" MUST be a number, got ${JSON.stringify(v)}`);
|
|
119
|
+
}
|
|
120
|
+
if (def.maximum !== undefined && v > def.maximum) {
|
|
121
|
+
throw new HapError('BOUNDS_INVALID_VALUE', `bound "${name}" = ${v} exceeds the profile's maximum ${def.maximum}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const bt = def.boundType;
|
|
125
|
+
if (bt?.kind === 'enum' && !bt.values.includes(String(v))) {
|
|
126
|
+
throw new HapError('BOUNDS_INVALID_VALUE', `bound "${name}" = ${JSON.stringify(v)} is not in [${bt.values.join(', ')}]`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A group's caps for one profile, as the AS holds them. */
|
|
132
|
+
export interface GroupCaps {
|
|
133
|
+
/** Caps a proposal may lift (`approval` kind) — numeric per_transaction fields only. */
|
|
134
|
+
approval?: Record<string, number>;
|
|
135
|
+
/** Hard caps: refused at signing, never routed. Numeric or enum values. */
|
|
136
|
+
hard?: Record<string, number | string>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a bound value exceeds a cap. For an `enum` bound the cap is one of
|
|
141
|
+
* its values, and a value that comes EARLIER in the profile's `values` order
|
|
142
|
+
* (more permissive) exceeds it (Bounds Schema rule 10; *Group ceilings*).
|
|
143
|
+
*/
|
|
144
|
+
export function exceedsCap(value: string | number, cap: string | number, def: ProfileBoundsField): boolean {
|
|
145
|
+
const bt = def.boundType;
|
|
146
|
+
if (bt?.kind === 'enum') {
|
|
147
|
+
const order = bt.values;
|
|
148
|
+
const vi = order.indexOf(String(value));
|
|
149
|
+
const ci = order.indexOf(String(cap));
|
|
150
|
+
if (vi < 0 || ci < 0) return true; // an unknown value or cap fails closed
|
|
151
|
+
return vi < ci;
|
|
152
|
+
}
|
|
153
|
+
return typeof value !== 'number' || typeof cap !== 'number' || value > cap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Hard group ceilings at signing (*Group ceilings*; *Group roles*, rule 5):
|
|
158
|
+
* a cap on a bound the Gatekeeper alone enforces (`enforcedBy: "gatekeeper"`)
|
|
159
|
+
* or on an `enum` bound is always hard, as is every cap in `hard`; a bound
|
|
160
|
+
* above one, or an optional bound omitted where a cap exists, is refused.
|
|
161
|
+
* Caps in `approval` on a cumulative bound are hard too (Commitment Modes,
|
|
162
|
+
* rule 7). An omitted REQUIRED bound is `MALFORMED_MANDATE`, checked
|
|
163
|
+
* elsewhere.
|
|
164
|
+
* @throws HapError `GROUP_CAP_EXCEEDED`
|
|
165
|
+
*/
|
|
166
|
+
export function checkGroupCeilings(bounds: AgentBoundsParams, profile: AgentProfile, caps: GroupCaps): void {
|
|
167
|
+
const fields = profile.boundsSchema?.fields ?? {};
|
|
168
|
+
const hard: Record<string, number | string> = { ...(caps.hard ?? {}) };
|
|
169
|
+
for (const [name, cap] of Object.entries(caps.approval ?? {})) {
|
|
170
|
+
const def = fields[name];
|
|
171
|
+
const bt = def?.boundType;
|
|
172
|
+
if (!def || !bt) continue;
|
|
173
|
+
if (bt.kind !== 'per_transaction' || def.enforcedBy === 'gatekeeper') hard[name] = cap;
|
|
174
|
+
}
|
|
175
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
176
|
+
if (name === 'profile') continue;
|
|
177
|
+
const cap = hard[name];
|
|
178
|
+
if (cap === undefined) continue;
|
|
179
|
+
const v = bounds[name];
|
|
180
|
+
if (v === undefined || v === null) {
|
|
181
|
+
if (!def.required) {
|
|
182
|
+
throw new HapError('GROUP_CAP_EXCEEDED', `bound "${name}" is omitted under a group ceiling of ${JSON.stringify(cap)} — an omitted bound is unlimited`);
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (exceedsCap(v, cap, def)) {
|
|
187
|
+
throw new HapError('GROUP_CAP_EXCEEDED', `bound "${name}" = ${JSON.stringify(v)} exceeds the group's hard cap ${JSON.stringify(cap)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The above-cap configuration a mandate request or a group supplies
|
|
194
|
+
* (*Commitment Modes*, rule 7; *Group roles*, rules 2 and 4; Mandate rule
|
|
195
|
+
* 10): caps name numeric `per_transaction` bounds of the profile and never
|
|
196
|
+
* `profile`; approvers are key-bearing DIDs; outside a group the count is an
|
|
197
|
+
* integer ≥ 1 and no greater than the approvers other than the owner.
|
|
198
|
+
* @throws HapError `ABOVE_CAP_CONFIG_INVALID`
|
|
199
|
+
*/
|
|
200
|
+
export function validateAboveCapConfig(
|
|
201
|
+
config: { caps?: Record<string, number>; approvers?: readonly string[]; approvals?: number },
|
|
202
|
+
profile: AgentProfile,
|
|
203
|
+
opts: { ownerDid: string; inGroup: boolean },
|
|
204
|
+
): void {
|
|
205
|
+
const fields = profile.boundsSchema?.fields ?? {};
|
|
206
|
+
for (const name of Object.keys(config.caps ?? {})) {
|
|
207
|
+
if (name === 'profile') throw new HapError('ABOVE_CAP_CONFIG_INVALID', 'above_cap_caps MUST NOT name profile');
|
|
208
|
+
const def = fields[name];
|
|
209
|
+
if (!def || def.boundType?.kind !== 'per_transaction' || def.type !== 'number') {
|
|
210
|
+
throw new HapError('ABOVE_CAP_CONFIG_INVALID', `above_cap_caps names "${name}", which is not a numeric per_transaction bound of ${profile.id}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const did of config.approvers ?? []) {
|
|
214
|
+
if (!isKeyBearingDid(did)) {
|
|
215
|
+
throw new HapError('ABOVE_CAP_CONFIG_INVALID', `above-cap approver ${did} is not a key-bearing DID`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (config.approvers !== undefined && !opts.inGroup) {
|
|
219
|
+
const others = config.approvers.filter((d) => d !== opts.ownerDid).length;
|
|
220
|
+
const n = config.approvals;
|
|
221
|
+
if (n === undefined || !Number.isInteger(n) || n < 1 || n > others) {
|
|
222
|
+
throw new HapError(
|
|
223
|
+
'ABOVE_CAP_CONFIG_INVALID',
|
|
224
|
+
`above_cap_approvals MUST be an integer between 1 and the ${others} approver(s) other than the owner, got ${JSON.stringify(n)}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Whether a request exceeds an approval-kind cap of the mandate, or — v0.8 —
|
|
232
|
+
* the field the cap reads is absent (an action of unknown amount is above
|
|
233
|
+
* the cap). Returns the offending caps.
|
|
234
|
+
*/
|
|
235
|
+
export function capsExceeded(
|
|
236
|
+
execution: Record<string, unknown>,
|
|
237
|
+
mandate: Pick<MandatePayload, 'above_cap_caps'>,
|
|
238
|
+
profile: AgentProfile,
|
|
239
|
+
): Array<{ bound: string; field: string; cap: number; value: unknown }> {
|
|
240
|
+
const out: Array<{ bound: string; field: string; cap: number; value: unknown }> = [];
|
|
241
|
+
for (const [name, cap] of Object.entries(mandate.above_cap_caps ?? {})) {
|
|
242
|
+
const bt = profile.boundsSchema?.fields[name]?.boundType;
|
|
243
|
+
if (!bt || bt.kind !== 'per_transaction') continue;
|
|
244
|
+
const v = execution[bt.of];
|
|
245
|
+
if (v === undefined || v === null || typeof v !== 'number' || v > cap) {
|
|
246
|
+
out.push({ bound: name, field: bt.of, cap, value: v });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
package/src/mandate.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Renamed from `attestation.ts` (protocol.md → *Migration from v0.6*:
|
|
5
5
|
* `header.typ: "HAP-attestation"` → `"HAP-mandate"`, `attestation_id` →
|
|
6
6
|
* `mandate_id`). v0.3's frame-hash path and v0.4's dual frame/bounds
|
|
7
|
-
* verification are retired with the rest of pre-v0.5
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* verification are retired with the rest of pre-v0.5. This module issues
|
|
8
|
+
* `version: "0.8"` mandates and verifies `"0.8"` and `"0.7"` ones, each
|
|
9
|
+
* under its own rules (protocol.md → *Version negotiation*, rule 4): v0.8
|
|
10
|
+
* is a re-grant release, and a v0.7 artifact stays verifiable as evidence.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { createHash } from 'crypto';
|
|
@@ -15,7 +16,7 @@ import { canonicalize } from './canonicalize';
|
|
|
15
16
|
import { toBase64Url, fromBase64Url } from './base64url';
|
|
16
17
|
import { decodeDidKey, isKeyBearingDid } from './did-key';
|
|
17
18
|
import { HapError } from './errors';
|
|
18
|
-
import {
|
|
19
|
+
import { assertSupportedVersion, SUPPORTED_VERSIONS } from './versions';
|
|
19
20
|
import type { Mandate, MandateHeader, MandatePayload } from './types';
|
|
20
21
|
|
|
21
22
|
/** Clock-skew tolerance (Mandate rule 9): `issued_at`/`expires_at` checks
|
|
@@ -162,7 +163,7 @@ export function validateMandateOwners(payload: MandatePayload): void {
|
|
|
162
163
|
if (!Array.isArray(owners) || owners.length !== 1) {
|
|
163
164
|
throw new HapError(
|
|
164
165
|
'MALFORMED_MANDATE',
|
|
165
|
-
`mandate_owners MUST contain exactly one entry
|
|
166
|
+
`mandate_owners MUST contain exactly one entry (Mandate rule 7), got ${Array.isArray(owners) ? owners.length : typeof owners}`,
|
|
166
167
|
);
|
|
167
168
|
}
|
|
168
169
|
const entry = owners[0];
|
|
@@ -184,15 +185,57 @@ export function validateMandateOwners(payload: MandatePayload): void {
|
|
|
184
185
|
}
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The above-cap fields travel together (Mandate rule 10; *Group roles*,
|
|
190
|
+
* rule 7). Under v0.8 `above_cap_approvals` is present exactly when
|
|
191
|
+
* `above_cap_approvers` is, an integer ≥ 1; under v0.7 there is no count and
|
|
192
|
+
* caps + approvers are present exactly under `review_above_cap`.
|
|
193
|
+
*
|
|
194
|
+
* @throws HapError `MALFORMED_MANDATE`
|
|
195
|
+
*/
|
|
196
|
+
export function validateAboveCapFields(payload: MandatePayload): void {
|
|
197
|
+
const hasCaps = payload.above_cap_caps !== undefined;
|
|
198
|
+
const hasApprovers = payload.above_cap_approvers !== undefined;
|
|
199
|
+
const hasCount = payload.above_cap_approvals !== undefined;
|
|
200
|
+
if (hasCaps !== hasApprovers) {
|
|
201
|
+
throw new HapError('MALFORMED_MANDATE', 'above_cap_caps and above_cap_approvers are present together or absent together');
|
|
202
|
+
}
|
|
203
|
+
if (payload.commitment_mode === 'review_above_cap' && !hasApprovers) {
|
|
204
|
+
throw new HapError('MALFORMED_MANDATE', 'a review_above_cap mandate MUST carry above_cap_caps and above_cap_approvers');
|
|
205
|
+
}
|
|
206
|
+
if (payload.version === '0.7') {
|
|
207
|
+
if (hasCount) {
|
|
208
|
+
throw new HapError('MALFORMED_MANDATE', 'a v0.7 mandate carries no above_cap_approvals');
|
|
209
|
+
}
|
|
210
|
+
if (hasApprovers && payload.commitment_mode !== 'review_above_cap') {
|
|
211
|
+
throw new HapError('MALFORMED_MANDATE', 'a v0.7 mandate carries above-cap fields only under review_above_cap');
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (hasApprovers !== hasCount) {
|
|
216
|
+
throw new HapError('MALFORMED_MANDATE', 'above_cap_approvals is present exactly when above_cap_approvers is (Mandate rule 10)');
|
|
217
|
+
}
|
|
218
|
+
if (hasCount) {
|
|
219
|
+
const n = payload.above_cap_approvals as unknown;
|
|
220
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
|
|
221
|
+
throw new HapError('MALFORMED_MANDATE', `above_cap_approvals MUST be an integer >= 1, got ${JSON.stringify(n)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (hasApprovers && payload.commitment_mode === 'automatic') {
|
|
225
|
+
throw new HapError('MALFORMED_MANDATE', 'an automatic mandate carries no above-cap fields');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
187
229
|
/**
|
|
188
230
|
* Full mandate verification: decode, check `version`, verify the AS
|
|
189
|
-
* signature, check expiry, validate `mandate_owners` shape
|
|
231
|
+
* signature, check expiry, validate `mandate_owners` shape and the above-cap
|
|
232
|
+
* fields.
|
|
190
233
|
*
|
|
191
234
|
* Deliberately does NOT verify any `mandate_owners` co-signature — that is
|
|
192
235
|
* `verifyOwnerSignature` in `owner-signature.ts`, a separate trust axis the
|
|
193
236
|
* caller opts into.
|
|
194
237
|
*
|
|
195
|
-
* @throws HapError `VERSION_UNSUPPORTED` for
|
|
238
|
+
* @throws HapError `VERSION_UNSUPPORTED` for a version outside {@link SUPPORTED_VERSIONS}.
|
|
196
239
|
* @throws Error (`INVALID_SIGNATURE:` / `TTL_EXPIRED:` / `MALFORMED_MANDATE:`
|
|
197
240
|
* prefixed, or `OWNER_SIGNATURE_INVALID` as a HapError) on any other failure.
|
|
198
241
|
*/
|
|
@@ -202,17 +245,12 @@ export async function verifyMandate(
|
|
|
202
245
|
): Promise<MandatePayload> {
|
|
203
246
|
const mandate = decodeMandateBlob(blob);
|
|
204
247
|
|
|
205
|
-
|
|
206
|
-
throw new HapError(
|
|
207
|
-
'VERSION_UNSUPPORTED',
|
|
208
|
-
`mandate version ${JSON.stringify(mandate.payload.version)} is not verifiable by this implementation ` +
|
|
209
|
-
`(verifies ${PROTOCOL_VERSION} only — re-issue the mandate)`,
|
|
210
|
-
);
|
|
211
|
-
}
|
|
248
|
+
assertSupportedVersion(mandate.payload.version, 'mandate');
|
|
212
249
|
|
|
213
250
|
await verifyMandateSignature(mandate, opts);
|
|
214
251
|
checkMandateExpiry(mandate.payload, opts?.now);
|
|
215
252
|
validateMandateOwners(mandate.payload);
|
|
253
|
+
validateAboveCapFields(mandate.payload);
|
|
216
254
|
|
|
217
255
|
return mandate.payload;
|
|
218
256
|
}
|