@ixo/ucan 1.1.0 → 1.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.
@@ -6,6 +6,7 @@ import { defineCapability, Schema } from '../capabilities/capability.js';
6
6
  import {
7
7
  createDelegation,
8
8
  createInvocation,
9
+ serializeDelegation,
9
10
  serializeInvocation,
10
11
  type Capability,
11
12
  } from '../client/create-client.js';
@@ -568,6 +569,172 @@ describe('UCAN Validator', () => {
568
569
  });
569
570
  });
570
571
 
572
+ describe('facts', () => {
573
+ it('should return facts attached to the invocation', async () => {
574
+ const server = await keygen();
575
+ const root = await keygen();
576
+
577
+ const validator = await createUCANValidator({
578
+ serverDid: server.did,
579
+ rootIssuers: [root.did],
580
+ });
581
+
582
+ const facts = [
583
+ { verified: true, timestamp: 1234567890 },
584
+ { service: 'oracle', version: '1.0' },
585
+ ];
586
+
587
+ const invocation = Client.invoke({
588
+ issuer: root.signer,
589
+ audience: ed25519.Verifier.parse(server.did),
590
+ capability: {
591
+ can: 'test/read' as const,
592
+ with: 'ixo:resource:123' as const,
593
+ },
594
+ facts,
595
+ proofs: [],
596
+ });
597
+
598
+ const serialized = await serializeInvocation(invocation);
599
+ const result = await validator.validate(
600
+ serialized,
601
+ TestRead,
602
+ 'ixo:resource:123',
603
+ );
604
+
605
+ expect(result.ok).toBe(true);
606
+ expect(result.facts).toBeDefined();
607
+ expect(result.facts).toHaveLength(2);
608
+ expect(result.facts).toEqual(facts);
609
+ });
610
+
611
+ it('should return undefined facts when none are attached', async () => {
612
+ const server = await keygen();
613
+ const root = await keygen();
614
+
615
+ const validator = await createUCANValidator({
616
+ serverDid: server.did,
617
+ rootIssuers: [root.did],
618
+ });
619
+
620
+ const invocation = Client.invoke({
621
+ issuer: root.signer,
622
+ audience: ed25519.Verifier.parse(server.did),
623
+ capability: {
624
+ can: 'test/read' as const,
625
+ with: 'ixo:resource:123' as const,
626
+ },
627
+ proofs: [],
628
+ });
629
+
630
+ const serialized = await serializeInvocation(invocation);
631
+ const result = await validator.validate(
632
+ serialized,
633
+ TestRead,
634
+ 'ixo:resource:123',
635
+ );
636
+
637
+ expect(result.ok).toBe(true);
638
+ expect(result.facts).toBeUndefined();
639
+ });
640
+
641
+ it('should pass facts through createInvocation helper', async () => {
642
+ const server = await keygen();
643
+ const root = await keygen();
644
+ const user = await keygen();
645
+
646
+ const validator = await createUCANValidator({
647
+ serverDid: server.did,
648
+ rootIssuers: [root.did],
649
+ });
650
+
651
+ const facts = [{ requestId: 'abc-123', origin: 'portal' }];
652
+
653
+ const delegation = await createDelegation({
654
+ issuer: root.signer,
655
+ audience: user.did,
656
+ capabilities: [
657
+ {
658
+ can: 'test/read' as Capability['can'],
659
+ with: 'ixo:resource:123' as Capability['with'],
660
+ },
661
+ ],
662
+ });
663
+
664
+ const invocation = await createInvocation({
665
+ issuer: user.signer,
666
+ audience: server.did,
667
+ capability: {
668
+ can: 'test/read' as Capability['can'],
669
+ with: 'ixo:resource:123' as Capability['with'],
670
+ },
671
+ proofs: [delegation],
672
+ facts,
673
+ });
674
+
675
+ const serialized = await serializeInvocation(invocation);
676
+ const result = await validator.validate(
677
+ serialized,
678
+ TestRead,
679
+ 'ixo:resource:123',
680
+ );
681
+
682
+ expect(result.ok).toBe(true);
683
+ expect(result.facts).toEqual(facts);
684
+ });
685
+
686
+ it('should pass facts through createDelegation helper', async () => {
687
+ const server = await keygen();
688
+ const root = await keygen();
689
+ const user = await keygen();
690
+
691
+ const validator = await createUCANValidator({
692
+ serverDid: server.did,
693
+ rootIssuers: [root.did],
694
+ });
695
+
696
+ const delegationFacts = [{ purpose: 'oracle-access', level: 'standard' }];
697
+
698
+ const delegation = await createDelegation({
699
+ issuer: root.signer,
700
+ audience: user.did,
701
+ capabilities: [
702
+ {
703
+ can: 'test/read' as Capability['can'],
704
+ with: 'ixo:resource:123' as Capability['with'],
705
+ },
706
+ ],
707
+ facts: delegationFacts,
708
+ });
709
+
710
+ // Verify facts are on the delegation itself
711
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
712
+ expect((delegation as any).facts).toEqual(delegationFacts);
713
+
714
+ // Invocation without facts — facts on delegation don't propagate to result
715
+ const invocation = await createInvocation({
716
+ issuer: user.signer,
717
+ audience: server.did,
718
+ capability: {
719
+ can: 'test/read' as Capability['can'],
720
+ with: 'ixo:resource:123' as Capability['with'],
721
+ },
722
+ proofs: [delegation],
723
+ });
724
+
725
+ const serialized = await serializeInvocation(invocation);
726
+ const result = await validator.validate(
727
+ serialized,
728
+ TestRead,
729
+ 'ixo:resource:123',
730
+ );
731
+
732
+ expect(result.ok).toBe(true);
733
+ // Result facts come from the invocation, not the delegation
734
+ expect(result.facts).toBeUndefined();
735
+ });
736
+ });
737
+
571
738
  describe('replay protection', () => {
572
739
  it('should reject replayed invocations', async () => {
573
740
  const server = await keygen();
@@ -608,4 +775,339 @@ describe('UCAN Validator', () => {
608
775
  expect(result2.error?.code).toBe('REPLAY');
609
776
  });
610
777
  });
778
+
779
+ describe('validateDelegation', () => {
780
+ it('should validate a simple delegation with did:key', async () => {
781
+ const server = await keygen();
782
+ const user = await keygen();
783
+
784
+ const validator = await createUCANValidator({
785
+ serverDid: server.did,
786
+ rootIssuers: [user.did],
787
+ });
788
+
789
+ const delegation = await createDelegation({
790
+ issuer: user.signer,
791
+ audience: server.did,
792
+ capabilities: [
793
+ {
794
+ can: '*' as Capability['can'],
795
+ with: 'ixo:oracle' as Capability['with'],
796
+ },
797
+ ],
798
+ expiration: Math.floor(Date.now() / 1000) + 3600,
799
+ });
800
+
801
+ const serialized = await serializeDelegation(delegation);
802
+ const result = await validator.validateDelegation(serialized);
803
+
804
+ expect(result.ok).toBe(true);
805
+ expect(result.invoker).toBe(user.did);
806
+ expect(result.capability?.can).toBe('*');
807
+ expect(result.capability?.with).toBe('ixo:oracle');
808
+ expect(result.proofChain).toEqual([user.did]);
809
+ });
810
+
811
+ it('should validate a delegation with non-did:key issuer (withDID)', async () => {
812
+ const server = await keygen();
813
+ const userKey = await keygen();
814
+ // Simulate a did:ixo issuer (signer with overridden DID)
815
+ const ixoDid = 'did:ixo:ixo1testuser123' as const;
816
+ const signer = userKey.signer.withDID(ixoDid);
817
+
818
+ const validator = await createUCANValidator({
819
+ serverDid: server.did,
820
+ rootIssuers: [ixoDid],
821
+ // Provide a resolver that maps did:ixo -> did:key
822
+ didResolver: async (did) => {
823
+ if (did === ixoDid) return { ok: [userKey.did] };
824
+ return { error: { name: 'NotFound', did, message: 'Unknown DID' } };
825
+ },
826
+ });
827
+
828
+ const delegation = await createDelegation({
829
+ issuer: signer,
830
+ audience: server.did,
831
+ capabilities: [
832
+ {
833
+ can: '*' as Capability['can'],
834
+ with: 'ixo:oracle' as Capability['with'],
835
+ },
836
+ ],
837
+ expiration: Math.floor(Date.now() / 1000) + 3600,
838
+ });
839
+
840
+ const serialized = await serializeDelegation(delegation);
841
+ const result = await validator.validateDelegation(serialized);
842
+
843
+ expect(result.ok).toBe(true);
844
+ expect(result.invoker).toBe(ixoDid);
845
+ expect(result.proofChain).toEqual([ixoDid]);
846
+ });
847
+
848
+ it('should reject delegation with wrong audience', async () => {
849
+ const server = await keygen();
850
+ const wrongServer = await keygen();
851
+ const user = await keygen();
852
+
853
+ const validator = await createUCANValidator({
854
+ serverDid: server.did,
855
+ rootIssuers: [user.did],
856
+ });
857
+
858
+ const delegation = await createDelegation({
859
+ issuer: user.signer,
860
+ audience: wrongServer.did,
861
+ capabilities: [
862
+ {
863
+ can: '*' as Capability['can'],
864
+ with: 'ixo:oracle' as Capability['with'],
865
+ },
866
+ ],
867
+ });
868
+
869
+ const serialized = await serializeDelegation(delegation);
870
+ const result = await validator.validateDelegation(serialized);
871
+
872
+ expect(result.ok).toBe(false);
873
+ expect(result.error?.code).toBe('UNAUTHORIZED');
874
+ });
875
+
876
+ it('should reject expired delegation', async () => {
877
+ const server = await keygen();
878
+ const user = await keygen();
879
+
880
+ const validator = await createUCANValidator({
881
+ serverDid: server.did,
882
+ rootIssuers: [user.did],
883
+ });
884
+
885
+ const delegation = await createDelegation({
886
+ issuer: user.signer,
887
+ audience: server.did,
888
+ capabilities: [
889
+ {
890
+ can: '*' as Capability['can'],
891
+ with: 'ixo:oracle' as Capability['with'],
892
+ },
893
+ ],
894
+ expiration: Math.floor(Date.now() / 1000) - 60, // expired 1 minute ago
895
+ });
896
+
897
+ const serialized = await serializeDelegation(delegation);
898
+ const result = await validator.validateDelegation(serialized);
899
+
900
+ expect(result.ok).toBe(false);
901
+ expect(result.error?.code).toBe('EXPIRED');
902
+ });
903
+
904
+ it('should reject delegation with tampered signature', async () => {
905
+ const server = await keygen();
906
+ const user = await keygen();
907
+ const attacker = await keygen();
908
+
909
+ const validator = await createUCANValidator({
910
+ serverDid: server.did,
911
+ rootIssuers: [user.did],
912
+ });
913
+
914
+ // Attacker creates delegation pretending to be user
915
+ // but signing with their own key (signature won't match user's DID)
916
+ const delegation = await createDelegation({
917
+ issuer: attacker.signer.withDID(user.did),
918
+ audience: server.did,
919
+ capabilities: [
920
+ {
921
+ can: '*' as Capability['can'],
922
+ with: 'ixo:oracle' as Capability['with'],
923
+ },
924
+ ],
925
+ expiration: Math.floor(Date.now() / 1000) + 3600,
926
+ });
927
+
928
+ const serialized = await serializeDelegation(delegation);
929
+ const result = await validator.validateDelegation(serialized);
930
+
931
+ expect(result.ok).toBe(false);
932
+ expect(result.error?.code).toBe('INVALID_SIGNATURE');
933
+ });
934
+
935
+ it('should reject malformed base64 input', async () => {
936
+ const server = await keygen();
937
+
938
+ const validator = await createUCANValidator({
939
+ serverDid: server.did,
940
+ rootIssuers: [],
941
+ });
942
+
943
+ const result = await validator.validateDelegation('not-valid-base64!!!');
944
+
945
+ expect(result.ok).toBe(false);
946
+ expect(result.error?.code).toBe('INVALID_FORMAT');
947
+ });
948
+
949
+ it('should validate delegation chain (root -> user -> server)', async () => {
950
+ const server = await keygen();
951
+ const root = await keygen();
952
+ const user = await keygen();
953
+
954
+ const validator = await createUCANValidator({
955
+ serverDid: server.did,
956
+ rootIssuers: [root.did],
957
+ });
958
+
959
+ // Root delegates to user
960
+ const rootToUser = await Client.delegate({
961
+ issuer: root.signer,
962
+ audience: user.signer,
963
+ capabilities: [
964
+ {
965
+ can: '*' as const,
966
+ with: 'ixo:oracle' as const,
967
+ },
968
+ ],
969
+ expiration: Math.floor(Date.now() / 1000) + 7200,
970
+ });
971
+
972
+ // User re-delegates to server (with proof of root delegation)
973
+ const userToServer = await createDelegation({
974
+ issuer: user.signer,
975
+ audience: server.did,
976
+ capabilities: [
977
+ {
978
+ can: '*' as Capability['can'],
979
+ with: 'ixo:oracle' as Capability['with'],
980
+ },
981
+ ],
982
+ expiration: Math.floor(Date.now() / 1000) + 3600,
983
+ proofs: [rootToUser],
984
+ });
985
+
986
+ const serialized = await serializeDelegation(userToServer);
987
+ const result = await validator.validateDelegation(serialized);
988
+
989
+ expect(result.ok).toBe(true);
990
+ expect(result.invoker).toBe(user.did);
991
+ expect(result.proofChain).toEqual([root.did, user.did]);
992
+ });
993
+
994
+ it('should return effective expiration across delegation chain', async () => {
995
+ const server = await keygen();
996
+ const root = await keygen();
997
+ const user = await keygen();
998
+
999
+ const laterExp = Math.floor(Date.now() / 1000) + 7200; // 2 hours
1000
+ const earlierExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour
1001
+
1002
+ const validator = await createUCANValidator({
1003
+ serverDid: server.did,
1004
+ rootIssuers: [root.did],
1005
+ });
1006
+
1007
+ // Root -> user with later expiration
1008
+ const rootToUser = await Client.delegate({
1009
+ issuer: root.signer,
1010
+ audience: user.signer,
1011
+ capabilities: [
1012
+ {
1013
+ can: '*' as const,
1014
+ with: 'ixo:oracle' as const,
1015
+ },
1016
+ ],
1017
+ expiration: laterExp,
1018
+ });
1019
+
1020
+ // User -> server with earlier expiration
1021
+ const userToServer = await createDelegation({
1022
+ issuer: user.signer,
1023
+ audience: server.did,
1024
+ capabilities: [
1025
+ {
1026
+ can: '*' as Capability['can'],
1027
+ with: 'ixo:oracle' as Capability['with'],
1028
+ },
1029
+ ],
1030
+ expiration: earlierExp,
1031
+ proofs: [rootToUser],
1032
+ });
1033
+
1034
+ const serialized = await serializeDelegation(userToServer);
1035
+ const result = await validator.validateDelegation(serialized);
1036
+
1037
+ expect(result.ok).toBe(true);
1038
+ expect(result.expiration).toBeDefined();
1039
+ expect(result.expiration).toBeLessThanOrEqual(earlierExp);
1040
+ });
1041
+
1042
+ it('should reject delegation with broken proof chain', async () => {
1043
+ const server = await keygen();
1044
+ const root = await keygen();
1045
+ const user = await keygen();
1046
+ const unrelated = await keygen();
1047
+
1048
+ const validator = await createUCANValidator({
1049
+ serverDid: server.did,
1050
+ rootIssuers: [root.did],
1051
+ });
1052
+
1053
+ // Root delegates to an unrelated party (not user)
1054
+ const rootToUnrelated = await Client.delegate({
1055
+ issuer: root.signer,
1056
+ audience: unrelated.signer,
1057
+ capabilities: [
1058
+ {
1059
+ can: '*' as const,
1060
+ with: 'ixo:oracle' as const,
1061
+ },
1062
+ ],
1063
+ });
1064
+
1065
+ // User tries to use unrelated's delegation as proof (audience mismatch)
1066
+ const userToServer = await createDelegation({
1067
+ issuer: user.signer,
1068
+ audience: server.did,
1069
+ capabilities: [
1070
+ {
1071
+ can: '*' as Capability['can'],
1072
+ with: 'ixo:oracle' as Capability['with'],
1073
+ },
1074
+ ],
1075
+ proofs: [rootToUnrelated],
1076
+ });
1077
+
1078
+ const serialized = await serializeDelegation(userToServer);
1079
+ const result = await validator.validateDelegation(serialized);
1080
+
1081
+ expect(result.ok).toBe(false);
1082
+ expect(result.error?.code).toBe('UNAUTHORIZED');
1083
+ });
1084
+
1085
+ it('should return undefined expiration for non-expiring delegation', async () => {
1086
+ const server = await keygen();
1087
+ const user = await keygen();
1088
+
1089
+ const validator = await createUCANValidator({
1090
+ serverDid: server.did,
1091
+ rootIssuers: [user.did],
1092
+ });
1093
+
1094
+ const delegation = await createDelegation({
1095
+ issuer: user.signer,
1096
+ audience: server.did,
1097
+ capabilities: [
1098
+ {
1099
+ can: '*' as Capability['can'],
1100
+ with: 'ixo:oracle' as Capability['with'],
1101
+ },
1102
+ ],
1103
+ // No expiration = Infinity = no effective expiration
1104
+ });
1105
+
1106
+ const serialized = await serializeDelegation(delegation);
1107
+ const result = await validator.validateDelegation(serialized);
1108
+
1109
+ expect(result.ok).toBe(true);
1110
+ expect(result.expiration).toBeUndefined();
1111
+ });
1112
+ });
611
1113
  });