@orangecheck/agent-core 0.1.0 → 0.3.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/canonical.d.mts +5 -2
- package/dist/canonical.d.ts +5 -2
- package/dist/canonical.js +22 -0
- package/dist/canonical.js.map +1 -1
- package/dist/canonical.mjs +20 -1
- package/dist/canonical.mjs.map +1 -1
- package/dist/index.d.mts +60 -5
- package/dist/index.d.ts +60 -5
- package/dist/index.js +356 -29
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +347 -30
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.mts +65 -4
- package/dist/types.d.ts +65 -4
- package/dist/types.js.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/package.json +73 -69
- package/src/canonical.ts +23 -0
- package/src/index.ts +18 -0
- package/src/private-scope.test.ts +223 -0
- package/src/private-scope.ts +122 -0
- package/src/test-vectors.test.ts +358 -12
- package/src/types.ts +109 -4
- package/src/verify.ts +487 -40
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Round-trip + verifyDelegation integration for v1.2 private-scope mode.
|
|
2
|
+
//
|
|
3
|
+
// These tests don't depend on any of the cross-impl test vectors — they
|
|
4
|
+
// build envelopes inline using deterministic-enough inputs to verify the
|
|
5
|
+
// seal → embed → verify-with-key path works against agent-core's own
|
|
6
|
+
// expectations.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
generateX25519KeyPair,
|
|
10
|
+
hexEncode,
|
|
11
|
+
randomBytesN,
|
|
12
|
+
} from '@orangecheck/lock-crypto';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { computeDelegationId, delegationCanonicalMessage } from './canonical.js';
|
|
16
|
+
import { canonicalizeScopes } from './canonical.js';
|
|
17
|
+
import {
|
|
18
|
+
decodeScopesPayload,
|
|
19
|
+
encodeScopesPayload,
|
|
20
|
+
sealScopes,
|
|
21
|
+
unsealScopes,
|
|
22
|
+
} from './private-scope.js';
|
|
23
|
+
import { verifyDelegation } from './verify.js';
|
|
24
|
+
|
|
25
|
+
import type { DelegationEnvelope } from './types.js';
|
|
26
|
+
|
|
27
|
+
describe('private-scope payload codec', () => {
|
|
28
|
+
it('round-trips a canonical scope list as utf-8 JSON', () => {
|
|
29
|
+
const scopes = [
|
|
30
|
+
'ln:send(max_sats<=1000)',
|
|
31
|
+
'lock:seal(recipient=bc1qalice000000000000000000000000000000000)',
|
|
32
|
+
];
|
|
33
|
+
const bytes = encodeScopesPayload(scopes);
|
|
34
|
+
const decoded = decodeScopesPayload(bytes);
|
|
35
|
+
// payload encodes the *canonical* form (sorted, constraints sorted).
|
|
36
|
+
expect(decoded).toEqual(canonicalizeScopes(scopes));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects non-string-array payloads on decode', () => {
|
|
40
|
+
const bytes = new TextEncoder().encode('"not an array"');
|
|
41
|
+
expect(() => decodeScopesPayload(bytes)).toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('sealScopes / unsealScopes round-trip', () => {
|
|
46
|
+
it('seals to one recipient and unseals with that recipient device key', async () => {
|
|
47
|
+
const principalAddress = 'bc1qprincipal000000000000000000000000000000';
|
|
48
|
+
const agentKp = generateX25519KeyPair();
|
|
49
|
+
const agentDevice = {
|
|
50
|
+
address: 'bc1qagent0000000000000000000000000000000000',
|
|
51
|
+
device_id: 'agent-test',
|
|
52
|
+
device_pk: hexEncode(agentKp.public),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const sealed = await sealScopes({
|
|
56
|
+
scopes: ['ln:send(max_sats<=500)'],
|
|
57
|
+
sender: {
|
|
58
|
+
address: principalAddress,
|
|
59
|
+
signMessage: async () => 'AAAA',
|
|
60
|
+
},
|
|
61
|
+
recipients: [agentDevice],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(sealed.kind).toBe('identity');
|
|
65
|
+
expect(sealed.recipients).toHaveLength(1);
|
|
66
|
+
expect(sealed.from.address).toBe(principalAddress);
|
|
67
|
+
|
|
68
|
+
const unsealed = await unsealScopes({
|
|
69
|
+
envelope: sealed,
|
|
70
|
+
device: { device_id: 'agent-test', secretKey: agentKp.secret },
|
|
71
|
+
skipSenderVerification: true,
|
|
72
|
+
});
|
|
73
|
+
expect(unsealed.scopes).toEqual(['ln:send(max_sats<=500)']);
|
|
74
|
+
expect(unsealed.matchedDeviceId).toBe('agent-test');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('seals to multiple recipients; each can unseal independently', async () => {
|
|
78
|
+
const agentKp = generateX25519KeyPair();
|
|
79
|
+
const auditorKp = generateX25519KeyPair();
|
|
80
|
+
|
|
81
|
+
const sealed = await sealScopes({
|
|
82
|
+
scopes: ['mcp:invoke(server=https://x.com,tool=search,max_invocations<=50)'],
|
|
83
|
+
sender: {
|
|
84
|
+
address: 'bc1qprincipal000000000000000000000000000000',
|
|
85
|
+
signMessage: async () => 'AAAA',
|
|
86
|
+
},
|
|
87
|
+
recipients: [
|
|
88
|
+
{
|
|
89
|
+
address: 'bc1qagent0000000000000000000000000000000000',
|
|
90
|
+
device_id: 'agent',
|
|
91
|
+
device_pk: hexEncode(agentKp.public),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
address: 'bc1qauditor0000000000000000000000000000000',
|
|
95
|
+
device_id: 'auditor',
|
|
96
|
+
device_pk: hexEncode(auditorKp.public),
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
expect(sealed.recipients).toHaveLength(2);
|
|
101
|
+
|
|
102
|
+
const byAgent = await unsealScopes({
|
|
103
|
+
envelope: sealed,
|
|
104
|
+
device: { device_id: 'agent', secretKey: agentKp.secret },
|
|
105
|
+
skipSenderVerification: true,
|
|
106
|
+
});
|
|
107
|
+
const byAuditor = await unsealScopes({
|
|
108
|
+
envelope: sealed,
|
|
109
|
+
device: { device_id: 'auditor', secretKey: auditorKp.secret },
|
|
110
|
+
skipSenderVerification: true,
|
|
111
|
+
});
|
|
112
|
+
expect(byAgent.scopes).toEqual(byAuditor.scopes);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('verifyDelegation with v1.2 scopes_encrypted', () => {
|
|
117
|
+
it('returns E_SCOPES_BOTH_PROVIDED when both fields present', async () => {
|
|
118
|
+
const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
|
|
119
|
+
// Inject a public scopes field too — illegal.
|
|
120
|
+
const both = { ...env, scopes: ['ln:send(max_sats<=100)'] } as DelegationEnvelope;
|
|
121
|
+
const r = await verifyDelegation({ envelope: both, skipSignatureVerification: true });
|
|
122
|
+
expect(r.ok).toBe(false);
|
|
123
|
+
if (!r.ok) expect(r.code).toBe('E_SCOPES_BOTH_PROVIDED');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns E_SCOPES_NEITHER_PROVIDED when neither field present', async () => {
|
|
127
|
+
const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
|
|
128
|
+
const neither = { ...env } as DelegationEnvelope;
|
|
129
|
+
delete neither.scopes_encrypted;
|
|
130
|
+
const r = await verifyDelegation({ envelope: neither, skipSignatureVerification: true });
|
|
131
|
+
expect(r.ok).toBe(false);
|
|
132
|
+
if (!r.ok) expect(r.code).toBe('E_SCOPES_NEITHER_PROVIDED');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns E_SCOPES_UNREADABLE when no decryption key is supplied', async () => {
|
|
136
|
+
const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
|
|
137
|
+
const r = await verifyDelegation({ envelope: env, skipSignatureVerification: true });
|
|
138
|
+
expect(r.ok).toBe(false);
|
|
139
|
+
if (!r.ok) expect(r.code).toBe('E_SCOPES_UNREADABLE');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('decrypts and verifies cleanly when a matching device key is supplied', async () => {
|
|
143
|
+
const { envelope, agentSecret } = await buildPrivateEnvelopeWithKey([
|
|
144
|
+
'ln:send(max_sats<=100)',
|
|
145
|
+
]);
|
|
146
|
+
const r = await verifyDelegation({
|
|
147
|
+
envelope,
|
|
148
|
+
skipSignatureVerification: true,
|
|
149
|
+
decryptScopesWith: { device_id: 'agent', secretKey: agentSecret },
|
|
150
|
+
});
|
|
151
|
+
expect(r.ok).toBe(true);
|
|
152
|
+
if (r.ok) {
|
|
153
|
+
// Hydrated envelope returned with plaintext scopes.
|
|
154
|
+
expect(r.envelope.scopes).toEqual(['ln:send(max_sats<=100)']);
|
|
155
|
+
expect(r.envelope.scopes_encrypted).toBeDefined();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
// Helpers
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
async function buildPrivateEnvelope(scopes: string[]): Promise<DelegationEnvelope> {
|
|
165
|
+
const r = await buildPrivateEnvelopeWithKey(scopes);
|
|
166
|
+
return r.envelope;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function buildPrivateEnvelopeWithKey(
|
|
170
|
+
scopes: string[]
|
|
171
|
+
): Promise<{ envelope: DelegationEnvelope; agentSecret: Uint8Array }> {
|
|
172
|
+
const principalAddress = 'bc1qprincipal000000000000000000000000000000';
|
|
173
|
+
const agentAddress = 'bc1qagent0000000000000000000000000000000000';
|
|
174
|
+
const agentKp = generateX25519KeyPair();
|
|
175
|
+
|
|
176
|
+
const sealed = await sealScopes({
|
|
177
|
+
scopes,
|
|
178
|
+
sender: {
|
|
179
|
+
address: principalAddress,
|
|
180
|
+
signMessage: async () => 'AAAA',
|
|
181
|
+
},
|
|
182
|
+
recipients: [
|
|
183
|
+
{
|
|
184
|
+
address: agentAddress,
|
|
185
|
+
device_id: 'agent',
|
|
186
|
+
device_pk: hexEncode(agentKp.public),
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const issued_at = '2026-04-22T12:00:00Z';
|
|
192
|
+
const expires_at = '2099-04-22T12:00:00Z';
|
|
193
|
+
const nonce = hexEncode(randomBytesN(16));
|
|
194
|
+
|
|
195
|
+
const canonInput = {
|
|
196
|
+
principal: principalAddress,
|
|
197
|
+
agent: agentAddress,
|
|
198
|
+
scopes: canonicalizeScopes(scopes),
|
|
199
|
+
bond_sats: 0,
|
|
200
|
+
bond_attestation: 'none',
|
|
201
|
+
issued_at,
|
|
202
|
+
expires_at,
|
|
203
|
+
nonce,
|
|
204
|
+
};
|
|
205
|
+
const id = computeDelegationId(canonInput);
|
|
206
|
+
delegationCanonicalMessage(canonInput); // sanity check it builds
|
|
207
|
+
|
|
208
|
+
const env: DelegationEnvelope = {
|
|
209
|
+
v: 1,
|
|
210
|
+
kind: 'agent-delegation',
|
|
211
|
+
id,
|
|
212
|
+
principal: { address: principalAddress, alg: 'bip322' },
|
|
213
|
+
agent: { address: agentAddress, alg: 'bip322' },
|
|
214
|
+
scopes_encrypted: sealed,
|
|
215
|
+
bond: null,
|
|
216
|
+
issued_at,
|
|
217
|
+
expires_at,
|
|
218
|
+
nonce,
|
|
219
|
+
revocation: { holders: ['principal'], ref: null },
|
|
220
|
+
sig: { alg: 'bip322', pubkey: principalAddress, value: 'AAAA' },
|
|
221
|
+
};
|
|
222
|
+
return { envelope: env, agentSecret: agentKp.secret };
|
|
223
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Private-scope helpers (PRIVATE-SCOPE.md, v1.2). Wrapping the OC Lock
|
|
2
|
+
// `seal` and `unseal` primitives with the OC Agent-specific payload format
|
|
3
|
+
// (canonical JSON array of scope strings, UTF-8 encoded).
|
|
4
|
+
//
|
|
5
|
+
// Why this lives in agent-core and not the consumer side: the canonical-
|
|
6
|
+
// message commitment is the same across modes, so verifyDelegation needs to
|
|
7
|
+
// be able to recover the plaintext. Centralizing the payload codec here
|
|
8
|
+
// means every conformant verifier hashes the same bytes.
|
|
9
|
+
|
|
10
|
+
import { seal as lockSeal, unseal as lockUnseal } from '@orangecheck/lock-core';
|
|
11
|
+
import type {
|
|
12
|
+
DeviceRecord,
|
|
13
|
+
LockEnvelope,
|
|
14
|
+
SealInput,
|
|
15
|
+
UnsealInput,
|
|
16
|
+
} from '@orangecheck/lock-core';
|
|
17
|
+
|
|
18
|
+
import { canonicalizeScopes } from './canonical.js';
|
|
19
|
+
import type { ScopesEncryptedEnvelope } from './types.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The plaintext payload sealed inside the OC Lock envelope. Canonical JSON
|
|
23
|
+
* array of scope strings. Scopes are first put in canonical form (constraints
|
|
24
|
+
* sorted by key) and the array is sorted lexicographically — same discipline
|
|
25
|
+
* as v1.0 public-mode `scopes` field, so that the canonical-message bytes
|
|
26
|
+
* match byte-for-byte across modes.
|
|
27
|
+
*/
|
|
28
|
+
export function encodeScopesPayload(scopes: string[]): Uint8Array {
|
|
29
|
+
const canonical = canonicalizeScopes(scopes);
|
|
30
|
+
const json = JSON.stringify(canonical);
|
|
31
|
+
return new TextEncoder().encode(json);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Inverse of `encodeScopesPayload`. Returns the canonicalized scope array.
|
|
36
|
+
* Throws if the payload doesn't decode to a string array.
|
|
37
|
+
*/
|
|
38
|
+
export function decodeScopesPayload(bytes: Uint8Array): string[] {
|
|
39
|
+
const json = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
40
|
+
const parsed: unknown = JSON.parse(json);
|
|
41
|
+
if (!Array.isArray(parsed) || !parsed.every((s) => typeof s === 'string')) {
|
|
42
|
+
throw new Error('scopes payload must be a JSON array of strings');
|
|
43
|
+
}
|
|
44
|
+
return parsed as string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SealScopesInput {
|
|
48
|
+
scopes: string[];
|
|
49
|
+
/** Principal / sender — the same address that signs the OC Agent envelope. */
|
|
50
|
+
sender: SealInput['sender'];
|
|
51
|
+
/** Authorized decryptors. Must include at least the agent. */
|
|
52
|
+
recipients: DeviceRecord[];
|
|
53
|
+
/** Optional human hint stored in the OC Lock envelope. */
|
|
54
|
+
hint?: string;
|
|
55
|
+
/** Optional expiry on the OC Lock envelope itself. Independent of the
|
|
56
|
+
* delegation's expires_at; usually left null. */
|
|
57
|
+
expiresAt?: Date | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Seal a scope list to one or more recipients. Returns the OC Lock envelope
|
|
62
|
+
* to be embedded as `delegation.scopes_encrypted`.
|
|
63
|
+
*/
|
|
64
|
+
export async function sealScopes(
|
|
65
|
+
input: SealScopesInput
|
|
66
|
+
): Promise<ScopesEncryptedEnvelope> {
|
|
67
|
+
const env = await lockSeal({
|
|
68
|
+
kind: 'identity',
|
|
69
|
+
payload: encodeScopesPayload(input.scopes),
|
|
70
|
+
sender: input.sender,
|
|
71
|
+
recipients: input.recipients,
|
|
72
|
+
...(input.hint !== undefined && { hint: input.hint }),
|
|
73
|
+
...(input.expiresAt !== undefined && { expiresAt: input.expiresAt }),
|
|
74
|
+
});
|
|
75
|
+
return env as unknown as ScopesEncryptedEnvelope;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface UnsealScopesInput {
|
|
79
|
+
envelope: ScopesEncryptedEnvelope;
|
|
80
|
+
device: UnsealInput['device'];
|
|
81
|
+
/** BIP-322 verifier callback. If omitted, the embedded LockEnvelope's
|
|
82
|
+
* signature is NOT checked — useful for inspection or test paths. */
|
|
83
|
+
verifyBip322?: UnsealInput['verifyBip322'];
|
|
84
|
+
/** Skip the inner sender-signature check entirely. Default false. */
|
|
85
|
+
skipSenderVerification?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Result of unseal: decoded scope list plus the recovered sender address. */
|
|
89
|
+
export interface UnsealedScopes {
|
|
90
|
+
scopes: string[];
|
|
91
|
+
sender: { address: string; attestation_id?: string };
|
|
92
|
+
matchedDeviceId: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decrypt an OC Agent v1.2 `scopes_encrypted` field with one of the
|
|
97
|
+
* recipient device keys.
|
|
98
|
+
*/
|
|
99
|
+
export async function unsealScopes(
|
|
100
|
+
input: UnsealScopesInput
|
|
101
|
+
): Promise<UnsealedScopes> {
|
|
102
|
+
const r = await lockUnseal({
|
|
103
|
+
envelope: input.envelope as unknown as LockEnvelope,
|
|
104
|
+
device: input.device,
|
|
105
|
+
...(input.verifyBip322 ? { verifyBip322: input.verifyBip322 } : {}),
|
|
106
|
+
...(input.skipSenderVerification !== undefined && {
|
|
107
|
+
skipSenderVerification: input.skipSenderVerification,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
scopes: decodeScopesPayload(r.payload),
|
|
112
|
+
sender: r.sender,
|
|
113
|
+
matchedDeviceId: r.matchedDeviceId,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Convenience predicate. */
|
|
118
|
+
export function hasPrivateScopes<
|
|
119
|
+
T extends { scopes?: string[]; scopes_encrypted?: ScopesEncryptedEnvelope }
|
|
120
|
+
>(envelope: T): envelope is T & { scopes_encrypted: ScopesEncryptedEnvelope } {
|
|
121
|
+
return !!envelope.scopes_encrypted;
|
|
122
|
+
}
|