@orangecheck/agent-core 2.0.0 → 2.2.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/README.md +3 -1
- package/dist/canonical.js.map +1 -1
- package/dist/canonical.mjs.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +44 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +44 -34
- package/dist/index.mjs.map +1 -1
- package/dist/scope.js +7 -2
- package/dist/scope.js.map +1 -1
- package/dist/scope.mjs +7 -2
- package/dist/scope.mjs.map +1 -1
- package/package.json +1 -1
- package/src/federation.test.ts +26 -0
- package/src/federation.ts +14 -0
- package/src/revocation-authority.test.ts +258 -0
- package/src/revocation-failclosed.test.ts +19 -8
- package/src/scope.test.ts +44 -0
- package/src/scope.ts +17 -2
- package/src/verify.ts +61 -49
|
@@ -10,19 +10,30 @@
|
|
|
10
10
|
//
|
|
11
11
|
// This library has no network by design, so the caller fetches the feed. What
|
|
12
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
13
|
import { describe, expect, it } from 'vitest';
|
|
18
14
|
|
|
19
15
|
import { verifyDelegation } from './verify.js';
|
|
20
16
|
import type { DelegationEnvelope } from './types.js';
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
// Inlined rather than read from oc-agent-protocol/test-vectors: this test is
|
|
19
|
+
// about the revocation guard, not vector conformance, and a relative path into
|
|
20
|
+
// a sibling repo resolves locally but not in CI, where the spec repo is not
|
|
21
|
+
// checked out beside this one. test-vectors.test.ts is the place that depends
|
|
22
|
+
// on the real vectors, and CI checks them out for it.
|
|
23
|
+
const delegation: DelegationEnvelope = {
|
|
24
|
+
v: 1,
|
|
25
|
+
kind: 'agent-delegation',
|
|
26
|
+
id: '36d79600191db871baa3fc9aa3b5e77750a5c423b1f620ec26cf16bd122e19a7',
|
|
27
|
+
principal: { address: 'bc1qprincipal000000000000000000000000000000', alg: 'bip322' },
|
|
28
|
+
agent: { address: 'bc1qagent0000000000000000000000000000000000', alg: 'bip322' },
|
|
29
|
+
scopes: ['lock:seal(recipient=bc1qalice000000000000000000000000000000000)'],
|
|
30
|
+
bond: null,
|
|
31
|
+
issued_at: '2026-04-22T12:00:00Z',
|
|
32
|
+
expires_at: '2026-04-29T12:00:00Z',
|
|
33
|
+
nonce: '0123456789abcdef0123456789abcdef',
|
|
34
|
+
revocation: { holders: ['principal'], ref: null },
|
|
35
|
+
sig: { alg: 'bip322', pubkey: 'bc1qprincipal000000000000000000000000000000', value: 'AAAA' },
|
|
36
|
+
} as DelegationEnvelope;
|
|
26
37
|
|
|
27
38
|
const AT = new Date('2026-04-23T12:00:00Z'); // inside the delegation's window
|
|
28
39
|
|
package/src/scope.test.ts
CHANGED
|
@@ -160,3 +160,47 @@ describe('isSubScope (SPEC §7.4)', () => {
|
|
|
160
160
|
).toBe(false);
|
|
161
161
|
});
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
// SPEC §7.4: "values MUST be decimal integers for ordered ops". `Number()` is
|
|
165
|
+
// much looser and accepted 1e3, 0x3e8, +1000, " 1000 " and 1.5 — plus "", which
|
|
166
|
+
// becomes 0 and turns an empty constraint into a silent zero bound. A verifier
|
|
167
|
+
// with a strict decimal parser rejects all of them, so identical bytes produced
|
|
168
|
+
// different accept/reject answers across implementations, which SECURITY §5
|
|
169
|
+
// rules out.
|
|
170
|
+
describe('ordered-op values must be decimal integers (SPEC §7.4)', () => {
|
|
171
|
+
const granted = parseScope('ln:send(max_sats<=1000)');
|
|
172
|
+
|
|
173
|
+
it('still admits a plain decimal integer', () => {
|
|
174
|
+
expect(isSubScope(parseScope('ln:send(max_sats<=500)'), granted)).toBe(true);
|
|
175
|
+
expect(isSubScope(parseScope('ln:send(max_sats<=1000)'), granted)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('still rejects a decimal integer outside the granted range', () => {
|
|
179
|
+
expect(isSubScope(parseScope('ln:send(max_sats<=1001)'), granted)).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Each of these previously compared numerically and could be ADMITTED.
|
|
183
|
+
for (const v of ['1e3', '0x3e8', '+1000', '1.5']) {
|
|
184
|
+
it(`rejects ${JSON.stringify(v)} rather than coercing it`, () => {
|
|
185
|
+
let exercised;
|
|
186
|
+
try {
|
|
187
|
+
exercised = parseScope(`ln:send(max_sats<=${v})`);
|
|
188
|
+
} catch {
|
|
189
|
+
return; // rejected at parse time is also a rejection
|
|
190
|
+
}
|
|
191
|
+
expect(isSubScope(exercised, granted)).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The sharpest case: Number('') === 0, so an empty constraint became
|
|
196
|
+
// max_sats<=0 — a real bound nobody wrote.
|
|
197
|
+
it('does not silently read an empty value as zero', () => {
|
|
198
|
+
let exercised;
|
|
199
|
+
try {
|
|
200
|
+
exercised = parseScope('ln:send(max_sats<=)');
|
|
201
|
+
} catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
expect(isSubScope(exercised, parseScope('ln:send(max_sats<=0)'))).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/scope.ts
CHANGED
|
@@ -309,10 +309,25 @@ function rangeSubset(ex: ScopeConstraint, g: ScopeConstraint): boolean {
|
|
|
309
309
|
return gRange.lo <= exRange.lo && exRange.hi <= gRange.hi;
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* SPEC §7.4: "values MUST be decimal integers for ordered ops".
|
|
314
|
+
*
|
|
315
|
+
* `Number()` is far more permissive than that and accepts `1e3`, `0x3e8`,
|
|
316
|
+
* `+1000`, `" 1000 "`, `1.5` — and `""`, which becomes 0, turning an empty
|
|
317
|
+
* constraint into a silent zero bound. A verifier using a strict decimal parser
|
|
318
|
+
* rejects every one of those, so the same bytes produced different accept/reject
|
|
319
|
+
* answers on different implementations. SECURITY §5 requires they not.
|
|
320
|
+
*/
|
|
321
|
+
function parseDecimalInteger(value: string): number | null {
|
|
322
|
+
if (!/^-?\d+$/.test(value)) return null;
|
|
323
|
+
const n = Number(value);
|
|
324
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
312
327
|
function opToRange(c: ScopeConstraint): { lo: number; hi: number } | null {
|
|
313
328
|
if (c.value === undefined) return null;
|
|
314
|
-
const n =
|
|
315
|
-
if (
|
|
329
|
+
const n = parseDecimalInteger(c.value);
|
|
330
|
+
if (n === null) return null;
|
|
316
331
|
switch (c.op) {
|
|
317
332
|
case '=':
|
|
318
333
|
return { lo: n, hi: n };
|
package/src/verify.ts
CHANGED
|
@@ -298,8 +298,19 @@ export interface VerifyActionInput extends VerifyBase {
|
|
|
298
298
|
*/
|
|
299
299
|
maxChainDepth?: number;
|
|
300
300
|
content?: Uint8Array;
|
|
301
|
-
|
|
302
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Verify an OTS proof commits `envelopeId` into the block at `blockHeight`.
|
|
303
|
+
* Same shape as @orangecheck/stamp-core. An anchor is used for revocation
|
|
304
|
+
* priority (SPEC §9.3) only when this returns true; without it every
|
|
305
|
+
* anchor is treated as absent.
|
|
306
|
+
*/
|
|
307
|
+
verifyOtsAnchor?: (
|
|
308
|
+
proofB64: string,
|
|
309
|
+
blockHeight: number,
|
|
310
|
+
blockHash: string,
|
|
311
|
+
envelopeId: string
|
|
312
|
+
) => Promise<boolean>;
|
|
313
|
+
/** 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. The height is still checked by verifyOtsAnchor. */
|
|
303
314
|
resolveAnchorBlockHeight?: (env: ActionEnvelope | RevocationEnvelope) => number | null;
|
|
304
315
|
/**
|
|
305
316
|
* v1.2 private-scope decryption key. Applied to the root delegation AND
|
|
@@ -445,6 +456,14 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
445
456
|
'in the chain) or an explicit skipRevocationCheck:true',
|
|
446
457
|
);
|
|
447
458
|
}
|
|
459
|
+
// Anchor heights only count once the proof is verified against the
|
|
460
|
+
// envelope's own id; `ots` is outside the signature.
|
|
461
|
+
let actionAnchor: number | null;
|
|
462
|
+
try {
|
|
463
|
+
actionAnchor = await verifiedAnchorHeight(a, input);
|
|
464
|
+
} catch (e) {
|
|
465
|
+
return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
|
|
466
|
+
}
|
|
448
467
|
if (input.revocations && input.revocations.length > 0) {
|
|
449
468
|
const allLinks: ChainLink[] = [rootHydrated, ...hydratedChain];
|
|
450
469
|
for (const link of allLinks) {
|
|
@@ -460,9 +479,13 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
460
479
|
skipSignatureVerification: input.skipSignatureVerification,
|
|
461
480
|
});
|
|
462
481
|
if (!rr.ok) continue; // malformed revocations don't affect the action
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
482
|
+
let revAnchor: number | null;
|
|
483
|
+
try {
|
|
484
|
+
revAnchor = await verifiedAnchorHeight(rev, input);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
|
|
487
|
+
}
|
|
488
|
+
if (revocationPrecedes(rev, revAnchor, a, actionAnchor)) {
|
|
466
489
|
return err('E_REVOKED', `chain link ${link.id} was revoked by ${rev.id} before action was signed`);
|
|
467
490
|
}
|
|
468
491
|
}
|
|
@@ -489,14 +512,7 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
489
512
|
if (h === null || hash === null) {
|
|
490
513
|
return err('E_MALFORMED', 'confirmed OTS proof missing block_height or block_hash');
|
|
491
514
|
}
|
|
492
|
-
|
|
493
|
-
if (input.verifyOtsAnchor) {
|
|
494
|
-
try {
|
|
495
|
-
verified = await input.verifyOtsAnchor(a.ots.proof, h, hash);
|
|
496
|
-
} catch (e) {
|
|
497
|
-
return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
515
|
+
const verified = actionAnchor !== null;
|
|
500
516
|
anchor = { status: 'confirmed', blockHeight: h, blockHash: hash, verified } as typeof anchor;
|
|
501
517
|
}
|
|
502
518
|
|
|
@@ -597,11 +613,11 @@ export async function verifyRevocation(input: VerifyRevocationInput): Promise<Ve
|
|
|
597
613
|
return err('E_DELEGATION_MISMATCH', `revocation.delegation_id (${env.delegation_id}) != delegation.id (${d.id})`);
|
|
598
614
|
}
|
|
599
615
|
|
|
600
|
-
//
|
|
616
|
+
// The principal may always revoke (SPEC §9.5). `revocation.holders` is not
|
|
617
|
+
// covered by the delegation signature, so it may only add the agent.
|
|
601
618
|
const holders = d.revocation?.holders ?? ['principal'];
|
|
602
|
-
const holderAddrs = new Set<string>();
|
|
603
|
-
if (holders.includes('
|
|
604
|
-
if (holders.includes('agent')) holderAddrs.add(d.agent.address);
|
|
619
|
+
const holderAddrs = new Set<string>([d.principal.address]);
|
|
620
|
+
if (Array.isArray(holders) && holders.includes('agent')) holderAddrs.add(d.agent.address);
|
|
605
621
|
if (!holderAddrs.has(env.signer.address)) {
|
|
606
622
|
return err('E_REVOKER_UNAUTHORIZED', `revocation signer ${env.signer.address} not in delegation holders`);
|
|
607
623
|
}
|
|
@@ -901,40 +917,36 @@ export async function verifySubdelegation(
|
|
|
901
917
|
// Time comparison for revocation vs action (SPEC §9.3)
|
|
902
918
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
903
919
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
function
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
):
|
|
912
|
-
if (
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
return
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
function effectiveRevocationTime(
|
|
920
|
-
r: RevocationEnvelope,
|
|
921
|
-
resolve?: (env: ActionEnvelope | RevocationEnvelope) => number | null
|
|
922
|
-
): EffectiveTime {
|
|
923
|
-
if (r.ots?.status === 'confirmed') {
|
|
924
|
-
const h = resolve ? resolve(r) : r.ots.block_height;
|
|
925
|
-
if (h !== null && h !== undefined) return { kind: 'anchor', blockHeight: h };
|
|
926
|
-
}
|
|
927
|
-
return { kind: 'signed', ms: new Date(r.signed_at).getTime() };
|
|
920
|
+
/**
|
|
921
|
+
* The block height of an envelope's OTS anchor, or null unless the proof has
|
|
922
|
+
* been verified to commit this envelope's id at that height.
|
|
923
|
+
*/
|
|
924
|
+
async function verifiedAnchorHeight(
|
|
925
|
+
env: ActionEnvelope | RevocationEnvelope,
|
|
926
|
+
input: Pick<VerifyActionInput, 'verifyOtsAnchor' | 'resolveAnchorBlockHeight'>
|
|
927
|
+
): Promise<number | null> {
|
|
928
|
+
if (env.ots?.status !== 'confirmed' || !input.verifyOtsAnchor) return null;
|
|
929
|
+
const h = input.resolveAnchorBlockHeight ? input.resolveAnchorBlockHeight(env) : env.ots.block_height;
|
|
930
|
+
const hash = env.ots.block_hash;
|
|
931
|
+
if (h === null || h === undefined || !Number.isInteger(h) || !hash) return null;
|
|
932
|
+
return (await input.verifyOtsAnchor(env.ots.proof, h, hash, env.id)) ? h : null;
|
|
928
933
|
}
|
|
929
934
|
|
|
930
|
-
/**
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
935
|
+
/**
|
|
936
|
+
* True when the revocation takes effect at or before the action (SPEC §9.3).
|
|
937
|
+
* Only verified anchors count for priority. An action without one cannot prove
|
|
938
|
+
* priority over a revocation that claims an anchor; an anchored action against
|
|
939
|
+
* an unanchored revocation falls back to the signed times.
|
|
940
|
+
*/
|
|
941
|
+
function revocationPrecedes(
|
|
942
|
+
rev: RevocationEnvelope,
|
|
943
|
+
revAnchor: number | null,
|
|
944
|
+
action: ActionEnvelope,
|
|
945
|
+
actionAnchor: number | null
|
|
946
|
+
): boolean {
|
|
947
|
+
if (revAnchor !== null && actionAnchor !== null) return revAnchor <= actionAnchor;
|
|
948
|
+
if (actionAnchor === null && rev.ots?.status === 'confirmed') return true;
|
|
949
|
+
return new Date(rev.signed_at).getTime() <= new Date(action.signed_at).getTime();
|
|
938
950
|
}
|
|
939
951
|
|
|
940
952
|
// ─────────────────────────────────────────────────────────────────────────────
|