@humanagencyp/hap-core 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/index.d.mts +567 -123
- package/dist/index.d.ts +567 -123
- package/dist/index.js +869 -23
- package/dist/index.mjs +829 -22
- package/package.json +2 -1
- package/src/canonicalize.ts +9 -0
- package/src/commitment-modes.ts +21 -9
- package/src/errors.ts +24 -8
- package/src/gatekeeper.ts +52 -8
- package/src/index.ts +5 -1
- package/src/issuance.ts +250 -0
- package/src/mandate.ts +52 -14
- package/src/owner-signature.ts +86 -39
- package/src/profile.ts +45 -0
- package/src/proposal-content.ts +219 -0
- package/src/ticket-projection.ts +167 -0
- package/src/ticket.ts +254 -20
- package/src/types.ts +33 -5
- package/src/verifier-policy.ts +178 -0
- package/src/versions.ts +50 -19
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifier policy — `HAP-verifier-policy` (protocol.md → *Owner Signatures*
|
|
3
|
+
* → *Verifier policy*, new in v0.8, specification-led).
|
|
4
|
+
*
|
|
5
|
+
* The relying party's own, declarative statement of what it demands of a
|
|
6
|
+
* ticket (or projection) before acting on or trusting it. Every present
|
|
7
|
+
* line is a conjunct; an unmet line is a refusal naming the line and its
|
|
8
|
+
* code; absent lines impose nothing. The policy is never sent to the
|
|
9
|
+
* Authority Server.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { HapError, type HapErrorCode } from './errors';
|
|
13
|
+
import type { Mandate, OwnerSignatureBinding } from './types';
|
|
14
|
+
import type { TicketPayload } from './ticket';
|
|
15
|
+
import { verifyTicketSignature, verifyTicketApprovals, approvalsSufficient } from './ticket';
|
|
16
|
+
import { verifyTicketProjection, verifyProjectionOwner, type TicketProjection } from './ticket-projection';
|
|
17
|
+
import { verifyMandateSignature } from './mandate';
|
|
18
|
+
import { verifyOwnerSignatures, bindingRank, BINDING_RANK } from './owner-signature';
|
|
19
|
+
|
|
20
|
+
export interface VerifierPolicy {
|
|
21
|
+
typ: 'HAP-verifier-policy';
|
|
22
|
+
version: string;
|
|
23
|
+
trusted_issuers?: string[];
|
|
24
|
+
min_binding?: OwnerSignatureBinding | string;
|
|
25
|
+
required_owners?: string[];
|
|
26
|
+
profile_hashes?: Record<string, string>;
|
|
27
|
+
action_types?: string[];
|
|
28
|
+
freshness_seconds?: number;
|
|
29
|
+
required_fields?: string[];
|
|
30
|
+
require_approval_signature?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A refusal naming the policy line that failed. */
|
|
34
|
+
export class PolicyError extends HapError {
|
|
35
|
+
constructor(public readonly line: keyof VerifierPolicy, code: HapErrorCode, message: string) {
|
|
36
|
+
super(code, `${line}: ${message}`);
|
|
37
|
+
this.name = 'PolicyError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PolicyInput {
|
|
42
|
+
/** The complete ticket, where the relying party holds it. */
|
|
43
|
+
ticket?: TicketPayload;
|
|
44
|
+
/** Or the projection, where only the public view was served. */
|
|
45
|
+
projection?: TicketProjection;
|
|
46
|
+
/** The mandate the artifact chains to, where the policy needs it
|
|
47
|
+
* (`min_binding`, `required_owners`, `profile_hashes`, `require_approval_signature`). */
|
|
48
|
+
mandate?: Mandate;
|
|
49
|
+
now?: number;
|
|
50
|
+
/** The relying party acts on the artifact (freshness applies to `valid_until`). */
|
|
51
|
+
asAuthorization?: boolean;
|
|
52
|
+
/** Whether the ticket's action exceeded an approval-kind cap — known to a
|
|
53
|
+
* party that holds the mandate's caps and the execution context. */
|
|
54
|
+
aboveCap?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Evaluate a policy (protocol.md → *Verifier policy*, "Evaluation"). The
|
|
59
|
+
* artifact's own signature is verified first under `trusted_issuers` where
|
|
60
|
+
* present, else under any resolvable issuer.
|
|
61
|
+
* @throws PolicyError with the line and the code the table assigns.
|
|
62
|
+
*/
|
|
63
|
+
export async function evaluateVerifierPolicy(policy: VerifierPolicy, input: PolicyInput): Promise<void> {
|
|
64
|
+
if (policy.typ !== 'HAP-verifier-policy') {
|
|
65
|
+
throw new HapError('POLICY_UNMET', 'policy document must carry typ "HAP-verifier-policy"');
|
|
66
|
+
}
|
|
67
|
+
const artifact = input.ticket ?? input.projection;
|
|
68
|
+
if (!artifact) throw new HapError('POLICY_UNMET', 'no ticket or projection to evaluate');
|
|
69
|
+
const now = input.now ?? Math.floor(Date.now() / 1000);
|
|
70
|
+
|
|
71
|
+
// trusted_issuers — an unlisted issuer, or a signature that does not verify
|
|
72
|
+
// under the listed key, is reported as INVALID_SIGNATURE on purpose.
|
|
73
|
+
try {
|
|
74
|
+
if (input.ticket) {
|
|
75
|
+
await verifyTicketSignature(input.ticket, { trustedIssuers: policy.trusted_issuers });
|
|
76
|
+
} else if (input.projection) {
|
|
77
|
+
await verifyTicketProjection(input.projection, { trustedIssuers: policy.trusted_issuers, now, asAuthorization: input.asAuthorization });
|
|
78
|
+
}
|
|
79
|
+
if (input.mandate) {
|
|
80
|
+
await verifyMandateSignature(input.mandate, { trustedIssuers: policy.trusted_issuers });
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const code = err instanceof HapError ? err.code : 'INVALID_SIGNATURE';
|
|
84
|
+
throw new PolicyError(code === 'POLICY_UNMET' ? 'freshness_seconds' : 'trusted_issuers', code, (err as Error).message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Owner signatures on the mandate — verified once, used by two lines.
|
|
88
|
+
let verifiedOwnerDids: Map<string, string> | undefined; // did → binding
|
|
89
|
+
const needOwners = policy.min_binding !== undefined || policy.required_owners !== undefined;
|
|
90
|
+
if (needOwners) {
|
|
91
|
+
verifiedOwnerDids = new Map();
|
|
92
|
+
if (input.mandate) {
|
|
93
|
+
try {
|
|
94
|
+
const entries = await verifyOwnerSignatures(input.mandate);
|
|
95
|
+
for (const e of entries) verifiedOwnerDids.set(e.did, e.binding ?? 'raw');
|
|
96
|
+
if (input.projection?.owner) await verifyProjectionOwner(input.projection, input.mandate);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new PolicyError('min_binding', 'OWNER_SIGNATURE_REQUIRED', `no verifiable owner signature: ${(err as Error).message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (policy.min_binding !== undefined) {
|
|
104
|
+
const minRank = BINDING_RANK[policy.min_binding as OwnerSignatureBinding];
|
|
105
|
+
if (minRank === undefined) {
|
|
106
|
+
throw new PolicyError('min_binding', 'OWNER_SIGNATURE_REQUIRED', `unknown binding "${policy.min_binding}" — satisfies no minimum`);
|
|
107
|
+
}
|
|
108
|
+
const best = Math.max(-1, ...[...verifiedOwnerDids!.values()].map(bindingRank));
|
|
109
|
+
if (best < minRank) {
|
|
110
|
+
throw new PolicyError('min_binding', 'OWNER_SIGNATURE_REQUIRED', `no verified owner signature at binding >= "${policy.min_binding}"`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (policy.required_owners !== undefined) {
|
|
115
|
+
for (const did of policy.required_owners) {
|
|
116
|
+
if (!verifiedOwnerDids!.has(did)) {
|
|
117
|
+
throw new PolicyError('required_owners', 'OWNER_SIGNATURE_REQUIRED', `no verified owner signature by ${did}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (policy.profile_hashes !== undefined) {
|
|
123
|
+
const mandate = input.mandate;
|
|
124
|
+
if (!mandate) throw new PolicyError('profile_hashes', 'PROFILE_HASH_MISMATCH', 'the mandate is needed to check profile_hashes');
|
|
125
|
+
const expected = policy.profile_hashes[mandate.payload.profile_id];
|
|
126
|
+
if (expected === undefined) {
|
|
127
|
+
throw new PolicyError('profile_hashes', 'PROFILE_HASH_MISMATCH', `profile ${mandate.payload.profile_id} is not listed`);
|
|
128
|
+
}
|
|
129
|
+
if (expected !== mandate.payload.profile_hash) {
|
|
130
|
+
throw new PolicyError('profile_hashes', 'PROFILE_HASH_MISMATCH', `profile_hash differs from the listed one for ${mandate.payload.profile_id}`);
|
|
131
|
+
}
|
|
132
|
+
if (mandate.payload.mandate_id !== artifact.mandateId) {
|
|
133
|
+
throw new PolicyError('profile_hashes', 'PROFILE_HASH_MISMATCH', 'the mandate supplied is not the one the artifact names');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (policy.action_types !== undefined && !policy.action_types.includes(artifact.actionType)) {
|
|
138
|
+
throw new PolicyError('action_types', 'POLICY_UNMET', `actionType ${artifact.actionType} is not in the listed set`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (policy.freshness_seconds !== undefined) {
|
|
142
|
+
if (now - artifact.timestamp > policy.freshness_seconds) {
|
|
143
|
+
throw new PolicyError('freshness_seconds', 'POLICY_UNMET', `ticket is ${now - artifact.timestamp}s old, policy allows ${policy.freshness_seconds}s`);
|
|
144
|
+
}
|
|
145
|
+
if (input.projection && input.asAuthorization && now > input.projection.valid_until) {
|
|
146
|
+
throw new PolicyError('freshness_seconds', 'POLICY_UNMET', 'projection presented after valid_until');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (policy.required_fields !== undefined) {
|
|
151
|
+
const ctx = input.ticket?.executionContext ?? input.projection?.executionContext ?? {};
|
|
152
|
+
for (const f of policy.required_fields) {
|
|
153
|
+
if (ctx[f] === undefined) {
|
|
154
|
+
throw new PolicyError('required_fields', 'POLICY_UNMET', `execution-context field ${f} is not ${input.ticket ? 'present' : 'disclosed'}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (policy.require_approval_signature) {
|
|
160
|
+
const ticket = input.ticket;
|
|
161
|
+
const mandate = input.mandate;
|
|
162
|
+
if (!ticket || !mandate) {
|
|
163
|
+
throw new PolicyError('require_approval_signature', 'APPROVAL_SIGNATURE_REQUIRED', 'the complete ticket and its mandate are needed to verify approvals');
|
|
164
|
+
}
|
|
165
|
+
if (ticket.proposalId !== undefined) {
|
|
166
|
+
let verified;
|
|
167
|
+
try {
|
|
168
|
+
verified = await verifyTicketApprovals(ticket, mandate);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
throw new PolicyError('require_approval_signature', 'APPROVAL_SIGNATURE_REQUIRED', (err as Error).message);
|
|
171
|
+
}
|
|
172
|
+
const aboveCap = input.aboveCap ?? (ticket.approvalSignatures !== undefined && ticket.approvalSignatures.length > 0);
|
|
173
|
+
if (!approvalsSufficient(mandate, verified, aboveCap)) {
|
|
174
|
+
throw new PolicyError('require_approval_signature', 'APPROVAL_SIGNATURE_REQUIRED', 'the ticket lacks a verifiable signature for each decision it rests on');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/versions.ts
CHANGED
|
@@ -1,36 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Version negotiation — protocol.md → *Version negotiation (v0.7)
|
|
2
|
+
* Version negotiation — protocol.md → *Version negotiation (v0.7)* and the
|
|
3
|
+
* v0.7 → v0.8 exception (*Migration from v0.7*).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* issues
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
5
|
+
* Two sets, because v0.8 is a re-grant release:
|
|
6
|
+
*
|
|
7
|
+
* - {@link PROTOCOL_VERSION} — the one version this package ISSUES. A v0.8
|
|
8
|
+
* Authority Server issues `version: "0.8"` only, and refuses a mandate
|
|
9
|
+
* request that does not support it (rule 3, exception).
|
|
10
|
+
* - {@link SUPPORTED_VERSIONS} — the versions this package can VERIFY and
|
|
11
|
+
* ENFORCE, each under its own rules (rule 4). A Gatekeeper sends this list
|
|
12
|
+
* as `supported_versions`; a verifier accepts an artifact of any of them.
|
|
13
|
+
* v0.7 is kept so that a Gatekeeper upgraded before its Authority Server
|
|
14
|
+
* keeps working, and so that v0.7 artifacts remain verifiable as evidence.
|
|
15
|
+
*
|
|
16
|
+
* `negotiateVersion` picks what an Authority Server issues. Its default is
|
|
17
|
+
* the strict v0.8 set; an AS that bridges the transition passes the wider
|
|
18
|
+
* set explicitly, behind its own switch.
|
|
13
19
|
*/
|
|
14
20
|
import { HapError } from './errors';
|
|
15
21
|
|
|
16
|
-
/** The protocol version this package issues
|
|
17
|
-
export const PROTOCOL_VERSION = '0.
|
|
22
|
+
/** The protocol version this package issues. */
|
|
23
|
+
export const PROTOCOL_VERSION = '0.8';
|
|
24
|
+
|
|
25
|
+
/** Every version this package verifies and enforces, newest first. */
|
|
26
|
+
export const SUPPORTED_VERSIONS: readonly string[] = ['0.8', '0.7'];
|
|
27
|
+
|
|
28
|
+
/** The versions an Authority Server built on this package issues. */
|
|
29
|
+
export const ISSUABLE_VERSIONS: readonly string[] = [PROTOCOL_VERSION];
|
|
18
30
|
|
|
19
|
-
/**
|
|
20
|
-
export
|
|
31
|
+
/** A protocol version this package knows the rules of. */
|
|
32
|
+
export type SupportedVersion = '0.8' | '0.7';
|
|
33
|
+
|
|
34
|
+
export function isSupportedVersion(v: unknown): v is SupportedVersion {
|
|
35
|
+
return typeof v === 'string' && SUPPORTED_VERSIONS.includes(v);
|
|
36
|
+
}
|
|
21
37
|
|
|
22
38
|
/**
|
|
23
|
-
* The highest version both the requester and
|
|
39
|
+
* The highest version both the requester and the issuer support.
|
|
24
40
|
*
|
|
25
41
|
* @param requested `supported_versions` from the mandate request. Absent →
|
|
26
|
-
* read as `["0.6"]` (protocol.md rule 1)
|
|
27
|
-
*
|
|
28
|
-
*
|
|
42
|
+
* read as `["0.6"]` (protocol.md rule 1), which no issuer here supports, so
|
|
43
|
+
* the refusal is honest rather than a silent assumption.
|
|
44
|
+
* @param supported What the issuer is willing to issue. Default: v0.8 only.
|
|
29
45
|
* @throws HapError `VERSION_UNSUPPORTED` when no version is common to both.
|
|
30
46
|
*/
|
|
31
47
|
export function negotiateVersion(
|
|
32
48
|
requested: readonly string[] | undefined,
|
|
33
|
-
supported: readonly string[] =
|
|
49
|
+
supported: readonly string[] = ISSUABLE_VERSIONS,
|
|
34
50
|
): string {
|
|
35
51
|
const requestedVersions = requested ?? ['0.6'];
|
|
36
52
|
// Highest-first so a requester offering several supported versions gets the
|
|
@@ -45,3 +61,18 @@ export function negotiateVersion(
|
|
|
45
61
|
}
|
|
46
62
|
return common[0];
|
|
47
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Refuse an artifact of a version this package cannot verify (rule 4: an
|
|
67
|
+
* artifact is read under its own version, never re-interpreted).
|
|
68
|
+
* @throws HapError `VERSION_UNSUPPORTED`
|
|
69
|
+
*/
|
|
70
|
+
export function assertSupportedVersion(version: unknown, what: string): asserts version is SupportedVersion {
|
|
71
|
+
if (!isSupportedVersion(version)) {
|
|
72
|
+
throw new HapError(
|
|
73
|
+
'VERSION_UNSUPPORTED',
|
|
74
|
+
`${what} version ${JSON.stringify(version)} is not verifiable by this implementation ` +
|
|
75
|
+
`(verifies [${SUPPORTED_VERSIONS.join(', ')}])`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|