@orangecheck/agent-core 2.1.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/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +37 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +37 -32
- package/dist/index.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/verify.ts +61 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangecheck/agent-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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",
|
package/src/federation.test.ts
CHANGED
|
@@ -97,13 +97,39 @@ d('federation delegation vectors (v19–v24, v26)', () => {
|
|
|
97
97
|
d('federation revocation (v25)', () => {
|
|
98
98
|
it('3-of-5 threshold met → valid', async () => {
|
|
99
99
|
const v = await load('v25-federation-revocation-3of5-valid.json');
|
|
100
|
+
const dv = await load('v19-federation-delegation-3of5-valid.json');
|
|
100
101
|
const result = await verifyFederationRevocation({
|
|
101
102
|
envelope: v.expected.envelope as FederationRevocationEnvelope,
|
|
103
|
+
delegation: dv.expected.envelope as FederationDelegationEnvelope,
|
|
102
104
|
skipSignatureVerification: true,
|
|
103
105
|
});
|
|
104
106
|
expect(result.ok).toBe(true);
|
|
105
107
|
if (result.ok) expect(result.id).toBe(v.expected.envelope.id);
|
|
106
108
|
});
|
|
109
|
+
|
|
110
|
+
it('is bound to the delegation it revokes and to that delegation\'s federation', async () => {
|
|
111
|
+
const v = await load('v25-federation-revocation-3of5-valid.json');
|
|
112
|
+
const dv = await load('v19-federation-delegation-3of5-valid.json');
|
|
113
|
+
const rev = v.expected.envelope as FederationRevocationEnvelope;
|
|
114
|
+
const del = dv.expected.envelope as FederationDelegationEnvelope;
|
|
115
|
+
const otherDelegation = await verifyFederationRevocation({
|
|
116
|
+
envelope: rev,
|
|
117
|
+
delegation: { ...del, id: 'f'.repeat(64) },
|
|
118
|
+
skipSignatureVerification: true,
|
|
119
|
+
});
|
|
120
|
+
expect(otherDelegation.ok === false && otherDelegation.code).toBe('E_DELEGATION_MISMATCH');
|
|
121
|
+
const otherFederation = await verifyFederationRevocation({
|
|
122
|
+
envelope: rev,
|
|
123
|
+
delegation: { ...del, principal: { ...del.principal, descriptor_id: 'e'.repeat(64) } },
|
|
124
|
+
skipSignatureVerification: true,
|
|
125
|
+
});
|
|
126
|
+
expect(otherFederation.ok === false && otherFederation.code).toBe('E_REVOKER_UNAUTHORIZED');
|
|
127
|
+
const missing = await verifyFederationRevocation({
|
|
128
|
+
envelope: rev,
|
|
129
|
+
skipSignatureVerification: true,
|
|
130
|
+
} as Parameters<typeof verifyFederationRevocation>[0]);
|
|
131
|
+
expect(missing.ok).toBe(false);
|
|
132
|
+
});
|
|
107
133
|
});
|
|
108
134
|
|
|
109
135
|
d('federation reject reasons (each invalid case fails for its named reason)', () => {
|
package/src/federation.ts
CHANGED
|
@@ -291,6 +291,8 @@ export async function verifyFederationDelegation(
|
|
|
291
291
|
|
|
292
292
|
export interface VerifyFederationRevocationInput extends VerifyFederationBase {
|
|
293
293
|
envelope: FederationRevocationEnvelope;
|
|
294
|
+
/** The delegation being revoked. Its federation is the one that may revoke it. */
|
|
295
|
+
delegation: FederationDelegationEnvelope;
|
|
294
296
|
}
|
|
295
297
|
|
|
296
298
|
export async function verifyFederationRevocation(
|
|
@@ -305,6 +307,18 @@ export async function verifyFederationRevocation(
|
|
|
305
307
|
}
|
|
306
308
|
if (!ISO_UTC.test(env.signed_at)) return fail('E_MALFORMED', 'signed_at must be ISO 8601 UTC');
|
|
307
309
|
|
|
310
|
+
const d = input.delegation;
|
|
311
|
+
if (!d || !HEX64.test(d.id ?? '')) return fail('E_MALFORMED', 'the revoked delegation is required');
|
|
312
|
+
if (env.delegation_id !== d.id) {
|
|
313
|
+
return fail('E_DELEGATION_MISMATCH', `revocation.delegation_id (${env.delegation_id}) != delegation.id (${d.id})`);
|
|
314
|
+
}
|
|
315
|
+
if (d.principal?.alg !== 'federation' || env.signer?.descriptor_id !== d.principal.descriptor_id) {
|
|
316
|
+
return fail(
|
|
317
|
+
'E_REVOKER_UNAUTHORIZED',
|
|
318
|
+
`revoking federation (${env.signer?.descriptor_id}) is not the delegation's principal`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
308
322
|
// §4 — the `address:` line carries the `federation:<descriptor_id>`
|
|
309
323
|
// substitution; everything else is the v1 revocation canonical message.
|
|
310
324
|
const canonInput = {
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// SPEC §9.5 / §9.3: who may revoke a delegation, and when a revocation takes
|
|
2
|
+
// effect against an action. `revocation.holders` and `ots` are both outside the
|
|
3
|
+
// signatures they sit next to, so neither may narrow what a signed revocation
|
|
4
|
+
// does.
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { computeActionId, computeDelegationId, computeRevocationId } from './canonical.js';
|
|
8
|
+
import { verifyAction, verifyDelegation, verifyRevocation } from './verify.js';
|
|
9
|
+
import type { ActionEnvelope, ActionOts, DelegationEnvelope, RevocationEnvelope } from './types.js';
|
|
10
|
+
|
|
11
|
+
const PRINCIPAL = 'bc1qprincipal000000000000000000000000000000';
|
|
12
|
+
const AGENT = 'bc1qagent0000000000000000000000000000000000';
|
|
13
|
+
const SCOPE = 'lock:seal(recipient=bc1qalice000000000000000000000000000000000)';
|
|
14
|
+
|
|
15
|
+
function delegation(holders: unknown): DelegationEnvelope {
|
|
16
|
+
const canon = {
|
|
17
|
+
principal: PRINCIPAL,
|
|
18
|
+
agent: AGENT,
|
|
19
|
+
scopes: [SCOPE],
|
|
20
|
+
bond_sats: 0,
|
|
21
|
+
bond_attestation: 'none',
|
|
22
|
+
issued_at: '2026-04-22T12:00:00Z',
|
|
23
|
+
expires_at: '2026-04-29T12:00:00Z',
|
|
24
|
+
nonce: '0123456789abcdef0123456789abcdef',
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
v: 1,
|
|
28
|
+
kind: 'agent-delegation',
|
|
29
|
+
id: computeDelegationId(canon),
|
|
30
|
+
principal: { address: PRINCIPAL, alg: 'bip322' },
|
|
31
|
+
agent: { address: AGENT, alg: 'bip322' },
|
|
32
|
+
scopes: [SCOPE],
|
|
33
|
+
bond: null,
|
|
34
|
+
issued_at: canon.issued_at,
|
|
35
|
+
expires_at: canon.expires_at,
|
|
36
|
+
nonce: canon.nonce,
|
|
37
|
+
revocation: { holders, ref: null },
|
|
38
|
+
sig: { alg: 'bip322', pubkey: PRINCIPAL, value: 'AAAA' },
|
|
39
|
+
} as DelegationEnvelope;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function revocation(d: DelegationEnvelope, signer: string, signedAt: string, ots: ActionOts | null = null): RevocationEnvelope {
|
|
43
|
+
const id = computeRevocationId({ address: signer, delegation_id: d.id, reason: '', signed_at: signedAt });
|
|
44
|
+
return {
|
|
45
|
+
v: 1,
|
|
46
|
+
kind: 'agent-revocation',
|
|
47
|
+
id,
|
|
48
|
+
delegation_id: d.id,
|
|
49
|
+
signer: { address: signer, alg: 'bip322' },
|
|
50
|
+
reason: '',
|
|
51
|
+
signed_at: signedAt,
|
|
52
|
+
ots,
|
|
53
|
+
sig: { alg: 'bip322', pubkey: signer, value: 'AAAA' },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function action(d: DelegationEnvelope, signedAt: string, ots: ActionOts | null = null): ActionEnvelope {
|
|
58
|
+
const canon = {
|
|
59
|
+
address: AGENT,
|
|
60
|
+
content_hash: 'sha256:' + '00'.repeat(32),
|
|
61
|
+
content_length: 0,
|
|
62
|
+
content_mime: 'text/plain',
|
|
63
|
+
signed_at: signedAt,
|
|
64
|
+
delegation_id: d.id,
|
|
65
|
+
scope_exercised: SCOPE,
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
v: 1,
|
|
69
|
+
kind: 'agent-action',
|
|
70
|
+
id: computeActionId(canon),
|
|
71
|
+
content: { hash: canon.content_hash, length: 0, mime: 'text/plain', ref: null },
|
|
72
|
+
signer: { address: AGENT, alg: 'bip322' },
|
|
73
|
+
signed_at: signedAt,
|
|
74
|
+
delegation_id: d.id,
|
|
75
|
+
scope_exercised: SCOPE,
|
|
76
|
+
ots,
|
|
77
|
+
sig: { alg: 'bip322', pubkey: AGENT, value: 'AAAA' },
|
|
78
|
+
} as ActionEnvelope;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function confirmed(height: number): ActionOts {
|
|
82
|
+
return {
|
|
83
|
+
status: 'confirmed',
|
|
84
|
+
proof: 'AAAA',
|
|
85
|
+
calendars: [],
|
|
86
|
+
block_height: height,
|
|
87
|
+
block_hash: 'ab'.repeat(32),
|
|
88
|
+
upgraded_at: null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const AT = new Date('2026-04-23T12:00:00Z');
|
|
93
|
+
|
|
94
|
+
describe('revocation authority always includes the principal', () => {
|
|
95
|
+
for (const holders of [['agent'], [], ['principal'], undefined, 'agent']) {
|
|
96
|
+
it(`principal revocation is honoured with holders=${JSON.stringify(holders)}`, async () => {
|
|
97
|
+
const d = delegation(holders);
|
|
98
|
+
const rev = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z');
|
|
99
|
+
const rr = await verifyRevocation({ envelope: rev, delegation: d, skipSignatureVerification: true });
|
|
100
|
+
expect(rr.ok).toBe(true);
|
|
101
|
+
const dr = await verifyDelegation({
|
|
102
|
+
envelope: d,
|
|
103
|
+
revocations: [rev],
|
|
104
|
+
skipSignatureVerification: true,
|
|
105
|
+
now: AT,
|
|
106
|
+
});
|
|
107
|
+
expect(dr.ok === false && dr.code).toBe('E_REVOKED');
|
|
108
|
+
const ar = await verifyAction({
|
|
109
|
+
action: action(d, '2026-04-23T06:00:00Z'),
|
|
110
|
+
delegation: d,
|
|
111
|
+
revocations: [rev],
|
|
112
|
+
skipSignatureVerification: true,
|
|
113
|
+
});
|
|
114
|
+
expect(ar.ok === false && ar.code).toBe('E_REVOKED');
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it('the agent may revoke only when holders lists it', async () => {
|
|
119
|
+
const open = delegation(['principal', 'agent']);
|
|
120
|
+
const closed = delegation(['principal']);
|
|
121
|
+
const okR = await verifyRevocation({
|
|
122
|
+
envelope: revocation(open, AGENT, '2026-04-23T00:00:00Z'),
|
|
123
|
+
delegation: open,
|
|
124
|
+
skipSignatureVerification: true,
|
|
125
|
+
});
|
|
126
|
+
expect(okR.ok).toBe(true);
|
|
127
|
+
const noR = await verifyRevocation({
|
|
128
|
+
envelope: revocation(closed, AGENT, '2026-04-23T00:00:00Z'),
|
|
129
|
+
delegation: closed,
|
|
130
|
+
skipSignatureVerification: true,
|
|
131
|
+
});
|
|
132
|
+
expect(noR.ok === false && noR.code).toBe('E_REVOKER_UNAUTHORIZED');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('a third party is never an authorised revoker', async () => {
|
|
136
|
+
const d = delegation(['principal', 'agent']);
|
|
137
|
+
const r = await verifyRevocation({
|
|
138
|
+
envelope: revocation(d, 'bc1qother0000000000000000000000000000000000', '2026-04-23T00:00:00Z'),
|
|
139
|
+
delegation: d,
|
|
140
|
+
skipSignatureVerification: true,
|
|
141
|
+
});
|
|
142
|
+
expect(r.ok === false && r.code).toBe('E_REVOKER_UNAUTHORIZED');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('revocation priority uses only verified anchors', () => {
|
|
147
|
+
const d = delegation(['principal']);
|
|
148
|
+
// Revocation signed before the action: revoked unless the action proves priority.
|
|
149
|
+
const rev = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z');
|
|
150
|
+
const later = '2026-04-23T06:00:00Z';
|
|
151
|
+
|
|
152
|
+
it('an action anchor with no anchor verifier does not outrank a revocation', async () => {
|
|
153
|
+
const r = await verifyAction({
|
|
154
|
+
action: action(d, later, confirmed(1)),
|
|
155
|
+
delegation: d,
|
|
156
|
+
revocations: [rev],
|
|
157
|
+
skipSignatureVerification: true,
|
|
158
|
+
});
|
|
159
|
+
expect(r.ok === false && r.code).toBe('E_REVOKED');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('an action anchor the verifier rejects does not outrank a revocation', async () => {
|
|
163
|
+
const r = await verifyAction({
|
|
164
|
+
action: action(d, later, confirmed(1)),
|
|
165
|
+
delegation: d,
|
|
166
|
+
revocations: [rev],
|
|
167
|
+
skipSignatureVerification: true,
|
|
168
|
+
verifyOtsAnchor: async () => false,
|
|
169
|
+
});
|
|
170
|
+
expect(r.ok === false && r.code).toBe('E_REVOKED');
|
|
171
|
+
const clean = await verifyAction({
|
|
172
|
+
action: action(d, later, confirmed(1)),
|
|
173
|
+
delegation: d,
|
|
174
|
+
revocations: [],
|
|
175
|
+
skipSignatureVerification: true,
|
|
176
|
+
verifyOtsAnchor: async () => false,
|
|
177
|
+
});
|
|
178
|
+
expect(clean.ok && clean.anchor).toEqual({
|
|
179
|
+
status: 'confirmed',
|
|
180
|
+
blockHeight: 1,
|
|
181
|
+
blockHash: 'ab'.repeat(32),
|
|
182
|
+
verified: false,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('the anchor verifier is asked about the envelope it belongs to', async () => {
|
|
187
|
+
const a = action(d, later, confirmed(100));
|
|
188
|
+
const revAnchored = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z', confirmed(200));
|
|
189
|
+
const seen: string[] = [];
|
|
190
|
+
const r = await verifyAction({
|
|
191
|
+
action: a,
|
|
192
|
+
delegation: d,
|
|
193
|
+
revocations: [revAnchored],
|
|
194
|
+
skipSignatureVerification: true,
|
|
195
|
+
verifyOtsAnchor: async (_p, _h, _b, id) => {
|
|
196
|
+
seen.push(id);
|
|
197
|
+
return true;
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
expect(seen).toEqual([a.id, revAnchored.id]);
|
|
201
|
+
// Both verified: action at 100 precedes revocation at 200 (§9.3).
|
|
202
|
+
expect(r.ok).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('a verified action anchor against an unanchored revocation falls back to signed times', async () => {
|
|
206
|
+
const r = await verifyAction({
|
|
207
|
+
action: action(d, later, confirmed(1)),
|
|
208
|
+
delegation: d,
|
|
209
|
+
revocations: [rev],
|
|
210
|
+
skipSignatureVerification: true,
|
|
211
|
+
verifyOtsAnchor: async () => true,
|
|
212
|
+
});
|
|
213
|
+
expect(r.ok === false && r.code).toBe('E_REVOKED');
|
|
214
|
+
const before = await verifyAction({
|
|
215
|
+
action: action(d, '2026-04-22T18:00:00Z', confirmed(1)),
|
|
216
|
+
delegation: d,
|
|
217
|
+
revocations: [rev],
|
|
218
|
+
skipSignatureVerification: true,
|
|
219
|
+
verifyOtsAnchor: async () => true,
|
|
220
|
+
});
|
|
221
|
+
expect(before.ok).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('a revocation anchor that does not verify cannot move the revocation later', async () => {
|
|
225
|
+
const revFarFuture = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z', confirmed(10_000_000));
|
|
226
|
+
const a = action(d, later, confirmed(100));
|
|
227
|
+
const r = await verifyAction({
|
|
228
|
+
action: a,
|
|
229
|
+
delegation: d,
|
|
230
|
+
revocations: [revFarFuture],
|
|
231
|
+
skipSignatureVerification: true,
|
|
232
|
+
verifyOtsAnchor: async (_p, _h, _b, id) => id === a.id,
|
|
233
|
+
});
|
|
234
|
+
expect(r.ok === false && r.code).toBe('E_REVOKED');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('both anchors verified: the later block does not revoke the earlier action', async () => {
|
|
238
|
+
const a = action(d, later, confirmed(300));
|
|
239
|
+
const revAt = (h: number) => revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z', confirmed(h));
|
|
240
|
+
const verifyOtsAnchor = async () => true;
|
|
241
|
+
const earlierRev = await verifyAction({
|
|
242
|
+
action: a,
|
|
243
|
+
delegation: d,
|
|
244
|
+
revocations: [revAt(300)],
|
|
245
|
+
skipSignatureVerification: true,
|
|
246
|
+
verifyOtsAnchor,
|
|
247
|
+
});
|
|
248
|
+
expect(earlierRev.ok === false && earlierRev.code).toBe('E_REVOKED');
|
|
249
|
+
const laterRev = await verifyAction({
|
|
250
|
+
action: a,
|
|
251
|
+
delegation: d,
|
|
252
|
+
revocations: [revAt(301)],
|
|
253
|
+
skipSignatureVerification: true,
|
|
254
|
+
verifyOtsAnchor,
|
|
255
|
+
});
|
|
256
|
+
expect(laterRev.ok).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
});
|
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
|
// ─────────────────────────────────────────────────────────────────────────────
|