package testkit import ( "bytes" "crypto/sha256" "encoding/binary" "encoding/hex" "encoding/json" "fmt" "strconv" "strings" "g.activething.com/go/DateKeys/capsule" "g.activething.com/go/DateKeys/datekey" "g.activething.com/go/DateKeys/internal/cbortest" "g.activething.com/go/DateKeys/profile" ) // DifferentialSeed seeds the generator of the differential corpus. const DifferentialSeed = 20260925 // DifferentialFile is testdata/vectors/inspect_differential.json. type DifferentialFile struct { Spec string `json:"spec"` Description string `json:"description"` Format string `json:"format"` Seed uint64 `json:"seed"` Bases []DifferentialBase `json:"bases"` Mutations []DifferentialCase `json:"mutations"` } // DifferentialBase is an official .dkc fixture the mutations edit. type DifferentialBase struct { File string `json:"file"` SHA256 string `json:"sha256"` } // DifferentialCase is one mutation and the verdict of capsule.Inspect. type DifferentialCase struct { Base int `json:"base"` // index into Bases Kind string `json:"kind"` Edits []Edit `json:"edits"` // Result is ResultOK or the normative code of the failure. Result string `json:"result"` // Step is the step of spec §63 that failed; absent when Result is ok. Step int `json:"step,omitempty"` } const differentialFormat = "Each mutation is bases[base].file (in testdata/fixtures) with its edits applied. " + "An edit is [at, delete, insert]: the delete bytes at offset at of the base are replaced by the bytes of the hex string insert. " + "The edits of one mutation refer to offsets of the unmodified base, are sorted by offset and do not overlap. " + "result is the verdict of steps 1 to 8 of spec §63 (capsule.Inspect, the Quicknet profile pinned, no extension known, no network, no secret): " + "ok, or the normative error code, with step the step that failed. kind names the generator of the mutation and is informative." // Kinds of differential mutations and how many each base gets. var differentialKinds = []struct { kind string count int }{ {"flip", 60}, // one bit flipped {"byte", 30}, // one byte replaced {"truncate", 25}, // the file cut short {"insert", 30}, // one to four bytes inserted {"delete", 30}, // one to four bytes deleted {"length", 25}, // PUBLIC_HEADER_LEN or SEALED_CONTROL_LEN edited {"header", 80}, // PUBLIC_HEADER re-encoded with a CBOR-aware change {"datekey", 25}, // PUBLIC_HEADER re-encoded with another DateKey string {"age", 60}, // an age header of SEALED_CONTROL or PAYLOAD_AGE edited } // InspectVerdict runs capsule.Inspect on dkc with the default registry and // no extension known, and returns ResultOK or the error code, and the step // that failed. func InspectVerdict(dkc []byte) (string, int) { in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: Registry()}) if err == nil { return ResultOK, 0 } step := 0 if n := len(in.Checks); n > 0 && !in.Checks[n-1].OK { step = in.Checks[n-1].Step } return Result(err), step } // splitmix64 is the generator of the corpus: fixed, simple and independent // of the Go release. type splitmix64 struct{ s uint64 } func (r *splitmix64) next() uint64 { r.s += 0x9e3779b97f4a7c15 z := r.s z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9 z = (z ^ (z >> 27)) * 0x94d049bb133111eb return z ^ (z >> 31) } // intn returns a number in [0, n). func (r *splitmix64) intn(n int) int { return int(r.next() % uint64(n)) } func (r *splitmix64) byte() byte { return byte(r.next()) } func (r *splitmix64) bytes(n int) []byte { b := make([]byte, n) for i := range b { b[i] = r.byte() } return b } func pick[T any](r *splitmix64, s []T) T { return s[r.intn(len(s))] } // diffBase is a base fixture and the offsets of its sections. type diffBase struct { file string dkc []byte parts Parts header map[uint64]any sealedAt, payloadAt int // offsets of SEALED_CONTROL and PAYLOAD_AGE sealedHdr, payHdr int // lengths of their age headers interesting int // the end of the bytes Inspect reads, and a little more regions [][2]int } func newDiffBase(dir, name string) (*diffBase, error) { f, err := LoadFixture(dir, name) if err != nil { return nil, err } b := &diffBase{file: f.File, dkc: f.DKC, parts: f.Parts} if b.header, err = cbortest.UnmarshalMap(f.Parts.Header); err != nil { return nil, err } b.sealedAt = capsule.PreludeSize + len(f.Parts.Header) b.payloadAt = b.sealedAt + len(f.Parts.Sealed) if b.sealedHdr, err = HeaderLen(f.Parts.Sealed); err != nil { return nil, err } if b.payHdr, err = HeaderLen(f.Parts.Payload); err != nil { return nil, err } b.interesting = min(len(b.dkc), b.payloadAt+b.payHdr+64) b.regions = [][2]int{ {0, capsule.PreludeSize}, {capsule.PreludeSize, b.sealedAt}, {b.sealedAt, b.sealedAt + b.sealedHdr}, {b.sealedAt + b.sealedHdr, b.payloadAt}, {b.payloadAt, b.payloadAt + b.payHdr}, {b.payloadAt + b.payHdr, len(b.dkc)}, } return b, nil } // position picks an offset, weighted towards the bytes steps 1 to 8 read: // the prelude, the header, and the age headers of the two age files. func (b *diffBase) position(r *splitmix64) int { weights := []int{15, 30, 25, 5, 20, 5} n := r.intn(100) for i, w := range weights { if n < w { reg := b.regions[i] if reg[1] > reg[0] { return reg[0] + r.intn(reg[1]-reg[0]) } break } n -= w } return r.intn(len(b.dkc)) } // lengths returns an edit of the prelude that sets the section lengths. func (b *diffBase) lengths(headerLen, sealedLen int) Edit { var v [8]byte binary.BigEndian.PutUint32(v[0:4], uint32(headerLen)) binary.BigEndian.PutUint32(v[4:8], uint32(sealedLen)) return Edit{At: 8, Delete: 8, Insert: v[:]} } // InspectDifferential computes testdata/vectors/inspect_differential.json: // deterministic mutations of the official .dkc fixtures of dir, each with // the verdict of capsule.Inspect. func InspectDifferential(dir string, names []string) (DifferentialFile, error) { f := DifferentialFile{ Spec: SpecVersion, Description: "Differential corpus of the pre-unlock checks (spec §63 steps 1 to 8): deterministic mutations of the official .dkc fixtures " + "with the verdict of the reference implementation. See testdata/README.md.", Format: differentialFormat, Seed: DifferentialSeed, } r := &splitmix64{s: DifferentialSeed} for bi, name := range names { b, err := newDiffBase(dir, name) if err != nil { return f, err } sum := sha256.Sum256(b.dkc) f.Bases = append(f.Bases, DifferentialBase{File: b.file, SHA256: hex.EncodeToString(sum[:])}) seen := map[[32]byte]bool{sum: true} for _, k := range differentialKinds { made := 0 for attempt := 0; made < k.count; attempt++ { if attempt > 50*k.count { return f, fmt.Errorf("differential %s %s: only %d distinct mutations", name, k.kind, made) } edits, err := b.mutate(r, k.kind) if err != nil { return f, fmt.Errorf("differential %s %s: %w", name, k.kind, err) } if edits, err = normalizeEdits(b.dkc, edits); err != nil { return f, err } out, err := ApplyEdits(b.dkc, edits) if err != nil { return f, err } h := sha256.Sum256(out) if len(edits) == 0 || seen[h] { continue } seen[h] = true result, step := InspectVerdict(out) f.Mutations = append(f.Mutations, DifferentialCase{Base: bi, Kind: k.kind, Edits: edits, Result: result, Step: step}) made++ } } } return f, nil } func (b *diffBase) mutate(r *splitmix64, kind string) ([]Edit, error) { switch kind { case "flip": p := b.position(r) return []Edit{{At: p, Delete: 1, Insert: []byte{b.dkc[p] ^ 1<> 32) } return v ^ 1<<(8+r.intn(24)) } switch r.intn(4) { case 0: hl = vary(hl) case 1: sl = vary(sl) case 2: hl, sl = sl, hl default: hl, sl = vary(hl), vary(sl) } return []Edit{b.lengths(hl, sl)} } // replaceHeader returns the edits that replace PUBLIC_HEADER with h and, // unless keepPrelude, set PUBLIC_HEADER_LEN to its length. func (b *diffBase) replaceHeader(h []byte, keepPrelude bool) []Edit { edits := []Edit{{At: capsule.PreludeSize, Delete: len(b.parts.Header), Insert: h}} if !keepPrelude { edits = append([]Edit{b.lengths(len(h), len(b.parts.Sealed))}, edits...) } return edits } // dateKey returns the DateKey of the base header. func (b *diffBase) dateKey() datekey.DateKey { if s, ok := b.header[3].(string); ok { if d, err := datekey.Parse(s); err == nil { return d } } return datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000} } func (b *diffBase) headerEdit(r *splitmix64) ([]Edit, error) { m := with(b.header) dk := b.dateKey() var v any = m switch r.intn(12) { case 0: // remove a key keys := sortedKeys(m) delete(m, pick(r, keys)) case 1: // add a key m[pick(r, []uint64{5, 6, 7, 8, 23, 24, 255, 65535, 1 << 32})] = r.value() case 2: // change the type of a value m[pick(r, sortedKeys(m))] = r.value() case 3: m[0] = pick(r, []string{"", "datekeyca", "datekeycapx", "DATEKEYCAP", capsule.ControlTypeTag, "datekeys-access-key", strings.Repeat("a", 65)}) case 4: m[1] = pick(r, []any{uint64(0), uint64(2), uint64(255), uint64(256), uint64(1 << 32), uint64(1<<53 - 1), uint64(1 << 53), uint64(1<<64 - 1), "1", -1}) case 5: m[2] = r.bytes(pick(r, []int{0, 1, 15, 17, 32})) case 6: m[3] = r.dateKey(dk) case 7: m[4] = pick(r, []any{uint64(2), uint64(3), uint64(23), uint64(24), uint64(255), uint64(256), uint64(257), uint64(65536), uint64(1 << 32), uint64(1<<53 - 1), uint64(1 << 53)}) case 8: // an extension array key := uint64(5 + r.intn(2)) arr := r.extensions() if arr == nil { // The same extension in both arrays. m[5] = []any{ext("org.example.a", 1)} m[6] = []any{ext("org.example.a", 1)} } else { m[key] = arr } case 9: // a head not in its shortest form enc, err := cbortest.Marshal(m) if err != nil { return nil, err } switch r.intn(4) { case 0: v = cbortest.Raw(append([]byte{0xb8, enc[0] & 0x1f}, enc[1:]...)) case 1: m[4] = cbortest.Raw(pick(r, [][]byte{{0x18, 0x00}, {0x18, 0x01}, {0x19, 0x00, 0x00}, {0x1b, 0, 0, 0, 0, 0, 0, 0, 0}})) case 2: m[1] = cbortest.Raw{0x18, 0x01} default: if id, ok := m[2].([]byte); ok { m[2] = cbortest.Raw(append([]byte{0x58, byte(len(id))}, id...)) } } case 10: // keys out of order or repeated keys := sortedKeys(m) var p cbortest.Pairs for _, k := range keys { p = append(p, k, m[k]) } i := 2 * r.intn(len(keys)) j := 2 * r.intn(len(keys)) if r.intn(2) == 0 { p[i], p[j] = p[j], p[i] p[i+1], p[j+1] = p[j+1], p[i+1] } else { p = append(p, p[i], p[i+1]) } v = p default: // the whole item wrapped or framed differently enc, err := cbortest.Marshal(m) if err != nil { return nil, err } switch r.intn(6) { case 0: v = cbortest.Raw(append([]byte{0xc1}, enc...)) case 1: v = []any{cbortest.Raw(enc)} case 2: v = cbortest.Raw(append(append([]byte{0xbf}, enc[1:]...), 0xff)) case 3: v = cbortest.Raw(append(enc, 0x00)) case 4: v = cbortest.Raw(append(enc, enc...)) default: v = cbortest.Raw(enc[:len(enc)-1]) } } h, err := cbortest.Marshal(v) if err != nil { return nil, err } return b.replaceHeader(h, r.intn(10) == 0), nil } func sortedKeys(m map[uint64]any) []uint64 { var keys []uint64 for k := range m { keys = append(keys, k) } for i := 1; i < len(keys); i++ { for j := i; j > 0 && keys[j] < keys[j-1]; j-- { keys[j], keys[j-1] = keys[j-1], keys[j] } } return keys } // value returns a value of a random type, most of them outside the profile. func (r *splitmix64) value() any { return pick(r, []any{ uint64(0), uint64(1), uint64(1 << 53), "x", []byte{0}, []byte{}, []any{}, map[uint64]any{}, cbortest.Raw{0xf6}, cbortest.Raw{0xf5}, cbortest.Raw{0x20}, cbortest.Raw{0xc1, 0x00}, cbortest.Raw{0xf9, 0x3c, 0x00}, cbortest.Raw{0x5f, 0x40, 0xff}, }) } // dateKey returns a DateKey string derived from dk, most of them invalid. func (r *splitmix64) dateKey(dk datekey.DateKey) any { s := dk.Compact() enc := func(j string) string { return datekey.Prefix + b64(j) } switch r.intn(11) { case 0: // one character changed i := len(datekey.Prefix) + r.intn(len(s)-len(datekey.Prefix)) c := pick(r, []byte("AZaz09-_=+/ .")) return s[:i] + string(c) + s[i+1:] case 1: return s[:len(datekey.Prefix)+r.intn(len(s)-len(datekey.Prefix))] case 2: return s + "=" case 3: return "DK1_" + s[len(datekey.Prefix):] case 4: // canonical, but of a profile that is not pinned return datekey.DateKey{ProfileID: pick(r, []string{"datekeys:evmnet:v1", "datekeys:quicknet:v2", "drand:quicknet"}), Round: dk.Round}.Compact() case 5: // canonical, but of a round the tlock stanza does not name or the profile never reaches return datekey.DateKey{ProfileID: dk.ProfileID, Round: pick(r, []uint64{1, dk.Round - 1, dk.Round + 1, 66884212, profile.Quicknet().MaxRound(), profile.Quicknet().MaxRound() + 1, datekey.MaxRound})}.Compact() case 6: return enc(fmt.Sprintf(`{"version":1,"network":"%s","round":%s}`, dk.ProfileID, pick(r, []string{"0", "-1", "1.0", "1e3", "9007199254740992", strconv.FormatUint(dk.Round+1, 10), `"1000"`}))) case 7: return enc(fmt.Sprintf(`{"version":1, "network":"%s", "round":%d}`, dk.ProfileID, dk.Round)) case 8: return datekey.Prefix case 9: return cbortest.Raw(append([]byte{0x62}, 0xc0, 0x80)) } return s + strings.Repeat("A", 200+r.intn(100)) } func (r *splitmix64) extensions() []any { many := func(n int) []any { out := make([]any, n) for i := range out { out[i] = ext(fmt.Sprintf("org.example.%03d", i), 1) } return out } switch r.intn(16) { case 0: return []any{} case 1: return []any{ext("org.example.a", 1)} case 2: return []any{ext("org.example.a", 1, r.bytes(1+r.intn(8)))} case 3: return []any{ext("org.example.a", 1, []byte{})} case 4: return []any{ext("org.example.a", 1, pick(r, []any{"text", uint64(7), cbortest.Raw{0xf6}, map[uint64]any{}, []any{}, cbortest.Raw{0x58, 0x01, 0x2a}, cbortest.Raw{0xc1, 0x41, 0x00}}))} case 5: return []any{ext("org.example.a", 1<<32)} case 6: return many(64) case 7: return many(65) case 8: return []any{ext("org.example.b", 1), ext("org.example.a", 1)} case 9: return []any{ext("org.example.a", 1), ext("org.example.a", 1)} case 10: return []any{map[uint64]any{0: "org.example.a", 1: uint64(1), 3: uint64(0)}} case 11: return []any{map[uint64]any{0: "org.example.a"}} case 12: return []any{ext("\ufefforg.example.a", 1)} case 13: return []any{ext("\U00010000", 1), ext("\uff61", 1)} case 14: return []any{ext("\uff61", 1), ext("\U00010000", 1)} } return nil } // ageEdit edits the age header of SEALED_CONTROL (and then, most of the // time, SEALED_CONTROL_LEN) or of PAYLOAD_AGE. func (b *diffBase) ageEdit(r *splitmix64) ([]Edit, error) { sealed := r.intn(5) < 3 at, n := b.payloadAt, b.payHdr if sealed { at, n = b.sealedAt, b.sealedHdr } hdr := string(b.dkc[at : at+n]) lines := strings.SplitAfter(hdr, "\n") lines = lines[:len(lines)-1] // SplitAfter leaves an empty string after the final newline // lines: intro, stanza lines (argument line, body lines), MAC line. stanzaAt := 1 mac := len(lines) - 1 switch r.intn(11) { case 0: // intro line lines[0] = pick(r, []string{"age-encryption.org/v2\n", "age-encryption.org/V1\n", "age-encryption.org/v1 \n", "age-encryption.org/\n", "\n"}) case 1: // stanza type f := strings.Fields(lines[stanzaAt]) f[1] = pick(r, []string{"tlock", "TLOCK", "X25519", "x25519", "scrypt", "tlock2", "t"}) lines[stanzaAt] = strings.Join(f, " ") + "\n" case 2: // one argument f := strings.Fields(lines[stanzaAt]) if len(f) > 2 { i := 2 + r.intn(len(f)-2) f[i] = r.arg(f[i]) lines[stanzaAt] = strings.Join(f, " ") + "\n" } case 3: // one more argument, or one less f := strings.Fields(lines[stanzaAt]) if r.intn(2) == 0 { f = append(f, pick(r, []string{"extra", "1000", "AAAA"})) } else if len(f) > 2 { f = f[:len(f)-1] } lines[stanzaAt] = strings.Join(f, " ") + "\n" case 4: // the stanza twice stanza := lines[stanzaAt:mac] lines = append(append(append([]string{}, lines[:mac]...), stanza...), lines[mac]) case 5: // an extra stanza extra := pick(r, []string{ "-> X25519 " + strings.Repeat("A", 43) + "\n" + strings.Repeat("B", 43) + "\n", "-> scrypt c2FsdHNhbHRzYWx0c2FsdA 18\n" + strings.Repeat("C", 43) + "\n", "-> tlock 1000 " + profile.Quicknet().ChainHashHex() + "\n" + strings.Repeat("D", 64) + "\n\n", "-> grease-x !@#\n\n", }) lines = append(append(append([]string{}, lines[:mac]...), extra), lines[mac]) case 6: // no stanza lines = []string{lines[0], lines[mac]} case 7: // a body line if mac-stanzaAt > 1 { i := stanzaAt + 1 + r.intn(mac-stanzaAt-1) l := strings.TrimSuffix(lines[i], "\n") switch r.intn(4) { case 0: if len(l) > 0 { j := r.intn(len(l)) l = l[:j] + string(pick(r, []byte("A/+=_-"))) + l[j+1:] } case 1: l += "A" case 2: l += " " default: if len(l) > 0 { l = l[:len(l)-1] } } lines[i] = l + "\n" } case 8: // the MAC line l := lines[mac] switch r.intn(4) { case 0: l = "--" + l[3:] case 1: j := 4 + r.intn(len(l)-5) l = l[:j] + string(pick(r, []byte("AB/+"))) + l[j+1:] case 2: l = "---\n" default: l = strings.TrimSuffix(l, "\n") + " \n" } lines[mac] = l case 9: // a line ending i := r.intn(len(lines)) lines[i] = strings.TrimSuffix(lines[i], "\n") + "\r\n" default: // an empty line i := 1 + r.intn(len(lines)-1) lines = append(append(append([]string{}, lines[:i]...), "\n"), lines[i:]...) } edited := []byte(strings.Join(lines, "")) edits := []Edit{{At: at, Delete: n, Insert: edited}} if sealed && r.intn(10) != 0 { edits = append([]Edit{b.lengths(len(b.parts.Header), len(b.parts.Sealed)-n+len(edited))}, edits...) } return edits, nil } // arg returns a variant of a stanza argument. func (r *splitmix64) arg(a string) string { if _, err := strconv.ParseUint(a, 10, 64); err == nil { return pick(r, []string{"999", "1001", "0" + a, a + "0", "0", "-1", "18446744073709551616", a + "a", "2000"}) } if len(a) == 0 { return "A" } i := r.intn(len(a)) switch r.intn(4) { case 0: return a[:i] + string(pick(r, []byte("0aAf/+"))) + a[i+1:] case 1: return strings.ToUpper(a) case 2: return a[:len(a)-1] } return a + "A" } // MarshalDifferential writes f with one mutation per line. func MarshalDifferential(f DifferentialFile) ([]byte, error) { head := f head.Mutations = nil b, err := json.MarshalIndent(head, "", " ") if err != nil { return nil, err } // Replace the closing "\n}" and the null mutations. b = bytes.TrimSuffix(b, []byte("\n}")) b = bytes.TrimSuffix(b, []byte(",\n \"mutations\": null")) var out bytes.Buffer out.Write(b) out.WriteString(",\n \"mutations\": [\n") for i, m := range f.Mutations { line, err := json.Marshal(m) if err != nil { return nil, err } out.WriteString(" ") out.Write(line) if i < len(f.Mutations)-1 { out.WriteByte(',') } out.WriteByte('\n') } out.WriteString(" ]\n}\n") return out.Bytes(), nil } // WriteDifferential writes f to path with MarshalDifferential. func WriteDifferential(path string, f DifferentialFile) error { b, err := MarshalDifferential(f) if err != nil { return err } return writeFile(path, b) }