From 1e6a6e795a5d42818831ec47e5d477abd686e2c2 Mon Sep 17 00:00:00 2001 From: Abdel Fane <32418586+thebenignhacker@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:03:53 -0600 Subject: [PATCH] fix: read the trust key table's sources and report dates from a committed capture of the public records trustgen reads each row's source and reported from pkg/patterns/testdata/trust-captured/source-records.txt: one line per public record, with the UTC time it states for itself, its role and the keys it lists. A row's source is the sorted URLs of the records that list it, and its reported is the UTC date of the earliest of those that reports its status. The generator refuses a record that lists no row, a row that no status record lists, a row with a record dated before its earliest status record, and a time that is not UTC. The table test derives each row's source and reported from the committed capture, and refuses a table with a row reported a day before its earliest record or citing a record that does not list it. --- pkg/patterns/internal/trustgen/main.go | 192 +++++++++++++-- .../trust-captured/source-records.txt | 23 ++ pkg/patterns/trust.go | 11 +- pkg/patterns/trust_keys.json | 46 ++-- pkg/patterns/trust_table_test.go | 221 +++++++++++++++++- 5 files changed, 438 insertions(+), 55 deletions(-) create mode 100644 pkg/patterns/testdata/trust-captured/source-records.txt diff --git a/pkg/patterns/internal/trustgen/main.go b/pkg/patterns/internal/trustgen/main.go index 7da2211..77c02f0 100644 --- a/pkg/patterns/internal/trustgen/main.go +++ b/pkg/patterns/internal/trustgen/main.go @@ -2,13 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 // Command trustgen writes the trust key table, pkg/patterns/trust_keys.json, -// from the certificates in pkg/patterns/testdata/trust, and the key table of -// each finding page under docs/findings. Run it with +// from the certificates in pkg/patterns/testdata/trust and the records in +// pkg/patterns/testdata/trust-captured/source-records.txt, and the key table +// of each finding page under docs/findings. Run it with // // go generate ./pkg/patterns // -// after adding or changing a certificate there. No value in the table is -// typed: every hash, key and name is read from a certificate file. +// after adding or changing a certificate or a record there. No hash, key, +// name, source or reported date in the table is typed: each hash, key and +// name is read from a certificate file, and each source and reported date +// from the records file. // // A certificate's key use is the directory it sits in: tls, codesigning or // root. The command refuses a TLS certificate without the serverAuth extended @@ -19,10 +22,19 @@ // and a key whose certificates carry two names, are refused. Each file holds // one certificate and is named by its SHA-256. // +// The records file holds one line per public record the table cites: its +// URL, the UTC time it states for itself, its role and the keys it lists, in +// columns separated by single spaces; its header states the rules. A row's +// source is the sorted URLs of the records that list it, and its reported +// date is the UTC date of the earliest of those whose role is status. The +// command refuses a record that lists no row, a row that no status record +// lists, and a row with a record dated before the earliest that reports its +// status, so that reported is also the earliest date among the row's sources. +// // The table is written in the key table file format: schemaVersion 1, then // the rows sorted by SPKI hash, each row's certificate hashes and sources // sorted, in the bytes a json.Encoder writes with SetEscapeHTML(false) and -// SetIndent("", " "). The same certificates and sources always give the same +// SetIndent("", " "). The same certificates and records always give the same // bytes. package main @@ -41,17 +53,9 @@ import ( "path/filepath" "slices" "strings" + "time" ) -// The listing every row of the current table comes from: the Mozilla bug -// that reported the eight keys, and the Chrome root program's announcement. -var sources = []string{ - "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449", - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", -} - -const reported = "2026-10-17" - // schemaVersion is the version of the key table file format written. const schemaVersion = 1 @@ -66,6 +70,9 @@ var useDirs = []struct { } // Key is one row of trust_keys.json, its fields in the file format's order. +// Its source is the https URL of each public record that reports the key's +// status (for the root, its removal from a trust store), and its reported is +// the earliest UTC date those records state for themselves. type Key struct { SPKISHA256 string `json:"spkiSha256"` Name string `json:"name"` @@ -92,11 +99,12 @@ var pages = map[string]string{ func main() { fixtures := flag.String("fixtures", "testdata/trust", "directory holding tls, codesigning and root") + records := flag.String("records", "testdata/trust-captured/source-records.txt", "the public records the table cites") keysPath := flag.String("keys", "trust_keys.json", "key table to write") pagesDir := flag.String("pages", "", "directory of the finding pages whose tables to rewrite") flag.Parse() - keys, err := buildKeys(*fixtures) + keys, err := buildKeys(*fixtures, *records) if err != nil { fmt.Fprintln(os.Stderr, "trustgen:", err) os.Exit(1) @@ -137,10 +145,13 @@ func encodeTable(keys []*Key) ([]byte, error) { } // buildKeys reads every certificate under fixtures and returns one row per -// SubjectPublicKeyInfo, sorted by SPKI hash. -func buildKeys(fixtures string) ([]*Key, error) { - source := slices.Clone(sources) - slices.Sort(source) +// SubjectPublicKeyInfo, sorted by SPKI hash, each citing the records of the +// records file that list it. +func buildKeys(fixtures, recordsPath string) ([]*Key, error) { + records, err := readRecords(recordsPath) + if err != nil { + return nil, err + } var keys []*Key for _, d := range useDirs { files, err := filepath.Glob(filepath.Join(fixtures, d.dir, "*.crt")) @@ -171,8 +182,6 @@ func buildKeys(fixtures string) ([]*Key, error) { Name: cert.Subject.CommonName, SPKIDER: base64.StdEncoding.EncodeToString(cert.RawSubjectPublicKeyInfo), Use: d.use, - Source: source, - Reported: reported, Status: d.status, DistrustDate: d.distrustDate, } @@ -196,9 +205,150 @@ func buildKeys(fixtures string) ([]*Key, error) { } } slices.SortFunc(keys, func(a, b *Key) int { return strings.Compare(a.SPKISHA256, b.SPKISHA256) }) + if err := cite(keys, records, filepath.Dir(recordsPath)); err != nil { + return nil, fmt.Errorf("%s: %w", recordsPath, err) + } return keys, nil } +// record is one line of the records file. +type record struct { + url string + date string // the UTC date of the time the record states for itself + role string // status when it reports the key's status, lists when it only lists the key + lists string // certs:, pkh: or root: +} + +// readRecords returns the records of the records file in file order. A line +// that starts with # is a comment; any other is a record, whose four columns +// are separated by single spaces, the last, lists, holding any spaces. +func readRecords(path string) ([]record, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var records []record + seen := map[string]bool{} + for i, line := range strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") { + if strings.HasPrefix(line, "#") { + continue + } + f := strings.SplitN(line, " ", 4) + if len(f) != 4 { + return nil, fmt.Errorf("%s:%d: want url, published, role and lists", path, i+1) + } + url, published, role, lists := f[0], f[1], f[2], f[3] + if !strings.HasPrefix(url, "https://") { + return nil, fmt.Errorf("%s:%d: %q is not an https URL", path, i+1, url) + } + if seen[url] { + return nil, fmt.Errorf("%s:%d: %s is listed twice", path, i+1, url) + } + seen[url] = true + at, err := time.Parse(time.RFC3339, published) + if err != nil || !strings.HasSuffix(published, "Z") { + return nil, fmt.Errorf("%s:%d: published %q is not a UTC time", path, i+1, published) + } + if role != "status" && role != "lists" { + return nil, fmt.Errorf("%s:%d: role %q is not status or lists", path, i+1, role) + } + records = append(records, record{url: url, date: at.Format("2006-01-02"), role: role, lists: lists}) + } + if len(records) == 0 { + return nil, fmt.Errorf("%s: holds no record", path) + } + return records, nil +} + +// cite sets each row's source to the sorted URLs of the records that list +// it, and its reported to the date of the earliest of those whose role is +// status. A certs: file is read from dir. +func cite(keys []*Key, records []record, dir string) error { + earliest := map[*Key]string{} + for _, r := range records { + lists, err := lister(r.lists, dir) + if err != nil { + return fmt.Errorf("%s: %w", r.url, err) + } + listed := false + for _, k := range keys { + if !lists(k) { + continue + } + listed = true + k.Source = append(k.Source, r.url) + if e, ok := earliest[k]; !ok || r.date < e { + earliest[k] = r.date + } + if r.role == "status" && (k.Reported == "" || r.date < k.Reported) { + k.Reported = r.date + } + } + if !listed { + return fmt.Errorf("%s lists no row of the table", r.url) + } + } + for _, k := range keys { + if k.Reported == "" { + return fmt.Errorf("key %s: no record that reports its status lists it", k.SPKISHA256) + } + if earliest[k] != k.Reported { + return fmt.Errorf("key %s: a record of %s lists it before the earliest that reports its status, of %s", k.SPKISHA256, earliest[k], k.Reported) + } + slices.Sort(k.Source) + } + return nil +} + +// lister returns whether a record's lists column lists a row: certs: +// lists every row with a certificate in that captured file, pkh: the +// row whose SPKI digest that is, and root: the root row of that +// subject name. +func lister(lists, dir string) (func(*Key) bool, error) { + kind, value, _ := strings.Cut(lists, ":") + switch kind { + case "certs": + if value == "" || value != filepath.Base(value) { + return nil, fmt.Errorf("certs:%s is not a file name", value) + } + certs, err := readFirstColumn(filepath.Join(dir, value)) + if err != nil { + return nil, err + } + return func(k *Key) bool { + return slices.ContainsFunc(k.CertSHA256, func(c string) bool { return certs[c] }) + }, nil + case "pkh": + digest, err := base64.StdEncoding.DecodeString(value) + if err != nil || len(digest) != sha256.Size { + return nil, fmt.Errorf("pkh:%s is not a base64 SHA-256", value) + } + spki := hex.EncodeToString(digest) + return func(k *Key) bool { return k.SPKISHA256 == spki }, nil + case "root": + return func(k *Key) bool { return k.Use == "root" && k.Name == value }, nil + } + return nil, fmt.Errorf("lists %q is not certs:, pkh: or root:", lists) +} + +// readFirstColumn returns the first column of each line of a captured-values +// file that is neither empty nor a comment. +func readFirstColumn(path string) (map[string]bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + values := map[string]bool{} + for _, line := range strings.Split(string(data), "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + value, _, _ := strings.Cut(line, " ") + values[value] = true + } + return values, nil +} + // readCertificate returns the one certificate a PEM file holds. func readCertificate(file string) (*x509.Certificate, []byte, error) { data, err := os.ReadFile(file) diff --git a/pkg/patterns/testdata/trust-captured/source-records.txt b/pkg/patterns/testdata/trust-captured/source-records.txt new file mode 100644 index 0000000..0ad6b23 --- /dev/null +++ b/pkg/patterns/testdata/trust-captured/source-records.txt @@ -0,0 +1,23 @@ +# The public records the key table cites, one line per record. Columns: +# url, published, role, lists. published is the UTC time the record was +# published, read from the record itself: a bug's creation_time, a comment's +# creation_time, a post's datePublished, a OneCRL record's last_modified. +# role is status when the record reports the key's status (a compromised +# key or a removed root), and lists when it only lists the key. lists names +# the keys the record lists: certs: for every row with a +# certificate in that file, pkh: for the row whose SPKI digest +# it is, root: for the root row of that name. +# A row's source is the sorted urls of the records that list it; its +# reported is the UTC date of the earliest of those whose role is status. +# The root's two records name it by subject alone: no record captured on +# 2026-10-26 lists its certificate SHA-256, so its binding to the committed +# certificate is by name until a root-store record that lists it is captured. +https://bugzilla.mozilla.org/show_bug.cgi?id=2083449 2026-10-18T12:06:03Z status certs:bugzilla-2083449-certificate-sha256.txt +https://bugzilla.mozilla.org/show_bug.cgi?id=2083449#c18 2026-10-20T13:06:02Z status root:emSign ECC Root CA - G3 +https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/ 2026-10-20T19:49:00Z status root:emSign ECC Root CA - G3 +https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/5856b9af-5c14-46e7-b660-04d1cc3f2819 2026-10-21T22:34:01Z lists pkh:4CVMf83CZeLw+KQo2rt1q0rgObSHLWL17lfwh31aikE= +https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/3f07ab6b-0d52-41a3-9a7b-de945502a3b8 2026-10-21T22:34:01Z lists pkh:Q7/6GdbvZRRR9oQeAZkx6W9vLq7/KKtLbxzkV23icXs= +https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/5ed2b441-a5bf-4f8e-89af-d01fa292a9b8 2026-10-21T22:34:01Z lists pkh:6+1kdcLC2A/LGZ8/6N1uazzmC3frFdTkLD3sDG9bUQU= +https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/adaa1e8d-57cb-4cfb-b647-305aa20f1b04 2026-10-21T22:34:01Z lists pkh:XAZgorFkkQ2P9YnBjhRVLp4Owjfc7+LLc21BpIEfgaA= +https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/f67aff65-7a13-42ef-b818-74bb69053273 2026-10-21T22:34:01Z lists pkh:Y+zv7kMdSVI7wRNsTnLQIGpHcIbK9gbNXPAlaGwQnPg= +https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/fb4be101-8280-4d4e-866e-fd0213bc3a02 2026-10-21T22:34:01Z lists pkh:Sd01PcK5GaN3BkURfQ5gwWEV59G8JZ7v5zSwDqQ0/l8= diff --git a/pkg/patterns/trust.go b/pkg/patterns/trust.go index 913d910..7e1497e 100644 --- a/pkg/patterns/trust.go +++ b/pkg/patterns/trust.go @@ -94,17 +94,18 @@ var trustKeysJSON []byte const trustSchemaVersion = 1 // trustKey is one row of the key table: a public key a published advisory -// lists, the certificates known for it and the listing. The table is -// generated from the certificates in testdata/trust by internal/trustgen, and -// a test recomputes every row from them. +// lists, the certificates known for it and the public records that report its +// status. The table is generated from the certificates in testdata/trust and +// the records in testdata/trust-captured by internal/trustgen, and a test +// recomputes every row from them. type trustKey struct { SPKISHA256 string `json:"spkiSha256"` // SHA-256 of the DER SubjectPublicKeyInfo Name string `json:"name"` // the subject common name of the key's certificates CertSHA256 []string `json:"certSha256"` // SHA-256 of the DER of each known certificate SPKIDER string `json:"spkiDer"` // the DER SubjectPublicKeyInfo, base64 Use string `json:"use"` // tls, codeSigning or root - Source []string `json:"source"` // public pages that list the key - Reported string `json:"reported"` // date the listing was reported + Source []string `json:"source"` // https URL of each public record that reports the key's status (for the root, its removal from a trust store) + Reported string `json:"reported"` // earliest UTC date the source records state for themselves Status string `json:"status"` // compromised or distrusted DistrustDate string `json:"distrustDate,omitempty"` // for a distrusted root, the date browsers remove it diff --git a/pkg/patterns/trust_keys.json b/pkg/patterns/trust_keys.json index 9349237..1518c12 100644 --- a/pkg/patterns/trust_keys.json +++ b/pkg/patterns/trust_keys.json @@ -11,10 +11,10 @@ "spkiDer": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEc4l6oOs8/cqLEkAv+vCrxOEhK6+N7neKye4tbbg/rCgMXgVjiueirNccMiqONhzeufacQwK53Y1z0cxsqrLMCu5OIWzped1Rikr0tiblaEzdZYA37QyFTkzBL0z4ZG0N", "use": "tls", "source": [ - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", - "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" + "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449", + "https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/3f07ab6b-0d52-41a3-9a7b-de945502a3b8" ], - "reported": "2026-10-17", + "reported": "2026-10-18", "status": "compromised" }, { @@ -27,10 +27,10 @@ "spkiDer": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh3xsqRB0B", "use": "tls", "source": [ - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", - "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" + "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449", + "https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/fb4be101-8280-4d4e-866e-fd0213bc3a02" ], - "reported": "2026-10-17", + "reported": "2026-10-18", "status": "compromised" }, { @@ -43,10 +43,10 @@ "spkiDer": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAELVboKab7gsZKRpfZYK+VHpBnrpH5JtNDBtpIqKrreGm2pn5tJ0JuBEKqA7NuGYjf9jZRTuHPB/W6Sz/yoGZFrn0x1YZq0xqj0kbrCzF37pnLdWbFFs50gIpmj5HwxH/K", "use": "tls", "source": [ - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", - "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" + "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449", + "https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/adaa1e8d-57cb-4cfb-b647-305aa20f1b04" ], - "reported": "2026-10-17", + "reported": "2026-10-18", "status": "compromised" }, { @@ -59,10 +59,10 @@ "spkiDer": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSfHAKycO1n", "use": "tls", "source": [ - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", - "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" + "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449", + "https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/f67aff65-7a13-42ef-b818-74bb69053273" ], - "reported": "2026-10-17", + "reported": "2026-10-18", "status": "compromised" }, { @@ -75,9 +75,9 @@ "use": "root", "source": [ "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", - "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" + "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449#c18" ], - "reported": "2026-10-17", + "reported": "2026-10-20", "status": "distrusted", "distrustDate": "2026-10-27" }, @@ -91,10 +91,9 @@ "spkiDer": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEufDgY0cCppymqt1EPP1ixBubpLIJJGl9eXTnaRoSb7lq+z40wv07xfSDBh8mXcKlhDY0cyNRBDayjwTzhiNjBnmA4lA+AlMtZUYjWnFlolmh70lvdBtf3ud4cgjkuPrv", "use": "codeSigning", "source": [ - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" ], - "reported": "2026-10-17", + "reported": "2026-10-18", "status": "compromised" }, { @@ -107,10 +106,10 @@ "spkiDer": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ5LZUw4Uu1krx6GV9", "use": "tls", "source": [ - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", - "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" + "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449", + "https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/5856b9af-5c14-46e7-b660-04d1cc3f2819" ], - "reported": "2026-10-17", + "reported": "2026-10-18", "status": "compromised" }, { @@ -123,10 +122,10 @@ "spkiDer": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/JhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p1E+R4VYGtjzO2rRo", "use": "tls", "source": [ - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", - "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" + "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449", + "https://firefox.settings.services.mozilla.com/v1/buckets/security-state/collections/onecrl/records/5ed2b441-a5bf-4f8e-89af-d01fa292a9b8" ], - "reported": "2026-10-17", + "reported": "2026-10-18", "status": "compromised" }, { @@ -139,10 +138,9 @@ "spkiDer": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr5eBQ", "use": "codeSigning", "source": [ - "https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/", "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449" ], - "reported": "2026-10-17", + "reported": "2026-10-18", "status": "compromised" } ] diff --git a/pkg/patterns/trust_table_test.go b/pkg/patterns/trust_table_test.go index 35f4924..a848df0 100644 --- a/pkg/patterns/trust_table_test.go +++ b/pkg/patterns/trust_table_test.go @@ -114,7 +114,8 @@ func readLines(t *testing.T, path string) []string { // Every row of the embedded key table is what the fixture certificates give: // the certificate hashes, the SPKI hash and DER, the subject common name the -// certificates share, the use of the directory they sit in, and the listing. +// certificates share and the use of the directory they sit in. Its source and +// reported are what the committed records capture gives it. func TestTrustKeyTableRecomputesFromFixtures(t *testing.T) { type group struct { dir string @@ -146,6 +147,7 @@ func TestTrustKeyTableRecomputesFromFixtures(t *testing.T) { if len(table.keys) != len(groups) { t.Errorf("the key table has %d rows; the fixture certificates have %d keys", len(table.keys), len(groups)) } + records := readSourceRecords(t) for i, k := range table.keys { g, ok := groups[k.SPKISHA256] if !ok { @@ -172,9 +174,16 @@ func TestTrustKeyTableRecomputesFromFixtures(t *testing.T) { if k.Status != wantStatus || k.DistrustDate != wantDate { t.Errorf("row %d: status %q, distrustDate %q; want %q, %q", i, k.Status, k.DistrustDate, wantStatus, wantDate) } - // The listing's two pages, sorted. - if !slices.Equal(k.Source, []string{trustBlogURL, trustBugURL}) || k.Reported != "2026-10-17" { - t.Errorf("row %d: source %q, reported %q", i, k.Source, k.Reported) + // The sorted URLs of the captured records that list the key, and the + // date of the earliest of them that reports its status. + for _, f := range sourceAndReportedFaults(t, []*trustKey{k}, records) { + t.Errorf("row %d: %s", i, f) + } + // The root is cited by the bug's comment 18 and the post, which name + // it, and not by the bug's opening comment, which does not. + if k.Use == "root" && (!slices.Contains(k.Source, trustBugURL+"#c18") || !slices.Contains(k.Source, trustBlogURL) || + slices.ContainsFunc(k.Source, func(u string) bool { return strings.HasSuffix(u, "id=2083449") })) { + t.Errorf("row %d: the root's source %q is not comment 18 of the bug and the post", i, k.Source) } } @@ -422,6 +431,205 @@ func TestTrustKeyTableMatchesCapturedValues(t *testing.T) { } } +// sourceRecord is one line of the records capture: a public record the key +// table cites, the UTC time it states for itself, its role (status when it +// reports the key's status, lists when it only lists the key) and the keys +// it lists. +type sourceRecord struct { + url, published, role, lists string +} + +// readSourceRecords returns the records of the committed records capture in +// file order. Its columns are separated by single spaces; the last, lists, +// may hold spaces. +func readSourceRecords(t *testing.T) []sourceRecord { + t.Helper() + var records []sourceRecord + for _, line := range readLines(t, filepath.Join(trustCapturedDir, "source-records.txt")) { + if strings.HasPrefix(line, "#") { + continue + } + f := strings.SplitN(line, " ", 4) + if len(f) != 4 { + t.Fatalf("source-records.txt: %q is not url, published, role and lists", line) + } + records = append(records, sourceRecord{url: f[0], published: f[1], role: f[2], lists: f[3]}) + } + if len(records) == 0 { + t.Fatal("source-records.txt holds no record") + } + return records +} + +// date returns the UTC date of the time the record states for itself. +func (r sourceRecord) date(t *testing.T) string { + t.Helper() + at, err := time.Parse(time.RFC3339, r.published) + if err != nil || !strings.HasSuffix(r.published, "Z") { + t.Fatalf("%s: published %q is not a UTC time (%v)", r.url, r.published, err) + } + return at.Format("2006-01-02") +} + +// selects reports whether the record lists row k: certs: lists every +// row with a certificate in that captured file, pkh: the row whose +// SPKI digest that is, and root: the root row of that subject name. +func (r sourceRecord) selects(t *testing.T, k *trustKey) bool { + t.Helper() + kind, value, _ := strings.Cut(r.lists, ":") + switch kind { + case "certs": + listed := readCaptured(t, value) + return slices.ContainsFunc(k.CertSHA256, func(c string) bool { _, ok := listed[c]; return ok }) + case "pkh": + digest, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatalf("%s: pkh %q: %v", r.url, value, err) + } + return hex.EncodeToString(digest) == k.SPKISHA256 + case "root": + return k.Use == "root" && k.Name == value + } + t.Fatalf("%s: lists %q is not certs:, pkh: or root:", r.url, r.lists) + return false +} + +// recordsGive returns the source and reported the records give row k: the +// sorted URLs of the records that list it, and the UTC date of the earliest +// of those whose role is status, or "" when none is. +func recordsGive(t *testing.T, k *trustKey, records []sourceRecord) (source []string, reported string) { + t.Helper() + for _, r := range records { + if !r.selects(t, k) { + continue + } + source = append(source, r.url) + if d := r.date(t); r.role == "status" && (reported == "" || d < reported) { + reported = d + } + } + slices.Sort(source) + return source, reported +} + +// sourceAndReportedFaults returns each way the rows' source and reported +// differ from what the records give them: a source other than the sorted +// URLs of the records that list the row, a reported other than the date of +// the earliest of those that reports its status, an entry that is no +// captured record, and a reported other than the earliest date of the +// records its source names. +func sourceAndReportedFaults(t *testing.T, keys []*trustKey, records []sourceRecord) []string { + t.Helper() + dates := map[string]string{} + for _, r := range records { + dates[r.url] = r.date(t) + } + var faults []string + for _, k := range keys { + source, reported := recordsGive(t, k, records) + if !slices.Equal(k.Source, source) { + faults = append(faults, fmt.Sprintf("key %s: source %q; the records that list it are %q", k.SPKISHA256, k.Source, source)) + } + if reported == "" || k.Reported != reported { + faults = append(faults, fmt.Sprintf("key %s: reported %q; the earliest record that reports its status is of %q", k.SPKISHA256, k.Reported, reported)) + } + earliest := "" + for _, u := range k.Source { + d, ok := dates[u] + if !ok { + faults = append(faults, fmt.Sprintf("key %s: source entry %s is no captured record", k.SPKISHA256, u)) + continue + } + if earliest == "" || d < earliest { + earliest = d + } + } + if k.Reported != earliest { + faults = append(faults, fmt.Sprintf("key %s: reported %q; the earliest of its source records is of %q", k.SPKISHA256, k.Reported, earliest)) + } + } + return faults +} + +// The check of source and reported refuses a table the records do not give. +// A copy of the table whose rows carry what the records give them has no +// fault. The same copy is refused, by a fault that names only the edited +// row, when one row's reported is a day before its earliest record, or when +// a row's source holds a captured record that does not list it. +func TestTrustSourceAndReportedCheckRefusesWhatTheRecordsDoNotGive(t *testing.T) { + records := readSourceRecords(t) + recorded := func() []*trustKey { + t.Helper() + var keys []*trustKey + for _, k := range trustKeys().keys { + c := *k + c.CertSHA256 = slices.Clone(k.CertSHA256) + c.Source, c.Reported = recordsGive(t, k, records) + keys = append(keys, &c) + } + return keys + } + if faults := sourceAndReportedFaults(t, recorded(), records); len(faults) != 0 { + t.Fatalf("the table as the records give it has faults, so a refusal below would not be its edit's doing: %q", faults) + } + refused := func(t *testing.T, keys []*trustKey, k *trustKey) { + t.Helper() + faults := sourceAndReportedFaults(t, keys, records) + if len(faults) == 0 { + t.Fatalf("read with no fault; want key %s refused", k.SPKISHA256) + } + for _, f := range faults { + if !strings.Contains(f, k.SPKISHA256) { + t.Errorf("fault %q names another key; want only key %s", f, k.SPKISHA256) + } + } + t.Logf("refused: %s", strings.Join(faults, "; ")) + } + // citing returns a captured record that does not list row k. + citing := func(t *testing.T, k *trustKey) string { + t.Helper() + for _, r := range records { + if !r.selects(t, k) { + return r.url + } + } + t.Fatalf("every captured record lists key %s", k.SPKISHA256) + return "" + } + + for i := range recorded() { + keys := recorded() + k := keys[i] + t.Run(fmt.Sprintf("row %d, %s, reported a day before its earliest record", i, k.Use), func(t *testing.T) { + earliest, err := time.Parse("2006-01-02", k.Reported) + if err != nil { + t.Fatal(err) + } + k.Reported = earliest.AddDate(0, 0, -1).Format("2006-01-02") + refused(t, keys, k) + }) + } + for _, c := range []struct { + name string + row func(k *trustKey) bool + }{ + {"the root row citing a record that does not list it", func(k *trustKey) bool { return k.Use == "root" }}, + {"a compromised row citing a record that does not list it", func(k *trustKey) bool { return k.Status == trustStatusCompromised }}, + } { + t.Run(c.name, func(t *testing.T) { + keys := recorded() + i := slices.IndexFunc(keys, c.row) + if i < 0 { + t.Fatalf("the key table has no row for %q", c.name) + } + k := keys[i] + k.Source = append(k.Source, citing(t, k)) + slices.Sort(k.Source) + refused(t, keys, k) + }) + } +} + // Every row has a fix for its use and a kind for its status. func TestTrustTableRowsHaveAFixAndAKind(t *testing.T) { for _, k := range trustKeys().keys { @@ -545,10 +753,13 @@ func TestTrustRulesWithGeneratedKeys(t *testing.T) { return k } compromised, root, other := newKey(), newKey(), newKey() + // The generated rows cite the first captured record, with the date it + // states for itself. + cited := readSourceRecords(t)[0] row := func(pub any, name, use, status, date string) map[string]any { spki := spkiOf(t, pub) r := map[string]any{"spkiSha256": sha256Hex(spki), "name": name, "certSha256": []string{}, "spkiDer": base64.StdEncoding.EncodeToString(spki), - "use": use, "source": []string{trustBugURL}, "reported": "2026-10-17", "status": status} + "use": use, "source": []string{cited.url}, "reported": cited.date(t), "status": status} if date != "" { r["distrustDate"] = date }