ccp-core 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,495 @@
1
+ import { digestBytes, keyIdFromPublic, publicBytesFromKeyId, publicFromSeed, sha256Hex, verify, } from "./crypto.js";
2
+ import { serialize } from "./json.js";
3
+ import { parseStrict } from "./parse.js";
4
+ import { signDocument, signingBytes } from "./signing.js";
5
+ import { utcnow } from "./time.js";
6
+ /** Wire type of an identity document (§25.8.1). */
7
+ export const IDENTITY_TYPE = "ccp.identity.v1";
8
+ /** Wire type of an admission policy (§25.8.3). */
9
+ export const POLICY_TYPE = "ccp.admission-policy.v1";
10
+ /** Tolerated clock skew for validity windows (§25.8.1). */
11
+ export const IDENTITY_CLOCK_SKEW_SECONDS = 120;
12
+ const SUPPORTED_LABELS = new Set([
13
+ "0.5-draft",
14
+ "0.4-draft",
15
+ ]);
16
+ const ROLES = new Set([
17
+ "provider",
18
+ "requester",
19
+ "consumer",
20
+ ]);
21
+ const ADMITTABLE_RISK = new Set(["read", "disclose"]);
22
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
23
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
24
+ export class IdentityError extends Error {
25
+ code;
26
+ constructor(code, message = "") {
27
+ super(message ? `${code}: ${message}` : code);
28
+ this.name = "IdentityError";
29
+ this.code = code;
30
+ }
31
+ }
32
+ function asObject(value, what) {
33
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
34
+ throw new IdentityError("invalid_request", `${what} must be an object`);
35
+ }
36
+ return value;
37
+ }
38
+ function readString(obj, key, what) {
39
+ const value = obj[key];
40
+ if (typeof value !== "string" || value === "") {
41
+ throw new IdentityError("invalid_request", `${what}.${key} is required`);
42
+ }
43
+ return value;
44
+ }
45
+ function optionalString(obj, key, what) {
46
+ if (!(key in obj))
47
+ return undefined;
48
+ return readString(obj, key, what);
49
+ }
50
+ function readStringArray(obj, key, what) {
51
+ const value = obj[key];
52
+ if (!Array.isArray(value) || value.length === 0) {
53
+ throw new IdentityError("invalid_request", `${what}.${key} is required`);
54
+ }
55
+ return value.map((item) => {
56
+ if (typeof item !== "string" || item === "") {
57
+ throw new IdentityError("invalid_request", `${what}.${key} must contain non-empty strings`);
58
+ }
59
+ return item;
60
+ });
61
+ }
62
+ function optionalStringArray(obj, key, what) {
63
+ if (!(key in obj))
64
+ return undefined;
65
+ return readStringArray(obj, key, what);
66
+ }
67
+ function readTimestamp(obj, key, what) {
68
+ const value = readString(obj, key, what);
69
+ if (!TIMESTAMP.test(value)) {
70
+ throw new IdentityError("invalid_request", `${what}.${key} must be RFC 3339 UTC with second precision`);
71
+ }
72
+ return value;
73
+ }
74
+ function assertDigest(value, field) {
75
+ if (value !== undefined && !DIGEST.test(value)) {
76
+ throw new IdentityError("invalid_request", `${field} is not a digest`);
77
+ }
78
+ }
79
+ function assertKeyId(identity) {
80
+ let raw;
81
+ try {
82
+ raw = publicBytesFromKeyId(identity);
83
+ }
84
+ catch {
85
+ throw new IdentityError("invalid_request", "identity is not a key id");
86
+ }
87
+ if (raw.length !== 32) {
88
+ throw new IdentityError("invalid_request", "identity key is not 32 bytes");
89
+ }
90
+ }
91
+ function addSeconds(timestamp, seconds) {
92
+ const ms = Date.parse(timestamp);
93
+ if (Number.isNaN(ms)) {
94
+ throw new IdentityError("invalid_request", "invalid timestamp");
95
+ }
96
+ return new Date(ms + seconds * 1000)
97
+ .toISOString()
98
+ .replace(/\.\d+Z$/, "Z");
99
+ }
100
+ function assertLabel(obj) {
101
+ const label = readString(obj, "ccp", "document");
102
+ if (!SUPPORTED_LABELS.has(label)) {
103
+ throw new IdentityError("invalid_request", "unsupported ccp label");
104
+ }
105
+ }
106
+ /** Verify `signature` over the signing bytes using the document's own key. */
107
+ function assertSignature(obj, identity) {
108
+ const signature = obj.signature;
109
+ if (typeof signature !== "string" || !signature.startsWith("ed25519:")) {
110
+ throw new IdentityError("invalid_request", "signature is required");
111
+ }
112
+ if (!verify(signingBytes(obj), signature, publicBytesFromKeyId(identity))) {
113
+ throw new IdentityError("unauthenticated", "signature is invalid");
114
+ }
115
+ }
116
+ /* ------------------------------------------------------------------ */
117
+ /* identity documents */
118
+ /* ------------------------------------------------------------------ */
119
+ /** `sha256:<hex>` over the signing bytes of an identity document. */
120
+ export function identityDigest(document) {
121
+ return digestBytes(signingBytes(parseStrict(document)));
122
+ }
123
+ /** Build and sign an identity document; the identity is derived from `seed`. */
124
+ export function buildIdentityDocument(input, seed) {
125
+ const value = {
126
+ ccp: input.ccp ?? "0.5-draft",
127
+ type: IDENTITY_TYPE,
128
+ identity: keyIdFromPublic(publicFromSeed(seed)),
129
+ issued_at: input.issuedAt,
130
+ expires_at: input.expiresAt,
131
+ };
132
+ if (input.name !== undefined)
133
+ value.name = input.name;
134
+ if (input.roles !== undefined)
135
+ value.roles = [...input.roles];
136
+ if (input.endpoints !== undefined) {
137
+ value.endpoints = input.endpoints.map((endpoint) => {
138
+ const entry = { transport: endpoint.transport };
139
+ if (endpoint.manifest !== undefined)
140
+ entry.manifest = endpoint.manifest;
141
+ if (endpoint.signal !== undefined)
142
+ entry.signal = endpoint.signal;
143
+ return entry;
144
+ });
145
+ }
146
+ if (input.manifestDigest !== undefined) {
147
+ value.manifest_digest = input.manifestDigest;
148
+ }
149
+ if (input.policyDigest !== undefined) {
150
+ value.policy_digest = input.policyDigest;
151
+ }
152
+ return signDocument(value, seed);
153
+ }
154
+ /**
155
+ * Parse and verify an identity document (§25.8.1).
156
+ *
157
+ * `expectedIdentity` is the key the caller already knows for this contact.
158
+ * A different key is `identity_changed`, not `unauthenticated`, because the
159
+ * consequence differs: affected grants must be suspended (§7.4).
160
+ */
161
+ export function verifyIdentityDocument(document, options = {}) {
162
+ const obj = asObject(parseStrict(document), "identity document");
163
+ assertLabel(obj);
164
+ const type = readString(obj, "type", "identity");
165
+ if (type !== IDENTITY_TYPE) {
166
+ throw new IdentityError("invalid_request", "type is not an identity");
167
+ }
168
+ const identity = readString(obj, "identity", "identity");
169
+ assertKeyId(identity);
170
+ assertSignature(obj, identity);
171
+ if (options.expectedIdentity !== undefined &&
172
+ options.expectedIdentity !== identity) {
173
+ throw new IdentityError("identity_changed", "identity key differs from the known contact");
174
+ }
175
+ const issuedAt = readTimestamp(obj, "issued_at", "identity");
176
+ const expiresAt = readTimestamp(obj, "expires_at", "identity");
177
+ if (expiresAt <= issuedAt) {
178
+ throw new IdentityError("invalid_request", "expires_at must sort after issued_at");
179
+ }
180
+ const now = options.now ?? utcnow();
181
+ if (issuedAt > addSeconds(now, IDENTITY_CLOCK_SKEW_SECONDS)) {
182
+ throw new IdentityError("identity_expired", "identity document is not yet valid");
183
+ }
184
+ if (now > expiresAt) {
185
+ throw new IdentityError("identity_expired", "identity document expired");
186
+ }
187
+ let roles = [];
188
+ if ("roles" in obj) {
189
+ roles = readStringArray(obj, "roles", "identity");
190
+ for (const role of roles) {
191
+ if (!ROLES.has(role)) {
192
+ throw new IdentityError("invalid_request", `unknown role: ${role}`);
193
+ }
194
+ }
195
+ if (new Set(roles).size !== roles.length) {
196
+ throw new IdentityError("invalid_request", "roles must be unique");
197
+ }
198
+ }
199
+ const endpoints = [];
200
+ if ("endpoints" in obj) {
201
+ const raw = obj.endpoints;
202
+ if (!Array.isArray(raw) || raw.length === 0) {
203
+ throw new IdentityError("invalid_request", "endpoints must be a non-empty array");
204
+ }
205
+ for (const item of raw) {
206
+ const entry = asObject(item, "endpoint");
207
+ const transport = readString(entry, "transport", "endpoint");
208
+ const endpoint = { transport };
209
+ const manifest = optionalString(entry, "manifest", "endpoint");
210
+ const signal = optionalString(entry, "signal", "endpoint");
211
+ if (manifest === undefined && signal === undefined) {
212
+ throw new IdentityError("invalid_request", "endpoint must carry manifest or signal");
213
+ }
214
+ if (manifest !== undefined)
215
+ endpoint.manifest = manifest;
216
+ if (signal !== undefined)
217
+ endpoint.signal = signal;
218
+ endpoints.push(endpoint);
219
+ }
220
+ }
221
+ const manifestDigest = optionalString(obj, "manifest_digest", "identity");
222
+ assertDigest(manifestDigest, "manifest_digest");
223
+ if (options.manifestDigest !== undefined &&
224
+ manifestDigest !== undefined &&
225
+ options.manifestDigest !== manifestDigest) {
226
+ throw new IdentityError("manifest_mismatch", "manifest digest does not match the document");
227
+ }
228
+ const policyDigest = optionalString(obj, "policy_digest", "identity");
229
+ assertDigest(policyDigest, "policy_digest");
230
+ if (options.policyDigest !== undefined &&
231
+ policyDigest !== undefined &&
232
+ options.policyDigest !== policyDigest) {
233
+ throw new IdentityError("policy_mismatch", "policy digest does not match the document");
234
+ }
235
+ const verified = {
236
+ document,
237
+ identity,
238
+ roles,
239
+ endpoints,
240
+ issuedAt,
241
+ expiresAt,
242
+ };
243
+ const name = optionalString(obj, "name", "identity");
244
+ if (name !== undefined)
245
+ verified.name = name;
246
+ if (manifestDigest !== undefined)
247
+ verified.manifestDigest = manifestDigest;
248
+ if (policyDigest !== undefined)
249
+ verified.policyDigest = policyDigest;
250
+ return verified;
251
+ }
252
+ /**
253
+ * Deterministic grant id for a policy rule and requester (§25.8.3).
254
+ *
255
+ * Reusing the id is what makes revocation durable: the host stores one
256
+ * record, the consumer revokes it, and every later admission of the same
257
+ * rule resolves to that revoked record.
258
+ */
259
+ export function admittedGrantId(policy, ruleId, request) {
260
+ const material = serialize({
261
+ intent: request.intent,
262
+ policy: policy.digest,
263
+ requester: request.requesterKey,
264
+ rule: ruleId,
265
+ });
266
+ return "pg-" + sha256Hex(new TextEncoder().encode(material)).slice(0, 32);
267
+ }
268
+ /** `sha256:<hex>` over the signing bytes of an admission policy. */
269
+ export function policyDigest(document) {
270
+ return digestBytes(signingBytes(parseStrict(document)));
271
+ }
272
+ /** Build and sign an admission policy; `host` is derived from `seed`. */
273
+ export function buildAdmissionPolicy(input, seed) {
274
+ const rules = input.rules.map((rule) => {
275
+ const entry = {
276
+ id: rule.id,
277
+ intents: [...rule.intents],
278
+ risk: rule.risk,
279
+ confirmation: rule.confirmation,
280
+ expires_at: rule.expires_at,
281
+ };
282
+ if (rule.contacts !== undefined)
283
+ entry.contacts = [...rule.contacts];
284
+ if (rule.requesters !== undefined) {
285
+ entry.requesters = [...rule.requesters];
286
+ }
287
+ if (rule.destinations !== undefined) {
288
+ entry.destinations = [...rule.destinations];
289
+ }
290
+ if (rule.limits !== undefined)
291
+ entry.limits = { ...rule.limits };
292
+ return entry;
293
+ });
294
+ const value = {
295
+ ccp: input.ccp ?? "0.5-draft",
296
+ type: POLICY_TYPE,
297
+ host: keyIdFromPublic(publicFromSeed(seed)),
298
+ version: BigInt(input.version),
299
+ default: input.default,
300
+ rules,
301
+ issued_at: input.issuedAt,
302
+ expires_at: input.expiresAt,
303
+ };
304
+ return signDocument(value, seed);
305
+ }
306
+ function readLimits(obj) {
307
+ if (!("limits" in obj))
308
+ return undefined;
309
+ const raw = asObject(obj.limits, "rule.limits");
310
+ const limits = {};
311
+ for (const [key, value] of Object.entries(raw)) {
312
+ if (typeof value !== "bigint") {
313
+ throw new IdentityError("invalid_request", "rule.limits values must be integers");
314
+ }
315
+ if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
316
+ throw new IdentityError("invalid_request", "rule.limits out of range");
317
+ }
318
+ limits[key] = Number(value);
319
+ }
320
+ return limits;
321
+ }
322
+ function readRule(value, policyExpiresAt) {
323
+ const obj = asObject(value, "rule");
324
+ const id = readString(obj, "id", "rule");
325
+ const intents = readStringArray(obj, "intents", "rule");
326
+ const risk = readString(obj, "risk", "rule");
327
+ if (!ADMITTABLE_RISK.has(risk)) {
328
+ throw new IdentityError("invalid_request", "a policy may only admit read or disclose intents");
329
+ }
330
+ const contacts = optionalStringArray(obj, "contacts", "rule");
331
+ const requesters = optionalStringArray(obj, "requesters", "rule");
332
+ if (contacts === undefined && requesters === undefined) {
333
+ throw new IdentityError("invalid_request", "a rule must name contacts or requesters");
334
+ }
335
+ const destinations = optionalStringArray(obj, "destinations", "rule");
336
+ if (risk === "disclose" && destinations === undefined) {
337
+ throw new IdentityError("invalid_request", "a disclose rule must pin destinations");
338
+ }
339
+ if (risk === "read" && destinations !== undefined) {
340
+ throw new IdentityError("invalid_request", "only a disclose rule may pin destinations");
341
+ }
342
+ const confirmation = readString(obj, "confirmation", "rule");
343
+ if (confirmation !== "none") {
344
+ throw new IdentityError("invalid_request", "rule.confirmation must be none");
345
+ }
346
+ const expiresAt = readTimestamp(obj, "expires_at", "rule");
347
+ if (expiresAt > policyExpiresAt) {
348
+ throw new IdentityError("invalid_request", "rule expires after the policy");
349
+ }
350
+ const rule = {
351
+ id,
352
+ intents,
353
+ risk: risk,
354
+ confirmation: "none",
355
+ expires_at: expiresAt,
356
+ };
357
+ if (contacts !== undefined)
358
+ rule.contacts = contacts;
359
+ if (requesters !== undefined)
360
+ rule.requesters = requesters;
361
+ if (destinations !== undefined)
362
+ rule.destinations = destinations;
363
+ const limits = readLimits(obj);
364
+ if (limits !== undefined)
365
+ rule.limits = limits;
366
+ return rule;
367
+ }
368
+ /** Parse and verify an admission policy (§25.8.3). */
369
+ export function verifyAdmissionPolicy(document, options = {}) {
370
+ const obj = asObject(parseStrict(document), "policy");
371
+ assertLabel(obj);
372
+ const type = readString(obj, "type", "policy");
373
+ if (type !== POLICY_TYPE) {
374
+ throw new IdentityError("invalid_request", "type is not a policy");
375
+ }
376
+ const host = readString(obj, "host", "policy");
377
+ assertKeyId(host);
378
+ assertSignature(obj, host);
379
+ if (options.expectedHost !== undefined && options.expectedHost !== host) {
380
+ throw new IdentityError("identity_changed", "policy host differs from the known identity");
381
+ }
382
+ const rawVersion = obj.version;
383
+ if (typeof rawVersion !== "bigint") {
384
+ throw new IdentityError("invalid_request", "version must be an integer");
385
+ }
386
+ if (rawVersion < 1n || rawVersion > BigInt(Number.MAX_SAFE_INTEGER)) {
387
+ throw new IdentityError("invalid_request", "version is out of range");
388
+ }
389
+ const fallback = readString(obj, "default", "policy");
390
+ if (fallback !== "confirm" && fallback !== "deny") {
391
+ throw new IdentityError("invalid_request", "default must be confirm or deny");
392
+ }
393
+ const issuedAt = readTimestamp(obj, "issued_at", "policy");
394
+ const expiresAt = readTimestamp(obj, "expires_at", "policy");
395
+ if (expiresAt <= issuedAt) {
396
+ throw new IdentityError("invalid_request", "expires_at must sort after issued_at");
397
+ }
398
+ const rawRules = obj.rules;
399
+ if (!Array.isArray(rawRules) || rawRules.length === 0) {
400
+ throw new IdentityError("invalid_request", "rules must be a non-empty array");
401
+ }
402
+ const rules = [];
403
+ const seen = new Set();
404
+ for (const value of rawRules) {
405
+ const rule = readRule(value, expiresAt);
406
+ if (seen.has(rule.id)) {
407
+ throw new IdentityError("invalid_request", "rule ids must be unique");
408
+ }
409
+ seen.add(rule.id);
410
+ rules.push(rule);
411
+ }
412
+ const now = options.now ?? utcnow();
413
+ if (now > expiresAt) {
414
+ throw new IdentityError("policy_expired", "admission policy expired");
415
+ }
416
+ return {
417
+ document,
418
+ host,
419
+ version: Number(rawVersion),
420
+ default: fallback,
421
+ rules,
422
+ issuedAt,
423
+ expiresAt,
424
+ digest: digestBytes(signingBytes(obj)),
425
+ };
426
+ }
427
+ /**
428
+ * Evaluate a verified policy against one request (§25.8.3).
429
+ *
430
+ * `allow` is not an execution: use `admittedGrant` to create the record and
431
+ * then proceed through §10.3.
432
+ */
433
+ export function admit(policy, request, options = {}) {
434
+ if (request.now > policy.expiresAt) {
435
+ return { decision: "deny", reason: "policy_expired" };
436
+ }
437
+ if (!ADMITTABLE_RISK.has(request.risk)) {
438
+ return { decision: "deny", reason: "risk_not_admittable" };
439
+ }
440
+ for (const rule of policy.rules) {
441
+ if (request.now > rule.expires_at)
442
+ continue;
443
+ if (!rule.intents.includes(request.intent))
444
+ continue;
445
+ if (rule.risk !== request.risk)
446
+ continue;
447
+ if (rule.contacts !== undefined &&
448
+ !rule.contacts.includes(request.contactId)) {
449
+ continue;
450
+ }
451
+ if (rule.requesters !== undefined &&
452
+ !rule.requesters.includes(request.requesterKey)) {
453
+ continue;
454
+ }
455
+ if (rule.risk === "disclose") {
456
+ if (request.destination === undefined ||
457
+ rule.destinations === undefined ||
458
+ !rule.destinations.includes(request.destination)) {
459
+ continue;
460
+ }
461
+ }
462
+ if (options.revoked?.has(admittedGrantId(policy, rule.id, request))) {
463
+ return {
464
+ decision: "deny",
465
+ reason: "grant_revoked",
466
+ ruleId: rule.id,
467
+ };
468
+ }
469
+ return { decision: "allow", reason: "rule_matched", ruleId: rule.id };
470
+ }
471
+ if (policy.default === "deny") {
472
+ return { decision: "deny", reason: "default_deny" };
473
+ }
474
+ return { decision: "confirm", reason: "default_confirm" };
475
+ }
476
+ /**
477
+ * The grant record a match may create: exactly the requested intent, the
478
+ * rule's limits, and the earlier of the rule and policy expiries.
479
+ * Returns `undefined` unless the decision was `allow`.
480
+ */
481
+ export function admittedGrant(policy, request, result) {
482
+ if (result.decision !== "allow" || result.ruleId === undefined) {
483
+ return undefined;
484
+ }
485
+ const rule = policy.rules.find((entry) => entry.id === result.ruleId);
486
+ if (rule === undefined)
487
+ return undefined;
488
+ const expiresAt = rule.expires_at < policy.expiresAt ? rule.expires_at : policy.expiresAt;
489
+ return {
490
+ id: admittedGrantId(policy, rule.id, request),
491
+ intent: request.intent,
492
+ limits: rule.limits ?? {},
493
+ expiresAt,
494
+ };
495
+ }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./schema.js";
7
7
  export * from "./decision.js";
8
8
  export * from "./state-machine.js";
9
9
  export * from "./signing.js";
10
+ export * from "./identity.js";
10
11
  export * from "./errors.js";
11
12
  export * from "./requester.js";
12
13
  export * from "./consumer.js";
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export * from "./schema.js";
7
7
  export * from "./decision.js";
8
8
  export * from "./state-machine.js";
9
9
  export * from "./signing.js";
10
+ export * from "./identity.js";
10
11
  export * from "./errors.js";
11
12
  export * from "./requester.js";
12
13
  export * from "./consumer.js";