@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.
@@ -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
- const negatives = vectors.filter(({ data }) => isNegative(data)) as {
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
- /** Sorted lexicographically in the canonical message; stored in sorted order on the envelope too. */
54
- scopes: string[];
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
- /** Each scope MUST be a sub-scope of some scope on the parent. */
132
- scopes: string[];
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;