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

307 lines
10 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 fixtures are never overwritten
// unless -force is given; vectors are always regenerated, and the tests fail
// if the implementation stops reproducing the committed ones.
//
// go run ./internal/testkit/genfixtures -out testdata
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
)
func main() {
out := flag.String("out", "testdata", "output directory")
force := flag.Bool("force", false, "overwrite existing fixtures")
flag.Parse()
if err := vectors(filepath.Join(*out, "vectors")); err != nil {
log.Fatal(err)
}
if err := fixtures(filepath.Join(*out, "fixtures"), *force); err != nil {
log.Fatal(err)
}
}
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
}
return testkit.WriteJSON(filepath.Join(dir, "dk1.json"), testkit.DK1Vectors())
}
type spec struct {
name, description string
round uint64
policy capsule.Policy
recipients int
portable bool
plaintext []byte
headerExt []extension.Extension
controlExt []extension.Extension
}
func fixtures(dir string, force bool) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
large := []byte(strings.Repeat("DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk.\n", 1000))
hExt, err := extension.New("org.example.label", 1, "public label")
if err != nil {
return err
}
cExt, err := extension.New("org.example.note", 2, map[string]any{"sealed": true, "n": 7})
if err != nil {
return err
}
specs := []spec{
{name: "time_only", description: "time_only capsule, two STREAM chunks, no extensions", round: 1000, policy: capsule.TimeOnly, plaintext: large},
{name: "time_only_extensions", 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", 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", 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", description: "time_only capsule with an empty payload", round: 1001, policy: capsule.TimeOnly, plaintext: []byte{}},
}
for _, s := range specs {
path := filepath.Join(dir, s.name+".dkc")
if _, err := os.Stat(path); err == nil && !force {
log.Printf("keeping existing %s", path)
continue
}
if err := generate(dir, s); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
log.Printf("generated %s", path)
}
return nil
}
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
}
opts := capsule.EncryptOptions{
Profile: p, UnlockAt: unlock, Policy: s.policy, NewPortableKey: s.portable,
Noncritical: s.headerExt, ControlNoncritical: s.controlExt, Now: testkit.Fixed(testkit.Genesis()),
}
var ids []*age.X25519Identity
for range s.recipients {
id, err := age.GenerateX25519Identity()
if err != nil {
return err
}
ids = append(ids, id)
opts.Recipients = append(opts.Recipients, id.Recipient())
}
var dkc bytes.Buffer
res, err := capsule.Encrypt(&dkc, bytes.NewReader(s.plaintext), opts)
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(dkc.Bytes())
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
var innerStanzas []testkit.FixtureStanza
var identities []string
var dkkFile string
var dkkBytes []byte
if s.policy == capsule.TimeAndKey {
st, err := agewrap.Stanzas(bytes.NewReader(inner))
if err != nil {
return err
}
innerStanzas = stanzas(st)
var tryIDs []age.Identity
for _, id := range ids {
identities = append(identities, id.String())
tryIDs = append(tryIDs, id)
}
if res.PortableKey != nil {
var kb bytes.Buffer
if err := accesskey.Encode(&kb, res.PortableKey); err != nil {
return err
}
dkkBytes = kb.Bytes()
dkkFile = s.name + ".dkk"
kid, err := res.PortableKey.Identity()
if err != nil {
return err
}
tryIDs = append(tryIDs, kid)
}
accessID, err := agewrap.NewAccessIdentity(tryIDs...)
if err != nil {
return err
}
if control, err = decrypt(inner, accessID); err != nil {
return err
}
}
ctrl, err := capsule.DecodeControl(control)
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 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 ids {
oo.Identities = append(oo.Identities, id)
}
if s.policy == capsule.TimeAndKey && len(ids) == 0 {
oo.AccessKey = res.PortableKey
}
var plain bytes.Buffer
opened, err := capsule.Open(context.Background(), &plain, bytes.NewReader(dkc.Bytes()), oo)
if err != nil {
return fmt.Errorf("fixture does not open: %w", err)
}
if !bytes.Equal(plain.Bytes(), s.plaintext) {
return fmt.Errorf("fixture plaintext mismatch")
}
sum := sha256.Sum256(dkc.Bytes())
psum := sha256.Sum256(s.plaintext)
f := testkit.DKCFixture{
Description: s.description,
Spec: testkit.SpecVersion,
File: s.name + ".dkc",
SHA256: hex.EncodeToString(sum[:]),
Release: testkit.FixtureRelease{Round: release.Round, Signature: hex.EncodeToString(release.Signature)},
Prelude: hex.EncodeToString(parts.Prelude),
PublicHeader: hex.EncodeToString(parts.Header),
DateKey: res.DateKey.Compact(),
CapsuleID: hex.EncodeToString(res.CapsuleID[:]),
AccessPolicy: s.policy.String(),
Structure: s.policy.String(),
UnlockAt: unlock.Format(time.RFC3339),
HeaderBinding: hex.EncodeToString(ctrl.HeaderBinding[:]),
OuterStanzas: stanzas(outer),
PayloadStanzas: stanzas(payload),
InnerStanzas: innerStanzas,
AccessKeyFile: dkkFile,
Identities: identities,
ControlCBOR: hex.EncodeToString(control),
PayloadIdentity: hex.EncodeToString(ctrl.PayloadIdentity[:]),
PlaintextFile: s.name + ".plaintext",
PlaintextSHA256: hex.EncodeToString(psum[:]),
HeaderExtensions: exts(false, s.headerExt),
ControlExt: exts(false, s.controlExt),
}
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), dkc.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, f.PlaintextFile), s.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 := res.PortableKey
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)
}
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.