package profile_test import ( "bytes" "encoding/hex" "errors" "testing" "time" datekeys "g.activething.com/go/DateKeys" "g.activething.com/go/DateKeys/codec" "g.activething.com/go/DateKeys/internal/testkit" "g.activething.com/go/DateKeys/profile" ) func TestQuicknetMatchesGoldenVector(t *testing.T) { var golden testkit.ProfileVector if err := testkit.ReadJSON("../testdata/vectors/profile_quicknet.json", &golden); err != nil { t.Fatal(err) } got, err := testkit.QuicknetProfileVector() if err != nil { t.Fatal(err) } if got != golden { t.Fatalf("Quicknet profile vector changed:\n got %+v\nwant %+v", got, golden) } if golden.ProfileHash != profile.QuicknetProfileHash { t.Fatalf("pinned hash %s differs from golden %s", profile.QuicknetProfileHash, golden.ProfileHash) } // Spec §12 values, restated independently of the constants. p := profile.Quicknet() if p.ID != "datekeys:quicknet:v1" || p.Provider != "drand" || p.Network != "quicknet" || p.Period != 3*time.Second || p.GenesisTime != 1692803367 || p.Scheme != "bls-unchained-g1-rfc9380" || p.ChainHashHex() != "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971" || hex.EncodeToString(p.GenesisSeed[:]) != "f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e" { t.Fatal("Quicknet parameters differ from spec §12") } } func TestQuicknetCBORLayout(t *testing.T) { b, err := profile.Quicknet().CanonicalCBOR() if err != nil { t.Fatal(err) } // Map of 11 entries whose keys 0..10 appear in order (spec §11). if b[0] != 0xab { t.Fatalf("map header %#x", b[0]) } var m map[uint64]any if err := codec.Unmarshal(b, &m); err != nil { t.Fatal(err) } want := map[uint64]any{0: "datekeys-provider-profile", 1: uint64(1), 2: "datekeys:quicknet:v1", 3: "drand", 4: "quicknet", 7: uint64(3), 8: uint64(1692803367), 9: "bls-unchained-g1-rfc9380"} for k, v := range want { if m[k] != v { t.Errorf("key %d = %v, want %v", k, m[k], v) } } for _, k := range []uint64{5, 6, 10} { if _, ok := m[k].([]byte); !ok { t.Errorf("key %d is not a byte string", k) } } } func TestDecodeRoundTrip(t *testing.T) { b, _ := profile.Quicknet().CanonicalCBOR() p, err := profile.Decode(b) if err != nil { t.Fatal(err) } b2, _ := p.CanonicalCBOR() if !bytes.Equal(b, b2) { t.Fatal("Decode/CanonicalCBOR is not the identity") } // The same values with a different key order or integer width are rejected. period, genesis := []byte{0x07, 0x03}, []byte{0x08, 0x1a, 0x64, 0xe6, 0x21, 0x27} pair := append(append([]byte(nil), period...), genesis...) if !bytes.Contains(b, pair) { t.Fatal("unexpected layout") } swapped := bytes.Replace(b, pair, append(append([]byte(nil), genesis...), period...), 1) widened := bytes.Replace(b, period, []byte{0x07, 0x18, 0x03}, 1) for name, in := range map[string][]byte{"keys out of order": swapped, "integer not in shortest form": widened} { if _, err := profile.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) { t.Errorf("%s accepted: %v", name, err) } } future, _ := codec.Marshal(map[uint64]any{0: profile.TypeTag, 1: uint64(2)}) if _, err := profile.Decode(future); !errors.Is(err, datekeys.ErrUnsupportedVersion) { t.Fatalf("future schema: %v", err) } } // Spec §11, §58: period in 1..2^53-1 and genesis_time in 0..2^53-1, both // unsigned. Out of the schema is ErrNonCanonicalCBOR, whatever Validate would // say of the value. func TestIntegerRanges(t *testing.T) { b, _ := profile.Quicknet().CanonicalCBOR() var m map[uint64]any if err := codec.Unmarshal(b, &m); err != nil { t.Fatal(err) } for name, v := range map[string]struct { key uint64 val any }{ "negative genesis_time": {8, int64(-1)}, "genesis_time 2^53": {8, uint64(codec.MaxSafeUint + 1)}, "period 2^53": {7, uint64(codec.MaxSafeUint + 1)}, "period 0": {7, uint64(0)}, "period above one day": {7, uint64(86401)}, } { c := map[uint64]any{} for k, x := range m { c[k] = x } c[v.key] = v.val in, err := codec.Marshal(c) if err != nil { t.Fatal(err) } if _, err := profile.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) { t.Errorf("%s: %v", name, err) } } // The encoder refuses the same values with the same code. for _, g := range []int64{-1, codec.MaxSafeUint + 1} { p := profile.Quicknet() p.GenesisTime = g if _, err := p.CanonicalCBOR(); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) { t.Errorf("genesis time %d encoded: %v", g, err) } } } func TestValidateRejectsTamperedProfiles(t *testing.T) { for _, tc := range []struct { name string mutate func(p *profile.Profile) want error }{ {"chain hash changed", func(p *profile.Profile) { p.ChainHash[0] ^= 1 }, datekeys.ErrProfileMismatch}, {"genesis seed changed", func(p *profile.Profile) { p.GenesisSeed[0] ^= 1 }, datekeys.ErrProfileMismatch}, {"genesis time changed", func(p *profile.Profile) { p.GenesisTime++ }, datekeys.ErrProfileMismatch}, {"period changed", func(p *profile.Profile) { p.Period = 30 * time.Second }, datekeys.ErrProfileMismatch}, {"network id changed", func(p *profile.Profile) { p.Network = "default" }, datekeys.ErrProfileMismatch}, {"public key not a point", func(p *profile.Profile) { p.PublicKey = bytes.Repeat([]byte{0xff}, 96) }, datekeys.ErrUnknownProfile}, {"public key truncated", func(p *profile.Profile) { p.PublicKey = p.PublicKey[:95] }, datekeys.ErrUnknownProfile}, {"unknown scheme", func(p *profile.Profile) { p.Scheme = "bls-unchained-g9" }, datekeys.ErrUnknownProfile}, {"scheme without tlock support", func(p *profile.Profile) { p.Scheme = "pedersen-bls-chained" }, datekeys.ErrUnknownProfile}, {"unknown provider", func(p *profile.Profile) { p.Provider = "roughtime" }, datekeys.ErrUnknownProfile}, {"sub-second period", func(p *profile.Profile) { p.Period = 1500 * time.Millisecond }, datekeys.ErrUnknownProfile}, {"uppercase profile id", func(p *profile.Profile) { p.ID = "DateKeys:quicknet:v1" }, datekeys.ErrUnknownProfile}, {"profile id with quote", func(p *profile.Profile) { p.ID = `datekeys:"quicknet` }, datekeys.ErrUnknownProfile}, // Spec §57: the Provider Profile names and public key outside their // CDDL rules are ERR_UNKNOWN_PROFILE. {"uppercase provider", func(p *profile.Profile) { p.Provider = "Drand" }, datekeys.ErrUnknownProfile}, {"empty public key", func(p *profile.Profile) { p.PublicKey = []byte{} }, datekeys.ErrUnknownProfile}, {"public key above 1024 bytes", func(p *profile.Profile) { p.PublicKey = make([]byte, 1025) }, datekeys.ErrUnknownProfile}, } { t.Run(tc.name, func(t *testing.T) { p := profile.Quicknet() tc.mutate(p) if err := p.Validate(); !errors.Is(err, tc.want) { t.Fatalf("got %v, want %v", err, tc.want) } }) } } func TestRegistry(t *testing.T) { reg, err := profile.Default() if err != nil { t.Fatal(err) } p, ok := reg.Lookup(profile.QuicknetID) if !ok { t.Fatal("Quicknet not pinned") } // Lookup hands out copies: mutating one does not alter the registry. p.PublicKey[0] ^= 0xff p.ChainHash[0] ^= 0xff again, _ := reg.Lookup(profile.QuicknetID) if err := again.Validate(); err != nil { t.Fatalf("registry state was mutated through a lookup: %v", err) } if _, ok := reg.Lookup("datekeys:evmnet:v1"); ok { t.Fatal("unknown profile found") } var wrong [32]byte if _, err := profile.NewRegistry(profile.Pin{Profile: profile.Quicknet(), Hash: wrong}); !errors.Is(err, datekeys.ErrProfileMismatch) { t.Fatalf("wrong pinned hash accepted: %v", err) } h, _ := profile.Quicknet().Hash() if _, err := profile.NewRegistry(profile.Pin{Profile: profile.Quicknet(), Hash: h}, profile.Pin{Profile: profile.Quicknet(), Hash: h}); err == nil { t.Fatal("duplicate profile accepted") } bad := profile.Quicknet() bad.ChainHash[31] ^= 1 bh, _ := bad.Hash() if _, err := profile.NewRegistry(profile.Pin{Profile: bad, Hash: bh}); !errors.Is(err, datekeys.ErrProfileMismatch) { t.Fatalf("self-inconsistent profile pinned: %v", err) } } func TestMaxRound(t *testing.T) { p := profile.Quicknet() if got := p.MaxRound(); got != 83903165811 { t.Fatalf("MaxRound = %d", got) } } func FuzzDecode(f *testing.F) { b, _ := profile.Quicknet().CanonicalCBOR() f.Add(b) f.Add(b[:100]) f.Fuzz(func(t *testing.T, in []byte) { p, err := profile.Decode(in) if err != nil { if datekeys.Code(err) == "" { t.Fatalf("error without a normative code: %v", err) } return } out, err := p.CanonicalCBOR() if err != nil || !bytes.Equal(out, in) { t.Fatal("accepted a profile that does not re-encode to its input") } }) }