@agentunion/fastaun-browser 0.5.18 → 0.5.20
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/CHANGELOG.md +172 -79
- package/_packed_docs/CHANGELOG.md +172 -79
- package/_packed_docs/INDEX.md +244 -116
- package/_packed_docs/KITE_DOCS_GUIDE.md +98 -69
- package/_packed_docs/agent.md/SCHEMA.md +245 -205
- package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +11 -11
- package/_packed_docs/aun/345/210/206/345/270/203/345/274/217/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +42 -15
- package/_packed_docs/aun/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +4 -4
- package/_packed_docs/protocol/01-/350/272/253/344/273/275/344/270/216/345/207/255/350/257/201/345/215/217/350/256/256-auth.md +45 -134
- package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +1072 -1072
- package/_packed_docs/protocol/07-/351/224/231/350/257/257/347/240/201/344/270/216/347/212/266/346/200/201/346/234/272.md +5 -20
- package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +687 -668
- package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +19 -28
- package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/211.md +443 -0
- package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2112.0.md +161 -0
- package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2112.1.md +658 -0
- package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2113.0.md +233 -0
- package/_packed_docs/protocol/agent.md/SCHEMA.md +4 -0
- package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +1 -1
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +68 -61
- package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +1 -1
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +279 -285
- package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +3 -3
- package/_packed_docs/sdk/09-group-rpc-manual.md +271 -235
- package/_packed_docs/sdk/09-message-rpc-manual.md +47 -6
- package/_packed_docs/sdk/09-storage-rpc-manual.md +70 -68
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +63 -34
- package/_packed_docs/sdk/INDEX.md +105 -34
- package/_packed_docs/sdk/Ownership-SDK/345/256/236/347/216/260/346/214/207/345/215/227.md +169 -0
- package/_packed_docs/sdk/Ownership-SDK/347/274/226/346/216/222/346/214/207/345/215/227.md +349 -0
- package/_packed_docs/sdk/Ownership-SDK/347/274/226/346/216/222/346/214/207/345/215/2272.1.md +922 -0
- package/_packed_docs/sdk/Ownership-SDK/351/233/206/346/210/220/346/214/207/345/215/227.md +270 -0
- package/_packed_docs/sdk/Ownership2.1-SDK/345/272/224/347/224/250/351/233/206/346/210/220/346/214/207/345/215/227.md +300 -0
- package/_packed_docs/sdk/Ownership2.1-SDK/345/274/200/345/217/221/344/270/216/347/247/273/346/244/215/346/214/207/345/215/227.md +382 -0
- package/_packed_docs/sdk/Ownership2.1-TDD/345/210/206/351/230/266/346/256/265/345/256/236/346/226/275/350/256/241/345/210/222.md +708 -0
- package/_packed_docs/sdk/Ownership2.1/350/257/246/347/273/206/350/256/276/350/256/241.md +476 -0
- package/_packed_docs/sdk/Ownership/347/256/241/347/220/206/344/270/223/351/242/230.md +559 -0
- package/_packed_docs/sdk/ProtectedHeaders/347/224/250/346/263/225/344/270/223/351/242/230.md +299 -0
- package/_packed_docs/sdk//345/207/255/350/257/201/344/270/216/347/273/204/347/273/207/346/234/215/345/212/241SDK/345/272/224/347/224/250/351/233/206/346/210/220/346/214/207/345/215/227.md +173 -0
- package/_packed_docs/sdk//345/207/255/350/257/201/344/270/216/347/273/204/347/273/207/346/234/215/345/212/241SDK/345/274/200/345/217/221/344/270/216/347/247/273/346/244/215/346/214/207/345/215/227.md +211 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273SDK/345/256/236/347/216/260/347/247/273/346/244/215/346/214/207/345/215/227.md +331 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273SDK/351/233/206/346/210/220/346/214/207/345/215/227.md +207 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273/344/273/243/347/240/201/345/256/241/346/237/245/346/212/245/345/221/212.md +252 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273/345/256/236/346/226/275/350/256/241/345/210/222.md +156 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273/346/226/271/346/241/210.md +273 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273/350/257/246/347/273/206/350/256/276/350/256/241.md +211 -0
- package/dist/agent-md-schema.d.ts +17 -3
- package/dist/agent-md-schema.d.ts.map +1 -1
- package/dist/agent-md-schema.js +94 -26
- package/dist/agent-md-schema.js.map +1 -1
- package/dist/agent-md.d.ts +54 -3
- package/dist/agent-md.d.ts.map +1 -1
- package/dist/agent-md.js +711 -65
- package/dist/agent-md.js.map +1 -1
- package/dist/aid-store.d.ts +58 -46
- package/dist/aid-store.d.ts.map +1 -1
- package/dist/aid-store.js +254 -248
- package/dist/aid-store.js.map +1 -1
- package/dist/aid.d.ts +4 -1
- package/dist/aid.d.ts.map +1 -1
- package/dist/aid.js +27 -33
- package/dist/aid.js.map +1 -1
- package/dist/auth.d.ts +4 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +426 -39
- package/dist/auth.js.map +1 -1
- package/dist/bundle.js +38179 -32054
- package/dist/cert-utils.d.ts +50 -2
- package/dist/cert-utils.d.ts.map +1 -1
- package/dist/cert-utils.js +105 -47
- package/dist/cert-utils.js.map +1 -1
- package/dist/client/delivery.d.ts +22 -1
- package/dist/client/delivery.d.ts.map +1 -1
- package/dist/client/delivery.js +389 -78
- package/dist/client/delivery.js.map +1 -1
- package/dist/client/rpc-pipeline.d.ts.map +1 -1
- package/dist/client/rpc-pipeline.js +103 -77
- package/dist/client/rpc-pipeline.js.map +1 -1
- package/dist/client/v2-e2ee.d.ts +1 -0
- package/dist/client/v2-e2ee.d.ts.map +1 -1
- package/dist/client/v2-e2ee.js +182 -89
- package/dist/client/v2-e2ee.js.map +1 -1
- package/dist/client.d.ts +100 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1220 -25
- package/dist/client.js.map +1 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +8 -6
- package/dist/crypto.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -2
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +9 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +72 -13
- package/dist/events.js.map +1 -1
- package/dist/facades.d.ts +82 -2
- package/dist/facades.d.ts.map +1 -1
- package/dist/facades.js +100 -6
- package/dist/facades.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/keystore/index.d.ts +17 -0
- package/dist/keystore/index.d.ts.map +1 -1
- package/dist/keystore/indexeddb-shared.d.ts +10 -1
- package/dist/keystore/indexeddb-shared.d.ts.map +1 -1
- package/dist/keystore/indexeddb-shared.js +111 -0
- package/dist/keystore/indexeddb-shared.js.map +1 -1
- package/dist/keystore/indexeddb-token-store.d.ts +7 -1
- package/dist/keystore/indexeddb-token-store.d.ts.map +1 -1
- package/dist/keystore/indexeddb-token-store.js +30 -1
- package/dist/keystore/indexeddb-token-store.js.map +1 -1
- package/dist/org-protocol.d.ts +98 -0
- package/dist/org-protocol.d.ts.map +1 -0
- package/dist/org-protocol.js +1802 -0
- package/dist/org-protocol.js.map +1 -0
- package/dist/ownership-proof.d.ts +93 -5
- package/dist/ownership-proof.d.ts.map +1 -1
- package/dist/ownership-proof.js +239 -53
- package/dist/ownership-proof.js.map +1 -1
- package/dist/ownership-v21.d.ts +194 -0
- package/dist/ownership-v21.d.ts.map +1 -0
- package/dist/ownership-v21.js +678 -0
- package/dist/ownership-v21.js.map +1 -0
- package/dist/ownership-workflow.d.ts +151 -0
- package/dist/ownership-workflow.d.ts.map +1 -0
- package/dist/ownership-workflow.js +1229 -0
- package/dist/ownership-workflow.js.map +1 -0
- package/dist/seq-tracker.d.ts +1 -0
- package/dist/seq-tracker.d.ts.map +1 -1
- package/dist/seq-tracker.js +10 -2
- package/dist/seq-tracker.js.map +1 -1
- package/dist/tools/cross-sdk-agent.d.ts +83 -0
- package/dist/tools/cross-sdk-agent.d.ts.map +1 -1
- package/dist/tools/cross-sdk-agent.js +415 -7
- package/dist/tools/cross-sdk-agent.js.map +1 -1
- package/dist/transport.d.ts +7 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +90 -2
- package/dist/transport.js.map +1 -1
- package/dist/v2/e2ee/decrypt.d.ts +1 -0
- package/dist/v2/e2ee/decrypt.d.ts.map +1 -1
- package/dist/v2/e2ee/decrypt.js +9 -1
- package/dist/v2/e2ee/decrypt.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1802 @@
|
|
|
1
|
+
import { canonicalJson as baseCanonicalJson } from './v2/crypto/canonical.js';
|
|
2
|
+
import { uint8ToBase64 } from './crypto.js';
|
|
3
|
+
import { certFingerprint, parseCertMetadata, publicKeyDerB64, publicKeyFingerprint, verifySignatureWithCert } from './cert-utils.js';
|
|
4
|
+
const AID_RE = /^[^\s/]+\.[^\s/]+$/;
|
|
5
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
6
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
7
|
+
const ERROR_RETRYABLE = { CREDENTIAL_STATUS_UNKNOWN: true };
|
|
8
|
+
const CREDENTIAL_FIELDS = new Set([
|
|
9
|
+
'ver', 'cid', 'iat', 'iss', 'sub', 'aud', 'nbf', 'exp', 'cap', 'delegate',
|
|
10
|
+
'delegation_depth', 'parent', 'proof_chain', 'key_version', 'security_level',
|
|
11
|
+
'revocation_endpoint', 'credential_service', 'ext', 'signature',
|
|
12
|
+
]);
|
|
13
|
+
const CAP_FIELDS = new Set(['ns', 'action', 'resource', 'constraints']);
|
|
14
|
+
const SIGNATURE_FIELDS = new Set(['cert_fingerprint', 'public_key_fingerprint', 'timestamp', 'signature']);
|
|
15
|
+
const BINDING_FIELDS = new Set([
|
|
16
|
+
'ver', 'membership_id', 'binding_version', 'org_id', 'member_aid', 'member_type',
|
|
17
|
+
'category', 'role', 'ownership', 'visibility', 'terms_hash', 'request_id', 'nonce',
|
|
18
|
+
'iat', 'exp', 'member_signature', 'org_signature',
|
|
19
|
+
]);
|
|
20
|
+
const RECEIPT_FIELDS = new Set([
|
|
21
|
+
'ver', 'receipt_id', 'request_id', 'credential_cid', 'credential_hash',
|
|
22
|
+
'registrar_aid', 'service_aid', 'status', 'registered_at', 'exp', 'nonce', 'signature', 'registration_authorization',
|
|
23
|
+
]);
|
|
24
|
+
const REPRESENTATION_FIELDS = new Set([
|
|
25
|
+
'issuer', 'represented', 'from', 'for', 'audience', 'credential_cid', 'method',
|
|
26
|
+
'resource', 'params_hash', 'iat', 'exp', 'nonce', 'signature',
|
|
27
|
+
]);
|
|
28
|
+
const REPRESENTATION_SIGNATURE_FIELDS = new Set(['cert_fingerprint', 'public_key_fingerprint', 'timestamp', 'signature']);
|
|
29
|
+
const FINGERPRINT_RE = /^sha256:[0-9a-f]{64}$/i;
|
|
30
|
+
const SERVICE_EVENTS = new Set([
|
|
31
|
+
'org.member.join_requested', 'org.member.join_approved', 'org.member.join_rejected',
|
|
32
|
+
'org.member.invite_created', 'org.member.invite_accepted', 'org.member.invite_rejected',
|
|
33
|
+
'org.member.changed', 'org.lifecycle.created', 'org.lifecycle.dissolving',
|
|
34
|
+
'org.lifecycle.dissolved', 'credential.revoked',
|
|
35
|
+
]);
|
|
36
|
+
export class ProtocolError extends Error {
|
|
37
|
+
code;
|
|
38
|
+
retryable;
|
|
39
|
+
constructor(code, message = code) {
|
|
40
|
+
super(`${code}: ${message}`);
|
|
41
|
+
this.name = 'ProtocolError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.retryable = protocolErrorRetryable(code);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function verifyServiceEvent(eventName, payload, context) {
|
|
47
|
+
const event = eventName.startsWith('event/') ? eventName.slice(6) : eventName;
|
|
48
|
+
if (!SERVICE_EVENTS.has(event) || !isRecord(payload) || !context || !['type', 'event', 'event_id', 'responder', 'target_aids', 'as_of', 'expires_at', 'signature'].every((field) => field in payload) || payload.type !== event || payload.event !== event || typeof payload.event_id !== 'string' || !payload.event_id || ['_route', '_federation', '_trace', 'member_aids', 'inline_message'].some((field) => field in payload))
|
|
49
|
+
throw invalid('service event payload is invalid');
|
|
50
|
+
normalizeNfc(payload);
|
|
51
|
+
aid(context.targetAid, 'event target');
|
|
52
|
+
aid(payload.responder, 'event responder');
|
|
53
|
+
if (!Array.isArray(payload.target_aids) || !payload.target_aids.length || payload.target_aids.some((target) => typeof target !== 'string' || !AID_RE.test(target)))
|
|
54
|
+
throw invalid('service event audience is invalid');
|
|
55
|
+
const targetAids = new Set(payload.target_aids.map((target) => aidKey(target)));
|
|
56
|
+
if (targetAids.size !== payload.target_aids.length || !targetAids.has(aidKey(context.targetAid)))
|
|
57
|
+
throw invalid('service event audience is invalid');
|
|
58
|
+
const responder = payload.responder;
|
|
59
|
+
const responderKey = aidKey(responder);
|
|
60
|
+
const now = context.now ?? Math.trunc(Date.now() / 1000);
|
|
61
|
+
if (!isInt(now) || !isInt(payload.as_of) || !isInt(payload.expires_at) || payload.as_of <= 0 || payload.expires_at <= payload.as_of || payload.expires_at > payload.as_of + 300 || payload.as_of > now + 300 || now >= payload.expires_at)
|
|
62
|
+
throw invalid('service event time window is invalid');
|
|
63
|
+
let known = true;
|
|
64
|
+
if (event.startsWith('org.')) {
|
|
65
|
+
aid(payload.org_id, 'event organization');
|
|
66
|
+
if (responderKey !== aidKey('org.' + String(payload.org_id).slice(String(payload.org_id).indexOf('.') + 1)))
|
|
67
|
+
throw invalid('organization event authority is invalid');
|
|
68
|
+
}
|
|
69
|
+
else if (event.startsWith('credential.')) {
|
|
70
|
+
const cid = payload.credentialCid ?? payload.credential_cid;
|
|
71
|
+
if (!responderKey.startsWith('credential.') || typeof payload.target !== 'string' || !targetAids.has(aidKey(payload.target)) || typeof cid !== 'string' || !cid || payload.credentialCid !== undefined && payload.credential_cid !== undefined && payload.credentialCid !== payload.credential_cid)
|
|
72
|
+
throw invalid('credential event authority or target is invalid');
|
|
73
|
+
let service;
|
|
74
|
+
try {
|
|
75
|
+
service = await context.resolveCredentialService?.(cid);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw invalid('trusted credential authority is unavailable');
|
|
79
|
+
}
|
|
80
|
+
known = service != null;
|
|
81
|
+
if (known && (typeof service !== 'string' || !AID_RE.test(service) || !aidKey(service).startsWith('credential.') || aidKey(service) !== responderKey))
|
|
82
|
+
throw invalid('credential event authority differs from the trusted provider');
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
throw invalid('service event namespace is invalid');
|
|
86
|
+
try {
|
|
87
|
+
signature(payload.signature);
|
|
88
|
+
const signed = payload.signature;
|
|
89
|
+
if (signed.timestamp !== payload.as_of || typeof context.resolveEventCertificate !== 'function')
|
|
90
|
+
throw new Error('service event signature context is invalid');
|
|
91
|
+
const body = { ...payload };
|
|
92
|
+
delete body.signature;
|
|
93
|
+
const certificate = await context.resolveEventCertificate(responderKey, signed.cert_fingerprint);
|
|
94
|
+
await verifyAgentSignature(signed, body, responder, () => certificate, context.now ?? Math.trunc(Date.now() / 1000), false);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'service event signature verification failed');
|
|
98
|
+
}
|
|
99
|
+
if ((context.now ?? Math.trunc(Date.now() / 1000)) >= payload.expires_at)
|
|
100
|
+
throw invalid('service event time window is invalid');
|
|
101
|
+
return { signature_valid: true, credential_known: known };
|
|
102
|
+
}
|
|
103
|
+
function aidKey(value) {
|
|
104
|
+
return value.trim().toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
export function protocolErrorRetryable(code) {
|
|
107
|
+
return ERROR_RETRYABLE[String(code)] === true;
|
|
108
|
+
}
|
|
109
|
+
function invalid(message) {
|
|
110
|
+
return new ProtocolError('CREDENTIAL_INVALID', message);
|
|
111
|
+
}
|
|
112
|
+
function isRecord(value) {
|
|
113
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
114
|
+
}
|
|
115
|
+
function isInt(value) {
|
|
116
|
+
return typeof value === 'number' && Number.isInteger(value) && Number.isSafeInteger(value);
|
|
117
|
+
}
|
|
118
|
+
function certificatePem(value) {
|
|
119
|
+
if (typeof value === 'string' && value.includes('-----BEGIN CERTIFICATE-----'))
|
|
120
|
+
return value;
|
|
121
|
+
const encoded = typeof value === 'string' ? value : value instanceof Uint8Array ? uint8ToBase64(value) : '';
|
|
122
|
+
if (!encoded || encoded.length % 4 !== 0 || !BASE64_RE.test(encoded) || btoa(atob(encoded)) !== encoded)
|
|
123
|
+
throw new Error('certificate encoding is invalid');
|
|
124
|
+
return '-----BEGIN CERTIFICATE-----\n' + encoded + '\n-----END CERTIFICATE-----';
|
|
125
|
+
}
|
|
126
|
+
function required(obj, fields) {
|
|
127
|
+
const missing = [...fields].filter((field) => !(field in obj));
|
|
128
|
+
if (missing.length > 0)
|
|
129
|
+
throw invalid(`missing fields: ${missing.sort().join(',')}`);
|
|
130
|
+
}
|
|
131
|
+
function onlyKnown(obj, allowed) {
|
|
132
|
+
const unknown = Object.keys(obj).filter((field) => !allowed.has(field));
|
|
133
|
+
if (unknown.length > 0)
|
|
134
|
+
throw invalid(`unknown fields: ${unknown.sort().join(',')}`);
|
|
135
|
+
}
|
|
136
|
+
function aid(value, field) {
|
|
137
|
+
if (typeof value !== 'string' || value.length === 0 || !AID_RE.test(value)) {
|
|
138
|
+
throw invalid(`${field} must be an AID`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function signature(value, field = 'signature') {
|
|
142
|
+
if (!isRecord(value) || !['cert_fingerprint', 'timestamp', 'signature'].every((key) => key in value) || Object.keys(value).some((key) => !SIGNATURE_FIELDS.has(key))) {
|
|
143
|
+
throw invalid(`${field} is invalid`);
|
|
144
|
+
}
|
|
145
|
+
if (typeof value.cert_fingerprint !== 'string' || !FINGERPRINT_RE.test(value.cert_fingerprint)) {
|
|
146
|
+
throw invalid(`${field} is invalid`);
|
|
147
|
+
}
|
|
148
|
+
if (value.public_key_fingerprint !== undefined && (typeof value.public_key_fingerprint !== 'string' || !FINGERPRINT_RE.test(value.public_key_fingerprint)))
|
|
149
|
+
throw invalid(`${field} is invalid`);
|
|
150
|
+
if (!isInt(value.timestamp) || value.timestamp < 0)
|
|
151
|
+
throw invalid(`${field} is invalid`);
|
|
152
|
+
if (typeof value.signature !== 'string' || !strictDerBase64(value.signature))
|
|
153
|
+
throw invalid(`${field} is invalid`);
|
|
154
|
+
}
|
|
155
|
+
function strictDerBase64(value) {
|
|
156
|
+
if (!value || value.length % 4 !== 0 || !BASE64_RE.test(value))
|
|
157
|
+
return false;
|
|
158
|
+
let decodedText;
|
|
159
|
+
try {
|
|
160
|
+
decodedText = atob(value);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
if (btoa(decodedText) !== value)
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
const decoded = Uint8Array.from(decodedText, (char) => char.charCodeAt(0));
|
|
173
|
+
if (decoded.length < 8 || decoded[0] !== 0x30)
|
|
174
|
+
return false;
|
|
175
|
+
const outer = derLength(decoded, 1);
|
|
176
|
+
if (!outer || outer.length !== decoded.length - outer.pos)
|
|
177
|
+
return false;
|
|
178
|
+
let pos = outer.pos;
|
|
179
|
+
for (let i = 0; i < 2; i++) {
|
|
180
|
+
if (pos >= decoded.length || decoded[pos] !== 0x02)
|
|
181
|
+
return false;
|
|
182
|
+
const item = derLength(decoded, pos + 1);
|
|
183
|
+
if (!item || item.length < 1 || item.length > 33 || item.pos + item.length > decoded.length)
|
|
184
|
+
return false;
|
|
185
|
+
const first = decoded[item.pos];
|
|
186
|
+
if (first === 0 && (item.length === 1 || decoded[item.pos + 1] < 0x80))
|
|
187
|
+
return false;
|
|
188
|
+
if ((first & 0x80) !== 0)
|
|
189
|
+
return false;
|
|
190
|
+
pos = item.pos + item.length;
|
|
191
|
+
}
|
|
192
|
+
return pos === decoded.length;
|
|
193
|
+
}
|
|
194
|
+
function derLength(data, pos) {
|
|
195
|
+
if (pos >= data.length)
|
|
196
|
+
return null;
|
|
197
|
+
const first = data[pos];
|
|
198
|
+
pos += 1;
|
|
199
|
+
if (first < 0x80)
|
|
200
|
+
return { length: first, pos };
|
|
201
|
+
const count = first & 0x7f;
|
|
202
|
+
if (count === 0 || count > 2 || pos + count > data.length || data[pos] === 0)
|
|
203
|
+
return null;
|
|
204
|
+
let length = 0;
|
|
205
|
+
for (let i = 0; i < count; i++)
|
|
206
|
+
length = length * 256 + data[pos + i];
|
|
207
|
+
if (length < 0x80)
|
|
208
|
+
return null;
|
|
209
|
+
return { length, pos: pos + count };
|
|
210
|
+
}
|
|
211
|
+
function normalizeNfc(value) {
|
|
212
|
+
if (typeof value === 'string') {
|
|
213
|
+
for (const char of value)
|
|
214
|
+
if (char.codePointAt(0) >= 0xd800 && char.codePointAt(0) <= 0xdfff)
|
|
215
|
+
throw invalid('isolated surrogate is invalid');
|
|
216
|
+
return value.normalize('NFC');
|
|
217
|
+
}
|
|
218
|
+
if (Array.isArray(value))
|
|
219
|
+
return value.map(normalizeNfc);
|
|
220
|
+
if (value !== null && typeof value === 'object') {
|
|
221
|
+
const out = Object.create(null);
|
|
222
|
+
for (const [key, item] of Object.entries(value)) {
|
|
223
|
+
const normalized = normalizeNfc(key);
|
|
224
|
+
if (Object.hasOwn(out, normalized))
|
|
225
|
+
throw invalid('duplicate NFC object key');
|
|
226
|
+
out[normalized] = normalizeNfc(item);
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
if (value === null || typeof value === 'boolean')
|
|
231
|
+
return value;
|
|
232
|
+
if (typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER)
|
|
233
|
+
return value;
|
|
234
|
+
throw invalid('unsafe or unsupported JSON value');
|
|
235
|
+
}
|
|
236
|
+
export function validateOrgRequestJSON(value) {
|
|
237
|
+
if (typeof value === 'string') {
|
|
238
|
+
for (const char of value)
|
|
239
|
+
if (char.codePointAt(0) >= 0xd800 && char.codePointAt(0) <= 0xdfff)
|
|
240
|
+
throw invalid('isolated surrogate is invalid');
|
|
241
|
+
}
|
|
242
|
+
else if (Array.isArray(value)) {
|
|
243
|
+
for (const item of value)
|
|
244
|
+
validateOrgRequestJSON(item);
|
|
245
|
+
}
|
|
246
|
+
else if (isRecord(value)) {
|
|
247
|
+
for (const [key, item] of Object.entries(value)) {
|
|
248
|
+
validateOrgRequestJSON(key);
|
|
249
|
+
validateOrgRequestJSON(item);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
export function canonicalJson(value) {
|
|
254
|
+
return baseCanonicalJson(normalizeNfc(value));
|
|
255
|
+
}
|
|
256
|
+
const CONSTRAINT_OPERATORS = { max_value: 'lte', min_value: 'gte', allowed_values: 'in', denied_values: 'not_in', range: 'between', regex: 'match', path_prefix: 'prefix', time_window: 'between', rate_limit: 'lte', ip_whitelist: 'in' };
|
|
257
|
+
function canonicalPath(raw) {
|
|
258
|
+
if (typeof raw !== 'string')
|
|
259
|
+
return undefined;
|
|
260
|
+
let value;
|
|
261
|
+
try {
|
|
262
|
+
value = decodeURIComponent(raw);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
if (!value.startsWith('/') || /[\\\x00%]/u.test(value) || value.includes('//') || value.split('/').some((part) => part === '.' || part === '..'))
|
|
268
|
+
return undefined;
|
|
269
|
+
return value.length > 1 ? value.replace(/\/$/u, '') : value;
|
|
270
|
+
}
|
|
271
|
+
function constraintIP(raw) {
|
|
272
|
+
if (typeof raw !== 'string' || raw.includes('%'))
|
|
273
|
+
return undefined;
|
|
274
|
+
const parts = raw.split('/');
|
|
275
|
+
if (parts.length > 2)
|
|
276
|
+
return undefined;
|
|
277
|
+
let value = parts[0];
|
|
278
|
+
const ipv4 = (text) => {
|
|
279
|
+
const octets = text.split('.');
|
|
280
|
+
if (octets.length !== 4 || octets.some((part) => !/^(0|[1-9][0-9]{0,2})$/u.test(part) || Number(part) > 255))
|
|
281
|
+
return undefined;
|
|
282
|
+
return octets.map(Number);
|
|
283
|
+
};
|
|
284
|
+
let bits;
|
|
285
|
+
let address = 0n;
|
|
286
|
+
if (!value.includes(':')) {
|
|
287
|
+
const octets = ipv4(value);
|
|
288
|
+
if (!octets)
|
|
289
|
+
return undefined;
|
|
290
|
+
bits = 32;
|
|
291
|
+
for (const octet of octets)
|
|
292
|
+
address = address * 256n + BigInt(octet);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
bits = 128;
|
|
296
|
+
if (value.includes('.')) {
|
|
297
|
+
const end = value.lastIndexOf(':');
|
|
298
|
+
const octets = ipv4(value.slice(end + 1));
|
|
299
|
+
if (!octets)
|
|
300
|
+
return undefined;
|
|
301
|
+
value = value.slice(0, end + 1) + ((octets[0] * 256 + octets[1]).toString(16)) + ':' + ((octets[2] * 256 + octets[3]).toString(16));
|
|
302
|
+
}
|
|
303
|
+
const halves = value.split('::');
|
|
304
|
+
if (halves.length > 2)
|
|
305
|
+
return undefined;
|
|
306
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
307
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
308
|
+
if ([...left, ...right].some((part) => !/^[0-9a-fA-F]{1,4}$/u.test(part)))
|
|
309
|
+
return undefined;
|
|
310
|
+
if (halves.length === 1 && left.length !== 8 || halves.length === 2 && left.length + right.length >= 8)
|
|
311
|
+
return undefined;
|
|
312
|
+
const groups = halves.length === 1 ? left : [...left, ...Array(8 - left.length - right.length).fill('0'), ...right];
|
|
313
|
+
for (const group of groups)
|
|
314
|
+
address = address * 65536n + BigInt(Number.parseInt(group, 16));
|
|
315
|
+
}
|
|
316
|
+
const prefix = parts.length === 1 ? bits : /^[0-9]+$/u.test(parts[1]) ? Number(parts[1]) : -1;
|
|
317
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits)
|
|
318
|
+
return undefined;
|
|
319
|
+
const shift = BigInt(bits - prefix);
|
|
320
|
+
return { bits, prefix, address: (address >> shift) << shift };
|
|
321
|
+
}
|
|
322
|
+
function validConstraintRegex(value) {
|
|
323
|
+
if (typeof value !== 'string' || new TextEncoder().encode(value).length > 256 || value.length < 2 || !value.startsWith('^') || !value.endsWith('$'))
|
|
324
|
+
return false;
|
|
325
|
+
let inside = false;
|
|
326
|
+
let repeats = 0;
|
|
327
|
+
for (let index = 1; index < value.length - 1; index++) {
|
|
328
|
+
const char = value[index];
|
|
329
|
+
if (char === '\\') {
|
|
330
|
+
index++;
|
|
331
|
+
if (index >= value.length - 1 || !'dDwW\\[]{}*+?^$-.nrtf'.includes(value[index]) || value[index] === '-' && !inside)
|
|
332
|
+
return false;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (char === '[') {
|
|
336
|
+
if (inside)
|
|
337
|
+
return false;
|
|
338
|
+
inside = true;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (char === ']') {
|
|
342
|
+
if (!inside)
|
|
343
|
+
return false;
|
|
344
|
+
inside = false;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (inside) {
|
|
348
|
+
if (['&&', '--', '~~'].includes(value.slice(index, index + 2)))
|
|
349
|
+
return false;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if ('()|.^$'.includes(char))
|
|
353
|
+
return false;
|
|
354
|
+
if ('*+?'.includes(char))
|
|
355
|
+
repeats++;
|
|
356
|
+
if (char === '{') {
|
|
357
|
+
const end = value.indexOf('}', index);
|
|
358
|
+
if (end < 0)
|
|
359
|
+
return false;
|
|
360
|
+
const bounds = value.slice(index + 1, end).split(',');
|
|
361
|
+
if (bounds.length > 2 || bounds.some((bound) => !/^[0-9]+$/u.test(bound) || Number(bound) > 128) || bounds.length === 2 && Number(bounds[0]) > Number(bounds[1]))
|
|
362
|
+
return false;
|
|
363
|
+
index = end;
|
|
364
|
+
repeats++;
|
|
365
|
+
}
|
|
366
|
+
if (repeats > 1)
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
new RegExp(value, 'u');
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
return !inside;
|
|
376
|
+
}
|
|
377
|
+
function validateCapability(capability) {
|
|
378
|
+
const constraints = capability.constraints;
|
|
379
|
+
if (!Array.isArray(constraints) || constraints.length > 64)
|
|
380
|
+
throw invalid('capability constraints are invalid');
|
|
381
|
+
const groups = new Map();
|
|
382
|
+
const number = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
383
|
+
for (const item of constraints) {
|
|
384
|
+
if (!isRecord(item) || !['type', 'field', 'value'].every((field) => field in item) || Object.keys(item).some((field) => !['type', 'field', 'value', 'unit', 'operator'].includes(field)))
|
|
385
|
+
throw invalid('constraint fields are invalid');
|
|
386
|
+
const kind = item.type;
|
|
387
|
+
const field = item.field;
|
|
388
|
+
const unit = item.unit ?? '';
|
|
389
|
+
if (typeof kind !== 'string' || !Object.hasOwn(CONSTRAINT_OPERATORS, kind) || typeof field !== 'string' || field.length > 128 || !/^[A-Za-z_][A-Za-z0-9_.]*$/u.test(field) || field.includes('..') || field.endsWith('.'))
|
|
390
|
+
throw invalid('constraint type or field is invalid');
|
|
391
|
+
if ('operator' in item && item.operator !== CONSTRAINT_OPERATORS[kind])
|
|
392
|
+
throw invalid('constraint operator is invalid');
|
|
393
|
+
if ('unit' in item && (typeof unit !== 'string' || unit.length === 0 || new TextEncoder().encode(unit).length > 32))
|
|
394
|
+
throw invalid('constraint unit is invalid');
|
|
395
|
+
const key = field + '\x00' + constraintUnit(item);
|
|
396
|
+
let group = groups.get(key);
|
|
397
|
+
if (!group) {
|
|
398
|
+
group = { lower: -Infinity, upper: Infinity, denied: new Set(), timeFrom: '00:00', timeTo: '24:00', field };
|
|
399
|
+
groups.set(key, group);
|
|
400
|
+
}
|
|
401
|
+
const value = item.value;
|
|
402
|
+
switch (kind) {
|
|
403
|
+
case 'max_value':
|
|
404
|
+
case 'min_value':
|
|
405
|
+
case 'rate_limit':
|
|
406
|
+
if (!number(value))
|
|
407
|
+
throw invalid('constraint value must be numeric');
|
|
408
|
+
if (kind === 'rate_limit') {
|
|
409
|
+
if (!Number.isSafeInteger(value) || value <= 0 || !['', 'second', 'minute', 'hour', 'day'].includes(unit))
|
|
410
|
+
throw invalid('rate limit is invalid');
|
|
411
|
+
}
|
|
412
|
+
else if (kind === 'max_value')
|
|
413
|
+
group.upper = Math.min(group.upper, value);
|
|
414
|
+
else
|
|
415
|
+
group.lower = Math.max(group.lower, value);
|
|
416
|
+
break;
|
|
417
|
+
case 'range':
|
|
418
|
+
if (!Array.isArray(value) || value.length !== 2 || !number(value[0]) || !number(value[1]) || value[0] > value[1])
|
|
419
|
+
throw invalid('range is invalid');
|
|
420
|
+
group.lower = Math.max(group.lower, value[0]);
|
|
421
|
+
group.upper = Math.min(group.upper, value[1]);
|
|
422
|
+
break;
|
|
423
|
+
case 'allowed_values':
|
|
424
|
+
case 'denied_values': {
|
|
425
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 64 || unit || value.some((entry) => entry !== null && typeof entry === 'object'))
|
|
426
|
+
throw invalid('enumeration constraint is invalid');
|
|
427
|
+
const values = new Set(value.map(canonicalText));
|
|
428
|
+
if (values.size !== value.length)
|
|
429
|
+
throw invalid('enumeration contains duplicates');
|
|
430
|
+
if (kind === 'denied_values')
|
|
431
|
+
for (const entry of values)
|
|
432
|
+
group.denied.add(entry);
|
|
433
|
+
else if (!group.allowed)
|
|
434
|
+
group.allowed = values;
|
|
435
|
+
else
|
|
436
|
+
for (const entry of group.allowed)
|
|
437
|
+
if (!values.has(entry))
|
|
438
|
+
group.allowed.delete(entry);
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
case 'regex':
|
|
442
|
+
if (unit || !validConstraintRegex(value))
|
|
443
|
+
throw invalid('regex is outside the supported subset');
|
|
444
|
+
break;
|
|
445
|
+
case 'path_prefix': {
|
|
446
|
+
const path = canonicalPath(value);
|
|
447
|
+
if (unit || typeof value !== 'string' || new TextEncoder().encode(value).length > 1024 || path === undefined || path !== (value.replace(/\/$/u, '') || '/'))
|
|
448
|
+
throw invalid('path prefix is invalid');
|
|
449
|
+
if (group.path === undefined || group.path === '/' || path === group.path || path.startsWith(group.path + '/'))
|
|
450
|
+
group.path = path;
|
|
451
|
+
else if (path !== '/' && !group.path.startsWith(path + '/'))
|
|
452
|
+
throw invalid('capability path prefixes conflict');
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
case 'time_window':
|
|
456
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== 'string' || typeof value[1] !== 'string' || !['', 'UTC'].includes(unit) || !/^([01][0-9]|2[0-3]):[0-5][0-9]$/u.test(value[0]) || !(/^([01][0-9]|2[0-3]):[0-5][0-9]$/u.test(value[1]) || value[1] === '24:00') || value[0] >= value[1])
|
|
457
|
+
throw invalid('time window is invalid');
|
|
458
|
+
if (value[0] > group.timeFrom)
|
|
459
|
+
group.timeFrom = value[0];
|
|
460
|
+
if (value[1] < group.timeTo)
|
|
461
|
+
group.timeTo = value[1];
|
|
462
|
+
break;
|
|
463
|
+
case 'ip_whitelist': {
|
|
464
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 64 || unit || value.some((entry) => !constraintIP(entry)))
|
|
465
|
+
throw invalid('IP whitelist is invalid');
|
|
466
|
+
const networks = value.map((entry) => constraintIP(entry));
|
|
467
|
+
if (!group.ips)
|
|
468
|
+
group.ips = networks;
|
|
469
|
+
else {
|
|
470
|
+
const intersection = new Map();
|
|
471
|
+
for (const left of group.ips)
|
|
472
|
+
for (const right of networks) {
|
|
473
|
+
if (left.bits !== right.bits)
|
|
474
|
+
continue;
|
|
475
|
+
const prefix = Math.min(left.prefix, right.prefix);
|
|
476
|
+
const shift = BigInt(left.bits - prefix);
|
|
477
|
+
if (left.address >> shift === right.address >> shift) {
|
|
478
|
+
const narrower = left.prefix >= right.prefix ? left : right;
|
|
479
|
+
intersection.set(`${narrower.bits}/${narrower.prefix}/${narrower.address}`, narrower);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
group.ips = [...intersection.values()];
|
|
483
|
+
}
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for (const group of groups.values()) {
|
|
489
|
+
if (group.lower > group.upper || group.timeFrom >= group.timeTo || group.ips?.length === 0)
|
|
490
|
+
throw invalid('capability constraints conflict');
|
|
491
|
+
if (group.allowed) {
|
|
492
|
+
for (const entry of group.allowed)
|
|
493
|
+
if (group.denied.has(entry) || constraints.some((item) => isRecord(item) && item.field === group.field && item.type !== 'rate_limit' && !constraintSatisfied(item, parseJsonStrict(entry))))
|
|
494
|
+
group.allowed.delete(entry);
|
|
495
|
+
if (!group.allowed.size)
|
|
496
|
+
throw invalid('capability constraints conflict');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const ns = capability.ns;
|
|
500
|
+
const action = capability.action;
|
|
501
|
+
const resource = capability.resource;
|
|
502
|
+
if (/[*?]/u.test(ns) || /[*?]/u.test(action) && action !== '*' || /[*?]/u.test(resource) && resource !== '*' || action === '*' && resource === '*')
|
|
503
|
+
throw invalid('wildcard scope is invalid');
|
|
504
|
+
if (action === '*') {
|
|
505
|
+
const bounded = constraints.some((item) => isRecord(item) && item.type === 'allowed_values' && item.field === 'operation' && item.value.every((entry) => ['get', 'list', 'read', 'usage'].includes(entry)));
|
|
506
|
+
if (!bounded || ns === 'org')
|
|
507
|
+
throw invalid('wildcard action requires an explicit read-only set');
|
|
508
|
+
}
|
|
509
|
+
if (resource === '*') {
|
|
510
|
+
const bounded = constraints.some((item) => isRecord(item) && item.field === 'resource' && ['path_prefix', 'allowed_values'].includes(item.type));
|
|
511
|
+
if (!bounded || !['get', 'list', 'read', 'usage'].includes(action.split('.').at(-1)))
|
|
512
|
+
throw invalid('wildcard resource requires a bounded read-only scope');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
export function validateCredential(credential, chainDepth = 0, externalProofChain) {
|
|
516
|
+
if (!isRecord(credential))
|
|
517
|
+
throw invalid('credential must be an object');
|
|
518
|
+
normalizeNfc(credential);
|
|
519
|
+
onlyKnown(credential, CREDENTIAL_FIELDS);
|
|
520
|
+
required(credential, new Set(['ver', 'cid', 'iat', 'iss', 'sub', 'aud', 'nbf', 'exp', 'cap', 'key_version', 'signature']));
|
|
521
|
+
if (credential.ver !== 'aun-cap/1' || typeof credential.cid !== 'string' || credential.cid.length === 0) {
|
|
522
|
+
throw invalid('credential version or cid is invalid');
|
|
523
|
+
}
|
|
524
|
+
aid(credential.iss, 'iss');
|
|
525
|
+
aid(credential.sub, 'sub');
|
|
526
|
+
if (!isInt(credential.iat) || !isInt(credential.nbf) || !isInt(credential.exp))
|
|
527
|
+
throw invalid('credential timestamps must be integers');
|
|
528
|
+
if (credential.iat > credential.nbf || credential.exp <= credential.nbf)
|
|
529
|
+
throw invalid('credential time window is invalid');
|
|
530
|
+
if (!Array.isArray(credential.aud) || credential.aud.length === 0)
|
|
531
|
+
throw invalid('aud must be a non-empty array');
|
|
532
|
+
for (const audience of credential.aud)
|
|
533
|
+
aid(audience, 'aud');
|
|
534
|
+
if (!Array.isArray(credential.cap) || credential.cap.length === 0)
|
|
535
|
+
throw invalid('cap must be a non-empty array');
|
|
536
|
+
for (const capability of credential.cap) {
|
|
537
|
+
if (!isRecord(capability) || Object.keys(capability).length !== CAP_FIELDS.size || Object.keys(capability).some((key) => !CAP_FIELDS.has(key)))
|
|
538
|
+
throw invalid('cap item is invalid');
|
|
539
|
+
for (const field of ['ns', 'action', 'resource'])
|
|
540
|
+
if (typeof capability[field] !== 'string' || capability[field].length === 0)
|
|
541
|
+
throw invalid('cap item is invalid');
|
|
542
|
+
if (!Array.isArray(capability.constraints))
|
|
543
|
+
throw invalid('cap constraints must be an array');
|
|
544
|
+
validateCapability(capability);
|
|
545
|
+
}
|
|
546
|
+
if ('delegate' in credential && typeof credential.delegate !== 'boolean')
|
|
547
|
+
throw invalid('delegate must be boolean');
|
|
548
|
+
const delegationDepth = 'delegation_depth' in credential ? credential.delegation_depth : 0;
|
|
549
|
+
if (!isInt(delegationDepth) || delegationDepth < 0)
|
|
550
|
+
throw invalid('delegation_depth is invalid');
|
|
551
|
+
const parent = credential.parent;
|
|
552
|
+
if (credential.proof_chain !== undefined && externalProofChain !== undefined && canonicalText(credential.proof_chain) !== canonicalText(externalProofChain))
|
|
553
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'embedded proof_chain conflicts with external proof_chain');
|
|
554
|
+
if (parent == null && delegationDepth !== 0)
|
|
555
|
+
throw invalid('root credential must have delegation_depth 0');
|
|
556
|
+
if (parent != null) {
|
|
557
|
+
if (!('delegation_depth' in credential))
|
|
558
|
+
throw invalid('child delegation_depth is required');
|
|
559
|
+
if (typeof parent !== 'string' || parent.length === 0)
|
|
560
|
+
throw invalid('parent is invalid');
|
|
561
|
+
const chain = credential.proof_chain ?? externalProofChain;
|
|
562
|
+
if (!Array.isArray(chain) || chain.length === 0)
|
|
563
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'proof_chain is required');
|
|
564
|
+
if (chain.length > 8 || chainDepth >= 8)
|
|
565
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'proof_chain is too deep');
|
|
566
|
+
for (let index = 0; index < chain.length; index++) {
|
|
567
|
+
const item = chain[index];
|
|
568
|
+
validateCredential(item, chainDepth + 1);
|
|
569
|
+
if (canonicalText(item.proof_chain ?? []) !== canonicalText(chain.slice(index + 1)))
|
|
570
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'embedded proof_chain conflicts with outer chain');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else if ('proof_chain' in credential && JSON.stringify(credential.proof_chain) !== '[]') {
|
|
574
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'root proof_chain must be empty');
|
|
575
|
+
}
|
|
576
|
+
if ('security_level' in credential && !['low', 'medium', 'high'].includes(credential.security_level))
|
|
577
|
+
throw invalid('security_level is invalid');
|
|
578
|
+
if (typeof credential.key_version !== 'string' || credential.key_version.length === 0)
|
|
579
|
+
throw invalid('key_version is invalid');
|
|
580
|
+
if ('ext' in credential && (!isRecord(credential.ext) || Object.keys(credential.ext).some((key) => typeof key !== 'string')))
|
|
581
|
+
throw invalid('ext is invalid');
|
|
582
|
+
const ext = (credential.ext ?? {});
|
|
583
|
+
if (ext.credential_type === 'archive') {
|
|
584
|
+
aid(ext.org_id, 'archive org_id');
|
|
585
|
+
if ('bound_to_membership' in ext && ext.bound_to_membership !== false || !Array.isArray(ext.audit_fields) || !ext.audit_fields.length || ext.audit_fields.some((field) => typeof field !== 'string' || !field))
|
|
586
|
+
throw invalid('archive credential requires independent, explicit audit fields');
|
|
587
|
+
const window = ext.audit_window;
|
|
588
|
+
const start = isRecord(window) ? auditScopeTime(window.from) : undefined;
|
|
589
|
+
const end = isRecord(window) ? auditScopeTime(window.to) : undefined;
|
|
590
|
+
if (start === undefined || end === undefined || end < start)
|
|
591
|
+
throw invalid('archive audit window must have explicit bounds');
|
|
592
|
+
if (!credential.aud.includes(ext.org_id) || credential.cap.some((cap) => !isRecord(cap) || cap.ns !== 'org' || cap.action !== 'role.auditor' || cap.resource !== ext.org_id))
|
|
593
|
+
throw invalid('archive credential must grant only organization audit reads');
|
|
594
|
+
}
|
|
595
|
+
signature(credential.signature);
|
|
596
|
+
}
|
|
597
|
+
export async function createCredential(options, signer) {
|
|
598
|
+
if (!isRecord(options) || 'signature' in options)
|
|
599
|
+
throw invalid('credential creation requires an unsigned object');
|
|
600
|
+
const value = structuredClone(options);
|
|
601
|
+
const now = Math.trunc(Date.now() / 1000);
|
|
602
|
+
const cid = 'cred-' + [...crypto.getRandomValues(new Uint8Array(16))].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
603
|
+
const defaults = { ver: 'aun-cap/1', cid, iat: now, iss: signer.aid, delegate: false, proof_chain: [] };
|
|
604
|
+
for (const [key, item] of Object.entries(defaults))
|
|
605
|
+
if (!(key in value))
|
|
606
|
+
value[key] = item;
|
|
607
|
+
if (!('nbf' in value))
|
|
608
|
+
value.nbf = value.iat;
|
|
609
|
+
if (value.iss !== signer.aid)
|
|
610
|
+
throw invalid('issuer does not match signing AID');
|
|
611
|
+
const chain = value.proof_chain;
|
|
612
|
+
const parentDepth = Array.isArray(chain) && isRecord(chain[0]) ? chain[0].delegation_depth ?? 0 : 0;
|
|
613
|
+
if (!('delegation_depth' in value))
|
|
614
|
+
value.delegation_depth = value.parent !== undefined && value.parent !== null && isInt(parentDepth) ? parentDepth + 1 : 0;
|
|
615
|
+
if (Array.isArray(value.cap))
|
|
616
|
+
for (const item of value.cap)
|
|
617
|
+
if (isRecord(item) && !('constraints' in item))
|
|
618
|
+
item.constraints = [];
|
|
619
|
+
if (!isInt(value.iat) || !isInt(value.exp) || value.iat > now || value.exp <= now)
|
|
620
|
+
throw invalid('credential signing time is outside its validity window');
|
|
621
|
+
const result = await signer.sign(canonicalJson(value));
|
|
622
|
+
if (!result.ok)
|
|
623
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'credential signing failed');
|
|
624
|
+
value.signature = { cert_fingerprint: signer.certFingerprint, timestamp: now, signature: result.data.signature };
|
|
625
|
+
credentialChainDocuments(value);
|
|
626
|
+
await verifyCredentialSignature(value, (fingerprint) => fingerprint === signer.certFingerprint ? signer.certPem : null);
|
|
627
|
+
return value;
|
|
628
|
+
}
|
|
629
|
+
function organizationIdentifier() { return [...crypto.getRandomValues(new Uint8Array(16))].map((byte) => byte.toString(16).padStart(2, '0')).join(''); }
|
|
630
|
+
function organizationNonce() { return btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); }
|
|
631
|
+
export async function signMembershipBinding(binding, signer, party) {
|
|
632
|
+
validateMembershipBindingPayload(binding, true);
|
|
633
|
+
if (party !== 'member' && party !== 'org' || party === 'member' && binding.member_aid !== signer.aid)
|
|
634
|
+
throw invalid('membership signer does not match party');
|
|
635
|
+
const value = structuredClone(binding);
|
|
636
|
+
const payload = { ...value };
|
|
637
|
+
delete payload.member_signature;
|
|
638
|
+
delete payload.org_signature;
|
|
639
|
+
value[party + '_signature'] = await signProtocolPayload(payload, signer);
|
|
640
|
+
return value;
|
|
641
|
+
}
|
|
642
|
+
async function signProtocolPayload(payload, signer) {
|
|
643
|
+
const now = Math.trunc(Date.now() / 1000);
|
|
644
|
+
const result = await signer.sign(canonicalJson(payload));
|
|
645
|
+
if (!result.ok)
|
|
646
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'protocol signing failed');
|
|
647
|
+
const signature = { cert_fingerprint: signer.certFingerprint, timestamp: now, signature: result.data.signature };
|
|
648
|
+
try {
|
|
649
|
+
await verifyAgentSignature(signature, payload, signer.aid, () => signer.certPem, now);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'protocol signing identity is invalid');
|
|
653
|
+
}
|
|
654
|
+
return signature;
|
|
655
|
+
}
|
|
656
|
+
export async function createMembershipBinding(options, memberSigner, orgSigner) {
|
|
657
|
+
const value = structuredClone(options);
|
|
658
|
+
const terms = 'terms' in value ? value.terms : {};
|
|
659
|
+
delete value.terms;
|
|
660
|
+
if (!isRecord(terms) || 'member_signature' in value || 'org_signature' in value)
|
|
661
|
+
throw invalid('membership creation requires unsigned terms');
|
|
662
|
+
const now = Math.trunc(Date.now() / 1000);
|
|
663
|
+
const defaults = { ver: 'aun-membership-binding/1', membership_id: 'mem-' + organizationIdentifier(), binding_version: 1, member_type: 'custom', category: 'member', role: 'member', ownership: 'personal', visibility: 'members', request_id: organizationIdentifier(), nonce: organizationNonce(), iat: now, exp: now + 3600, terms_hash: digest(terms) };
|
|
664
|
+
for (const [key, item] of Object.entries(defaults))
|
|
665
|
+
if (!(key in value))
|
|
666
|
+
value[key] = item;
|
|
667
|
+
if (value.terms_hash !== digest(terms) || !['owner', 'admin', 'member', 'observer'].includes(String(value.role)))
|
|
668
|
+
throw invalid('membership terms or role is invalid');
|
|
669
|
+
validateMembershipBindingPayload(value, true);
|
|
670
|
+
let binding = value;
|
|
671
|
+
if (memberSigner)
|
|
672
|
+
binding = await signMembershipBinding(binding, memberSigner, 'member');
|
|
673
|
+
if (orgSigner)
|
|
674
|
+
binding = await signMembershipBinding(binding, orgSigner, 'org');
|
|
675
|
+
return binding;
|
|
676
|
+
}
|
|
677
|
+
export async function createRepresentationProof(credential, params, binding, signer, proofChain) {
|
|
678
|
+
const now = Math.trunc(Date.now() / 1000);
|
|
679
|
+
const target = params.for;
|
|
680
|
+
validateCredentialAt(credential, now, undefined, proofChain);
|
|
681
|
+
if (typeof target !== 'string' || !AID_RE.test(target) || target === signer.aid)
|
|
682
|
+
throw invalid('representation requires another target AID');
|
|
683
|
+
validateRequestBinding(binding, String(binding.method), String(binding.target), params, String(credential.cid), String(params.nonce), String(params.request_id), now);
|
|
684
|
+
const proof = { issuer: signer.aid, from: signer.aid, represented: credential.sub, for: target, audience: binding.target, resource: target, method: binding.method, params_hash: binding.params_hash, iat: binding.iat, exp: binding.exp, nonce: binding.nonce, credential_cid: credential.cid };
|
|
685
|
+
proof.signature = await signProtocolPayload(proof, signer);
|
|
686
|
+
return proof;
|
|
687
|
+
}
|
|
688
|
+
export async function signTransferAcceptance(proposal, signer) {
|
|
689
|
+
if (!isRecord(proposal) || proposal.ver !== 'aun-org-transfer/1' || proposal.target_member_aid !== signer.aid || !isInt(proposal.exp) || proposal.exp <= Math.trunc(Date.now() / 1000))
|
|
690
|
+
throw invalid('transfer proposal does not match the accepting identity');
|
|
691
|
+
const payload = { ...proposal };
|
|
692
|
+
delete payload.signature;
|
|
693
|
+
return { transfer_id: proposal.transfer_id, proposal_hash: digest(payload), target_signature: await signProtocolPayload(payload, signer) };
|
|
694
|
+
}
|
|
695
|
+
export async function prepareOrganizationRequest(method, params, target, signer, options = {}) {
|
|
696
|
+
if (!method.startsWith('org.') || !AID_RE.test(target))
|
|
697
|
+
throw invalid('organization method and service AID are required');
|
|
698
|
+
let value = structuredClone(params);
|
|
699
|
+
const memberSigner = options.memberSigner ?? signer;
|
|
700
|
+
let binding = isRecord(value.membership_binding) ? value.membership_binding : undefined;
|
|
701
|
+
if (method === 'org.member.inviteAccept' && value.for !== undefined && value.for !== signer.aid && (!binding || !('org_signature' in binding) || !('credential_template' in value) || !('registration_authorization' in value)))
|
|
702
|
+
throw invalid('represented invitation acceptance requires original organization materials');
|
|
703
|
+
if ('membership_binding' in value)
|
|
704
|
+
validateMembershipBindingPayload(value.membership_binding, true);
|
|
705
|
+
const anchored = ['org.lifecycle.create', 'org.member.join', 'org.member.approveJoin', 'org.member.inviteAccept', 'org.member.renewCredential'].includes(method);
|
|
706
|
+
for (const key of ['request_id', 'nonce']) {
|
|
707
|
+
if (anchored && binding) {
|
|
708
|
+
if (key in value && value[key] !== binding[key])
|
|
709
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'request identity differs from membership binding');
|
|
710
|
+
value[key] ??= binding[key];
|
|
711
|
+
}
|
|
712
|
+
if (!(key in value))
|
|
713
|
+
value[key] = key === 'nonce' ? organizationNonce() : organizationIdentifier();
|
|
714
|
+
if (typeof value[key] !== 'string' || !value[key])
|
|
715
|
+
throw invalid('request binding identity is invalid');
|
|
716
|
+
}
|
|
717
|
+
if (method === 'org.lifecycle.create') {
|
|
718
|
+
if (!('owner_aid' in value))
|
|
719
|
+
value.owner_aid = signer.aid;
|
|
720
|
+
if (value.owner_aid !== signer.aid)
|
|
721
|
+
throw invalid('initial owner must match the authenticated signer');
|
|
722
|
+
if (!binding) {
|
|
723
|
+
if (!options.orgSigner || options.orgSigner.aid !== value.org_id)
|
|
724
|
+
throw invalid('organization signing identity is required');
|
|
725
|
+
binding = await createMembershipBinding({ org_id: value.org_id, member_aid: signer.aid, role: 'owner', member_type: 'human', terms: { org_id: value.org_id, owner_aid: signer.aid }, request_id: value.request_id, nonce: value.nonce }, signer, options.orgSigner);
|
|
726
|
+
value.membership_binding = binding;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
else if (method === 'org.member.join') {
|
|
730
|
+
if (!('member_aid' in value))
|
|
731
|
+
value.member_aid = 'for' in value ? value.for : memberSigner.aid;
|
|
732
|
+
for (const [key, fallback] of Object.entries({ member_type: 'custom', category: 'member', requested_role: 'member', ownership: 'personal', visibility: 'members', terms: {} }))
|
|
733
|
+
if (!(key in value))
|
|
734
|
+
value[key] = binding?.[key === 'requested_role' ? 'role' : key] ?? fallback;
|
|
735
|
+
if (!('terms_hash' in value))
|
|
736
|
+
value.terms_hash = digest(value.terms);
|
|
737
|
+
if (!binding) {
|
|
738
|
+
const material = {};
|
|
739
|
+
for (const key of ['org_id', 'member_aid', 'member_type', 'category', 'ownership', 'visibility', 'terms', 'request_id', 'nonce'])
|
|
740
|
+
material[key] = value[key];
|
|
741
|
+
material.role = value.requested_role;
|
|
742
|
+
if ('exp' in value)
|
|
743
|
+
material.exp = value.exp;
|
|
744
|
+
binding = await createMembershipBinding(material, memberSigner);
|
|
745
|
+
}
|
|
746
|
+
else if (!('member_signature' in binding))
|
|
747
|
+
binding = await signMembershipBinding(binding, memberSigner, 'member');
|
|
748
|
+
value.membership_binding = binding;
|
|
749
|
+
value.member_signature = binding.member_signature;
|
|
750
|
+
value.exp = binding.exp;
|
|
751
|
+
}
|
|
752
|
+
else if (method === 'org.member.invite') {
|
|
753
|
+
const targets = value.targets;
|
|
754
|
+
if (!Array.isArray(targets) || !targets.length || targets.some((aid) => typeof aid !== 'string' || !AID_RE.test(aid)) || new Set(targets).size !== targets.length)
|
|
755
|
+
throw invalid('invite targets must be distinct concrete AIDs');
|
|
756
|
+
for (const [key, fallback] of Object.entries({ member_type: 'custom', category: 'member', role: 'member', ownership: 'personal', visibility: 'members', terms: {} }))
|
|
757
|
+
if (!(key in value))
|
|
758
|
+
value[key] = binding?.[key] ?? fallback;
|
|
759
|
+
if (!('terms_hash' in value))
|
|
760
|
+
value.terms_hash = digest(value.terms);
|
|
761
|
+
const materials = 'membership_bindings' in value ? value.membership_bindings : (targets.length === 1 && binding ? { [String(targets[0])]: binding } : {});
|
|
762
|
+
if (!isRecord(materials) || Object.keys(materials).some((aid) => !targets.includes(aid)))
|
|
763
|
+
throw invalid('invite bindings do not match targets');
|
|
764
|
+
for (const targetAid of targets) {
|
|
765
|
+
let material = materials[targetAid];
|
|
766
|
+
if (!isRecord(material) || !('org_signature' in material)) {
|
|
767
|
+
if (!options.orgSigner || options.orgSigner.aid !== value.org_id)
|
|
768
|
+
throw invalid('organization signing identity is required');
|
|
769
|
+
if (material === undefined) {
|
|
770
|
+
const fields = { member_aid: targetAid };
|
|
771
|
+
for (const key of ['org_id', 'member_type', 'category', 'role', 'ownership', 'visibility', 'terms'])
|
|
772
|
+
fields[key] = value[key];
|
|
773
|
+
if (targets.length === 1)
|
|
774
|
+
fields.nonce = value.nonce;
|
|
775
|
+
if ('exp' in value)
|
|
776
|
+
fields.exp = value.exp;
|
|
777
|
+
material = await createMembershipBinding(fields, undefined, options.orgSigner);
|
|
778
|
+
}
|
|
779
|
+
else
|
|
780
|
+
material = await signMembershipBinding(material, options.orgSigner, 'org');
|
|
781
|
+
}
|
|
782
|
+
validateMembershipBindingPayload(material, true);
|
|
783
|
+
materials[targetAid] = material;
|
|
784
|
+
}
|
|
785
|
+
if (targets.length === 1) {
|
|
786
|
+
const nonce = materials[String(targets[0])].nonce;
|
|
787
|
+
if ('nonce' in params && params.nonce !== nonce)
|
|
788
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'request nonce differs from invite binding');
|
|
789
|
+
value.nonce = nonce;
|
|
790
|
+
}
|
|
791
|
+
value.membership_bindings = materials;
|
|
792
|
+
}
|
|
793
|
+
else if (method === 'org.member.approveJoin' && binding) {
|
|
794
|
+
if (!('org_signature' in binding))
|
|
795
|
+
binding = await signMembershipBinding(binding, signer, 'org');
|
|
796
|
+
value.membership_binding = binding;
|
|
797
|
+
value.org_signature = binding.org_signature;
|
|
798
|
+
if (!('approved_role' in value))
|
|
799
|
+
value.approved_role = binding.role;
|
|
800
|
+
if (!('join_request_id' in value))
|
|
801
|
+
value.join_request_id = binding.request_id;
|
|
802
|
+
}
|
|
803
|
+
else if (method === 'org.member.inviteAccept' && binding) {
|
|
804
|
+
if (!('member_signature' in binding))
|
|
805
|
+
binding = await signMembershipBinding(binding, memberSigner, 'member');
|
|
806
|
+
value.membership_binding = binding;
|
|
807
|
+
value.member_signature = binding.member_signature;
|
|
808
|
+
if (!('member_aid' in value))
|
|
809
|
+
value.member_aid = binding.member_aid;
|
|
810
|
+
if (!('terms_hash' in value))
|
|
811
|
+
value.terms_hash = binding.terms_hash;
|
|
812
|
+
}
|
|
813
|
+
if (method === 'org.member.renewCredential') {
|
|
814
|
+
if (!binding)
|
|
815
|
+
throw invalid('replacement membership binding is required');
|
|
816
|
+
if (!('member_signature' in binding))
|
|
817
|
+
binding = await signMembershipBinding(binding, memberSigner, 'member');
|
|
818
|
+
if (!('org_signature' in binding)) {
|
|
819
|
+
if (!options.orgSigner || options.orgSigner.aid !== value.org_id)
|
|
820
|
+
throw invalid('organization signing identity is required');
|
|
821
|
+
binding = await signMembershipBinding(binding, options.orgSigner, 'org');
|
|
822
|
+
}
|
|
823
|
+
value.membership_binding = binding;
|
|
824
|
+
if (!('member_aid' in value))
|
|
825
|
+
value.member_aid = binding.member_aid;
|
|
826
|
+
}
|
|
827
|
+
const materials = isRecord(value.membership_bindings) ? value.membership_bindings : {};
|
|
828
|
+
const signedBinding = method === 'org.member.invite' ? (Object.keys(materials).length === 1 ? Object.values(materials)[0] : undefined) : binding;
|
|
829
|
+
for (const field of ['member_signature', 'org_signature']) {
|
|
830
|
+
if (field in params && (!isRecord(signedBinding) || !(field in signedBinding) || canonicalText(params[field]) !== canonicalText(signedBinding[field])))
|
|
831
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'outer signature differs from membership binding');
|
|
832
|
+
}
|
|
833
|
+
if (['org.lifecycle.create', 'org.member.approveJoin', 'org.member.renewCredential'].includes(method) && !('credential_template' in value)) {
|
|
834
|
+
if (!options.orgSigner || !binding)
|
|
835
|
+
throw invalid('organization signer or signed member CAP is required');
|
|
836
|
+
value.credential_template = await createMemberCredential(binding, options.orgSigner, { key_version: options.orgSigner.certFingerprint, ...options.credentialOptions });
|
|
837
|
+
}
|
|
838
|
+
if (method !== 'org.member.inviteAccept' && isRecord(value.credential_template) && !('registration_authorization' in value)) {
|
|
839
|
+
if (!binding)
|
|
840
|
+
throw invalid('membership binding is required for registration');
|
|
841
|
+
const issuerSigner = options.orgSigner ?? (signer.aid === value.credential_template.iss ? signer : undefined);
|
|
842
|
+
value.registration_authorization = await createRegistrationAuthorization(value.credential_template, binding, target, issuerSigner);
|
|
843
|
+
}
|
|
844
|
+
if (method === 'org.member.invite' && options.orgSigner) {
|
|
845
|
+
const templates = isRecord(value.credential_templates) ? value.credential_templates : {};
|
|
846
|
+
const authorizations = isRecord(value.registration_authorizations) ? value.registration_authorizations : {};
|
|
847
|
+
for (const [aid, material] of Object.entries(materials)) {
|
|
848
|
+
if (!(aid in templates))
|
|
849
|
+
templates[aid] = await createMemberCredential(material, options.orgSigner, { key_version: options.orgSigner.certFingerprint, ...options.credentialOptions });
|
|
850
|
+
if (!(aid in authorizations))
|
|
851
|
+
authorizations[aid] = await createRegistrationAuthorization(templates[aid], material, target, options.orgSigner);
|
|
852
|
+
}
|
|
853
|
+
value.credential_templates = templates;
|
|
854
|
+
value.registration_authorizations = authorizations;
|
|
855
|
+
}
|
|
856
|
+
if (method === 'org.lifecycle.transferAccept' && value.stage !== 'rekey') {
|
|
857
|
+
for (const field of ['previous_owner', 'new_owner']) {
|
|
858
|
+
const material = value[field];
|
|
859
|
+
if (isRecord(material) && !('registration_authorization' in material)) {
|
|
860
|
+
if (!isRecord(material.credential_template) || !isRecord(material.membership_binding))
|
|
861
|
+
throw invalid('transfer registration materials are required');
|
|
862
|
+
const issuerSigner = options.orgSigner ?? (signer.aid === material.credential_template.iss ? signer : undefined);
|
|
863
|
+
material.registration_authorization = await createRegistrationAuthorization(material.credential_template, material.membership_binding, target, issuerSigner);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
value = orgRequestParams(method, value);
|
|
868
|
+
const credentials = isRecord(value.credentials) ? value.credentials : {};
|
|
869
|
+
if ('credentials' in value && !isRecord(value.credentials))
|
|
870
|
+
throw invalid('credentials must be an object');
|
|
871
|
+
if (['org.member.join', 'org.member.inviteAccept'].includes(method)) {
|
|
872
|
+
if (!binding || !isRecord(binding.member_signature) || ('for' in value ? value.for : signer.aid) !== binding.member_aid || value.member_aid !== binding.member_aid || binding.org_id !== value.org_id || target !== 'org.' + String(binding.org_id).slice(String(binding.org_id).indexOf('.') + 1))
|
|
873
|
+
throw representationBindingInvalid('admission target must match the signed membership binding');
|
|
874
|
+
value.credentials = credentials;
|
|
875
|
+
const now = Math.trunc(Date.now() / 1000);
|
|
876
|
+
if (now >= Number(binding.exp))
|
|
877
|
+
throw representationBindingInvalid('membership binding has expired');
|
|
878
|
+
let maximum = Math.min(now + 300, Number(binding.exp));
|
|
879
|
+
for (const field of ['credential_template', 'registration_authorization'])
|
|
880
|
+
if (field in value) {
|
|
881
|
+
const material = value[field];
|
|
882
|
+
if (!isRecord(material) || !isInt(material.exp))
|
|
883
|
+
throw invalid('admission material expiry is invalid');
|
|
884
|
+
maximum = Math.min(maximum, material.exp);
|
|
885
|
+
}
|
|
886
|
+
const paramsHash = digest(orgRequestBindingParams(method, value));
|
|
887
|
+
const expected = { ver: 'aun-membership-admission-request/1', request_id: value.request_id, nonce: value.nonce, target, method, params_hash: paramsHash, membership_id: binding.membership_id, iat: now, exp: maximum };
|
|
888
|
+
if (!('request_binding' in value))
|
|
889
|
+
value.request_binding = expected;
|
|
890
|
+
const request = value.request_binding;
|
|
891
|
+
if (!isRecord(request) || Object.keys(request).length !== Object.keys(expected).length || Object.keys(request).some((key) => !(key in expected)) || Object.entries(expected).some(([key, item]) => !['iat', 'exp'].includes(key) && request[key] !== item) || !isInt(request.iat) || !isInt(request.exp) || request.iat < 0 || request.iat > now || request.iat < Number(binding.iat) || now >= request.exp || request.exp > request.iat + 300 || request.exp > maximum)
|
|
892
|
+
throw representationBindingInvalid('admission request binding is invalid');
|
|
893
|
+
if (value.for === undefined || value.for === signer.aid)
|
|
894
|
+
return value;
|
|
895
|
+
const proof = { ver: 'aun-membership-admission-representation/1', issuer: signer.aid, from: signer.aid, represented: binding.member_aid, for: binding.member_aid, audience: target, resource: binding.member_aid, org_id: binding.org_id, membership_id: binding.membership_id, method, params_hash: paramsHash, request_id: value.request_id, nonce: value.nonce, iat: request.iat, exp: request.exp };
|
|
896
|
+
if (!('representation_proof' in credentials))
|
|
897
|
+
credentials.representation_proof = { ...proof, signature: await signProtocolPayload(proof, signer) };
|
|
898
|
+
const supplied = credentials.representation_proof;
|
|
899
|
+
if (!isRecord(supplied) || Object.keys(supplied).length !== Object.keys(proof).length + 1 || Object.keys(supplied).some((key) => key !== 'signature' && !(key in proof)) || Object.entries(proof).some(([key, item]) => supplied[key] !== item))
|
|
900
|
+
throw representationBindingInvalid('admission representation proof is invalid');
|
|
901
|
+
validateRepresentationSignature(supplied.signature);
|
|
902
|
+
try {
|
|
903
|
+
await verifyAgentSignature(supplied.signature, proof, signer.aid, () => signer.certPem, now);
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'admission representation signature is invalid');
|
|
907
|
+
}
|
|
908
|
+
return value;
|
|
909
|
+
}
|
|
910
|
+
const credential = ['member_credential', 'approval_credential', 'audit_credential'].map((key) => credentials[key]).find(isRecord) ?? value.credential_template;
|
|
911
|
+
if (isRecord(credential)) {
|
|
912
|
+
validateCredential(credential, 0, value.proof_chain);
|
|
913
|
+
const now = Math.trunc(Date.now() / 1000);
|
|
914
|
+
if (!('request_binding' in value))
|
|
915
|
+
value.request_binding = buildRequestBinding(method, target, value, credential.cid, value.nonce, value.request_id, now, Math.min(now + 300, credential.exp));
|
|
916
|
+
validateRequestBinding(value.request_binding, method, target, value, credential.cid, value.nonce, value.request_id, now);
|
|
917
|
+
if (value.for && value.for !== signer.aid && !('representation_proof' in credentials))
|
|
918
|
+
value.credentials = { ...credentials, representation_proof: await createRepresentationProof(credential, value, value.request_binding, signer, value.proof_chain) };
|
|
919
|
+
}
|
|
920
|
+
else if (value.for && value.for !== signer.aid)
|
|
921
|
+
throw invalid('representation requires a current holder CAP');
|
|
922
|
+
return value;
|
|
923
|
+
}
|
|
924
|
+
async function createRegistrationAuthorization(credential, binding, relay, signer) {
|
|
925
|
+
if (!signer || signer.aid !== credential.iss)
|
|
926
|
+
throw new ProtocolError('CREDENTIAL_AUTHENTICATION_INVALID', 'issuer signing identity or registration authorization is required');
|
|
927
|
+
const now = Math.trunc(Date.now() / 1000);
|
|
928
|
+
const value = { ver: 'aun-credential-registration-authorization/1', issuer_aid: credential.iss, credential_cid: credential.cid, credential_hash: digest(credential), request_id: binding.request_id, nonce: binding.nonce, target: 'credential.' + relay.slice(relay.indexOf('.') + 1), relay_aid: relay, iat: now, exp: binding.exp, revocation_authority: { aid: relay, org_id: binding.org_id, credential_cid: credential.cid, registration_request_id: binding.request_id, reason_codes: ['request_failed', 'membership_ended', 'organization_dissolved', 'org_ownership_transfer', 'credential_renewed'] } };
|
|
929
|
+
value.signature = await signProtocolPayload(value, signer);
|
|
930
|
+
return value;
|
|
931
|
+
}
|
|
932
|
+
export async function createMemberCredential(binding, signer, options) {
|
|
933
|
+
validateMembershipBindingPayload(binding, true);
|
|
934
|
+
if (!['owner', 'admin', 'member', 'observer'].includes(String(binding.role)))
|
|
935
|
+
throw invalid('membership role is invalid');
|
|
936
|
+
const fixed = { iss: binding.org_id, sub: binding.member_aid, aud: [binding.org_id],
|
|
937
|
+
cap: [{ ns: 'org', action: 'role.' + String(binding.role), resource: binding.org_id, constraints: [] }],
|
|
938
|
+
delegate: false, delegation_depth: 0, proof_chain: [],
|
|
939
|
+
ext: { credential_type: 'member', org_id: binding.org_id, membership_id: binding.membership_id, binding_version: binding.binding_version, membership_terms_hash: binding.terms_hash, bound_to_membership: true } };
|
|
940
|
+
const text = (value) => new TextDecoder().decode(canonicalJson(value));
|
|
941
|
+
if (!isRecord(options) || Object.entries(fixed).some(([key, value]) => key in options && text(options[key]) !== text(value)) || options.parent !== undefined && options.parent !== null)
|
|
942
|
+
throw invalid('member credential options conflict with binding');
|
|
943
|
+
return createCredential({ exp: binding.exp, ...options, ...fixed }, signer);
|
|
944
|
+
}
|
|
945
|
+
export async function verifyCredential(credential, context) {
|
|
946
|
+
if (!context || !context.audience || !context.subject)
|
|
947
|
+
throw invalid('credential verification requires a target audience and holder');
|
|
948
|
+
validateCredential(credential, 0, context.proofChain);
|
|
949
|
+
const value = credential;
|
|
950
|
+
if (value.sub !== context.subject)
|
|
951
|
+
throw invalid('credential subject does not match holder');
|
|
952
|
+
const now = context.now ?? Math.trunc(Date.now() / 1000);
|
|
953
|
+
const documents = credentialChainDocuments(credential, context.proofChain);
|
|
954
|
+
const resolvedCertificates = [];
|
|
955
|
+
const resolveCertificate = async (fingerprint) => {
|
|
956
|
+
const material = await context.resolveCertificate(fingerprint);
|
|
957
|
+
if (material != null)
|
|
958
|
+
resolvedCertificates.push(material);
|
|
959
|
+
return material;
|
|
960
|
+
};
|
|
961
|
+
const resolveActorCertificate = context.resolveActorCertificate
|
|
962
|
+
? async (fingerprint) => {
|
|
963
|
+
const material = await context.resolveActorCertificate(fingerprint);
|
|
964
|
+
if (material != null)
|
|
965
|
+
resolvedCertificates.push(material);
|
|
966
|
+
return material;
|
|
967
|
+
}
|
|
968
|
+
: resolveCertificate;
|
|
969
|
+
const statusProofs = [];
|
|
970
|
+
const status = async (cid) => {
|
|
971
|
+
const document = documents.find((item) => item.cid === cid);
|
|
972
|
+
let proof;
|
|
973
|
+
try {
|
|
974
|
+
proof = await context.resolveStatusProof?.(cid);
|
|
975
|
+
if (proof == null)
|
|
976
|
+
proof = await context.resolveStatus?.(cid);
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
throw new ProtocolError('CREDENTIAL_STATUS_UNKNOWN', 'trusted credential status is unavailable');
|
|
980
|
+
}
|
|
981
|
+
if (isRecord(proof)) {
|
|
982
|
+
statusProofs.push(proof);
|
|
983
|
+
if (typeof proof.credentialCid === 'string' && proof.credentialCid !== cid)
|
|
984
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'registered credential cid differs from the signed document');
|
|
985
|
+
if (proof.ver !== 'aun-credential-status/1' || proof.credentialCid !== cid || typeof proof.credential_hash !== 'string' || !DIGEST_RE.test(proof.credential_hash) || !isInt(proof.asOf) || !isInt(proof.proofExpiresAt) || proof.asOf > now + 300 || proof.proofExpiresAt <= proof.asOf || proof.proofExpiresAt > proof.asOf + 300 || now >= proof.proofExpiresAt || typeof proof.responder !== 'string' || !proof.responder.startsWith('credential.') || !AID_RE.test(proof.responder.slice(11)) || typeof proof.request_id !== 'string' || !proof.request_id || typeof proof.nonce !== 'string' || !proof.nonce || typeof proof.target !== 'string' || !AID_RE.test(proof.target))
|
|
986
|
+
throw new ProtocolError('CREDENTIAL_STATUS_UNKNOWN', 'trusted credential status proof is unavailable');
|
|
987
|
+
try {
|
|
988
|
+
signature(proof.signature);
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
throw new ProtocolError('CREDENTIAL_STATUS_UNKNOWN', 'trusted credential status proof is unavailable');
|
|
992
|
+
}
|
|
993
|
+
if (proof.signature.timestamp !== proof.asOf)
|
|
994
|
+
throw new ProtocolError('CREDENTIAL_STATUS_UNKNOWN', 'trusted credential status proof is unavailable');
|
|
995
|
+
if (proof.credential_hash !== digest(document))
|
|
996
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'registered credential hash differs from the signed document');
|
|
997
|
+
return typeof proof.status === 'string' ? proof.status : undefined;
|
|
998
|
+
}
|
|
999
|
+
if (document !== value)
|
|
1000
|
+
throw new ProtocolError('CREDENTIAL_STATUS_UNKNOWN', 'parent credential status proof is required');
|
|
1001
|
+
return typeof proof === 'string' ? proof : undefined;
|
|
1002
|
+
};
|
|
1003
|
+
await verifyCredentialChain(credential, resolveCertificate, status, now, context.audience, context.proofChain);
|
|
1004
|
+
if (credentialChainDocuments(credential, context.proofChain).at(-1).iss !== (context.rootIssuer ?? context.audience))
|
|
1005
|
+
throw invalid('credential root issuer is not trusted for this resource');
|
|
1006
|
+
const rate = context.checkRateLimit ? (field, unit, limit) => context.checkRateLimit(String(value.cid), String(value.sub), field, unit, limit) : undefined;
|
|
1007
|
+
await validateCredentialCapability(credential, { ns: context.ns, action: context.action, resource: context.resource, params: context.params, units: context.units, now, client_ip: context.clientIp, proof_chain: context.proofChain }, rate);
|
|
1008
|
+
if (!context.method || !context.target)
|
|
1009
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'the actual request context is required');
|
|
1010
|
+
validateRequestBinding(context.requestBinding, context.method, context.target, context.params ?? {}, String(value.cid), context.nonce ?? '', context.requestId ?? '', now);
|
|
1011
|
+
const ext = value.ext;
|
|
1012
|
+
if (isRecord(ext) && ext.credential_type === 'member' && (ext.bound_to_membership !== true || value.delegate === true))
|
|
1013
|
+
throw invalid('member credential must be bound and cannot delegate');
|
|
1014
|
+
if (isRecord(ext) && ext.bound_to_membership === true) {
|
|
1015
|
+
const binding = context.membershipBinding;
|
|
1016
|
+
if (!isRecord(binding) || binding.member_aid !== value.sub || binding.org_id !== ext.org_id || binding.membership_id !== ext.membership_id || binding.binding_version !== ext.binding_version || binding.terms_hash !== ext.membership_terms_hash)
|
|
1017
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'current membership binding is required');
|
|
1018
|
+
if (ext.credential_type === 'member') {
|
|
1019
|
+
const caps = value.cap;
|
|
1020
|
+
if (value.iss !== binding.org_id || !value.aud.includes(binding.org_id) || caps.length !== 1 || caps[0].ns !== 'org' || caps[0].action !== 'role.' + String(binding.role) || caps[0].resource !== binding.org_id)
|
|
1021
|
+
throw invalid('member credential capability does not match binding role');
|
|
1022
|
+
}
|
|
1023
|
+
await verifyMembershipBinding(binding, resolveCertificate, now, context.orgSigner, true);
|
|
1024
|
+
if (!context.resolveMembershipStatus || await context.resolveMembershipStatus(binding, value) !== true)
|
|
1025
|
+
throw new ProtocolError('MEMBERSHIP_STATE_INVALID', 'trusted current membership status does not permit this operation');
|
|
1026
|
+
}
|
|
1027
|
+
const result = { signature_valid: true, chain_valid: true, status_checked: true, capability_checked: true, request_bound: true };
|
|
1028
|
+
const params = context.params ?? {};
|
|
1029
|
+
const target = context.for ?? params.for;
|
|
1030
|
+
const actor = context.actor ?? context.subject;
|
|
1031
|
+
let verifiedRepresentation;
|
|
1032
|
+
if (typeof actor !== 'string' || !AID_RE.test(actor))
|
|
1033
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'authenticated actor is invalid');
|
|
1034
|
+
if (target !== undefined && (typeof target !== 'string' || !target || !context.actor || Object.hasOwn(params, 'for') && params.for !== target))
|
|
1035
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'authenticated representation context is required');
|
|
1036
|
+
if (target !== undefined && target !== actor || actor !== context.subject) {
|
|
1037
|
+
if (target === undefined || target === actor)
|
|
1038
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'credential holder requires representation');
|
|
1039
|
+
const packaged = isRecord(params.credentials) ? params.credentials.representation_proof : undefined;
|
|
1040
|
+
const proof = context.representationProof ?? packaged;
|
|
1041
|
+
if (packaged !== undefined && canonicalText(packaged) !== canonicalText(proof))
|
|
1042
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'representation proof differs from the request');
|
|
1043
|
+
const representationRequest = { from: actor, for: target, audience: context.target, resource: target, params, binding: context.requestBinding };
|
|
1044
|
+
await verifyRepresentationProof(proof, credential, representationRequest, resolveActorCertificate, now, context.proofChain);
|
|
1045
|
+
verifiedRepresentation = { proof: proof, request: representationRequest };
|
|
1046
|
+
const subjects = [String(target), ...(context.subject !== actor && context.subject !== target ? [context.subject] : [])];
|
|
1047
|
+
let allowed = Boolean(context.resolveOwnership);
|
|
1048
|
+
try {
|
|
1049
|
+
for (const subject of subjects)
|
|
1050
|
+
if (!context.resolveOwnership || await context.resolveOwnership(subject, actor) !== true)
|
|
1051
|
+
allowed = false;
|
|
1052
|
+
if (context.ns === 'org' && !['active', 'nonmember', 'frozen', 'offboarding', 'ended'].includes(await context.resolveActorStatus?.(context.audience, actor) ?? 'unknown'))
|
|
1053
|
+
allowed = false;
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
allowed = false;
|
|
1057
|
+
}
|
|
1058
|
+
if (!allowed)
|
|
1059
|
+
throw new ProtocolError('ROLE_ACCESS_DENIED', 'trusted current ownership does not permit representation');
|
|
1060
|
+
result.representation_checked = true;
|
|
1061
|
+
}
|
|
1062
|
+
if (context.now === undefined) {
|
|
1063
|
+
const completedAt = Math.trunc(Date.now() / 1000);
|
|
1064
|
+
for (const document of documents)
|
|
1065
|
+
validateCredentialAt(document, completedAt, context.audience);
|
|
1066
|
+
await validateCredentialCapability(credential, { ns: context.ns, action: context.action, resource: context.resource, params: context.params, units: context.units, now: completedAt, client_ip: context.clientIp, proof_chain: context.proofChain }, () => true);
|
|
1067
|
+
validateRequestBinding(context.requestBinding, context.method, context.target, context.params ?? {}, String(value.cid), context.nonce ?? '', context.requestId ?? '', completedAt);
|
|
1068
|
+
for (const proof of statusProofs) {
|
|
1069
|
+
if (proof.asOf > completedAt + 300 || completedAt >= proof.proofExpiresAt)
|
|
1070
|
+
throw new ProtocolError('CREDENTIAL_STATUS_UNKNOWN', 'trusted credential status proof is unavailable');
|
|
1071
|
+
}
|
|
1072
|
+
for (const material of resolvedCertificates) {
|
|
1073
|
+
const metadata = parseCertMetadata(certificatePem(material));
|
|
1074
|
+
if (completedAt < metadata.notBefore.getTime() / 1000 || completedAt > metadata.notAfter.getTime() / 1000)
|
|
1075
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'certificate is not valid');
|
|
1076
|
+
}
|
|
1077
|
+
if (verifiedRepresentation) {
|
|
1078
|
+
const { proof, request } = verifiedRepresentation;
|
|
1079
|
+
if (completedAt < proof.iat || completedAt >= proof.exp)
|
|
1080
|
+
throw representationBindingInvalid('representation time window is invalid');
|
|
1081
|
+
validateRepresentationContext(proof, value, request, completedAt);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return result;
|
|
1085
|
+
}
|
|
1086
|
+
export function validateCredentialAt(credential, now, audience, proofChain) {
|
|
1087
|
+
validateCredential(credential, 0, proofChain);
|
|
1088
|
+
if (!isInt(now))
|
|
1089
|
+
throw invalid('credential validation time must be an integer');
|
|
1090
|
+
const value = credential;
|
|
1091
|
+
const nbf = value.nbf;
|
|
1092
|
+
const exp = value.exp;
|
|
1093
|
+
if (!isInt(nbf) || !isInt(exp) || now < nbf || now >= exp)
|
|
1094
|
+
throw new ProtocolError('CREDENTIAL_EXPIRED', 'credential is outside its validity window');
|
|
1095
|
+
if (audience !== undefined) {
|
|
1096
|
+
if (!Array.isArray(value.aud) || !value.aud.includes(audience))
|
|
1097
|
+
throw new ProtocolError('CREDENTIAL_AUDIENCE_MISMATCH', 'credential audience does not match target');
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
export async function verifyCredentialSignature(credential, resolveCertificate, now, audience, proofChain) {
|
|
1101
|
+
try {
|
|
1102
|
+
validateCredential(credential, 0, proofChain);
|
|
1103
|
+
}
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
if (error instanceof ProtocolError && error.message.toLowerCase().includes('signature')) {
|
|
1106
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'credential signature structure is invalid');
|
|
1107
|
+
}
|
|
1108
|
+
throw error;
|
|
1109
|
+
}
|
|
1110
|
+
const value = credential;
|
|
1111
|
+
const signatureValue = value.signature;
|
|
1112
|
+
const issuer = value.iss;
|
|
1113
|
+
try {
|
|
1114
|
+
const payload = { ...value };
|
|
1115
|
+
delete payload.signature;
|
|
1116
|
+
await verifyAgentSignature(signatureValue, payload, issuer, resolveCertificate, now);
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'credential signature verification failed');
|
|
1120
|
+
}
|
|
1121
|
+
if (now !== undefined) {
|
|
1122
|
+
validateCredentialAt(credential, now, audience, proofChain);
|
|
1123
|
+
}
|
|
1124
|
+
else if (audience !== undefined && !value.aud.includes(audience)) {
|
|
1125
|
+
throw new ProtocolError('CREDENTIAL_AUDIENCE_MISMATCH', 'credential audience does not match target');
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
function canonicalText(value) {
|
|
1129
|
+
return new TextDecoder().decode(canonicalJson(value));
|
|
1130
|
+
}
|
|
1131
|
+
function constraintField(context, field) {
|
|
1132
|
+
let value = context;
|
|
1133
|
+
for (const part of field.split('.')) {
|
|
1134
|
+
if (!isRecord(value) || !Object.hasOwn(value, part))
|
|
1135
|
+
return [undefined, false];
|
|
1136
|
+
value = value[part];
|
|
1137
|
+
}
|
|
1138
|
+
return [value, true];
|
|
1139
|
+
}
|
|
1140
|
+
async function evaluateConstraints(constraints, context, rate) {
|
|
1141
|
+
const pending = [];
|
|
1142
|
+
for (const item of constraints) {
|
|
1143
|
+
if (item.type === 'rate_limit') {
|
|
1144
|
+
pending.push(item);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
const field = String(item.field);
|
|
1148
|
+
let [actual, found] = constraintField(context, field);
|
|
1149
|
+
if (!found)
|
|
1150
|
+
[actual, found] = constraintField(context.params, field);
|
|
1151
|
+
if (!found)
|
|
1152
|
+
return false;
|
|
1153
|
+
const units = isRecord(context.units) ? context.units : {};
|
|
1154
|
+
if (item.type !== 'time_window' && (units[field] ?? '') !== constraintUnit(item))
|
|
1155
|
+
return false;
|
|
1156
|
+
try {
|
|
1157
|
+
if (!constraintSatisfied(item, actual))
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
for (const item of pending)
|
|
1165
|
+
if (!rate || await rate(String(item.field), constraintUnit(item), Number(item.value)) !== true)
|
|
1166
|
+
return false;
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
export async function validateCredentialCapability(credential, context, rate) {
|
|
1170
|
+
validateCredential(credential, 0, context.proof_chain);
|
|
1171
|
+
if (!isRecord(context) || !['ns', 'action', 'resource'].every((field) => typeof context[field] === 'string' && context[field]))
|
|
1172
|
+
throw new ProtocolError('CREDENTIAL_CAPABILITY_DENIED', 'an explicit capability requirement is required');
|
|
1173
|
+
const value = credential;
|
|
1174
|
+
const actual = { ...context, sub: value.sub, delegation_depth: value.delegation_depth ?? 0, operation: String(context.action).split('.').at(-1), valid_time: context.now };
|
|
1175
|
+
for (const cap of value.cap) {
|
|
1176
|
+
if (cap.ns === context.ns && (cap.action === context.action || cap.action === '*') && (cap.resource === context.resource || cap.resource === '*') && await evaluateConstraints(cap.constraints, actual, rate))
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
throw new ProtocolError('CREDENTIAL_CAPABILITY_DENIED', 'credential does not cover the actual operation or constraints');
|
|
1180
|
+
}
|
|
1181
|
+
function constraintUnit(item) {
|
|
1182
|
+
return String(item.unit || (item.type === 'rate_limit' ? 'second' : item.type === 'time_window' ? 'UTC' : ''));
|
|
1183
|
+
}
|
|
1184
|
+
function constraintSatisfied(item, actual) {
|
|
1185
|
+
const value = item.value;
|
|
1186
|
+
switch (item.type) {
|
|
1187
|
+
case 'max_value':
|
|
1188
|
+
case 'min_value':
|
|
1189
|
+
return typeof actual === 'number' && Number.isFinite(actual) && typeof value === 'number' && (item.type === 'max_value' ? actual <= value : actual >= value);
|
|
1190
|
+
case 'range':
|
|
1191
|
+
return typeof actual === 'number' && Number.isFinite(actual) && Array.isArray(value) && actual >= value[0] && actual <= value[1];
|
|
1192
|
+
case 'allowed_values':
|
|
1193
|
+
case 'denied_values': {
|
|
1194
|
+
if (!Array.isArray(value))
|
|
1195
|
+
return false;
|
|
1196
|
+
const found = value.some((entry) => canonicalText(entry) === canonicalText(actual));
|
|
1197
|
+
return item.type === 'allowed_values' ? found : !found;
|
|
1198
|
+
}
|
|
1199
|
+
case 'path_prefix': {
|
|
1200
|
+
const path = canonicalPath(actual), prefix = canonicalPath(value);
|
|
1201
|
+
return path !== undefined && prefix !== undefined && (path === prefix || path.startsWith(prefix.replace(/\/$/u, '') + '/'));
|
|
1202
|
+
}
|
|
1203
|
+
case 'regex': {
|
|
1204
|
+
if (typeof actual !== 'string' || new TextEncoder().encode(actual).length > 4096 || typeof value !== 'string' || !validConstraintRegex(value))
|
|
1205
|
+
return false;
|
|
1206
|
+
const match = new RegExp(value, 'u').exec(actual);
|
|
1207
|
+
return match !== null && match[0] === actual;
|
|
1208
|
+
}
|
|
1209
|
+
case 'ip_whitelist': {
|
|
1210
|
+
if (typeof actual !== 'string' || /[/%]/u.test(actual) || !Array.isArray(value))
|
|
1211
|
+
return false;
|
|
1212
|
+
const address = constraintIP(actual);
|
|
1213
|
+
return address !== undefined && value.some((entry) => {
|
|
1214
|
+
const prefix = constraintIP(entry);
|
|
1215
|
+
return prefix !== undefined && address.bits === prefix.bits && (address.address >> BigInt(address.bits - prefix.prefix)) === (prefix.address >> BigInt(prefix.bits - prefix.prefix));
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
case 'time_window': {
|
|
1219
|
+
if (!isInt(actual) || actual < 0 || !Array.isArray(value))
|
|
1220
|
+
return false;
|
|
1221
|
+
const current = new Date(actual * 1000).toISOString().slice(11, 16);
|
|
1222
|
+
return current >= value[0] && current < value[1];
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
function constraintNarrower(child, parent) {
|
|
1228
|
+
if (!isRecord(child) || !isRecord(parent) || child.type !== parent.type || child.field !== parent.field || constraintUnit(child) !== constraintUnit(parent))
|
|
1229
|
+
return false;
|
|
1230
|
+
const a = child.value, b = parent.value;
|
|
1231
|
+
if (['max_value', 'min_value', 'rate_limit'].includes(child.type))
|
|
1232
|
+
return typeof a === 'number' && Number.isFinite(a) && typeof b === 'number' && Number.isFinite(b) && (child.type === 'min_value' ? a >= b : a <= b);
|
|
1233
|
+
if (child.type === 'allowed_values' || child.type === 'denied_values') {
|
|
1234
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
1235
|
+
return false;
|
|
1236
|
+
const [subset, superset] = child.type === 'denied_values' ? [b, a] : [a, b];
|
|
1237
|
+
return subset.every((entry) => superset.some((candidate) => canonicalText(entry) === canonicalText(candidate)));
|
|
1238
|
+
}
|
|
1239
|
+
if (child.type === 'ip_whitelist') {
|
|
1240
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
1241
|
+
return false;
|
|
1242
|
+
return a.every((entry) => {
|
|
1243
|
+
const subnet = constraintIP(entry);
|
|
1244
|
+
return subnet !== undefined && b.some((candidate) => {
|
|
1245
|
+
const prefix = constraintIP(candidate);
|
|
1246
|
+
return prefix !== undefined && subnet.bits === prefix.bits && subnet.prefix >= prefix.prefix && (subnet.address >> BigInt(subnet.bits - prefix.prefix)) === (prefix.address >> BigInt(prefix.bits - prefix.prefix));
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
if (child.type === 'range' || child.type === 'time_window')
|
|
1251
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === 2 && b.length === 2 && a[0] >= b[0] && a[1] <= b[1];
|
|
1252
|
+
if (child.type === 'path_prefix') {
|
|
1253
|
+
const path = canonicalPath(a), prefix = canonicalPath(b);
|
|
1254
|
+
return path !== undefined && prefix !== undefined && (path === prefix || path.startsWith(prefix.replace(/\/$/u, '') + '/'));
|
|
1255
|
+
}
|
|
1256
|
+
return canonicalText(a) === canonicalText(b);
|
|
1257
|
+
}
|
|
1258
|
+
function capabilitiesNarrower(child, parent, subject, depth) {
|
|
1259
|
+
if (!Array.isArray(child) || !Array.isArray(parent))
|
|
1260
|
+
return false;
|
|
1261
|
+
return child.every((current) => isRecord(current) && Array.isArray(current.constraints) && parent.some((candidate) => {
|
|
1262
|
+
if (!isRecord(candidate) || current.ns !== candidate.ns || current.action !== candidate.action && candidate.action !== '*' || current.resource !== candidate.resource && candidate.resource !== '*' || !Array.isArray(candidate.constraints))
|
|
1263
|
+
return false;
|
|
1264
|
+
return candidate.constraints.every((raw) => {
|
|
1265
|
+
if (!isRecord(raw))
|
|
1266
|
+
return false;
|
|
1267
|
+
const actual = { sub: subject, delegation_depth: depth };
|
|
1268
|
+
if (candidate.action === '*')
|
|
1269
|
+
actual.operation = String(current.action).split('.').at(-1);
|
|
1270
|
+
if (candidate.resource === '*' && current.resource !== '*')
|
|
1271
|
+
actual.resource = current.resource;
|
|
1272
|
+
if (Object.hasOwn(actual, String(raw.field)) && !constraintSatisfied(raw, actual[String(raw.field)]))
|
|
1273
|
+
return false;
|
|
1274
|
+
return current.constraints.some((item) => constraintNarrower(item, raw));
|
|
1275
|
+
});
|
|
1276
|
+
}));
|
|
1277
|
+
}
|
|
1278
|
+
async function verifyAgentSignature(signatureValue, payload, expectedAid, resolveCertificate, now, enforceWindow = true) {
|
|
1279
|
+
const certPem = certificatePem(await resolveCertificate(String(signatureValue.cert_fingerprint)));
|
|
1280
|
+
const lower = enforceWindow ? payload.iat ?? payload.registered_at : undefined;
|
|
1281
|
+
if (typeof lower === 'number' && (Number(signatureValue.timestamp) < lower || Number(signatureValue.timestamp) >= Number(payload.exp)))
|
|
1282
|
+
throw new Error('signature timestamp is outside the signed object window');
|
|
1283
|
+
if (!certPem)
|
|
1284
|
+
throw new Error('certificate is missing');
|
|
1285
|
+
if ((await certFingerprint(certPem)).toLowerCase() !== String(signatureValue.cert_fingerprint).toLowerCase())
|
|
1286
|
+
throw new Error('certificate fingerprint mismatch');
|
|
1287
|
+
const metadata = parseCertMetadata(certPem);
|
|
1288
|
+
const current = Math.trunc(now ?? Date.now() / 1000);
|
|
1289
|
+
const timestamp = Number(signatureValue.timestamp);
|
|
1290
|
+
if (metadata.subject.toLowerCase() !== expectedAid.toLowerCase())
|
|
1291
|
+
throw new Error('certificate subject does not match AID');
|
|
1292
|
+
if (current < metadata.notBefore.getTime() / 1000 || current > metadata.notAfter.getTime() / 1000 || timestamp < metadata.notBefore.getTime() / 1000 || timestamp > metadata.notAfter.getTime() / 1000)
|
|
1293
|
+
throw new Error('certificate is not valid');
|
|
1294
|
+
if (timestamp > current + 300)
|
|
1295
|
+
throw new Error('signature timestamp is too far in the future');
|
|
1296
|
+
if (signatureValue.public_key_fingerprint !== undefined && (await publicKeyFingerprint(certPem)).toLowerCase() !== String(signatureValue.public_key_fingerprint).toLowerCase())
|
|
1297
|
+
throw new Error('public key fingerprint mismatch');
|
|
1298
|
+
if (!await verifySignatureWithCert(certPem, String(signatureValue.signature), canonicalJson(payload)))
|
|
1299
|
+
throw new Error('signature verification failed');
|
|
1300
|
+
}
|
|
1301
|
+
function auditScopeTime(value) {
|
|
1302
|
+
if (isInt(value) && value >= 0)
|
|
1303
|
+
return value;
|
|
1304
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value))
|
|
1305
|
+
return undefined;
|
|
1306
|
+
const parsed = Math.floor(Date.parse(value) / 1000);
|
|
1307
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
1308
|
+
}
|
|
1309
|
+
function auditScopeNarrower(child, parent) {
|
|
1310
|
+
const current = isRecord(child.ext) ? child.ext : {};
|
|
1311
|
+
const previous = isRecord(parent.ext) ? parent.ext : {};
|
|
1312
|
+
const archive = previous.credential_type === 'archive' || current.credential_type === 'archive';
|
|
1313
|
+
if (archive && (previous.credential_type !== 'archive' || current.credential_type !== 'archive' || previous.org_id !== current.org_id))
|
|
1314
|
+
return false;
|
|
1315
|
+
if (previous.credential_type === 'audit' || 'audit_fields' in previous) {
|
|
1316
|
+
const allowed = new Set(['event_id', 'event_seq', 'event_type', 'org_id', 'occurred_at', 'received_at', 'previous_hash', 'event_hash']);
|
|
1317
|
+
if (archive)
|
|
1318
|
+
allowed.clear();
|
|
1319
|
+
for (const ext of [previous, current])
|
|
1320
|
+
if ('audit_fields' in ext && (!Array.isArray(ext.audit_fields) || !ext.audit_fields.length || ext.audit_fields.some((item) => typeof item !== 'string' || !item)))
|
|
1321
|
+
return false;
|
|
1322
|
+
for (const field of (previous.audit_fields ?? []))
|
|
1323
|
+
allowed.add(field);
|
|
1324
|
+
if (!(current.audit_fields ?? []).every((field) => allowed.has(field)))
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
if ('audit_window' in previous) {
|
|
1328
|
+
const bounds = [];
|
|
1329
|
+
for (const ext of [previous, current]) {
|
|
1330
|
+
if (!isRecord(ext.audit_window))
|
|
1331
|
+
return false;
|
|
1332
|
+
const start = auditScopeTime(ext.audit_window.from);
|
|
1333
|
+
const end = ext.audit_window.to == null ? Infinity : auditScopeTime(ext.audit_window.to);
|
|
1334
|
+
if (start === undefined || end === undefined || end < start)
|
|
1335
|
+
return false;
|
|
1336
|
+
bounds.push([start, end]);
|
|
1337
|
+
}
|
|
1338
|
+
if (bounds[1][0] < bounds[0][0] || bounds[1][1] > bounds[0][1])
|
|
1339
|
+
return false;
|
|
1340
|
+
}
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
function credentialChainDocuments(credential, externalProofChain) {
|
|
1344
|
+
validateCredential(credential, 0, externalProofChain);
|
|
1345
|
+
const value = credential;
|
|
1346
|
+
const chain = value.parent !== undefined && value.parent !== null ? (value.proof_chain ?? externalProofChain) : [];
|
|
1347
|
+
const documents = [value, ...chain];
|
|
1348
|
+
const seen = new Set();
|
|
1349
|
+
for (const document of documents) {
|
|
1350
|
+
if (!isRecord(document) || typeof document.cid !== 'string' || seen.has(document.cid))
|
|
1351
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'proof_chain contains a repeated cid');
|
|
1352
|
+
seen.add(document.cid);
|
|
1353
|
+
}
|
|
1354
|
+
let child = value;
|
|
1355
|
+
for (const parent of chain) {
|
|
1356
|
+
if (!isRecord(parent) || child.parent !== parent.cid)
|
|
1357
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'parent cid does not match proof_chain');
|
|
1358
|
+
if (child.iss !== parent.sub || parent.delegate !== true)
|
|
1359
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'proof_chain issuer or delegation is invalid');
|
|
1360
|
+
if (child.delegation_depth !== (parent.delegation_depth ?? 0) + 1)
|
|
1361
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'delegation depth is invalid');
|
|
1362
|
+
if (child.iat < parent.iat || child.nbf < parent.nbf || child.exp > parent.exp)
|
|
1363
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'credential time window is wider than parent');
|
|
1364
|
+
if (!child.aud.every((item) => parent.aud.includes(item)))
|
|
1365
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'credential audience is wider than parent');
|
|
1366
|
+
if (!capabilitiesNarrower(child.cap, parent.cap, child.sub, child.delegation_depth))
|
|
1367
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'credential capability is wider than parent');
|
|
1368
|
+
if (!auditScopeNarrower(child, parent))
|
|
1369
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'credential audit scope is wider than parent');
|
|
1370
|
+
child = parent;
|
|
1371
|
+
}
|
|
1372
|
+
if (chain.length > 0 && child.parent !== undefined && child.parent !== null)
|
|
1373
|
+
throw new ProtocolError('CREDENTIAL_CHAIN_INVALID', 'proof_chain is incomplete');
|
|
1374
|
+
return documents;
|
|
1375
|
+
}
|
|
1376
|
+
export async function verifyCredentialChain(credential, resolveCertificate, resolveStatus, now, audience, proofChain) {
|
|
1377
|
+
if (typeof resolveStatus !== 'function')
|
|
1378
|
+
throw new ProtocolError('CREDENTIAL_STATUS_UNKNOWN', 'credential status is unavailable');
|
|
1379
|
+
const documents = credentialChainDocuments(credential, proofChain);
|
|
1380
|
+
for (const document of documents) {
|
|
1381
|
+
const status = await resolveStatus(document.cid);
|
|
1382
|
+
if (status === 'revoked')
|
|
1383
|
+
throw new ProtocolError('CREDENTIAL_REVOKED', 'credential is revoked');
|
|
1384
|
+
if (status === 'expired')
|
|
1385
|
+
throw new ProtocolError('CREDENTIAL_EXPIRED', 'credential is expired');
|
|
1386
|
+
if (status !== 'active')
|
|
1387
|
+
throw new ProtocolError('CREDENTIAL_STATUS_UNKNOWN', 'credential status is unavailable');
|
|
1388
|
+
}
|
|
1389
|
+
for (const [index, document] of documents.entries())
|
|
1390
|
+
await verifyCredentialSignature(document, resolveCertificate, now, audience, index === 0 ? proofChain : undefined);
|
|
1391
|
+
}
|
|
1392
|
+
function validateMembershipBindingPayload(binding, allowPartial = false) {
|
|
1393
|
+
if (!isRecord(binding))
|
|
1394
|
+
throw invalid('membership_binding must be an object');
|
|
1395
|
+
onlyKnown(binding, BINDING_FIELDS);
|
|
1396
|
+
required(binding, allowPartial ? new Set([...BINDING_FIELDS].filter((key) => key !== 'member_signature' && key !== 'org_signature')) : BINDING_FIELDS);
|
|
1397
|
+
if (binding.ver !== 'aun-membership-binding/1' || typeof binding.membership_id !== 'string' || !binding.membership_id)
|
|
1398
|
+
throw invalid('membership_binding identity is invalid');
|
|
1399
|
+
for (const field of ['member_type', 'category', 'role', 'ownership', 'visibility', 'request_id', 'nonce'])
|
|
1400
|
+
if (typeof binding[field] !== 'string' || binding[field].length === 0)
|
|
1401
|
+
throw invalid(`${field} is invalid`);
|
|
1402
|
+
aid(binding.org_id, 'org_id');
|
|
1403
|
+
aid(binding.member_aid, 'member_aid');
|
|
1404
|
+
if (!isInt(binding.binding_version) || binding.binding_version < 1)
|
|
1405
|
+
throw invalid('binding_version is invalid');
|
|
1406
|
+
if (typeof binding.terms_hash !== 'string' || !DIGEST_RE.test(binding.terms_hash))
|
|
1407
|
+
throw invalid('terms_hash is invalid');
|
|
1408
|
+
if (!isInt(binding.iat) || !isInt(binding.exp) || binding.exp <= binding.iat)
|
|
1409
|
+
throw invalid('membership_binding time window is invalid');
|
|
1410
|
+
if ('member_signature' in binding)
|
|
1411
|
+
signature(binding.member_signature, 'member_signature');
|
|
1412
|
+
if ('org_signature' in binding)
|
|
1413
|
+
signature(binding.org_signature, 'org_signature');
|
|
1414
|
+
}
|
|
1415
|
+
export function validateMembershipBinding(binding) {
|
|
1416
|
+
validateMembershipBindingPayload(binding);
|
|
1417
|
+
}
|
|
1418
|
+
export function validateMembershipBindingAt(binding, now, expectedRole) {
|
|
1419
|
+
validateMembershipBinding(binding);
|
|
1420
|
+
const value = binding;
|
|
1421
|
+
const iat = value.iat;
|
|
1422
|
+
const exp = value.exp;
|
|
1423
|
+
if (!isInt(now) || !isInt(iat) || !isInt(exp) || now < iat || now >= exp)
|
|
1424
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'membership binding is outside its validity window');
|
|
1425
|
+
if (expectedRole !== undefined && value.role !== expectedRole)
|
|
1426
|
+
throw invalid('membership role is invalid');
|
|
1427
|
+
}
|
|
1428
|
+
export async function verifyMembershipBinding(binding, resolveCertificate, now, expectedOrgSigner, historical = false) {
|
|
1429
|
+
try {
|
|
1430
|
+
validateMembershipBinding(binding);
|
|
1431
|
+
}
|
|
1432
|
+
catch (error) {
|
|
1433
|
+
if (error instanceof ProtocolError && error.message.toLowerCase().includes('signature')) {
|
|
1434
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'membership binding signature structure is invalid');
|
|
1435
|
+
}
|
|
1436
|
+
throw error;
|
|
1437
|
+
}
|
|
1438
|
+
if (now !== undefined && !historical)
|
|
1439
|
+
validateMembershipBindingAt(binding, now);
|
|
1440
|
+
const value = binding;
|
|
1441
|
+
const payload = { ...value };
|
|
1442
|
+
const memberSignature = payload.member_signature;
|
|
1443
|
+
const orgSignature = payload.org_signature;
|
|
1444
|
+
delete payload.member_signature;
|
|
1445
|
+
delete payload.org_signature;
|
|
1446
|
+
for (const [signatureValue, signer] of [[memberSignature, value.member_aid], [orgSignature, expectedOrgSigner ?? value.org_id]]) {
|
|
1447
|
+
try {
|
|
1448
|
+
await verifyAgentSignature(signatureValue, payload, String(signer), resolveCertificate, now);
|
|
1449
|
+
}
|
|
1450
|
+
catch {
|
|
1451
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'membership binding signature verification failed');
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
export function validateRegistrationReceipt(receipt, credential, request, expectedServiceAid, now) {
|
|
1456
|
+
if (!isRecord(request))
|
|
1457
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'request must be an object');
|
|
1458
|
+
validateCredential(credential, 0, request.proof_chain);
|
|
1459
|
+
if (!isRecord(receipt))
|
|
1460
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'receipt must be an object');
|
|
1461
|
+
onlyKnown(receipt, RECEIPT_FIELDS);
|
|
1462
|
+
required(receipt, new Set([...RECEIPT_FIELDS].filter((field) => field !== 'registration_authorization')));
|
|
1463
|
+
if (receipt.ver !== 'aun-credential-registration-receipt/1' || receipt.status !== 'active')
|
|
1464
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'receipt status is invalid');
|
|
1465
|
+
if (receipt.credential_hash !== digest(credential))
|
|
1466
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_MISMATCH', 'credential hash mismatch');
|
|
1467
|
+
if (isRecord(credential) && (receipt.credential_cid !== credential.cid || receipt.request_id !== request.request_id || receipt.nonce !== request.nonce))
|
|
1468
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_MISMATCH', 'receipt binding mismatch');
|
|
1469
|
+
if (!isInt(receipt.registered_at) || !isInt(receipt.exp) || receipt.exp <= receipt.registered_at)
|
|
1470
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'receipt time window is invalid');
|
|
1471
|
+
if (receipt.registrar_aid !== credential.iss)
|
|
1472
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_MISMATCH', 'receipt registrar is not the credential issuer');
|
|
1473
|
+
aid(receipt.registrar_aid, 'registrar_aid');
|
|
1474
|
+
aid(receipt.service_aid, 'service_aid');
|
|
1475
|
+
if (request.registrar_aid !== undefined && receipt.registrar_aid !== request.registrar_aid)
|
|
1476
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_MISMATCH', 'receipt registrar does not match request');
|
|
1477
|
+
if (expectedServiceAid !== undefined && receipt.service_aid !== expectedServiceAid)
|
|
1478
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'receipt service AID mismatch');
|
|
1479
|
+
if (now !== undefined && (!isInt(now) || now < receipt.registered_at || now >= receipt.exp))
|
|
1480
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'receipt is outside its validity window');
|
|
1481
|
+
signature(receipt.signature);
|
|
1482
|
+
}
|
|
1483
|
+
export async function verifyRegistrationReceipt(receipt, credential, request, resolveCertificate, expectedServiceAid, now) {
|
|
1484
|
+
if (!isRecord(request))
|
|
1485
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'request must be an object');
|
|
1486
|
+
validateCredential(credential, 0, request.proof_chain);
|
|
1487
|
+
try {
|
|
1488
|
+
validateRegistrationReceipt(receipt, credential, request, expectedServiceAid, now);
|
|
1489
|
+
}
|
|
1490
|
+
catch (error) {
|
|
1491
|
+
if (error instanceof ProtocolError && error.code === 'CREDENTIAL_INVALID') {
|
|
1492
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'registration receipt structure is invalid');
|
|
1493
|
+
}
|
|
1494
|
+
throw error;
|
|
1495
|
+
}
|
|
1496
|
+
const value = receipt;
|
|
1497
|
+
const signatureValue = value.signature;
|
|
1498
|
+
try {
|
|
1499
|
+
const payload = { ...value };
|
|
1500
|
+
delete payload.signature;
|
|
1501
|
+
await verifyAgentSignature(signatureValue, payload, String(value.service_aid), resolveCertificate, now);
|
|
1502
|
+
}
|
|
1503
|
+
catch {
|
|
1504
|
+
throw new ProtocolError('CREDENTIAL_REGISTRATION_RECEIPT_INVALID', 'registration receipt signature verification failed');
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
export function digest(value) {
|
|
1508
|
+
return `sha256:${sha256Hex(canonicalJson(value))}`;
|
|
1509
|
+
}
|
|
1510
|
+
function sha256Hex(input) {
|
|
1511
|
+
const K = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2];
|
|
1512
|
+
const bytes = new Uint8Array(input.length + 1 + ((55 - input.length) % 64 + 64) % 64 + 8);
|
|
1513
|
+
bytes.set(input);
|
|
1514
|
+
bytes[input.length] = 0x80;
|
|
1515
|
+
const view = new DataView(bytes.buffer);
|
|
1516
|
+
const bits = input.length * 8;
|
|
1517
|
+
view.setUint32(bytes.length - 4, bits >>> 0, false);
|
|
1518
|
+
view.setUint32(bytes.length - 8, Math.floor(bits / 0x100000000), false);
|
|
1519
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
1520
|
+
let h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
|
1521
|
+
for (let offset = 0; offset < bytes.length; offset += 64) {
|
|
1522
|
+
const w = new Uint32Array(64);
|
|
1523
|
+
for (let i = 0; i < 16; i++)
|
|
1524
|
+
w[i] = view.getUint32(offset + i * 4, false);
|
|
1525
|
+
for (let i = 16; i < 64; i++) {
|
|
1526
|
+
const x = w[i - 15], y = w[i - 2];
|
|
1527
|
+
w[i] = (w[i - 16] + (rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3)) + w[i - 7] + (rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10))) >>> 0;
|
|
1528
|
+
}
|
|
1529
|
+
let [a, b, c, d, e, f, g, hh] = h;
|
|
1530
|
+
for (let i = 0; i < 64; i++) {
|
|
1531
|
+
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
1532
|
+
const ch = (e & f) ^ (~e & g);
|
|
1533
|
+
const t1 = (hh + s1 + ch + K[i] + w[i]) >>> 0;
|
|
1534
|
+
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
1535
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
1536
|
+
const t2 = (s0 + maj) >>> 0;
|
|
1537
|
+
[hh, g, f, e, d, c, b, a] = [g, f, e, (d + t1) >>> 0, c, b, a, (t1 + t2) >>> 0];
|
|
1538
|
+
}
|
|
1539
|
+
h = h.map((v, i) => (v + [a, b, c, d, e, f, g, hh][i]) >>> 0);
|
|
1540
|
+
}
|
|
1541
|
+
return h.map((v) => v.toString(16).padStart(8, '0')).join('');
|
|
1542
|
+
}
|
|
1543
|
+
export function orgRequestParams(method, params) {
|
|
1544
|
+
if (!method.startsWith('org.'))
|
|
1545
|
+
return params;
|
|
1546
|
+
const orgId = params.org_id ?? null;
|
|
1547
|
+
if (orgId === null) {
|
|
1548
|
+
if (!['org.lifecycle.list', 'org.health', 'org.status'].includes(method))
|
|
1549
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'org_id is required');
|
|
1550
|
+
}
|
|
1551
|
+
else if (typeof orgId !== 'string' || !AID_RE.test(orgId))
|
|
1552
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'org_id is invalid');
|
|
1553
|
+
const aad = params.aad === undefined ? {} : params.aad;
|
|
1554
|
+
if (!isRecord(aad))
|
|
1555
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'organization AAD is invalid');
|
|
1556
|
+
if ('org_id' in aad && aad.org_id !== orgId)
|
|
1557
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'AAD org_id does not match request');
|
|
1558
|
+
return { ...params, aad: { ...aad, org_id: orgId } };
|
|
1559
|
+
}
|
|
1560
|
+
function orgRequestBindingParams(method, params) {
|
|
1561
|
+
const prepared = orgRequestParams(method, params);
|
|
1562
|
+
const result = Object.fromEntries(Object.entries(prepared).filter(([key]) => !['request_binding', 'client_signature'].includes(key) && !key.startsWith('_')));
|
|
1563
|
+
if (method.startsWith('org.') && isRecord(result.credentials))
|
|
1564
|
+
result.credentials = Object.fromEntries(Object.entries(result.credentials).filter(([key]) => key !== 'representation_proof'));
|
|
1565
|
+
return result;
|
|
1566
|
+
}
|
|
1567
|
+
export function buildRequestBinding(method, target, params, credentialCid, nonce, requestId, iat, exp) {
|
|
1568
|
+
if (![method, target, nonce, requestId].every((value) => typeof value === 'string' && value.length > 0) || typeof credentialCid !== 'string' || (!credentialCid !== (method === 'credential.revocationList')))
|
|
1569
|
+
throw invalid('request binding identity is invalid');
|
|
1570
|
+
if (!isInt(iat) || !isInt(exp) || exp <= iat || exp > iat + 300)
|
|
1571
|
+
throw invalid('request binding time window is invalid');
|
|
1572
|
+
return { request_id: requestId, nonce, target, method, params_hash: digest(orgRequestBindingParams(method, params)), iat, exp, credential_cid: credentialCid };
|
|
1573
|
+
}
|
|
1574
|
+
export function validateRequestBinding(binding, method, target, params, credentialCid, nonce, requestId, now) {
|
|
1575
|
+
const fields = ['request_id', 'nonce', 'target', 'method', 'params_hash', 'iat', 'exp', 'credential_cid'];
|
|
1576
|
+
if (!isRecord(binding) || Object.keys(binding).length !== fields.length || Object.keys(binding).some((key) => !fields.includes(key)))
|
|
1577
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'request binding is invalid');
|
|
1578
|
+
if (![binding.request_id, binding.nonce, binding.target, binding.method, binding.params_hash].every((value) => typeof value === 'string' && value.length > 0) || typeof binding.credential_cid !== 'string' || (!binding.credential_cid !== (method === 'credential.revocationList')))
|
|
1579
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'request binding is invalid');
|
|
1580
|
+
if (binding.request_id !== requestId || binding.nonce !== nonce || binding.target !== target || binding.method !== method || binding.credential_cid !== credentialCid || binding.params_hash !== digest(orgRequestBindingParams(method, params)))
|
|
1581
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'request binding does not match request');
|
|
1582
|
+
if (!isInt(binding.iat) || !isInt(binding.exp) || binding.exp <= binding.iat || binding.exp > binding.iat + 300 || !isInt(now) || now < binding.iat || now >= binding.exp)
|
|
1583
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'request binding is outside its validity window');
|
|
1584
|
+
}
|
|
1585
|
+
function representationBindingInvalid(message) {
|
|
1586
|
+
return new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', message);
|
|
1587
|
+
}
|
|
1588
|
+
function validateRepresentationSignature(value) {
|
|
1589
|
+
if (!isRecord(value) || !['cert_fingerprint', 'timestamp', 'signature'].every((field) => field in value) || Object.keys(value).some((key) => !REPRESENTATION_SIGNATURE_FIELDS.has(key)))
|
|
1590
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'representation signature structure is invalid');
|
|
1591
|
+
if (typeof value.cert_fingerprint !== 'string' || !FINGERPRINT_RE.test(value.cert_fingerprint))
|
|
1592
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'representation certificate fingerprint is invalid');
|
|
1593
|
+
if (value.public_key_fingerprint !== undefined && (typeof value.public_key_fingerprint !== 'string' || !FINGERPRINT_RE.test(value.public_key_fingerprint)))
|
|
1594
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'representation public key fingerprint is invalid');
|
|
1595
|
+
if (!isInt(value.timestamp) || value.timestamp < 0)
|
|
1596
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'representation signature timestamp is invalid');
|
|
1597
|
+
if (typeof value.signature !== 'string' || !strictDerBase64(value.signature))
|
|
1598
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'representation signature encoding is invalid');
|
|
1599
|
+
}
|
|
1600
|
+
function validateRepresentationContext(proof, credential, request, now) {
|
|
1601
|
+
if (!isRecord(request))
|
|
1602
|
+
throw representationBindingInvalid('representation request is invalid');
|
|
1603
|
+
if (Object.keys(request).length !== 6 || Object.keys(request).some((key) => !['from', 'for', 'audience', 'resource', 'params', 'binding'].includes(key)))
|
|
1604
|
+
throw representationBindingInvalid('representation request is invalid');
|
|
1605
|
+
for (const field of ['from', 'for', 'audience', 'resource'])
|
|
1606
|
+
if (typeof request[field] !== 'string' || !request[field])
|
|
1607
|
+
throw representationBindingInvalid('representation request is invalid');
|
|
1608
|
+
for (const field of ['from', 'for', 'audience'])
|
|
1609
|
+
if (!AID_RE.test(request[field]))
|
|
1610
|
+
throw representationBindingInvalid(`representation request ${field} is invalid`);
|
|
1611
|
+
if (!isRecord(request.params) || !isRecord(request.binding))
|
|
1612
|
+
throw representationBindingInvalid('representation request is invalid');
|
|
1613
|
+
const binding = request.binding;
|
|
1614
|
+
if (typeof binding.method !== 'string' || !binding.method || typeof binding.target !== 'string' || !binding.target)
|
|
1615
|
+
throw representationBindingInvalid('representation request binding is invalid');
|
|
1616
|
+
validateRequestBinding(binding, binding.method, binding.target, request.params, String(credential.cid), String(binding.nonce ?? ''), String(binding.request_id ?? ''), now);
|
|
1617
|
+
if (binding.target !== request.audience || proof.issuer !== request.from)
|
|
1618
|
+
throw representationBindingInvalid('representation signer or service target does not match request');
|
|
1619
|
+
if (proof.from !== request.from || proof.for !== request.for || proof.audience !== request.audience || proof.resource !== request.resource)
|
|
1620
|
+
throw representationBindingInvalid('representation identity does not match request');
|
|
1621
|
+
if (proof.represented !== credential.sub || proof.credential_cid !== credential.cid)
|
|
1622
|
+
throw representationBindingInvalid('representation subject does not match credential');
|
|
1623
|
+
if (proof.method !== binding.method || proof.params_hash !== binding.params_hash || proof.nonce !== binding.nonce)
|
|
1624
|
+
throw representationBindingInvalid('representation operation does not match request');
|
|
1625
|
+
if (proof.iat !== binding.iat || proof.exp !== binding.exp)
|
|
1626
|
+
throw representationBindingInvalid('representation time window does not match request');
|
|
1627
|
+
}
|
|
1628
|
+
export async function verifyRepresentationProof(proof, credential, request, resolveCertificate, now, proofChain) {
|
|
1629
|
+
if (!isRecord(proof) || Object.keys(proof).length !== REPRESENTATION_FIELDS.size || Object.keys(proof).some((key) => !REPRESENTATION_FIELDS.has(key)))
|
|
1630
|
+
throw representationBindingInvalid('representation proof is invalid');
|
|
1631
|
+
for (const field of ['issuer', 'represented', 'from', 'for', 'audience', 'credential_cid', 'method', 'resource', 'params_hash', 'nonce'])
|
|
1632
|
+
if (typeof proof[field] !== 'string' || !proof[field])
|
|
1633
|
+
throw representationBindingInvalid('representation proof is invalid');
|
|
1634
|
+
for (const field of ['issuer', 'represented', 'from', 'for', 'audience'])
|
|
1635
|
+
if (!AID_RE.test(proof[field]))
|
|
1636
|
+
throw representationBindingInvalid('representation proof AID is invalid');
|
|
1637
|
+
if (!DIGEST_RE.test(proof.params_hash))
|
|
1638
|
+
throw representationBindingInvalid('representation params_hash is invalid');
|
|
1639
|
+
if (!isInt(proof.iat) || !isInt(proof.exp) || proof.exp <= proof.iat || !isInt(now) || now < proof.iat || now >= proof.exp)
|
|
1640
|
+
throw representationBindingInvalid('representation time window is invalid');
|
|
1641
|
+
validateRepresentationSignature(proof.signature);
|
|
1642
|
+
if (!isRecord(credential))
|
|
1643
|
+
throw new ProtocolError('CREDENTIAL_INVALID', 'credential must be an object');
|
|
1644
|
+
validateCredential(credential, 0, proofChain);
|
|
1645
|
+
try {
|
|
1646
|
+
const certPem = certificatePem(await resolveCertificate(proof.signature.cert_fingerprint));
|
|
1647
|
+
if (!certPem || !publicKeyDerB64(certPem))
|
|
1648
|
+
throw new Error('certificate is missing');
|
|
1649
|
+
if ((await certFingerprint(certPem)).toLowerCase() !== proof.signature.cert_fingerprint.toLowerCase())
|
|
1650
|
+
throw new Error('certificate fingerprint mismatch');
|
|
1651
|
+
const metadata = parseCertMetadata(certPem);
|
|
1652
|
+
if (metadata.subject.toLowerCase() !== String(proof.issuer).toLowerCase())
|
|
1653
|
+
throw new Error('certificate subject does not match issuer');
|
|
1654
|
+
if (proof.signature.public_key_fingerprint !== undefined && (await publicKeyFingerprint(certPem)).toLowerCase() !== String(proof.signature.public_key_fingerprint).toLowerCase())
|
|
1655
|
+
throw new Error('public key fingerprint mismatch');
|
|
1656
|
+
const timestamp = proof.signature.timestamp;
|
|
1657
|
+
if (timestamp > now + 300 || timestamp < proof.iat || timestamp >= proof.exp)
|
|
1658
|
+
throw new Error('representation signature timestamp is outside its window');
|
|
1659
|
+
if (timestamp < Math.floor(metadata.notBefore.getTime() / 1000) || timestamp > Math.floor(metadata.notAfter.getTime() / 1000) || now < Math.floor(metadata.notBefore.getTime() / 1000) || now > Math.floor(metadata.notAfter.getTime() / 1000))
|
|
1660
|
+
throw new Error('certificate is not valid at signature or verification time');
|
|
1661
|
+
const payload = { ...proof };
|
|
1662
|
+
delete payload.signature;
|
|
1663
|
+
if (!await verifySignatureWithCert(certPem, proof.signature.signature, canonicalJson(payload)))
|
|
1664
|
+
throw new Error('representation proof signature verification failed');
|
|
1665
|
+
}
|
|
1666
|
+
catch {
|
|
1667
|
+
throw new ProtocolError('CREDENTIAL_SIGNATURE_INVALID', 'representation proof signature verification failed');
|
|
1668
|
+
}
|
|
1669
|
+
validateRepresentationContext(proof, credential, request, now);
|
|
1670
|
+
}
|
|
1671
|
+
export function validateRequestNonce(nonce, usedNonces) {
|
|
1672
|
+
if (typeof nonce !== 'string' || nonce.length === 0 || usedNonces.includes(nonce))
|
|
1673
|
+
throw new ProtocolError('CREDENTIAL_REQUEST_BINDING_INVALID', 'request nonce has already been used');
|
|
1674
|
+
}
|
|
1675
|
+
export function validateIdempotency(existing, incoming) {
|
|
1676
|
+
if (existing.request_id === incoming.request_id && digest(existing) !== digest(incoming))
|
|
1677
|
+
throw new ProtocolError('CREDENTIAL_IDEMPOTENCY_KEY_REUSE', 'request id was reused with a different payload');
|
|
1678
|
+
}
|
|
1679
|
+
export function buildRpcRequest(id, method, params) {
|
|
1680
|
+
return { jsonrpc: '2.0', id, method, params };
|
|
1681
|
+
}
|
|
1682
|
+
export function buildRpcNotification(method, params) {
|
|
1683
|
+
return { jsonrpc: '2.0', method, params };
|
|
1684
|
+
}
|
|
1685
|
+
export function buildRpcResult(id, result) {
|
|
1686
|
+
return { jsonrpc: '2.0', id, result };
|
|
1687
|
+
}
|
|
1688
|
+
export function buildRpcError(id, code, message, errorCode) {
|
|
1689
|
+
return { jsonrpc: '2.0', id, error: { code, message, data: { error_code: errorCode } } };
|
|
1690
|
+
}
|
|
1691
|
+
export function parseJsonStrict(text, normalizeKeys = true) {
|
|
1692
|
+
try {
|
|
1693
|
+
let index = scanValue(text, 0, normalizeKeys);
|
|
1694
|
+
index = skipWhitespace(text, index);
|
|
1695
|
+
if (index !== text.length)
|
|
1696
|
+
throw new Error('trailing JSON data');
|
|
1697
|
+
const value = JSON.parse(text);
|
|
1698
|
+
if (normalizeKeys)
|
|
1699
|
+
normalizeNfc(value);
|
|
1700
|
+
else {
|
|
1701
|
+
validateOrgRequestJSON(value);
|
|
1702
|
+
baseCanonicalJson(value);
|
|
1703
|
+
}
|
|
1704
|
+
return value;
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
if (error instanceof ProtocolError)
|
|
1708
|
+
throw error;
|
|
1709
|
+
throw invalid(error instanceof Error ? error.message : String(error));
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
function skipWhitespace(text, index) {
|
|
1713
|
+
while (index < text.length && /\s/.test(text[index]))
|
|
1714
|
+
index++;
|
|
1715
|
+
return index;
|
|
1716
|
+
}
|
|
1717
|
+
function scanString(text, start) {
|
|
1718
|
+
if (text[start] !== '"')
|
|
1719
|
+
throw new Error('expected string');
|
|
1720
|
+
let escaped = false;
|
|
1721
|
+
for (let index = start + 1; index < text.length; index++) {
|
|
1722
|
+
const char = text[index];
|
|
1723
|
+
if (escaped) {
|
|
1724
|
+
escaped = false;
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (char === '\\') {
|
|
1728
|
+
escaped = true;
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
if (char === '"')
|
|
1732
|
+
return index + 1;
|
|
1733
|
+
}
|
|
1734
|
+
throw new Error('unterminated string');
|
|
1735
|
+
}
|
|
1736
|
+
function scanValue(text, start, normalizeKeys = true) {
|
|
1737
|
+
const index = skipWhitespace(text, start);
|
|
1738
|
+
const char = text[index];
|
|
1739
|
+
if (char === '"')
|
|
1740
|
+
return scanString(text, index);
|
|
1741
|
+
if (char === '{')
|
|
1742
|
+
return scanObject(text, index, normalizeKeys);
|
|
1743
|
+
if (char === '[')
|
|
1744
|
+
return scanArray(text, index, normalizeKeys);
|
|
1745
|
+
for (const literal of ['true', 'false', 'null'])
|
|
1746
|
+
if (text.startsWith(literal, index))
|
|
1747
|
+
return index + literal.length;
|
|
1748
|
+
if (char && '-0123456789'.includes(char)) {
|
|
1749
|
+
let end = index + 1;
|
|
1750
|
+
while (end < text.length && !/[\s,\]}]/.test(text[end]))
|
|
1751
|
+
end++;
|
|
1752
|
+
const token = text.slice(index, end);
|
|
1753
|
+
const parts = /^-?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(token);
|
|
1754
|
+
if (parts && Number.isInteger(Number(token))) {
|
|
1755
|
+
const digits = parts[1] + (parts[2] ?? '');
|
|
1756
|
+
const fraction = (parts[2]?.length ?? 0) - Number(parts[3] ?? 0);
|
|
1757
|
+
if (fraction > 0 && /[1-9]/.test(digits.slice(-Math.min(fraction, digits.length))))
|
|
1758
|
+
throw invalid('JSON fraction rounded to an integer');
|
|
1759
|
+
}
|
|
1760
|
+
return end;
|
|
1761
|
+
}
|
|
1762
|
+
throw new Error('invalid JSON value');
|
|
1763
|
+
}
|
|
1764
|
+
function scanArray(text, start, normalizeKeys = true) {
|
|
1765
|
+
let index = skipWhitespace(text, start + 1);
|
|
1766
|
+
if (text[index] === ']')
|
|
1767
|
+
return index + 1;
|
|
1768
|
+
while (true) {
|
|
1769
|
+
index = scanValue(text, index, normalizeKeys);
|
|
1770
|
+
index = skipWhitespace(text, index);
|
|
1771
|
+
if (text[index] === ']')
|
|
1772
|
+
return index + 1;
|
|
1773
|
+
if (text[index] !== ',')
|
|
1774
|
+
throw new Error('expected comma');
|
|
1775
|
+
index = skipWhitespace(text, index + 1);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
function scanObject(text, start, normalizeKeys = true) {
|
|
1779
|
+
let index = skipWhitespace(text, start + 1);
|
|
1780
|
+
const keys = new Set();
|
|
1781
|
+
if (text[index] === '}')
|
|
1782
|
+
return index + 1;
|
|
1783
|
+
while (true) {
|
|
1784
|
+
const keyStart = index;
|
|
1785
|
+
const keyEnd = scanString(text, keyStart);
|
|
1786
|
+
const key = (normalizeKeys ? normalizeNfc(JSON.parse(text.slice(keyStart, keyEnd))) : JSON.parse(text.slice(keyStart, keyEnd)));
|
|
1787
|
+
if (keys.has(key))
|
|
1788
|
+
throw invalid('duplicate object key');
|
|
1789
|
+
keys.add(key);
|
|
1790
|
+
index = skipWhitespace(text, keyEnd);
|
|
1791
|
+
if (text[index] !== ':')
|
|
1792
|
+
throw new Error('expected colon');
|
|
1793
|
+
index = scanValue(text, index + 1, normalizeKeys);
|
|
1794
|
+
index = skipWhitespace(text, index);
|
|
1795
|
+
if (text[index] === '}')
|
|
1796
|
+
return index + 1;
|
|
1797
|
+
if (text[index] !== ',')
|
|
1798
|
+
throw new Error('expected comma');
|
|
1799
|
+
index = skipWhitespace(text, index + 1);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
//# sourceMappingURL=org-protocol.js.map
|