@orangecheck/agent-core 0.2.0 → 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/dist/index.d.mts +47 -2
- package/dist/index.d.ts +47 -2
- package/dist/index.js +184 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +180 -17
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.mts +39 -4
- package/dist/types.d.ts +39 -4
- package/dist/types.js.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +12 -0
- package/src/private-scope.test.ts +223 -0
- package/src/private-scope.ts +122 -0
- package/src/test-vectors.test.ts +185 -1
- package/src/types.ts +59 -5
- package/src/verify.ts +234 -18
package/src/test-vectors.test.ts
CHANGED
|
@@ -217,6 +217,7 @@ describe('oc-agent-protocol test vectors', () => {
|
|
|
217
217
|
const subdelegationEnvelopes = new Map<string, SubdelegationEnvelope>();
|
|
218
218
|
for (const { data } of vectors) {
|
|
219
219
|
if (isNegative(data)) continue;
|
|
220
|
+
if ((data as { private_scope?: unknown }).private_scope) continue;
|
|
220
221
|
if (data.kind === 'delegation') {
|
|
221
222
|
delegationEnvelopes.set(data.expected.id, data.expected.envelope);
|
|
222
223
|
} else if (data.kind === 'subdelegation') {
|
|
@@ -226,6 +227,7 @@ describe('oc-agent-protocol test vectors', () => {
|
|
|
226
227
|
|
|
227
228
|
for (const { name, data } of vectors) {
|
|
228
229
|
if (isNegative(data)) continue;
|
|
230
|
+
if ((data as { private_scope?: unknown }).private_scope) continue;
|
|
229
231
|
it(`${name} — canonical message reconstructs byte-identical`, () => {
|
|
230
232
|
const msg = reconstructCanonical(data);
|
|
231
233
|
expect(msg).toBe(data.expected.canonical_message);
|
|
@@ -367,7 +369,14 @@ function reconstructId(v: PositiveVector): string {
|
|
|
367
369
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
368
370
|
|
|
369
371
|
describe('oc-agent-protocol negative test vectors', () => {
|
|
370
|
-
|
|
372
|
+
// Filter out private-scope vectors — they have their own describe block
|
|
373
|
+
// because the round-trip path requires sealing first, not just verifying
|
|
374
|
+
// a pre-built envelope.
|
|
375
|
+
const negatives = vectors.filter(
|
|
376
|
+
({ data }) =>
|
|
377
|
+
isNegative(data) &&
|
|
378
|
+
!(data as { private_scope?: unknown }).private_scope
|
|
379
|
+
) as {
|
|
371
380
|
name: string;
|
|
372
381
|
data: NegativeVector;
|
|
373
382
|
}[];
|
|
@@ -543,3 +552,178 @@ describe('oc-agent-protocol negative test vectors', () => {
|
|
|
543
552
|
});
|
|
544
553
|
}
|
|
545
554
|
});
|
|
555
|
+
|
|
556
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
557
|
+
// Private-scope (v1.2) vectors — round-trip via sealScopes/verifyDelegation.
|
|
558
|
+
// The OC Lock envelope uses random nonces, so byte-identical reproducibility
|
|
559
|
+
// of the ciphertext is not asserted; what's pinned is the round-trip outcome.
|
|
560
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
interface PrivateScopeRecipientFixture {
|
|
563
|
+
address: string;
|
|
564
|
+
device_id: string;
|
|
565
|
+
device_pk: string;
|
|
566
|
+
device_secret: string;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
interface PrivateScopeBaseInputs {
|
|
570
|
+
principal: string;
|
|
571
|
+
agent: string;
|
|
572
|
+
scopes_plaintext: string[];
|
|
573
|
+
recipients: PrivateScopeRecipientFixture[];
|
|
574
|
+
bond: { sats: number; attestation_id: string } | null;
|
|
575
|
+
issued_at: string;
|
|
576
|
+
expires_at: string;
|
|
577
|
+
nonce: string;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
interface PrivateScopePositiveVector {
|
|
581
|
+
description: string;
|
|
582
|
+
kind: 'delegation';
|
|
583
|
+
private_scope: true;
|
|
584
|
+
inputs: PrivateScopeBaseInputs;
|
|
585
|
+
expected: {
|
|
586
|
+
verification_outcome: 'ACCEPT';
|
|
587
|
+
decrypted_scopes: string[];
|
|
588
|
+
verifier_uses_device_id: string;
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
interface PrivateScopeNegativeVector {
|
|
593
|
+
description: string;
|
|
594
|
+
kind: 'delegation';
|
|
595
|
+
private_scope: true;
|
|
596
|
+
negative: true;
|
|
597
|
+
inputs: PrivateScopeBaseInputs & {
|
|
598
|
+
verifier_holds_unrelated_key: {
|
|
599
|
+
device_id: string;
|
|
600
|
+
device_pk: string;
|
|
601
|
+
device_secret: string;
|
|
602
|
+
};
|
|
603
|
+
};
|
|
604
|
+
expected: {
|
|
605
|
+
verification_outcome: 'REJECT';
|
|
606
|
+
error_code: AgentErrorCode;
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
type PrivateScopeVector = PrivateScopePositiveVector | PrivateScopeNegativeVector;
|
|
611
|
+
|
|
612
|
+
function isPrivateScopeVector(v: unknown): v is PrivateScopeVector {
|
|
613
|
+
return (
|
|
614
|
+
typeof v === 'object' &&
|
|
615
|
+
v !== null &&
|
|
616
|
+
(v as { private_scope?: unknown }).private_scope === true
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
describe('oc-agent-protocol private-scope vectors (v1.2)', () => {
|
|
621
|
+
const psVectors = vectors.filter(({ data }) =>
|
|
622
|
+
isPrivateScopeVector(data)
|
|
623
|
+
) as { name: string; data: PrivateScopeVector }[];
|
|
624
|
+
|
|
625
|
+
if (psVectors.length === 0) {
|
|
626
|
+
it.skip('(no private-scope vectors found)', () => {});
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
for (const { name, data } of psVectors) {
|
|
631
|
+
it(`${name} — ${data.expected.verification_outcome}`, async () => {
|
|
632
|
+
// Lazy-load to avoid pulling lock-core at module init time when no
|
|
633
|
+
// private-scope vectors are present.
|
|
634
|
+
const ps = await import('./private-scope.js');
|
|
635
|
+
const { hexDecode } = await import('@orangecheck/lock-crypto');
|
|
636
|
+
|
|
637
|
+
const sealed = await ps.sealScopes({
|
|
638
|
+
scopes: data.inputs.scopes_plaintext,
|
|
639
|
+
sender: {
|
|
640
|
+
address: data.inputs.principal,
|
|
641
|
+
signMessage: async () => 'AAAA',
|
|
642
|
+
},
|
|
643
|
+
recipients: data.inputs.recipients.map((r) => ({
|
|
644
|
+
address: r.address,
|
|
645
|
+
device_id: r.device_id,
|
|
646
|
+
device_pk: r.device_pk,
|
|
647
|
+
})),
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Build the OC Agent envelope. Use the canonical (sorted) scopes
|
|
651
|
+
// so the id reconstructs cleanly.
|
|
652
|
+
const canonScopes = canonicalizeScopes(data.inputs.scopes_plaintext);
|
|
653
|
+
const canonInput = {
|
|
654
|
+
principal: data.inputs.principal,
|
|
655
|
+
agent: data.inputs.agent,
|
|
656
|
+
scopes: canonScopes,
|
|
657
|
+
bond_sats: data.inputs.bond?.sats ?? 0,
|
|
658
|
+
bond_attestation: data.inputs.bond?.attestation_id ?? 'none',
|
|
659
|
+
issued_at: data.inputs.issued_at,
|
|
660
|
+
expires_at: data.inputs.expires_at,
|
|
661
|
+
nonce: data.inputs.nonce,
|
|
662
|
+
};
|
|
663
|
+
const id = computeDelegationId(canonInput);
|
|
664
|
+
const env: DelegationEnvelope = {
|
|
665
|
+
v: 1,
|
|
666
|
+
kind: 'agent-delegation',
|
|
667
|
+
id,
|
|
668
|
+
principal: { address: data.inputs.principal, alg: 'bip322' },
|
|
669
|
+
agent: { address: data.inputs.agent, alg: 'bip322' },
|
|
670
|
+
scopes_encrypted: sealed,
|
|
671
|
+
bond: data.inputs.bond,
|
|
672
|
+
issued_at: data.inputs.issued_at,
|
|
673
|
+
expires_at: data.inputs.expires_at,
|
|
674
|
+
nonce: data.inputs.nonce,
|
|
675
|
+
revocation: { holders: ['principal'], ref: null },
|
|
676
|
+
sig: { alg: 'bip322', pubkey: data.inputs.principal, value: 'AAAA' },
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
if (data.expected.verification_outcome === 'ACCEPT') {
|
|
680
|
+
const positive = data as PrivateScopePositiveVector;
|
|
681
|
+
const recip = data.inputs.recipients.find(
|
|
682
|
+
(r) => r.device_id === positive.expected.verifier_uses_device_id
|
|
683
|
+
);
|
|
684
|
+
if (!recip) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
`vector ${name} names verifier_uses_device_id ${positive.expected.verifier_uses_device_id}, not in recipients`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const r = await verifyDelegation({
|
|
690
|
+
envelope: env,
|
|
691
|
+
skipSignatureVerification: true,
|
|
692
|
+
skipTemporalCheck: true,
|
|
693
|
+
decryptScopesWith: {
|
|
694
|
+
device_id: recip.device_id,
|
|
695
|
+
secretKey: hexDecode(recip.device_secret),
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
expect(r.ok).toBe(true);
|
|
699
|
+
if (r.ok) {
|
|
700
|
+
expect(r.envelope.scopes).toEqual(
|
|
701
|
+
canonicalizeScopes(positive.expected.decrypted_scopes)
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
const negative = data as PrivateScopeNegativeVector;
|
|
706
|
+
const r = await verifyDelegation({
|
|
707
|
+
envelope: env,
|
|
708
|
+
skipSignatureVerification: true,
|
|
709
|
+
skipTemporalCheck: true,
|
|
710
|
+
decryptScopesWith: {
|
|
711
|
+
device_id: negative.inputs.verifier_holds_unrelated_key.device_id,
|
|
712
|
+
secretKey: hexDecode(
|
|
713
|
+
negative.inputs.verifier_holds_unrelated_key.device_secret
|
|
714
|
+
),
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
expect(r.ok).toBe(false);
|
|
718
|
+
if (!r.ok) {
|
|
719
|
+
// Accept either E_SCOPES_UNREADABLE (preferred) or
|
|
720
|
+
// E_BAD_LOCK_ENVELOPE (acceptable per harness_assertion).
|
|
721
|
+
expect([
|
|
722
|
+
'E_SCOPES_UNREADABLE',
|
|
723
|
+
'E_BAD_LOCK_ENVELOPE',
|
|
724
|
+
]).toContain(r.code);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -44,14 +44,59 @@ export interface DelegationRevocationRef {
|
|
|
44
44
|
ref: string | null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* v1.2 private-scope mode: a wholesale OC Lock v2 LockEnvelope wrapping the
|
|
49
|
+
* canonical scope list as its payload. We re-import the LockEnvelope type
|
|
50
|
+
* structurally rather than depending on the @orangecheck/lock-core type — agent-
|
|
51
|
+
* core's type surface stays loose so verifiers can be authored in either an
|
|
52
|
+
* agent-only or full-family setup.
|
|
53
|
+
*/
|
|
54
|
+
export interface ScopesEncryptedEnvelope {
|
|
55
|
+
v: 2;
|
|
56
|
+
kind: 'identity';
|
|
57
|
+
id: string;
|
|
58
|
+
alg: { kem: 'x25519'; aead: 'aes-256-gcm'; kdf: 'hkdf-sha256' };
|
|
59
|
+
from: { address: string; attestation_id?: string };
|
|
60
|
+
recipients: Array<{
|
|
61
|
+
address: string;
|
|
62
|
+
device_id: string;
|
|
63
|
+
device_pk: string;
|
|
64
|
+
eph_pk: string;
|
|
65
|
+
wrapped_key: string;
|
|
66
|
+
nonce_kek: string;
|
|
67
|
+
}>;
|
|
68
|
+
ciphertext: string;
|
|
69
|
+
nonce_ct: string;
|
|
70
|
+
hint?: string;
|
|
71
|
+
created_at: string;
|
|
72
|
+
expires_at: string | null;
|
|
73
|
+
payment: unknown | null;
|
|
74
|
+
sig: { alg: 'bip322'; pubkey: string; value: string };
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
export interface DelegationEnvelope {
|
|
48
78
|
v: typeof ENVELOPE_VERSION;
|
|
49
79
|
kind: 'agent-delegation';
|
|
50
80
|
id: string; // 64-hex sha256(canonical_message)
|
|
51
81
|
principal: ActorRef;
|
|
52
82
|
agent: ActorRef;
|
|
53
|
-
/**
|
|
54
|
-
|
|
83
|
+
/**
|
|
84
|
+
* v1.0 / v1.1 public mode: sorted lexicographically in the canonical
|
|
85
|
+
* message; stored in sorted order on the envelope too.
|
|
86
|
+
*
|
|
87
|
+
* v1.2 private mode: this field is OMITTED from the envelope JSON;
|
|
88
|
+
* `scopes_encrypted` is set instead. After decryption, the recovered
|
|
89
|
+
* scope list takes this field's place in the in-memory envelope object
|
|
90
|
+
* for the remainder of verification.
|
|
91
|
+
*/
|
|
92
|
+
scopes?: string[];
|
|
93
|
+
/**
|
|
94
|
+
* v1.2 private mode (PRIVATE-SCOPE.md §1.1): an OC Lock v2 envelope
|
|
95
|
+
* wrapping the canonical scope-list bytes as its payload, sealed to one
|
|
96
|
+
* or more recipients (typically the agent ± named verifiers). MUTUALLY
|
|
97
|
+
* EXCLUSIVE with `scopes`.
|
|
98
|
+
*/
|
|
99
|
+
scopes_encrypted?: ScopesEncryptedEnvelope;
|
|
55
100
|
bond: DelegationBond | null;
|
|
56
101
|
issued_at: string; // ISO 8601 UTC
|
|
57
102
|
expires_at: string; // ISO 8601 UTC
|
|
@@ -128,8 +173,13 @@ export interface SubdelegationEnvelope {
|
|
|
128
173
|
principal: ActorRef;
|
|
129
174
|
/** The recipient sub-agent. */
|
|
130
175
|
agent: ActorRef;
|
|
131
|
-
/**
|
|
132
|
-
|
|
176
|
+
/**
|
|
177
|
+
* v1.0 / v1.1 public mode: each scope MUST be a sub-scope of some scope
|
|
178
|
+
* on the parent. v1.2 private mode: omitted; `scopes_encrypted` set.
|
|
179
|
+
*/
|
|
180
|
+
scopes?: string[];
|
|
181
|
+
/** v1.2 private mode — same shape as the root delegation field. */
|
|
182
|
+
scopes_encrypted?: ScopesEncryptedEnvelope;
|
|
133
183
|
issued_at: string; // ISO 8601 UTC; >= parent.issued_at
|
|
134
184
|
expires_at: string; // ISO 8601 UTC; <= parent.expires_at
|
|
135
185
|
nonce: string; // 32-hex random
|
|
@@ -200,7 +250,11 @@ export type AgentErrorCode =
|
|
|
200
250
|
| 'E_SUBDELEGATION_DEPTH_EXCEEDED'
|
|
201
251
|
| 'E_SUBDELEGATION_PRINCIPAL_MISMATCH'
|
|
202
252
|
| 'E_SUBDELEGATION_EXPIRES_EXTENDED'
|
|
203
|
-
| 'E_SUBDELEGATION_SCOPE_ESCALATED'
|
|
253
|
+
| 'E_SUBDELEGATION_SCOPE_ESCALATED'
|
|
254
|
+
| 'E_SCOPES_BOTH_PROVIDED'
|
|
255
|
+
| 'E_SCOPES_NEITHER_PROVIDED'
|
|
256
|
+
| 'E_SCOPES_UNREADABLE'
|
|
257
|
+
| 'E_BAD_LOCK_ENVELOPE';
|
|
204
258
|
|
|
205
259
|
export interface VerifyOk<T> {
|
|
206
260
|
ok: true;
|