@humanagencyp/hap-core 0.4.6 → 0.5.1
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 +118 -1
- package/dist/index.d.ts +118 -1
- package/dist/index.js +67 -7
- package/dist/index.mjs +63 -7
- package/package.json +12 -11
- package/src/attestation.ts +4 -1
- package/src/canonicalize.ts +65 -0
- package/src/content-binding.ts +83 -0
- package/src/index.ts +2 -0
- package/src/types.ts +35 -0
package/dist/index.d.mts
CHANGED
|
@@ -161,6 +161,32 @@ interface ProfileBoundsField {
|
|
|
161
161
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
|
162
162
|
enum?: string[];
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
166
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
167
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
168
|
+
*
|
|
169
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
170
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
171
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
172
|
+
*
|
|
173
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
174
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
175
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
176
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
177
|
+
*/
|
|
178
|
+
interface ContentBinding {
|
|
179
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
180
|
+
version: string;
|
|
181
|
+
/**
|
|
182
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
183
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
184
|
+
* canonicalizeText), auto-detected content field.
|
|
185
|
+
*/
|
|
186
|
+
kind: 'jcs' | 'text';
|
|
187
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
188
|
+
pre_footer?: boolean;
|
|
189
|
+
}
|
|
164
190
|
/**
|
|
165
191
|
* Context field definition within a v0.4 profile.
|
|
166
192
|
*/
|
|
@@ -280,6 +306,13 @@ interface AgentProfile {
|
|
|
280
306
|
max: number;
|
|
281
307
|
};
|
|
282
308
|
retention_minimum: number;
|
|
309
|
+
/**
|
|
310
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
311
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
312
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
313
|
+
* → no content hash. See {@link ContentBinding}.
|
|
314
|
+
*/
|
|
315
|
+
content_binding?: ContentBinding;
|
|
283
316
|
/**
|
|
284
317
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
285
318
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|
|
@@ -401,6 +434,90 @@ type GatekeeperResult = {
|
|
|
401
434
|
errors: GatekeeperError[];
|
|
402
435
|
};
|
|
403
436
|
|
|
437
|
+
/**
|
|
438
|
+
* JCS — JSON Canonicalization (RFC 8785) for signing.
|
|
439
|
+
*
|
|
440
|
+
* Both attestation and receipt signatures are computed over a canonical byte
|
|
441
|
+
* serialization of the payload, so that two independent implementations (an AS,
|
|
442
|
+
* a gateway, an external verifier — in any language) agree on exactly which
|
|
443
|
+
* bytes were signed regardless of how they happened to construct the object.
|
|
444
|
+
*
|
|
445
|
+
* `JSON.stringify` is NOT sufficient: it preserves key *insertion* order, so the
|
|
446
|
+
* same logical payload built with keys in a different order serializes to
|
|
447
|
+
* different bytes and the signature fails to verify. Canonicalization removes
|
|
448
|
+
* that dependency by sorting keys.
|
|
449
|
+
*
|
|
450
|
+
* This is RFC 8785-compatible and matches the v0.5 spec's normative signing
|
|
451
|
+
* canonicalization (core.md "Signing Canonicalization"):
|
|
452
|
+
* 1. UTF-8.
|
|
453
|
+
* 2. Object keys sorted (RFC 8785 sorts by UTF-16 code units — exactly what
|
|
454
|
+
* JavaScript's default String comparison does, so `Object.keys().sort()` is
|
|
455
|
+
* correct here).
|
|
456
|
+
* 3. No insignificant whitespace.
|
|
457
|
+
* 4. Numbers in the shortest round-trippable form — the ECMAScript
|
|
458
|
+
* Number-to-String algorithm, which `JSON.stringify(number)` produces and
|
|
459
|
+
* RFC 8785 adopts verbatim.
|
|
460
|
+
* 5. Strings escaped per RFC 8259; non-ASCII passed through as UTF-8 (this is
|
|
461
|
+
* `JSON.stringify`'s behaviour for strings).
|
|
462
|
+
* 6. Array order preserved.
|
|
463
|
+
*
|
|
464
|
+
* Because it relies only on `JSON.stringify` for leaves plus `Object.keys`,
|
|
465
|
+
* `Array`, and `String` sort — all of which are environment-independent in
|
|
466
|
+
* JavaScript — Node and the browser produce byte-identical output. The
|
|
467
|
+
* conformance test pins a (payload → canonical bytes) vector that any other
|
|
468
|
+
* implementation can check against.
|
|
469
|
+
*/
|
|
470
|
+
declare function canonicalize(value: unknown): string;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Content binding — Level 2 content proof (HAP v0.5 Content Provenance).
|
|
474
|
+
*
|
|
475
|
+
* A receipt normally proves who/why/bounds/when but NOT the action's content.
|
|
476
|
+
* Content binding closes that gap: the gateway computes a `content_hash` over
|
|
477
|
+
* the action's content per the profile's {@link ContentBinding} and hands the
|
|
478
|
+
* SP only the hash. The SP signs it into the receipt verbatim — it never sees
|
|
479
|
+
* the content, so HAP's privacy-minimal design is preserved. Anyone holding
|
|
480
|
+
* the content can recompute the hash and check it against the signed receipt.
|
|
481
|
+
*
|
|
482
|
+
* The hash only verifies if the verifier reproduces the EXACT bytes we hashed,
|
|
483
|
+
* so canonicalization is normative and versioned (pin via `ContentBinding.version`):
|
|
484
|
+
*
|
|
485
|
+
* - kind:"jcs" → RFC 8785 JCS of the record payload (see {@link canonicalize}).
|
|
486
|
+
* - kind:"text" → UTF-8 of the string after {@link canonicalizeText}
|
|
487
|
+
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
488
|
+
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
489
|
+
*
|
|
490
|
+
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
491
|
+
* environment-independent primitives, and the text rule uses String.normalize +
|
|
492
|
+
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
493
|
+
* pattern as frame.ts); browser callers that need to recompute use their own
|
|
494
|
+
* SubtleCrypto digest over the identical canonical bytes.
|
|
495
|
+
*/
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Canonicalize free text per the v0.5 'text' rule. Idempotent.
|
|
499
|
+
*
|
|
500
|
+
* 1. Unicode NFC normalization.
|
|
501
|
+
* 2. CRLF / CR → LF.
|
|
502
|
+
* 3. Strip trailing spaces/tabs from every line.
|
|
503
|
+
* 4. Remove trailing blank lines.
|
|
504
|
+
*/
|
|
505
|
+
declare function canonicalizeText(input: string): string;
|
|
506
|
+
/**
|
|
507
|
+
* Compute the canonical bytes that a content hash is taken over, WITHOUT
|
|
508
|
+
* hashing — exposed so verifiers can debug a mismatch by inspecting the exact
|
|
509
|
+
* serialization both sides should agree on.
|
|
510
|
+
*/
|
|
511
|
+
declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Record<string, unknown> | string): string;
|
|
512
|
+
/**
|
|
513
|
+
* Compute a profile-bound content hash, formatted `sha256:<hex>` (matching the
|
|
514
|
+
* frame/bounds/context hash format used elsewhere in HAP).
|
|
515
|
+
*
|
|
516
|
+
* @param binding the profile's content_binding declaration
|
|
517
|
+
* @param content the record payload (jcs) or the resolved text field (text)
|
|
518
|
+
*/
|
|
519
|
+
declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
|
|
520
|
+
|
|
404
521
|
/**
|
|
405
522
|
* Frame Canonicalization for Agent Profiles
|
|
406
523
|
*
|
|
@@ -598,4 +715,4 @@ declare function listProfiles(): string[];
|
|
|
598
715
|
declare function getAllProfiles(): AgentProfile[];
|
|
599
716
|
declare function clearProfiles(): void;
|
|
600
717
|
|
|
601
|
-
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, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
718
|
+
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, contentCanonicalBytes, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
package/dist/index.d.ts
CHANGED
|
@@ -161,6 +161,32 @@ interface ProfileBoundsField {
|
|
|
161
161
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
|
162
162
|
enum?: string[];
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
166
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
167
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
168
|
+
*
|
|
169
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
170
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
171
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
172
|
+
*
|
|
173
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
174
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
175
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
176
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
177
|
+
*/
|
|
178
|
+
interface ContentBinding {
|
|
179
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
180
|
+
version: string;
|
|
181
|
+
/**
|
|
182
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
183
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
184
|
+
* canonicalizeText), auto-detected content field.
|
|
185
|
+
*/
|
|
186
|
+
kind: 'jcs' | 'text';
|
|
187
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
188
|
+
pre_footer?: boolean;
|
|
189
|
+
}
|
|
164
190
|
/**
|
|
165
191
|
* Context field definition within a v0.4 profile.
|
|
166
192
|
*/
|
|
@@ -280,6 +306,13 @@ interface AgentProfile {
|
|
|
280
306
|
max: number;
|
|
281
307
|
};
|
|
282
308
|
retention_minimum: number;
|
|
309
|
+
/**
|
|
310
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
311
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
312
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
313
|
+
* → no content hash. See {@link ContentBinding}.
|
|
314
|
+
*/
|
|
315
|
+
content_binding?: ContentBinding;
|
|
283
316
|
/**
|
|
284
317
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
285
318
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|
|
@@ -401,6 +434,90 @@ type GatekeeperResult = {
|
|
|
401
434
|
errors: GatekeeperError[];
|
|
402
435
|
};
|
|
403
436
|
|
|
437
|
+
/**
|
|
438
|
+
* JCS — JSON Canonicalization (RFC 8785) for signing.
|
|
439
|
+
*
|
|
440
|
+
* Both attestation and receipt signatures are computed over a canonical byte
|
|
441
|
+
* serialization of the payload, so that two independent implementations (an AS,
|
|
442
|
+
* a gateway, an external verifier — in any language) agree on exactly which
|
|
443
|
+
* bytes were signed regardless of how they happened to construct the object.
|
|
444
|
+
*
|
|
445
|
+
* `JSON.stringify` is NOT sufficient: it preserves key *insertion* order, so the
|
|
446
|
+
* same logical payload built with keys in a different order serializes to
|
|
447
|
+
* different bytes and the signature fails to verify. Canonicalization removes
|
|
448
|
+
* that dependency by sorting keys.
|
|
449
|
+
*
|
|
450
|
+
* This is RFC 8785-compatible and matches the v0.5 spec's normative signing
|
|
451
|
+
* canonicalization (core.md "Signing Canonicalization"):
|
|
452
|
+
* 1. UTF-8.
|
|
453
|
+
* 2. Object keys sorted (RFC 8785 sorts by UTF-16 code units — exactly what
|
|
454
|
+
* JavaScript's default String comparison does, so `Object.keys().sort()` is
|
|
455
|
+
* correct here).
|
|
456
|
+
* 3. No insignificant whitespace.
|
|
457
|
+
* 4. Numbers in the shortest round-trippable form — the ECMAScript
|
|
458
|
+
* Number-to-String algorithm, which `JSON.stringify(number)` produces and
|
|
459
|
+
* RFC 8785 adopts verbatim.
|
|
460
|
+
* 5. Strings escaped per RFC 8259; non-ASCII passed through as UTF-8 (this is
|
|
461
|
+
* `JSON.stringify`'s behaviour for strings).
|
|
462
|
+
* 6. Array order preserved.
|
|
463
|
+
*
|
|
464
|
+
* Because it relies only on `JSON.stringify` for leaves plus `Object.keys`,
|
|
465
|
+
* `Array`, and `String` sort — all of which are environment-independent in
|
|
466
|
+
* JavaScript — Node and the browser produce byte-identical output. The
|
|
467
|
+
* conformance test pins a (payload → canonical bytes) vector that any other
|
|
468
|
+
* implementation can check against.
|
|
469
|
+
*/
|
|
470
|
+
declare function canonicalize(value: unknown): string;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Content binding — Level 2 content proof (HAP v0.5 Content Provenance).
|
|
474
|
+
*
|
|
475
|
+
* A receipt normally proves who/why/bounds/when but NOT the action's content.
|
|
476
|
+
* Content binding closes that gap: the gateway computes a `content_hash` over
|
|
477
|
+
* the action's content per the profile's {@link ContentBinding} and hands the
|
|
478
|
+
* SP only the hash. The SP signs it into the receipt verbatim — it never sees
|
|
479
|
+
* the content, so HAP's privacy-minimal design is preserved. Anyone holding
|
|
480
|
+
* the content can recompute the hash and check it against the signed receipt.
|
|
481
|
+
*
|
|
482
|
+
* The hash only verifies if the verifier reproduces the EXACT bytes we hashed,
|
|
483
|
+
* so canonicalization is normative and versioned (pin via `ContentBinding.version`):
|
|
484
|
+
*
|
|
485
|
+
* - kind:"jcs" → RFC 8785 JCS of the record payload (see {@link canonicalize}).
|
|
486
|
+
* - kind:"text" → UTF-8 of the string after {@link canonicalizeText}
|
|
487
|
+
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
488
|
+
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
489
|
+
*
|
|
490
|
+
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
491
|
+
* environment-independent primitives, and the text rule uses String.normalize +
|
|
492
|
+
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
493
|
+
* pattern as frame.ts); browser callers that need to recompute use their own
|
|
494
|
+
* SubtleCrypto digest over the identical canonical bytes.
|
|
495
|
+
*/
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Canonicalize free text per the v0.5 'text' rule. Idempotent.
|
|
499
|
+
*
|
|
500
|
+
* 1. Unicode NFC normalization.
|
|
501
|
+
* 2. CRLF / CR → LF.
|
|
502
|
+
* 3. Strip trailing spaces/tabs from every line.
|
|
503
|
+
* 4. Remove trailing blank lines.
|
|
504
|
+
*/
|
|
505
|
+
declare function canonicalizeText(input: string): string;
|
|
506
|
+
/**
|
|
507
|
+
* Compute the canonical bytes that a content hash is taken over, WITHOUT
|
|
508
|
+
* hashing — exposed so verifiers can debug a mismatch by inspecting the exact
|
|
509
|
+
* serialization both sides should agree on.
|
|
510
|
+
*/
|
|
511
|
+
declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Record<string, unknown> | string): string;
|
|
512
|
+
/**
|
|
513
|
+
* Compute a profile-bound content hash, formatted `sha256:<hex>` (matching the
|
|
514
|
+
* frame/bounds/context hash format used elsewhere in HAP).
|
|
515
|
+
*
|
|
516
|
+
* @param binding the profile's content_binding declaration
|
|
517
|
+
* @param content the record payload (jcs) or the resolved text field (text)
|
|
518
|
+
*/
|
|
519
|
+
declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
|
|
520
|
+
|
|
404
521
|
/**
|
|
405
522
|
* Frame Canonicalization for Agent Profiles
|
|
406
523
|
*
|
|
@@ -598,4 +715,4 @@ declare function listProfiles(): string[];
|
|
|
598
715
|
declare function getAllProfiles(): AgentProfile[];
|
|
599
716
|
declare function clearProfiles(): void;
|
|
600
717
|
|
|
601
|
-
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, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
718
|
+
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, contentCanonicalBytes, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
package/dist/index.js
CHANGED
|
@@ -34,11 +34,15 @@ __export(index_exports, {
|
|
|
34
34
|
canonicalBounds: () => canonicalBounds,
|
|
35
35
|
canonicalContext: () => canonicalContext,
|
|
36
36
|
canonicalFrame: () => canonicalFrame,
|
|
37
|
+
canonicalize: () => canonicalize,
|
|
38
|
+
canonicalizeText: () => canonicalizeText,
|
|
37
39
|
checkAttestationExpiry: () => checkAttestationExpiry,
|
|
38
40
|
clearProfiles: () => clearProfiles,
|
|
39
41
|
computeBoundsHash: () => computeBoundsHash,
|
|
42
|
+
computeContentHash: () => computeContentHash,
|
|
40
43
|
computeContextHash: () => computeContextHash,
|
|
41
44
|
computeFrameHash: () => computeFrameHash,
|
|
45
|
+
contentCanonicalBytes: () => contentCanonicalBytes,
|
|
42
46
|
decodeAttestationBlob: () => decodeAttestationBlob,
|
|
43
47
|
encodeAttestationBlob: () => encodeAttestationBlob,
|
|
44
48
|
frameHash: () => frameHash,
|
|
@@ -60,8 +64,60 @@ __export(index_exports, {
|
|
|
60
64
|
});
|
|
61
65
|
module.exports = __toCommonJS(index_exports);
|
|
62
66
|
|
|
63
|
-
// src/
|
|
67
|
+
// src/canonicalize.ts
|
|
68
|
+
function canonicalize(value) {
|
|
69
|
+
if (value === void 0) {
|
|
70
|
+
throw new Error("canonicalize: undefined is not serializable");
|
|
71
|
+
}
|
|
72
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
73
|
+
throw new Error(`canonicalize: ${value} is not a valid JSON number`);
|
|
74
|
+
}
|
|
75
|
+
if (value === null || typeof value !== "object") {
|
|
76
|
+
return JSON.stringify(value);
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
const items = value.map((el) => el === void 0 ? "null" : canonicalize(el));
|
|
80
|
+
return "[" + items.join(",") + "]";
|
|
81
|
+
}
|
|
82
|
+
const obj = value;
|
|
83
|
+
const parts = [];
|
|
84
|
+
for (const key of Object.keys(obj).sort()) {
|
|
85
|
+
const v = obj[key];
|
|
86
|
+
if (v === void 0) continue;
|
|
87
|
+
parts.push(JSON.stringify(key) + ":" + canonicalize(v));
|
|
88
|
+
}
|
|
89
|
+
return "{" + parts.join(",") + "}";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/content-binding.ts
|
|
64
93
|
var import_crypto = require("crypto");
|
|
94
|
+
function canonicalizeText(input) {
|
|
95
|
+
const nfc = input.normalize("NFC");
|
|
96
|
+
const lf = nfc.replace(/\r\n?/g, "\n");
|
|
97
|
+
const lines = lf.split("\n").map((line) => line.replace(/[ \t]+$/, ""));
|
|
98
|
+
return lines.join("\n").replace(/\n+$/, "");
|
|
99
|
+
}
|
|
100
|
+
function sha256Hex(bytes) {
|
|
101
|
+
return (0, import_crypto.createHash)("sha256").update(bytes, "utf8").digest("hex");
|
|
102
|
+
}
|
|
103
|
+
function contentCanonicalBytes(kind, content) {
|
|
104
|
+
if (kind === "jcs") {
|
|
105
|
+
if (typeof content === "string") {
|
|
106
|
+
throw new Error('content_binding kind="jcs" expects a record payload (object), got a string');
|
|
107
|
+
}
|
|
108
|
+
return canonicalize(content);
|
|
109
|
+
}
|
|
110
|
+
if (typeof content !== "string") {
|
|
111
|
+
throw new Error('content_binding kind="text" expects a string, got an object');
|
|
112
|
+
}
|
|
113
|
+
return canonicalizeText(content);
|
|
114
|
+
}
|
|
115
|
+
function computeContentHash(binding, content) {
|
|
116
|
+
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/frame.ts
|
|
120
|
+
var import_crypto2 = require("crypto");
|
|
65
121
|
function validateFrameParams(params, profile) {
|
|
66
122
|
const errors = [];
|
|
67
123
|
if (!profile.frameSchema) {
|
|
@@ -98,7 +154,7 @@ function canonicalFrame(params, profile) {
|
|
|
98
154
|
return lines.join("\n");
|
|
99
155
|
}
|
|
100
156
|
function frameHash(canonicalFrameString) {
|
|
101
|
-
const hash = (0,
|
|
157
|
+
const hash = (0, import_crypto2.createHash)("sha256").update(canonicalFrameString, "utf8").digest("hex");
|
|
102
158
|
return `sha256:${hash}`;
|
|
103
159
|
}
|
|
104
160
|
function computeFrameHash(params, profile) {
|
|
@@ -182,17 +238,17 @@ function canonicalContext(params, profile) {
|
|
|
182
238
|
}
|
|
183
239
|
function computeBoundsHash(params, profile) {
|
|
184
240
|
const canonical = canonicalBounds(params, profile);
|
|
185
|
-
const hash = (0,
|
|
241
|
+
const hash = (0, import_crypto2.createHash)("sha256").update(canonical, "utf8").digest("hex");
|
|
186
242
|
return `sha256:${hash}`;
|
|
187
243
|
}
|
|
188
244
|
function computeContextHash(params, profile) {
|
|
189
245
|
const canonical = canonicalContext(params, profile);
|
|
190
|
-
const hash = (0,
|
|
246
|
+
const hash = (0, import_crypto2.createHash)("sha256").update(canonical, "utf8").digest("hex");
|
|
191
247
|
return `sha256:${hash}`;
|
|
192
248
|
}
|
|
193
249
|
|
|
194
250
|
// src/attestation.ts
|
|
195
|
-
var
|
|
251
|
+
var import_crypto3 = require("crypto");
|
|
196
252
|
var ed = __toESM(require("@noble/ed25519"));
|
|
197
253
|
function decodeAttestationBlob(blob) {
|
|
198
254
|
try {
|
|
@@ -210,12 +266,12 @@ function encodeAttestationBlob(attestation) {
|
|
|
210
266
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
211
267
|
}
|
|
212
268
|
function attestationId(blob) {
|
|
213
|
-
const hash = (0,
|
|
269
|
+
const hash = (0, import_crypto3.createHash)("sha256").update(blob, "utf8").digest("hex");
|
|
214
270
|
return `sha256:${hash}`;
|
|
215
271
|
}
|
|
216
272
|
async function verifyAttestationSignature(attestation, publicKeyHex) {
|
|
217
273
|
try {
|
|
218
|
-
const payloadJson =
|
|
274
|
+
const payloadJson = canonicalize(attestation.payload);
|
|
219
275
|
const payloadBytes = new TextEncoder().encode(payloadJson);
|
|
220
276
|
const signatureBytes = Buffer.from(attestation.signature, "base64");
|
|
221
277
|
const publicKeyBytes = Buffer.from(publicKeyHex, "hex");
|
|
@@ -719,11 +775,15 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
719
775
|
canonicalBounds,
|
|
720
776
|
canonicalContext,
|
|
721
777
|
canonicalFrame,
|
|
778
|
+
canonicalize,
|
|
779
|
+
canonicalizeText,
|
|
722
780
|
checkAttestationExpiry,
|
|
723
781
|
clearProfiles,
|
|
724
782
|
computeBoundsHash,
|
|
783
|
+
computeContentHash,
|
|
725
784
|
computeContextHash,
|
|
726
785
|
computeFrameHash,
|
|
786
|
+
contentCanonicalBytes,
|
|
727
787
|
decodeAttestationBlob,
|
|
728
788
|
encodeAttestationBlob,
|
|
729
789
|
frameHash,
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/canonicalize.ts
|
|
2
|
+
function canonicalize(value) {
|
|
3
|
+
if (value === void 0) {
|
|
4
|
+
throw new Error("canonicalize: undefined is not serializable");
|
|
5
|
+
}
|
|
6
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
7
|
+
throw new Error(`canonicalize: ${value} is not a valid JSON number`);
|
|
8
|
+
}
|
|
9
|
+
if (value === null || typeof value !== "object") {
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
const items = value.map((el) => el === void 0 ? "null" : canonicalize(el));
|
|
14
|
+
return "[" + items.join(",") + "]";
|
|
15
|
+
}
|
|
16
|
+
const obj = value;
|
|
17
|
+
const parts = [];
|
|
18
|
+
for (const key of Object.keys(obj).sort()) {
|
|
19
|
+
const v = obj[key];
|
|
20
|
+
if (v === void 0) continue;
|
|
21
|
+
parts.push(JSON.stringify(key) + ":" + canonicalize(v));
|
|
22
|
+
}
|
|
23
|
+
return "{" + parts.join(",") + "}";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/content-binding.ts
|
|
2
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/frame.ts
|
|
54
|
+
import { createHash as createHash2 } from "crypto";
|
|
3
55
|
function validateFrameParams(params, profile) {
|
|
4
56
|
const errors = [];
|
|
5
57
|
if (!profile.frameSchema) {
|
|
@@ -36,7 +88,7 @@ function canonicalFrame(params, profile) {
|
|
|
36
88
|
return lines.join("\n");
|
|
37
89
|
}
|
|
38
90
|
function frameHash(canonicalFrameString) {
|
|
39
|
-
const hash =
|
|
91
|
+
const hash = createHash2("sha256").update(canonicalFrameString, "utf8").digest("hex");
|
|
40
92
|
return `sha256:${hash}`;
|
|
41
93
|
}
|
|
42
94
|
function computeFrameHash(params, profile) {
|
|
@@ -120,17 +172,17 @@ function canonicalContext(params, profile) {
|
|
|
120
172
|
}
|
|
121
173
|
function computeBoundsHash(params, profile) {
|
|
122
174
|
const canonical = canonicalBounds(params, profile);
|
|
123
|
-
const hash =
|
|
175
|
+
const hash = createHash2("sha256").update(canonical, "utf8").digest("hex");
|
|
124
176
|
return `sha256:${hash}`;
|
|
125
177
|
}
|
|
126
178
|
function computeContextHash(params, profile) {
|
|
127
179
|
const canonical = canonicalContext(params, profile);
|
|
128
|
-
const hash =
|
|
180
|
+
const hash = createHash2("sha256").update(canonical, "utf8").digest("hex");
|
|
129
181
|
return `sha256:${hash}`;
|
|
130
182
|
}
|
|
131
183
|
|
|
132
184
|
// src/attestation.ts
|
|
133
|
-
import { createHash as
|
|
185
|
+
import { createHash as createHash3 } from "crypto";
|
|
134
186
|
import * as ed from "@noble/ed25519";
|
|
135
187
|
function decodeAttestationBlob(blob) {
|
|
136
188
|
try {
|
|
@@ -148,12 +200,12 @@ function encodeAttestationBlob(attestation) {
|
|
|
148
200
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
149
201
|
}
|
|
150
202
|
function attestationId(blob) {
|
|
151
|
-
const hash =
|
|
203
|
+
const hash = createHash3("sha256").update(blob, "utf8").digest("hex");
|
|
152
204
|
return `sha256:${hash}`;
|
|
153
205
|
}
|
|
154
206
|
async function verifyAttestationSignature(attestation, publicKeyHex) {
|
|
155
207
|
try {
|
|
156
|
-
const payloadJson =
|
|
208
|
+
const payloadJson = canonicalize(attestation.payload);
|
|
157
209
|
const payloadBytes = new TextEncoder().encode(payloadJson);
|
|
158
210
|
const signatureBytes = Buffer.from(attestation.signature, "base64");
|
|
159
211
|
const publicKeyBytes = Buffer.from(publicKeyHex, "hex");
|
|
@@ -656,11 +708,15 @@ export {
|
|
|
656
708
|
canonicalBounds,
|
|
657
709
|
canonicalContext,
|
|
658
710
|
canonicalFrame,
|
|
711
|
+
canonicalize,
|
|
712
|
+
canonicalizeText,
|
|
659
713
|
checkAttestationExpiry,
|
|
660
714
|
clearProfiles,
|
|
661
715
|
computeBoundsHash,
|
|
716
|
+
computeContentHash,
|
|
662
717
|
computeContextHash,
|
|
663
718
|
computeFrameHash,
|
|
719
|
+
contentCanonicalBytes,
|
|
664
720
|
decodeAttestationBlob,
|
|
665
721
|
encodeAttestationBlob,
|
|
666
722
|
frameHash,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanagencyp/hap-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,22 +21,23 @@
|
|
|
21
21
|
"dist",
|
|
22
22
|
"src"
|
|
23
23
|
],
|
|
24
|
-
"dependencies": {
|
|
25
|
-
"@noble/ed25519": "^2.1.0"
|
|
26
|
-
},
|
|
27
|
-
"devDependencies": {
|
|
28
|
-
"@types/node": "^20.10.0",
|
|
29
|
-
"tsup": "^8.0.0",
|
|
30
|
-
"typescript": "^5.3.0",
|
|
31
|
-
"vitest": "^1.0.0"
|
|
32
|
-
},
|
|
33
24
|
"scripts": {
|
|
34
25
|
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
35
26
|
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
36
27
|
"test": "vitest run",
|
|
37
28
|
"test:watch": "vitest",
|
|
29
|
+
"prepublishOnly": "pnpm build",
|
|
38
30
|
"release:patch": "pnpm version patch && git push --follow-tags",
|
|
39
31
|
"release:minor": "pnpm version minor && git push --follow-tags",
|
|
40
32
|
"release:major": "pnpm version major && git push --follow-tags"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@noble/ed25519": "^2.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^20.10.0",
|
|
39
|
+
"tsup": "^8.0.0",
|
|
40
|
+
"typescript": "^5.3.0",
|
|
41
|
+
"vitest": "^1.0.0"
|
|
41
42
|
}
|
|
42
|
-
}
|
|
43
|
+
}
|
package/src/attestation.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { createHash } from 'crypto';
|
|
8
8
|
import * as ed from '@noble/ed25519';
|
|
9
9
|
import type { Attestation, AttestationPayload } from './types';
|
|
10
|
+
import { canonicalize } from './canonicalize';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Decodes a base64url-encoded attestation blob.
|
|
@@ -50,7 +51,9 @@ export async function verifyAttestationSignature(
|
|
|
50
51
|
publicKeyHex: string
|
|
51
52
|
): Promise<void> {
|
|
52
53
|
try {
|
|
53
|
-
|
|
54
|
+
// JCS-canonical bytes (RFC 8785) — must match the canonicalization the
|
|
55
|
+
// signer used. The AS signs the same canonical form (see keys.ts).
|
|
56
|
+
const payloadJson = canonicalize(attestation.payload);
|
|
54
57
|
const payloadBytes = new TextEncoder().encode(payloadJson);
|
|
55
58
|
const signatureBytes = Buffer.from(attestation.signature, 'base64');
|
|
56
59
|
const publicKeyBytes = Buffer.from(publicKeyHex, 'hex');
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JCS — JSON Canonicalization (RFC 8785) for signing.
|
|
3
|
+
*
|
|
4
|
+
* Both attestation and receipt signatures are computed over a canonical byte
|
|
5
|
+
* serialization of the payload, so that two independent implementations (an AS,
|
|
6
|
+
* a gateway, an external verifier — in any language) agree on exactly which
|
|
7
|
+
* bytes were signed regardless of how they happened to construct the object.
|
|
8
|
+
*
|
|
9
|
+
* `JSON.stringify` is NOT sufficient: it preserves key *insertion* order, so the
|
|
10
|
+
* same logical payload built with keys in a different order serializes to
|
|
11
|
+
* different bytes and the signature fails to verify. Canonicalization removes
|
|
12
|
+
* that dependency by sorting keys.
|
|
13
|
+
*
|
|
14
|
+
* This is RFC 8785-compatible and matches the v0.5 spec's normative signing
|
|
15
|
+
* canonicalization (core.md "Signing Canonicalization"):
|
|
16
|
+
* 1. UTF-8.
|
|
17
|
+
* 2. Object keys sorted (RFC 8785 sorts by UTF-16 code units — exactly what
|
|
18
|
+
* JavaScript's default String comparison does, so `Object.keys().sort()` is
|
|
19
|
+
* correct here).
|
|
20
|
+
* 3. No insignificant whitespace.
|
|
21
|
+
* 4. Numbers in the shortest round-trippable form — the ECMAScript
|
|
22
|
+
* Number-to-String algorithm, which `JSON.stringify(number)` produces and
|
|
23
|
+
* RFC 8785 adopts verbatim.
|
|
24
|
+
* 5. Strings escaped per RFC 8259; non-ASCII passed through as UTF-8 (this is
|
|
25
|
+
* `JSON.stringify`'s behaviour for strings).
|
|
26
|
+
* 6. Array order preserved.
|
|
27
|
+
*
|
|
28
|
+
* Because it relies only on `JSON.stringify` for leaves plus `Object.keys`,
|
|
29
|
+
* `Array`, and `String` sort — all of which are environment-independent in
|
|
30
|
+
* JavaScript — Node and the browser produce byte-identical output. The
|
|
31
|
+
* conformance test pins a (payload → canonical bytes) vector that any other
|
|
32
|
+
* implementation can check against.
|
|
33
|
+
*/
|
|
34
|
+
export function canonicalize(value: unknown): string {
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
throw new Error('canonicalize: undefined is not serializable');
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
39
|
+
throw new Error(`canonicalize: ${value} is not a valid JSON number`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Primitives: string, finite number, boolean, null — JSON.stringify is already
|
|
43
|
+
// canonical (RFC 8259 string escaping, ECMAScript number formatting).
|
|
44
|
+
if (value === null || typeof value !== 'object') {
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
// Array order is preserved; undefined elements become null (matching
|
|
50
|
+
// JSON.stringify), which keeps array length stable across serializers.
|
|
51
|
+
const items = value.map((el) => (el === undefined ? 'null' : canonicalize(el)));
|
|
52
|
+
return '[' + items.join(',') + ']';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Plain object: sort keys, omit undefined-valued properties (as JSON.stringify
|
|
56
|
+
// does), recurse on values.
|
|
57
|
+
const obj = value as Record<string, unknown>;
|
|
58
|
+
const parts: string[] = [];
|
|
59
|
+
for (const key of Object.keys(obj).sort()) {
|
|
60
|
+
const v = obj[key];
|
|
61
|
+
if (v === undefined) continue;
|
|
62
|
+
parts.push(JSON.stringify(key) + ':' + canonicalize(v));
|
|
63
|
+
}
|
|
64
|
+
return '{' + parts.join(',') + '}';
|
|
65
|
+
}
|
|
@@ -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/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -166,6 +166,33 @@ export interface ProfileBoundsField {
|
|
|
166
166
|
enum?: string[];
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
171
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
172
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
173
|
+
*
|
|
174
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
175
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
176
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
177
|
+
*
|
|
178
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
179
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
180
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
181
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
182
|
+
*/
|
|
183
|
+
export interface ContentBinding {
|
|
184
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
185
|
+
version: string;
|
|
186
|
+
/**
|
|
187
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
188
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
189
|
+
* canonicalizeText), auto-detected content field.
|
|
190
|
+
*/
|
|
191
|
+
kind: 'jcs' | 'text';
|
|
192
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
193
|
+
pre_footer?: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
169
196
|
/**
|
|
170
197
|
* Context field definition within a v0.4 profile.
|
|
171
198
|
*/
|
|
@@ -295,6 +322,14 @@ export interface AgentProfile {
|
|
|
295
322
|
ttl: { default: number; max: number };
|
|
296
323
|
retention_minimum: number;
|
|
297
324
|
|
|
325
|
+
/**
|
|
326
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
327
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
328
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
329
|
+
* → no content hash. See {@link ContentBinding}.
|
|
330
|
+
*/
|
|
331
|
+
content_binding?: ContentBinding;
|
|
332
|
+
|
|
298
333
|
/**
|
|
299
334
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
300
335
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|