@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/rbac.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ArohaMessageType } from "@aroha-sdk/core";
|
|
2
|
+
import { ArohaRole, type HumanCredential } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export { ArohaRole } from "./types.js";
|
|
5
|
+
|
|
6
|
+
// ─── Actions ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export type ArohaAction =
|
|
9
|
+
| "query"
|
|
10
|
+
| "reserve"
|
|
11
|
+
| "commit"
|
|
12
|
+
| "cancel"
|
|
13
|
+
| "delegate"
|
|
14
|
+
| "negotiate"
|
|
15
|
+
| "mandate"
|
|
16
|
+
| "admin";
|
|
17
|
+
|
|
18
|
+
export function actionForMessageType(type: ArohaMessageType): ArohaAction | null {
|
|
19
|
+
switch (type) {
|
|
20
|
+
case "ArohaRequest":
|
|
21
|
+
case "ArohaStream":
|
|
22
|
+
return "query";
|
|
23
|
+
case "ArohaReserve":
|
|
24
|
+
return "reserve";
|
|
25
|
+
case "ArohaCommit":
|
|
26
|
+
return "commit";
|
|
27
|
+
case "ArohaCancel":
|
|
28
|
+
return "cancel";
|
|
29
|
+
case "ArohaDelegate":
|
|
30
|
+
return "delegate";
|
|
31
|
+
case "ArohaNegotiate":
|
|
32
|
+
case "ArohaCounterOffer":
|
|
33
|
+
case "ArohaAccept":
|
|
34
|
+
return "negotiate";
|
|
35
|
+
case "ArohaSpendingMandate":
|
|
36
|
+
return "mandate";
|
|
37
|
+
default:
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Policy ───────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export type RolePermissions = Partial<Record<ArohaRole, ArohaAction[]>>;
|
|
45
|
+
|
|
46
|
+
export interface RbacPolicy {
|
|
47
|
+
permissions?: RolePermissions;
|
|
48
|
+
resolveAgentRoles: (agentDID: string) => Promise<ArohaRole[]> | ArohaRole[];
|
|
49
|
+
resolveIssuerPublicKey?: (issuerDID: string) => Promise<Uint8Array | null> | Uint8Array | null;
|
|
50
|
+
credentialRegistry?: {
|
|
51
|
+
resolve(credentialId: string): HumanCredential | null | Promise<HumanCredential | null>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_RBAC_PERMISSIONS: RolePermissions = {
|
|
56
|
+
[ArohaRole.HumanAdmin]: ["query", "reserve", "commit", "cancel", "delegate", "negotiate", "mandate", "admin"],
|
|
57
|
+
[ArohaRole.HumanUser]: ["query", "reserve", "commit", "cancel", "negotiate", "mandate"],
|
|
58
|
+
[ArohaRole.HumanReadonly]: ["query"],
|
|
59
|
+
[ArohaRole.AgentOrchestrator]: ["query", "reserve", "commit", "cancel", "delegate", "negotiate", "mandate"],
|
|
60
|
+
[ArohaRole.AgentProvider]: [],
|
|
61
|
+
[ArohaRole.AgentObserver]: ["query"],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ─── Permission check ─────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export interface RbacCheckResult {
|
|
67
|
+
allowed: boolean;
|
|
68
|
+
roles: ArohaRole[];
|
|
69
|
+
reason?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function checkPermission(
|
|
73
|
+
roles: ArohaRole[],
|
|
74
|
+
action: ArohaAction,
|
|
75
|
+
permissions: RolePermissions = DEFAULT_RBAC_PERMISSIONS
|
|
76
|
+
): RbacCheckResult {
|
|
77
|
+
if (roles.length === 0) {
|
|
78
|
+
return { allowed: false, roles, reason: "No roles assigned" };
|
|
79
|
+
}
|
|
80
|
+
for (const role of roles) {
|
|
81
|
+
const allowed = permissions[role] ?? [];
|
|
82
|
+
if (allowed.includes("admin") || allowed.includes(action)) {
|
|
83
|
+
return { allowed: true, roles };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
allowed: false,
|
|
88
|
+
roles,
|
|
89
|
+
reason: `Roles [${roles.join(", ")}] do not permit action "${action}"`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as ed from "@noble/ed25519";
|
|
3
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
4
|
+
import { CredentialRegistry, buildRevokeProof, verifyRevokeProof } from "./registry.js";
|
|
5
|
+
import { issueHumanCredential, serializeCredential } from "./credentials.js";
|
|
6
|
+
import { ArohaRole } from "./types.js";
|
|
7
|
+
|
|
8
|
+
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
|
|
9
|
+
|
|
10
|
+
async function makeIssuer() {
|
|
11
|
+
const priv = ed.utils.randomPrivateKey();
|
|
12
|
+
const pub = await ed.getPublicKeyAsync(priv);
|
|
13
|
+
return { priv, pub, did: "did:aroha:issuer" };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function makeCred(issuer: Awaited<ReturnType<typeof makeIssuer>>, userId = "user-1") {
|
|
17
|
+
return issueHumanCredential(userId, [ArohaRole.HumanUser], issuer.did, issuer.priv);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("CredentialRegistry", () => {
|
|
21
|
+
it("registers and resolves a valid credential", async () => {
|
|
22
|
+
const issuer = await makeIssuer();
|
|
23
|
+
const cred = await makeCred(issuer);
|
|
24
|
+
const registry = new CredentialRegistry();
|
|
25
|
+
|
|
26
|
+
const ok = await registry.register(cred, issuer.pub);
|
|
27
|
+
expect(ok).toBe(true);
|
|
28
|
+
|
|
29
|
+
const resolved = registry.resolve(cred.credentialId);
|
|
30
|
+
expect(resolved).not.toBeNull();
|
|
31
|
+
expect(resolved?.userId).toBe("user-1");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects registration with wrong issuer key", async () => {
|
|
35
|
+
const issuer = await makeIssuer();
|
|
36
|
+
const wrongIssuer = await makeIssuer();
|
|
37
|
+
const cred = await makeCred(issuer);
|
|
38
|
+
const registry = new CredentialRegistry();
|
|
39
|
+
|
|
40
|
+
const ok = await registry.register(cred, wrongIssuer.pub);
|
|
41
|
+
expect(ok).toBe(false);
|
|
42
|
+
expect(registry.resolve(cred.credentialId)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("registerTrusted bypasses signature check", async () => {
|
|
46
|
+
const issuer = await makeIssuer();
|
|
47
|
+
const cred = await makeCred(issuer);
|
|
48
|
+
const registry = new CredentialRegistry();
|
|
49
|
+
|
|
50
|
+
registry.registerTrusted(cred);
|
|
51
|
+
expect(registry.resolve(cred.credentialId)).not.toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("revokes a credential", async () => {
|
|
55
|
+
const issuer = await makeIssuer();
|
|
56
|
+
const cred = await makeCred(issuer);
|
|
57
|
+
const registry = new CredentialRegistry();
|
|
58
|
+
|
|
59
|
+
registry.registerTrusted(cred);
|
|
60
|
+
expect(registry.resolve(cred.credentialId)).not.toBeNull();
|
|
61
|
+
|
|
62
|
+
const removed = registry.revoke(cred.credentialId);
|
|
63
|
+
expect(removed).toBe(true);
|
|
64
|
+
expect(registry.resolve(cred.credentialId)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns false when revoking a non-existent credential", () => {
|
|
68
|
+
const registry = new CredentialRegistry();
|
|
69
|
+
expect(registry.revoke("nonexistent")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("listForUser returns credentials for that user", async () => {
|
|
73
|
+
const issuer = await makeIssuer();
|
|
74
|
+
const cred1 = await makeCred(issuer, "alice");
|
|
75
|
+
const cred2 = await makeCred(issuer, "bob");
|
|
76
|
+
const registry = new CredentialRegistry();
|
|
77
|
+
|
|
78
|
+
registry.registerTrusted(cred1);
|
|
79
|
+
registry.registerTrusted(cred2);
|
|
80
|
+
|
|
81
|
+
const aliceList = registry.listForUser("alice");
|
|
82
|
+
expect(aliceList).toHaveLength(1);
|
|
83
|
+
expect(aliceList[0].userId).toBe("alice");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("evicts expired credentials", async () => {
|
|
87
|
+
const issuer = await makeIssuer();
|
|
88
|
+
const expired = await issueHumanCredential("user-exp", [ArohaRole.HumanUser], issuer.did, issuer.priv, -1000);
|
|
89
|
+
const registry = new CredentialRegistry();
|
|
90
|
+
registry.registerTrusted(expired);
|
|
91
|
+
|
|
92
|
+
const count = registry.evictExpired();
|
|
93
|
+
expect(count).toBe(1);
|
|
94
|
+
expect(registry.size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null on resolve of expired credential (auto-evict)", async () => {
|
|
98
|
+
const issuer = await makeIssuer();
|
|
99
|
+
const expired = await issueHumanCredential("user-exp", [ArohaRole.HumanUser], issuer.did, issuer.priv, -1000);
|
|
100
|
+
const registry = new CredentialRegistry();
|
|
101
|
+
registry.registerTrusted(expired);
|
|
102
|
+
|
|
103
|
+
expect(registry.resolve(expired.credentialId)).toBeNull();
|
|
104
|
+
expect(registry.size).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("buildRevokeProof / verifyRevokeProof", () => {
|
|
109
|
+
it("builds a proof that verifies", async () => {
|
|
110
|
+
const issuer = await makeIssuer();
|
|
111
|
+
const proof = await buildRevokeProof("cred-123", issuer.priv);
|
|
112
|
+
const valid = await verifyRevokeProof("cred-123", proof, issuer.pub);
|
|
113
|
+
expect(valid).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("fails verification with wrong public key", async () => {
|
|
117
|
+
const issuer = await makeIssuer();
|
|
118
|
+
const other = await makeIssuer();
|
|
119
|
+
const proof = await buildRevokeProof("cred-456", issuer.priv);
|
|
120
|
+
const valid = await verifyRevokeProof("cred-456", proof, other.pub);
|
|
121
|
+
expect(valid).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("fails verification with different credentialId", async () => {
|
|
125
|
+
const issuer = await makeIssuer();
|
|
126
|
+
const proof = await buildRevokeProof("cred-A", issuer.priv);
|
|
127
|
+
const valid = await verifyRevokeProof("cred-B", proof, issuer.pub);
|
|
128
|
+
expect(valid).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
import type { HumanCredential } from "./types.js";
|
|
133
|
+
|
|
134
|
+
describe("CredentialStore interface", () => {
|
|
135
|
+
it("MapCredentialStore is exported and usable", async () => {
|
|
136
|
+
const { MapCredentialStore } = await import("./registry.js");
|
|
137
|
+
const store = new MapCredentialStore();
|
|
138
|
+
const cred: HumanCredential = {
|
|
139
|
+
credentialId: "test-id",
|
|
140
|
+
userId: "user-1",
|
|
141
|
+
roles: [ArohaRole.HumanUser],
|
|
142
|
+
issuedAt: new Date().toISOString(),
|
|
143
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
144
|
+
issuerDID: "did:aroha:issuer",
|
|
145
|
+
signature: "dummy",
|
|
146
|
+
};
|
|
147
|
+
await store.set(cred.credentialId, cred);
|
|
148
|
+
expect(await store.get(cred.credentialId)).toEqual(cred);
|
|
149
|
+
await store.delete(cred.credentialId);
|
|
150
|
+
expect(await store.get(cred.credentialId)).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("CredentialRegistry accepts a custom store", async () => {
|
|
154
|
+
const { MapCredentialStore, CredentialRegistry } = await import("./registry.js");
|
|
155
|
+
const store = new MapCredentialStore();
|
|
156
|
+
const reg = new CredentialRegistry(store);
|
|
157
|
+
const cred: HumanCredential = {
|
|
158
|
+
credentialId: "custom-store-id",
|
|
159
|
+
userId: "user-2",
|
|
160
|
+
roles: [ArohaRole.HumanUser],
|
|
161
|
+
issuedAt: new Date().toISOString(),
|
|
162
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
163
|
+
issuerDID: "did:aroha:issuer",
|
|
164
|
+
signature: "dummy",
|
|
165
|
+
};
|
|
166
|
+
reg.registerTrusted(cred);
|
|
167
|
+
expect(reg.resolve("custom-store-id")).toEqual(cred);
|
|
168
|
+
});
|
|
169
|
+
});
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aroha Protocol — Human Credential Registry
|
|
3
|
+
*
|
|
4
|
+
* Stores signed HumanCredentials so that any agent in the network can
|
|
5
|
+
* verify a human user's identity and roles by credential ID, without
|
|
6
|
+
* requiring the full credential token to be re-sent on every message.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Personal Agent issues a HumanCredential (issueHumanCredential)
|
|
10
|
+
* 2. Personal Agent registers it in the registry (CredentialRegistry.register)
|
|
11
|
+
* 3. Human sends a Aroha message with credentialId in the body
|
|
12
|
+
* 4. Provider agent looks up the credential (CredentialRegistry.resolve)
|
|
13
|
+
* 5. Provider verifies the stored credential's signature and checks roles
|
|
14
|
+
*
|
|
15
|
+
* The registry is exposed as HTTP endpoints by ArohaServer when
|
|
16
|
+
* ArohaServerOptions.credentialRegistry is set:
|
|
17
|
+
*
|
|
18
|
+
* POST /aroha/credentials — register (verifies sig before storing)
|
|
19
|
+
* GET /aroha/credentials/:id — resolve by ID (public)
|
|
20
|
+
* DELETE /aroha/credentials/:id — revoke (requires issuer proof header)
|
|
21
|
+
*
|
|
22
|
+
* Remote agents use CredentialRegistryClient to call these endpoints.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as ed from "@noble/ed25519";
|
|
26
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
27
|
+
import { type HumanCredential } from "./types.js";
|
|
28
|
+
import { verifyHumanCredential } from "./credentials.js";
|
|
29
|
+
|
|
30
|
+
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
|
|
31
|
+
|
|
32
|
+
// ─── CredentialStore interface ────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export interface CredentialStore {
|
|
35
|
+
set(id: string, credential: HumanCredential): Promise<void> | void;
|
|
36
|
+
get(id: string): Promise<HumanCredential | undefined> | HumanCredential | undefined;
|
|
37
|
+
delete(id: string): Promise<boolean> | boolean;
|
|
38
|
+
values(): AsyncIterable<HumanCredential> | Iterable<HumanCredential>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Default in-process store ─────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export class MapCredentialStore implements CredentialStore {
|
|
44
|
+
private readonly store = new Map<string, HumanCredential>();
|
|
45
|
+
|
|
46
|
+
set(id: string, credential: HumanCredential): void {
|
|
47
|
+
this.store.set(id, credential);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get(id: string): HumanCredential | undefined {
|
|
51
|
+
return this.store.get(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
delete(id: string): boolean {
|
|
55
|
+
return this.store.delete(id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
values(): Iterable<HumanCredential> {
|
|
59
|
+
return this.store.values();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get size(): number { return this.store.size; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Registry ─────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export class CredentialRegistry {
|
|
68
|
+
private readonly store: CredentialStore;
|
|
69
|
+
|
|
70
|
+
constructor(store: CredentialStore = new MapCredentialStore()) {
|
|
71
|
+
this.store = store;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Register a credential after verifying its Ed25519 signature.
|
|
76
|
+
*
|
|
77
|
+
* @param credential The credential to store
|
|
78
|
+
* @param issuerPublicKey Ed25519 public key of credential.issuerDID (32 bytes)
|
|
79
|
+
* @returns `true` if stored successfully, `false` if signature is invalid
|
|
80
|
+
*/
|
|
81
|
+
async register(credential: HumanCredential, issuerPublicKey: Uint8Array): Promise<boolean> {
|
|
82
|
+
const { serializeCredential } = await import("./credentials.js");
|
|
83
|
+
const token = serializeCredential(credential);
|
|
84
|
+
const result = await verifyHumanCredential(token, issuerPublicKey);
|
|
85
|
+
if (!result.valid) return false;
|
|
86
|
+
await this.store.set(credential.credentialId, credential);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Register a credential that has already been verified.
|
|
92
|
+
* Use this when the calling code has already confirmed validity
|
|
93
|
+
* (e.g., the issuing agent registering its own credential).
|
|
94
|
+
*/
|
|
95
|
+
registerTrusted(credential: HumanCredential): void {
|
|
96
|
+
this.store.set(credential.credentialId, credential);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a credential by ID.
|
|
101
|
+
* Returns null if not found, or if the credential has expired (auto-evicts).
|
|
102
|
+
*/
|
|
103
|
+
resolve(credentialId: string): HumanCredential | null {
|
|
104
|
+
const cred = this.store.get(credentialId);
|
|
105
|
+
if (!cred) return null;
|
|
106
|
+
if (cred instanceof Promise) return null; // async store: use async resolveAsync instead
|
|
107
|
+
const credTyped = cred as HumanCredential;
|
|
108
|
+
if (new Date(credTyped.expiresAt) < new Date()) {
|
|
109
|
+
this.store.delete(credentialId); // evict expired
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return credTyped;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Revoke a credential by ID.
|
|
117
|
+
* @returns `true` if it existed and was removed, `false` if not found
|
|
118
|
+
*/
|
|
119
|
+
revoke(credentialId: string): boolean {
|
|
120
|
+
const result = this.store.delete(credentialId);
|
|
121
|
+
if (result instanceof Promise) return false;
|
|
122
|
+
return result as boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List all active (non-expired) credentials for a given userId.
|
|
127
|
+
*/
|
|
128
|
+
listForUser(userId: string): HumanCredential[] {
|
|
129
|
+
const now = new Date();
|
|
130
|
+
const results: HumanCredential[] = [];
|
|
131
|
+
const vals = this.store.values();
|
|
132
|
+
for (const cred of vals as Iterable<HumanCredential>) {
|
|
133
|
+
if (cred.userId === userId && new Date(cred.expiresAt) >= now) {
|
|
134
|
+
results.push(cred);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Total number of stored credentials (including potentially expired ones). */
|
|
141
|
+
get size(): number {
|
|
142
|
+
const s = this.store as MapCredentialStore;
|
|
143
|
+
return s.size ?? 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Evict all expired credentials. Called periodically to free memory.
|
|
148
|
+
* Returns the number of credentials evicted.
|
|
149
|
+
*/
|
|
150
|
+
evictExpired(): number {
|
|
151
|
+
const now = new Date();
|
|
152
|
+
let count = 0;
|
|
153
|
+
const vals = this.store.values();
|
|
154
|
+
for (const cred of vals as Iterable<HumanCredential>) {
|
|
155
|
+
if (new Date(cred.expiresAt) < now) {
|
|
156
|
+
this.store.delete(cred.credentialId);
|
|
157
|
+
count++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return count;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── HTTP Client ───────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* HTTP client for remote credential registry lookups.
|
|
168
|
+
*
|
|
169
|
+
* Any agent can use this to query the registry hosted by a Personal Agent
|
|
170
|
+
* (or any ArohaServer with credentialRegistry configured).
|
|
171
|
+
*/
|
|
172
|
+
export class CredentialRegistryClient {
|
|
173
|
+
constructor(private readonly baseUrl: string) {}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register a credential in the remote registry.
|
|
177
|
+
* The registry will verify the credential's signature before storing.
|
|
178
|
+
*/
|
|
179
|
+
async register(credential: HumanCredential): Promise<{ ok: boolean; reason?: string }> {
|
|
180
|
+
const res = await fetch(`${this.baseUrl}/aroha/credentials`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/json" },
|
|
183
|
+
body: JSON.stringify(credential),
|
|
184
|
+
});
|
|
185
|
+
if (res.ok) return { ok: true };
|
|
186
|
+
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
187
|
+
return { ok: false, reason: String(body.error ?? res.statusText) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve a credential by ID from the remote registry.
|
|
192
|
+
* Returns null if not found or expired.
|
|
193
|
+
*/
|
|
194
|
+
async resolve(credentialId: string): Promise<HumanCredential | null> {
|
|
195
|
+
const res = await fetch(`${this.baseUrl}/aroha/credentials/${encodeURIComponent(credentialId)}`);
|
|
196
|
+
if (!res.ok) return null;
|
|
197
|
+
return res.json() as Promise<HumanCredential>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Revoke a credential.
|
|
202
|
+
*
|
|
203
|
+
* The caller must provide a revocation proof: a base64url Ed25519 signature
|
|
204
|
+
* over `{credentialId, action:"revoke"}` using the issuer's private key.
|
|
205
|
+
* This is checked server-side before deletion.
|
|
206
|
+
*/
|
|
207
|
+
async revoke(credentialId: string, revokeProof: string): Promise<{ ok: boolean; reason?: string }> {
|
|
208
|
+
const res = await fetch(`${this.baseUrl}/aroha/credentials/${encodeURIComponent(credentialId)}`, {
|
|
209
|
+
method: "DELETE",
|
|
210
|
+
headers: { "X-Revoke-Proof": revokeProof },
|
|
211
|
+
});
|
|
212
|
+
if (res.ok) return { ok: true };
|
|
213
|
+
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
214
|
+
return { ok: false, reason: String(body.error ?? res.statusText) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* List all active credentials for a userId.
|
|
219
|
+
*/
|
|
220
|
+
async listForUser(userId: string): Promise<HumanCredential[]> {
|
|
221
|
+
const res = await fetch(`${this.baseUrl}/aroha/credentials?userId=${encodeURIComponent(userId)}`);
|
|
222
|
+
if (!res.ok) return [];
|
|
223
|
+
return res.json() as Promise<HumanCredential[]>;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Revoke proof helpers ──────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build a revocation proof to pass to CredentialRegistryClient.revoke().
|
|
231
|
+
* Signs `{action:"revoke",credentialId}` with the issuer's private key.
|
|
232
|
+
*/
|
|
233
|
+
export async function buildRevokeProof(
|
|
234
|
+
credentialId: string,
|
|
235
|
+
issuerPrivateKey: Uint8Array
|
|
236
|
+
): Promise<string> {
|
|
237
|
+
const payload = JSON.stringify({ action: "revoke", credentialId });
|
|
238
|
+
const bytes = new TextEncoder().encode(payload);
|
|
239
|
+
const sig = await ed.signAsync(bytes, issuerPrivateKey);
|
|
240
|
+
return Buffer.from(sig).toString("base64url");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Verify a revocation proof server-side.
|
|
245
|
+
*/
|
|
246
|
+
export async function verifyRevokeProof(
|
|
247
|
+
credentialId: string,
|
|
248
|
+
proof: string,
|
|
249
|
+
issuerPublicKey: Uint8Array
|
|
250
|
+
): Promise<boolean> {
|
|
251
|
+
try {
|
|
252
|
+
const payload = JSON.stringify({ action: "revoke", credentialId });
|
|
253
|
+
const bytes = new TextEncoder().encode(payload);
|
|
254
|
+
const sig = Buffer.from(proof, "base64url");
|
|
255
|
+
return await ed.verifyAsync(sig, bytes, issuerPublicKey);
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export enum ArohaRole {
|
|
2
|
+
HumanAdmin = "human:admin",
|
|
3
|
+
HumanUser = "human:user",
|
|
4
|
+
HumanReadonly = "human:readonly",
|
|
5
|
+
AgentOrchestrator = "agent:orchestrator",
|
|
6
|
+
AgentProvider = "agent:provider",
|
|
7
|
+
AgentObserver = "agent:observer",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HumanCredential {
|
|
11
|
+
credentialId: string;
|
|
12
|
+
userId: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
roles: ArohaRole[];
|
|
15
|
+
issuedAt: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
issuerDID: string;
|
|
18
|
+
signature: string;
|
|
19
|
+
}
|