@humanagencyp/hap-core 0.5.0 → 0.5.2
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/dist/index.d.mts +152 -3
- package/dist/index.d.ts +152 -3
- package/dist/index.js +66 -10
- package/dist/index.mjs +60 -10
- package/package.json +1 -1
- package/src/content-binding.ts +83 -0
- package/src/gatekeeper.ts +4 -4
- package/src/index.ts +2 -0
- package/src/intent-disclosure.ts +69 -0
- package/src/types.ts +58 -2
package/dist/index.d.mts
CHANGED
|
@@ -14,7 +14,7 @@ interface ResolvedDomain {
|
|
|
14
14
|
}
|
|
15
15
|
interface AttestationPayload {
|
|
16
16
|
attestation_id: string;
|
|
17
|
-
version: '0.3' | '0.4';
|
|
17
|
+
version: '0.3' | '0.4' | '0.5';
|
|
18
18
|
profile_id: string;
|
|
19
19
|
/** v0.3 (deprecated) — hash of the authorization frame */
|
|
20
20
|
frame_hash?: string;
|
|
@@ -23,8 +23,29 @@ interface AttestationPayload {
|
|
|
23
23
|
/** v0.4 — hash of the context parameters */
|
|
24
24
|
context_hash?: string;
|
|
25
25
|
execution_context_hash: string;
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* v0.4 (deprecated in v0.5) — domain-scoped owners. Optional so v0.5
|
|
28
|
+
* attestations that carry only `resolved_owners` remain valid. During the
|
|
29
|
+
* transition the AS emits BOTH `resolved_domains` (internal coverage) and
|
|
30
|
+
* `resolved_owners` (the v0.5 signed wire field).
|
|
31
|
+
*/
|
|
32
|
+
resolved_domains?: ResolvedDomain[];
|
|
33
|
+
/**
|
|
34
|
+
* v0.5 — the Decision Owner DIDs this attestation covers (person-centric
|
|
35
|
+
* model; replaces the abstract domain in `resolved_domains`). Carried in the
|
|
36
|
+
* signed payload so verifiers bind the action to the human(s) who authorized it.
|
|
37
|
+
*/
|
|
38
|
+
resolved_owners?: string[];
|
|
27
39
|
gate_content_hashes: Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* v0.5 (companion spec `intent-disclosure@0.1`) — present iff the attestation
|
|
42
|
+
* carries an encrypted-intent disclosure object. `sha256:`-prefixed hash that
|
|
43
|
+
* binds the disclosure's `intent_ciphertext` + `approvers_frozen` into the
|
|
44
|
+
* signed payload (see {@link computeIntentDisclosureHash}), so a compromised
|
|
45
|
+
* AS cannot swap the ciphertext/wrapped keys or alter the approver set
|
|
46
|
+
* without invalidating the attestation signature (companion invariant C2).
|
|
47
|
+
*/
|
|
48
|
+
intent_disclosure_hash?: string;
|
|
28
49
|
/**
|
|
29
50
|
* v0.4 — commitment mode chosen by the decision owner at attestation time.
|
|
30
51
|
* - 'automatic': agent may invoke bounded tools without per-action review.
|
|
@@ -161,6 +182,32 @@ interface ProfileBoundsField {
|
|
|
161
182
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
|
162
183
|
enum?: string[];
|
|
163
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
187
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
188
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
189
|
+
*
|
|
190
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
191
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
192
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
193
|
+
*
|
|
194
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
195
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
196
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
197
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
198
|
+
*/
|
|
199
|
+
interface ContentBinding {
|
|
200
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
201
|
+
version: string;
|
|
202
|
+
/**
|
|
203
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
204
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
205
|
+
* canonicalizeText), auto-detected content field.
|
|
206
|
+
*/
|
|
207
|
+
kind: 'jcs' | 'text';
|
|
208
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
209
|
+
pre_footer?: boolean;
|
|
210
|
+
}
|
|
164
211
|
/**
|
|
165
212
|
* Context field definition within a v0.4 profile.
|
|
166
213
|
*/
|
|
@@ -280,6 +327,13 @@ interface AgentProfile {
|
|
|
280
327
|
max: number;
|
|
281
328
|
};
|
|
282
329
|
retention_minimum: number;
|
|
330
|
+
/**
|
|
331
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
332
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
333
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
334
|
+
* → no content hash. See {@link ContentBinding}.
|
|
335
|
+
*/
|
|
336
|
+
content_binding?: ContentBinding;
|
|
283
337
|
/**
|
|
284
338
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
285
339
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|
|
@@ -436,6 +490,101 @@ type GatekeeperResult = {
|
|
|
436
490
|
*/
|
|
437
491
|
declare function canonicalize(value: unknown): string;
|
|
438
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Content binding — Level 2 content proof (HAP v0.5 Content Provenance).
|
|
495
|
+
*
|
|
496
|
+
* A receipt normally proves who/why/bounds/when but NOT the action's content.
|
|
497
|
+
* Content binding closes that gap: the gateway computes a `content_hash` over
|
|
498
|
+
* the action's content per the profile's {@link ContentBinding} and hands the
|
|
499
|
+
* SP only the hash. The SP signs it into the receipt verbatim — it never sees
|
|
500
|
+
* the content, so HAP's privacy-minimal design is preserved. Anyone holding
|
|
501
|
+
* the content can recompute the hash and check it against the signed receipt.
|
|
502
|
+
*
|
|
503
|
+
* The hash only verifies if the verifier reproduces the EXACT bytes we hashed,
|
|
504
|
+
* so canonicalization is normative and versioned (pin via `ContentBinding.version`):
|
|
505
|
+
*
|
|
506
|
+
* - kind:"jcs" → RFC 8785 JCS of the record payload (see {@link canonicalize}).
|
|
507
|
+
* - kind:"text" → UTF-8 of the string after {@link canonicalizeText}
|
|
508
|
+
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
509
|
+
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
510
|
+
*
|
|
511
|
+
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
512
|
+
* environment-independent primitives, and the text rule uses String.normalize +
|
|
513
|
+
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
514
|
+
* pattern as frame.ts); browser callers that need to recompute use their own
|
|
515
|
+
* SubtleCrypto digest over the identical canonical bytes.
|
|
516
|
+
*/
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Canonicalize free text per the v0.5 'text' rule. Idempotent.
|
|
520
|
+
*
|
|
521
|
+
* 1. Unicode NFC normalization.
|
|
522
|
+
* 2. CRLF / CR → LF.
|
|
523
|
+
* 3. Strip trailing spaces/tabs from every line.
|
|
524
|
+
* 4. Remove trailing blank lines.
|
|
525
|
+
*/
|
|
526
|
+
declare function canonicalizeText(input: string): string;
|
|
527
|
+
/**
|
|
528
|
+
* Compute the canonical bytes that a content hash is taken over, WITHOUT
|
|
529
|
+
* hashing — exposed so verifiers can debug a mismatch by inspecting the exact
|
|
530
|
+
* serialization both sides should agree on.
|
|
531
|
+
*/
|
|
532
|
+
declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Record<string, unknown> | string): string;
|
|
533
|
+
/**
|
|
534
|
+
* Compute a profile-bound content hash, formatted `sha256:<hex>` (matching the
|
|
535
|
+
* frame/bounds/context hash format used elsewhere in HAP).
|
|
536
|
+
*
|
|
537
|
+
* @param binding the profile's content_binding declaration
|
|
538
|
+
* @param content the record payload (jcs) or the resolved text field (text)
|
|
539
|
+
*/
|
|
540
|
+
declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Intent hashing + intent-disclosure binding (HAP v0.5).
|
|
544
|
+
*
|
|
545
|
+
* Two shared, environment-independent hashes that the attester (gateway), the
|
|
546
|
+
* Authority Server, and any approver MUST compute identically:
|
|
547
|
+
*
|
|
548
|
+
* 1. {@link computeIntentHash} — `gate_content_hashes.intent`. The intent text
|
|
549
|
+
* is canonicalized (Unicode NFC, LF line endings, trailing-whitespace strip
|
|
550
|
+
* — the same rule as content-binding's `canonicalizeText`) before hashing,
|
|
551
|
+
* so independent multi-owner approvers reproduce an identical hash from the
|
|
552
|
+
* same logical statement. See protocol.md → "Intent canonicalization".
|
|
553
|
+
*
|
|
554
|
+
* 2. {@link computeIntentDisclosureHash} — `intent_disclosure_hash`, the C2
|
|
555
|
+
* binding from companion spec `intent-disclosure@0.1`. It commits the
|
|
556
|
+
* encrypted disclosure object (`intent_ciphertext` + `approvers_frozen`)
|
|
557
|
+
* into the SIGNED attestation payload, so a compromised AS cannot swap the
|
|
558
|
+
* ciphertext/wrapped keys or widen/shrink the approver set without breaking
|
|
559
|
+
* the attestation signature.
|
|
560
|
+
*
|
|
561
|
+
* Both produce `sha256:<hex>` to match the frame/bounds/context hash format.
|
|
562
|
+
*/
|
|
563
|
+
/**
|
|
564
|
+
* `gate_content_hashes.intent` for an intent statement.
|
|
565
|
+
* Canonicalizes the text (NFC / LF / trailing-whitespace strip) before hashing.
|
|
566
|
+
*/
|
|
567
|
+
declare function computeIntentHash(intentText: string): string;
|
|
568
|
+
/**
|
|
569
|
+
* The exact bytes `intent_disclosure_hash` is taken over, WITHOUT hashing —
|
|
570
|
+
* exposed so an implementer debugging a mismatch can inspect the serialization
|
|
571
|
+
* both sides must agree on:
|
|
572
|
+
*
|
|
573
|
+
* intent_ciphertext ‖ "\n" ‖ JCS(sort(approvers_frozen))
|
|
574
|
+
*
|
|
575
|
+
* where `intent_ciphertext` is the exact base64url string of the disclosure
|
|
576
|
+
* object, `sort` orders the approver DIDs by Unicode code point, and `JCS` is
|
|
577
|
+
* RFC 8785 canonicalization of the sorted array (via {@link canonicalize}).
|
|
578
|
+
*/
|
|
579
|
+
declare function intentDisclosureCanonicalBytes(intentCiphertext: string, approversFrozen: string[]): string;
|
|
580
|
+
/**
|
|
581
|
+
* `intent_disclosure_hash` — `sha256:<hex>` over
|
|
582
|
+
* {@link intentDisclosureCanonicalBytes}. Computed by the attester after
|
|
583
|
+
* encryption and signed into the attestation; recomputed by the AS (at
|
|
584
|
+
* issuance) and by each approver (on receipt) and compared, fail-closed.
|
|
585
|
+
*/
|
|
586
|
+
declare function computeIntentDisclosureHash(intentCiphertext: string, approversFrozen: string[]): string;
|
|
587
|
+
|
|
439
588
|
/**
|
|
440
589
|
* Frame Canonicalization for Agent Profiles
|
|
441
590
|
*
|
|
@@ -633,4 +782,4 @@ declare function listProfiles(): string[];
|
|
|
633
782
|
declare function getAllProfiles(): AgentProfile[];
|
|
634
783
|
declare function clearProfiles(): void;
|
|
635
784
|
|
|
636
|
-
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
785
|
+
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ interface ResolvedDomain {
|
|
|
14
14
|
}
|
|
15
15
|
interface AttestationPayload {
|
|
16
16
|
attestation_id: string;
|
|
17
|
-
version: '0.3' | '0.4';
|
|
17
|
+
version: '0.3' | '0.4' | '0.5';
|
|
18
18
|
profile_id: string;
|
|
19
19
|
/** v0.3 (deprecated) — hash of the authorization frame */
|
|
20
20
|
frame_hash?: string;
|
|
@@ -23,8 +23,29 @@ interface AttestationPayload {
|
|
|
23
23
|
/** v0.4 — hash of the context parameters */
|
|
24
24
|
context_hash?: string;
|
|
25
25
|
execution_context_hash: string;
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* v0.4 (deprecated in v0.5) — domain-scoped owners. Optional so v0.5
|
|
28
|
+
* attestations that carry only `resolved_owners` remain valid. During the
|
|
29
|
+
* transition the AS emits BOTH `resolved_domains` (internal coverage) and
|
|
30
|
+
* `resolved_owners` (the v0.5 signed wire field).
|
|
31
|
+
*/
|
|
32
|
+
resolved_domains?: ResolvedDomain[];
|
|
33
|
+
/**
|
|
34
|
+
* v0.5 — the Decision Owner DIDs this attestation covers (person-centric
|
|
35
|
+
* model; replaces the abstract domain in `resolved_domains`). Carried in the
|
|
36
|
+
* signed payload so verifiers bind the action to the human(s) who authorized it.
|
|
37
|
+
*/
|
|
38
|
+
resolved_owners?: string[];
|
|
27
39
|
gate_content_hashes: Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* v0.5 (companion spec `intent-disclosure@0.1`) — present iff the attestation
|
|
42
|
+
* carries an encrypted-intent disclosure object. `sha256:`-prefixed hash that
|
|
43
|
+
* binds the disclosure's `intent_ciphertext` + `approvers_frozen` into the
|
|
44
|
+
* signed payload (see {@link computeIntentDisclosureHash}), so a compromised
|
|
45
|
+
* AS cannot swap the ciphertext/wrapped keys or alter the approver set
|
|
46
|
+
* without invalidating the attestation signature (companion invariant C2).
|
|
47
|
+
*/
|
|
48
|
+
intent_disclosure_hash?: string;
|
|
28
49
|
/**
|
|
29
50
|
* v0.4 — commitment mode chosen by the decision owner at attestation time.
|
|
30
51
|
* - 'automatic': agent may invoke bounded tools without per-action review.
|
|
@@ -161,6 +182,32 @@ interface ProfileBoundsField {
|
|
|
161
182
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
|
162
183
|
enum?: string[];
|
|
163
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
187
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
188
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
189
|
+
*
|
|
190
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
191
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
192
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
193
|
+
*
|
|
194
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
195
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
196
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
197
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
198
|
+
*/
|
|
199
|
+
interface ContentBinding {
|
|
200
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
201
|
+
version: string;
|
|
202
|
+
/**
|
|
203
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
204
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
205
|
+
* canonicalizeText), auto-detected content field.
|
|
206
|
+
*/
|
|
207
|
+
kind: 'jcs' | 'text';
|
|
208
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
209
|
+
pre_footer?: boolean;
|
|
210
|
+
}
|
|
164
211
|
/**
|
|
165
212
|
* Context field definition within a v0.4 profile.
|
|
166
213
|
*/
|
|
@@ -280,6 +327,13 @@ interface AgentProfile {
|
|
|
280
327
|
max: number;
|
|
281
328
|
};
|
|
282
329
|
retention_minimum: number;
|
|
330
|
+
/**
|
|
331
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
332
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
333
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
334
|
+
* → no content hash. See {@link ContentBinding}.
|
|
335
|
+
*/
|
|
336
|
+
content_binding?: ContentBinding;
|
|
283
337
|
/**
|
|
284
338
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
285
339
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|
|
@@ -436,6 +490,101 @@ type GatekeeperResult = {
|
|
|
436
490
|
*/
|
|
437
491
|
declare function canonicalize(value: unknown): string;
|
|
438
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Content binding — Level 2 content proof (HAP v0.5 Content Provenance).
|
|
495
|
+
*
|
|
496
|
+
* A receipt normally proves who/why/bounds/when but NOT the action's content.
|
|
497
|
+
* Content binding closes that gap: the gateway computes a `content_hash` over
|
|
498
|
+
* the action's content per the profile's {@link ContentBinding} and hands the
|
|
499
|
+
* SP only the hash. The SP signs it into the receipt verbatim — it never sees
|
|
500
|
+
* the content, so HAP's privacy-minimal design is preserved. Anyone holding
|
|
501
|
+
* the content can recompute the hash and check it against the signed receipt.
|
|
502
|
+
*
|
|
503
|
+
* The hash only verifies if the verifier reproduces the EXACT bytes we hashed,
|
|
504
|
+
* so canonicalization is normative and versioned (pin via `ContentBinding.version`):
|
|
505
|
+
*
|
|
506
|
+
* - kind:"jcs" → RFC 8785 JCS of the record payload (see {@link canonicalize}).
|
|
507
|
+
* - kind:"text" → UTF-8 of the string after {@link canonicalizeText}
|
|
508
|
+
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
509
|
+
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
510
|
+
*
|
|
511
|
+
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
512
|
+
* environment-independent primitives, and the text rule uses String.normalize +
|
|
513
|
+
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
514
|
+
* pattern as frame.ts); browser callers that need to recompute use their own
|
|
515
|
+
* SubtleCrypto digest over the identical canonical bytes.
|
|
516
|
+
*/
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Canonicalize free text per the v0.5 'text' rule. Idempotent.
|
|
520
|
+
*
|
|
521
|
+
* 1. Unicode NFC normalization.
|
|
522
|
+
* 2. CRLF / CR → LF.
|
|
523
|
+
* 3. Strip trailing spaces/tabs from every line.
|
|
524
|
+
* 4. Remove trailing blank lines.
|
|
525
|
+
*/
|
|
526
|
+
declare function canonicalizeText(input: string): string;
|
|
527
|
+
/**
|
|
528
|
+
* Compute the canonical bytes that a content hash is taken over, WITHOUT
|
|
529
|
+
* hashing — exposed so verifiers can debug a mismatch by inspecting the exact
|
|
530
|
+
* serialization both sides should agree on.
|
|
531
|
+
*/
|
|
532
|
+
declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Record<string, unknown> | string): string;
|
|
533
|
+
/**
|
|
534
|
+
* Compute a profile-bound content hash, formatted `sha256:<hex>` (matching the
|
|
535
|
+
* frame/bounds/context hash format used elsewhere in HAP).
|
|
536
|
+
*
|
|
537
|
+
* @param binding the profile's content_binding declaration
|
|
538
|
+
* @param content the record payload (jcs) or the resolved text field (text)
|
|
539
|
+
*/
|
|
540
|
+
declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Intent hashing + intent-disclosure binding (HAP v0.5).
|
|
544
|
+
*
|
|
545
|
+
* Two shared, environment-independent hashes that the attester (gateway), the
|
|
546
|
+
* Authority Server, and any approver MUST compute identically:
|
|
547
|
+
*
|
|
548
|
+
* 1. {@link computeIntentHash} — `gate_content_hashes.intent`. The intent text
|
|
549
|
+
* is canonicalized (Unicode NFC, LF line endings, trailing-whitespace strip
|
|
550
|
+
* — the same rule as content-binding's `canonicalizeText`) before hashing,
|
|
551
|
+
* so independent multi-owner approvers reproduce an identical hash from the
|
|
552
|
+
* same logical statement. See protocol.md → "Intent canonicalization".
|
|
553
|
+
*
|
|
554
|
+
* 2. {@link computeIntentDisclosureHash} — `intent_disclosure_hash`, the C2
|
|
555
|
+
* binding from companion spec `intent-disclosure@0.1`. It commits the
|
|
556
|
+
* encrypted disclosure object (`intent_ciphertext` + `approvers_frozen`)
|
|
557
|
+
* into the SIGNED attestation payload, so a compromised AS cannot swap the
|
|
558
|
+
* ciphertext/wrapped keys or widen/shrink the approver set without breaking
|
|
559
|
+
* the attestation signature.
|
|
560
|
+
*
|
|
561
|
+
* Both produce `sha256:<hex>` to match the frame/bounds/context hash format.
|
|
562
|
+
*/
|
|
563
|
+
/**
|
|
564
|
+
* `gate_content_hashes.intent` for an intent statement.
|
|
565
|
+
* Canonicalizes the text (NFC / LF / trailing-whitespace strip) before hashing.
|
|
566
|
+
*/
|
|
567
|
+
declare function computeIntentHash(intentText: string): string;
|
|
568
|
+
/**
|
|
569
|
+
* The exact bytes `intent_disclosure_hash` is taken over, WITHOUT hashing —
|
|
570
|
+
* exposed so an implementer debugging a mismatch can inspect the serialization
|
|
571
|
+
* both sides must agree on:
|
|
572
|
+
*
|
|
573
|
+
* intent_ciphertext ‖ "\n" ‖ JCS(sort(approvers_frozen))
|
|
574
|
+
*
|
|
575
|
+
* where `intent_ciphertext` is the exact base64url string of the disclosure
|
|
576
|
+
* object, `sort` orders the approver DIDs by Unicode code point, and `JCS` is
|
|
577
|
+
* RFC 8785 canonicalization of the sorted array (via {@link canonicalize}).
|
|
578
|
+
*/
|
|
579
|
+
declare function intentDisclosureCanonicalBytes(intentCiphertext: string, approversFrozen: string[]): string;
|
|
580
|
+
/**
|
|
581
|
+
* `intent_disclosure_hash` — `sha256:<hex>` over
|
|
582
|
+
* {@link intentDisclosureCanonicalBytes}. Computed by the attester after
|
|
583
|
+
* encryption and signed into the attestation; recomputed by the AS (at
|
|
584
|
+
* issuance) and by each approver (on receipt) and compared, fail-closed.
|
|
585
|
+
*/
|
|
586
|
+
declare function computeIntentDisclosureHash(intentCiphertext: string, approversFrozen: string[]): string;
|
|
587
|
+
|
|
439
588
|
/**
|
|
440
589
|
* Frame Canonicalization for Agent Profiles
|
|
441
590
|
*
|
|
@@ -633,4 +782,4 @@ declare function listProfiles(): string[];
|
|
|
633
782
|
declare function getAllProfiles(): AgentProfile[];
|
|
634
783
|
declare function clearProfiles(): void;
|
|
635
784
|
|
|
636
|
-
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
785
|
+
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
package/dist/index.js
CHANGED
|
@@ -35,16 +35,22 @@ __export(index_exports, {
|
|
|
35
35
|
canonicalContext: () => canonicalContext,
|
|
36
36
|
canonicalFrame: () => canonicalFrame,
|
|
37
37
|
canonicalize: () => canonicalize,
|
|
38
|
+
canonicalizeText: () => canonicalizeText,
|
|
38
39
|
checkAttestationExpiry: () => checkAttestationExpiry,
|
|
39
40
|
clearProfiles: () => clearProfiles,
|
|
40
41
|
computeBoundsHash: () => computeBoundsHash,
|
|
42
|
+
computeContentHash: () => computeContentHash,
|
|
41
43
|
computeContextHash: () => computeContextHash,
|
|
42
44
|
computeFrameHash: () => computeFrameHash,
|
|
45
|
+
computeIntentDisclosureHash: () => computeIntentDisclosureHash,
|
|
46
|
+
computeIntentHash: () => computeIntentHash,
|
|
47
|
+
contentCanonicalBytes: () => contentCanonicalBytes,
|
|
43
48
|
decodeAttestationBlob: () => decodeAttestationBlob,
|
|
44
49
|
encodeAttestationBlob: () => encodeAttestationBlob,
|
|
45
50
|
frameHash: () => frameHash,
|
|
46
51
|
getAllProfiles: () => getAllProfiles,
|
|
47
52
|
getProfile: () => getProfile,
|
|
53
|
+
intentDisclosureCanonicalBytes: () => intentDisclosureCanonicalBytes,
|
|
48
54
|
isV4Attestation: () => isV4Attestation,
|
|
49
55
|
listProfiles: () => listProfiles,
|
|
50
56
|
registerProfile: () => registerProfile,
|
|
@@ -86,8 +92,52 @@ function canonicalize(value) {
|
|
|
86
92
|
return "{" + parts.join(",") + "}";
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
// src/
|
|
95
|
+
// src/content-binding.ts
|
|
90
96
|
var import_crypto = require("crypto");
|
|
97
|
+
function canonicalizeText(input) {
|
|
98
|
+
const nfc = input.normalize("NFC");
|
|
99
|
+
const lf = nfc.replace(/\r\n?/g, "\n");
|
|
100
|
+
const lines = lf.split("\n").map((line) => line.replace(/[ \t]+$/, ""));
|
|
101
|
+
return lines.join("\n").replace(/\n+$/, "");
|
|
102
|
+
}
|
|
103
|
+
function sha256Hex(bytes) {
|
|
104
|
+
return (0, import_crypto.createHash)("sha256").update(bytes, "utf8").digest("hex");
|
|
105
|
+
}
|
|
106
|
+
function contentCanonicalBytes(kind, content) {
|
|
107
|
+
if (kind === "jcs") {
|
|
108
|
+
if (typeof content === "string") {
|
|
109
|
+
throw new Error('content_binding kind="jcs" expects a record payload (object), got a string');
|
|
110
|
+
}
|
|
111
|
+
return canonicalize(content);
|
|
112
|
+
}
|
|
113
|
+
if (typeof content !== "string") {
|
|
114
|
+
throw new Error('content_binding kind="text" expects a string, got an object');
|
|
115
|
+
}
|
|
116
|
+
return canonicalizeText(content);
|
|
117
|
+
}
|
|
118
|
+
function computeContentHash(binding, content) {
|
|
119
|
+
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/intent-disclosure.ts
|
|
123
|
+
var import_crypto2 = require("crypto");
|
|
124
|
+
function sha256Hex2(bytes) {
|
|
125
|
+
return (0, import_crypto2.createHash)("sha256").update(bytes, "utf8").digest("hex");
|
|
126
|
+
}
|
|
127
|
+
function computeIntentHash(intentText) {
|
|
128
|
+
return `sha256:${sha256Hex2(canonicalizeText(intentText))}`;
|
|
129
|
+
}
|
|
130
|
+
function intentDisclosureCanonicalBytes(intentCiphertext, approversFrozen) {
|
|
131
|
+
const sorted = [...approversFrozen].sort();
|
|
132
|
+
return `${intentCiphertext}
|
|
133
|
+
${canonicalize(sorted)}`;
|
|
134
|
+
}
|
|
135
|
+
function computeIntentDisclosureHash(intentCiphertext, approversFrozen) {
|
|
136
|
+
return `sha256:${sha256Hex2(intentDisclosureCanonicalBytes(intentCiphertext, approversFrozen))}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/frame.ts
|
|
140
|
+
var import_crypto3 = require("crypto");
|
|
91
141
|
function validateFrameParams(params, profile) {
|
|
92
142
|
const errors = [];
|
|
93
143
|
if (!profile.frameSchema) {
|
|
@@ -124,7 +174,7 @@ function canonicalFrame(params, profile) {
|
|
|
124
174
|
return lines.join("\n");
|
|
125
175
|
}
|
|
126
176
|
function frameHash(canonicalFrameString) {
|
|
127
|
-
const hash = (0,
|
|
177
|
+
const hash = (0, import_crypto3.createHash)("sha256").update(canonicalFrameString, "utf8").digest("hex");
|
|
128
178
|
return `sha256:${hash}`;
|
|
129
179
|
}
|
|
130
180
|
function computeFrameHash(params, profile) {
|
|
@@ -208,17 +258,17 @@ function canonicalContext(params, profile) {
|
|
|
208
258
|
}
|
|
209
259
|
function computeBoundsHash(params, profile) {
|
|
210
260
|
const canonical = canonicalBounds(params, profile);
|
|
211
|
-
const hash = (0,
|
|
261
|
+
const hash = (0, import_crypto3.createHash)("sha256").update(canonical, "utf8").digest("hex");
|
|
212
262
|
return `sha256:${hash}`;
|
|
213
263
|
}
|
|
214
264
|
function computeContextHash(params, profile) {
|
|
215
265
|
const canonical = canonicalContext(params, profile);
|
|
216
|
-
const hash = (0,
|
|
266
|
+
const hash = (0, import_crypto3.createHash)("sha256").update(canonical, "utf8").digest("hex");
|
|
217
267
|
return `sha256:${hash}`;
|
|
218
268
|
}
|
|
219
269
|
|
|
220
270
|
// src/attestation.ts
|
|
221
|
-
var
|
|
271
|
+
var import_crypto4 = require("crypto");
|
|
222
272
|
var ed = __toESM(require("@noble/ed25519"));
|
|
223
273
|
function decodeAttestationBlob(blob) {
|
|
224
274
|
try {
|
|
@@ -236,7 +286,7 @@ function encodeAttestationBlob(attestation) {
|
|
|
236
286
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
237
287
|
}
|
|
238
288
|
function attestationId(blob) {
|
|
239
|
-
const hash = (0,
|
|
289
|
+
const hash = (0, import_crypto4.createHash)("sha256").update(blob, "utf8").digest("hex");
|
|
240
290
|
return `sha256:${hash}`;
|
|
241
291
|
}
|
|
242
292
|
async function verifyAttestationSignature(attestation, publicKeyHex) {
|
|
@@ -366,11 +416,11 @@ async function verifyV3(request, profile, publicKeyHex, now, executionLog, error
|
|
|
366
416
|
try {
|
|
367
417
|
checkAttestationExpiry(attestation.payload, now);
|
|
368
418
|
} catch {
|
|
369
|
-
const domainNames = attestation.payload.resolved_domains.map((d) => d.domain).join(", ");
|
|
419
|
+
const domainNames = (attestation.payload.resolved_domains ?? []).map((d) => d.domain).join(", ");
|
|
370
420
|
errors.push({ code: "TTL_EXPIRED", message: `Attestation for domain "${domainNames}" has expired` });
|
|
371
421
|
continue;
|
|
372
422
|
}
|
|
373
|
-
for (const rd of attestation.payload.resolved_domains) {
|
|
423
|
+
for (const rd of attestation.payload.resolved_domains ?? []) {
|
|
374
424
|
coveredDomains.add(rd.domain);
|
|
375
425
|
}
|
|
376
426
|
}
|
|
@@ -445,11 +495,11 @@ async function verifyV4(request, profile, publicKeyHex, now, executionLog) {
|
|
|
445
495
|
try {
|
|
446
496
|
checkAttestationExpiry(attestation.payload, now);
|
|
447
497
|
} catch {
|
|
448
|
-
const domainNames = attestation.payload.resolved_domains.map((d) => d.domain).join(", ");
|
|
498
|
+
const domainNames = (attestation.payload.resolved_domains ?? []).map((d) => d.domain).join(", ");
|
|
449
499
|
errors.push({ code: "TTL_EXPIRED", message: `Attestation for domain "${domainNames}" has expired` });
|
|
450
500
|
continue;
|
|
451
501
|
}
|
|
452
|
-
for (const rd of attestation.payload.resolved_domains) {
|
|
502
|
+
for (const rd of attestation.payload.resolved_domains ?? []) {
|
|
453
503
|
coveredDomains.add(rd.domain);
|
|
454
504
|
}
|
|
455
505
|
}
|
|
@@ -746,16 +796,22 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
746
796
|
canonicalContext,
|
|
747
797
|
canonicalFrame,
|
|
748
798
|
canonicalize,
|
|
799
|
+
canonicalizeText,
|
|
749
800
|
checkAttestationExpiry,
|
|
750
801
|
clearProfiles,
|
|
751
802
|
computeBoundsHash,
|
|
803
|
+
computeContentHash,
|
|
752
804
|
computeContextHash,
|
|
753
805
|
computeFrameHash,
|
|
806
|
+
computeIntentDisclosureHash,
|
|
807
|
+
computeIntentHash,
|
|
808
|
+
contentCanonicalBytes,
|
|
754
809
|
decodeAttestationBlob,
|
|
755
810
|
encodeAttestationBlob,
|
|
756
811
|
frameHash,
|
|
757
812
|
getAllProfiles,
|
|
758
813
|
getProfile,
|
|
814
|
+
intentDisclosureCanonicalBytes,
|
|
759
815
|
isV4Attestation,
|
|
760
816
|
listProfiles,
|
|
761
817
|
registerProfile,
|
package/dist/index.mjs
CHANGED
|
@@ -23,8 +23,52 @@ function canonicalize(value) {
|
|
|
23
23
|
return "{" + parts.join(",") + "}";
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// src/
|
|
26
|
+
// src/content-binding.ts
|
|
27
27
|
import { createHash } from "crypto";
|
|
28
|
+
function canonicalizeText(input) {
|
|
29
|
+
const nfc = input.normalize("NFC");
|
|
30
|
+
const lf = nfc.replace(/\r\n?/g, "\n");
|
|
31
|
+
const lines = lf.split("\n").map((line) => line.replace(/[ \t]+$/, ""));
|
|
32
|
+
return lines.join("\n").replace(/\n+$/, "");
|
|
33
|
+
}
|
|
34
|
+
function sha256Hex(bytes) {
|
|
35
|
+
return createHash("sha256").update(bytes, "utf8").digest("hex");
|
|
36
|
+
}
|
|
37
|
+
function contentCanonicalBytes(kind, content) {
|
|
38
|
+
if (kind === "jcs") {
|
|
39
|
+
if (typeof content === "string") {
|
|
40
|
+
throw new Error('content_binding kind="jcs" expects a record payload (object), got a string');
|
|
41
|
+
}
|
|
42
|
+
return canonicalize(content);
|
|
43
|
+
}
|
|
44
|
+
if (typeof content !== "string") {
|
|
45
|
+
throw new Error('content_binding kind="text" expects a string, got an object');
|
|
46
|
+
}
|
|
47
|
+
return canonicalizeText(content);
|
|
48
|
+
}
|
|
49
|
+
function computeContentHash(binding, content) {
|
|
50
|
+
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/intent-disclosure.ts
|
|
54
|
+
import { createHash as createHash2 } from "crypto";
|
|
55
|
+
function sha256Hex2(bytes) {
|
|
56
|
+
return createHash2("sha256").update(bytes, "utf8").digest("hex");
|
|
57
|
+
}
|
|
58
|
+
function computeIntentHash(intentText) {
|
|
59
|
+
return `sha256:${sha256Hex2(canonicalizeText(intentText))}`;
|
|
60
|
+
}
|
|
61
|
+
function intentDisclosureCanonicalBytes(intentCiphertext, approversFrozen) {
|
|
62
|
+
const sorted = [...approversFrozen].sort();
|
|
63
|
+
return `${intentCiphertext}
|
|
64
|
+
${canonicalize(sorted)}`;
|
|
65
|
+
}
|
|
66
|
+
function computeIntentDisclosureHash(intentCiphertext, approversFrozen) {
|
|
67
|
+
return `sha256:${sha256Hex2(intentDisclosureCanonicalBytes(intentCiphertext, approversFrozen))}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/frame.ts
|
|
71
|
+
import { createHash as createHash3 } from "crypto";
|
|
28
72
|
function validateFrameParams(params, profile) {
|
|
29
73
|
const errors = [];
|
|
30
74
|
if (!profile.frameSchema) {
|
|
@@ -61,7 +105,7 @@ function canonicalFrame(params, profile) {
|
|
|
61
105
|
return lines.join("\n");
|
|
62
106
|
}
|
|
63
107
|
function frameHash(canonicalFrameString) {
|
|
64
|
-
const hash =
|
|
108
|
+
const hash = createHash3("sha256").update(canonicalFrameString, "utf8").digest("hex");
|
|
65
109
|
return `sha256:${hash}`;
|
|
66
110
|
}
|
|
67
111
|
function computeFrameHash(params, profile) {
|
|
@@ -145,17 +189,17 @@ function canonicalContext(params, profile) {
|
|
|
145
189
|
}
|
|
146
190
|
function computeBoundsHash(params, profile) {
|
|
147
191
|
const canonical = canonicalBounds(params, profile);
|
|
148
|
-
const hash =
|
|
192
|
+
const hash = createHash3("sha256").update(canonical, "utf8").digest("hex");
|
|
149
193
|
return `sha256:${hash}`;
|
|
150
194
|
}
|
|
151
195
|
function computeContextHash(params, profile) {
|
|
152
196
|
const canonical = canonicalContext(params, profile);
|
|
153
|
-
const hash =
|
|
197
|
+
const hash = createHash3("sha256").update(canonical, "utf8").digest("hex");
|
|
154
198
|
return `sha256:${hash}`;
|
|
155
199
|
}
|
|
156
200
|
|
|
157
201
|
// src/attestation.ts
|
|
158
|
-
import { createHash as
|
|
202
|
+
import { createHash as createHash4 } from "crypto";
|
|
159
203
|
import * as ed from "@noble/ed25519";
|
|
160
204
|
function decodeAttestationBlob(blob) {
|
|
161
205
|
try {
|
|
@@ -173,7 +217,7 @@ function encodeAttestationBlob(attestation) {
|
|
|
173
217
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
174
218
|
}
|
|
175
219
|
function attestationId(blob) {
|
|
176
|
-
const hash =
|
|
220
|
+
const hash = createHash4("sha256").update(blob, "utf8").digest("hex");
|
|
177
221
|
return `sha256:${hash}`;
|
|
178
222
|
}
|
|
179
223
|
async function verifyAttestationSignature(attestation, publicKeyHex) {
|
|
@@ -303,11 +347,11 @@ async function verifyV3(request, profile, publicKeyHex, now, executionLog, error
|
|
|
303
347
|
try {
|
|
304
348
|
checkAttestationExpiry(attestation.payload, now);
|
|
305
349
|
} catch {
|
|
306
|
-
const domainNames = attestation.payload.resolved_domains.map((d) => d.domain).join(", ");
|
|
350
|
+
const domainNames = (attestation.payload.resolved_domains ?? []).map((d) => d.domain).join(", ");
|
|
307
351
|
errors.push({ code: "TTL_EXPIRED", message: `Attestation for domain "${domainNames}" has expired` });
|
|
308
352
|
continue;
|
|
309
353
|
}
|
|
310
|
-
for (const rd of attestation.payload.resolved_domains) {
|
|
354
|
+
for (const rd of attestation.payload.resolved_domains ?? []) {
|
|
311
355
|
coveredDomains.add(rd.domain);
|
|
312
356
|
}
|
|
313
357
|
}
|
|
@@ -382,11 +426,11 @@ async function verifyV4(request, profile, publicKeyHex, now, executionLog) {
|
|
|
382
426
|
try {
|
|
383
427
|
checkAttestationExpiry(attestation.payload, now);
|
|
384
428
|
} catch {
|
|
385
|
-
const domainNames = attestation.payload.resolved_domains.map((d) => d.domain).join(", ");
|
|
429
|
+
const domainNames = (attestation.payload.resolved_domains ?? []).map((d) => d.domain).join(", ");
|
|
386
430
|
errors.push({ code: "TTL_EXPIRED", message: `Attestation for domain "${domainNames}" has expired` });
|
|
387
431
|
continue;
|
|
388
432
|
}
|
|
389
|
-
for (const rd of attestation.payload.resolved_domains) {
|
|
433
|
+
for (const rd of attestation.payload.resolved_domains ?? []) {
|
|
390
434
|
coveredDomains.add(rd.domain);
|
|
391
435
|
}
|
|
392
436
|
}
|
|
@@ -682,16 +726,22 @@ export {
|
|
|
682
726
|
canonicalContext,
|
|
683
727
|
canonicalFrame,
|
|
684
728
|
canonicalize,
|
|
729
|
+
canonicalizeText,
|
|
685
730
|
checkAttestationExpiry,
|
|
686
731
|
clearProfiles,
|
|
687
732
|
computeBoundsHash,
|
|
733
|
+
computeContentHash,
|
|
688
734
|
computeContextHash,
|
|
689
735
|
computeFrameHash,
|
|
736
|
+
computeIntentDisclosureHash,
|
|
737
|
+
computeIntentHash,
|
|
738
|
+
contentCanonicalBytes,
|
|
690
739
|
decodeAttestationBlob,
|
|
691
740
|
encodeAttestationBlob,
|
|
692
741
|
frameHash,
|
|
693
742
|
getAllProfiles,
|
|
694
743
|
getProfile,
|
|
744
|
+
intentDisclosureCanonicalBytes,
|
|
695
745
|
isV4Attestation,
|
|
696
746
|
listProfiles,
|
|
697
747
|
registerProfile,
|
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content binding — Level 2 content proof (HAP v0.5 Content Provenance).
|
|
3
|
+
*
|
|
4
|
+
* A receipt normally proves who/why/bounds/when but NOT the action's content.
|
|
5
|
+
* Content binding closes that gap: the gateway computes a `content_hash` over
|
|
6
|
+
* the action's content per the profile's {@link ContentBinding} and hands the
|
|
7
|
+
* SP only the hash. The SP signs it into the receipt verbatim — it never sees
|
|
8
|
+
* the content, so HAP's privacy-minimal design is preserved. Anyone holding
|
|
9
|
+
* the content can recompute the hash and check it against the signed receipt.
|
|
10
|
+
*
|
|
11
|
+
* The hash only verifies if the verifier reproduces the EXACT bytes we hashed,
|
|
12
|
+
* so canonicalization is normative and versioned (pin via `ContentBinding.version`):
|
|
13
|
+
*
|
|
14
|
+
* - kind:"jcs" → RFC 8785 JCS of the record payload (see {@link canonicalize}).
|
|
15
|
+
* - kind:"text" → UTF-8 of the string after {@link canonicalizeText}
|
|
16
|
+
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
17
|
+
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
18
|
+
*
|
|
19
|
+
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
20
|
+
* environment-independent primitives, and the text rule uses String.normalize +
|
|
21
|
+
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
22
|
+
* pattern as frame.ts); browser callers that need to recompute use their own
|
|
23
|
+
* SubtleCrypto digest over the identical canonical bytes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from 'crypto';
|
|
27
|
+
import { canonicalize } from './canonicalize';
|
|
28
|
+
import type { ContentBinding } from './types';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Canonicalize free text per the v0.5 'text' rule. Idempotent.
|
|
32
|
+
*
|
|
33
|
+
* 1. Unicode NFC normalization.
|
|
34
|
+
* 2. CRLF / CR → LF.
|
|
35
|
+
* 3. Strip trailing spaces/tabs from every line.
|
|
36
|
+
* 4. Remove trailing blank lines.
|
|
37
|
+
*/
|
|
38
|
+
export function canonicalizeText(input: string): string {
|
|
39
|
+
const nfc = input.normalize('NFC');
|
|
40
|
+
const lf = nfc.replace(/\r\n?/g, '\n');
|
|
41
|
+
const lines = lf.split('\n').map((line) => line.replace(/[ \t]+$/, ''));
|
|
42
|
+
return lines.join('\n').replace(/\n+$/, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** sha256 of a UTF-8 string → 64 hex chars. */
|
|
46
|
+
function sha256Hex(bytes: string): string {
|
|
47
|
+
return createHash('sha256').update(bytes, 'utf8').digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute the canonical bytes that a content hash is taken over, WITHOUT
|
|
52
|
+
* hashing — exposed so verifiers can debug a mismatch by inspecting the exact
|
|
53
|
+
* serialization both sides should agree on.
|
|
54
|
+
*/
|
|
55
|
+
export function contentCanonicalBytes(
|
|
56
|
+
kind: ContentBinding['kind'],
|
|
57
|
+
content: Record<string, unknown> | string,
|
|
58
|
+
): string {
|
|
59
|
+
if (kind === 'jcs') {
|
|
60
|
+
if (typeof content === 'string') {
|
|
61
|
+
throw new Error('content_binding kind="jcs" expects a record payload (object), got a string');
|
|
62
|
+
}
|
|
63
|
+
return canonicalize(content);
|
|
64
|
+
}
|
|
65
|
+
if (typeof content !== 'string') {
|
|
66
|
+
throw new Error('content_binding kind="text" expects a string, got an object');
|
|
67
|
+
}
|
|
68
|
+
return canonicalizeText(content);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compute a profile-bound content hash, formatted `sha256:<hex>` (matching the
|
|
73
|
+
* frame/bounds/context hash format used elsewhere in HAP).
|
|
74
|
+
*
|
|
75
|
+
* @param binding the profile's content_binding declaration
|
|
76
|
+
* @param content the record payload (jcs) or the resolved text field (text)
|
|
77
|
+
*/
|
|
78
|
+
export function computeContentHash(
|
|
79
|
+
binding: ContentBinding,
|
|
80
|
+
content: Record<string, unknown> | string,
|
|
81
|
+
): string {
|
|
82
|
+
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
83
|
+
}
|
package/src/gatekeeper.ts
CHANGED
|
@@ -131,12 +131,12 @@ async function verifyV3(
|
|
|
131
131
|
try {
|
|
132
132
|
checkAttestationExpiry(attestation.payload, now);
|
|
133
133
|
} catch {
|
|
134
|
-
const domainNames = attestation.payload.resolved_domains.map(d => d.domain).join(', ');
|
|
134
|
+
const domainNames = (attestation.payload.resolved_domains ?? []).map(d => d.domain).join(', ');
|
|
135
135
|
errors.push({ code: 'TTL_EXPIRED', message: `Attestation for domain "${domainNames}" has expired` });
|
|
136
136
|
continue;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
for (const rd of attestation.payload.resolved_domains) {
|
|
139
|
+
for (const rd of attestation.payload.resolved_domains ?? []) {
|
|
140
140
|
coveredDomains.add(rd.domain);
|
|
141
141
|
}
|
|
142
142
|
}
|
|
@@ -244,12 +244,12 @@ async function verifyV4(
|
|
|
244
244
|
try {
|
|
245
245
|
checkAttestationExpiry(attestation.payload, now);
|
|
246
246
|
} catch {
|
|
247
|
-
const domainNames = attestation.payload.resolved_domains.map(d => d.domain).join(', ');
|
|
247
|
+
const domainNames = (attestation.payload.resolved_domains ?? []).map(d => d.domain).join(', ');
|
|
248
248
|
errors.push({ code: 'TTL_EXPIRED', message: `Attestation for domain "${domainNames}" has expired` });
|
|
249
249
|
continue;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
for (const rd of attestation.payload.resolved_domains) {
|
|
252
|
+
for (const rd of attestation.payload.resolved_domains ?? []) {
|
|
253
253
|
coveredDomains.add(rd.domain);
|
|
254
254
|
}
|
|
255
255
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent hashing + intent-disclosure binding (HAP v0.5).
|
|
3
|
+
*
|
|
4
|
+
* Two shared, environment-independent hashes that the attester (gateway), the
|
|
5
|
+
* Authority Server, and any approver MUST compute identically:
|
|
6
|
+
*
|
|
7
|
+
* 1. {@link computeIntentHash} — `gate_content_hashes.intent`. The intent text
|
|
8
|
+
* is canonicalized (Unicode NFC, LF line endings, trailing-whitespace strip
|
|
9
|
+
* — the same rule as content-binding's `canonicalizeText`) before hashing,
|
|
10
|
+
* so independent multi-owner approvers reproduce an identical hash from the
|
|
11
|
+
* same logical statement. See protocol.md → "Intent canonicalization".
|
|
12
|
+
*
|
|
13
|
+
* 2. {@link computeIntentDisclosureHash} — `intent_disclosure_hash`, the C2
|
|
14
|
+
* binding from companion spec `intent-disclosure@0.1`. It commits the
|
|
15
|
+
* encrypted disclosure object (`intent_ciphertext` + `approvers_frozen`)
|
|
16
|
+
* into the SIGNED attestation payload, so a compromised AS cannot swap the
|
|
17
|
+
* ciphertext/wrapped keys or widen/shrink the approver set without breaking
|
|
18
|
+
* the attestation signature.
|
|
19
|
+
*
|
|
20
|
+
* Both produce `sha256:<hex>` to match the frame/bounds/context hash format.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'crypto';
|
|
24
|
+
import { canonicalize } from './canonicalize';
|
|
25
|
+
import { canonicalizeText } from './content-binding';
|
|
26
|
+
|
|
27
|
+
function sha256Hex(bytes: string): string {
|
|
28
|
+
return createHash('sha256').update(bytes, 'utf8').digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `gate_content_hashes.intent` for an intent statement.
|
|
33
|
+
* Canonicalizes the text (NFC / LF / trailing-whitespace strip) before hashing.
|
|
34
|
+
*/
|
|
35
|
+
export function computeIntentHash(intentText: string): string {
|
|
36
|
+
return `sha256:${sha256Hex(canonicalizeText(intentText))}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The exact bytes `intent_disclosure_hash` is taken over, WITHOUT hashing —
|
|
41
|
+
* exposed so an implementer debugging a mismatch can inspect the serialization
|
|
42
|
+
* both sides must agree on:
|
|
43
|
+
*
|
|
44
|
+
* intent_ciphertext ‖ "\n" ‖ JCS(sort(approvers_frozen))
|
|
45
|
+
*
|
|
46
|
+
* where `intent_ciphertext` is the exact base64url string of the disclosure
|
|
47
|
+
* object, `sort` orders the approver DIDs by Unicode code point, and `JCS` is
|
|
48
|
+
* RFC 8785 canonicalization of the sorted array (via {@link canonicalize}).
|
|
49
|
+
*/
|
|
50
|
+
export function intentDisclosureCanonicalBytes(
|
|
51
|
+
intentCiphertext: string,
|
|
52
|
+
approversFrozen: string[],
|
|
53
|
+
): string {
|
|
54
|
+
const sorted = [...approversFrozen].sort();
|
|
55
|
+
return `${intentCiphertext}\n${canonicalize(sorted)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `intent_disclosure_hash` — `sha256:<hex>` over
|
|
60
|
+
* {@link intentDisclosureCanonicalBytes}. Computed by the attester after
|
|
61
|
+
* encryption and signed into the attestation; recomputed by the AS (at
|
|
62
|
+
* issuance) and by each approver (on receipt) and compared, fail-closed.
|
|
63
|
+
*/
|
|
64
|
+
export function computeIntentDisclosureHash(
|
|
65
|
+
intentCiphertext: string,
|
|
66
|
+
approversFrozen: string[],
|
|
67
|
+
): string {
|
|
68
|
+
return `sha256:${sha256Hex(intentDisclosureCanonicalBytes(intentCiphertext, approversFrozen))}`;
|
|
69
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface ResolvedDomain {
|
|
|
19
19
|
|
|
20
20
|
export interface AttestationPayload {
|
|
21
21
|
attestation_id: string;
|
|
22
|
-
version: '0.3' | '0.4';
|
|
22
|
+
version: '0.3' | '0.4' | '0.5';
|
|
23
23
|
profile_id: string;
|
|
24
24
|
/** v0.3 (deprecated) — hash of the authorization frame */
|
|
25
25
|
frame_hash?: string;
|
|
@@ -28,8 +28,29 @@ export interface AttestationPayload {
|
|
|
28
28
|
/** v0.4 — hash of the context parameters */
|
|
29
29
|
context_hash?: string;
|
|
30
30
|
execution_context_hash: string;
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* v0.4 (deprecated in v0.5) — domain-scoped owners. Optional so v0.5
|
|
33
|
+
* attestations that carry only `resolved_owners` remain valid. During the
|
|
34
|
+
* transition the AS emits BOTH `resolved_domains` (internal coverage) and
|
|
35
|
+
* `resolved_owners` (the v0.5 signed wire field).
|
|
36
|
+
*/
|
|
37
|
+
resolved_domains?: ResolvedDomain[];
|
|
38
|
+
/**
|
|
39
|
+
* v0.5 — the Decision Owner DIDs this attestation covers (person-centric
|
|
40
|
+
* model; replaces the abstract domain in `resolved_domains`). Carried in the
|
|
41
|
+
* signed payload so verifiers bind the action to the human(s) who authorized it.
|
|
42
|
+
*/
|
|
43
|
+
resolved_owners?: string[];
|
|
32
44
|
gate_content_hashes: Record<string, string>;
|
|
45
|
+
/**
|
|
46
|
+
* v0.5 (companion spec `intent-disclosure@0.1`) — present iff the attestation
|
|
47
|
+
* carries an encrypted-intent disclosure object. `sha256:`-prefixed hash that
|
|
48
|
+
* binds the disclosure's `intent_ciphertext` + `approvers_frozen` into the
|
|
49
|
+
* signed payload (see {@link computeIntentDisclosureHash}), so a compromised
|
|
50
|
+
* AS cannot swap the ciphertext/wrapped keys or alter the approver set
|
|
51
|
+
* without invalidating the attestation signature (companion invariant C2).
|
|
52
|
+
*/
|
|
53
|
+
intent_disclosure_hash?: string;
|
|
33
54
|
/**
|
|
34
55
|
* v0.4 — commitment mode chosen by the decision owner at attestation time.
|
|
35
56
|
* - 'automatic': agent may invoke bounded tools without per-action review.
|
|
@@ -166,6 +187,33 @@ export interface ProfileBoundsField {
|
|
|
166
187
|
enum?: string[];
|
|
167
188
|
}
|
|
168
189
|
|
|
190
|
+
/**
|
|
191
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
192
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
193
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
194
|
+
*
|
|
195
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
196
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
197
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
198
|
+
*
|
|
199
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
200
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
201
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
202
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
203
|
+
*/
|
|
204
|
+
export interface ContentBinding {
|
|
205
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
206
|
+
version: string;
|
|
207
|
+
/**
|
|
208
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
209
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
210
|
+
* canonicalizeText), auto-detected content field.
|
|
211
|
+
*/
|
|
212
|
+
kind: 'jcs' | 'text';
|
|
213
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
214
|
+
pre_footer?: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
169
217
|
/**
|
|
170
218
|
* Context field definition within a v0.4 profile.
|
|
171
219
|
*/
|
|
@@ -295,6 +343,14 @@ export interface AgentProfile {
|
|
|
295
343
|
ttl: { default: number; max: number };
|
|
296
344
|
retention_minimum: number;
|
|
297
345
|
|
|
346
|
+
/**
|
|
347
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
348
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
349
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
350
|
+
* → no content hash. See {@link ContentBinding}.
|
|
351
|
+
*/
|
|
352
|
+
content_binding?: ContentBinding;
|
|
353
|
+
|
|
298
354
|
/**
|
|
299
355
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
300
356
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|