You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DateKeys/internal/testkit/genfixtures/main.go

952 lines
37 KiB

// Command genfixtures generates the official DateKeys vectors and fixtures
// (spec §65-§68) into testdata/.
//
// Fixtures are generated once, over rounds that are already published, and
// then committed: age randomness cannot be injected through its public API,
// so they are decryption and validation fixtures, not byte-reproducible
// encryption outputs (spec §67). Existing format 2 and 3 fixtures are never
// overwritten unless -force (every one) or -only (the named ones) is given;
// vectors are always regenerated, and the tests fail if the implementation
// stops reproducing the committed ones.
//
// The format 1 fixtures are the ones of spec v0.8.2, kept as compatibility
// fixtures (spec §67, §70). They are never regenerated: only a generator of
// test vectors may write format 1, and these were written by the v0.8.2
// reference itself. The .dkk with an extension (spec §68) is derived from the
// portable .dkk of time_and_key_portable, a format 1 fixture, with -only.
//
// Derived from the fixtures and always regenerated, like the vectors: the
// fields of each fixture record that the implementation computes (spec
// version, format, lengths and the stages of spec §63), the frozen
// "datekeys inspect -json" output of each .dkc (<name>.inspect.json), the
// exported mutation corpus (vectors/mutations.json) and the differential
// corpus of the pre-unlock checks (vectors/inspect_differential.json). The
// mutations whose capsule is built with age randomness keep the bytes
// recorded in the committed mutations.json; -force, or -only mutations,
// builds them afresh. A fixture a mutation derives from must not be
// regenerated alone: regenerate the mutations with it.
//
// go run ./internal/testkit/genfixtures -out testdata
// go run ./internal/testkit/genfixtures -out testdata -only format2_time_only_extensions,mutations
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/authorkey"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
func main() {
out := flag.String("out", "testdata", "output directory")
force := flag.Bool("force", false, "overwrite every existing format 2 and 3 fixture")
only := flag.String("only", "", "comma-separated format 2 and 3 fixture names to regenerate, overwriting them, \""+extDKK+"\" to derive the .dkk with an extension again, and \"mutations\" to rebuild the randomly built mutations; every other fixture is left untouched")
flag.Parse()
sel, err := selection(*force, *only)
if err != nil {
log.Fatal(err)
}
if err := vectors(filepath.Join(*out, "vectors")); err != nil {
log.Fatal(err)
}
if err := fixtures(filepath.Join(*out, "fixtures"), sel); err != nil {
log.Fatal(err)
}
if err := derived(*out, sel); err != nil {
log.Fatal(err)
}
}
// selector decides which fixtures are (re)generated.
type selector struct {
force bool // overwrite every format 2 and 3 fixture
only map[string]bool // when non-empty, regenerate exactly these
}
func selection(force bool, only string) (selector, error) {
sel := selector{force: force, only: map[string]bool{}}
if only == "" {
return sel, nil
}
known := map[string]bool{extDKK: true, mutationsName: true}
frozen := map[string]bool{}
for _, s := range specs() {
if s.format == capsule.Format1 {
frozen[s.name] = true
} else {
known[s.name] = true
}
}
for _, name := range strings.Split(only, ",") {
if frozen[name] {
return sel, fmt.Errorf("-only: %q is a format 1 fixture of spec v0.8.2, kept frozen as a compatibility fixture", name)
}
if !known[name] {
return sel, fmt.Errorf("-only: unknown fixture %q", name)
}
sel.only[name] = true
}
return sel, nil
}
// generate reports whether the fixture name, whose main file is path, is
// written.
func (s selector) generate(name, path string) bool {
if len(s.only) > 0 {
return s.only[name]
}
_, err := os.Stat(path)
return s.force || err != nil
}
func vectors(dir string) error {
pv, err := testkit.QuicknetProfileVector()
if err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "profile_quicknet.json"), pv); err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "quicknet_rounds.json"), testkit.RoundVectors()); err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "dk1.json"), testkit.DK1Vectors()); err != nil {
return err
}
cv, err := testkit.CBORVectors()
if err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "cbor.json"), cv); err != nil {
return err
}
pad, err := testkit.PaddingVectors()
if err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "padding.json"), pad); err != nil {
return err
}
iv, err := testkit.IBEVectors()
if err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "tlock_ibe.json"), iv); err != nil {
return err
}
return format3Vectors(dir)
}
// format3Vectors writes the vectors of format 3 (spec §67): the paths, the
// keys of R7, the heads and security.
func format3Vectors(dir string) error {
paths, err := testkit.PathVectors()
if err != nil {
return err
}
fold, err := testkit.PathFoldVectors()
if err != nil {
return err
}
heads, err := testkit.HeadSchemaVectors()
if err != nil {
return err
}
security, err := testkit.SecurityVectors()
if err != nil {
return err
}
strict, err := testkit.Ed25519StrictVectors()
if err != nil {
return err
}
note, err := testkit.NoteVectors()
if err != nil {
return err
}
words, err := testkit.WordKeyVectors()
if err != nil {
return err
}
resolved, err := testkit.ResolvedIPVectors()
if err != nil {
return err
}
steps, err := testkit.TlockStepVectors()
if err != nil {
return err
}
for name, v := range map[string]any{"paths.json": paths, "path_fold.json": fold, "head_schema.json": heads, "security.json": security, "ed25519_strict.json": strict, "note.json": note, "wordkey.json": words, "resolved_ip.json": resolved, "tlock_steps.json": steps} {
if err := testkit.WriteJSON(filepath.Join(dir, name), v); err != nil {
return err
}
}
return frozenVectors(dir)
}
// mutationsName is the -only name that rebuilds the capsules of the
// mutations built with age randomness.
const mutationsName = "mutations"
// derived writes what is computed from the fixtures: the computed fields of
// the records, the inspect outputs, the mutation corpus and the differential
// corpus.
func derived(out string, sel selector) error {
fixtureDir := filepath.Join(out, "fixtures")
var names []string
for _, s := range specs() {
if s.format != capsule.Format3 || differentialBases3[s.name] {
names = append(names, s.name)
}
if err := refreshRecord(fixtureDir, s.name); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
if err := inspectOutput(fixtureDir, s.name); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
}
keys, err := filepath.Glob(filepath.Join(fixtureDir, "*.dkk.json"))
if err != nil {
return err
}
for _, path := range keys {
var k testkit.DKKFixture
if err := testkit.ReadJSON(path, &k); err != nil {
return err
}
k.Spec = testkit.SpecVersion
if err := testkit.WriteJSON(path, k); err != nil {
return err
}
}
path := filepath.Join(out, "vectors", "mutations.json")
var frozen *testkit.MutationFile
if !sel.force && !sel.only[mutationsName] {
var f testkit.MutationFile
switch err := testkit.ReadJSON(path, &f); {
case err == nil:
frozen = &f
case !errors.Is(err, fs.ErrNotExist):
return err
}
}
m, err := testkit.MutationCorpus(fixtureDir, frozen)
if err != nil {
return err
}
if err := testkit.WriteJSONEdits(path, m); err != nil {
return err
}
// The format 2 fixtures come after the format 1 ones, and the format 3
// ones after them, so the corpus keeps the cases of the earlier bases and
// adds a block for each new base.
d, err := testkit.InspectDifferential(fixtureDir, names)
if err != nil {
return err
}
return testkit.WriteDifferential(filepath.Join(out, "vectors", "inspect_differential.json"), d)
}
// differentialBases3 are the format 3 fixtures of the differential corpus,
// one for each policy: steps 1 to 8 see nothing of format 3 that format 2
// does not have, but VERSION.
var differentialBases3 = map[string]bool{"format3_single": true, "format3_time_and_key_portable": true}
// refreshRecord recomputes the fields of the record of the fixture name that
// the implementation derives from the fixture: the spec version, the
// format, the content length and, from a complete opening through the public
// API with the credentials the record names, the stages of spec §63.
func refreshRecord(dir, name string) error {
path := filepath.Join(dir, name+".json")
var f testkit.DKCFixture
if err := testkit.ReadJSON(path, &f); err != nil {
return err
}
dkc, err := os.ReadFile(filepath.Join(dir, f.File))
if err != nil {
return err
}
plaintext, err := os.ReadFile(filepath.Join(dir, f.PlaintextFile))
if err != nil {
return err
}
sig, err := hex.DecodeString(f.Release.Signature)
if err != nil {
return err
}
unlock, err := time.Parse(time.RFC3339, f.UnlockAt)
if err != nil {
return err
}
release := provider.Release{Round: f.Release.Round, Signature: sig}
oo := capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(release), Now: testkit.Fixed(unlock)}
switch {
case f.AccessKeyFile != "":
b, err := os.ReadFile(filepath.Join(dir, f.AccessKeyFile))
if err != nil {
return err
}
if oo.AccessKey, err = accesskey.Decode(bytes.NewReader(b)); err != nil {
return err
}
default:
for _, s := range f.Identities {
id, err := age.ParseX25519Identity(s)
if err != nil {
return err
}
oo.Identities = append(oo.Identities, id)
}
}
sink := &testkit.MemorySink{}
oo.Sink = sink
var plain bytes.Buffer
opened, err := capsule.Open(context.Background(), &plain, bytes.NewReader(dkc), oo)
if err != nil {
return fmt.Errorf("fixture does not open: %w", err)
}
if opened.Format == capsule.Format3 {
// The plaintext file holds BODY.
if err := record3(&f, plaintext, &opened.Verdicts); err != nil {
return err
}
if err := check3(&f, plaintext, opened, sink); err != nil {
return err
}
} else if !bytes.Equal(plain.Bytes(), plaintext) {
return errors.New("fixture plaintext mismatch")
}
f.Spec = testkit.SpecVersion
f.Format = int(opened.Format)
f.PayloadLength = opened.PayloadLength
f.Padding, f.PaddedLength = int(opened.Padding), opened.PaddedLength
f.Stages = nil
for _, c := range opened.Inspection.Checks {
f.Stages = append(f.Stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error})
}
return testkit.WriteJSON(path, f)
}
// inspectOutput writes <name>.inspect.json: the output of
// "datekeys inspect -json -in <name>.dkc" run in the fixture directory.
func inspectOutput(dir, name string) error {
file := name + ".dkc"
dkc, err := os.ReadFile(filepath.Join(dir, file))
if err != nil {
return err
}
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: testkit.Registry()})
var b bytes.Buffer
if err := inspectview.New(file, in, err).WriteJSON(&b); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, name+".inspect.json"), b.Bytes(), 0o644)
}
type spec struct {
name, description string
format capsule.Format
round uint64
policy capsule.Policy
recipients int
portable bool
padding capsule.Padding
plaintext []byte
headerExt []extension.Extension
controlExt []extension.Extension
// Format 3: the files, the comment and the declared author. body, when
// not nil, gives the BODY of a capsule that only a generator of test
// vectors writes (spec §62.1 rule 13), and testkit.Build writes it.
files []file3
comment, author string
// signer, when not nil, is the 32-byte seed of the author key that signs
// a format 3 fixture written by EncryptFiles (spec v0.11, §29.9).
signer []byte
// configure, when not nil, sets what only this fixture needs in the
// options of EncryptFiles: a signer with certificates, or a sealer.
configure func(o *capsule.EncryptOptions) error
body func() ([]byte, error)
// area, when not 0, is the security area of a fixture of an earlier
// version, which EncryptFiles writes only for test vectors: 512 bytes in
// the fixtures of v0.10, which are compatibility fixtures (spec §67).
area uint32
}
// Extension data of the fixtures (spec §54, §72): the header carries the raw
// UTF-8 bytes of a label, which are not CBOR; the control carries
// {0: 7, 1: "sealed"} in the CBOR profile of spec §58; the .dkk carries
// {0: "hand"}. The base protocol decodes none of them.
var (
headerExtData = []byte("public label")
controlExtData = mustHex("a2000701667365616c6564")
dkkExtData = mustHex("a1006468616e64")
// signerSeed is the seed of the test key of format3_signed: the SHA-256
// of a text. It is not a secret: anyone may sign with it.
signerSeed = func() []byte { h := sha256.Sum256([]byte("DateKeys fixture author key 1")); return h[:] }()
)
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic(err)
}
return b
}
func mustExt(id string, version uint64, data []byte) extension.Extension {
e, err := extension.New(id, version, data)
if err != nil {
panic(err)
}
return e
}
// specs lists the fixtures: the frozen format 1 fixtures of spec v0.8.2
// first, then the format 2 fixtures of spec v0.9, then the format 3
// fixtures of spec §67.
func specs() []spec {
large := []byte(strings.Repeat("DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk.\n", 1000))
hExt := mustExt("org.example.label", 1, headerExtData)
cExt := mustExt("org.example.note", 2, controlExtData)
f1, f2, f3 := capsule.Format1, capsule.Format2, capsule.Format3
when := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
note := []file3{{path: "nota.txt", content: []byte("Hola desde el pasado.\n"), mtime: when}}
tree := []file3{
{path: "carta.txt", content: []byte("Querida familia:\n\nAbrid esto juntos.\n"), mtime: when},
{path: "fotos/2025/atardecer.jpg", content: patterned("atardecer", 3000)},
{path: "fotos/2025/playa.jpg", content: patterned("playa", 80000), mtime: when.Add(-24 * time.Hour)},
{path: "fotos/leeme.txt", content: []byte("Las fotos del verano.\n"), mtime: when},
{path: "música/canción.txt", content: []byte("La, la, la.\n"), mtime: when},
}
report := []file3{{path: "informe.txt", content: []byte(strings.Repeat("Informe trimestral, sin cifras.\n", 625)), mtime: when}}
secret := []file3{{path: "secreto.txt", content: []byte("DateKeys fixture opened with a portable .dkk.\n"), mtime: when}}
return []spec{
{name: "time_only", format: f1, description: "time_only capsule, two STREAM chunks, no extensions", round: 1000, policy: capsule.TimeOnly, plaintext: large},
{name: "time_only_extensions", format: f1, description: "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension", round: 2000, policy: capsule.TimeOnly, plaintext: []byte("DateKeys fixture with extensions.\n"), headerExt: []extension.Extension{hExt}, controlExt: []extension.Extension{cExt}},
{name: "time_and_key_portable", format: f1, description: "time_and_key capsule whose only recipient is a portable .dkk", round: 1000, policy: capsule.TimeAndKey, portable: true, plaintext: []byte("DateKeys fixture opened with a portable .dkk.\n")},
{name: "time_and_key_recipients", format: f1, description: "time_and_key capsule for two known X25519 recipients and a portable .dkk", round: 1001, policy: capsule.TimeAndKey, recipients: 2, portable: true, plaintext: []byte("DateKeys fixture for several recipients.\n")},
{name: "empty_payload", format: f1, description: "time_only capsule with an empty payload", round: 1001, policy: capsule.TimeOnly, plaintext: []byte{}},
{name: "format2_time_only", format: f2, description: "format 2 time_only capsule, padding code 2 (reforzado): L = 78000, P = 79872, two STREAM chunks", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, plaintext: large},
{name: "format2_time_only_bloque256", format: f2, description: "format 2 time_only capsule with the content of format2_time_only and padding code 1 (bloque256): L = 78000, P = 78080", round: 1000, policy: capsule.TimeOnly, padding: capsule.Bloque256, plaintext: large},
{name: "format2_empty_payload", format: f2, description: "format 2 time_only capsule with an empty content: L = 0, P = 256", round: 1001, policy: capsule.TimeOnly, padding: capsule.Reforzado, plaintext: []byte{}},
{name: "format2_time_only_extensions", format: f2, description: "format 2 time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension", round: 2000, policy: capsule.TimeOnly, padding: capsule.Reforzado, plaintext: []byte("DateKeys fixture with extensions.\n"), headerExt: []extension.Extension{hExt}, controlExt: []extension.Extension{cExt}},
{name: "format2_time_and_key_portable", format: f2, description: "format 2 time_and_key capsule with one credential, a portable .dkk, and 15 dummies", round: 1000, policy: capsule.TimeAndKey, portable: true, padding: capsule.Reforzado, plaintext: []byte("DateKeys fixture opened with a portable .dkk.\n")},
{name: "format2_time_and_key_recipients", format: f2, description: "format 2 time_and_key capsule for three known X25519 recipients and a portable .dkk, and 12 dummies", round: 1001, policy: capsule.TimeAndKey, recipients: 3, portable: true, padding: capsule.Reforzado, plaintext: []byte("DateKeys fixture for several recipients.\n")},
{name: "format2_time_and_key_sixteen", format: f2, description: "format 2 time_and_key capsule for sixteen known X25519 recipients, without dummies", round: 2000, policy: capsule.TimeAndKey, recipients: 16, padding: capsule.Reforzado, plaintext: []byte("DateKeys fixture for sixteen recipients.\n")},
{name: "format3_single", format: f3, area: capsule.AreaUnit, description: "format 3 time_only capsule with a single file, nota.txt, with its mtime", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note},
{name: "format3_tree", format: f3, area: capsule.AreaUnit, description: "format 3 time_only capsule with five files in three folders, one of them over two STREAM chunks and one without mtime, a comment of two lines and a declared author", round: 1001, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: tree, comment: "Para abrir en familia.\nCon cariño, desde 2026.", author: "Ana López"},
{name: "format3_comment_only", format: f3, area: capsule.AreaUnit, description: "format 3 time_only capsule with a comment of two lines, the second one with a TAB, a declared author and no files", round: 1004, policy: capsule.TimeOnly, padding: capsule.Reforzado, comment: "Feliz cumpleaños.\n\tAbre esto dentro de diez años.", author: "Ana"},
{name: "format3_bloque256", format: f3, area: capsule.AreaUnit, description: "format 3 time_only capsule with padding code 1 (bloque256) and one file of 20000 bytes", round: 1000, policy: capsule.TimeOnly, padding: capsule.Bloque256, files: report},
{name: "format3_time_and_key_portable", format: f3, area: capsule.AreaUnit, description: "format 3 time_and_key capsule with one credential, a portable .dkk, and 15 dummies", round: 1000, policy: capsule.TimeAndKey, portable: true, padding: capsule.Reforzado, files: secret},
{name: "format3_area_1024", format: f3, description: "format 3 time_only capsule with a security area of 1024 bytes, as a later version may write it, holding the empty security", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado,
body: func() ([]byte, error) { return body3(1024, capsule.EncodeSecurity(), "", "", note) }},
{name: "format3_security_v2", format: f3, description: "format 3 time_only capsule whose security is of version 2: verdict X", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado,
body: func() ([]byte, error) {
s, err := securityV2()
if err != nil {
return nil, err
}
return body3(capsule.AreaUnit, s, "", "", note)
}},
{name: "format3_signature_unsupported", format: f3, description: "format 3 time_only capsule with an author-signature of alg 4294967295, a random key of 32 bytes and a random signature of 64: verdicts F1 and S0", round: 1001, policy: capsule.TimeOnly, padding: capsule.Reforzado,
body: func() ([]byte, error) {
sig, err := unsupportedSignature()
if err != nil {
return nil, err
}
s, err := capsule.EncodeSecurityWith(sig, nil)
if err != nil {
return nil, err
}
return body3(capsule.AreaUnit, s, "", "", note)
}},
{name: "format3_seal_unsupported", format: f3, description: "format 3 time_only capsule with an author-signature of alg 4294967295, as in format3_signature_unsupported, and a seal of seal_type 4294967295, reserved for tests, with a random token of 32 bytes: verdicts F1 and S1", round: 2000, policy: capsule.TimeOnly, padding: capsule.Reforzado,
body: func() ([]byte, error) {
sig, err := unsupportedSignature()
if err != nil {
return nil, err
}
seal, err := capsule.EncodeSeal(capsule.SealTypeTest, randomBytes(32))
if err != nil {
return nil, err
}
s, err := capsule.EncodeSecurityWith(sig, seal)
if err != nil {
return nil, err
}
return body3(capsule.AreaUnit, s, "", "", note)
}},
{name: "format3_unsigned", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, as format3_signed, without a signature: the area of 32 KiB of spec v0.11 holds the empty security, and P is the one of format3_signed", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note},
{name: "format3_signed", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, signed with alg 1 by a test key whose seed the record gives: verdict F4, and the commitments and the message of the signature", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, signer: signerSeed},
{name: "format3_signed_cms", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, signed with alg 2 by two test certificates, an ECDSA P-256 one and an RSA 2048 one, each sealed by a test time-stamping authority before the round time: verdict F6, with the certificates, the commitments, SIGNERS and the result of each signer in the record", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, configure: configureCMS},
{name: "format3_note", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, and the public note «Cartas del viaje a Lisboa» in the noncritical array of PUBLIC_HEADER (spec v0.11, §24.1)", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note,
configure: func(o *capsule.EncryptOptions) error { o.PublicNote = "Cartas del viaje a Lisboa"; return nil }},
{name: "format3_sealed", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, signed with alg 1 by the test key of format3_signed and sealed with seal_type 2 by a test time-stamping authority before the round time: verdicts F4 and S4, with SEAL_SUBJECT and the token in the record", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, signer: signerSeed, configure: configureSeal},
}
}
func fixtures(dir string, sel selector) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, s := range specs() {
path := filepath.Join(dir, s.name+".dkc")
if s.format == capsule.Format1 {
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("%s: the frozen format 1 fixture is missing: %w", s.name, err)
}
continue
}
if !sel.generate(s.name, path) {
log.Printf("leaving %s untouched", path)
continue
}
if err := generate(dir, s); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
log.Printf("generated %s", path)
}
path := filepath.Join(dir, extDKK+".dkk")
if _, err := os.Stat(path); err == nil && !sel.only[extDKK] {
log.Printf("leaving %s untouched", path)
return nil
}
if err := deriveDKK(dir); err != nil {
return fmt.Errorf("%s: %w", extDKK, err)
}
log.Printf("generated %s", path)
return nil
}
// extDKK is the .dkk vector with an extension (spec §68): the portable
// credential of extDKKSource re-issued with a noncritical extension. It keeps
// the credential_id, the key and the capsule_digest, so its bytes are a
// function of the source .dkk.
const (
extDKK = "time_and_key_portable_extension"
extDKKSource = "time_and_key_portable"
)
func deriveDKK(dir string) error {
src, err := os.ReadFile(filepath.Join(dir, extDKKSource+".dkk"))
if err != nil {
return err
}
k, err := accesskey.Decode(bytes.NewReader(src))
if err != nil {
return err
}
k.Noncritical = []extension.Extension{mustExt("org.example.delivery", 1, dkkExtData)}
var kb bytes.Buffer
if err := accesskey.Encode(&kb, k); err != nil {
return err
}
back, err := accesskey.Decode(bytes.NewReader(kb.Bytes()))
if err != nil {
return err
}
// The credential must open its capsule through the public API.
dkcFile := extDKKSource + ".dkc"
dkc, err := os.ReadFile(filepath.Join(dir, dkcFile))
if err != nil {
return err
}
reg := testkit.Registry()
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: reg})
if err != nil {
return err
}
oo := capsule.OpenOptions{Registry: reg, Source: testkit.NewSource(testkit.Release(in.Header.DateKey.Round)),
AccessKey: back, Now: testkit.Fixed(in.UnlockAt)}
if _, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(dkc), oo); err != nil {
return fmt.Errorf("the .dkk does not open %s: %w", dkcFile, err)
}
ksum := sha256.Sum256(kb.Bytes())
kf := testkit.DKKFixture{
Description: "portable X25519 .dkk of " + dkcFile + " with a noncritical extension: the credential of " + extDKKSource + ".dkk re-issued with org.example.delivery",
Spec: testkit.SpecVersion,
File: extDKK + ".dkk",
SHA256: hex.EncodeToString(ksum[:]),
CredentialID: hex.EncodeToString(back.CredentialID[:]),
CapsuleID: hex.EncodeToString(back.CapsuleID[:]),
AccessType: back.Type,
Material: hex.EncodeToString(back.Material),
CapsuleDigest: hex.EncodeToString(back.Verification.CapsuleDigest),
Extensions: exts(false, back.Noncritical),
Capsule: dkcFile,
ExpectedResult: "opens INNER_ACCESS_AGE of " + dkcFile + " and yields its CONTROL_CBOR",
}
if err := os.WriteFile(filepath.Join(dir, kf.File), kb.Bytes(), 0o644); err != nil {
return err
}
return testkit.WriteJSON(filepath.Join(dir, extDKK+".dkk.json"), kf)
}
// written is a fixture as its writer wrote it, with the credentials that
// only the writer knows.
type written struct {
dkc []byte
ids []*age.X25519Identity
portable *accesskey.AccessKey
}
// write writes the fixture s: format 3 with capsule.EncryptFiles, or with
// testkit.Build when only a generator of test vectors may write it, and
// format 2 with capsule.Encrypt, as a generator of test vectors.
func write(s spec) (*written, error) {
p := profile.Quicknet()
unlock, err := datekey.RoundTime(p, s.round)
if err != nil {
return nil, err
}
if s.body != nil {
body, err := s.body()
if err != nil {
return nil, err
}
b, err := testkit.Build{Round: s.round, Format: capsule.Format3, Declared: s.policy, Structure: s.policy, Padding: s.padding, Plaintext: body}.Make()
if err != nil {
return nil, err
}
return &written{dkc: b.DKC}, nil
}
opts := capsule.EncryptOptions{
Profile: p, UnlockAt: unlock, Policy: s.policy, NewPortableKey: s.portable, Padding: s.padding,
Noncritical: s.headerExt, ControlNoncritical: s.controlExt, Now: testkit.Fixed(testkit.Genesis()),
}
w := &written{}
for range s.recipients {
id, err := age.GenerateX25519Identity()
if err != nil {
return nil, err
}
w.ids = append(w.ids, id)
opts.Recipients = append(opts.Recipients, id.Recipient())
}
var dkc bytes.Buffer
var res *capsule.Result
if s.format == capsule.Format3 {
opts.Comment, opts.Author = s.comment, s.author
if s.area != 0 {
opts.TestVectors, opts.TestAreaLen = true, s.area
}
if s.configure != nil {
if err := s.configure(&opts); err != nil {
return nil, err
}
}
if s.signer != nil {
k, err := authorkey.NewFromSeed(s.signer)
if err != nil {
return nil, err
}
defer k.Clear()
opts.AuthorKey = k
}
res, err = capsule.EncryptFiles(&dkc, sources(s.files), opts)
} else {
opts.Length, opts.TestVectors = int64(len(s.plaintext)), true
res, err = capsule.Encrypt(&dkc, bytes.NewReader(s.plaintext), opts)
}
if err != nil {
return nil, err
}
w.dkc, w.portable = dkc.Bytes(), res.PortableKey
return w, nil
}
// generate writes the fixture s and records every intermediate value,
// recovered by opening it layer by layer.
func generate(dir string, s spec) error {
p := profile.Quicknet()
reg := testkit.Registry()
unlock, err := datekey.RoundTime(p, s.round)
if err != nil {
return err
}
w, err := write(s)
if err != nil {
return err
}
release := testkit.Release(s.round)
// Recover every intermediate value by opening the fixture step by step.
parts, err := testkit.Split(w.dkc)
if err != nil {
return err
}
format := capsule.Format(parts.Prelude[4])
header, err := capsule.DecodeHeader(parts.Header)
if err != nil {
return err
}
timeID, err := agewrap.NewTimeIdentity(p, s.round, release)
if err != nil {
return err
}
inner, err := decrypt(parts.Sealed, timeID)
if err != nil {
return err
}
control := inner
f := testkit.DKCFixture{Description: s.description, File: s.name + ".dkc", PlaintextFile: s.name + ".plaintext"}
var dkkFile string
var dkkBytes []byte
if s.policy == capsule.TimeAndKey {
st, err := agewrap.Stanzas(bytes.NewReader(inner))
if err != nil {
return err
}
f.InnerStanzas = stanzas(st)
var tryIDs []age.Identity
for _, id := range w.ids {
i, err := opens(id, st)
if err != nil {
return err
}
f.Identities = append(f.Identities, id.String())
f.IdentityStanzas = append(f.IdentityStanzas, i)
tryIDs = append(tryIDs, id)
}
if w.portable != nil {
var kb bytes.Buffer
if err := accesskey.Encode(&kb, w.portable); err != nil {
return err
}
dkkBytes = kb.Bytes()
dkkFile = s.name + ".dkk"
kid, err := w.portable.Identity()
if err != nil {
return err
}
i, err := opens(kid, st)
if err != nil {
return err
}
f.AccessKeyStanza = &i
tryIDs = append(tryIDs, kid)
}
accessID, err := agewrap.NewAccessIdentity(agewrap.AccessSlots, tryIDs...)
if err != nil {
return err
}
if control, err = decrypt(inner, accessID); err != nil {
return err
}
}
ctrl, err := capsule.DecodeControl(control, format)
if err != nil {
return err
}
outer, err := agewrap.Stanzas(bytes.NewReader(parts.Sealed))
if err != nil {
return err
}
payload, err := agewrap.Stanzas(bytes.NewReader(parts.Payload))
if err != nil {
return err
}
// The plaintext of PAYLOAD_AGE: the content and its padding, the content
// being BODY in format 3.
payloadID, err := agewrap.NewPayloadIdentity(ctrl.PayloadIdentity[:])
if err != nil {
return err
}
pt, err := decrypt(parts.Payload, payloadID)
if err != nil {
return err
}
padded, err := capsule.PaddedLength(ctrl.PayloadLength, ctrl.Padding)
if err != nil {
return err
}
if uint64(len(pt)) != padded || len(bytes.Trim(pt[ctrl.PayloadLength:], "\x00")) != 0 {
return errors.New("the plaintext of PAYLOAD_AGE is not the content and its padding")
}
plaintext := pt[:ctrl.PayloadLength]
switch format {
case capsule.Format2:
if !bytes.Equal(plaintext, s.plaintext) {
return errors.New("fixture plaintext mismatch")
}
case capsule.Format3:
// Without the verdicts of Open, which come later: the derived record
// that refreshRecord writes takes them from the opening.
if err := record3(&f, plaintext, nil); err != nil {
return err
}
}
// The fixture must open through the public API with the embedded release.
oo := capsule.OpenOptions{Registry: reg, Source: testkit.NewSource(release), Now: testkit.Fixed(unlock)}
for _, id := range w.ids {
oo.Identities = append(oo.Identities, id)
}
if s.policy == capsule.TimeAndKey && len(w.ids) == 0 {
oo.AccessKey = w.portable
}
sink := &testkit.MemorySink{}
oo.Sink = sink
var plain bytes.Buffer
opened, err := capsule.Open(context.Background(), &plain, bytes.NewReader(w.dkc), oo)
if err != nil {
return fmt.Errorf("fixture does not open: %w", err)
}
if format == capsule.Format3 {
// A signature is checked in the context of its capsule, which the
// record above does not have yet: the verdicts are those of Open.
v := opened.Verdicts
f.Verdicts = &testkit.FixtureVerdicts{Signature: string(v.Signature), Seal: string(v.Seal), Lines: v.Lines()}
if err := check3(&f, plaintext, opened, sink); err != nil {
return err
}
} else if !bytes.Equal(plain.Bytes(), plaintext) {
return errors.New("fixture plaintext mismatch")
}
sum := sha256.Sum256(w.dkc)
psum := sha256.Sum256(plaintext)
f.Spec = testkit.SpecVersion
f.Format = int(format)
f.SHA256 = hex.EncodeToString(sum[:])
f.Release = testkit.FixtureRelease{Round: release.Round, Signature: hex.EncodeToString(release.Signature)}
f.Prelude = hex.EncodeToString(parts.Prelude)
f.PublicHeader = hex.EncodeToString(parts.Header)
f.DateKey = header.DateKey.Compact()
f.CapsuleID = hex.EncodeToString(header.CapsuleID[:])
f.AccessPolicy = s.policy.String()
f.Structure = s.policy.String()
f.UnlockAt = unlock.Format(time.RFC3339)
f.HeaderBinding = hex.EncodeToString(ctrl.HeaderBinding[:])
f.OuterStanzas = stanzas(outer)
f.PayloadStanzas = stanzas(payload)
f.AccessKeyFile = dkkFile
f.ControlCBOR = hex.EncodeToString(control)
f.PayloadIdentity = hex.EncodeToString(ctrl.PayloadIdentity[:])
f.PayloadLength, f.Padding, f.PaddedLength = ctrl.PayloadLength, int(ctrl.Padding), padded
f.PlaintextSHA256 = hex.EncodeToString(psum[:])
// The extensions of the header as it is written: those given, and the
// public note that the writer adds (spec v0.11, §24.1).
f.HeaderExtensions = append(exts(true, header.Critical), exts(false, header.Noncritical)...)
f.ControlExt = exts(false, s.controlExt)
if s.signer != nil {
f.Signature = &testkit.FixtureSignature{SecretSeed: hex.EncodeToString(s.signer)}
}
for _, c := range opened.Inspection.Checks {
f.Stages = append(f.Stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error})
}
if err := os.WriteFile(filepath.Join(dir, f.File), w.dkc, 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, f.PlaintextFile), plaintext, 0o644); err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, s.name+".json"), f); err != nil {
return err
}
if dkkBytes == nil {
return nil
}
k := w.portable
ksum := sha256.Sum256(dkkBytes)
kf := testkit.DKKFixture{
Description: "portable X25519 .dkk of " + f.File,
Spec: testkit.SpecVersion,
File: dkkFile,
SHA256: hex.EncodeToString(ksum[:]),
CredentialID: hex.EncodeToString(k.CredentialID[:]),
CapsuleID: hex.EncodeToString(k.CapsuleID[:]),
AccessType: k.Type,
Material: hex.EncodeToString(k.Material),
CapsuleDigest: hex.EncodeToString(k.Verification.CapsuleDigest),
Capsule: f.File,
ExpectedResult: "opens INNER_ACCESS_AGE of " + f.File + " and yields its CONTROL_CBOR",
}
if err := os.WriteFile(filepath.Join(dir, dkkFile), dkkBytes, 0o644); err != nil {
return err
}
return testkit.WriteJSON(filepath.Join(dir, s.name+".dkk.json"), kf)
}
// opens returns the index of the only stanza of st that id unwraps.
func opens(id age.Identity, st []*age.Stanza) (int, error) {
found := -1
for i, s := range st {
fk, err := id.Unwrap([]*age.Stanza{s})
clear(fk)
if errors.Is(err, age.ErrIncorrectIdentity) {
continue
}
if err != nil {
return 0, err
}
if found >= 0 {
return 0, fmt.Errorf("a credential opens stanzas %d and %d", found, i)
}
found = i
}
if found < 0 {
return 0, errors.New("a credential opens no stanza")
}
return found, nil
}
func decrypt(file []byte, id age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(file), id)
if err != nil {
return nil, err
}
var b bytes.Buffer
if _, err := b.ReadFrom(r); err != nil {
return nil, err
}
return b.Bytes(), nil
}
func stanzas(in []*age.Stanza) []testkit.FixtureStanza {
out := make([]testkit.FixtureStanza, len(in))
for i, s := range in {
out[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
}
return out
}
func exts(critical bool, in []extension.Extension) []testkit.FixtureExt {
var out []testkit.FixtureExt
for _, e := range in {
out = append(out, testkit.FixtureExt{Critical: critical, ID: e.ID, Version: e.Version, Data: hex.EncodeToString(e.Data)})
}
return out
}

Powered by TurnKey Linux.