@aroha-sdk/credentials 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +29 -0
- package/src/credentials.test.ts +93 -0
- package/src/credentials.ts +148 -0
- package/src/index.ts +6 -0
- package/src/mandate.test.ts +293 -0
- package/src/mandate.ts +272 -0
- package/src/middleware.ts +247 -0
- package/src/rbac.test.ts +113 -0
- package/src/rbac.ts +91 -0
- package/src/registry.test.ts +169 -0
- package/src/registry.ts +259 -0
- package/src/types.ts +19 -0
- package/tsconfig.json +12 -0
package/src/mandate.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aroha-sdk/credentials — ArohaMandateCredential
|
|
3
|
+
*
|
|
4
|
+
* Issues, attenuates, and verifies ArohaSpendingMandate tokens.
|
|
5
|
+
*
|
|
6
|
+
* The mandate chain enforces monotonic scope narrowing:
|
|
7
|
+
* User → Intent Mandate (max budget)
|
|
8
|
+
* Personal Agent → Cart Mandate (narrowed to specific service)
|
|
9
|
+
* Provider Agent → Payment Mandate (single-transaction authorisation)
|
|
10
|
+
*
|
|
11
|
+
* Each mandate is Ed25519-signed by the grantor. The grantee carries the
|
|
12
|
+
* token in the ArohaSpendingMandate message body — the payment processor
|
|
13
|
+
* verifies the full chain without ever seeing the user's card number.
|
|
14
|
+
*
|
|
15
|
+
* References:
|
|
16
|
+
* AP2 Protocol (Google) Intent/Cart/Payment Mandate pattern.
|
|
17
|
+
* IETF draft-niyikiza-oauth-attenuating-agent-tokens (2024).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as ed from "@noble/ed25519";
|
|
21
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
22
|
+
import { v4 as uuidv4 } from "uuid";
|
|
23
|
+
import { type ArohaSpendingMandateBody, type SpendingConstraints } from "@aroha-sdk/core";
|
|
24
|
+
|
|
25
|
+
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
|
|
26
|
+
|
|
27
|
+
export interface SignedMandate {
|
|
28
|
+
mandate: ArohaSpendingMandateBody;
|
|
29
|
+
/** base64url Ed25519 signature over the canonical mandate (proof). */
|
|
30
|
+
signature: string;
|
|
31
|
+
/** base64url serialised mandate + signature — embed in message body credentialToken. */
|
|
32
|
+
token: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MandateVerificationResult {
|
|
36
|
+
valid: boolean;
|
|
37
|
+
mandate?: ArohaSpendingMandateBody;
|
|
38
|
+
reason?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Issue ────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Issue a root Intent Mandate from a user to their personal agent.
|
|
45
|
+
*
|
|
46
|
+
* @param grantorDID User's DID (or personal-agent acting on user's behalf)
|
|
47
|
+
* @param granteeDID Personal agent's DID
|
|
48
|
+
* @param constraints Maximum spend envelope — all downstream mandates must be ≤ these
|
|
49
|
+
* @param privateKey Grantor's Ed25519 private key (32 bytes)
|
|
50
|
+
* @param ttlMs Time-to-live in milliseconds (default: 1 hour)
|
|
51
|
+
*/
|
|
52
|
+
export async function issueIntentMandate(
|
|
53
|
+
grantorDID: string,
|
|
54
|
+
granteeDID: string,
|
|
55
|
+
constraints: SpendingConstraints,
|
|
56
|
+
privateKey: Uint8Array,
|
|
57
|
+
ttlMs = 3_600_000
|
|
58
|
+
): Promise<SignedMandate> {
|
|
59
|
+
return issueMandateInternal("intent", grantorDID, granteeDID, constraints, null, privateKey, ttlMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Attenuate a mandate to a narrower Cart Mandate.
|
|
64
|
+
* Validates that the new constraints are a subset of the parent's constraints.
|
|
65
|
+
*/
|
|
66
|
+
export async function attenuateToCart(
|
|
67
|
+
parent: SignedMandate,
|
|
68
|
+
granteeDID: string,
|
|
69
|
+
narrowedConstraints: SpendingConstraints,
|
|
70
|
+
grantorPrivateKey: Uint8Array,
|
|
71
|
+
ttlMs?: number
|
|
72
|
+
): Promise<SignedMandate> {
|
|
73
|
+
assertNarrowing(parent.mandate.constraints, narrowedConstraints, parent.mandate.mandateTier, "cart");
|
|
74
|
+
const expiry = ttlMs
|
|
75
|
+
? Math.min(new Date(parent.mandate.expiresAt).getTime(), Date.now() + ttlMs)
|
|
76
|
+
: new Date(parent.mandate.expiresAt).getTime();
|
|
77
|
+
return issueMandateInternal(
|
|
78
|
+
"cart",
|
|
79
|
+
parent.mandate.grantee,
|
|
80
|
+
granteeDID,
|
|
81
|
+
narrowedConstraints,
|
|
82
|
+
parent.mandate.mandateId,
|
|
83
|
+
grantorPrivateKey,
|
|
84
|
+
expiry - Date.now()
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Attenuate a Cart Mandate to a single-transaction Payment Mandate.
|
|
90
|
+
*/
|
|
91
|
+
export async function attenuateToPayment(
|
|
92
|
+
parent: SignedMandate,
|
|
93
|
+
granteeDID: string,
|
|
94
|
+
narrowedConstraints: SpendingConstraints,
|
|
95
|
+
grantorPrivateKey: Uint8Array,
|
|
96
|
+
ttlMs?: number
|
|
97
|
+
): Promise<SignedMandate> {
|
|
98
|
+
assertNarrowing(parent.mandate.constraints, narrowedConstraints, parent.mandate.mandateTier, "payment");
|
|
99
|
+
const expiry = ttlMs
|
|
100
|
+
? Math.min(new Date(parent.mandate.expiresAt).getTime(), Date.now() + ttlMs)
|
|
101
|
+
: new Date(parent.mandate.expiresAt).getTime();
|
|
102
|
+
return issueMandateInternal(
|
|
103
|
+
"payment",
|
|
104
|
+
parent.mandate.grantee,
|
|
105
|
+
granteeDID,
|
|
106
|
+
narrowedConstraints,
|
|
107
|
+
parent.mandate.mandateId,
|
|
108
|
+
grantorPrivateKey,
|
|
109
|
+
expiry - Date.now()
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Verify ───────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Verify a mandate token.
|
|
117
|
+
*
|
|
118
|
+
* @param token The base64url mandate token from credentialToken field
|
|
119
|
+
* @param publicKey Grantor's Ed25519 public key (32 bytes)
|
|
120
|
+
* @param expectedGrantee If set, validates the grantee DID matches
|
|
121
|
+
*/
|
|
122
|
+
export async function verifyMandate(
|
|
123
|
+
token: string,
|
|
124
|
+
publicKey: Uint8Array,
|
|
125
|
+
expectedGrantee?: string
|
|
126
|
+
): Promise<MandateVerificationResult> {
|
|
127
|
+
let signed: SignedMandate;
|
|
128
|
+
try {
|
|
129
|
+
signed = JSON.parse(Buffer.from(token, "base64url").toString("utf8")) as SignedMandate;
|
|
130
|
+
} catch {
|
|
131
|
+
return { valid: false, reason: "Malformed token" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { mandate, signature } = signed;
|
|
135
|
+
|
|
136
|
+
// Expiry check
|
|
137
|
+
if (new Date(mandate.expiresAt) < new Date()) {
|
|
138
|
+
return { valid: false, reason: "Mandate expired" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Grantee check
|
|
142
|
+
if (expectedGrantee && mandate.grantee !== expectedGrantee) {
|
|
143
|
+
return { valid: false, reason: `Mandate grantee mismatch: expected ${expectedGrantee}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Signature check
|
|
147
|
+
try {
|
|
148
|
+
const canonical = canonicalizeMandate(mandate);
|
|
149
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
150
|
+
const sigBytes = Buffer.from(signature, "base64url");
|
|
151
|
+
const valid = await ed.verifyAsync(sigBytes, bytes, publicKey);
|
|
152
|
+
if (!valid) return { valid: false, reason: "Invalid signature" };
|
|
153
|
+
} catch {
|
|
154
|
+
return { valid: false, reason: "Signature verification error" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { valid: true, mandate };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async function issueMandateInternal(
|
|
163
|
+
tier: "intent" | "cart" | "payment",
|
|
164
|
+
grantorDID: string,
|
|
165
|
+
granteeDID: string,
|
|
166
|
+
constraints: SpendingConstraints,
|
|
167
|
+
parentMandateId: string | null,
|
|
168
|
+
privateKey: Uint8Array,
|
|
169
|
+
ttlMs: number
|
|
170
|
+
): Promise<SignedMandate> {
|
|
171
|
+
const mandate: ArohaSpendingMandateBody = {
|
|
172
|
+
mandateTier: tier,
|
|
173
|
+
constraints,
|
|
174
|
+
grantee: granteeDID,
|
|
175
|
+
grantor: grantorDID,
|
|
176
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
177
|
+
parentMandateId,
|
|
178
|
+
mandateId: uuidv4(),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const canonical = canonicalizeMandate(mandate);
|
|
182
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
183
|
+
const sig = await ed.signAsync(bytes, privateKey);
|
|
184
|
+
const signature = Buffer.from(sig).toString("base64url");
|
|
185
|
+
const token = Buffer.from(JSON.stringify({ mandate, signature })).toString("base64url");
|
|
186
|
+
|
|
187
|
+
return { mandate, signature, token };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Canonical JSON — lexicographically sorted keys, no whitespace. */
|
|
191
|
+
function canonicalizeMandate(mandate: ArohaSpendingMandateBody): string {
|
|
192
|
+
return JSON.stringify(sortKeys(mandate));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sortKeys(obj: unknown): unknown {
|
|
196
|
+
if (Array.isArray(obj)) return obj.map(sortKeys);
|
|
197
|
+
if (obj !== null && typeof obj === "object") {
|
|
198
|
+
return Object.keys(obj as Record<string, unknown>)
|
|
199
|
+
.sort()
|
|
200
|
+
.reduce((acc, k) => {
|
|
201
|
+
(acc as Record<string, unknown>)[k] = sortKeys((obj as Record<string, unknown>)[k]);
|
|
202
|
+
return acc;
|
|
203
|
+
}, {} as Record<string, unknown>);
|
|
204
|
+
}
|
|
205
|
+
return obj;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Validate that a narrowed constraint set is a subset of the parent's.
|
|
210
|
+
* Throws if any narrowed value exceeds the parent's limit.
|
|
211
|
+
*/
|
|
212
|
+
function assertNarrowing(
|
|
213
|
+
parent: SpendingConstraints,
|
|
214
|
+
child: SpendingConstraints,
|
|
215
|
+
parentTier: string,
|
|
216
|
+
childTier: string
|
|
217
|
+
): void {
|
|
218
|
+
const prefix = `Cannot attenuate ${parentTier} → ${childTier}`;
|
|
219
|
+
|
|
220
|
+
if (child.spendLimitUsd > parent.spendLimitUsd) {
|
|
221
|
+
throw new Error(`${prefix}: child spendLimitUsd (${child.spendLimitUsd}) exceeds parent (${parent.spendLimitUsd})`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
parent.sessionLimitUsd !== undefined &&
|
|
226
|
+
child.sessionLimitUsd !== undefined &&
|
|
227
|
+
child.sessionLimitUsd > parent.sessionLimitUsd
|
|
228
|
+
) {
|
|
229
|
+
throw new Error(`${prefix}: child sessionLimitUsd exceeds parent`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
parent.requireHumanApprovalAboveUsd !== undefined &&
|
|
234
|
+
child.requireHumanApprovalAboveUsd !== undefined &&
|
|
235
|
+
child.requireHumanApprovalAboveUsd > parent.requireHumanApprovalAboveUsd
|
|
236
|
+
) {
|
|
237
|
+
throw new Error(`${prefix}: child approval threshold exceeds parent`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (parent.allowedMerchants != null && parent.allowedMerchants.length > 0) {
|
|
241
|
+
const parentSet = new Set(parent.allowedMerchants);
|
|
242
|
+
if (!child.allowedMerchants || child.allowedMerchants.length === 0) {
|
|
243
|
+
throw new Error(`${prefix}: parent restricts allowedMerchants but child sets none`);
|
|
244
|
+
}
|
|
245
|
+
for (const m of child.allowedMerchants) {
|
|
246
|
+
if (!parentSet.has(m)) {
|
|
247
|
+
throw new Error(`${prefix}: child allowedMerchants contains "${m}" not in parent`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (parent.merchantCategory !== undefined && parent.merchantCategory !== null) {
|
|
253
|
+
if (child.merchantCategory === null || child.merchantCategory === undefined) {
|
|
254
|
+
throw new Error(`${prefix}: child merchantCategory must not widen parent's "${parent.merchantCategory}" restriction`);
|
|
255
|
+
}
|
|
256
|
+
if (child.merchantCategory !== parent.merchantCategory) {
|
|
257
|
+
throw new Error(`${prefix}: child merchantCategory "${child.merchantCategory}" does not match parent "${parent.merchantCategory}"`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (parent.validUntil !== undefined && child.validUntil !== undefined) {
|
|
262
|
+
if (new Date(child.validUntil) > new Date(parent.validUntil)) {
|
|
263
|
+
throw new Error(`${prefix}: child validUntil (${child.validUntil}) extends beyond parent (${parent.validUntil})`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (parent.validFrom !== undefined && child.validFrom !== undefined) {
|
|
268
|
+
if (new Date(child.validFrom) < new Date(parent.validFrom)) {
|
|
269
|
+
throw new Error(`${prefix}: child validFrom (${child.validFrom}) precedes parent (${parent.validFrom})`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aroha-sdk/credentials — ArohaServer middleware and HTTP route helpers
|
|
3
|
+
*
|
|
4
|
+
* Wire RBAC and the credential registry into ArohaServer without coupling
|
|
5
|
+
* aroha-core to this package.
|
|
6
|
+
*
|
|
7
|
+
* import { createRbacMiddleware, credentialRegistryRoutes } from "@aroha-sdk/credentials";
|
|
8
|
+
*
|
|
9
|
+
* const server = new ArohaServer({
|
|
10
|
+
* ...
|
|
11
|
+
* middleware: [createRbacMiddleware(policy)],
|
|
12
|
+
* httpRoutes: credentialRegistryRoutes(registry, { resolveIssuerPublicKey }),
|
|
13
|
+
* });
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type IncomingMessage, type ServerResponse } from "http";
|
|
17
|
+
import { type ArohaEnvelope, type ArohaMiddleware, type ArohaHttpRoute, readBody } from "@aroha-sdk/core";
|
|
18
|
+
import { verifyHumanCredential, deserializeCredential, serializeCredential } from "./credentials.js";
|
|
19
|
+
import { verifyRevokeProof, CredentialRegistry } from "./registry.js";
|
|
20
|
+
import { checkPermission, actionForMessageType, DEFAULT_RBAC_PERMISSIONS, type RbacPolicy } from "./rbac.js";
|
|
21
|
+
import { type ArohaRole } from "./types.js";
|
|
22
|
+
import { type HumanCredential } from "./types.js";
|
|
23
|
+
|
|
24
|
+
// ─── RBAC middleware ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a ArohaServer middleware that enforces RBAC on every inbound message.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* server = new ArohaServer({
|
|
31
|
+
* middleware: [createRbacMiddleware({
|
|
32
|
+
* resolveAgentRoles: async (did) => agentRoles.get(did) ?? [],
|
|
33
|
+
* resolveIssuerPublicKey: async (did) => issuerKeys.get(did) ?? null,
|
|
34
|
+
* })],
|
|
35
|
+
* });
|
|
36
|
+
*/
|
|
37
|
+
export function createRbacMiddleware(policy: RbacPolicy): ArohaMiddleware {
|
|
38
|
+
return async (envelope: ArohaEnvelope, reject) => {
|
|
39
|
+
const result = await checkRbac(envelope, policy);
|
|
40
|
+
if (!result.allowed) {
|
|
41
|
+
const status = result.unauthorized ? 401 : 403;
|
|
42
|
+
const code = result.unauthorized ? "Aroha_UNAUTHORIZED" : "Aroha_FORBIDDEN";
|
|
43
|
+
reject(status, code, result.reason ?? "Access denied");
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function checkRbac(
|
|
49
|
+
envelope: ArohaEnvelope,
|
|
50
|
+
policy: RbacPolicy
|
|
51
|
+
): Promise<{ allowed: boolean; unauthorized?: boolean; reason?: string }> {
|
|
52
|
+
const action = actionForMessageType(envelope.type as Parameters<typeof actionForMessageType>[0]);
|
|
53
|
+
if (!action) return { allowed: true }; // acks and responses skip RBAC
|
|
54
|
+
|
|
55
|
+
const permissions = policy.permissions ?? DEFAULT_RBAC_PERMISSIONS;
|
|
56
|
+
const body = envelope.body as unknown as Record<string, unknown>;
|
|
57
|
+
const credentialId = body?.credentialId as string | undefined;
|
|
58
|
+
const credentialToken = body?.credentialToken as string | undefined;
|
|
59
|
+
|
|
60
|
+
let roles: ArohaRole[];
|
|
61
|
+
|
|
62
|
+
if (credentialId) {
|
|
63
|
+
if (!policy.credentialRegistry) {
|
|
64
|
+
return { allowed: false, unauthorized: true, reason: "credentialId supplied but no credential registry configured" };
|
|
65
|
+
}
|
|
66
|
+
const cred = await policy.credentialRegistry.resolve(credentialId);
|
|
67
|
+
if (!cred) {
|
|
68
|
+
return { allowed: false, unauthorized: true, reason: `Credential not found or expired: ${credentialId}` };
|
|
69
|
+
}
|
|
70
|
+
if (policy.resolveIssuerPublicKey) {
|
|
71
|
+
const issuerPubKey = await policy.resolveIssuerPublicKey(cred.issuerDID);
|
|
72
|
+
if (issuerPubKey) {
|
|
73
|
+
const token = serializeCredential(cred as HumanCredential);
|
|
74
|
+
const verifyResult = await verifyHumanCredential(token, issuerPubKey);
|
|
75
|
+
if (!verifyResult.valid) {
|
|
76
|
+
return { allowed: false, unauthorized: true, reason: `Registry credential signature invalid: ${verifyResult.reason}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
roles = (cred as HumanCredential).roles;
|
|
81
|
+
|
|
82
|
+
} else if (credentialToken) {
|
|
83
|
+
if (!policy.resolveIssuerPublicKey) {
|
|
84
|
+
return { allowed: false, unauthorized: true, reason: "Server does not accept human credentials (no resolveIssuerPublicKey configured)" };
|
|
85
|
+
}
|
|
86
|
+
const parsed = deserializeCredential(credentialToken);
|
|
87
|
+
if (!parsed) {
|
|
88
|
+
return { allowed: false, unauthorized: true, reason: "credentialToken is malformed" };
|
|
89
|
+
}
|
|
90
|
+
const issuerPubKey = await policy.resolveIssuerPublicKey(parsed.issuerDID);
|
|
91
|
+
if (!issuerPubKey) {
|
|
92
|
+
return { allowed: false, unauthorized: true, reason: `Unknown credential issuer: ${parsed.issuerDID}` };
|
|
93
|
+
}
|
|
94
|
+
const verifyResult = await verifyHumanCredential(credentialToken, issuerPubKey);
|
|
95
|
+
if (!verifyResult.valid) {
|
|
96
|
+
return { allowed: false, unauthorized: true, reason: verifyResult.reason };
|
|
97
|
+
}
|
|
98
|
+
roles = verifyResult.credential!.roles;
|
|
99
|
+
|
|
100
|
+
} else {
|
|
101
|
+
roles = await policy.resolveAgentRoles(envelope.from);
|
|
102
|
+
if (roles.length === 0) {
|
|
103
|
+
return { allowed: false, unauthorized: false, reason: `Agent ${envelope.from} has no assigned roles` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = checkPermission(roles, action, permissions);
|
|
108
|
+
return { allowed: result.allowed, reason: result.reason };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Credential registry HTTP routes ─────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export interface CredentialRegistryRouteOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the issuer's public key for signature verification on register/revoke.
|
|
116
|
+
* When omitted, credentials are stored without signature verification.
|
|
117
|
+
*/
|
|
118
|
+
resolveIssuerPublicKey?: (issuerDID: string) => Promise<Uint8Array | null>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Returns ArohaHttpRoute[] that mount credential registry endpoints on ArohaServer.
|
|
123
|
+
*
|
|
124
|
+
* Endpoints added:
|
|
125
|
+
* POST /aroha/credentials — register a credential
|
|
126
|
+
* GET /aroha/credentials/:id — resolve by ID
|
|
127
|
+
* GET /aroha/credentials?userId= — list by user
|
|
128
|
+
* DELETE /aroha/credentials/:id — revoke (requires X-Revoke-Proof header)
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* const registry = new CredentialRegistry();
|
|
132
|
+
* const server = new ArohaServer({
|
|
133
|
+
* httpRoutes: credentialRegistryRoutes(registry, { resolveIssuerPublicKey }),
|
|
134
|
+
* });
|
|
135
|
+
*/
|
|
136
|
+
export function credentialRegistryRoutes(
|
|
137
|
+
registry: CredentialRegistry,
|
|
138
|
+
opts: CredentialRegistryRouteOptions = {}
|
|
139
|
+
): ArohaHttpRoute[] {
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
method: "POST",
|
|
143
|
+
test: (url) => url === "/aroha/credentials",
|
|
144
|
+
handle: (req, res) => handleRegister(req, res, registry, opts),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
method: "DELETE",
|
|
148
|
+
test: (url) => /^\/aroha\/credentials\/[^?/]+$/.test(url),
|
|
149
|
+
handle: (req, res) => handleRevoke(req, res, registry, opts),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
method: "GET",
|
|
153
|
+
test: (url) => /^\/aroha\/credentials\/[^?/]+$/.test(url),
|
|
154
|
+
handle: (req, res) => handleResolve(req, res, registry),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
method: "GET",
|
|
158
|
+
test: (url) => url === "/aroha/credentials" || url.startsWith("/aroha/credentials?"),
|
|
159
|
+
handle: (req, res) => handleList(req, res, registry),
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Route handlers ───────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
async function handleRegister(
|
|
167
|
+
req: IncomingMessage,
|
|
168
|
+
res: ServerResponse,
|
|
169
|
+
registry: CredentialRegistry,
|
|
170
|
+
opts: CredentialRegistryRouteOptions
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
const body = await readBody(req).catch(() => null);
|
|
173
|
+
if (!body) { res.writeHead(400); res.end(JSON.stringify({ error: "Invalid body" })); return; }
|
|
174
|
+
|
|
175
|
+
let cred: HumanCredential;
|
|
176
|
+
try { cred = JSON.parse(body) as HumanCredential; }
|
|
177
|
+
catch { res.writeHead(400); res.end(JSON.stringify({ error: "Invalid JSON" })); return; }
|
|
178
|
+
|
|
179
|
+
if (opts.resolveIssuerPublicKey) {
|
|
180
|
+
const pubKey = await opts.resolveIssuerPublicKey(cred.issuerDID);
|
|
181
|
+
if (!pubKey) {
|
|
182
|
+
res.writeHead(401);
|
|
183
|
+
res.end(JSON.stringify({ error: `Unknown credential issuer: ${cred.issuerDID}` }));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const ok = await registry.register(cred, pubKey);
|
|
187
|
+
if (!ok) { res.writeHead(400); res.end(JSON.stringify({ error: "Credential signature invalid" })); return; }
|
|
188
|
+
} else {
|
|
189
|
+
registry.registerTrusted(cred);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
193
|
+
res.end(JSON.stringify({ credentialId: cred.credentialId, stored: true }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function handleRevoke(
|
|
197
|
+
req: IncomingMessage,
|
|
198
|
+
res: ServerResponse,
|
|
199
|
+
registry: CredentialRegistry,
|
|
200
|
+
opts: CredentialRegistryRouteOptions
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
const credentialId = decodeURIComponent((req.url ?? "").split("/").pop() ?? "");
|
|
203
|
+
const cred = registry.resolve(credentialId);
|
|
204
|
+
if (!cred) { res.writeHead(404); res.end(JSON.stringify({ error: "Credential not found" })); return; }
|
|
205
|
+
|
|
206
|
+
if (opts.resolveIssuerPublicKey) {
|
|
207
|
+
const pubKey = await opts.resolveIssuerPublicKey(cred.issuerDID);
|
|
208
|
+
if (!pubKey) {
|
|
209
|
+
res.writeHead(401);
|
|
210
|
+
res.end(JSON.stringify({ error: `Unknown credential issuer: ${cred.issuerDID}` }));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const proof = req.headers["x-revoke-proof"] as string | undefined;
|
|
214
|
+
if (!proof) { res.writeHead(401); res.end(JSON.stringify({ error: "X-Revoke-Proof header required" })); return; }
|
|
215
|
+
const valid = await verifyRevokeProof(credentialId, proof, pubKey);
|
|
216
|
+
if (!valid) { res.writeHead(403); res.end(JSON.stringify({ error: "Revoke proof invalid" })); return; }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
registry.revoke(credentialId);
|
|
220
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
221
|
+
res.end(JSON.stringify({ credentialId, revoked: true }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function handleResolve(
|
|
225
|
+
_req: IncomingMessage,
|
|
226
|
+
res: ServerResponse,
|
|
227
|
+
registry: CredentialRegistry
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
const credentialId = decodeURIComponent((_req.url ?? "").split("?")[0].split("/").pop() ?? "");
|
|
230
|
+
const cred = registry.resolve(credentialId);
|
|
231
|
+
if (!cred) { res.writeHead(404); res.end(JSON.stringify({ error: "Credential not found or expired" })); return; }
|
|
232
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
233
|
+
res.end(JSON.stringify(cred));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function handleList(
|
|
237
|
+
req: IncomingMessage,
|
|
238
|
+
res: ServerResponse,
|
|
239
|
+
registry: CredentialRegistry
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const urlObj = new URL(req.url ?? "/", "http://localhost");
|
|
242
|
+
const userId = urlObj.searchParams.get("userId");
|
|
243
|
+
if (!userId) { res.writeHead(400); res.end(JSON.stringify({ error: "userId query param required" })); return; }
|
|
244
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
245
|
+
res.end(JSON.stringify(registry.listForUser(userId)));
|
|
246
|
+
}
|
|
247
|
+
|
package/src/rbac.test.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { checkPermission, actionForMessageType, DEFAULT_RBAC_PERMISSIONS } from "./rbac.js";
|
|
3
|
+
import { ArohaRole } from "./types.js";
|
|
4
|
+
|
|
5
|
+
describe("actionForMessageType", () => {
|
|
6
|
+
it("maps ArohaRequest to query", () => {
|
|
7
|
+
expect(actionForMessageType("ArohaRequest")).toBe("query");
|
|
8
|
+
});
|
|
9
|
+
it("maps ArohaReserve to reserve", () => {
|
|
10
|
+
expect(actionForMessageType("ArohaReserve")).toBe("reserve");
|
|
11
|
+
});
|
|
12
|
+
it("maps ArohaCommit to commit", () => {
|
|
13
|
+
expect(actionForMessageType("ArohaCommit")).toBe("commit");
|
|
14
|
+
});
|
|
15
|
+
it("maps ArohaCancel to cancel", () => {
|
|
16
|
+
expect(actionForMessageType("ArohaCancel")).toBe("cancel");
|
|
17
|
+
});
|
|
18
|
+
it("maps ArohaDelegate to delegate", () => {
|
|
19
|
+
expect(actionForMessageType("ArohaDelegate")).toBe("delegate");
|
|
20
|
+
});
|
|
21
|
+
it("maps ArohaNegotiate to negotiate", () => {
|
|
22
|
+
expect(actionForMessageType("ArohaNegotiate")).toBe("negotiate");
|
|
23
|
+
});
|
|
24
|
+
it("returns null for response/ack types", () => {
|
|
25
|
+
expect(actionForMessageType("ArohaResponse")).toBeNull();
|
|
26
|
+
expect(actionForMessageType("ArohaReserveAck")).toBeNull();
|
|
27
|
+
expect(actionForMessageType("ArohaCommitAck")).toBeNull();
|
|
28
|
+
expect(actionForMessageType("ArohaError")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("checkPermission", () => {
|
|
33
|
+
it("allows HumanAdmin all actions", () => {
|
|
34
|
+
const actions = ["query", "reserve", "commit", "cancel", "delegate", "negotiate", "admin"] as const;
|
|
35
|
+
for (const action of actions) {
|
|
36
|
+
const result = checkPermission([ArohaRole.HumanAdmin], action);
|
|
37
|
+
expect(result.allowed).toBe(true);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("allows HumanUser to query/reserve/commit/cancel/negotiate but not delegate or admin", () => {
|
|
42
|
+
expect(checkPermission([ArohaRole.HumanUser], "query").allowed).toBe(true);
|
|
43
|
+
expect(checkPermission([ArohaRole.HumanUser], "reserve").allowed).toBe(true);
|
|
44
|
+
expect(checkPermission([ArohaRole.HumanUser], "delegate").allowed).toBe(false);
|
|
45
|
+
expect(checkPermission([ArohaRole.HumanUser], "admin").allowed).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("allows HumanReadonly only query", () => {
|
|
49
|
+
expect(checkPermission([ArohaRole.HumanReadonly], "query").allowed).toBe(true);
|
|
50
|
+
expect(checkPermission([ArohaRole.HumanReadonly], "reserve").allowed).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("denies AgentProvider all actions", () => {
|
|
54
|
+
expect(checkPermission([ArohaRole.AgentProvider], "query").allowed).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("allows AgentOrchestrator to delegate", () => {
|
|
58
|
+
expect(checkPermission([ArohaRole.AgentOrchestrator], "delegate").allowed).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns false with no roles", () => {
|
|
62
|
+
const result = checkPermission([], "query");
|
|
63
|
+
expect(result.allowed).toBe(false);
|
|
64
|
+
expect(result.reason).toMatch(/No roles/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("unions permissions across multiple roles", () => {
|
|
68
|
+
// HumanReadonly (query only) + AgentOrchestrator (query+reserve+..+delegate) → delegate allowed
|
|
69
|
+
const result = checkPermission([ArohaRole.HumanReadonly, ArohaRole.AgentOrchestrator], "delegate");
|
|
70
|
+
expect(result.allowed).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("includes role list in result", () => {
|
|
74
|
+
const result = checkPermission([ArohaRole.HumanUser], "query");
|
|
75
|
+
expect(result.roles).toContain(ArohaRole.HumanUser);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("ArohaSpendingMandate action mapping", () => {
|
|
80
|
+
it("maps ArohaSpendingMandate to mandate", () => {
|
|
81
|
+
expect(actionForMessageType("ArohaSpendingMandate")).toBe("mandate");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("ArohaSatisfactionSignal returns null (private signal, no RBAC)", () => {
|
|
85
|
+
expect(actionForMessageType("ArohaSatisfactionSignal")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("mandate action permissions", () => {
|
|
90
|
+
it("allows HumanAdmin to issue mandates", () => {
|
|
91
|
+
expect(checkPermission([ArohaRole.HumanAdmin], "mandate").allowed).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("allows HumanUser to issue mandates", () => {
|
|
95
|
+
expect(checkPermission([ArohaRole.HumanUser], "mandate").allowed).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("allows AgentOrchestrator to issue mandates", () => {
|
|
99
|
+
expect(checkPermission([ArohaRole.AgentOrchestrator], "mandate").allowed).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("denies HumanReadonly from issuing mandates", () => {
|
|
103
|
+
expect(checkPermission([ArohaRole.HumanReadonly], "mandate").allowed).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("denies AgentObserver from issuing mandates", () => {
|
|
107
|
+
expect(checkPermission([ArohaRole.AgentObserver], "mandate").allowed).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("denies AgentProvider from issuing mandates", () => {
|
|
111
|
+
expect(checkPermission([ArohaRole.AgentProvider], "mandate").allowed).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|