@orangecheck/agent-core 1.1.0 → 2.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/dist/index.d.mts +14 -3
- package/dist/index.d.ts +14 -3
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +63 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/assert-scope.test.ts +72 -0
- package/src/assert-scope.ts +80 -0
- package/src/federation.test.ts +1 -0
- package/src/index.ts +3 -0
- package/src/private-scope.test.ts +4 -3
- package/src/revocation-failclosed.test.ts +67 -0
- package/src/test-vectors.test.ts +5 -0
- package/src/verify.ts +56 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangecheck/agent-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "OC Agent canonical messages, envelope formats (delegation/action/revocation), scope grammar, and verification. See https://github.com/orangecheck/oc-agent-protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bitcoin",
|
|
@@ -64,14 +64,14 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@noble/hashes": "^1.5.0",
|
|
67
|
-
"@orangecheck/
|
|
68
|
-
"@orangecheck/
|
|
67
|
+
"@orangecheck/lock-core": "^1.0.1",
|
|
68
|
+
"@orangecheck/stamp-core": "^1.0.0"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@types/node": "^22.10.2",
|
|
72
72
|
"tsup": "^8.3.5",
|
|
73
73
|
"typescript": "^5.7.2",
|
|
74
|
-
"vitest": "^
|
|
74
|
+
"vitest": "^4.1.11"
|
|
75
75
|
},
|
|
76
76
|
"publishConfig": {
|
|
77
77
|
"access": "public"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ScopeNotGrantedError, assertScopeGranted } from './assert-scope.js';
|
|
4
|
+
import type { ScopesEncryptedEnvelope } from './types.js';
|
|
5
|
+
|
|
6
|
+
const SEALED = {
|
|
7
|
+
v: 1,
|
|
8
|
+
alg: 'x25519-xchacha20poly1305',
|
|
9
|
+
recipients: [{ device_id: 'dev1', ct: 'AA' }],
|
|
10
|
+
ct: 'BB',
|
|
11
|
+
nonce: 'CC',
|
|
12
|
+
} as unknown as ScopesEncryptedEnvelope;
|
|
13
|
+
|
|
14
|
+
function reasonOf(fn: () => void): string {
|
|
15
|
+
try {
|
|
16
|
+
fn();
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return e instanceof ScopeNotGrantedError ? e.reason : `wrong-error:${String(e)}`;
|
|
19
|
+
}
|
|
20
|
+
return 'no-throw';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('assertScopeGranted', () => {
|
|
24
|
+
it('permits an exact grant and a sub-scope of one', () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
assertScopeGranted({ scopes: ['mcp:invoke(server=s,tool=t)'] }, 'mcp:invoke(server=s,tool=t)', 'f')
|
|
27
|
+
).not.toThrow();
|
|
28
|
+
expect(() =>
|
|
29
|
+
assertScopeGranted({ scopes: ['mcp:invoke(server=s)'] }, 'mcp:invoke(server=s,tool=t)', 'f')
|
|
30
|
+
).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('refuses a scope outside the grant', () => {
|
|
34
|
+
expect(
|
|
35
|
+
reasonOf(() => assertScopeGranted({ scopes: ['mcp:invoke(server=a)'] }, 'mcp:invoke(server=b)', 'f'))
|
|
36
|
+
).toBe('not_subscope');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// The whole reason this module exists. Five adapters open-coded
|
|
40
|
+
// `(scopes ?? []).map(parseScope)`, so a v1.2 private delegation — where
|
|
41
|
+
// `scopes` is absent BECAUSE it is sealed — reported "not a sub-scope of
|
|
42
|
+
// any granted scope". The refusal was right; the diagnosis sent the
|
|
43
|
+
// integrator to audit a scope string that was very possibly correct.
|
|
44
|
+
it('names encrypted scopes as such rather than blaming the scope string', () => {
|
|
45
|
+
const d = { scopes_encrypted: SEALED };
|
|
46
|
+
expect(reasonOf(() => assertScopeGranted(d, 'mcp:invoke(server=s)', 'f'))).toBe('scopes_encrypted');
|
|
47
|
+
try {
|
|
48
|
+
assertScopeGranted(d, 'mcp:invoke(server=s)', 'f');
|
|
49
|
+
} catch (e) {
|
|
50
|
+
expect((e as Error).message).toMatch(/decryptPrivateScopes/);
|
|
51
|
+
expect((e as Error).message).not.toMatch(/not a sub-scope/);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('treats absent and empty scopes as granting nothing, not everything', () => {
|
|
56
|
+
expect(reasonOf(() => assertScopeGranted({}, 'x:y', 'f'))).toBe('no_scopes');
|
|
57
|
+
expect(reasonOf(() => assertScopeGranted({ scopes: [] }, 'x:y', 'f'))).toBe('no_scopes');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// A sealed delegation that ALSO carries a scopes array must not be
|
|
61
|
+
// evaluated against that array: the fields are exclusive per the spec, so
|
|
62
|
+
// a populated `scopes` alongside `scopes_encrypted` is a malformed
|
|
63
|
+
// envelope and possibly an attempt to present a benign readable grant
|
|
64
|
+
// while the real one stays hidden.
|
|
65
|
+
it('refuses on the sealed field even when a scopes array is also present', () => {
|
|
66
|
+
expect(
|
|
67
|
+
reasonOf(() =>
|
|
68
|
+
assertScopeGranted({ scopes: ['x:y'], scopes_encrypted: SEALED }, 'x:y', 'f')
|
|
69
|
+
)
|
|
70
|
+
).toBe('scopes_encrypted');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { hasPrivateScopes } from './private-scope.js';
|
|
2
|
+
import { isSubScope, parseScope } from './scope.js';
|
|
3
|
+
import type { ScopesEncryptedEnvelope } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The minimum an adapter needs to make the authorization decision. Kept
|
|
7
|
+
* structural rather than importing `Delegation` so `stampX` helpers can accept
|
|
8
|
+
* the narrower shapes they already declare.
|
|
9
|
+
*/
|
|
10
|
+
export interface ScopeBearingDelegation {
|
|
11
|
+
scopes?: string[];
|
|
12
|
+
scopes_encrypted?: ScopesEncryptedEnvelope;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Distinguishes the three reasons a stamp can be refused. */
|
|
16
|
+
export class ScopeNotGrantedError extends Error {
|
|
17
|
+
readonly reason: 'not_subscope' | 'scopes_encrypted' | 'no_scopes';
|
|
18
|
+
constructor(reason: ScopeNotGrantedError['reason'], message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'ScopeNotGrantedError';
|
|
21
|
+
this.reason = reason;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Refuse unless `scopeExercised` is a sub-scope of something the delegation
|
|
27
|
+
* actually grants. Every `agent-*` adapter's pre-flight check routes here.
|
|
28
|
+
*
|
|
29
|
+
* The check itself is unchanged and still fails closed. What this adds is
|
|
30
|
+
* telling the caller WHICH of three things went wrong, because the five
|
|
31
|
+
* adapters each open-coded `(delegation.scopes ?? []).map(parseScope)` and so
|
|
32
|
+
* reported all three as the same thing:
|
|
33
|
+
*
|
|
34
|
+
* - **not_subscope** — scopes are readable and this one is not among them.
|
|
35
|
+
* The integrator's own mistake, and the only case the old message fit.
|
|
36
|
+
* - **scopes_encrypted** — v1.2 private mode. `scopes` is absent *because it
|
|
37
|
+
* is sealed to a device key*, so the adapter cannot evaluate the request at
|
|
38
|
+
* all. `?? []` collapsed this into "not a sub-scope of any granted scope",
|
|
39
|
+
* which sends the integrator to audit a scope string that may well be
|
|
40
|
+
* correct, when what they need is `decryptPrivateScopes` and to pass the
|
|
41
|
+
* recovered list. Refusing is right; misnaming why is not.
|
|
42
|
+
* - **no_scopes** — neither field present. A delegation granting nothing.
|
|
43
|
+
*
|
|
44
|
+
* Fail-closed is not a judgement call here: an over-broad stamp is a signed,
|
|
45
|
+
* content-addressed authorization artifact that a verifier will accept.
|
|
46
|
+
*/
|
|
47
|
+
export function assertScopeGranted(
|
|
48
|
+
delegation: ScopeBearingDelegation,
|
|
49
|
+
scopeExercised: string,
|
|
50
|
+
fnName: string
|
|
51
|
+
): void {
|
|
52
|
+
if (hasPrivateScopes(delegation)) {
|
|
53
|
+
throw new ScopeNotGrantedError(
|
|
54
|
+
'scopes_encrypted',
|
|
55
|
+
`${fnName}: this delegation uses v1.2 private scopes — \`scopes_encrypted\` is ` +
|
|
56
|
+
`set and \`scopes\` is absent, so the granted set cannot be read here. ` +
|
|
57
|
+
`Decrypt it with decryptPrivateScopes() using a matching device key and pass ` +
|
|
58
|
+
`the recovered scopes as \`delegation.scopes\`. Refusing to stamp ` +
|
|
59
|
+
`\`${scopeExercised}\` against an unreadable grant.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const scopes = delegation.scopes;
|
|
64
|
+
if (scopes === undefined || scopes.length === 0) {
|
|
65
|
+
throw new ScopeNotGrantedError(
|
|
66
|
+
'no_scopes',
|
|
67
|
+
`${fnName}: delegation grants no scopes, so \`${scopeExercised}\` cannot be ` +
|
|
68
|
+
`exercised. An absent or empty \`scopes\` means nothing is granted, never everything.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const exercised = parseScope(scopeExercised);
|
|
73
|
+
if (!scopes.map(parseScope).some((g) => isSubScope(exercised, g))) {
|
|
74
|
+
throw new ScopeNotGrantedError(
|
|
75
|
+
'not_subscope',
|
|
76
|
+
`${fnName}: scope_exercised (${scopeExercised}) is not a sub-scope of any granted ` +
|
|
77
|
+
`scope (${scopes.join(', ')})`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/federation.test.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -118,7 +118,7 @@ describe('verifyDelegation with v1.2 scopes_encrypted', () => {
|
|
|
118
118
|
const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
|
|
119
119
|
// Inject a public scopes field too — illegal.
|
|
120
120
|
const both = { ...env, scopes: ['ln:send(max_sats<=100)'] } as DelegationEnvelope;
|
|
121
|
-
const r = await verifyDelegation({ envelope: both, skipSignatureVerification: true });
|
|
121
|
+
const r = await verifyDelegation({ envelope: both, skipSignatureVerification: true, skipRevocationCheck: true });
|
|
122
122
|
expect(r.ok).toBe(false);
|
|
123
123
|
if (!r.ok) expect(r.code).toBe('E_SCOPES_BOTH_PROVIDED');
|
|
124
124
|
});
|
|
@@ -127,14 +127,14 @@ describe('verifyDelegation with v1.2 scopes_encrypted', () => {
|
|
|
127
127
|
const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
|
|
128
128
|
const neither = { ...env } as DelegationEnvelope;
|
|
129
129
|
delete neither.scopes_encrypted;
|
|
130
|
-
const r = await verifyDelegation({ envelope: neither, skipSignatureVerification: true });
|
|
130
|
+
const r = await verifyDelegation({ envelope: neither, skipSignatureVerification: true, skipRevocationCheck: true });
|
|
131
131
|
expect(r.ok).toBe(false);
|
|
132
132
|
if (!r.ok) expect(r.code).toBe('E_SCOPES_NEITHER_PROVIDED');
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
it('returns E_SCOPES_UNREADABLE when no decryption key is supplied', async () => {
|
|
136
136
|
const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
|
|
137
|
-
const r = await verifyDelegation({ envelope: env, skipSignatureVerification: true });
|
|
137
|
+
const r = await verifyDelegation({ envelope: env, skipSignatureVerification: true, skipRevocationCheck: true });
|
|
138
138
|
expect(r.ok).toBe(false);
|
|
139
139
|
if (!r.ok) expect(r.code).toBe('E_SCOPES_UNREADABLE');
|
|
140
140
|
});
|
|
@@ -144,6 +144,7 @@ describe('verifyDelegation with v1.2 scopes_encrypted', () => {
|
|
|
144
144
|
'ln:send(max_sats<=100)',
|
|
145
145
|
]);
|
|
146
146
|
const r = await verifyDelegation({
|
|
147
|
+
skipRevocationCheck: true,
|
|
147
148
|
envelope,
|
|
148
149
|
skipSignatureVerification: true,
|
|
149
150
|
decryptScopesWith: { device_id: 'agent', secretKey: agentSecret },
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SECURITY §7 item 7: "Query revocation feeds (Nostr kind-30085 by
|
|
2
|
+
// `#delegation`) for the cited delegation id before reporting OK, **unless the
|
|
3
|
+
// caller explicitly opts out** of revocation checking."
|
|
4
|
+
//
|
|
5
|
+
// The default was inverted. verifyDelegation had NO revocations parameter at
|
|
6
|
+
// all — a revoked delegation returned ok:true and a caller had no way to say
|
|
7
|
+
// otherwise. verifyAction accepted one but checked only `if (input.revocations
|
|
8
|
+
// && length > 0)`, so omitting it skipped the check silently and "checked,
|
|
9
|
+
// clean" was indistinguishable from "never checked".
|
|
10
|
+
//
|
|
11
|
+
// This library has no network by design, so the caller fetches the feed. What
|
|
12
|
+
// changed is that declining to is now a decision they have to state.
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import { verifyDelegation } from './verify.js';
|
|
20
|
+
import type { DelegationEnvelope } from './types.js';
|
|
21
|
+
|
|
22
|
+
const vectors = join(dirname(fileURLToPath(import.meta.url)), '../../../oc-agent-protocol/test-vectors');
|
|
23
|
+
const delegation = JSON.parse(
|
|
24
|
+
readFileSync(join(vectors, 'v01-delegation-minimal.json'), 'utf8'),
|
|
25
|
+
).expected.envelope as DelegationEnvelope;
|
|
26
|
+
|
|
27
|
+
const AT = new Date('2026-04-23T12:00:00Z'); // inside the delegation's window
|
|
28
|
+
|
|
29
|
+
describe('verifyDelegation fails closed on revocation', () => {
|
|
30
|
+
it('refuses when given neither revocations nor an explicit skip', async () => {
|
|
31
|
+
const r = await verifyDelegation({
|
|
32
|
+
envelope: delegation,
|
|
33
|
+
skipSignatureVerification: true,
|
|
34
|
+
now: AT,
|
|
35
|
+
});
|
|
36
|
+
expect(r.ok).toBe(false);
|
|
37
|
+
expect(r.ok === false && r.code).toBe('E_MALFORMED');
|
|
38
|
+
expect(r.ok === false && r.message).toMatch(/requires `revocations`/i);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts an EMPTY revocation list — "I looked, there were none"', async () => {
|
|
42
|
+
const r = await verifyDelegation({
|
|
43
|
+
envelope: delegation,
|
|
44
|
+
skipSignatureVerification: true,
|
|
45
|
+
now: AT,
|
|
46
|
+
revocations: [],
|
|
47
|
+
});
|
|
48
|
+
expect(r.ok).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('accepts an explicit skip, for callers that genuinely cannot check', async () => {
|
|
52
|
+
const r = await verifyDelegation({
|
|
53
|
+
envelope: delegation,
|
|
54
|
+
skipSignatureVerification: true,
|
|
55
|
+
now: AT,
|
|
56
|
+
skipRevocationCheck: true,
|
|
57
|
+
});
|
|
58
|
+
expect(r.ok).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// An empty list and a skip both verify — but they mean different things, and
|
|
62
|
+
// only one of them is a claim about the feed. That distinction is the point
|
|
63
|
+
// of the change.
|
|
64
|
+
it('the two are not the same statement', () => {
|
|
65
|
+
expect('revocations: []').not.toBe('skipRevocationCheck: true');
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/test-vectors.test.ts
CHANGED
|
@@ -262,6 +262,7 @@ describe('oc-agent-protocol test vectors', () => {
|
|
|
262
262
|
it(`${name} — declared envelope passes verify() with skipSignatureVerification`, async () => {
|
|
263
263
|
if (data.kind === 'delegation') {
|
|
264
264
|
const r = await verifyDelegation({
|
|
265
|
+
skipRevocationCheck: true,
|
|
265
266
|
envelope: data.expected.envelope,
|
|
266
267
|
skipSignatureVerification: true,
|
|
267
268
|
skipTemporalCheck: true,
|
|
@@ -273,6 +274,7 @@ describe('oc-agent-protocol test vectors', () => {
|
|
|
273
274
|
throw new Error(`action vector ${name} references missing delegation ${data.inputs.delegation_id}`);
|
|
274
275
|
}
|
|
275
276
|
const r = await verifyAction({
|
|
277
|
+
skipRevocationCheck: true,
|
|
276
278
|
action: data.expected.envelope,
|
|
277
279
|
delegation,
|
|
278
280
|
skipSignatureVerification: true,
|
|
@@ -454,6 +456,7 @@ describe('oc-agent-protocol negative test vectors', () => {
|
|
|
454
456
|
sig: { alg: 'bip322', pubkey: v.inputs.address, value: 'AAAA' },
|
|
455
457
|
};
|
|
456
458
|
const r = await verifyAction({
|
|
459
|
+
skipRevocationCheck: true,
|
|
457
460
|
action,
|
|
458
461
|
delegation,
|
|
459
462
|
skipSignatureVerification: true,
|
|
@@ -704,6 +707,7 @@ describe('oc-agent-protocol private-scope vectors (v1.2)', () => {
|
|
|
704
707
|
);
|
|
705
708
|
}
|
|
706
709
|
const r = await verifyDelegation({
|
|
710
|
+
skipRevocationCheck: true,
|
|
707
711
|
envelope: env,
|
|
708
712
|
skipSignatureVerification: true,
|
|
709
713
|
skipTemporalCheck: true,
|
|
@@ -721,6 +725,7 @@ describe('oc-agent-protocol private-scope vectors (v1.2)', () => {
|
|
|
721
725
|
} else {
|
|
722
726
|
const negative = data as PrivateScopeNegativeVector;
|
|
723
727
|
const r = await verifyDelegation({
|
|
728
|
+
skipRevocationCheck: true,
|
|
724
729
|
envelope: env,
|
|
725
730
|
skipSignatureVerification: true,
|
|
726
731
|
skipTemporalCheck: true,
|
package/src/verify.ts
CHANGED
|
@@ -48,6 +48,24 @@ export interface VerifyBase {
|
|
|
48
48
|
verifyBip322?: (msg: string, signatureB64: string, address: string) => Promise<boolean>;
|
|
49
49
|
skipSignatureVerification?: boolean;
|
|
50
50
|
scopeMode?: ValidationOptions['mode'];
|
|
51
|
+
/**
|
|
52
|
+
* Verify WITHOUT consulting revocation feeds. Required to be explicit.
|
|
53
|
+
*
|
|
54
|
+
* SECURITY §7 item 7: "Query revocation feeds (Nostr kind-30085 by
|
|
55
|
+
* `#delegation`) for the cited delegation id before reporting OK, **unless
|
|
56
|
+
* the caller explicitly opts out**." The default was inverted — revocations
|
|
57
|
+
* were checked only when supplied, and omitting them skipped the check
|
|
58
|
+
* silently, so "checked, clean" was indistinguishable from "never checked".
|
|
59
|
+
*
|
|
60
|
+
* This library has no network by design, so the caller fetches the feed and
|
|
61
|
+
* passes `revocations`. Declining to is a decision they must state.
|
|
62
|
+
*/
|
|
63
|
+
skipRevocationCheck?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Revocations to evaluate against the delegation (or, for an action, every
|
|
66
|
+
* link in its chain). Fetch these from kind-30085 by `#delegation`.
|
|
67
|
+
*/
|
|
68
|
+
revocations?: RevocationEnvelope[];
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
export class AgentError extends Error {
|
|
@@ -204,6 +222,31 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
|
|
|
204
222
|
if (!ok) return err('E_BAD_SIG', 'BIP-322 signature did not verify');
|
|
205
223
|
}
|
|
206
224
|
|
|
225
|
+
// Revocation. SECURITY §7 item 7 — check unless the caller states otherwise.
|
|
226
|
+
// Before this, verifyDelegation had NO revocations parameter at all: a
|
|
227
|
+
// revoked delegation returned ok:true and a caller had no way to say
|
|
228
|
+
// otherwise.
|
|
229
|
+
if (!input.skipRevocationCheck && !input.revocations) {
|
|
230
|
+
return err(
|
|
231
|
+
'E_MALFORMED',
|
|
232
|
+
'verifyDelegation requires `revocations` (fetch kind-30085 by #delegation) or an ' +
|
|
233
|
+
'explicit skipRevocationCheck:true to state that revocation is not being checked',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (input.revocations) {
|
|
237
|
+
for (const rev of input.revocations) {
|
|
238
|
+
if (rev.delegation_id !== env.id) continue;
|
|
239
|
+
const rr = await verifyRevocation({
|
|
240
|
+
envelope: rev,
|
|
241
|
+
delegation: env,
|
|
242
|
+
verifyBip322: input.verifyBip322,
|
|
243
|
+
skipSignatureVerification: input.skipSignatureVerification,
|
|
244
|
+
});
|
|
245
|
+
if (!rr.ok) continue; // a malformed revocation revokes nothing
|
|
246
|
+
return err('E_REVOKED', `delegation ${env.id} was revoked by ${rev.id}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
207
250
|
// Temporal.
|
|
208
251
|
if (!input.skipTemporalCheck) {
|
|
209
252
|
const now = input.now ?? new Date();
|
|
@@ -254,12 +297,6 @@ export interface VerifyActionInput extends VerifyBase {
|
|
|
254
297
|
* E_SUBDELEGATION_DEPTH_EXCEEDED before any per-link work is performed.
|
|
255
298
|
*/
|
|
256
299
|
maxChainDepth?: number;
|
|
257
|
-
/**
|
|
258
|
-
* Known revocations targeting any envelope in the chain (root + each
|
|
259
|
-
* subdelegation). The verifier checks every link per SUB-DELEGATION.md §2.2
|
|
260
|
-
* step 5 — a revocation against ANY link invalidates the action.
|
|
261
|
-
*/
|
|
262
|
-
revocations?: RevocationEnvelope[];
|
|
263
300
|
content?: Uint8Array;
|
|
264
301
|
verifyOtsAnchor?: (proofB64: string, blockHeight: number, blockHash: string) => Promise<boolean>;
|
|
265
302
|
/** If action and revocation are both OTS-anchored, pass a function that returns the comparable block height of each via proof parsing. Defaults: use envelope.ots.block_height. */
|
|
@@ -301,6 +338,12 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
301
338
|
skipSignatureVerification: input.skipSignatureVerification,
|
|
302
339
|
scopeMode: input.scopeMode,
|
|
303
340
|
skipTemporalCheck: true, // action window check dominates
|
|
341
|
+
// Revocation is evaluated ONCE, below, across every link in the chain
|
|
342
|
+
// (root + subdelegations) against the action's effective time — a
|
|
343
|
+
// per-link check here would use the wrong clock and would also
|
|
344
|
+
// double-report. Forward the caller's decision so this inner call does
|
|
345
|
+
// not trip the fail-closed guard.
|
|
346
|
+
skipRevocationCheck: true,
|
|
304
347
|
...(input.decryptScopesWith ? { decryptScopesWith: input.decryptScopesWith } : {}),
|
|
305
348
|
});
|
|
306
349
|
if (!dr.ok) return dr;
|
|
@@ -395,6 +438,13 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
395
438
|
// 6. Revocation check — applies per-link to ALL envelopes in the chain
|
|
396
439
|
// (root + every subdelegation). Per SUB-DELEGATION.md §2.2 step 5, a
|
|
397
440
|
// revocation against ANY link invalidates the action.
|
|
441
|
+
if (!input.skipRevocationCheck && !input.revocations) {
|
|
442
|
+
return err(
|
|
443
|
+
'E_MALFORMED',
|
|
444
|
+
'verifyAction requires `revocations` (fetch kind-30085 by #delegation for every link ' +
|
|
445
|
+
'in the chain) or an explicit skipRevocationCheck:true',
|
|
446
|
+
);
|
|
447
|
+
}
|
|
398
448
|
if (input.revocations && input.revocations.length > 0) {
|
|
399
449
|
const allLinks: ChainLink[] = [rootHydrated, ...hydratedChain];
|
|
400
450
|
for (const link of allLinks) {
|