@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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public ticket projection — `HAP-ticket-projection` (protocol.md → *Public
|
|
3
|
+
* ticket projection*, new in v0.8, specification-led).
|
|
4
|
+
*
|
|
5
|
+
* A ticket is one signature over every field, so a redacted view cannot be
|
|
6
|
+
* re-verified. Where a ticket is shown to parties other than its holder —
|
|
7
|
+
* a public page, a machine verifier at an effector, an auditor — the AS
|
|
8
|
+
* serves a public-only subset, separately signed by the same key, so the
|
|
9
|
+
* public view verifies on its own. It never widens what the ticket
|
|
10
|
+
* discloses: `executionContext` carries only the governing
|
|
11
|
+
* `disclose_fields`, and `userId`, `cumulativeState`, `limits` and `action`
|
|
12
|
+
* never appear.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as ed from '@noble/ed25519';
|
|
16
|
+
import { canonicalize } from './canonicalize';
|
|
17
|
+
import { toBase64Url, fromBase64Url } from './base64url';
|
|
18
|
+
import { decodeDidKey } from './did-key';
|
|
19
|
+
import { HapError } from './errors';
|
|
20
|
+
import type { Mandate, MandateOwnerEntry, Subject } from './types';
|
|
21
|
+
import type { TicketPayload } from './ticket';
|
|
22
|
+
import { verifyOwnerSignature, OwnerSignatureError } from './owner-signature';
|
|
23
|
+
|
|
24
|
+
/** The AS may not let a projection be accepted as authorization later than
|
|
25
|
+
* this after the ticket's timestamp (rule 3). */
|
|
26
|
+
export const PROJECTION_MAX_VALIDITY_SECONDS = 86_400;
|
|
27
|
+
|
|
28
|
+
export interface TicketProjection {
|
|
29
|
+
typ: 'HAP-ticket-projection';
|
|
30
|
+
version: string;
|
|
31
|
+
id: string;
|
|
32
|
+
mandateId: string;
|
|
33
|
+
issuer: string;
|
|
34
|
+
timestamp: number;
|
|
35
|
+
/** Set by the AS at projection time; governs acceptance as authorization, not evidence. */
|
|
36
|
+
valid_until: number;
|
|
37
|
+
profileId: string;
|
|
38
|
+
actionType: string;
|
|
39
|
+
boundsHash: string;
|
|
40
|
+
contentHash?: string;
|
|
41
|
+
contentBinding?: TicketPayload['contentBinding'];
|
|
42
|
+
subjects?: Subject[];
|
|
43
|
+
/** Exactly the governing `disclose_fields` the ticket carries; absent when none. */
|
|
44
|
+
executionContext?: Record<string, unknown>;
|
|
45
|
+
/** The mandate's `mandate_owners` entry, present exactly when it carries a signature. */
|
|
46
|
+
owner?: MandateOwnerEntry;
|
|
47
|
+
signature: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the projection of a ticket (rule 1). `discloseFields` is the
|
|
52
|
+
* governing list — the mandate's `disclose_fields` where present, else the
|
|
53
|
+
* profile's; nothing when neither declares one.
|
|
54
|
+
*/
|
|
55
|
+
export function buildTicketProjection(
|
|
56
|
+
ticket: TicketPayload,
|
|
57
|
+
mandate: Mandate,
|
|
58
|
+
opts: { validUntil: number; discloseFields?: readonly string[] },
|
|
59
|
+
): Omit<TicketProjection, 'signature'> {
|
|
60
|
+
if (ticket.mandateId !== mandate.payload.mandate_id) {
|
|
61
|
+
throw new HapError('MALFORMED_TICKET_REQUEST', 'the mandate supplied is not the one the ticket names');
|
|
62
|
+
}
|
|
63
|
+
if (opts.validUntil > ticket.timestamp + PROJECTION_MAX_VALIDITY_SECONDS) {
|
|
64
|
+
throw new HapError('MALFORMED_TICKET_REQUEST', `valid_until MUST NOT end later than ${PROJECTION_MAX_VALIDITY_SECONDS}s after the ticket's timestamp`);
|
|
65
|
+
}
|
|
66
|
+
const projection: Omit<TicketProjection, 'signature'> = {
|
|
67
|
+
typ: 'HAP-ticket-projection',
|
|
68
|
+
version: ticket.version,
|
|
69
|
+
id: ticket.id,
|
|
70
|
+
mandateId: ticket.mandateId,
|
|
71
|
+
issuer: ticket.issuer,
|
|
72
|
+
timestamp: ticket.timestamp,
|
|
73
|
+
valid_until: opts.validUntil,
|
|
74
|
+
profileId: ticket.profileId,
|
|
75
|
+
actionType: ticket.actionType,
|
|
76
|
+
boundsHash: ticket.boundsHash,
|
|
77
|
+
};
|
|
78
|
+
if (ticket.contentHash !== undefined) projection.contentHash = ticket.contentHash;
|
|
79
|
+
if (ticket.contentBinding !== undefined) projection.contentBinding = ticket.contentBinding;
|
|
80
|
+
if (ticket.subjects !== undefined) projection.subjects = ticket.subjects;
|
|
81
|
+
const disclosed: Record<string, unknown> = {};
|
|
82
|
+
for (const field of opts.discloseFields ?? []) {
|
|
83
|
+
if (ticket.executionContext[field] !== undefined) disclosed[field] = ticket.executionContext[field];
|
|
84
|
+
}
|
|
85
|
+
if (Object.keys(disclosed).length > 0) projection.executionContext = disclosed;
|
|
86
|
+
const owner = mandate.payload.mandate_owners[0];
|
|
87
|
+
if (owner?.signature !== undefined) projection.owner = owner;
|
|
88
|
+
return projection;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Sign a projection with the key that signed its ticket (rule 2). */
|
|
92
|
+
export async function signTicketProjection(
|
|
93
|
+
projection: Omit<TicketProjection, 'signature'>,
|
|
94
|
+
privateKey: Uint8Array,
|
|
95
|
+
): Promise<TicketProjection> {
|
|
96
|
+
const signature = toBase64Url(await ed.signAsync(new TextEncoder().encode(canonicalize(projection)), privateKey));
|
|
97
|
+
return { ...projection, signature };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Verify a projection on its own: `typ` required, signature under the key
|
|
102
|
+
* `issuer` names. With `asAuthorization`, a projection past `valid_until`
|
|
103
|
+
* is refused (rule 3); as evidence it is not.
|
|
104
|
+
* @throws HapError `INVALID_SIGNATURE`, `POLICY_UNMET` (validity window)
|
|
105
|
+
*/
|
|
106
|
+
export async function verifyTicketProjection(
|
|
107
|
+
projection: TicketProjection,
|
|
108
|
+
opts: { trustedIssuers?: readonly string[]; now?: number; asAuthorization?: boolean } = {},
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
if (projection.typ !== 'HAP-ticket-projection') {
|
|
111
|
+
throw new HapError('INVALID_SIGNATURE', 'a projection verifier requires typ "HAP-ticket-projection"');
|
|
112
|
+
}
|
|
113
|
+
for (const k of ['userId', 'cumulativeState', 'limits', 'action'] as const) {
|
|
114
|
+
if (k in projection) throw new HapError('INVALID_SIGNATURE', `a projection MUST NOT carry ${k}`);
|
|
115
|
+
}
|
|
116
|
+
if (opts.trustedIssuers && !opts.trustedIssuers.includes(projection.issuer)) {
|
|
117
|
+
throw new HapError('INVALID_SIGNATURE', `issuer ${JSON.stringify(projection.issuer)} is not a trusted Authority Server`);
|
|
118
|
+
}
|
|
119
|
+
const { signature, ...unsigned } = projection;
|
|
120
|
+
if (!signature) throw new HapError('INVALID_SIGNATURE', 'projection carries no signature');
|
|
121
|
+
let publicKey: Uint8Array;
|
|
122
|
+
try {
|
|
123
|
+
publicKey = decodeDidKey(projection.issuer);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
throw new HapError('INVALID_SIGNATURE', `cannot resolve a key from issuer ${JSON.stringify(projection.issuer)}: ${(err as Error).message}`);
|
|
126
|
+
}
|
|
127
|
+
let sigBytes: Uint8Array;
|
|
128
|
+
try {
|
|
129
|
+
sigBytes = fromBase64Url(signature);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
throw new HapError('INVALID_SIGNATURE', (err as Error).message);
|
|
132
|
+
}
|
|
133
|
+
const ok = await ed.verifyAsync(sigBytes, new TextEncoder().encode(canonicalize(unsigned)), publicKey).catch(() => false);
|
|
134
|
+
if (!ok) throw new HapError('INVALID_SIGNATURE', 'projection signature verification failed');
|
|
135
|
+
if (opts.asAuthorization) {
|
|
136
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
137
|
+
if (now > projection.valid_until) {
|
|
138
|
+
throw new HapError('POLICY_UNMET', `projection presented after valid_until ${projection.valid_until}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Verify the projection's `owner` entry against the mandate obtained through
|
|
145
|
+
* the third-party verification API (rule 4): the entry is the mandate's
|
|
146
|
+
* own, and its signature verifies over the projection rebuilt from the
|
|
147
|
+
* mandate. The projection alone cannot establish this.
|
|
148
|
+
* @throws HapError `OWNER_SIGNATURE_INVALID`
|
|
149
|
+
*/
|
|
150
|
+
export async function verifyProjectionOwner(projection: TicketProjection, mandate: Mandate): Promise<void> {
|
|
151
|
+
if (!projection.owner) {
|
|
152
|
+
throw new HapError('OWNER_SIGNATURE_INVALID', 'projection carries no owner entry');
|
|
153
|
+
}
|
|
154
|
+
if (projection.mandateId !== mandate.payload.mandate_id) {
|
|
155
|
+
throw new HapError('OWNER_SIGNATURE_INVALID', 'the mandate supplied is not the one the projection names');
|
|
156
|
+
}
|
|
157
|
+
const own = mandate.payload.mandate_owners[0];
|
|
158
|
+
if (!own || canonicalize(own) !== canonicalize(projection.owner)) {
|
|
159
|
+
throw new HapError('OWNER_SIGNATURE_INVALID', 'projection owner entry differs from the mandate\'s mandate_owners entry');
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
await verifyOwnerSignature(mandate, projection.owner);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof OwnerSignatureError) throw new HapError(err.code, err.message);
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/ticket.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mandate tickets
|
|
3
|
-
*
|
|
4
|
-
* "v0.4 introduced the object under the name execution receipt; v0.7 renames
|
|
5
|
-
* it because the tense was wrong — a receipt follows payment, and the ticket
|
|
6
|
-
* must come first").
|
|
2
|
+
* Mandate tickets — the wire type, signing, holder-side verification, and
|
|
3
|
+
* (v0.8) the Gatekeeper's check that a ticket answers the request it sent.
|
|
7
4
|
*
|
|
8
5
|
* Ticket payloads are camelCase on the wire (unlike the snake_case mandate
|
|
9
6
|
* payload) — a shipped inconsistency the spec documents rather than breaks
|
|
@@ -12,10 +9,28 @@
|
|
|
12
9
|
|
|
13
10
|
import { HapError } from './errors';
|
|
14
11
|
import * as ed from '@noble/ed25519';
|
|
15
|
-
import type { ContentBinding, Subject } from './types';
|
|
12
|
+
import type { ContentBinding, Mandate, Subject } from './types';
|
|
16
13
|
import { canonicalize } from './canonicalize';
|
|
17
14
|
import { toBase64Url, fromBase64Url } from './base64url';
|
|
18
15
|
import { decodeDidKey } from './did-key';
|
|
16
|
+
import { assertSupportedVersion } from './versions';
|
|
17
|
+
import { verifyApproval, OwnerSignatureError, type ApprovalObject } from './owner-signature';
|
|
18
|
+
|
|
19
|
+
/** Clock-skew tolerance and freshness window (Mandate rule 9; Ticket rule 4). */
|
|
20
|
+
export const TICKET_FRESHNESS_SECONDS = 300;
|
|
21
|
+
|
|
22
|
+
/** One verified approval carried by a ticket. */
|
|
23
|
+
export interface ApprovalSignatureEntry {
|
|
24
|
+
alg: 'EdDSA' | 'ES256';
|
|
25
|
+
signature: string;
|
|
26
|
+
nonce: string;
|
|
27
|
+
decided_at: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** v0.8 — an above-cap approver's entry in `approvalSignatures`. */
|
|
31
|
+
export interface ApproverSignatureEntry extends ApprovalSignatureEntry {
|
|
32
|
+
approver_did: string;
|
|
33
|
+
}
|
|
19
34
|
|
|
20
35
|
/** Signed mandate ticket — protocol.md → *Ticket Payload Schema*. */
|
|
21
36
|
export interface TicketPayload {
|
|
@@ -42,6 +57,10 @@ export interface TicketPayload {
|
|
|
42
57
|
/** The AS DID, as in the mandate (new in v0.7). */
|
|
43
58
|
issuer: string;
|
|
44
59
|
timestamp: number;
|
|
60
|
+
/** v0.8 — copied verbatim from the request, so the ticket names the single
|
|
61
|
+
* request it answers. Present exactly when the request carried one
|
|
62
|
+
* (synchronous path). */
|
|
63
|
+
idempotencyKey?: string;
|
|
45
64
|
/** Hash of the bound content, computed by the Gatekeeper, copied
|
|
46
65
|
* verbatim by the AS. The AS never sees the content. */
|
|
47
66
|
contentHash?: string;
|
|
@@ -52,10 +71,15 @@ export interface TicketPayload {
|
|
|
52
71
|
subjects?: Subject[];
|
|
53
72
|
/** Review path: the proposal this ticket executed. */
|
|
54
73
|
proposalId?: string;
|
|
74
|
+
/** v0.8, review path: the proposal's content fingerprint, copied verbatim
|
|
75
|
+
* from the proposal the AS compared it against. */
|
|
76
|
+
proposalContentHash?: string;
|
|
55
77
|
/** Review path: the owner's HAP-approval signature for the executed
|
|
56
78
|
* proposal, as the object itself — never a hash of it, which a holder
|
|
57
79
|
* could check only by asking the AS. */
|
|
58
|
-
approvalSignature?:
|
|
80
|
+
approvalSignature?: ApprovalSignatureEntry;
|
|
81
|
+
/** v0.8, above-cap path: one entry per approver signature the AS verified. */
|
|
82
|
+
approvalSignatures?: ApproverSignatureEntry[];
|
|
59
83
|
/** Ed25519 over the JCS-canonical payload (this field excluded), base64url. */
|
|
60
84
|
signature: string;
|
|
61
85
|
}
|
|
@@ -72,25 +96,50 @@ export async function signTicket(
|
|
|
72
96
|
return { ...payload, signature };
|
|
73
97
|
}
|
|
74
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The structural rules that keep a ticket, a projection and a mandate
|
|
101
|
+
* payload apart (protocol.md → *Ticket Payload Schema*, "Signature"): a
|
|
102
|
+
* ticket verifier MUST require `id`, `mandateId`, `actionType`, `timestamp`
|
|
103
|
+
* and `issuer`, MUST reject an object carrying `typ` (a projection can never
|
|
104
|
+
* be accepted as a ticket), and MUST reject `mandate_id` or `issued_at`.
|
|
105
|
+
* @throws HapError `INVALID_SIGNATURE`
|
|
106
|
+
*/
|
|
107
|
+
export function assertTicketShape(ticket: Record<string, unknown>): void {
|
|
108
|
+
if ('typ' in ticket) {
|
|
109
|
+
throw new HapError('INVALID_SIGNATURE', 'a ticket carries no typ — an object with one is a projection, not a ticket');
|
|
110
|
+
}
|
|
111
|
+
if ('mandate_id' in ticket || 'issued_at' in ticket) {
|
|
112
|
+
throw new HapError('INVALID_SIGNATURE', 'a ticket carries no mandate_id or issued_at — this object is a mandate payload');
|
|
113
|
+
}
|
|
114
|
+
for (const required of ['id', 'mandateId', 'actionType', 'timestamp', 'issuer'] as const) {
|
|
115
|
+
if (ticket[required] === undefined) {
|
|
116
|
+
throw new HapError('INVALID_SIGNATURE', `ticket is missing required field ${required}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
75
121
|
/**
|
|
76
122
|
* Verify a ticket's AS signature: strip `signature`, JCS-canonicalize the
|
|
77
123
|
* rest, Ed25519-verify against the key resolved from `issuer` — never
|
|
78
124
|
* against a key offered alongside the ticket (protocol.md → *Ticket
|
|
79
125
|
* Verification* step 1). This is holder-side verification — it needs the
|
|
80
126
|
* COMPLETE ticket; a redacted public view cannot be re-verified this way
|
|
81
|
-
* (see
|
|
127
|
+
* (see `ticket-projection.ts` for the view that can).
|
|
82
128
|
*
|
|
83
129
|
* Content is checked separately: where `contentHash` is present, recompute
|
|
84
130
|
* it from the held artifact via the content-binding module and compare.
|
|
85
131
|
*
|
|
86
132
|
* @param opts.trustedIssuers When supplied, an `issuer` not in this list is
|
|
87
133
|
* rejected before any cryptography runs.
|
|
88
|
-
* @throws
|
|
134
|
+
* @throws HapError `INVALID_SIGNATURE` when verification fails;
|
|
135
|
+
* `VERSION_UNSUPPORTED` for a version this package cannot read.
|
|
89
136
|
*/
|
|
90
137
|
export async function verifyTicketSignature(
|
|
91
138
|
ticket: TicketPayload,
|
|
92
139
|
opts?: { trustedIssuers?: readonly string[] },
|
|
93
140
|
): Promise<void> {
|
|
141
|
+
assertTicketShape(ticket as unknown as Record<string, unknown>);
|
|
142
|
+
assertSupportedVersion(ticket.version, 'ticket');
|
|
94
143
|
const { signature, ...unsigned } = ticket;
|
|
95
144
|
if (!signature) {
|
|
96
145
|
throw new HapError('INVALID_SIGNATURE', 'ticket carries no signature');
|
|
@@ -120,19 +169,204 @@ export async function verifyTicketSignature(
|
|
|
120
169
|
}
|
|
121
170
|
}
|
|
122
171
|
|
|
172
|
+
// ─── The request a ticket answers (v0.8) ─────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** The fields of a ticket request a Gatekeeper compares with the ticket it
|
|
175
|
+
* receives (protocol.md → *Ticket Payload Schema*, rule 4). */
|
|
176
|
+
export interface TicketRequestEcho {
|
|
177
|
+
profileId: string;
|
|
178
|
+
actionType: string;
|
|
179
|
+
boundsHash: string;
|
|
180
|
+
executionContext: Record<string, unknown>;
|
|
181
|
+
mandateId?: string;
|
|
182
|
+
idempotencyKey?: string;
|
|
183
|
+
contentHash?: string;
|
|
184
|
+
contentBinding?: TicketPayload['contentBinding'];
|
|
185
|
+
proposalId?: string;
|
|
186
|
+
proposalContentHash?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const ECHOED_FIELDS = [
|
|
190
|
+
'profileId', 'actionType', 'boundsHash', 'executionContext',
|
|
191
|
+
'contentHash', 'contentBinding', 'proposalId', 'proposalContentHash',
|
|
192
|
+
] as const;
|
|
193
|
+
|
|
123
194
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* `
|
|
127
|
-
*
|
|
128
|
-
*
|
|
195
|
+
* v0.8 — the Gatekeeper's half of "a ticket names the request it answers":
|
|
196
|
+
* every echoed field equals what was sent, the `idempotencyKey` it sent is
|
|
197
|
+
* the one the ticket carries, `mandateId` and `version` are the verified
|
|
198
|
+
* mandate's, and a fresh ticket's `timestamp` lies within
|
|
199
|
+
* {@link TICKET_FRESHNESS_SECONDS}. A replay (`idempotent: true`, unsigned)
|
|
200
|
+
* is exempt from freshness only; the caller passes `replay: true` and then
|
|
201
|
+
* applies *One execution per ticket*, rule 2.
|
|
129
202
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
203
|
+
* Does NOT verify the signature — call {@link verifyTicketSignature} first.
|
|
204
|
+
* @throws HapError `TICKET_REQUEST_MISMATCH`
|
|
205
|
+
*/
|
|
206
|
+
export function verifyTicketAgainstRequest(
|
|
207
|
+
ticket: TicketPayload,
|
|
208
|
+
request: TicketRequestEcho,
|
|
209
|
+
opts: { mandate?: Mandate; now?: number; replay?: boolean } = {},
|
|
210
|
+
): void {
|
|
211
|
+
for (const field of ECHOED_FIELDS) {
|
|
212
|
+
const sent = request[field];
|
|
213
|
+
const got = ticket[field];
|
|
214
|
+
if (sent === undefined && got === undefined) continue;
|
|
215
|
+
if (sent === undefined || got === undefined || canonicalize(sent) !== canonicalize(got)) {
|
|
216
|
+
throw new HapError('TICKET_REQUEST_MISMATCH', `ticket ${field} differs from the request's`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (request.idempotencyKey !== undefined && ticket.idempotencyKey !== request.idempotencyKey) {
|
|
220
|
+
throw new HapError(
|
|
221
|
+
'TICKET_REQUEST_MISMATCH',
|
|
222
|
+
ticket.idempotencyKey === undefined
|
|
223
|
+
? 'ticket carries no idempotencyKey although the request sent one'
|
|
224
|
+
: 'ticket idempotencyKey differs from the one the request sent',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const expectedMandateId = opts.mandate?.payload.mandate_id ?? request.mandateId;
|
|
228
|
+
if (expectedMandateId !== undefined && ticket.mandateId !== expectedMandateId) {
|
|
229
|
+
throw new HapError('TICKET_REQUEST_MISMATCH', `ticket mandateId ${ticket.mandateId} is not the mandate this request was made under`);
|
|
230
|
+
}
|
|
231
|
+
if (opts.mandate && ticket.version !== opts.mandate.payload.version) {
|
|
232
|
+
throw new HapError('TICKET_REQUEST_MISMATCH', `ticket version ${ticket.version} differs from its mandate's ${opts.mandate.payload.version}`);
|
|
233
|
+
}
|
|
234
|
+
if (!opts.replay) {
|
|
235
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
236
|
+
if (Math.abs(now - ticket.timestamp) > TICKET_FRESHNESS_SECONDS) {
|
|
237
|
+
throw new HapError('TICKET_REQUEST_MISMATCH', `ticket timestamp ${ticket.timestamp} is outside the freshness window at ${now}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Approvals carried by a ticket ───────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Rebuild the `HAP-approval` a decider signed, from the ticket and the
|
|
246
|
+
* mandate it names (protocol.md → *Ticket Payload Schema*, `approvalSignature`):
|
|
247
|
+
* `typ` and `version`; `proposal_id` and `mandate_id` from `proposalId` and
|
|
248
|
+
* `mandateId`; `action` and `action_type` (v0.8) from `action` and
|
|
249
|
+
* `actionType`; `proposal_content_hash` from `proposalContentHash`;
|
|
250
|
+
* `content_hash` from `contentHash` where a binding applies to this action
|
|
251
|
+
* (the AS copies one only then), else `proposalContentHash`; `decision`
|
|
252
|
+
* `"commit"`; `approver_did` as given; `decided_at` and `nonce` from the
|
|
253
|
+
* entry.
|
|
254
|
+
*/
|
|
255
|
+
export function reconstructApproval(
|
|
256
|
+
ticket: TicketPayload,
|
|
257
|
+
entry: ApprovalSignatureEntry,
|
|
258
|
+
approverDid: string,
|
|
259
|
+
): ApprovalObject {
|
|
260
|
+
if (!ticket.proposalId) {
|
|
261
|
+
throw new HapError('APPROVAL_SIGNATURE_INVALID', 'ticket carries an approval signature but no proposalId');
|
|
262
|
+
}
|
|
263
|
+
if (ticket.version === '0.7') {
|
|
264
|
+
if (!ticket.contentHash) {
|
|
265
|
+
throw new HapError('APPROVAL_SIGNATURE_INVALID', 'a v0.7 approval without a content binding is not reconstructible from the ticket alone');
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
typ: 'HAP-approval',
|
|
269
|
+
version: '0.7',
|
|
270
|
+
proposal_id: ticket.proposalId,
|
|
271
|
+
mandate_id: ticket.mandateId,
|
|
272
|
+
decision: 'commit',
|
|
273
|
+
content_hash: ticket.contentHash,
|
|
274
|
+
decided_at: entry.decided_at,
|
|
275
|
+
nonce: entry.nonce,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (!ticket.proposalContentHash) {
|
|
279
|
+
throw new HapError('APPROVAL_SIGNATURE_INVALID', 'a v0.8 review-path ticket carries proposalContentHash');
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
typ: 'HAP-approval',
|
|
283
|
+
version: '0.8',
|
|
284
|
+
approver_did: approverDid,
|
|
285
|
+
proposal_id: ticket.proposalId,
|
|
286
|
+
mandate_id: ticket.mandateId,
|
|
287
|
+
action: ticket.action,
|
|
288
|
+
action_type: ticket.actionType,
|
|
289
|
+
decision: 'commit',
|
|
290
|
+
content_hash: ticket.contentHash ?? ticket.proposalContentHash,
|
|
291
|
+
proposal_content_hash: ticket.proposalContentHash,
|
|
292
|
+
decided_at: entry.decided_at,
|
|
293
|
+
nonce: entry.nonce,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** What a ticket's approvals, once verified, establish. */
|
|
298
|
+
export interface VerifiedApprovals {
|
|
299
|
+
/** The Mandate Owner's DID, when `approvalSignature` verified. */
|
|
300
|
+
owner?: string;
|
|
301
|
+
/** Distinct above-cap approver DIDs whose `approvalSignatures` entries verified. */
|
|
302
|
+
approvers: string[];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Verify every approval a ticket carries against the mandate it names
|
|
307
|
+
* (protocol.md → *Ticket Payload Schema*, rule 4): `approvalSignature`
|
|
308
|
+
* under the Mandate Owner's DID; each `approvalSignatures` entry under its
|
|
309
|
+
* `approver_did`, which MUST be in the signed `above_cap_approvers` and
|
|
310
|
+
* MUST NOT be the Mandate Owner (the proposer never counts). Whether the
|
|
311
|
+
* verified set is ENOUGH is {@link approvalsSufficient}, so a verifier can
|
|
312
|
+
* report what it found and a policy can decide.
|
|
313
|
+
*
|
|
314
|
+
* @throws HapError `APPROVAL_SIGNATURE_INVALID`
|
|
315
|
+
*/
|
|
316
|
+
export async function verifyTicketApprovals(ticket: TicketPayload, mandate: Mandate): Promise<VerifiedApprovals> {
|
|
317
|
+
if (ticket.mandateId !== mandate.payload.mandate_id) {
|
|
318
|
+
throw new HapError('APPROVAL_SIGNATURE_INVALID', 'the mandate supplied is not the one the ticket names');
|
|
319
|
+
}
|
|
320
|
+
const ownerDid = mandate.payload.mandate_owners[0]?.did;
|
|
321
|
+
const result: VerifiedApprovals = { approvers: [] };
|
|
322
|
+
try {
|
|
323
|
+
if (ticket.approvalSignature) {
|
|
324
|
+
const approval = reconstructApproval(ticket, ticket.approvalSignature, ownerDid);
|
|
325
|
+
await verifyApproval(approval, ticket.approvalSignature.signature, ownerDid);
|
|
326
|
+
result.owner = ownerDid;
|
|
327
|
+
}
|
|
328
|
+
const signed = new Set(mandate.payload.above_cap_approvers ?? []);
|
|
329
|
+
for (const entry of ticket.approvalSignatures ?? []) {
|
|
330
|
+
if (!signed.has(entry.approver_did)) {
|
|
331
|
+
throw new HapError('APPROVAL_SIGNATURE_INVALID', `approver ${entry.approver_did} is not in the mandate's signed above_cap_approvers`);
|
|
332
|
+
}
|
|
333
|
+
if (entry.approver_did === ownerDid) {
|
|
334
|
+
throw new HapError('APPROVAL_SIGNATURE_INVALID', 'the proposer never counts as an above-cap approver');
|
|
335
|
+
}
|
|
336
|
+
const approval = reconstructApproval(ticket, entry, entry.approver_did);
|
|
337
|
+
await verifyApproval(approval, entry.signature, entry.approver_did);
|
|
338
|
+
if (!result.approvers.includes(entry.approver_did)) result.approvers.push(entry.approver_did);
|
|
339
|
+
}
|
|
340
|
+
} catch (err) {
|
|
341
|
+
if (err instanceof OwnerSignatureError) throw new HapError(err.code, err.message);
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Whether the verified approvals satisfy what the mandate requires for a
|
|
349
|
+
* decision above a cap: at least `above_cap_approvals` distinct approvers
|
|
350
|
+
* (v0.7: every signed approver), plus the owner's own approval in `review`.
|
|
351
|
+
*/
|
|
352
|
+
export function approvalsSufficient(mandate: Mandate, verified: VerifiedApprovals, aboveCap: boolean): boolean {
|
|
353
|
+
const p = mandate.payload;
|
|
354
|
+
const ownerNeeded = p.commitment_mode === 'review';
|
|
355
|
+
if (ownerNeeded && !verified.owner) return false;
|
|
356
|
+
if (!aboveCap) return true;
|
|
357
|
+
const required = p.version === '0.7'
|
|
358
|
+
? (p.above_cap_approvers ?? []).length
|
|
359
|
+
: (p.above_cap_approvals ?? 1);
|
|
360
|
+
return verified.approvers.length >= required;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A redacted public view of a ticket (protocol.md → *Ticket Disclosure Is
|
|
365
|
+
* Declared*). Hides `userId`, `cumulativeState`, and `limits`, and narrows
|
|
366
|
+
* `executionContext` to the governing `disclose_fields`. NOT independently
|
|
367
|
+
* re-verifiable — since v0.8 a verifiable public view is the separately
|
|
368
|
+
* signed `HAP-ticket-projection` (`ticket-projection.ts`); a view that is
|
|
369
|
+
* not one MUST NOT be presented as independently verified.
|
|
136
370
|
*/
|
|
137
371
|
export function publicTicketView(
|
|
138
372
|
ticket: TicketPayload,
|
package/src/types.ts
CHANGED
|
@@ -84,10 +84,15 @@ export interface Subject {
|
|
|
84
84
|
verified_at?: number;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/** protocol
|
|
87
|
+
/** The protocol version an artifact was issued under. This package verifies
|
|
88
|
+
* each under its own rules (protocol.md → *Version negotiation*, rule 4). */
|
|
89
|
+
export type ProtocolVersion = '0.8' | '0.7';
|
|
90
|
+
|
|
91
|
+
/** protocol.md → *Mandate Payload (v0.8)*; a `"0.7"` payload is the same shape
|
|
92
|
+
* without `above_cap_approvals` and without the v0.8 owner-signed fields. */
|
|
88
93
|
export interface MandatePayload {
|
|
89
94
|
mandate_id: string;
|
|
90
|
-
version:
|
|
95
|
+
version: ProtocolVersion;
|
|
91
96
|
profile_id: string;
|
|
92
97
|
/** Hash of the canonical bounds string. */
|
|
93
98
|
bounds_hash: string;
|
|
@@ -104,10 +109,16 @@ export interface MandatePayload {
|
|
|
104
109
|
/** Exactly one entry in v0.7 (Mandate rule 7). */
|
|
105
110
|
mandate_owners: MandateOwnerEntry[];
|
|
106
111
|
gate_content_hashes: Record<string, string>;
|
|
107
|
-
commitment_mode:
|
|
108
|
-
/**
|
|
112
|
+
commitment_mode: CommitmentMode;
|
|
113
|
+
/** Present together with `above_cap_approvers` and (v0.8) `above_cap_approvals`:
|
|
114
|
+
* under `review_above_cap`, or `review` in a group with approval-kind caps
|
|
115
|
+
* the owner's mandate can reach (protocol.md → *Group roles*, rule 7). */
|
|
109
116
|
above_cap_caps?: Record<string, number>;
|
|
110
117
|
above_cap_approvers?: string[];
|
|
118
|
+
/** v0.8 — how many distinct `above_cap_approvers`, other than the Mandate
|
|
119
|
+
* Owner, must approve. Integer ≥ 1. Present exactly when
|
|
120
|
+
* `above_cap_approvers` is (Mandate rule 10). A v0.7 mandate carries none. */
|
|
121
|
+
above_cap_approvals?: number;
|
|
111
122
|
/**
|
|
112
123
|
* Present iff the mandate carries an encrypted-intent disclosure object
|
|
113
124
|
* (companion spec `intent-disclosure@0.1`) — `sha256:`-prefixed hash
|
|
@@ -290,6 +301,15 @@ export interface ProfileBoundsField {
|
|
|
290
301
|
* min(value, maximum). Absent → no ceiling.
|
|
291
302
|
*/
|
|
292
303
|
maximum?: number;
|
|
304
|
+
/**
|
|
305
|
+
* v0.8 (Bounds Schema rule 11) — a bound only the Gatekeeper enforces, such
|
|
306
|
+
* as a read window whose actions produce no ticket. The Authority Server
|
|
307
|
+
* still verifies the value at issuance (`maximum`, enum membership, any
|
|
308
|
+
* group ceiling) and never reads it at ticket time. A group cap on such a
|
|
309
|
+
* bound is always hard, and the signed value is the ceiling (*Group
|
|
310
|
+
* ceilings*).
|
|
311
|
+
*/
|
|
312
|
+
enforcedBy?: 'gatekeeper';
|
|
293
313
|
}
|
|
294
314
|
|
|
295
315
|
/**
|
|
@@ -446,6 +466,9 @@ export type ExecutionContextFieldDef = DeclaredFieldDef | CumulativeFieldDef;
|
|
|
446
466
|
*/
|
|
447
467
|
export type SignableCommitmentMode = 'automatic' | 'review';
|
|
448
468
|
|
|
469
|
+
/** Every commitment mode a signed mandate can carry. */
|
|
470
|
+
export type CommitmentMode = SignableCommitmentMode | 'review_above_cap';
|
|
471
|
+
|
|
449
472
|
/** protocol.md → *Owner Signatures* → "Where the requirement lives" — the
|
|
450
473
|
* profile-floor tier: `minBinding` names the weakest `OwnerSignatureBinding`
|
|
451
474
|
* this profile accepts for a co-signing owner. */
|
|
@@ -504,8 +527,13 @@ export interface AgentProfile {
|
|
|
504
527
|
* a mandate screen offers only the listed modes. Fail-closed: a declared
|
|
505
528
|
* list with no valid entry allows nothing — a malformed declaration never
|
|
506
529
|
* widens what a profile allows. See {@link allowedCommitmentModes}.
|
|
530
|
+
*
|
|
531
|
+
* v0.8 (Commitment Modes, rule 8): `review_above_cap` is admitted if and
|
|
532
|
+
* only if `automatic` is listed; an entry `review_above_cap` in the list is
|
|
533
|
+
* ignored, and a list whose only entry is it admits nothing and is
|
|
534
|
+
* malformed (`PROFILE_INVALID`).
|
|
507
535
|
*/
|
|
508
|
-
commitment_modes?: readonly
|
|
536
|
+
commitment_modes?: readonly CommitmentMode[];
|
|
509
537
|
|
|
510
538
|
/**
|
|
511
539
|
* The bounds schema — the enforceable parameters (protocol.md → *Bounds
|