diff --git a/policy/tlog_policy.go b/policy/tlog_policy.go new file mode 100644 index 0000000..c91db34 --- /dev/null +++ b/policy/tlog_policy.go @@ -0,0 +1,425 @@ +// Copyright 2026 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package policy provides support for parsing and evaluating transparency +// log trust policies as described in https://c2sp.org/tlog-policy. +package policy + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "strconv" + "strings" + + f_log "github.com/transparency-dev/formats/log" + f_note "github.com/transparency-dev/formats/note" + "golang.org/x/mod/sumdb/note" +) + +// quorumNone is the predefined name which, when used as the quorum, +// indicates that no cosignatures are required. +const quorumNone = "none" + +const ( + algEd25519CosignatureV1 = 4 + algMLDSA44 = 6 +) + +// Log represents a log declaration in a policy: +// +// log [] +type Log struct { + // Verifier verifies this log's checkpoint signatures. Its Name() is the + // key name from the vkey, which per the spec MUST correspond to the + // log's origin line. + Verifier note.Verifier + // VKey is the log's verifier key exactly as it appeared in the policy. + VKey string + // URL is the log's optional application-specific URL, or "" if absent. + URL string +} + +// Witness represents a witness declaration in a policy: +// +// witness [] +type Witness struct { + // Name identifies this witness within the policy only. + Name string + // Verifier verifies this witness' cosignatures. + Verifier note.Verifier + // VKey is the witness' verifier key exactly as it appeared in the policy. + VKey string + // URL is the witness' optional application-specific URL, or "" if absent. + URL string +} + +// Group represents a group declaration in a policy: +// +// group ... +type Group struct { + // Name identifies this group within the policy only. + Name string + // Threshold is the number of members which must have witnessed a + // checkpoint for the group to be considered to have witnessed it. + // The "any" and "all" keywords are resolved to 1 and len(Members) + // respectively during parsing, so 1 <= Threshold <= len(Members). + Threshold int + // Members are the names of the witnesses and groups this group is + // composed of. Members always refer to entries defined on earlier + // lines of the policy. + Members []string +} + +// TLogPolicy represents a transparency log trust policy as described in +// https://c2sp.org/tlog-policy. +type TLogPolicy struct { + // Logs are the known logs; a checkpoint must be signed by any one of + // them to be considered valid. May be empty if the applicable log(s) + // are known from other context. + Logs []Log + // Witnesses are the known witnesses, in order of definition. + Witnesses []Witness + // Groups are the witness groups, in order of definition. + Groups []Group + // Quorum is the name of the witness or group whose witnessing of a + // checkpoint makes the checkpoint valid, or "none" if no cosignatures + // are required. + Quorum string +} + +// Unmarshal parses and validates policy text. +// +// The full syntax and structure described by the spec is enforced, e.g.: +// only tab and newline control characters are permitted, comments must be +// whole lines, group members must be defined on earlier lines and may be +// listed as a member at most once, thresholds must be within [1, n], no two +// logs (or witnesses) may share a public key, and exactly one quorum must +// be defined after everything it references. +// +// Witness keys must be tlog-cosignature verifier keys: key types 0x04 +// (Ed25519 cosignature/v1) and 0x06 (ML-DSA-44) are supported, and any +// other key type, including plain Ed25519 (0x01), is rejected. +// Log keys may use any note signature algorithm known to the note +// package, since tlog-checkpoint permits logs to use any note signature +// algorithm; unknown key types are rejected. +// Any name except "none" is accepted for witnesses and groups, including +// otherwise-reserved words such as "any"; the grammar is positional so +// such names are unambiguous. +// +// p is only modified if the policy is valid, in which case any previous +// contents are replaced. +func (p *TLogPolicy) Unmarshal(data []byte) error { + if err := checkCharset(data); err != nil { + return err + } + + var out TLogPolicy + // defined tracks the shared witness/group namespace, mapping each name + // to true once its definition line has been processed. + defined := map[string]bool{} + // member tracks names already listed as a group member; the spec allows + // each name to be a member at most once across the whole policy. + member := map[string]bool{} + logKeys := map[string]bool{} + witnessKeys := map[string]bool{} + quorumDefined := false + + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + fs := splitFields(scanner.Text()) + if len(fs) == 0 || strings.HasPrefix(fs[0], "#") { + continue + } + line := scanner.Text() + switch fs[0] { + case "log": + if len(fs) < 2 || len(fs) > 3 { + return fmt.Errorf("invalid log definition: %q", line) + } + vkey := fs[1] + // tlog-checkpoint permits logs to use any note signature + // algorithm, so accept every key type the note package knows. + v, err := f_note.NewVerifier(vkey) + if err != nil { + return fmt.Errorf("invalid log vkey in %q: %w", line, err) + } + key, err := vkeyPublicKey(vkey) + if err != nil { + return fmt.Errorf("invalid log vkey in %q: %w", line, err) + } + if logKeys[string(key)] { + return fmt.Errorf("duplicate log public key: %q", vkey) + } + logKeys[string(key)] = true + l := Log{Verifier: v, VKey: vkey} + if len(fs) == 3 { + l.URL = fs[2] + } + out.Logs = append(out.Logs, l) + case "witness": + if len(fs) < 3 || len(fs) > 4 { + return fmt.Errorf("invalid witness definition: %q", line) + } + name, vkey := fs[1], fs[2] + if name == quorumNone { + return fmt.Errorf("invalid witness name %q: name is reserved", name) + } + if defined[name] { + return fmt.Errorf("duplicate name: %q", name) + } + key, err := vkeyPublicKey(vkey) + if err != nil { + return fmt.Errorf("invalid witness vkey in %q: %w", line, err) + } + // TODO: remove this check if NewVerifierForCosignatureV1 is + // changed to reject non-cosignature key types itself. + if key[0] != algEd25519CosignatureV1 && key[0] != algMLDSA44 { + return fmt.Errorf("invalid witness vkey in %q: key type 0x%02x is not a cosignature key type", line, key[0]) + } + v, err := f_note.NewVerifierForCosignatureV1(vkey) + if err != nil { + return fmt.Errorf("invalid witness vkey in %q: %w", line, err) + } + if witnessKeys[string(key)] { + return fmt.Errorf("duplicate witness public key: %q", vkey) + } + witnessKeys[string(key)] = true + w := Witness{Name: name, Verifier: v, VKey: vkey} + if len(fs) == 4 { + w.URL = fs[3] + } + defined[name] = true + out.Witnesses = append(out.Witnesses, w) + case "group": + if len(fs) < 4 { + return fmt.Errorf("invalid group definition: %q", line) + } + name, threshold, members := fs[1], fs[2], fs[3:] + if name == quorumNone { + return fmt.Errorf("invalid group name %q: name is reserved", name) + } + if defined[name] { + return fmt.Errorf("duplicate name: %q", name) + } + var k int + switch threshold { + case "any": + k = 1 + case "all": + k = len(members) + default: + u, err := strconv.ParseUint(threshold, 10, 31) + if err != nil { + return fmt.Errorf("invalid threshold %q for group %q: %w", threshold, name, err) + } + k = int(u) + } + if k < 1 || k > len(members) { + return fmt.Errorf("threshold %d for group %q outside [1, %d]", k, name, len(members)) + } + for _, m := range members { + if !defined[m] { + return fmt.Errorf("unknown member %q in group %q", m, name) + } + if member[m] { + return fmt.Errorf("%q cannot be listed as a group member more than once", m) + } + member[m] = true + } + defined[name] = true + out.Groups = append(out.Groups, Group{Name: name, Threshold: k, Members: members}) + case "quorum": + if len(fs) != 2 { + return fmt.Errorf("invalid quorum definition: %q", line) + } + if quorumDefined { + return fmt.Errorf("policy must include exactly one quorum line") + } + quorumDefined = true + name := fs[1] + if name != quorumNone && !defined[name] { + return fmt.Errorf("quorum %q not defined", name) + } + out.Quorum = name + default: + return fmt.Errorf("unknown keyword: %q", fs[0]) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("scanning policy: %w", err) + } + if !quorumDefined { + return fmt.Errorf("policy must include exactly one quorum line") + } + + *p = out + return nil +} + +// Marshal serialises the policy in a canonical form which round-trips +// through Unmarshal. It is not byte-preserving: comments and blank lines +// are not represented, declarations are grouped by type, and thresholds +// are always written numerically. +func (p TLogPolicy) Marshal() []byte { + var b bytes.Buffer + for _, l := range p.Logs { + b.WriteString("log " + l.VKey) + if l.URL != "" { + b.WriteString(" " + l.URL) + } + b.WriteByte('\n') + } + for _, w := range p.Witnesses { + b.WriteString("witness " + w.Name + " " + w.VKey) + if w.URL != "" { + b.WriteString(" " + w.URL) + } + b.WriteByte('\n') + } + for _, g := range p.Groups { + fmt.Fprintf(&b, "group %s %d %s\n", g.Name, g.Threshold, strings.Join(g.Members, " ")) + } + b.WriteString("quorum " + p.Quorum + "\n") + return b.Bytes() +} + +// Satisfied returns true if the checkpoint provided is cosigned by +// witnesses according to the policy's quorum rule. +// This will return false if there are insufficient cosignatures, and also +// if the checkpoint cannot be read as a valid note. It is up to the caller +// to ensure that the input value represents a valid note. +// +// Note that Satisfied does not require a log signature; use Verify to +// apply the policy's full checkpoint validity rule. +func (p TLogPolicy) Satisfied(checkpoint []byte) bool { + if p.Quorum == quorumNone { + return true + } + witnesses := make(map[string]Witness, len(p.Witnesses)) + for _, w := range p.Witnesses { + witnesses[w.Name] = w + } + groups := make(map[string]Group, len(p.Groups)) + for _, g := range p.Groups { + groups[g.Name] = g + } + + visiting := make(map[string]bool) + var satisfied func(name string) bool + satisfied = func(name string) bool { + if w, ok := witnesses[name]; ok { + n, err := note.Open(checkpoint, note.VerifierList(w.Verifier)) + return err == nil && len(n.Sigs) == 1 + } + g, ok := groups[name] + // Unknown names and cyclic references (neither of which can occur + // in a policy produced by Unmarshal) are never satisfied. + if !ok || visiting[name] { + return false + } + visiting[name] = true + defer delete(visiting, name) + count := 0 + for _, m := range g.Members { + if satisfied(m) { + count++ + if count >= g.Threshold { + return true + } + } + } + return g.Threshold <= 0 + } + return satisfied(p.Quorum) +} + +// Verify applies the policy's checkpoint validity rule: the provided +// checkpoint must be a note signed by any one of the policy's logs, with +// an origin line matching that log's key name, and it must be cosigned by +// witnesses according to the quorum rule. The parsed checkpoint is +// returned if it is valid. +// +// A policy with an empty set of logs cannot validate any checkpoint; if +// the applicable log is known from other context, verify its signature +// separately and use Satisfied for the quorum rule. +func (p TLogPolicy) Verify(checkpoint []byte) (*f_log.Checkpoint, error) { + if len(p.Logs) == 0 { + return nil, fmt.Errorf("policy defines no logs") + } + for _, l := range p.Logs { + cp, _, _, err := f_log.ParseCheckpoint(checkpoint, l.Verifier.Name(), l.Verifier) + if err != nil { + continue + } + if !p.Satisfied(checkpoint) { + return nil, fmt.Errorf("checkpoint does not satisfy quorum %q", p.Quorum) + } + return cp, nil + } + return nil, fmt.Errorf("checkpoint is not signed by any log in the policy") +} + +// checkCharset returns an error if data contains an octet not permitted by +// the spec: the only allowed control characters are tab and newline. +func checkCharset(data []byte) error { + for i, b := range data { + if b == '\t' || b == '\n' || (b >= 0x20 && b != 0x7f) { + continue + } + return fmt.Errorf("invalid octet 0x%02x at offset %d", b, i) + } + return nil +} + +// splitFields splits a line into its items. Only space and tab act as +// separators; all other octets, including non-ASCII ones, are opaque data. +func splitFields(line string) []string { + var fs []string + start := -1 + for i := 0; i < len(line); i++ { + if line[i] == ' ' || line[i] == '\t' { + if start >= 0 { + fs = append(fs, line[start:i]) + start = -1 + } + } else if start < 0 { + start = i + } + } + if start >= 0 { + fs = append(fs, line[start:]) + } + return fs +} + +// vkeyPublicKey returns the decoded key bytes (the algorithm octet +// followed by the public key material) wrapped by a vkey. Two vkeys are +// duplicates if they wrap the same underlying public key, even if they +// differ by key name and key id. +func vkeyPublicKey(vkey string) ([]byte, error) { + parts := strings.SplitN(vkey, "+", 3) + if got, want := len(parts), 3; got != want { + return nil, fmt.Errorf("vkey has %d parts, expected %d", got, want) + } + key, err := base64.StdEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("vkey has invalid base64: %v", err) + } + if len(key) < 2 { + return nil, fmt.Errorf("vkey key bytes too short") + } + return key, nil +} diff --git a/policy/tlog_policy_test.go b/policy/tlog_policy_test.go new file mode 100644 index 0000000..4df0ac5 --- /dev/null +++ b/policy/tlog_policy_test.go @@ -0,0 +1,635 @@ +// Copyright 2026 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + f_log "github.com/transparency-dev/formats/log" + f_note "github.com/transparency-dev/formats/note" + "golang.org/x/mod/sumdb/note" +) + +const ( + // Plain Ed25519 (0x01) vkeys; usable as log keys but not witness keys. + wit1_ed25519_vkey = "Wit1+55ee4561+AVhZSmQj9+SoL+p/nN0Hh76xXmF7QcHfytUrI1XfSClk" + wit1_skey = "PRIVATE+KEY+Wit1+55ee4561+AeadRiG7XM4XiieCHzD8lxysXMwcViy5nYsoXURWGrlE" + wit2_ed25519_vkey = "Wit2+85ecc407+AWVbwFJte9wMQIPSnEnj4KibeO6vSIOEDUTDp3o63c2x" + wit2_skey = "PRIVATE+KEY+Wit2+85ecc407+AfPTvxw5eUcqSgivo2vaiC7JPOMUZ/9baHPSDrWqgdGm" + wit3_ed25519_vkey = "Wit3+d3ed3be7+ASb6Uz1+fxAcXkMvDd7nGa3FjDce7LxIKmbbTCT0MpVn" + wit3_skey = "PRIVATE+KEY+Wit3+d3ed3be7+AR2Kg8k6ccBr5QXz5SHtnkOS4UGQGEQaWi6Gfr6Mm3X5" + + testOrigin = "example.com/log" +) + +var ( + // A plain Ed25519 (0x01) log vkey named after the log's origin. + log_vkey = genVkey(testOrigin) + + // Witness vkeys must be cosignature key types; these wrap the same + // Ed25519 public keys as the fixtures above with the 0x04 key type. + wit1_vkey = rewrapVkey(wit1_ed25519_vkey, 0x04, "Wit1") + wit2_vkey = rewrapVkey(wit2_ed25519_vkey, 0x04, "Wit2") + wit3_vkey = rewrapVkey(wit3_ed25519_vkey, 0x04, "Wit3") + // Further distinct cosignature vkeys, without corresponding signers. + wit4_vkey = rewrapVkey(genVkey("Wit4"), 0x04, "Wit4") + wit5_vkey = rewrapVkey(genVkey("Wit5"), 0x04, "Wit5") + wit6_vkey = rewrapVkey(genVkey("Wit6"), 0x04, "Wit6") + + wit1Sign, _ = f_note.NewSignerForCosignatureV1(wit1_skey) + wit2Sign, _ = f_note.NewSignerForCosignatureV1(wit2_skey) + wit3Sign, _ = f_note.NewSignerForCosignatureV1(wit3_skey) + + // ignoreVerifiers excludes the Verifier fields from cmp diffs: the + // note.Verifier implementations are unexported types with unexported + // fields, which cmp refuses to compare, and the expected values in + // test tables carry no verifiers anyway. Tests assert on the + // verifiers separately where they matter. + ignoreVerifiers = []cmp.Option{ + cmpopts.IgnoreFields(Log{}, "Verifier"), + cmpopts.IgnoreFields(Witness{}, "Verifier"), + } +) + +// genVkey returns the vkey of a freshly generated Ed25519 note key pair. +func genVkey(name string) string { + _, vkey, err := note.GenerateKey(rand.Reader, name) + if err != nil { + panic(fmt.Sprintf("note.GenerateKey(%q): %v", name, err)) + } + return vkey +} + +// rewrapVkey rewraps the public key from vkey with the given algorithm +// octet under the given key name (with a placeholder key id, which +// cosignature verifiers do not validate). +func rewrapVkey(vkey string, alg byte, name string) string { + key, err := vkeyPublicKey(vkey) + if err != nil { + panic(fmt.Sprintf("vkeyPublicKey(%q): %v", vkey, err)) + } + key[0] = alg + return fmt.Sprintf("%s+00000000+%s", name, base64.StdEncoding.EncodeToString(key)) +} + +func TestUnmarshal(t *testing.T) { + for _, tc := range []struct { + desc string + policy string + want TLogPolicy + }{ + { + desc: "spec example", + policy: fmt.Sprintf(`log %s + +witness X1 %s +witness X2 %s +witness X3 %s +group X-witnesses 2 X1 X2 X3 + +witness Y1 %s +witness Y2 %s +witness Y3 %s +group Y-witnesses any Y1 Y2 Y3 + +group X-and-Y all X-witnesses Y-witnesses +quorum X-and-Y +`, log_vkey, wit1_vkey, wit2_vkey, wit3_vkey, wit4_vkey, wit5_vkey, wit6_vkey), + want: TLogPolicy{ + Logs: []Log{{VKey: log_vkey}}, + Witnesses: []Witness{ + {Name: "X1", VKey: wit1_vkey}, + {Name: "X2", VKey: wit2_vkey}, + {Name: "X3", VKey: wit3_vkey}, + {Name: "Y1", VKey: wit4_vkey}, + {Name: "Y2", VKey: wit5_vkey}, + {Name: "Y3", VKey: wit6_vkey}, + }, + Groups: []Group{ + {Name: "X-witnesses", Threshold: 2, Members: []string{"X1", "X2", "X3"}}, + {Name: "Y-witnesses", Threshold: 1, Members: []string{"Y1", "Y2", "Y3"}}, + {Name: "X-and-Y", Threshold: 2, Members: []string{"X-witnesses", "Y-witnesses"}}, + }, + Quorum: "X-and-Y", + }, + }, + { + desc: "minimal", + policy: "quorum none\n", + want: TLogPolicy{Quorum: "none"}, + }, + { + desc: "whitespace and comments", + policy: "\n# comment\n\t # another comment\n\twitness \t w1 " + wit1_vkey + + " https://w1.example.com/ \n\n quorum\tw1", + want: TLogPolicy{ + Witnesses: []Witness{{Name: "w1", VKey: wit1_vkey, URL: "https://w1.example.com/"}}, + Quorum: "w1", + }, + }, + { + desc: "urls optional and opaque", + policy: fmt.Sprintf("log %s https://log.example.com/\nwitness w1 %s\nquorum w1\n", log_vkey, wit1_vkey), + want: TLogPolicy{ + Logs: []Log{{VKey: log_vkey, URL: "https://log.example.com/"}}, + Witnesses: []Witness{{Name: "w1", VKey: wit1_vkey}}, + Quorum: "w1", + }, + }, + { + desc: "non-ASCII names are opaque octets", + policy: fmt.Sprintf("witness KKlvin-w\x80 %s\nquorum KKlvin-w\x80\n", wit1_vkey), + want: TLogPolicy{ + Witnesses: []Witness{{Name: "KKlvin-w\x80", VKey: wit1_vkey}}, + Quorum: "KKlvin-w\x80", + }, + }, + { + desc: "keywords other than none are valid names", + policy: fmt.Sprintf("witness all %s\nwitness any %s\ngroup group 2 all any\nquorum group\n", wit1_vkey, wit2_vkey), + want: TLogPolicy{ + Witnesses: []Witness{ + {Name: "all", VKey: wit1_vkey}, + {Name: "any", VKey: wit2_vkey}, + }, + Groups: []Group{{Name: "group", Threshold: 2, Members: []string{"all", "any"}}}, + Quorum: "group", + }, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + p := &TLogPolicy{} + if err := p.Unmarshal([]byte(tc.policy)); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + if diff := cmp.Diff(tc.want, *p, ignoreVerifiers...); diff != "" { + t.Errorf("Unmarshal() diff (-want +got):\n%s", diff) + } + for _, l := range p.Logs { + if l.Verifier == nil { + t.Errorf("log %q has no verifier", l.VKey) + } + } + for _, w := range p.Witnesses { + if w.Verifier == nil { + t.Errorf("witness %q has no verifier", w.Name) + } + } + }) + } +} + +func TestUnmarshalMLDSAWitness(t *testing.T) { + _, vkey, err := f_note.GenerateMLDSAKey("mldsa.example.com") + if err != nil { + t.Fatalf("GenerateMLDSAKey() failed: %v", err) + } + p := &TLogPolicy{} + if err := p.Unmarshal(fmt.Appendf(nil, "witness w1 %s\nquorum w1\n", vkey)); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + if got, want := p.Witnesses[0].Verifier.Name(), "mldsa.example.com"; got != want { + t.Errorf("verifier name = %q, want %q", got, want) + } +} + +func TestUnmarshalLogVerifierName(t *testing.T) { + p := &TLogPolicy{} + if err := p.Unmarshal(fmt.Appendf(nil, "log %s\nquorum none\n", log_vkey)); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + if got, want := p.Logs[0].Verifier.Name(), testOrigin; got != want { + t.Errorf("log verifier name = %q, want %q", got, want) + } +} + +func TestUnmarshal_Errors(t *testing.T) { + ecdsaVkey := fmt.Sprintf("ecdsa.example.com+00000000+%s", base64.StdEncoding.EncodeToString([]byte{0x02, 1, 2, 3})) + // A well-formed vkey with an unknown algorithm octet and a correctly + // computed key id, which golang.org/x/mod/sumdb/note validates. + unknownAlgKey := append([]byte{0x63}, make([]byte, 32)...) + unknownAlgHash := sha256.Sum256([]byte("unknown.example.com\n" + string(unknownAlgKey))) + unknownAlgVkey := fmt.Sprintf("unknown.example.com+%x+%s", unknownAlgHash[:4], base64.StdEncoding.EncodeToString(unknownAlgKey)) + for _, tc := range []struct { + desc string + policy string + errStr string + }{ + { + desc: "disallowed control octet", + policy: "quorum none\n\x01", + errStr: "invalid octet 0x01", + }, + { + desc: "carriage return", + policy: "quorum none\r\n", + errStr: "invalid octet 0x0d", + }, + { + desc: "unknown keyword", + policy: "quibble w1\nquorum none\n", + errStr: "unknown keyword", + }, + { + desc: "inline comments are not comments", + policy: fmt.Sprintf("witness w1 %s https://w1.example.com/ # comment\nquorum w1\n", wit1_vkey), + errStr: "invalid witness definition", + }, + { + desc: "log without vkey", + policy: "log\nquorum none\n", + errStr: "invalid log definition", + }, + { + desc: "log with invalid vkey", + policy: "log garbage\nquorum none\n", + errStr: "invalid log vkey", + }, + { + desc: "log with unknown key type", + policy: fmt.Sprintf("log %s\nquorum none\n", unknownAlgVkey), + errStr: "unknown verifier algorithm", + }, + { + desc: "duplicate log public key", + policy: fmt.Sprintf("log %s\nlog %s https://log.example.com/\nquorum none\n", log_vkey, log_vkey), + errStr: "duplicate log public key", + }, + { + desc: "witness without vkey", + policy: "witness w1\nquorum none\n", + errStr: "invalid witness definition", + }, + { + desc: "witness with non-cosignature vkey", + policy: fmt.Sprintf("witness w1 %s\nquorum w1\n", ecdsaVkey), + errStr: "key type 0x02 is not a cosignature key type", + }, + { + desc: "witness with plain ed25519 vkey", + policy: fmt.Sprintf("witness w1 %s\nquorum w1\n", wit1_ed25519_vkey), + errStr: "key type 0x01 is not a cosignature key type", + }, + { + desc: "witness named none", + policy: fmt.Sprintf("witness none %s\nquorum none\n", wit1_vkey), + errStr: "name is reserved", + }, + { + desc: "duplicate witness name", + policy: fmt.Sprintf("witness w1 %s\nwitness w1 %s\nquorum w1\n", wit1_vkey, wit2_vkey), + errStr: "duplicate name", + }, + { + desc: "group name duplicates witness name", + policy: fmt.Sprintf("witness w1 %s\ngroup w1 any w1\nquorum w1\n", wit1_vkey), + errStr: "duplicate name", + }, + { + desc: "group without members", + policy: fmt.Sprintf("witness w1 %s\ngroup g1 1\nquorum w1\n", wit1_vkey), + errStr: "invalid group definition", + }, + { + desc: "group named none", + policy: fmt.Sprintf("witness w1 %s\ngroup none any w1\nquorum none\n", wit1_vkey), + errStr: "name is reserved", + }, + { + desc: "zero threshold", + policy: fmt.Sprintf("witness w1 %s\ngroup g1 0 w1\nquorum g1\n", wit1_vkey), + errStr: "outside [1, 1]", + }, + { + desc: "negative threshold", + policy: fmt.Sprintf("witness w1 %s\ngroup g1 -1 w1\nquorum g1\n", wit1_vkey), + errStr: "invalid threshold", + }, + { + desc: "threshold exceeds members", + policy: fmt.Sprintf("witness w1 %s\ngroup g1 2 w1\nquorum g1\n", wit1_vkey), + errStr: "outside [1, 1]", + }, + { + desc: "unknown group member", + policy: fmt.Sprintf("witness w1 %s\ngroup g1 any w2\nquorum g1\n", wit1_vkey), + errStr: "unknown member", + }, + { + desc: "forward reference in group", + policy: fmt.Sprintf("group g1 any w1\nwitness w1 %s\nquorum g1\n", wit1_vkey), + errStr: "unknown member", + }, + { + desc: "none as group member", + policy: fmt.Sprintf("witness w1 %s\ngroup g1 any none w1\nquorum g1\n", wit1_vkey), + errStr: "unknown member", + }, + { + desc: "member repeated within a group", + policy: fmt.Sprintf("witness w1 %s\ngroup g1 all w1 w1\nquorum g1\n", wit1_vkey), + errStr: "more than once", + }, + { + desc: "member repeated across groups", + policy: fmt.Sprintf("witness w1 %s\nwitness w2 %s\ngroup g1 any w1 w2\ngroup g2 any w1\nquorum g1\n", + wit1_vkey, wit2_vkey), + errStr: "more than once", + }, + { + desc: "no quorum", + policy: fmt.Sprintf("witness w1 %s\n", wit1_vkey), + errStr: "exactly one quorum", + }, + { + desc: "multiple quorums", + policy: fmt.Sprintf("witness w1 %s\nquorum w1\nquorum w1\n", wit1_vkey), + errStr: "exactly one quorum", + }, + { + desc: "quorum with unknown name", + policy: "quorum unknown\n", + errStr: `quorum "unknown" not defined`, + }, + { + desc: "quorum before referenced definition", + policy: fmt.Sprintf("quorum w1\nwitness w1 %s\n", wit1_vkey), + errStr: `quorum "w1" not defined`, + }, + { + desc: "quorum without name", + policy: "quorum\n", + errStr: "invalid quorum definition", + }, + } { + t.Run(tc.desc, func(t *testing.T) { + err := (&TLogPolicy{}).Unmarshal([]byte(tc.policy)) + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), tc.errStr) { + t.Errorf("Expected error string to contain %q, got %q", tc.errStr, err.Error()) + } + }) + } +} + +func TestUnmarshal_DuplicateWitnessKeys(t *testing.T) { + // dup wraps the same public key as wit1_vkey with a different name + // and key id. + dup := rewrapVkey(wit1_vkey, 0x04, "impostor.example.com") + policy := fmt.Sprintf("witness w1 %s\nwitness w2 %s\nquorum w1\n", wit1_vkey, dup) + err := (&TLogPolicy{}).Unmarshal([]byte(policy)) + if err == nil { + t.Fatal("Expected error, got nil") + } + if want := "duplicate witness public key"; !strings.Contains(err.Error(), want) { + t.Errorf("Expected error string to contain %q, got %q", want, err.Error()) + } +} + +func TestMarshalRoundTrip(t *testing.T) { + policy := fmt.Sprintf(`# A policy. +log %s https://log.example.com/ +witness w1 %s https://w1.example.com/ +witness w2 %s +group g1 any w1 w2 +quorum g1 +`, log_vkey, wit1_vkey, wit2_vkey) + + p := &TLogPolicy{} + if err := p.Unmarshal([]byte(policy)); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + + marshalled := p.Marshal() + want := fmt.Sprintf("log %s https://log.example.com/\nwitness w1 %s https://w1.example.com/\nwitness w2 %s\ngroup g1 1 w1 w2\nquorum g1\n", + log_vkey, wit1_vkey, wit2_vkey) + if got := string(marshalled); got != want { + t.Errorf("Marshal() = %q, want %q", got, want) + } + + p2 := &TLogPolicy{} + if err := p2.Unmarshal(marshalled); err != nil { + t.Fatalf("Unmarshal(Marshal()) failed: %v", err) + } + if diff := cmp.Diff(*p, *p2, ignoreVerifiers...); diff != "" { + t.Errorf("policy did not round-trip (-first +second):\n%s", diff) + } +} + +// checkpointBody returns a valid checkpoint body for signing. +func checkpointBody() []byte { + cp := f_log.Checkpoint{ + Origin: testOrigin, + Size: 42, + Hash: make([]byte, 32), + } + return cp.Marshal() +} + +// signedCheckpoint returns a checkpoint note signed by the given signers. +func signedCheckpoint(t *testing.T, signers ...note.Signer) []byte { + t.Helper() + n := ¬e.Note{Text: string(checkpointBody())} + cp, err := note.Sign(n, signers...) + if err != nil { + t.Fatalf("note.Sign() failed: %v", err) + } + return cp +} + +func TestSatisfied(t *testing.T) { + policy := fmt.Sprintf(`witness w1 %s +witness w2 %s +witness w3 %s +group inner any w2 w3 +group outer all w1 inner +quorum outer +`, wit1_vkey, wit2_vkey, wit3_vkey) + p := &TLogPolicy{} + if err := p.Unmarshal([]byte(policy)); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + + for _, tc := range []struct { + desc string + signers []note.Signer + expectSatisfied bool + }{ + { + desc: "all witnesses", + signers: []note.Signer{wit1Sign, wit2Sign, wit3Sign}, + expectSatisfied: true, + }, + { + desc: "minimum satisfying set", + signers: []note.Signer{wit1Sign, wit3Sign}, + expectSatisfied: true, + }, + { + desc: "outer member without inner group", + signers: []note.Signer{wit1Sign}, + expectSatisfied: false, + }, + { + desc: "inner group without outer member", + signers: []note.Signer{wit2Sign, wit3Sign}, + expectSatisfied: false, + }, + { + desc: "no cosignatures", + signers: []note.Signer{}, + expectSatisfied: false, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + cp := signedCheckpoint(t, tc.signers...) + if got, want := p.Satisfied(cp), tc.expectSatisfied; got != want { + t.Errorf("Satisfied() = %t, want %t", got, want) + } + }) + } +} + +func TestSatisfiedQuorumNone(t *testing.T) { + p := &TLogPolicy{} + if err := p.Unmarshal([]byte("quorum none\n")); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + if !p.Satisfied(signedCheckpoint(t)) { + t.Error("quorum none should always be satisfied") + } +} + +func TestSatisfiedUnknownQuorum(t *testing.T) { + p := TLogPolicy{Quorum: "missing"} + if p.Satisfied(signedCheckpoint(t, wit1Sign)) { + t.Error("a quorum naming an unknown component should never be satisfied") + } +} + +func TestSatisfiedMLDSA(t *testing.T) { + skey, vkey, err := f_note.GenerateMLDSAKey("mldsa.example.com") + if err != nil { + t.Fatalf("GenerateMLDSAKey() failed: %v", err) + } + signer, err := f_note.NewMLDSASigner(skey) + if err != nil { + t.Fatalf("NewMLDSASigner() failed: %v", err) + } + + p := &TLogPolicy{} + if err := p.Unmarshal(fmt.Appendf(nil, "witness w1 %s\nquorum w1\n", vkey)); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + if !p.Satisfied(signedCheckpoint(t, signer)) { + t.Error("expected ML-DSA cosignature to satisfy quorum") + } + if p.Satisfied(signedCheckpoint(t)) { + t.Error("expected unsigned checkpoint not to satisfy quorum") + } +} + +func TestVerify(t *testing.T) { + logSkey, logVkey, err := note.GenerateKey(rand.Reader, testOrigin) + if err != nil { + t.Fatalf("note.GenerateKey() failed: %v", err) + } + logSign, err := note.NewSigner(logSkey) + if err != nil { + t.Fatalf("note.NewSigner() failed: %v", err) + } + otherSkey, otherVkey, err := note.GenerateKey(rand.Reader, "other.example.com/log") + if err != nil { + t.Fatalf("note.GenerateKey() failed: %v", err) + } + otherSign, err := note.NewSigner(otherSkey) + if err != nil { + t.Fatalf("note.NewSigner() failed: %v", err) + } + + for _, tc := range []struct { + desc string + policy string + signers []note.Signer + errStr string + }{ + { + desc: "log signature and quorum", + policy: fmt.Sprintf("log %s\nwitness w1 %s\nquorum w1\n", logVkey, wit1_vkey), + signers: []note.Signer{logSign, wit1Sign}, + }, + { + desc: "any one of the listed logs suffices", + policy: fmt.Sprintf("log %s\nlog %s\nwitness w1 %s\nquorum w1\n", otherVkey, logVkey, wit1_vkey), + signers: []note.Signer{logSign, wit1Sign}, + }, + { + desc: "log signature without quorum", + policy: fmt.Sprintf("log %s\nwitness w1 %s\nquorum w1\n", logVkey, wit1_vkey), + signers: []note.Signer{logSign}, + errStr: "does not satisfy quorum", + }, + { + desc: "quorum without log signature", + policy: fmt.Sprintf("log %s\nwitness w1 %s\nquorum w1\n", logVkey, wit1_vkey), + signers: []note.Signer{wit1Sign}, + errStr: "not signed by any log", + }, + { + desc: "log key name must match checkpoint origin", + policy: fmt.Sprintf("log %s\nwitness w1 %s\nquorum w1\n", otherVkey, wit1_vkey), + signers: []note.Signer{otherSign, wit1Sign}, + errStr: "not signed by any log", + }, + { + desc: "policy without logs", + policy: fmt.Sprintf("witness w1 %s\nquorum w1\n", wit1_vkey), + signers: []note.Signer{logSign, wit1Sign}, + errStr: "no logs", + }, + } { + t.Run(tc.desc, func(t *testing.T) { + p := &TLogPolicy{} + if err := p.Unmarshal([]byte(tc.policy)); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + cp, err := p.Verify(signedCheckpoint(t, tc.signers...)) + if tc.errStr != "" { + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), tc.errStr) { + t.Errorf("Expected error string to contain %q, got %q", tc.errStr, err.Error()) + } + return + } + if err != nil { + t.Fatalf("Verify() failed: %v", err) + } + if cp.Origin != testOrigin || cp.Size != 42 { + t.Errorf("Verify() returned unexpected checkpoint: %+v", cp) + } + }) + } +}