@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/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aroha-sdk/credentials",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Human credentials, RBAC, and credential registry for the Aroha Protocol",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"clean": "rm -rf dist",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@aroha-sdk/core": "^1.0.0",
|
|
21
|
+
"@noble/ed25519": "^2.1.0",
|
|
22
|
+
"@noble/hashes": "^1.4.0",
|
|
23
|
+
"uuid": "^10.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "^5.4.0",
|
|
27
|
+
"vitest": "^1.6.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as ed from "@noble/ed25519";
|
|
3
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
4
|
+
import {
|
|
5
|
+
issueHumanCredential,
|
|
6
|
+
verifyHumanCredential,
|
|
7
|
+
serializeCredential,
|
|
8
|
+
deserializeCredential,
|
|
9
|
+
} from "./credentials.js";
|
|
10
|
+
import { ArohaRole } from "./types.js";
|
|
11
|
+
|
|
12
|
+
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
|
|
13
|
+
|
|
14
|
+
async function makeIssuer() {
|
|
15
|
+
const priv = ed.utils.randomPrivateKey();
|
|
16
|
+
const pub = await ed.getPublicKeyAsync(priv);
|
|
17
|
+
return { priv, pub, did: "did:aroha:issuer" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("issueHumanCredential", () => {
|
|
21
|
+
it("issues a credential with correct fields", async () => {
|
|
22
|
+
const issuer = await makeIssuer();
|
|
23
|
+
const cred = await issueHumanCredential(
|
|
24
|
+
"user-123",
|
|
25
|
+
[ArohaRole.HumanUser],
|
|
26
|
+
issuer.did,
|
|
27
|
+
issuer.priv,
|
|
28
|
+
3_600_000,
|
|
29
|
+
"user@example.com"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(cred.userId).toBe("user-123");
|
|
33
|
+
expect(cred.roles).toContain(ArohaRole.HumanUser);
|
|
34
|
+
expect(cred.issuerDID).toBe(issuer.did);
|
|
35
|
+
expect(cred.email).toBe("user@example.com");
|
|
36
|
+
expect(cred.signature).toBeTruthy();
|
|
37
|
+
expect(new Date(cred.expiresAt) > new Date()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("verifyHumanCredential", () => {
|
|
42
|
+
it("verifies a valid credential token", async () => {
|
|
43
|
+
const issuer = await makeIssuer();
|
|
44
|
+
const cred = await issueHumanCredential("user-456", [ArohaRole.HumanAdmin], issuer.did, issuer.priv);
|
|
45
|
+
const token = serializeCredential(cred);
|
|
46
|
+
|
|
47
|
+
const result = await verifyHumanCredential(token, issuer.pub);
|
|
48
|
+
expect(result.valid).toBe(true);
|
|
49
|
+
expect(result.credential?.userId).toBe("user-456");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects a token signed by a different key", async () => {
|
|
53
|
+
const issuer = await makeIssuer();
|
|
54
|
+
const otherIssuer = await makeIssuer();
|
|
55
|
+
const cred = await issueHumanCredential("user-789", [ArohaRole.HumanUser], issuer.did, issuer.priv);
|
|
56
|
+
const token = serializeCredential(cred);
|
|
57
|
+
|
|
58
|
+
const result = await verifyHumanCredential(token, otherIssuer.pub);
|
|
59
|
+
expect(result.valid).toBe(false);
|
|
60
|
+
expect(result.reason).toMatch(/invalid/i);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects a malformed token", async () => {
|
|
64
|
+
const issuer = await makeIssuer();
|
|
65
|
+
const result = await verifyHumanCredential("not-valid-base64url!!!", issuer.pub);
|
|
66
|
+
expect(result.valid).toBe(false);
|
|
67
|
+
expect(result.reason).toMatch(/malformed/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects an expired credential", async () => {
|
|
71
|
+
const issuer = await makeIssuer();
|
|
72
|
+
const cred = await issueHumanCredential("user-exp", [ArohaRole.HumanUser], issuer.did, issuer.priv, -1000);
|
|
73
|
+
const token = serializeCredential(cred);
|
|
74
|
+
|
|
75
|
+
const result = await verifyHumanCredential(token, issuer.pub);
|
|
76
|
+
expect(result.valid).toBe(false);
|
|
77
|
+
expect(result.reason).toMatch(/expired/i);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("serializeCredential / deserializeCredential", () => {
|
|
82
|
+
it("round-trips a credential", async () => {
|
|
83
|
+
const issuer = await makeIssuer();
|
|
84
|
+
const cred = await issueHumanCredential("round-trip", [ArohaRole.HumanReadonly], issuer.did, issuer.priv);
|
|
85
|
+
const token = serializeCredential(cred);
|
|
86
|
+
const decoded = deserializeCredential(token);
|
|
87
|
+
expect(decoded).toEqual(cred);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns null for garbage input", () => {
|
|
91
|
+
expect(deserializeCredential("!!!")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aroha Protocol — Human Credential System (Layer 1 extension)
|
|
3
|
+
*
|
|
4
|
+
* Human users are not DID-bearing agents. They authenticate to their Personal
|
|
5
|
+
* Agent (out of band — password, OAuth, passkey, etc.), which then issues them
|
|
6
|
+
* a short-lived signed credential. That credential travels in the body of any
|
|
7
|
+
* Aroha message the user triggers, letting provider agents verify who initiated
|
|
8
|
+
* the request and what they are allowed to do.
|
|
9
|
+
*
|
|
10
|
+
* The credential is signed by the issuing agent's Ed25519 private key using
|
|
11
|
+
* the same canonicalization rules as Aroha envelope proofs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as ed from "@noble/ed25519";
|
|
15
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
16
|
+
import { v4 as uuidv4 } from "uuid";
|
|
17
|
+
import { type ArohaRole, type HumanCredential } from "./types.js";
|
|
18
|
+
|
|
19
|
+
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
|
|
20
|
+
|
|
21
|
+
// Re-export so consumers can import from this module
|
|
22
|
+
export type { HumanCredential } from "./types.js";
|
|
23
|
+
|
|
24
|
+
export interface CredentialVerificationResult {
|
|
25
|
+
valid: boolean;
|
|
26
|
+
credential?: HumanCredential;
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Issue ────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Issue a signed HumanCredential.
|
|
34
|
+
*
|
|
35
|
+
* @param userId Application user identifier
|
|
36
|
+
* @param roles Roles to grant (e.g. ["human:user"])
|
|
37
|
+
* @param issuerDID DID of the issuing agent (the Personal Agent)
|
|
38
|
+
* @param privateKey Ed25519 private key of the issuing agent (32 bytes)
|
|
39
|
+
* @param ttlMs Time-to-live in milliseconds (default: 1 hour)
|
|
40
|
+
* @param email Optional email for display purposes
|
|
41
|
+
*/
|
|
42
|
+
export async function issueHumanCredential(
|
|
43
|
+
userId: string,
|
|
44
|
+
roles: ArohaRole[],
|
|
45
|
+
issuerDID: string,
|
|
46
|
+
privateKey: Uint8Array,
|
|
47
|
+
ttlMs = 3_600_000,
|
|
48
|
+
email?: string
|
|
49
|
+
): Promise<HumanCredential> {
|
|
50
|
+
const now = new Date();
|
|
51
|
+
const credential: Omit<HumanCredential, "signature"> = {
|
|
52
|
+
credentialId: uuidv4(),
|
|
53
|
+
userId,
|
|
54
|
+
roles,
|
|
55
|
+
issuedAt: now.toISOString(),
|
|
56
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
57
|
+
issuerDID,
|
|
58
|
+
...(email ? { email } : {}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const canonical = canonicalizeCredential(credential);
|
|
62
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
63
|
+
const sig = await ed.signAsync(bytes, privateKey);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
...credential,
|
|
67
|
+
signature: Buffer.from(sig).toString("base64url"),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Serialize a HumanCredential to a compact base64url token suitable for
|
|
73
|
+
* embedding in a Aroha message body as `credentialToken`.
|
|
74
|
+
*/
|
|
75
|
+
export function serializeCredential(cred: HumanCredential): string {
|
|
76
|
+
return Buffer.from(JSON.stringify(cred)).toString("base64url");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Deserialize a credentialToken back to a HumanCredential object.
|
|
81
|
+
* Returns null if the token is malformed.
|
|
82
|
+
*/
|
|
83
|
+
export function deserializeCredential(token: string): HumanCredential | null {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(Buffer.from(token, "base64url").toString("utf8")) as HumanCredential;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Verify ───────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Verify a HumanCredential.
|
|
95
|
+
*
|
|
96
|
+
* Checks:
|
|
97
|
+
* 1. Token is parseable
|
|
98
|
+
* 2. Expiry has not passed
|
|
99
|
+
* 3. Ed25519 signature is valid against the issuer's public key
|
|
100
|
+
*
|
|
101
|
+
* @param token The base64url credentialToken from the message body
|
|
102
|
+
* @param issuerPublicKey Ed25519 public key of the issuer DID (32 bytes)
|
|
103
|
+
*/
|
|
104
|
+
export async function verifyHumanCredential(
|
|
105
|
+
token: string,
|
|
106
|
+
issuerPublicKey: Uint8Array
|
|
107
|
+
): Promise<CredentialVerificationResult> {
|
|
108
|
+
const cred = deserializeCredential(token);
|
|
109
|
+
if (!cred) {
|
|
110
|
+
return { valid: false, reason: "Credential token is malformed" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (new Date(cred.expiresAt) < new Date()) {
|
|
114
|
+
return { valid: false, reason: `Credential expired at ${cred.expiresAt}` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { signature, ...body } = cred;
|
|
118
|
+
const canonical = canonicalizeCredential(body);
|
|
119
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const sig = Buffer.from(signature, "base64url");
|
|
123
|
+
const ok = await ed.verifyAsync(sig, bytes, issuerPublicKey);
|
|
124
|
+
if (!ok) return { valid: false, reason: "Credential signature invalid" };
|
|
125
|
+
return { valid: true, credential: cred };
|
|
126
|
+
} catch {
|
|
127
|
+
return { valid: false, reason: "Credential signature verification threw an error" };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Canonicalization ─────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/** Canonical JSON for signing — excludes `signature`, sorts keys. */
|
|
134
|
+
function canonicalizeCredential(cred: Omit<HumanCredential, "signature">): string {
|
|
135
|
+
return JSON.stringify(sortKeysDeep(cred));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sortKeysDeep(value: unknown): unknown {
|
|
139
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
140
|
+
if (value !== null && typeof value === "object") {
|
|
141
|
+
const sorted: Record<string, unknown> = {};
|
|
142
|
+
for (const key of Object.keys(value as object).sort()) {
|
|
143
|
+
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
|
144
|
+
}
|
|
145
|
+
return sorted;
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as ed from "@noble/ed25519";
|
|
3
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
4
|
+
import {
|
|
5
|
+
issueIntentMandate,
|
|
6
|
+
attenuateToCart,
|
|
7
|
+
attenuateToPayment,
|
|
8
|
+
verifyMandate,
|
|
9
|
+
type SignedMandate,
|
|
10
|
+
} from "./mandate.js";
|
|
11
|
+
import type { SpendingConstraints } from "@aroha-sdk/core";
|
|
12
|
+
|
|
13
|
+
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
|
|
14
|
+
|
|
15
|
+
async function makeKey() {
|
|
16
|
+
const priv = ed.utils.randomPrivateKey();
|
|
17
|
+
const pub = await ed.getPublicKeyAsync(priv);
|
|
18
|
+
return { priv, pub };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BASE: SpendingConstraints = {
|
|
22
|
+
spendLimitUsd: 500,
|
|
23
|
+
sessionLimitUsd: 200,
|
|
24
|
+
requireHumanApprovalAboveUsd: 100,
|
|
25
|
+
allowedMerchants: ["merchant-a", "merchant-b", "merchant-c"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ─── issueIntentMandate ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("issueIntentMandate", () => {
|
|
31
|
+
it("issues a mandate with correct fields", async () => {
|
|
32
|
+
const k = await makeKey();
|
|
33
|
+
const m = await issueIntentMandate("did:aroha:grantor", "did:aroha:grantee", BASE, k.priv);
|
|
34
|
+
expect(m.mandate.mandateTier).toBe("intent");
|
|
35
|
+
expect(m.mandate.grantor).toBe("did:aroha:grantor");
|
|
36
|
+
expect(m.mandate.grantee).toBe("did:aroha:grantee");
|
|
37
|
+
expect(m.mandate.parentMandateId).toBeNull();
|
|
38
|
+
expect(m.mandate.mandateId).toBeTruthy();
|
|
39
|
+
expect(new Date(m.mandate.expiresAt) > new Date()).toBe(true);
|
|
40
|
+
expect(m.token).toBeTruthy();
|
|
41
|
+
expect(m.signature).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("respects custom ttlMs", async () => {
|
|
45
|
+
const k = await makeKey();
|
|
46
|
+
const before = Date.now();
|
|
47
|
+
const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv, 10_000);
|
|
48
|
+
const expMs = new Date(m.mandate.expiresAt).getTime();
|
|
49
|
+
expect(expMs - before).toBeGreaterThan(9_000);
|
|
50
|
+
expect(expMs - before).toBeLessThan(11_000);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ─── verifyMandate ────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe("verifyMandate", () => {
|
|
57
|
+
it("accepts a valid token", async () => {
|
|
58
|
+
const k = await makeKey();
|
|
59
|
+
const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv);
|
|
60
|
+
const r = await verifyMandate(m.token, k.pub);
|
|
61
|
+
expect(r.valid).toBe(true);
|
|
62
|
+
expect(r.mandate?.mandateId).toBe(m.mandate.mandateId);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("rejects a token signed by a different key", async () => {
|
|
66
|
+
const k = await makeKey();
|
|
67
|
+
const other = await makeKey();
|
|
68
|
+
const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv);
|
|
69
|
+
const r = await verifyMandate(m.token, other.pub);
|
|
70
|
+
expect(r.valid).toBe(false);
|
|
71
|
+
expect(r.reason).toMatch(/signature/i);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects an expired mandate", async () => {
|
|
75
|
+
const k = await makeKey();
|
|
76
|
+
const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv, -1);
|
|
77
|
+
const r = await verifyMandate(m.token, k.pub);
|
|
78
|
+
expect(r.valid).toBe(false);
|
|
79
|
+
expect(r.reason).toMatch(/expired/i);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("rejects grantee mismatch", async () => {
|
|
83
|
+
const k = await makeKey();
|
|
84
|
+
const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv);
|
|
85
|
+
const r = await verifyMandate(m.token, k.pub, "did:aroha:someone-else");
|
|
86
|
+
expect(r.valid).toBe(false);
|
|
87
|
+
expect(r.reason).toMatch(/grantee/i);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects malformed token", async () => {
|
|
91
|
+
const k = await makeKey();
|
|
92
|
+
const r = await verifyMandate("not!!valid!!base64url", k.pub);
|
|
93
|
+
expect(r.valid).toBe(false);
|
|
94
|
+
expect(r.reason).toMatch(/malformed/i);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ─── attenuateToCart — narrowing enforcement ──────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe("attenuateToCart narrowing", () => {
|
|
101
|
+
async function intent(): Promise<[SignedMandate, Uint8Array, Uint8Array]> {
|
|
102
|
+
const k = await makeKey();
|
|
103
|
+
const m = await issueIntentMandate("did:aroha:user", "did:aroha:agent", BASE, k.priv);
|
|
104
|
+
return [m, k.priv, k.pub];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it("allows valid narrowing", async () => {
|
|
108
|
+
const [parent, agentKey] = await intent();
|
|
109
|
+
const narrow: SpendingConstraints = {
|
|
110
|
+
spendLimitUsd: 100,
|
|
111
|
+
sessionLimitUsd: 50,
|
|
112
|
+
requireHumanApprovalAboveUsd: 50,
|
|
113
|
+
allowedMerchants: ["merchant-a"],
|
|
114
|
+
};
|
|
115
|
+
await expect(
|
|
116
|
+
attenuateToCart(parent, "did:aroha:provider", narrow, agentKey)
|
|
117
|
+
).resolves.toBeDefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rejects spendLimitUsd widening", async () => {
|
|
121
|
+
const [parent, agentKey] = await intent();
|
|
122
|
+
await expect(
|
|
123
|
+
attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 1000 }, agentKey)
|
|
124
|
+
).rejects.toThrow(/spendLimit/i);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects sessionLimitUsd widening", async () => {
|
|
128
|
+
const [parent, agentKey] = await intent();
|
|
129
|
+
await expect(
|
|
130
|
+
attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, sessionLimitUsd: 999 }, agentKey)
|
|
131
|
+
).rejects.toThrow(/sessionLimit/i);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects requireHumanApprovalAboveUsd widening", async () => {
|
|
135
|
+
const [parent, agentKey] = await intent();
|
|
136
|
+
await expect(
|
|
137
|
+
attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, requireHumanApprovalAboveUsd: 999 }, agentKey)
|
|
138
|
+
).rejects.toThrow(/approval threshold/i);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects allowedMerchants with unlisted merchant", async () => {
|
|
142
|
+
const [parent, agentKey] = await intent();
|
|
143
|
+
await expect(
|
|
144
|
+
attenuateToCart(parent, "did:aroha:p", {
|
|
145
|
+
spendLimitUsd: 100,
|
|
146
|
+
allowedMerchants: ["merchant-a", "merchant-unknown"],
|
|
147
|
+
}, agentKey)
|
|
148
|
+
).rejects.toThrow(/allowedMerchants/i);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("rejects empty allowedMerchants when parent restricts", async () => {
|
|
152
|
+
const [parent, agentKey] = await intent();
|
|
153
|
+
await expect(
|
|
154
|
+
attenuateToCart(parent, "did:aroha:p", {
|
|
155
|
+
spendLimitUsd: 100,
|
|
156
|
+
allowedMerchants: [],
|
|
157
|
+
}, agentKey)
|
|
158
|
+
).rejects.toThrow(/allowedMerchants/i);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects missing allowedMerchants when parent restricts", async () => {
|
|
162
|
+
const [parent, agentKey] = await intent();
|
|
163
|
+
await expect(
|
|
164
|
+
attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100 }, agentKey)
|
|
165
|
+
).rejects.toThrow(/allowedMerchants/i);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ─── attenuateToPayment — narrowing enforcement ───────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe("attenuateToPayment narrowing", () => {
|
|
172
|
+
it("rejects spendLimitUsd widening from cart", async () => {
|
|
173
|
+
const intentKey = await makeKey();
|
|
174
|
+
const cartKey = await makeKey();
|
|
175
|
+
const intent = await issueIntentMandate("did:aroha:user", "did:aroha:agent", BASE, intentKey.priv);
|
|
176
|
+
const cart = await attenuateToCart(intent, "did:aroha:provider", {
|
|
177
|
+
spendLimitUsd: 100, allowedMerchants: ["merchant-a"],
|
|
178
|
+
}, intentKey.priv);
|
|
179
|
+
await expect(
|
|
180
|
+
attenuateToPayment(cart, "did:aroha:processor", { spendLimitUsd: 999 }, cartKey.priv)
|
|
181
|
+
).rejects.toThrow(/spendLimit/i);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ─── Full chain: intent → cart → payment ─────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
describe("full delegation chain", () => {
|
|
188
|
+
it("intent → cart → payment verifies end-to-end", async () => {
|
|
189
|
+
const userKey = await makeKey();
|
|
190
|
+
const agentKey = await makeKey();
|
|
191
|
+
const providerKey = await makeKey();
|
|
192
|
+
|
|
193
|
+
const intent = await issueIntentMandate(
|
|
194
|
+
"did:aroha:user", "did:aroha:agent", BASE, userKey.priv
|
|
195
|
+
);
|
|
196
|
+
const cart = await attenuateToCart(intent, "did:aroha:provider", {
|
|
197
|
+
spendLimitUsd: 100,
|
|
198
|
+
allowedMerchants: ["merchant-a"],
|
|
199
|
+
}, userKey.priv);
|
|
200
|
+
const payment = await attenuateToPayment(cart, "did:aroha:processor", {
|
|
201
|
+
spendLimitUsd: 42,
|
|
202
|
+
allowedMerchants: ["merchant-a"],
|
|
203
|
+
}, userKey.priv);
|
|
204
|
+
|
|
205
|
+
expect(payment.mandate.mandateTier).toBe("payment");
|
|
206
|
+
expect(payment.mandate.parentMandateId).toBe(cart.mandate.mandateId);
|
|
207
|
+
expect(payment.mandate.constraints.spendLimitUsd).toBe(42);
|
|
208
|
+
|
|
209
|
+
const r = await verifyMandate(payment.token, userKey.pub, "did:aroha:processor");
|
|
210
|
+
expect(r.valid).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("assertNarrowing — merchantCategory", () => {
|
|
215
|
+
it("allows child to keep same merchantCategory", async () => {
|
|
216
|
+
const k = await makeKey();
|
|
217
|
+
const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
|
|
218
|
+
spendLimitUsd: 500,
|
|
219
|
+
merchantCategory: "travel",
|
|
220
|
+
}, k.priv);
|
|
221
|
+
await expect(
|
|
222
|
+
attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, merchantCategory: "travel" }, k.priv)
|
|
223
|
+
).resolves.toBeDefined();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("rejects child setting merchantCategory to null when parent restricts it", async () => {
|
|
227
|
+
const k = await makeKey();
|
|
228
|
+
const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
|
|
229
|
+
spendLimitUsd: 500,
|
|
230
|
+
merchantCategory: "travel",
|
|
231
|
+
}, k.priv);
|
|
232
|
+
await expect(
|
|
233
|
+
attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, merchantCategory: null }, k.priv)
|
|
234
|
+
).rejects.toThrow(/merchantCategory/i);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("rejects child setting different merchantCategory", async () => {
|
|
238
|
+
const k = await makeKey();
|
|
239
|
+
const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
|
|
240
|
+
spendLimitUsd: 500,
|
|
241
|
+
merchantCategory: "travel",
|
|
242
|
+
}, k.priv);
|
|
243
|
+
await expect(
|
|
244
|
+
attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, merchantCategory: "food" }, k.priv)
|
|
245
|
+
).rejects.toThrow(/merchantCategory/i);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("assertNarrowing — validUntil / validFrom", () => {
|
|
250
|
+
const future = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString();
|
|
251
|
+
|
|
252
|
+
it("rejects child validUntil beyond parent validUntil", async () => {
|
|
253
|
+
const k = await makeKey();
|
|
254
|
+
const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
|
|
255
|
+
spendLimitUsd: 500,
|
|
256
|
+
validUntil: future(3_600_000), // 1 hour
|
|
257
|
+
}, k.priv);
|
|
258
|
+
await expect(
|
|
259
|
+
attenuateToCart(parent, "did:aroha:p", {
|
|
260
|
+
spendLimitUsd: 100,
|
|
261
|
+
validUntil: future(7_200_000), // 2 hours — wider
|
|
262
|
+
}, k.priv)
|
|
263
|
+
).rejects.toThrow(/validUntil/i);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("allows child validUntil within parent window", async () => {
|
|
267
|
+
const k = await makeKey();
|
|
268
|
+
const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
|
|
269
|
+
spendLimitUsd: 500,
|
|
270
|
+
validUntil: future(3_600_000),
|
|
271
|
+
}, k.priv);
|
|
272
|
+
await expect(
|
|
273
|
+
attenuateToCart(parent, "did:aroha:p", {
|
|
274
|
+
spendLimitUsd: 100,
|
|
275
|
+
validUntil: future(1_800_000), // 30 min — narrower
|
|
276
|
+
}, k.priv)
|
|
277
|
+
).resolves.toBeDefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("rejects child validFrom before parent validFrom", async () => {
|
|
281
|
+
const k = await makeKey();
|
|
282
|
+
const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
|
|
283
|
+
spendLimitUsd: 500,
|
|
284
|
+
validFrom: future(3_600_000),
|
|
285
|
+
}, k.priv);
|
|
286
|
+
await expect(
|
|
287
|
+
attenuateToCart(parent, "did:aroha:p", {
|
|
288
|
+
spendLimitUsd: 100,
|
|
289
|
+
validFrom: future(1_800_000), // starts earlier than parent — wider
|
|
290
|
+
}, k.priv)
|
|
291
|
+
).rejects.toThrow(/validFrom/i);
|
|
292
|
+
});
|
|
293
|
+
});
|