@humanagencyp/hap-core 0.7.0 → 0.8.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 +124 -6
- package/dist/index.d.ts +124 -6
- package/dist/index.js +110 -0
- package/dist/index.mjs +105 -0
- package/package.json +1 -1
- package/src/content-binding.ts +168 -0
- package/src/gatekeeper.ts +28 -0
- package/src/types.ts +68 -5
package/dist/index.d.mts
CHANGED
|
@@ -113,6 +113,27 @@ interface Attestation {
|
|
|
113
113
|
interface FieldConstraint {
|
|
114
114
|
type: 'number' | 'string';
|
|
115
115
|
enforceable: Array<'max' | 'enum' | 'subset'>;
|
|
116
|
+
/**
|
|
117
|
+
* Action types for which this dimension MUST be present in the execution
|
|
118
|
+
* context. When the grant constrains this field and the action is one of
|
|
119
|
+
* these, an absent value is a denial rather than a skipped check.
|
|
120
|
+
*
|
|
121
|
+
* Without it, a constraint is only enforced against calls that happen to
|
|
122
|
+
* expose the value. Gmail's `send_message` is the live case: pass `raw` and
|
|
123
|
+
* `to` is never populated, so `allowed_recipients` had nothing to compare and
|
|
124
|
+
* the check passed — a send to an unverified recipient looked authorized.
|
|
125
|
+
* `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
|
|
126
|
+
* see to whom.
|
|
127
|
+
*
|
|
128
|
+
* Keyed on `action_type` because "does this action engage recipients?" is a
|
|
129
|
+
* property of the action, not of the field name — deleting a draft has no
|
|
130
|
+
* recipients and must keep passing, while sending one must not. This keeps
|
|
131
|
+
* the engine profile-agnostic: it compares declared strings and knows nothing
|
|
132
|
+
* about email, calendars or payments.
|
|
133
|
+
*
|
|
134
|
+
* Omitted → previous behaviour: absence skips the check.
|
|
135
|
+
*/
|
|
136
|
+
requiredFor?: string[];
|
|
116
137
|
}
|
|
117
138
|
/**
|
|
118
139
|
* Frame field definition within a profile.
|
|
@@ -246,22 +267,63 @@ interface ProfileBoundsField {
|
|
|
246
267
|
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
247
268
|
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
248
269
|
*
|
|
249
|
-
*
|
|
250
|
-
* canonicalize. It does NOT name the tool field: that is tool-specific
|
|
251
|
-
* resolved at runtime (the same content-field resolver the footer uses
|
|
252
|
-
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
270
|
+
* At `version:"1"` the profile declares only the *policy* — whether to bind and
|
|
271
|
+
* how to canonicalize. It does NOT name the tool field: that is tool-specific
|
|
272
|
+
* and is resolved at runtime (the same content-field resolver the footer uses
|
|
273
|
+
* for `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
274
|
+
*
|
|
275
|
+
* At `version:"2"` the profile additionally declares WHICH fields are bound (see
|
|
276
|
+
* {@link ContentBinding.fields}). Neither v1 mode is the general case: `text`
|
|
277
|
+
* binds one field and leaves everything beside it unbound, while `jcs` over the
|
|
278
|
+
* whole payload is checkable only by a party that already knows the whole
|
|
279
|
+
* payload — an email recipient holds the body, the subject and their own
|
|
280
|
+
* address, but not `bcc`. The general case is a declared subset, chosen so the
|
|
281
|
+
* intended verifier can reproduce it.
|
|
253
282
|
*/
|
|
254
283
|
interface ContentBinding {
|
|
255
284
|
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
256
285
|
version: string;
|
|
257
286
|
/**
|
|
258
|
-
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload
|
|
287
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload
|
|
288
|
+
* (v1) or over the object built from {@link fields} (v2).
|
|
259
289
|
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
260
290
|
* canonicalizeText), auto-detected content field.
|
|
261
291
|
*/
|
|
262
292
|
kind: 'jcs' | 'text';
|
|
263
293
|
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
264
294
|
pre_footer?: boolean;
|
|
295
|
+
/**
|
|
296
|
+
* v2 only — the tool-argument keys this binding covers, and the complete
|
|
297
|
+
* statement of what a verifier must reproduce. The Gatekeeper builds an
|
|
298
|
+
* object from exactly these keys and canonicalizes it by `kind`.
|
|
299
|
+
*
|
|
300
|
+
* Adding or removing an entry changes every resulting hash, so it is a
|
|
301
|
+
* BREAKING profile change requiring a version bump, never a silent edit.
|
|
302
|
+
*
|
|
303
|
+
* Choose the subset by one rule: bind everything the approving human is
|
|
304
|
+
* shown, and nothing the intended verifier cannot see.
|
|
305
|
+
*/
|
|
306
|
+
fields?: string[];
|
|
307
|
+
/**
|
|
308
|
+
* v2 only — the subset of {@link fields} whose absence is a fault rather than
|
|
309
|
+
* a fact. An absent OPTIONAL field is omitted from the hashed object (an
|
|
310
|
+
* email legitimately has no `cc`); an absent REQUIRED field means the call is
|
|
311
|
+
* not the call this profile thinks it is, and MUST refuse rather than hash a
|
|
312
|
+
* partial object that reads exactly like a complete one.
|
|
313
|
+
*
|
|
314
|
+
* MUST be a subset of `fields`. Absent → every field is optional, and only a
|
|
315
|
+
* wholly empty selection refuses.
|
|
316
|
+
*/
|
|
317
|
+
required_fields?: string[];
|
|
318
|
+
/**
|
|
319
|
+
* v2 only — the action types this binding covers, using the same vocabulary
|
|
320
|
+
* as {@link ProfileBoundsField.appliesTo}. A profile gates more than its
|
|
321
|
+
* content-bearing calls: `email` also gates deletes, which carry an id and no
|
|
322
|
+
* content, and applying a field binding to those would refuse them.
|
|
323
|
+
*
|
|
324
|
+
* Absent → the binding applies to every gated action under the profile.
|
|
325
|
+
*/
|
|
326
|
+
appliesTo?: string[];
|
|
265
327
|
}
|
|
266
328
|
/**
|
|
267
329
|
* Context field definition within a v0.4 profile.
|
|
@@ -575,6 +637,12 @@ declare function canonicalize(value: unknown): string;
|
|
|
575
637
|
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
576
638
|
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
577
639
|
*
|
|
640
|
+
* At `version:"2"` the profile also declares WHICH tool arguments are bound
|
|
641
|
+
* ({@link selectBoundFields}), so a receipt can commit to an email's recipients
|
|
642
|
+
* and not only its prose — while still omitting what the intended verifier
|
|
643
|
+
* cannot see. Every string entering the hashed object is canonicalized by the
|
|
644
|
+
* same `text` rule, so a delivered copy with CRLF endings still reproduces it.
|
|
645
|
+
*
|
|
578
646
|
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
579
647
|
* environment-independent primitives, and the text rule uses String.normalize +
|
|
580
648
|
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
@@ -605,6 +673,56 @@ declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Re
|
|
|
605
673
|
* @param content the record payload (jcs) or the resolved text field (text)
|
|
606
674
|
*/
|
|
607
675
|
declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
|
|
676
|
+
/** Why a field binding refused. Every case is fail-closed by design. */
|
|
677
|
+
type ContentBindingErrorCode =
|
|
678
|
+
/** The profile declares version:"2" with no usable `fields` list. */
|
|
679
|
+
'NO_FIELDS_DECLARED'
|
|
680
|
+
/** `required_fields` names something absent from `fields`. */
|
|
681
|
+
| 'REQUIRED_FIELD_NOT_DECLARED'
|
|
682
|
+
/** A required field was absent or empty at call time. */
|
|
683
|
+
| 'MISSING_REQUIRED_FIELD'
|
|
684
|
+
/** No declared field carried a value — the hash would commit to nothing. */
|
|
685
|
+
| 'EMPTY_BINDING';
|
|
686
|
+
/**
|
|
687
|
+
* A field binding could not be computed. ALWAYS a refusal, never a downgrade:
|
|
688
|
+
* the alternative is a receipt that verifies while proving less than it appears
|
|
689
|
+
* to, which is the failure content binding exists to prevent.
|
|
690
|
+
*/
|
|
691
|
+
declare class ContentBindingError extends Error {
|
|
692
|
+
readonly code: ContentBindingErrorCode;
|
|
693
|
+
/** The offending field, when the code names one. */
|
|
694
|
+
readonly field?: string;
|
|
695
|
+
constructor(code: ContentBindingErrorCode, message: string, field?: string);
|
|
696
|
+
}
|
|
697
|
+
/** True when this binding selects a declared subset (v2) rather than v1's implicit scope. */
|
|
698
|
+
declare function isFieldBinding(binding: ContentBinding): boolean;
|
|
699
|
+
/**
|
|
700
|
+
* Whether a field binding covers this action type, per the profile's `appliesTo`.
|
|
701
|
+
*
|
|
702
|
+
* Read STRICTLY — an undeclared action type is NOT covered. This differs from
|
|
703
|
+
* how bounds read the same key (there, an unknown action type enforces the
|
|
704
|
+
* bound, because an extra limit is safe). Here the two directions are not
|
|
705
|
+
* symmetric: applying a field binding to a call that carries no content refuses
|
|
706
|
+
* a legitimate action, so an unknown action type must fall outside rather than
|
|
707
|
+
* inside. Callers are expected to warn on the undeclared case — it is a
|
|
708
|
+
* manifest bug either way.
|
|
709
|
+
*/
|
|
710
|
+
declare function bindingAppliesTo(binding: ContentBinding, actionType: string | undefined): boolean;
|
|
711
|
+
/**
|
|
712
|
+
* Build the object a v2 binding hashes: exactly the declared `fields` that
|
|
713
|
+
* carry a value, canonicalized. Exported so a verifier can construct the same
|
|
714
|
+
* object from what they hold and see it before hashing.
|
|
715
|
+
*
|
|
716
|
+
* Throws {@link ContentBindingError} rather than returning a partial result —
|
|
717
|
+
* see that class for why refusing is the only safe outcome.
|
|
718
|
+
*/
|
|
719
|
+
declare function selectBoundFields(binding: ContentBinding, args: Record<string, unknown>): Record<string, unknown>;
|
|
720
|
+
/**
|
|
721
|
+
* Compute a v2 field-binding hash from raw tool arguments: select the declared
|
|
722
|
+
* subset, then hash it by the declared `kind`. Convenience over
|
|
723
|
+
* {@link selectBoundFields} + {@link computeContentHash} for the common path.
|
|
724
|
+
*/
|
|
725
|
+
declare function computeFieldsContentHash(binding: ContentBinding, args: Record<string, unknown>): string;
|
|
608
726
|
|
|
609
727
|
/**
|
|
610
728
|
* Intent hashing + intent-disclosure binding (HAP v0.5).
|
|
@@ -896,4 +1014,4 @@ declare function listProfiles(): string[];
|
|
|
896
1014
|
declare function getAllProfiles(): AgentProfile[];
|
|
897
1015
|
declare function clearProfiles(): void;
|
|
898
1016
|
|
|
899
|
-
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, type Subject, type SubjectValidation, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, deriveIdentityLine, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
1017
|
+
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, ContentBindingError, type ContentBindingErrorCode, 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, type Subject, type SubjectValidation, attestationId, bindingAppliesTo, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFieldsContentHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, deriveIdentityLine, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isFieldBinding, isV4Attestation, listProfiles, registerProfile, selectBoundFields, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
package/dist/index.d.ts
CHANGED
|
@@ -113,6 +113,27 @@ interface Attestation {
|
|
|
113
113
|
interface FieldConstraint {
|
|
114
114
|
type: 'number' | 'string';
|
|
115
115
|
enforceable: Array<'max' | 'enum' | 'subset'>;
|
|
116
|
+
/**
|
|
117
|
+
* Action types for which this dimension MUST be present in the execution
|
|
118
|
+
* context. When the grant constrains this field and the action is one of
|
|
119
|
+
* these, an absent value is a denial rather than a skipped check.
|
|
120
|
+
*
|
|
121
|
+
* Without it, a constraint is only enforced against calls that happen to
|
|
122
|
+
* expose the value. Gmail's `send_message` is the live case: pass `raw` and
|
|
123
|
+
* `to` is never populated, so `allowed_recipients` had nothing to compare and
|
|
124
|
+
* the check passed — a send to an unverified recipient looked authorized.
|
|
125
|
+
* `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
|
|
126
|
+
* see to whom.
|
|
127
|
+
*
|
|
128
|
+
* Keyed on `action_type` because "does this action engage recipients?" is a
|
|
129
|
+
* property of the action, not of the field name — deleting a draft has no
|
|
130
|
+
* recipients and must keep passing, while sending one must not. This keeps
|
|
131
|
+
* the engine profile-agnostic: it compares declared strings and knows nothing
|
|
132
|
+
* about email, calendars or payments.
|
|
133
|
+
*
|
|
134
|
+
* Omitted → previous behaviour: absence skips the check.
|
|
135
|
+
*/
|
|
136
|
+
requiredFor?: string[];
|
|
116
137
|
}
|
|
117
138
|
/**
|
|
118
139
|
* Frame field definition within a profile.
|
|
@@ -246,22 +267,63 @@ interface ProfileBoundsField {
|
|
|
246
267
|
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
247
268
|
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
248
269
|
*
|
|
249
|
-
*
|
|
250
|
-
* canonicalize. It does NOT name the tool field: that is tool-specific
|
|
251
|
-
* resolved at runtime (the same content-field resolver the footer uses
|
|
252
|
-
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
270
|
+
* At `version:"1"` the profile declares only the *policy* — whether to bind and
|
|
271
|
+
* how to canonicalize. It does NOT name the tool field: that is tool-specific
|
|
272
|
+
* and is resolved at runtime (the same content-field resolver the footer uses
|
|
273
|
+
* for `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
274
|
+
*
|
|
275
|
+
* At `version:"2"` the profile additionally declares WHICH fields are bound (see
|
|
276
|
+
* {@link ContentBinding.fields}). Neither v1 mode is the general case: `text`
|
|
277
|
+
* binds one field and leaves everything beside it unbound, while `jcs` over the
|
|
278
|
+
* whole payload is checkable only by a party that already knows the whole
|
|
279
|
+
* payload — an email recipient holds the body, the subject and their own
|
|
280
|
+
* address, but not `bcc`. The general case is a declared subset, chosen so the
|
|
281
|
+
* intended verifier can reproduce it.
|
|
253
282
|
*/
|
|
254
283
|
interface ContentBinding {
|
|
255
284
|
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
256
285
|
version: string;
|
|
257
286
|
/**
|
|
258
|
-
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload
|
|
287
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload
|
|
288
|
+
* (v1) or over the object built from {@link fields} (v2).
|
|
259
289
|
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
260
290
|
* canonicalizeText), auto-detected content field.
|
|
261
291
|
*/
|
|
262
292
|
kind: 'jcs' | 'text';
|
|
263
293
|
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
264
294
|
pre_footer?: boolean;
|
|
295
|
+
/**
|
|
296
|
+
* v2 only — the tool-argument keys this binding covers, and the complete
|
|
297
|
+
* statement of what a verifier must reproduce. The Gatekeeper builds an
|
|
298
|
+
* object from exactly these keys and canonicalizes it by `kind`.
|
|
299
|
+
*
|
|
300
|
+
* Adding or removing an entry changes every resulting hash, so it is a
|
|
301
|
+
* BREAKING profile change requiring a version bump, never a silent edit.
|
|
302
|
+
*
|
|
303
|
+
* Choose the subset by one rule: bind everything the approving human is
|
|
304
|
+
* shown, and nothing the intended verifier cannot see.
|
|
305
|
+
*/
|
|
306
|
+
fields?: string[];
|
|
307
|
+
/**
|
|
308
|
+
* v2 only — the subset of {@link fields} whose absence is a fault rather than
|
|
309
|
+
* a fact. An absent OPTIONAL field is omitted from the hashed object (an
|
|
310
|
+
* email legitimately has no `cc`); an absent REQUIRED field means the call is
|
|
311
|
+
* not the call this profile thinks it is, and MUST refuse rather than hash a
|
|
312
|
+
* partial object that reads exactly like a complete one.
|
|
313
|
+
*
|
|
314
|
+
* MUST be a subset of `fields`. Absent → every field is optional, and only a
|
|
315
|
+
* wholly empty selection refuses.
|
|
316
|
+
*/
|
|
317
|
+
required_fields?: string[];
|
|
318
|
+
/**
|
|
319
|
+
* v2 only — the action types this binding covers, using the same vocabulary
|
|
320
|
+
* as {@link ProfileBoundsField.appliesTo}. A profile gates more than its
|
|
321
|
+
* content-bearing calls: `email` also gates deletes, which carry an id and no
|
|
322
|
+
* content, and applying a field binding to those would refuse them.
|
|
323
|
+
*
|
|
324
|
+
* Absent → the binding applies to every gated action under the profile.
|
|
325
|
+
*/
|
|
326
|
+
appliesTo?: string[];
|
|
265
327
|
}
|
|
266
328
|
/**
|
|
267
329
|
* Context field definition within a v0.4 profile.
|
|
@@ -575,6 +637,12 @@ declare function canonicalize(value: unknown): string;
|
|
|
575
637
|
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
576
638
|
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
577
639
|
*
|
|
640
|
+
* At `version:"2"` the profile also declares WHICH tool arguments are bound
|
|
641
|
+
* ({@link selectBoundFields}), so a receipt can commit to an email's recipients
|
|
642
|
+
* and not only its prose — while still omitting what the intended verifier
|
|
643
|
+
* cannot see. Every string entering the hashed object is canonicalized by the
|
|
644
|
+
* same `text` rule, so a delivered copy with CRLF endings still reproduces it.
|
|
645
|
+
*
|
|
578
646
|
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
579
647
|
* environment-independent primitives, and the text rule uses String.normalize +
|
|
580
648
|
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
@@ -605,6 +673,56 @@ declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Re
|
|
|
605
673
|
* @param content the record payload (jcs) or the resolved text field (text)
|
|
606
674
|
*/
|
|
607
675
|
declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
|
|
676
|
+
/** Why a field binding refused. Every case is fail-closed by design. */
|
|
677
|
+
type ContentBindingErrorCode =
|
|
678
|
+
/** The profile declares version:"2" with no usable `fields` list. */
|
|
679
|
+
'NO_FIELDS_DECLARED'
|
|
680
|
+
/** `required_fields` names something absent from `fields`. */
|
|
681
|
+
| 'REQUIRED_FIELD_NOT_DECLARED'
|
|
682
|
+
/** A required field was absent or empty at call time. */
|
|
683
|
+
| 'MISSING_REQUIRED_FIELD'
|
|
684
|
+
/** No declared field carried a value — the hash would commit to nothing. */
|
|
685
|
+
| 'EMPTY_BINDING';
|
|
686
|
+
/**
|
|
687
|
+
* A field binding could not be computed. ALWAYS a refusal, never a downgrade:
|
|
688
|
+
* the alternative is a receipt that verifies while proving less than it appears
|
|
689
|
+
* to, which is the failure content binding exists to prevent.
|
|
690
|
+
*/
|
|
691
|
+
declare class ContentBindingError extends Error {
|
|
692
|
+
readonly code: ContentBindingErrorCode;
|
|
693
|
+
/** The offending field, when the code names one. */
|
|
694
|
+
readonly field?: string;
|
|
695
|
+
constructor(code: ContentBindingErrorCode, message: string, field?: string);
|
|
696
|
+
}
|
|
697
|
+
/** True when this binding selects a declared subset (v2) rather than v1's implicit scope. */
|
|
698
|
+
declare function isFieldBinding(binding: ContentBinding): boolean;
|
|
699
|
+
/**
|
|
700
|
+
* Whether a field binding covers this action type, per the profile's `appliesTo`.
|
|
701
|
+
*
|
|
702
|
+
* Read STRICTLY — an undeclared action type is NOT covered. This differs from
|
|
703
|
+
* how bounds read the same key (there, an unknown action type enforces the
|
|
704
|
+
* bound, because an extra limit is safe). Here the two directions are not
|
|
705
|
+
* symmetric: applying a field binding to a call that carries no content refuses
|
|
706
|
+
* a legitimate action, so an unknown action type must fall outside rather than
|
|
707
|
+
* inside. Callers are expected to warn on the undeclared case — it is a
|
|
708
|
+
* manifest bug either way.
|
|
709
|
+
*/
|
|
710
|
+
declare function bindingAppliesTo(binding: ContentBinding, actionType: string | undefined): boolean;
|
|
711
|
+
/**
|
|
712
|
+
* Build the object a v2 binding hashes: exactly the declared `fields` that
|
|
713
|
+
* carry a value, canonicalized. Exported so a verifier can construct the same
|
|
714
|
+
* object from what they hold and see it before hashing.
|
|
715
|
+
*
|
|
716
|
+
* Throws {@link ContentBindingError} rather than returning a partial result —
|
|
717
|
+
* see that class for why refusing is the only safe outcome.
|
|
718
|
+
*/
|
|
719
|
+
declare function selectBoundFields(binding: ContentBinding, args: Record<string, unknown>): Record<string, unknown>;
|
|
720
|
+
/**
|
|
721
|
+
* Compute a v2 field-binding hash from raw tool arguments: select the declared
|
|
722
|
+
* subset, then hash it by the declared `kind`. Convenience over
|
|
723
|
+
* {@link selectBoundFields} + {@link computeContentHash} for the common path.
|
|
724
|
+
*/
|
|
725
|
+
declare function computeFieldsContentHash(binding: ContentBinding, args: Record<string, unknown>): string;
|
|
608
726
|
|
|
609
727
|
/**
|
|
610
728
|
* Intent hashing + intent-disclosure binding (HAP v0.5).
|
|
@@ -896,4 +1014,4 @@ declare function listProfiles(): string[];
|
|
|
896
1014
|
declare function getAllProfiles(): AgentProfile[];
|
|
897
1015
|
declare function clearProfiles(): void;
|
|
898
1016
|
|
|
899
|
-
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, type Subject, type SubjectValidation, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, deriveIdentityLine, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
1017
|
+
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, ContentBindingError, type ContentBindingErrorCode, 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, type Subject, type SubjectValidation, attestationId, bindingAppliesTo, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFieldsContentHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, deriveIdentityLine, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isFieldBinding, isV4Attestation, listProfiles, registerProfile, selectBoundFields, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
package/dist/index.js
CHANGED
|
@@ -30,7 +30,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
ContentBindingError: () => ContentBindingError,
|
|
33
34
|
attestationId: () => attestationId,
|
|
35
|
+
bindingAppliesTo: () => bindingAppliesTo,
|
|
34
36
|
canonicalBounds: () => canonicalBounds,
|
|
35
37
|
canonicalContext: () => canonicalContext,
|
|
36
38
|
canonicalFrame: () => canonicalFrame,
|
|
@@ -41,6 +43,7 @@ __export(index_exports, {
|
|
|
41
43
|
computeBoundsHash: () => computeBoundsHash,
|
|
42
44
|
computeContentHash: () => computeContentHash,
|
|
43
45
|
computeContextHash: () => computeContextHash,
|
|
46
|
+
computeFieldsContentHash: () => computeFieldsContentHash,
|
|
44
47
|
computeFrameHash: () => computeFrameHash,
|
|
45
48
|
computeIntentDisclosureHash: () => computeIntentDisclosureHash,
|
|
46
49
|
computeIntentHash: () => computeIntentHash,
|
|
@@ -52,9 +55,11 @@ __export(index_exports, {
|
|
|
52
55
|
getAllProfiles: () => getAllProfiles,
|
|
53
56
|
getProfile: () => getProfile,
|
|
54
57
|
intentDisclosureCanonicalBytes: () => intentDisclosureCanonicalBytes,
|
|
58
|
+
isFieldBinding: () => isFieldBinding,
|
|
55
59
|
isV4Attestation: () => isV4Attestation,
|
|
56
60
|
listProfiles: () => listProfiles,
|
|
57
61
|
registerProfile: () => registerProfile,
|
|
62
|
+
selectBoundFields: () => selectBoundFields,
|
|
58
63
|
validateBoundsParams: () => validateBoundsParams,
|
|
59
64
|
validateContextParams: () => validateContextParams,
|
|
60
65
|
validateFrameParams: () => validateFrameParams,
|
|
@@ -120,6 +125,89 @@ function contentCanonicalBytes(kind, content) {
|
|
|
120
125
|
function computeContentHash(binding, content) {
|
|
121
126
|
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
122
127
|
}
|
|
128
|
+
var ContentBindingError = class extends Error {
|
|
129
|
+
code;
|
|
130
|
+
/** The offending field, when the code names one. */
|
|
131
|
+
field;
|
|
132
|
+
constructor(code, message, field) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.name = "ContentBindingError";
|
|
135
|
+
this.code = code;
|
|
136
|
+
this.field = field;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
function isFieldBinding(binding) {
|
|
140
|
+
return binding.version === "2" && Array.isArray(binding.fields) && binding.fields.length > 0;
|
|
141
|
+
}
|
|
142
|
+
function bindingAppliesTo(binding, actionType) {
|
|
143
|
+
if (!binding.appliesTo) return true;
|
|
144
|
+
return actionType !== void 0 && binding.appliesTo.includes(actionType);
|
|
145
|
+
}
|
|
146
|
+
function canonicalizeValue(value) {
|
|
147
|
+
if (value === null || value === void 0) return void 0;
|
|
148
|
+
if (typeof value === "string") {
|
|
149
|
+
const text = canonicalizeText(value);
|
|
150
|
+
return text === "" ? void 0 : text;
|
|
151
|
+
}
|
|
152
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
const items = value.map(canonicalizeValue).filter((v) => v !== void 0);
|
|
155
|
+
return items.length > 0 ? items : void 0;
|
|
156
|
+
}
|
|
157
|
+
if (typeof value === "object") {
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const [key, v] of Object.entries(value)) {
|
|
160
|
+
const c = canonicalizeValue(v);
|
|
161
|
+
if (c !== void 0) out[key] = c;
|
|
162
|
+
}
|
|
163
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
164
|
+
}
|
|
165
|
+
return void 0;
|
|
166
|
+
}
|
|
167
|
+
function selectBoundFields(binding, args) {
|
|
168
|
+
const fields = binding.fields;
|
|
169
|
+
if (!fields || fields.length === 0) {
|
|
170
|
+
throw new ContentBindingError(
|
|
171
|
+
"NO_FIELDS_DECLARED",
|
|
172
|
+
'content_binding version "2" requires a non-empty `fields` list.'
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const required = new Set(binding.required_fields ?? []);
|
|
176
|
+
for (const field of required) {
|
|
177
|
+
if (!fields.includes(field)) {
|
|
178
|
+
throw new ContentBindingError(
|
|
179
|
+
"REQUIRED_FIELD_NOT_DECLARED",
|
|
180
|
+
`content_binding requires "${field}" but does not bind it \u2014 required_fields must be a subset of fields.`,
|
|
181
|
+
field
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const bound = {};
|
|
186
|
+
for (const field of fields) {
|
|
187
|
+
const value = canonicalizeValue(args[field]);
|
|
188
|
+
if (value === void 0) {
|
|
189
|
+
if (required.has(field)) {
|
|
190
|
+
throw new ContentBindingError(
|
|
191
|
+
"MISSING_REQUIRED_FIELD",
|
|
192
|
+
`content_binding requires "${field}", which is absent or empty in this call. Refusing rather than hashing a partial object.`,
|
|
193
|
+
field
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
bound[field] = value;
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(bound).length === 0) {
|
|
201
|
+
throw new ContentBindingError(
|
|
202
|
+
"EMPTY_BINDING",
|
|
203
|
+
`No declared field (${fields.join(", ")}) carried a value \u2014 the hash would commit to nothing.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return bound;
|
|
207
|
+
}
|
|
208
|
+
function computeFieldsContentHash(binding, args) {
|
|
209
|
+
return computeContentHash(binding, selectBoundFields(binding, args));
|
|
210
|
+
}
|
|
123
211
|
|
|
124
212
|
// src/intent-disclosure.ts
|
|
125
213
|
var import_crypto2 = require("crypto");
|
|
@@ -757,6 +845,23 @@ function checkContextConstraints(context, execution, profile) {
|
|
|
757
845
|
if (!profile.contextSchema) return errors;
|
|
758
846
|
for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
|
|
759
847
|
if (!fieldDef.constraint) continue;
|
|
848
|
+
const requiredFor = fieldDef.constraint.requiredFor;
|
|
849
|
+
if (requiredFor?.length) {
|
|
850
|
+
const boundValue = context[fieldName];
|
|
851
|
+
const actualValue = execution[fieldName];
|
|
852
|
+
const actionType = execution["action_type"];
|
|
853
|
+
const constrained = boundValue !== void 0 && boundValue !== "";
|
|
854
|
+
const engaged = actionType !== void 0 && requiredFor.includes(String(actionType));
|
|
855
|
+
if (constrained && engaged && (actualValue === void 0 || actualValue === "")) {
|
|
856
|
+
errors.push({
|
|
857
|
+
code: "BOUND_EXCEEDED",
|
|
858
|
+
field: fieldName,
|
|
859
|
+
message: `Authorization limits "${fieldName}" to [${boundValue}], but this "${String(actionType)}" call exposes no ${fieldName} to check against it. Refusing: the call cannot be shown to stay in scope.`,
|
|
860
|
+
bound: boundValue
|
|
861
|
+
});
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
760
865
|
for (const enforceType of fieldDef.constraint.enforceable) {
|
|
761
866
|
if (enforceType === "enum") {
|
|
762
867
|
const boundValue = context[fieldName];
|
|
@@ -842,7 +947,9 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
842
947
|
}
|
|
843
948
|
// Annotate the CommonJS export names for ESM import in node:
|
|
844
949
|
0 && (module.exports = {
|
|
950
|
+
ContentBindingError,
|
|
845
951
|
attestationId,
|
|
952
|
+
bindingAppliesTo,
|
|
846
953
|
canonicalBounds,
|
|
847
954
|
canonicalContext,
|
|
848
955
|
canonicalFrame,
|
|
@@ -853,6 +960,7 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
853
960
|
computeBoundsHash,
|
|
854
961
|
computeContentHash,
|
|
855
962
|
computeContextHash,
|
|
963
|
+
computeFieldsContentHash,
|
|
856
964
|
computeFrameHash,
|
|
857
965
|
computeIntentDisclosureHash,
|
|
858
966
|
computeIntentHash,
|
|
@@ -864,9 +972,11 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
864
972
|
getAllProfiles,
|
|
865
973
|
getProfile,
|
|
866
974
|
intentDisclosureCanonicalBytes,
|
|
975
|
+
isFieldBinding,
|
|
867
976
|
isV4Attestation,
|
|
868
977
|
listProfiles,
|
|
869
978
|
registerProfile,
|
|
979
|
+
selectBoundFields,
|
|
870
980
|
validateBoundsParams,
|
|
871
981
|
validateContextParams,
|
|
872
982
|
validateFrameParams,
|
package/dist/index.mjs
CHANGED
|
@@ -49,6 +49,89 @@ function contentCanonicalBytes(kind, content) {
|
|
|
49
49
|
function computeContentHash(binding, content) {
|
|
50
50
|
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
51
51
|
}
|
|
52
|
+
var ContentBindingError = class extends Error {
|
|
53
|
+
code;
|
|
54
|
+
/** The offending field, when the code names one. */
|
|
55
|
+
field;
|
|
56
|
+
constructor(code, message, field) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "ContentBindingError";
|
|
59
|
+
this.code = code;
|
|
60
|
+
this.field = field;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
function isFieldBinding(binding) {
|
|
64
|
+
return binding.version === "2" && Array.isArray(binding.fields) && binding.fields.length > 0;
|
|
65
|
+
}
|
|
66
|
+
function bindingAppliesTo(binding, actionType) {
|
|
67
|
+
if (!binding.appliesTo) return true;
|
|
68
|
+
return actionType !== void 0 && binding.appliesTo.includes(actionType);
|
|
69
|
+
}
|
|
70
|
+
function canonicalizeValue(value) {
|
|
71
|
+
if (value === null || value === void 0) return void 0;
|
|
72
|
+
if (typeof value === "string") {
|
|
73
|
+
const text = canonicalizeText(value);
|
|
74
|
+
return text === "" ? void 0 : text;
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
const items = value.map(canonicalizeValue).filter((v) => v !== void 0);
|
|
79
|
+
return items.length > 0 ? items : void 0;
|
|
80
|
+
}
|
|
81
|
+
if (typeof value === "object") {
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const [key, v] of Object.entries(value)) {
|
|
84
|
+
const c = canonicalizeValue(v);
|
|
85
|
+
if (c !== void 0) out[key] = c;
|
|
86
|
+
}
|
|
87
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
88
|
+
}
|
|
89
|
+
return void 0;
|
|
90
|
+
}
|
|
91
|
+
function selectBoundFields(binding, args) {
|
|
92
|
+
const fields = binding.fields;
|
|
93
|
+
if (!fields || fields.length === 0) {
|
|
94
|
+
throw new ContentBindingError(
|
|
95
|
+
"NO_FIELDS_DECLARED",
|
|
96
|
+
'content_binding version "2" requires a non-empty `fields` list.'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const required = new Set(binding.required_fields ?? []);
|
|
100
|
+
for (const field of required) {
|
|
101
|
+
if (!fields.includes(field)) {
|
|
102
|
+
throw new ContentBindingError(
|
|
103
|
+
"REQUIRED_FIELD_NOT_DECLARED",
|
|
104
|
+
`content_binding requires "${field}" but does not bind it \u2014 required_fields must be a subset of fields.`,
|
|
105
|
+
field
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const bound = {};
|
|
110
|
+
for (const field of fields) {
|
|
111
|
+
const value = canonicalizeValue(args[field]);
|
|
112
|
+
if (value === void 0) {
|
|
113
|
+
if (required.has(field)) {
|
|
114
|
+
throw new ContentBindingError(
|
|
115
|
+
"MISSING_REQUIRED_FIELD",
|
|
116
|
+
`content_binding requires "${field}", which is absent or empty in this call. Refusing rather than hashing a partial object.`,
|
|
117
|
+
field
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
bound[field] = value;
|
|
123
|
+
}
|
|
124
|
+
if (Object.keys(bound).length === 0) {
|
|
125
|
+
throw new ContentBindingError(
|
|
126
|
+
"EMPTY_BINDING",
|
|
127
|
+
`No declared field (${fields.join(", ")}) carried a value \u2014 the hash would commit to nothing.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return bound;
|
|
131
|
+
}
|
|
132
|
+
function computeFieldsContentHash(binding, args) {
|
|
133
|
+
return computeContentHash(binding, selectBoundFields(binding, args));
|
|
134
|
+
}
|
|
52
135
|
|
|
53
136
|
// src/intent-disclosure.ts
|
|
54
137
|
import { createHash as createHash2 } from "crypto";
|
|
@@ -686,6 +769,23 @@ function checkContextConstraints(context, execution, profile) {
|
|
|
686
769
|
if (!profile.contextSchema) return errors;
|
|
687
770
|
for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
|
|
688
771
|
if (!fieldDef.constraint) continue;
|
|
772
|
+
const requiredFor = fieldDef.constraint.requiredFor;
|
|
773
|
+
if (requiredFor?.length) {
|
|
774
|
+
const boundValue = context[fieldName];
|
|
775
|
+
const actualValue = execution[fieldName];
|
|
776
|
+
const actionType = execution["action_type"];
|
|
777
|
+
const constrained = boundValue !== void 0 && boundValue !== "";
|
|
778
|
+
const engaged = actionType !== void 0 && requiredFor.includes(String(actionType));
|
|
779
|
+
if (constrained && engaged && (actualValue === void 0 || actualValue === "")) {
|
|
780
|
+
errors.push({
|
|
781
|
+
code: "BOUND_EXCEEDED",
|
|
782
|
+
field: fieldName,
|
|
783
|
+
message: `Authorization limits "${fieldName}" to [${boundValue}], but this "${String(actionType)}" call exposes no ${fieldName} to check against it. Refusing: the call cannot be shown to stay in scope.`,
|
|
784
|
+
bound: boundValue
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
689
789
|
for (const enforceType of fieldDef.constraint.enforceable) {
|
|
690
790
|
if (enforceType === "enum") {
|
|
691
791
|
const boundValue = context[fieldName];
|
|
@@ -770,7 +870,9 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
770
870
|
return errors;
|
|
771
871
|
}
|
|
772
872
|
export {
|
|
873
|
+
ContentBindingError,
|
|
773
874
|
attestationId,
|
|
875
|
+
bindingAppliesTo,
|
|
774
876
|
canonicalBounds,
|
|
775
877
|
canonicalContext,
|
|
776
878
|
canonicalFrame,
|
|
@@ -781,6 +883,7 @@ export {
|
|
|
781
883
|
computeBoundsHash,
|
|
782
884
|
computeContentHash,
|
|
783
885
|
computeContextHash,
|
|
886
|
+
computeFieldsContentHash,
|
|
784
887
|
computeFrameHash,
|
|
785
888
|
computeIntentDisclosureHash,
|
|
786
889
|
computeIntentHash,
|
|
@@ -792,9 +895,11 @@ export {
|
|
|
792
895
|
getAllProfiles,
|
|
793
896
|
getProfile,
|
|
794
897
|
intentDisclosureCanonicalBytes,
|
|
898
|
+
isFieldBinding,
|
|
795
899
|
isV4Attestation,
|
|
796
900
|
listProfiles,
|
|
797
901
|
registerProfile,
|
|
902
|
+
selectBoundFields,
|
|
798
903
|
validateBoundsParams,
|
|
799
904
|
validateContextParams,
|
|
800
905
|
validateFrameParams,
|
package/package.json
CHANGED
package/src/content-binding.ts
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
17
17
|
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
18
18
|
*
|
|
19
|
+
* At `version:"2"` the profile also declares WHICH tool arguments are bound
|
|
20
|
+
* ({@link selectBoundFields}), so a receipt can commit to an email's recipients
|
|
21
|
+
* and not only its prose — while still omitting what the intended verifier
|
|
22
|
+
* cannot see. Every string entering the hashed object is canonicalized by the
|
|
23
|
+
* same `text` rule, so a delivered copy with CRLF endings still reproduces it.
|
|
24
|
+
*
|
|
19
25
|
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
20
26
|
* environment-independent primitives, and the text rule uses String.normalize +
|
|
21
27
|
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
@@ -81,3 +87,165 @@ export function computeContentHash(
|
|
|
81
87
|
): string {
|
|
82
88
|
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
83
89
|
}
|
|
90
|
+
|
|
91
|
+
// ─── v2: binding over a declared field subset ────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** Why a field binding refused. Every case is fail-closed by design. */
|
|
94
|
+
export type ContentBindingErrorCode =
|
|
95
|
+
/** The profile declares version:"2" with no usable `fields` list. */
|
|
96
|
+
| 'NO_FIELDS_DECLARED'
|
|
97
|
+
/** `required_fields` names something absent from `fields`. */
|
|
98
|
+
| 'REQUIRED_FIELD_NOT_DECLARED'
|
|
99
|
+
/** A required field was absent or empty at call time. */
|
|
100
|
+
| 'MISSING_REQUIRED_FIELD'
|
|
101
|
+
/** No declared field carried a value — the hash would commit to nothing. */
|
|
102
|
+
| 'EMPTY_BINDING';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A field binding could not be computed. ALWAYS a refusal, never a downgrade:
|
|
106
|
+
* the alternative is a receipt that verifies while proving less than it appears
|
|
107
|
+
* to, which is the failure content binding exists to prevent.
|
|
108
|
+
*/
|
|
109
|
+
export class ContentBindingError extends Error {
|
|
110
|
+
readonly code: ContentBindingErrorCode;
|
|
111
|
+
/** The offending field, when the code names one. */
|
|
112
|
+
readonly field?: string;
|
|
113
|
+
|
|
114
|
+
constructor(code: ContentBindingErrorCode, message: string, field?: string) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.name = 'ContentBindingError';
|
|
117
|
+
this.code = code;
|
|
118
|
+
this.field = field;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True when this binding selects a declared subset (v2) rather than v1's implicit scope. */
|
|
123
|
+
export function isFieldBinding(binding: ContentBinding): boolean {
|
|
124
|
+
return binding.version === '2' && Array.isArray(binding.fields) && binding.fields.length > 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether a field binding covers this action type, per the profile's `appliesTo`.
|
|
129
|
+
*
|
|
130
|
+
* Read STRICTLY — an undeclared action type is NOT covered. This differs from
|
|
131
|
+
* how bounds read the same key (there, an unknown action type enforces the
|
|
132
|
+
* bound, because an extra limit is safe). Here the two directions are not
|
|
133
|
+
* symmetric: applying a field binding to a call that carries no content refuses
|
|
134
|
+
* a legitimate action, so an unknown action type must fall outside rather than
|
|
135
|
+
* inside. Callers are expected to warn on the undeclared case — it is a
|
|
136
|
+
* manifest bug either way.
|
|
137
|
+
*/
|
|
138
|
+
export function bindingAppliesTo(
|
|
139
|
+
binding: ContentBinding,
|
|
140
|
+
actionType: string | undefined,
|
|
141
|
+
): boolean {
|
|
142
|
+
if (!binding.appliesTo) return true;
|
|
143
|
+
return actionType !== undefined && binding.appliesTo.includes(actionType);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Canonicalize one value on its way into the bound object, or `undefined` when
|
|
148
|
+
* it carries nothing (absent / null / empty after canonicalization).
|
|
149
|
+
*
|
|
150
|
+
* Strings are run through {@link canonicalizeText} — the SAME rule `kind:"text"`
|
|
151
|
+
* uses, applied per string rather than to one field. This is not decoration:
|
|
152
|
+
* the verifier of an email holds the DELIVERED copy, whose body has CRLF line
|
|
153
|
+
* endings and transport-added trailing whitespace. JCS embeds strings verbatim,
|
|
154
|
+
* so without this the recipient could never reproduce the hash.
|
|
155
|
+
*
|
|
156
|
+
* Deliberately NOT normalized: array order (the To: header preserves what was
|
|
157
|
+
* sent, and reordering recipients is a change worth catching) and address case
|
|
158
|
+
* (the local part is case-sensitive per RFC 5321, so lowercasing would be a
|
|
159
|
+
* semantic claim this layer has no business making).
|
|
160
|
+
*/
|
|
161
|
+
function canonicalizeValue(value: unknown): unknown {
|
|
162
|
+
if (value === null || value === undefined) return undefined;
|
|
163
|
+
if (typeof value === 'string') {
|
|
164
|
+
const text = canonicalizeText(value);
|
|
165
|
+
return text === '' ? undefined : text;
|
|
166
|
+
}
|
|
167
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
168
|
+
if (Array.isArray(value)) {
|
|
169
|
+
const items = value.map(canonicalizeValue).filter((v) => v !== undefined);
|
|
170
|
+
return items.length > 0 ? items : undefined;
|
|
171
|
+
}
|
|
172
|
+
if (typeof value === 'object') {
|
|
173
|
+
const out: Record<string, unknown> = {};
|
|
174
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
175
|
+
const c = canonicalizeValue(v);
|
|
176
|
+
if (c !== undefined) out[key] = c;
|
|
177
|
+
}
|
|
178
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
179
|
+
}
|
|
180
|
+
return undefined; // functions/symbols — not JSON, nothing to bind
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build the object a v2 binding hashes: exactly the declared `fields` that
|
|
185
|
+
* carry a value, canonicalized. Exported so a verifier can construct the same
|
|
186
|
+
* object from what they hold and see it before hashing.
|
|
187
|
+
*
|
|
188
|
+
* Throws {@link ContentBindingError} rather than returning a partial result —
|
|
189
|
+
* see that class for why refusing is the only safe outcome.
|
|
190
|
+
*/
|
|
191
|
+
export function selectBoundFields(
|
|
192
|
+
binding: ContentBinding,
|
|
193
|
+
args: Record<string, unknown>,
|
|
194
|
+
): Record<string, unknown> {
|
|
195
|
+
const fields = binding.fields;
|
|
196
|
+
if (!fields || fields.length === 0) {
|
|
197
|
+
throw new ContentBindingError(
|
|
198
|
+
'NO_FIELDS_DECLARED',
|
|
199
|
+
'content_binding version "2" requires a non-empty `fields` list.',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const required = new Set(binding.required_fields ?? []);
|
|
204
|
+
for (const field of required) {
|
|
205
|
+
if (!fields.includes(field)) {
|
|
206
|
+
throw new ContentBindingError(
|
|
207
|
+
'REQUIRED_FIELD_NOT_DECLARED',
|
|
208
|
+
`content_binding requires "${field}" but does not bind it — required_fields must be a subset of fields.`,
|
|
209
|
+
field,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const bound: Record<string, unknown> = {};
|
|
215
|
+
for (const field of fields) {
|
|
216
|
+
const value = canonicalizeValue(args[field]);
|
|
217
|
+
if (value === undefined) {
|
|
218
|
+
if (required.has(field)) {
|
|
219
|
+
throw new ContentBindingError(
|
|
220
|
+
'MISSING_REQUIRED_FIELD',
|
|
221
|
+
`content_binding requires "${field}", which is absent or empty in this call. ` +
|
|
222
|
+
`Refusing rather than hashing a partial object.`,
|
|
223
|
+
field,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
continue; // legitimately absent (an email with no cc)
|
|
227
|
+
}
|
|
228
|
+
bound[field] = value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (Object.keys(bound).length === 0) {
|
|
232
|
+
throw new ContentBindingError(
|
|
233
|
+
'EMPTY_BINDING',
|
|
234
|
+
`No declared field (${fields.join(', ')}) carried a value — the hash would commit to nothing.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return bound;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Compute a v2 field-binding hash from raw tool arguments: select the declared
|
|
243
|
+
* subset, then hash it by the declared `kind`. Convenience over
|
|
244
|
+
* {@link selectBoundFields} + {@link computeContentHash} for the common path.
|
|
245
|
+
*/
|
|
246
|
+
export function computeFieldsContentHash(
|
|
247
|
+
binding: ContentBinding,
|
|
248
|
+
args: Record<string, unknown>,
|
|
249
|
+
): string {
|
|
250
|
+
return computeContentHash(binding, selectBoundFields(binding, args));
|
|
251
|
+
}
|
package/src/gatekeeper.ts
CHANGED
|
@@ -612,6 +612,34 @@ function checkContextConstraints(
|
|
|
612
612
|
for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
|
|
613
613
|
if (!fieldDef.constraint) continue;
|
|
614
614
|
|
|
615
|
+
// A constrained dimension that the call does not expose cannot be checked.
|
|
616
|
+
// Skipping is only safe when the action does not engage that dimension —
|
|
617
|
+
// deleting a draft has no recipients. When it DOES engage it, silence must
|
|
618
|
+
// not read as compliance: an absent value means the Gatekeeper cannot show
|
|
619
|
+
// the call is in scope, and an unverifiable send is exactly what the scope
|
|
620
|
+
// exists to prevent. The profile declares which actions engage the field.
|
|
621
|
+
const requiredFor = fieldDef.constraint.requiredFor;
|
|
622
|
+
if (requiredFor?.length) {
|
|
623
|
+
const boundValue = context[fieldName];
|
|
624
|
+
const actualValue = execution[fieldName];
|
|
625
|
+
const actionType = execution['action_type'];
|
|
626
|
+
const constrained = boundValue !== undefined && boundValue !== '';
|
|
627
|
+
const engaged = actionType !== undefined && requiredFor.includes(String(actionType));
|
|
628
|
+
|
|
629
|
+
if (constrained && engaged && (actualValue === undefined || actualValue === '')) {
|
|
630
|
+
errors.push({
|
|
631
|
+
code: 'BOUND_EXCEEDED',
|
|
632
|
+
field: fieldName,
|
|
633
|
+
message:
|
|
634
|
+
`Authorization limits "${fieldName}" to [${boundValue}], but this ` +
|
|
635
|
+
`"${String(actionType)}" call exposes no ${fieldName} to check against it. ` +
|
|
636
|
+
`Refusing: the call cannot be shown to stay in scope.`,
|
|
637
|
+
bound: boundValue,
|
|
638
|
+
});
|
|
639
|
+
continue; // nothing further to compare for this field
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
615
643
|
for (const enforceType of fieldDef.constraint.enforceable) {
|
|
616
644
|
if (enforceType === 'enum') {
|
|
617
645
|
// The context field value is the allowed value; execution must match
|
package/src/types.ts
CHANGED
|
@@ -121,6 +121,27 @@ export interface Attestation {
|
|
|
121
121
|
export interface FieldConstraint {
|
|
122
122
|
type: 'number' | 'string';
|
|
123
123
|
enforceable: Array<'max' | 'enum' | 'subset'>;
|
|
124
|
+
/**
|
|
125
|
+
* Action types for which this dimension MUST be present in the execution
|
|
126
|
+
* context. When the grant constrains this field and the action is one of
|
|
127
|
+
* these, an absent value is a denial rather than a skipped check.
|
|
128
|
+
*
|
|
129
|
+
* Without it, a constraint is only enforced against calls that happen to
|
|
130
|
+
* expose the value. Gmail's `send_message` is the live case: pass `raw` and
|
|
131
|
+
* `to` is never populated, so `allowed_recipients` had nothing to compare and
|
|
132
|
+
* the check passed — a send to an unverified recipient looked authorized.
|
|
133
|
+
* `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
|
|
134
|
+
* see to whom.
|
|
135
|
+
*
|
|
136
|
+
* Keyed on `action_type` because "does this action engage recipients?" is a
|
|
137
|
+
* property of the action, not of the field name — deleting a draft has no
|
|
138
|
+
* recipients and must keep passing, while sending one must not. This keeps
|
|
139
|
+
* the engine profile-agnostic: it compares declared strings and knows nothing
|
|
140
|
+
* about email, calendars or payments.
|
|
141
|
+
*
|
|
142
|
+
* Omitted → previous behaviour: absence skips the check.
|
|
143
|
+
*/
|
|
144
|
+
requiredFor?: string[];
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
/**
|
|
@@ -250,22 +271,64 @@ export interface ProfileBoundsField {
|
|
|
250
271
|
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
251
272
|
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
252
273
|
*
|
|
253
|
-
*
|
|
254
|
-
* canonicalize. It does NOT name the tool field: that is tool-specific
|
|
255
|
-
* resolved at runtime (the same content-field resolver the footer uses
|
|
256
|
-
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
274
|
+
* At `version:"1"` the profile declares only the *policy* — whether to bind and
|
|
275
|
+
* how to canonicalize. It does NOT name the tool field: that is tool-specific
|
|
276
|
+
* and is resolved at runtime (the same content-field resolver the footer uses
|
|
277
|
+
* for `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
278
|
+
*
|
|
279
|
+
* At `version:"2"` the profile additionally declares WHICH fields are bound (see
|
|
280
|
+
* {@link ContentBinding.fields}). Neither v1 mode is the general case: `text`
|
|
281
|
+
* binds one field and leaves everything beside it unbound, while `jcs` over the
|
|
282
|
+
* whole payload is checkable only by a party that already knows the whole
|
|
283
|
+
* payload — an email recipient holds the body, the subject and their own
|
|
284
|
+
* address, but not `bcc`. The general case is a declared subset, chosen so the
|
|
285
|
+
* intended verifier can reproduce it.
|
|
257
286
|
*/
|
|
258
287
|
export interface ContentBinding {
|
|
259
288
|
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
260
289
|
version: string;
|
|
261
290
|
/**
|
|
262
|
-
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload
|
|
291
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload
|
|
292
|
+
* (v1) or over the object built from {@link fields} (v2).
|
|
263
293
|
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
264
294
|
* canonicalizeText), auto-detected content field.
|
|
265
295
|
*/
|
|
266
296
|
kind: 'jcs' | 'text';
|
|
267
297
|
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
268
298
|
pre_footer?: boolean;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* v2 only — the tool-argument keys this binding covers, and the complete
|
|
302
|
+
* statement of what a verifier must reproduce. The Gatekeeper builds an
|
|
303
|
+
* object from exactly these keys and canonicalizes it by `kind`.
|
|
304
|
+
*
|
|
305
|
+
* Adding or removing an entry changes every resulting hash, so it is a
|
|
306
|
+
* BREAKING profile change requiring a version bump, never a silent edit.
|
|
307
|
+
*
|
|
308
|
+
* Choose the subset by one rule: bind everything the approving human is
|
|
309
|
+
* shown, and nothing the intended verifier cannot see.
|
|
310
|
+
*/
|
|
311
|
+
fields?: string[];
|
|
312
|
+
/**
|
|
313
|
+
* v2 only — the subset of {@link fields} whose absence is a fault rather than
|
|
314
|
+
* a fact. An absent OPTIONAL field is omitted from the hashed object (an
|
|
315
|
+
* email legitimately has no `cc`); an absent REQUIRED field means the call is
|
|
316
|
+
* not the call this profile thinks it is, and MUST refuse rather than hash a
|
|
317
|
+
* partial object that reads exactly like a complete one.
|
|
318
|
+
*
|
|
319
|
+
* MUST be a subset of `fields`. Absent → every field is optional, and only a
|
|
320
|
+
* wholly empty selection refuses.
|
|
321
|
+
*/
|
|
322
|
+
required_fields?: string[];
|
|
323
|
+
/**
|
|
324
|
+
* v2 only — the action types this binding covers, using the same vocabulary
|
|
325
|
+
* as {@link ProfileBoundsField.appliesTo}. A profile gates more than its
|
|
326
|
+
* content-bearing calls: `email` also gates deletes, which carry an id and no
|
|
327
|
+
* content, and applying a field binding to those would refuse them.
|
|
328
|
+
*
|
|
329
|
+
* Absent → the binding applies to every gated action under the profile.
|
|
330
|
+
*/
|
|
331
|
+
appliesTo?: string[];
|
|
269
332
|
}
|
|
270
333
|
|
|
271
334
|
/**
|