package profile_test import ( "bytes" "crypto/sha256" "encoding/binary" "encoding/hex" "errors" "maps" "math/big" "strings" "testing" "time" "github.com/drand/drand/v2/crypto" datekeys "g.activething.com/go/DateKeys" "g.activething.com/go/DateKeys/internal/cbortest" "g.activething.com/go/DateKeys/profile" ) // Spec §12.1, §69.1: a Provider Profile reports the code of its first failing // layer: the type tag and the schema version, then the CDDL with the // implementation limit on period (ERR_NON_CANONICAL_CBOR), then the rules // of its fields (ERR_UNKNOWN_PROFILE), and last the chain-hash self-check, // which relates several keys (ERR_PROFILE_MISMATCH). func TestDecodePrecedence(t *testing.T) { b, _ := profile.Quicknet().CanonicalCBOR() m, err := cbortest.UnmarshalMap(b) if err != nil { t.Fatal(err) } encode := func(edit func(c map[uint64]any)) []byte { c := maps.Clone(m) edit(c) out, err := cbortest.Marshal(c) if err != nil { t.Fatal(err) } return out } chain := func(c map[uint64]any) { c[5] = make([]byte, 32) } for _, tc := range []struct { name string edit func(c map[uint64]any) want error }{ {"type tag of another schema and version 2", func(c map[uint64]any) { c[0], c[1] = "datekeycap", uint64(2) }, datekeys.ErrNonCanonicalCBOR}, {"version 2, unknown key and invalid provider", func(c map[uint64]any) { c[1], c[11], c[3] = uint64(2), uint64(0), "Drand" }, datekeys.ErrUnsupportedVersion}, {"period above the implementation limit and invalid provider", func(c map[uint64]any) { c[7], c[3] = uint64(86401), "Drand" }, datekeys.ErrNonCanonicalCBOR}, {"network of another CBOR type", func(c map[uint64]any) { c[4] = uint64(7) }, datekeys.ErrNonCanonicalCBOR}, {"public key of another CBOR type and chain hash", func(c map[uint64]any) { c[6] = "key"; chain(c) }, datekeys.ErrNonCanonicalCBOR}, {"invalid provider and chain hash", func(c map[uint64]any) { c[3] = "Drand"; chain(c) }, datekeys.ErrUnknownProfile}, {"public key above 1024 bytes and chain hash", func(c map[uint64]any) { c[6] = make([]byte, 1025); chain(c) }, datekeys.ErrUnknownProfile}, {"genesis_time 0 and chain hash", func(c map[uint64]any) { c[8] = uint64(0); chain(c) }, datekeys.ErrUnknownProfile}, {"genesis_time 9999-12-31T23:59:59Z and chain hash", func(c map[uint64]any) { c[8] = uint64(profile.MaxUnixTime); chain(c) }, datekeys.ErrUnknownProfile}, {"scheme without tlock support and chain hash", func(c map[uint64]any) { c[9] = "pedersen-bls-chained"; chain(c) }, datekeys.ErrUnknownProfile}, {"public key not a point and chain hash", func(c map[uint64]any) { c[6] = bytes.Repeat([]byte{0xff}, 96); chain(c) }, datekeys.ErrUnknownProfile}, {"public key the identity and chain hash", func(c map[uint64]any) { k := make([]byte, 96); k[0] = 0xc0; c[6] = k; chain(c) }, datekeys.ErrUnknownProfile}, // Spec §12.1 rule 2: the key is a point of the prime-order subgroup. // pedersen-bls-unchained has its keys on G1 (48 bytes); its generator // passes rule 2 and only the chain hash fails, while a point of the // curve outside the subgroup fails rule 2. {"G1 generator and chain hash", func(c map[uint64]any) { c[9], c[6] = "pedersen-bls-unchained", g1Generator(t); chain(c) }, datekeys.ErrProfileMismatch}, {"G1 point outside the prime-order subgroup and chain hash", func(c map[uint64]any) { c[9], c[6] = "pedersen-bls-unchained", offSubgroupG1(t); chain(c) }, datekeys.ErrUnknownProfile}, {"chain hash alone", chain, datekeys.ErrProfileMismatch}, } { if _, err := profile.Decode(encode(tc.edit)); !errors.Is(err, tc.want) { t.Errorf("%s: got %v, want %v", tc.name, err, tc.want) } } } // Spec §12.1: chain_hash is the drand chain-info hash (drand specification, // Root of trust): // // SHA-256(uint32_be(period) || int64_be(genesis_time) || public_key || // genesis_seed || network) // // with network left out when it is "default". Computed here without drand. func TestChainHashFormula(t *testing.T) { sum := chainHashFormula q := profile.Quicknet() if got := hex.EncodeToString(sum(q)); got != profile.QuicknetChainHash { t.Fatalf("formula gives %s, Quicknet chain_hash is %s", got, profile.QuicknetChainHash) } if err := q.Validate(); err != nil { t.Fatal(err) } // The network "default" is not hashed. d := profile.Quicknet() d.Network = "default" copy(d.ChainHash[:], sum(d)) if err := d.Validate(); err != nil { t.Fatalf("network default: %v", err) } d.Network = "defaults" if err := d.Validate(); !errors.Is(err, datekeys.ErrProfileMismatch) { t.Fatalf("network defaults hashed like default: %v", err) } } // chainHashFormula is the chain_hash of p by the formula of spec §12.1, // computed without drand, over the exact bytes of its public key. func chainHashFormula(p *profile.Profile) []byte { var n [12]byte binary.BigEndian.PutUint32(n[:4], uint32(p.Period/time.Second)) binary.BigEndian.PutUint64(n[4:], uint64(p.GenesisTime)) h := sha256.New() h.Write(n[:]) h.Write(p.PublicKey) h.Write(p.GenesisSeed[:]) if p.Network != "default" { h.Write([]byte(p.Network)) } return h.Sum(nil) } // g1Generator is the compressed generator of G1, in the encoding of drand. func g1Generator(t *testing.T) []byte { t.Helper() s, err := crypto.SchemeFromName(crypto.UnchainedSchemeID) if err != nil { t.Fatal(err) } b, err := s.KeyGroup.Point().Base().MarshalBinary() if err != nil || len(b) != 48 { t.Fatalf("G1 generator: %d bytes, %v", len(b), err) } return b } // offSubgroupG1 returns the compressed encoding (48 bytes, flag 0x80) of the // point of the BLS12-381 curve y^2 = x^3 + 4 over Fp with the smallest x >= 1. // It is on the curve but not in the prime-order subgroup, whose cofactor is // about 2^126; the decoder of drand says so. func offSubgroupG1(t *testing.T) []byte { t.Helper() p, _ := new(big.Int).SetString("1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab", 16) half := new(big.Int).Rsh(new(big.Int).Sub(p, big.NewInt(1)), 1) for x := int64(1); x < 100; x++ { rhs := new(big.Int).Exp(big.NewInt(x), big.NewInt(3), p) rhs.Add(rhs, big.NewInt(4)).Mod(rhs, p) if new(big.Int).Exp(rhs, half, p).Cmp(big.NewInt(1)) != 0 { continue // x^3 + 4 is not a square: no point with this x } b := make([]byte, 48) big.NewInt(x).FillBytes(b) b[0] |= 0x80 s, err := crypto.SchemeFromName(crypto.UnchainedSchemeID) if err != nil { t.Fatal(err) } if err := s.KeyGroup.Point().UnmarshalBinary(b); err == nil || !strings.Contains(err.Error(), "subgroup") { t.Fatalf("x = %d: want a curve point outside the subgroup, got %v", x, err) } return b } t.Fatal("no curve point with a small x") return nil } // Spec §12.1, §69.1: a profile gets the same code when it is pinned // (NewRegistry), validated as a value (Validate) or decoded from its // encoding (Decode): the schema first, with the period limit of the // reference, then the field rules, then the chain hash, and last the pinned // profile_hash. func TestPinPathMatchesDecode(t *testing.T) { chain := func(p *profile.Profile) { p.ChainHash = [32]byte{} } for _, tc := range []struct { name string edit func(p *profile.Profile) want error }{ {"period of one day and one second", func(p *profile.Profile) { p.Period = 86401 * time.Second }, datekeys.ErrNonCanonicalCBOR}, {"period of one day and one second and provider Drand", func(p *profile.Profile) { p.Period, p.Provider = 86401*time.Second, "Drand" }, datekeys.ErrNonCanonicalCBOR}, {"sub-second period and provider Drand", func(p *profile.Profile) { p.Period, p.Provider = 1500*time.Millisecond, "Drand" }, datekeys.ErrNonCanonicalCBOR}, {"genesis_time 2^53 and provider Drand", func(p *profile.Profile) { p.GenesisTime, p.Provider = 1<<53, "Drand" }, datekeys.ErrNonCanonicalCBOR}, {"negative genesis_time", func(p *profile.Profile) { p.GenesisTime = -1 }, datekeys.ErrNonCanonicalCBOR}, {"network not valid UTF-8 and chain hash", func(p *profile.Profile) { p.Network = "quick\xffnet"; chain(p) }, datekeys.ErrNonCanonicalCBOR}, {"provider Drand and chain hash", func(p *profile.Profile) { p.Provider = "Drand"; chain(p) }, datekeys.ErrUnknownProfile}, {"genesis_time 9999-12-31T23:59:59Z and chain hash", func(p *profile.Profile) { p.GenesisTime = profile.MaxUnixTime; chain(p) }, datekeys.ErrUnknownProfile}, {"chain hash alone", chain, datekeys.ErrProfileMismatch}, } { p := profile.Quicknet() tc.edit(p) if err := p.Validate(); !errors.Is(err, tc.want) { t.Errorf("%s: Validate: got %v, want %v", tc.name, err, tc.want) } if _, err := profile.NewRegistry(profile.Pin{Profile: p}); !errors.Is(err, tc.want) { t.Errorf("%s: NewRegistry: got %v, want %v", tc.name, err, tc.want) } if b, err := p.CanonicalCBOR(); err != nil { if !errors.Is(err, tc.want) { t.Errorf("%s: CanonicalCBOR: got %v, want %v", tc.name, err, tc.want) } } else if _, err := profile.Decode(b); !errors.Is(err, tc.want) { t.Errorf("%s: Decode: got %v, want %v", tc.name, err, tc.want) } } // Rule 4 comes last: a valid profile with another pinned hash. if _, err := profile.NewRegistry(profile.Pin{Profile: profile.Quicknet()}); !errors.Is(err, datekeys.ErrProfileMismatch) { t.Errorf("another pinned profile_hash: %v", err) } }