|
|
|
|
|
// Writes test/vectors/capsule_writer.json, the vectors of the writer of
|
|
|
|
|
|
// capsules of format 3 of datekeys-dart, stage 6b of docs/PLAN_dart.md:
|
|
|
|
|
|
// what capsule.EncryptFiles of datekeys-go writes for each recipe of this
|
|
|
|
|
|
// file, and the text of each error it gives.
|
|
|
|
|
|
//
|
|
|
|
|
|
// EncryptFiles draws every random value from crypto/rand: the portable
|
|
|
|
|
|
// identity, the salt of the head, capsule_id, I_PAYLOAD, the dummies of
|
|
|
|
|
|
// INNER_ACCESS_AGE and their permutation, what age draws for the measured
|
|
|
|
|
|
// seal, the real seal and PAYLOAD_AGE, and the credential_id of the .dkk.
|
|
|
|
|
|
// Here crypto/rand.Reader reads the keystream of SeededRandomSource of
|
|
|
|
|
|
// lib/src/random.dart (ChaCha20 under SHA-256(seed), zero nonce), as in
|
|
|
|
|
|
// tool/age_writer_go_vectors.go: with the same seed the writer of
|
|
|
|
|
|
// datekeys-dart draws the same values, in the same order, and must write the
|
|
|
|
|
|
// same bytes. Each case records the size of each draw.
|
|
|
|
|
|
//
|
|
|
|
|
|
// A recipe that signs or seals runs the hooks of the tests of package
|
|
|
|
|
|
// capsule (signed_test.go): an Ed25519 author key from a seed (alg 1), the
|
|
|
|
|
|
// CMS signatures and RFC 3161 tokens of internal/cms/cmstest (alg 2 and
|
|
|
|
|
|
// seal_type 2). ECDSA and RSA draw from Go's internal generator, which only
|
|
|
|
|
|
// testing/cryptotest.SetGlobalRandom fixes, in a test binary: this file runs
|
|
|
|
|
|
// as a test. What each hook was given and returned is recorded, so that the
|
|
|
|
|
|
// tests of datekeys-dart give the writer the same signatures and tokens and
|
|
|
|
|
|
// check that it asks for them over the same messages.
|
|
|
|
|
|
//
|
|
|
|
|
|
// For each capsule written it records its length and SHA-256, the capsule
|
|
|
|
|
|
// itself when it is small, the .dkk, the Result, its PUBLIC_HEADER, its
|
|
|
|
|
|
// CONTROL_CBOR and its head; it opens the capsule with capsule.Open, with
|
|
|
|
|
|
// each credential alone, all together and none, and records the files, the
|
|
|
|
|
|
// verdicts and the area; and it encodes PUBLIC_HEADER, CONTROL_CBOR and the
|
|
|
|
|
|
// .dkk again and compares them with what was written. For each error it
|
|
|
|
|
|
// records the text, the code, and the number of bytes written before it.
|
|
|
|
|
|
//
|
|
|
|
|
|
// It imports internal packages, so it runs as a test in an export of
|
|
|
|
|
|
// datekeys-go made with git archive, which it does not change, never in the
|
|
|
|
|
|
// repository itself. From the root of datekeys-dart:
|
|
|
|
|
|
//
|
|
|
|
|
|
// commit=$(git -C ../datekeys-go rev-parse v0.12)
|
|
|
|
|
|
// out=$PWD/test/vectors
|
|
|
|
|
|
// tmp=$(mktemp -d)
|
|
|
|
|
|
// git -C ../datekeys-go archive "$commit" | tar -x -C "$tmp"
|
|
|
|
|
|
// mkdir "$tmp/capsulewriter"
|
|
|
|
|
|
// cp tool/capsule_writer_go_vectors_test.go "$tmp/capsulewriter/"
|
|
|
|
|
|
// (cd "$tmp/capsulewriter" && go test -run TestWriteVectors -count=1 \
|
|
|
|
|
|
// -args -source "$commit" -out "$out")
|
|
|
|
|
|
// rm -rf "$tmp"
|
|
|
|
|
|
//
|
|
|
|
|
|
// It also writes test/vectors/capsule_writer.g.dart, the cases marked node,
|
|
|
|
|
|
// for the tests that also run compiled to JavaScript. Every run writes the
|
|
|
|
|
|
// same bytes with Go 1.26.8.
|
|
|
|
|
|
package capsulewriter
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/ed25519"
|
|
|
|
|
|
"crypto/elliptic"
|
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"flag"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"runtime"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
"testing/cryptotest"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"filippo.io/age"
|
|
|
|
|
|
"golang.org/x/crypto/chacha20"
|
|
|
|
|
|
|
|
|
|
|
|
datekeys "g.activething.com/go/DateKeys"
|
|
|
|
|
|
"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/codec/bech32"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/datekey"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/extension"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/internal/cms/cmstest"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/internal/testkit"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/profile"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/provider"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/wordkey"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
sourceFlag = flag.String("source", "", "the commit of datekeys-go that this tree exports")
|
|
|
|
|
|
outFlag = flag.String("out", "", "the directory test/vectors of datekeys-dart")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type obj = map[string]any
|
|
|
|
|
|
|
|
|
|
|
|
func h(b []byte) string { return hex.EncodeToString(b) }
|
|
|
|
|
|
|
|
|
|
|
|
func sum(b []byte) string {
|
|
|
|
|
|
s := sha256.Sum256(b)
|
|
|
|
|
|
return h(s[:])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func must[T any](v T, err error) T {
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
panic(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func unhex(s string) []byte { return must(hex.DecodeString(s)) }
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// The recipes
|
|
|
|
|
|
|
|
|
|
|
|
type extIn struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Version uint64 `json:"version"`
|
|
|
|
|
|
Data *string `json:"data,omitempty"` // hex; absent for none
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// fileIn is a file of a recipe: its content is the text, or n bytes of the
|
|
|
|
|
|
// pattern (31·i + 7) mod 256, and its declared size the length of the
|
|
|
|
|
|
// content unless size is given.
|
|
|
|
|
|
type fileIn struct {
|
|
|
|
|
|
Path string `json:"path"`
|
|
|
|
|
|
Text *string `json:"text,omitempty"`
|
|
|
|
|
|
Pattern int `json:"pattern,omitempty"`
|
|
|
|
|
|
Size *int64 `json:"size,omitempty"`
|
|
|
|
|
|
MTime []int64 `json:"mtime,omitempty"` // seconds and nanoseconds since 1970
|
|
|
|
|
|
// Fault is what goes wrong with its source:
|
|
|
|
|
|
// - open1, open2: the first or the second Open fails with "the file is
|
|
|
|
|
|
// gone";
|
|
|
|
|
|
// - short1, short2: that reading gives one byte less;
|
|
|
|
|
|
// - long1, long2: that reading gives one byte more, 0x78;
|
|
|
|
|
|
// - change2: the second reading gives the content with its first byte
|
|
|
|
|
|
// XOR 1;
|
|
|
|
|
|
// - read2: the second reading gives the first half of the content and
|
|
|
|
|
|
// then fails with "the disk failed".
|
|
|
|
|
|
Fault string `json:"fault,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type authorIn struct {
|
|
|
|
|
|
Seed string `json:"seed"` // hex, 32 bytes
|
|
|
|
|
|
Bad string `json:"bad,omitempty"` // zero_signature, short_key
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type cmsIn struct {
|
|
|
|
|
|
Signers []string `json:"signers"` // names of the certificates that sign
|
|
|
|
|
|
Extra string `json:"extra,omitempty"` // a required signer that does not sign
|
|
|
|
|
|
Unsealed bool `json:"unsealed,omitempty"` // no seal in the signatures
|
|
|
|
|
|
Junk int `json:"junk,omitempty"` // bytes of an unsigned attribute
|
|
|
|
|
|
Error string `json:"error,omitempty"` // Sign fails with this text
|
|
|
|
|
|
Garbage bool `json:"garbage,omitempty"` // Sign returns bytes that are no signature
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type sealerIn struct {
|
|
|
|
|
|
Error string `json:"error,omitempty"` // Seal fails with this text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type recipe struct {
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
Seed string `json:"seed"`
|
|
|
|
|
|
Round uint64 `json:"round"`
|
|
|
|
|
|
// UnlockNs is added to the time of the round: the requested instant.
|
|
|
|
|
|
UnlockNs int64 `json:"unlock_ns,omitempty"`
|
|
|
|
|
|
// Now is the clock, in RFC 3339; the genesis of Quicknet by default.
|
|
|
|
|
|
Now string `json:"now,omitempty"`
|
|
|
|
|
|
Policy string `json:"policy,omitempty"` // time_and_key; time_only by default
|
|
|
|
|
|
// Identities are labels: the raw secret of each is
|
|
|
|
|
|
// SHA-256("identity " + label), and its recipient is given.
|
|
|
|
|
|
Identities []string `json:"identities,omitempty"`
|
|
|
|
|
|
// RawRecipients are given after them, as raw public keys.
|
|
|
|
|
|
RawRecipients []string `json:"raw_recipients,omitempty"`
|
|
|
|
|
|
Portable bool `json:"portable,omitempty"`
|
|
|
|
|
|
Words []string `json:"words,omitempty"`
|
|
|
|
|
|
Padding int `json:"padding,omitempty"`
|
|
|
|
|
|
Files []fileIn `json:"files,omitempty"`
|
|
|
|
|
|
// ManyFiles adds that many empty files, f00000 and on.
|
|
|
|
|
|
ManyFiles int `json:"many_files,omitempty"`
|
|
|
|
|
|
Comment string `json:"comment,omitempty"`
|
|
|
|
|
|
Author string `json:"author,omitempty"`
|
|
|
|
|
|
Note string `json:"note,omitempty"`
|
|
|
|
|
|
Critical []extIn `json:"critical,omitempty"`
|
|
|
|
|
|
Noncritical []extIn `json:"noncritical,omitempty"`
|
|
|
|
|
|
ControlCritical []extIn `json:"control_critical,omitempty"`
|
|
|
|
|
|
ControlNoncritical []extIn `json:"control_noncritical,omitempty"`
|
|
|
|
|
|
HeadCritical []extIn `json:"head_critical,omitempty"`
|
|
|
|
|
|
HeadNoncritical []extIn `json:"head_noncritical,omitempty"`
|
|
|
|
|
|
AuthorKey *authorIn `json:"author_key,omitempty"`
|
|
|
|
|
|
CMS *cmsIn `json:"cms,omitempty"`
|
|
|
|
|
|
Sealer *sealerIn `json:"sealer,omitempty"`
|
|
|
|
|
|
LargeArea bool `json:"large_area,omitempty"`
|
|
|
|
|
|
TestVectors bool `json:"test_vectors,omitempty"`
|
|
|
|
|
|
TestAreaLen uint32 `json:"test_area_len,omitempty"`
|
|
|
|
|
|
// Profile is "" for Quicknet, or genesis_plus_one, Quicknet with a
|
|
|
|
|
|
// genesis time one second later, which its chain hash does not match.
|
|
|
|
|
|
Profile string `json:"profile,omitempty"`
|
|
|
|
|
|
// Node puts the case in the .g.dart, for the tests compiled to
|
|
|
|
|
|
// JavaScript.
|
|
|
|
|
|
Node bool `json:"node,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
|
|
|
|
|
|
|
|
func ext(id string, version uint64, data string) extIn {
|
|
|
|
|
|
d := hex.EncodeToString([]byte(data))
|
|
|
|
|
|
return extIn{ID: id, Version: version, Data: &d}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func text(path, s string) fileIn { return fileIn{Path: path, Text: ptr(s)} }
|
|
|
|
|
|
|
|
|
|
|
|
var words = []string{"faro", "nube", "trigo", "menta", "barco", "lince"}
|
|
|
|
|
|
|
|
|
|
|
|
func ids(n int) []string {
|
|
|
|
|
|
var out []string
|
|
|
|
|
|
for i := range n {
|
|
|
|
|
|
out = append(out, fmt.Sprintf("holder %d", i))
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const authorSeed = "a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0"
|
|
|
|
|
|
|
|
|
|
|
|
func recipes() []recipe {
|
|
|
|
|
|
small := func(name string) recipe {
|
|
|
|
|
|
return recipe{Name: name, Round: 1000, TestVectors: true, TestAreaLen: 512}
|
|
|
|
|
|
}
|
|
|
|
|
|
var out []recipe
|
|
|
|
|
|
add := func(r recipe) { out = append(out, r) }
|
|
|
|
|
|
|
|
|
|
|
|
// Capsules.
|
|
|
|
|
|
r := small("time_only, one file, area of 512")
|
|
|
|
|
|
r.Files, r.Node = []fileIn{text("nota.txt", "Hola.\n")}, true
|
|
|
|
|
|
add(r)
|
|
|
|
|
|
r = small("time_only, a comment alone, bloque256")
|
|
|
|
|
|
r.Comment, r.Author, r.Padding, r.Node = "Una línea.\r\nOtra.\rFin", "Ana López", 1, true
|
|
|
|
|
|
add(r)
|
|
|
|
|
|
r = small("time_and_key, a portable key")
|
|
|
|
|
|
r.Policy, r.Portable, r.Node = "time_and_key", true, true
|
|
|
|
|
|
r.Files = []fileIn{text("a.txt", "a"), text("b/c.txt", "")}
|
|
|
|
|
|
add(r)
|
|
|
|
|
|
r = small("time_and_key, three recipients and a portable key, a note and extensions")
|
|
|
|
|
|
r.Policy, r.Portable, r.Identities = "time_and_key", true, ids(3)
|
|
|
|
|
|
r.Note = "Para Ana, en su cumpleaños"
|
|
|
|
|
|
r.Files = []fileIn{text("carta.txt", "Querida Ana:\n")}
|
|
|
|
|
|
r.Critical = []extIn{ext("org.example.crit", 1, "c")}
|
|
|
|
|
|
r.Noncritical = []extIn{ext("org.example.b", 2, "nb"), {ID: "org.example.a", Version: 1}}
|
|
|
|
|
|
r.ControlCritical = []extIn{ext("org.example.ctrl", 1, "k")}
|
|
|
|
|
|
r.ControlNoncritical = []extIn{ext("org.example.ctrl2", 3, "kk")}
|
|
|
|
|
|
r.HeadCritical = []extIn{ext("org.example.head", 1, "h")}
|
|
|
|
|
|
r.HeadNoncritical = []extIn{ext("org.example.head2", 1, "hh")}
|
|
|
|
|
|
add(r)
|
|
|
|
|
|
add(recipe{Name: "time_only, the common area, files in byte order with mtimes", Round: 1000,
|
|
|
|
|
|
Comment: "Fotos del verano", Author: "Luis",
|
|
|
|
|
|
Files: []fileIn{
|
|
|
|
|
|
{Path: "vacío.txt", Text: ptr("")},
|
|
|
|
|
|
{Path: "nota.txt", Text: ptr("Hola.\n"), MTime: []int64{1727712000, 0}},
|
|
|
|
|
|
{Path: "fotos/playa.jpg", Pattern: 200000, MTime: []int64{1727712000, 999999999}},
|
|
|
|
|
|
{Path: "\U0001F600.txt", Text: ptr("emoji")},
|
|
|
|
|
|
{Path: "~.txt", Text: ptr("tilde")},
|
|
|
|
|
|
{Path: "fotos/a.txt", Text: ptr("a"), MTime: []int64{0, 0}},
|
|
|
|
|
|
}})
|
|
|
|
|
|
add(recipe{Name: "time_only, mtimes out of range and at its ends", Round: 1000, Padding: 1,
|
|
|
|
|
|
Files: []fileIn{
|
|
|
|
|
|
{Path: "a", Text: ptr("1"), MTime: []int64{-1, 999999999}},
|
|
|
|
|
|
{Path: "b", Text: ptr("2"), MTime: []int64{253402300799, 999999999}},
|
|
|
|
|
|
{Path: "c", Text: ptr("3"), MTime: []int64{253402300800, 0}},
|
|
|
|
|
|
{Path: "d", Text: ptr("4"), MTime: []int64{-62135596800, 0}},
|
|
|
|
|
|
{Path: "e", Text: ptr("5"), MTime: []int64{-62135596800, 1}},
|
|
|
|
|
|
}})
|
|
|
|
|
|
add(recipe{Name: "time_and_key, fifteen recipients and a portable key", Round: 1000, Policy: "time_and_key",
|
|
|
|
|
|
Identities: ids(15), Portable: true, Files: []fileIn{{Path: "x.bin", Pattern: 65536}}})
|
|
|
|
|
|
add(recipe{Name: "time_and_key, sixteen recipients", Round: 1001, Policy: "time_and_key",
|
|
|
|
|
|
Identities: ids(16), Files: []fileIn{{Path: "x.bin", Pattern: 65537}}})
|
|
|
|
|
|
add(recipe{Name: "time_and_key, a key of words and a recipient", Round: 1000, Policy: "time_and_key",
|
|
|
|
|
|
Identities: ids(1), Words: words, Files: []fileIn{text("secreto.txt", "42")}})
|
|
|
|
|
|
add(recipe{Name: "a requested instant one nanosecond after round 1000", Round: 1000, UnlockNs: 1,
|
|
|
|
|
|
Files: []fileIn{text("a", "a")}})
|
|
|
|
|
|
add(recipe{Name: "a requested instant one nanosecond before round 1000", Round: 1000, UnlockNs: -1,
|
|
|
|
|
|
Now: "2023-08-23T15:09:30.5Z", Files: []fileIn{text("a", "a")}})
|
|
|
|
|
|
r = small("signed with alg 1, area of 512")
|
|
|
|
|
|
r.AuthorKey, r.Files, r.Node = &authorIn{Seed: authorSeed}, []fileIn{text("nota.txt", "Hola.\n")}, true
|
|
|
|
|
|
add(r)
|
|
|
|
|
|
add(recipe{Name: "signed with alg 1 and sealed", Round: 1000, AuthorKey: &authorIn{Seed: authorSeed},
|
|
|
|
|
|
Sealer: &sealerIn{}, Policy: "time_and_key", Portable: true, Files: []fileIn{text("nota.txt", "Hola.\n")}})
|
|
|
|
|
|
add(recipe{Name: "sealed, without a signature", Round: 1000, Sealer: &sealerIn{},
|
|
|
|
|
|
Files: []fileIn{text("nota.txt", "Hola.\n")}})
|
|
|
|
|
|
add(recipe{Name: "signed with alg 2 by two signers, each sealed", Round: 1000,
|
|
|
|
|
|
CMS: &cmsIn{Signers: []string{"Ana López", "Luis Gómez"}}, Files: []fileIn{text("nota.txt", "Hola.\n")}})
|
|
|
|
|
|
add(recipe{Name: "a signature of 40 KiB with LargeArea: the area of 64 KiB", Round: 1000, LargeArea: true,
|
|
|
|
|
|
CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40 << 10}, Files: []fileIn{text("nota.txt", "Hola.\n")}})
|
|
|
|
|
|
add(recipe{Name: "LargeArea without a signature keeps the common area", Round: 1000, LargeArea: true,
|
|
|
|
|
|
Files: []fileIn{text("nota.txt", "x")}})
|
|
|
|
|
|
add(recipe{Name: "content of whole chunks", Round: 1000, Padding: 1,
|
|
|
|
|
|
Files: []fileIn{{Path: "a", Pattern: 65536}, {Path: "b", Pattern: 131072 - 12 - 32768 - 200}}})
|
|
|
|
|
|
add(recipe{Name: "unknown extensions of the application in every array", Round: 1000, Policy: "time_and_key",
|
|
|
|
|
|
Words: words, Portable: true,
|
|
|
|
|
|
Critical: []extIn{{ID: "z", Version: 4294967295}}, HeadNoncritical: []extIn{ext("org.example.n", 1, "x")},
|
|
|
|
|
|
Files: []fileIn{text("a", "a")}})
|
|
|
|
|
|
|
|
|
|
|
|
// Errors of the options.
|
|
|
|
|
|
e := func(name string, f func(r *recipe)) {
|
|
|
|
|
|
r := recipe{Name: name, Round: 1000, Files: []fileIn{text("a.txt", "a")}, Node: true}
|
|
|
|
|
|
f(&r)
|
|
|
|
|
|
add(r)
|
|
|
|
|
|
}
|
|
|
|
|
|
e("TestAreaLen without TestVectors", func(r *recipe) { r.TestAreaLen = 512 })
|
|
|
|
|
|
e("TestAreaLen not a multiple of 512", func(r *recipe) { r.TestVectors, r.TestAreaLen = true, 1000 })
|
|
|
|
|
|
e("TestAreaLen above the largest", func(r *recipe) { r.TestVectors, r.TestAreaLen = true, 66048 })
|
|
|
|
|
|
e("TestAreaLen with LargeArea", func(r *recipe) { r.TestVectors, r.TestAreaLen, r.LargeArea = true, 512, true })
|
|
|
|
|
|
e("AuthorKey and CMSSigner", func(r *recipe) {
|
|
|
|
|
|
r.AuthorKey, r.CMS = &authorIn{Seed: authorSeed}, &cmsIn{Signers: []string{"Ana López"}}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("CMSSigner and Sealer", func(r *recipe) { r.CMS, r.Sealer = &cmsIn{Signers: []string{"Ana López"}}, &sealerIn{} })
|
|
|
|
|
|
e("a public note with a line feed", func(r *recipe) { r.Note = "dos\nlíneas" })
|
|
|
|
|
|
e("a public note of 1025 bytes", func(r *recipe) { r.Note = strings.Repeat("a", 1025) })
|
|
|
|
|
|
e("datekeys.note in CONTROL_CBOR", func(r *recipe) {
|
|
|
|
|
|
r.ControlNoncritical = []extIn{ext("datekeys.note", 1, "hola")}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("datekeys.capsule in PUBLIC_HEADER", func(r *recipe) {
|
|
|
|
|
|
r.Noncritical = []extIn{ext("datekeys.capsule", 1, "x")}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("datekeys.note with invalid data", func(r *recipe) {
|
|
|
|
|
|
r.Noncritical = []extIn{ext("datekeys.note", 1, "a\tb")}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("datekeys.note without data", func(r *recipe) { r.Noncritical = []extIn{{ID: "datekeys.note", Version: 1}} })
|
|
|
|
|
|
e("datekeys.note in the critical array of the head", func(r *recipe) {
|
|
|
|
|
|
r.HeadCritical = []extIn{ext("datekeys.note", 1, "hola")}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("an extension in both arrays", func(r *recipe) {
|
|
|
|
|
|
r.Critical = []extIn{ext("org.example.x", 1, "a")}
|
|
|
|
|
|
r.Noncritical = []extIn{ext("org.example.x", 2, "b")}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a profile whose chain hash does not match", func(r *recipe) { r.Profile = "genesis_plus_one" })
|
|
|
|
|
|
e("an instant that is now", func(r *recipe) { r.Now = "2023-08-23T15:59:24Z" })
|
|
|
|
|
|
e("an instant after now by one nanosecond", func(r *recipe) { r.Now = "2023-08-23T15:59:23.999999999Z"; r.Node = false })
|
|
|
|
|
|
e("an instant before the genesis", func(r *recipe) { r.Round, r.UnlockNs, r.Now = 1, -5e9, "2023-08-23T14:00:00Z" })
|
|
|
|
|
|
e("time_only with a recipient", func(r *recipe) { r.Identities = ids(1) })
|
|
|
|
|
|
e("time_only with a portable key", func(r *recipe) { r.Portable = true })
|
|
|
|
|
|
e("time_only with words", func(r *recipe) { r.Words = words })
|
|
|
|
|
|
e("time_and_key without credentials", func(r *recipe) { r.Policy = "time_and_key" })
|
|
|
|
|
|
e("seventeen credentials", func(r *recipe) { r.Policy, r.Identities, r.Portable = "time_and_key", ids(16), true })
|
|
|
|
|
|
e("seventeen credentials with words", func(r *recipe) { r.Policy, r.Identities, r.Words = "time_and_key", ids(16), words })
|
|
|
|
|
|
e("a recipient with bit 255 set", func(r *recipe) {
|
|
|
|
|
|
r.Policy, r.Identities = "time_and_key", ids(1)
|
|
|
|
|
|
raw := must(agewrap.RawX25519Recipient(identity("holder 0").Recipient()))
|
|
|
|
|
|
raw[31] |= 0x80
|
|
|
|
|
|
r.RawRecipients = []string{h(raw)}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a recipient of u = p", func(r *recipe) {
|
|
|
|
|
|
r.Policy = "time_and_key"
|
|
|
|
|
|
r.RawRecipients = []string{"edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a recipient of low order", func(r *recipe) {
|
|
|
|
|
|
r.Policy = "time_and_key"
|
|
|
|
|
|
r.RawRecipients = []string{h(make([]byte, 32))}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a recipient listed twice", func(r *recipe) {
|
|
|
|
|
|
r.Policy, r.Identities = "time_and_key", ids(2)
|
|
|
|
|
|
r.RawRecipients = []string{h(must(agewrap.RawX25519Recipient(identity("holder 1").Recipient())))}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("five words", func(r *recipe) { r.Policy, r.Words = "time_and_key", words[:5] })
|
|
|
|
|
|
e("words with a control character", func(r *recipe) {
|
|
|
|
|
|
r.Policy, r.Words = "time_and_key", append(append([]string(nil), words...), "a\u0085b")
|
|
|
|
|
|
})
|
|
|
|
|
|
e("words with an invisible character", func(r *recipe) {
|
|
|
|
|
|
r.Policy, r.Words = "time_and_key", append(append([]string(nil), words...), "ab")
|
|
|
|
|
|
})
|
|
|
|
|
|
e("no file and no comment", func(r *recipe) { r.Files = nil })
|
|
|
|
|
|
e("a comment that is a lone CR", func(r *recipe) { r.Files, r.Comment = nil, "\r" })
|
|
|
|
|
|
e("a comment with a control character", func(r *recipe) { r.Comment = "hola\x01" })
|
|
|
|
|
|
e("a comment of 16385 bytes", func(r *recipe) { r.Comment = strings.Repeat("ñ", 8192) + "a" })
|
|
|
|
|
|
e("a declared author with a line feed", func(r *recipe) { r.Author = "Ana\nLópez" })
|
|
|
|
|
|
e("a declared author of 257 bytes", func(r *recipe) { r.Author = strings.Repeat("a", 257) })
|
|
|
|
|
|
e("a path given twice", func(r *recipe) { r.Files = []fileIn{text("b", "1"), text("a", "2"), text("b", "3")} })
|
|
|
|
|
|
e("an empty path", func(r *recipe) { r.Files = []fileIn{text("", "1")} })
|
|
|
|
|
|
e("a path of 1025 bytes", func(r *recipe) { r.Files = []fileIn{text(strings.Repeat("a", 1025), "1")} })
|
|
|
|
|
|
e("an absolute path", func(r *recipe) { r.Files = []fileIn{text("/etc/passwd", "1")} })
|
|
|
|
|
|
e("a path with ..", func(r *recipe) { r.Files = []fileIn{text("a/../b", "1")} })
|
|
|
|
|
|
e("a path with a reserved name", func(r *recipe) { r.Files = []fileIn{text("docs/CON.txt", "1")} })
|
|
|
|
|
|
e("a file that is a folder too", func(r *recipe) {
|
|
|
|
|
|
r.Files = []fileIn{text("a/b", "1"), text("a", "2"), text("c", "3")}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("two paths that fold to the same key", func(r *recipe) {
|
|
|
|
|
|
r.Files = []fileIn{text("x/Año.txt", "1"), text("x/AÑO.txt", "2")}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a negative size", func(r *recipe) { r.Files = []fileIn{{Path: "a", Text: ptr(""), Size: ptr(int64(-1))}} })
|
|
|
|
|
|
e("files beyond L_MAX", func(r *recipe) {
|
|
|
|
|
|
r.Files = []fileIn{text("a", "a"), {Path: "b", Text: ptr(""), Size: ptr(int64(capsule.MaxPayloadLength))}}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("L beyond L_MAX", func(r *recipe) {
|
|
|
|
|
|
r.Files = []fileIn{{Path: "b", Text: ptr(""), Size: ptr(int64(capsule.MaxPayloadLength - 100))}}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("65536 files", func(r *recipe) { r.Files, r.ManyFiles, r.Node = nil, 65536, false })
|
|
|
|
|
|
for _, f := range []string{"open1", "short1", "long1", "open2", "short2", "long2", "change2", "read2"} {
|
|
|
|
|
|
e("a source that fails: "+f, func(r *recipe) {
|
|
|
|
|
|
r.Files = []fileIn{text("a.txt", "a"), {Path: "b.bin", Pattern: 100000, Fault: f}}
|
|
|
|
|
|
r.Node = false
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
e("a source that changes in a small file", func(r *recipe) { r.Files = []fileIn{{Path: "a", Text: ptr("abc"), Fault: "change2"}} })
|
|
|
|
|
|
|
|
|
|
|
|
// Errors of the hooks.
|
|
|
|
|
|
e("an author key whose signature does not verify", func(r *recipe) {
|
|
|
|
|
|
r.AuthorKey = &authorIn{Seed: authorSeed, Bad: "zero_signature"}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("an author key of 31 bytes", func(r *recipe) { r.AuthorKey = &authorIn{Seed: authorSeed, Bad: "short_key"} })
|
|
|
|
|
|
e("a CMS signature that lacks a required signer", func(r *recipe) {
|
|
|
|
|
|
r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Extra: "Falta"}, false
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a CMS signature without seals", func(r *recipe) {
|
|
|
|
|
|
r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Unsealed: true}, false
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a CMS signature of 40 KiB without LargeArea", func(r *recipe) {
|
|
|
|
|
|
r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Junk: 40 << 10}, false
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a CMS signature larger than a test area", func(r *recipe) {
|
|
|
|
|
|
r.CMS, r.TestVectors, r.TestAreaLen, r.Node = &cmsIn{Signers: []string{"Ana López"}}, true, 512, false
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a CMS signer that fails", func(r *recipe) {
|
|
|
|
|
|
r.CMS = &cmsIn{Signers: []string{"Ana López"}, Error: "the person cancelled"}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a CMS signer that returns garbage", func(r *recipe) {
|
|
|
|
|
|
r.CMS = &cmsIn{Signers: []string{"Ana López"}, Garbage: true}
|
|
|
|
|
|
})
|
|
|
|
|
|
e("a CMS signer without signers", func(r *recipe) { r.CMS = &cmsIn{} })
|
|
|
|
|
|
e("a sealer that fails", func(r *recipe) { r.Sealer = &sealerIn{Error: "the authority is down"} })
|
|
|
|
|
|
for i := range out {
|
|
|
|
|
|
if out[i].Seed == "" {
|
|
|
|
|
|
out[i].Seed = "capsule writer " + out[i].Name
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// crypto/rand from a seed
|
|
|
|
|
|
|
|
|
|
|
|
// seeded is the crypto/rand.Reader of a case: the ChaCha20 keystream under
|
|
|
|
|
|
// SHA-256(seed) and a zero nonce, the stream of SeededRandomSource. It
|
|
|
|
|
|
// records the size of every read.
|
|
|
|
|
|
type seeded struct {
|
|
|
|
|
|
c *chacha20.Cipher
|
|
|
|
|
|
draws []int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func newSeeded(seed string) *seeded {
|
|
|
|
|
|
key := sha256.Sum256([]byte(seed))
|
|
|
|
|
|
return &seeded{c: must(chacha20.NewUnauthenticatedCipher(key[:], make([]byte, chacha20.NonceSize)))}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *seeded) Read(p []byte) (int, error) {
|
|
|
|
|
|
clear(p)
|
|
|
|
|
|
s.c.XORKeyStream(p, p)
|
|
|
|
|
|
s.draws = append(s.draws, len(p))
|
|
|
|
|
|
return len(p), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// The keys and the hooks
|
|
|
|
|
|
|
|
|
|
|
|
// identity is the X25519 identity of a label: its raw secret is
|
|
|
|
|
|
// SHA-256("identity " + label).
|
|
|
|
|
|
func identity(label string) *age.X25519Identity {
|
|
|
|
|
|
s := sha256.Sum256([]byte("identity " + label))
|
|
|
|
|
|
return must(agewrap.X25519IdentityFromRaw(s[:]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// recipientOfRaw is the age recipient of 32 raw bytes, whatever they are:
|
|
|
|
|
|
// age.ParseX25519Recipient accepts any point.
|
|
|
|
|
|
func recipientOfRaw(raw []byte) *age.X25519Recipient {
|
|
|
|
|
|
return must(age.ParseX25519Recipient(must(bech32.Encode("age", raw))))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The certificates of the CMS hooks, made in this order under the fixed
|
|
|
|
|
|
// randomness of the test, and the authority.
|
|
|
|
|
|
type certs struct {
|
|
|
|
|
|
byName map[string]cmstest.Signer
|
|
|
|
|
|
tsa cmstest.Signer
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
certFrom, certTo = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func newCerts() *certs {
|
|
|
|
|
|
c := &certs{byName: map[string]cmstest.Signer{}}
|
|
|
|
|
|
c.byName["Ana López"] = cmstest.NewECDSA("Ana López", elliptic.P256(), certFrom, certTo)
|
|
|
|
|
|
c.byName["Luis Gómez"] = cmstest.NewRSA("Luis Gómez", 2048, certFrom, certTo)
|
|
|
|
|
|
c.byName["Falta"] = cmstest.NewECDSA("Falta", elliptic.P256(), certFrom, certTo)
|
|
|
|
|
|
c.tsa = cmstest.NewECDSA("TSA de prueba", elliptic.P256(), certFrom, certTo)
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// hooks records what each hook was given and what it returned.
|
|
|
|
|
|
type hooks struct {
|
|
|
|
|
|
rec obj
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (k *hooks) set(name string, v any) { k.rec[name] = v }
|
|
|
|
|
|
|
|
|
|
|
|
// authorHook is the author key of a recipe.
|
|
|
|
|
|
type authorHook struct {
|
|
|
|
|
|
key *authorkey.Key
|
|
|
|
|
|
bad string
|
|
|
|
|
|
k *hooks
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *authorHook) Public() []byte {
|
|
|
|
|
|
pub := a.key.Public()
|
|
|
|
|
|
if a.bad == "short_key" {
|
|
|
|
|
|
pub = pub[:31]
|
|
|
|
|
|
}
|
|
|
|
|
|
a.k.set("author_public", h(pub))
|
|
|
|
|
|
return pub
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *authorHook) Sign(msg []byte) []byte {
|
|
|
|
|
|
sig := a.key.Sign(msg)
|
|
|
|
|
|
if a.bad == "zero_signature" {
|
|
|
|
|
|
sig = make([]byte, ed25519.SignatureSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
a.k.set("author_message", h(msg))
|
|
|
|
|
|
a.k.set("author_signature", h(sig))
|
|
|
|
|
|
return sig
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// cmsHook signs as a signing application, as cmsSigner of signed_test.go.
|
|
|
|
|
|
type cmsHook struct {
|
|
|
|
|
|
in cmsIn
|
|
|
|
|
|
c *certs
|
|
|
|
|
|
when time.Time
|
|
|
|
|
|
k *hooks
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *cmsHook) signers() []cmstest.Signer {
|
|
|
|
|
|
var out []cmstest.Signer
|
|
|
|
|
|
for _, n := range s.in.Signers {
|
|
|
|
|
|
out = append(out, s.c.byName[n])
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *cmsHook) Signers() [][32]byte {
|
|
|
|
|
|
var out [][32]byte
|
|
|
|
|
|
for _, x := range s.signers() {
|
|
|
|
|
|
out = append(out, sha256.Sum256(x.Cert.Raw))
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.in.Extra != "" {
|
|
|
|
|
|
out = append(out, sha256.Sum256(s.c.byName[s.in.Extra].Cert.Raw))
|
|
|
|
|
|
}
|
|
|
|
|
|
list := []string{}
|
|
|
|
|
|
for _, x := range out {
|
|
|
|
|
|
list = append(list, h(x[:]))
|
|
|
|
|
|
}
|
|
|
|
|
|
s.k.set("cms_signers", list)
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *cmsHook) Sign(msg []byte) ([]byte, error) {
|
|
|
|
|
|
s.k.set("cms_message", h(msg))
|
|
|
|
|
|
if s.in.Error != "" {
|
|
|
|
|
|
return nil, errors.New(s.in.Error)
|
|
|
|
|
|
}
|
|
|
|
|
|
var der []byte
|
|
|
|
|
|
if s.in.Garbage {
|
|
|
|
|
|
der = []byte("not a signature")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
o := cmstest.Options{Junk: s.in.Junk}
|
|
|
|
|
|
if !s.in.Unsealed {
|
|
|
|
|
|
o.Token = func(sig []byte) []byte {
|
|
|
|
|
|
return cmstest.Token(sig, s.when, cmstest.TokenOptions{Accuracy: time.Second}, s.c.tsa)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
der = cmstest.Signature(msg, o, s.signers()...)
|
|
|
|
|
|
}
|
|
|
|
|
|
s.k.set("cms_der", h(der))
|
|
|
|
|
|
return der, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sealHook asks the authority, as sealer of signed_test.go.
|
|
|
|
|
|
type sealHook struct {
|
|
|
|
|
|
in sealerIn
|
|
|
|
|
|
c *certs
|
|
|
|
|
|
when time.Time
|
|
|
|
|
|
k *hooks
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *sealHook) Seal(subject [32]byte) ([]byte, error) {
|
|
|
|
|
|
s.k.set("seal_subject", h(subject[:]))
|
|
|
|
|
|
if s.in.Error != "" {
|
|
|
|
|
|
return nil, errors.New(s.in.Error)
|
|
|
|
|
|
}
|
|
|
|
|
|
token := cmstest.Token(subject[:], s.when, cmstest.TokenOptions{}, s.c.tsa)
|
|
|
|
|
|
s.k.set("seal_token", h(token))
|
|
|
|
|
|
return token, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Sources
|
|
|
|
|
|
|
|
|
|
|
|
func pattern(n int) []byte {
|
|
|
|
|
|
b := make([]byte, n)
|
|
|
|
|
|
for i := range b {
|
|
|
|
|
|
b[i] = byte(31*i + 7)
|
|
|
|
|
|
}
|
|
|
|
|
|
return b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func contentOf(f fileIn) []byte {
|
|
|
|
|
|
if f.Text != nil {
|
|
|
|
|
|
return []byte(*f.Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
return pattern(f.Pattern)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type errReader struct{ err error }
|
|
|
|
|
|
|
|
|
|
|
|
func (r errReader) Read([]byte) (int, error) { return 0, r.err }
|
|
|
|
|
|
|
|
|
|
|
|
func sourceOf(f fileIn) capsule.Source {
|
|
|
|
|
|
content := contentOf(f)
|
|
|
|
|
|
size := int64(len(content))
|
|
|
|
|
|
if f.Size != nil {
|
|
|
|
|
|
size = *f.Size
|
|
|
|
|
|
}
|
|
|
|
|
|
calls := 0
|
|
|
|
|
|
var mtime time.Time
|
|
|
|
|
|
if f.MTime != nil {
|
|
|
|
|
|
mtime = time.Unix(f.MTime[0], f.MTime[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
return capsule.Source{Path: f.Path, Size: size, ModTime: mtime, Open: func() (io.ReadCloser, error) {
|
|
|
|
|
|
if f.Size != nil && *f.Size != int64(len(content)) {
|
|
|
|
|
|
panic("a source opened whose size is not its content's")
|
|
|
|
|
|
}
|
|
|
|
|
|
calls++
|
|
|
|
|
|
c := content
|
|
|
|
|
|
switch fmt.Sprintf("%s/%d", f.Fault, calls) {
|
|
|
|
|
|
case "open1/1", "open2/2":
|
|
|
|
|
|
return nil, errors.New("the file is gone")
|
|
|
|
|
|
case "short1/1", "short2/2":
|
|
|
|
|
|
c = c[:len(c)-1]
|
|
|
|
|
|
case "long1/1", "long2/2":
|
|
|
|
|
|
c = append(bytes.Clone(c), 'x')
|
|
|
|
|
|
case "change2/2":
|
|
|
|
|
|
c = bytes.Clone(c)
|
|
|
|
|
|
c[0] ^= 1
|
|
|
|
|
|
case "read2/2":
|
|
|
|
|
|
return io.NopCloser(io.MultiReader(bytes.NewReader(c[:len(c)/2]), errReader{errors.New("the disk failed")})), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return io.NopCloser(bytes.NewReader(c)), nil
|
|
|
|
|
|
}}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Running a recipe
|
|
|
|
|
|
|
|
|
|
|
|
func exts(list []extIn) []extension.Extension {
|
|
|
|
|
|
var out []extension.Extension
|
|
|
|
|
|
for _, e := range list {
|
|
|
|
|
|
x := extension.Extension{ID: e.ID, Version: e.Version}
|
|
|
|
|
|
if e.Data != nil {
|
|
|
|
|
|
x.Data = unhex(*e.Data)
|
|
|
|
|
|
if x.Data == nil {
|
|
|
|
|
|
x.Data = []byte{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, x)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func profileOf(name string) *profile.Profile {
|
|
|
|
|
|
p := profile.Quicknet()
|
|
|
|
|
|
switch name {
|
|
|
|
|
|
case "":
|
|
|
|
|
|
case "genesis_plus_one":
|
|
|
|
|
|
p.GenesisTime++
|
|
|
|
|
|
default:
|
|
|
|
|
|
panic("profile " + name)
|
|
|
|
|
|
}
|
|
|
|
|
|
return p
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func nowOf(r recipe, p *profile.Profile) time.Time {
|
|
|
|
|
|
if r.Now == "" {
|
|
|
|
|
|
return time.Unix(p.GenesisTime, 0).UTC()
|
|
|
|
|
|
}
|
|
|
|
|
|
return must(time.Parse(time.RFC3339Nano, r.Now))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// run writes the capsule of r with EncryptFiles while crypto/rand reads the
|
|
|
|
|
|
// keystream of its seed.
|
|
|
|
|
|
func run(r recipe, c *certs) (*capsule.Result, []byte, *seeded, obj, error) {
|
|
|
|
|
|
p := profileOf(r.Profile)
|
|
|
|
|
|
q := profile.Quicknet()
|
|
|
|
|
|
unlock := must(datekey.RoundTime(q, r.Round)).Add(time.Duration(r.UnlockNs))
|
|
|
|
|
|
now := nowOf(r, q)
|
|
|
|
|
|
opts := capsule.EncryptOptions{
|
|
|
|
|
|
Profile: p, UnlockAt: unlock, Now: func() time.Time { return now },
|
|
|
|
|
|
NewPortableKey: r.Portable, Words: r.Words, Padding: capsule.Padding(r.Padding),
|
|
|
|
|
|
Critical: exts(r.Critical), Noncritical: exts(r.Noncritical),
|
|
|
|
|
|
ControlCritical: exts(r.ControlCritical), ControlNoncritical: exts(r.ControlNoncritical),
|
|
|
|
|
|
HeadCritical: exts(r.HeadCritical), HeadNoncritical: exts(r.HeadNoncritical),
|
|
|
|
|
|
Comment: r.Comment, Author: r.Author, PublicNote: r.Note,
|
|
|
|
|
|
LargeArea: r.LargeArea, TestVectors: r.TestVectors, TestAreaLen: r.TestAreaLen,
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.Policy == "time_and_key" {
|
|
|
|
|
|
opts.Policy = capsule.TimeAndKey
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, l := range r.Identities {
|
|
|
|
|
|
opts.Recipients = append(opts.Recipients, identity(l).Recipient())
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, raw := range r.RawRecipients {
|
|
|
|
|
|
opts.Recipients = append(opts.Recipients, recipientOfRaw(unhex(raw)))
|
|
|
|
|
|
}
|
|
|
|
|
|
k := &hooks{rec: obj{}}
|
|
|
|
|
|
if r.AuthorKey != nil {
|
|
|
|
|
|
opts.AuthorKey = &authorHook{key: must(authorkey.NewFromSeed(unhex(r.AuthorKey.Seed))), bad: r.AuthorKey.Bad, k: k}
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.CMS != nil {
|
|
|
|
|
|
opts.CMSSigner = &cmsHook{in: *r.CMS, c: c, when: now, k: k}
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.Sealer != nil {
|
|
|
|
|
|
opts.Sealer = &sealHook{in: *r.Sealer, c: c, when: now, k: k}
|
|
|
|
|
|
}
|
|
|
|
|
|
var sources []capsule.Source
|
|
|
|
|
|
for _, f := range r.Files {
|
|
|
|
|
|
sources = append(sources, sourceOf(f))
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := range r.ManyFiles {
|
|
|
|
|
|
sources = append(sources, sourceOf(text(fmt.Sprintf("f%05d", i), "")))
|
|
|
|
|
|
}
|
|
|
|
|
|
s := newSeeded(r.Seed)
|
|
|
|
|
|
old := rand.Reader
|
|
|
|
|
|
rand.Reader = s
|
|
|
|
|
|
var dst bytes.Buffer
|
|
|
|
|
|
res, err := capsule.EncryptFiles(&dst, sources, opts)
|
|
|
|
|
|
rand.Reader = old
|
|
|
|
|
|
return res, dst.Bytes(), s, k.rec, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Opening
|
|
|
|
|
|
|
|
|
|
|
|
func releases() provider.ReleaseSource {
|
|
|
|
|
|
return testkit.NewSource(testkit.Release(1000), testkit.Release(1001))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func extSet(r recipe) extension.Set {
|
|
|
|
|
|
s := extension.Set{}
|
|
|
|
|
|
for _, l := range [][]extIn{r.Critical, r.Noncritical, r.ControlCritical, r.ControlNoncritical, r.HeadCritical, r.HeadNoncritical} {
|
|
|
|
|
|
for _, e := range l {
|
|
|
|
|
|
s[e.ID] = append(s[e.ID], e.Version)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func opened(r recipe, dkc []byte, ids []age.Identity, dkk []byte) obj {
|
|
|
|
|
|
files := &testkit.MemorySink{}
|
|
|
|
|
|
o := capsule.OpenOptions{
|
|
|
|
|
|
Registry: testkit.Registry(), Extensions: extSet(r), Source: releases(), Identities: ids, Sink: files,
|
|
|
|
|
|
Now: func() time.Time { return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) },
|
|
|
|
|
|
}
|
|
|
|
|
|
if dkk != nil {
|
|
|
|
|
|
o.AccessKeyFile = bytes.NewReader(dkk)
|
|
|
|
|
|
}
|
|
|
|
|
|
res, err := capsule.Open(context.Background(), nil, bytes.NewReader(dkc), o)
|
|
|
|
|
|
v := obj{}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
v["result"] = datekeys.Code(err)
|
|
|
|
|
|
if v["result"] == "" {
|
|
|
|
|
|
v["result"] = "error: " + err.Error()
|
|
|
|
|
|
}
|
|
|
|
|
|
if res != nil && res.Inspection != nil && len(res.Inspection.Checks) > 0 {
|
|
|
|
|
|
v["step"] = res.Inspection.Checks[len(res.Inspection.Checks)-1].Step
|
|
|
|
|
|
}
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
v["result"] = "ok"
|
|
|
|
|
|
var fs []obj
|
|
|
|
|
|
for i, f := range res.Head.Files {
|
|
|
|
|
|
fj := obj{"path": f.Path, "size": f.Size, "sha256": sum(files.Files[i])}
|
|
|
|
|
|
if f.HasMTime {
|
|
|
|
|
|
fj["mtime"] = f.MTime
|
|
|
|
|
|
}
|
|
|
|
|
|
fs = append(fs, fj)
|
|
|
|
|
|
}
|
|
|
|
|
|
if fs == nil {
|
|
|
|
|
|
fs = []obj{}
|
|
|
|
|
|
}
|
|
|
|
|
|
v["files"] = fs
|
|
|
|
|
|
v["head"] = h(must(capsule.EncodeHead(res.Head)))
|
|
|
|
|
|
v["verdicts"] = []string{string(res.Verdicts.Signature), string(res.Verdicts.Seal)}
|
|
|
|
|
|
v["area_len"] = res.AreaLen
|
|
|
|
|
|
v["length"] = res.PayloadLength
|
|
|
|
|
|
v["padded_length"] = res.PaddedLength
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func opens(r recipe, dkc []byte, res *capsule.Result, dkk []byte) []obj {
|
|
|
|
|
|
if r.Policy != "time_and_key" {
|
|
|
|
|
|
v := opened(r, dkc, nil, nil)
|
|
|
|
|
|
v["with"] = "time"
|
|
|
|
|
|
return []obj{v}
|
|
|
|
|
|
}
|
|
|
|
|
|
var out []obj
|
|
|
|
|
|
var all []age.Identity
|
|
|
|
|
|
for _, l := range r.Identities {
|
|
|
|
|
|
id := identity(l)
|
|
|
|
|
|
v := opened(r, dkc, []age.Identity{id}, nil)
|
|
|
|
|
|
v["with"] = "identity " + l
|
|
|
|
|
|
out = append(out, v)
|
|
|
|
|
|
all = append(all, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(r.Words) != 0 {
|
|
|
|
|
|
id := must(wordkey.Identity(r.Words, profile.Quicknet().ChainHash[:], res.DateKey.Round, res.CapsuleID[:]))
|
|
|
|
|
|
v := opened(r, dkc, []age.Identity{id}, nil)
|
|
|
|
|
|
v["with"] = "words"
|
|
|
|
|
|
out = append(out, v)
|
|
|
|
|
|
all = append(all, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
if dkk != nil {
|
|
|
|
|
|
v := opened(r, dkc, nil, dkk)
|
|
|
|
|
|
v["with"] = "portable"
|
|
|
|
|
|
out = append(out, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
v := opened(r, dkc, all, dkk)
|
|
|
|
|
|
v["with"] = "all"
|
|
|
|
|
|
out = append(out, v)
|
|
|
|
|
|
v = opened(r, dkc, nil, nil)
|
|
|
|
|
|
v["with"] = "none"
|
|
|
|
|
|
out = append(out, v)
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// layers opens SEALED_CONTROL with the release and, in time_and_key, with
|
|
|
|
|
|
// the first credential, and returns PUBLIC_HEADER, CONTROL_CBOR and whether
|
|
|
|
|
|
// they and the .dkk encode again to the bytes written.
|
|
|
|
|
|
func layers(r recipe, dkc []byte, res *capsule.Result, dkk []byte) (header, control []byte, same bool) {
|
|
|
|
|
|
pre := must(capsule.ParsePrelude(dkc))
|
|
|
|
|
|
header = dkc[capsule.PreludeSize : capsule.PreludeSize+int(pre.PublicHeaderLen)]
|
|
|
|
|
|
sealed := dkc[capsule.PreludeSize+int(pre.PublicHeaderLen) : capsule.PreludeSize+int(pre.PublicHeaderLen)+int(pre.SealedControlLen)]
|
|
|
|
|
|
hd := must(capsule.DecodeHeader(header))
|
|
|
|
|
|
same = bytes.Equal(must(capsule.EncodeHeader(hd)), header)
|
|
|
|
|
|
tid := must(agewrap.NewTimeIdentity(profile.Quicknet(), res.DateKey.Round, testkit.Release(res.DateKey.Round)))
|
|
|
|
|
|
control = must(io.ReadAll(must(age.Decrypt(bytes.NewReader(sealed), tid))))
|
|
|
|
|
|
if r.Policy == "time_and_key" {
|
|
|
|
|
|
var id age.Identity
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case len(r.Identities) > 0:
|
|
|
|
|
|
id = identity(r.Identities[0])
|
|
|
|
|
|
case dkk != nil:
|
|
|
|
|
|
k := must(accesskey.Decode(bytes.NewReader(dkk)))
|
|
|
|
|
|
id = must(agewrap.X25519IdentityFromRaw(k.Material))
|
|
|
|
|
|
default:
|
|
|
|
|
|
id = must(wordkey.Identity(r.Words, profile.Quicknet().ChainHash[:], res.DateKey.Round, res.CapsuleID[:]))
|
|
|
|
|
|
}
|
|
|
|
|
|
aid := must(agewrap.NewAccessIdentity(agewrap.AccessSlots, id))
|
|
|
|
|
|
control = must(io.ReadAll(must(age.Decrypt(bytes.NewReader(control), aid))))
|
|
|
|
|
|
}
|
|
|
|
|
|
c := must(capsule.DecodeControl(control, pre.Format))
|
|
|
|
|
|
same = same && bytes.Equal(must(capsule.EncodeControl(c, pre.Format)), control)
|
|
|
|
|
|
if dkk != nil {
|
|
|
|
|
|
k := must(accesskey.Decode(bytes.NewReader(dkk)))
|
|
|
|
|
|
var b bytes.Buffer
|
|
|
|
|
|
must(0, accesskey.Encode(&b, k))
|
|
|
|
|
|
same = same && bytes.Equal(b.Bytes(), dkk)
|
|
|
|
|
|
}
|
|
|
|
|
|
return header, control, same
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// The vectors
|
|
|
|
|
|
|
|
|
|
|
|
func caseOf(r recipe, c *certs) obj {
|
|
|
|
|
|
res, dkc, s, rec, err := run(r, c)
|
|
|
|
|
|
draws := s.draws
|
|
|
|
|
|
if draws == nil {
|
|
|
|
|
|
draws = []int{}
|
|
|
|
|
|
}
|
|
|
|
|
|
out := obj{"recipe": r, "draws": draws}
|
|
|
|
|
|
if len(rec) > 0 {
|
|
|
|
|
|
out["hooks"] = rec
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
e := obj{"text": err.Error(), "written": len(dkc)}
|
|
|
|
|
|
if code := datekeys.Code(err); code != "" {
|
|
|
|
|
|
e["code"] = code
|
|
|
|
|
|
}
|
|
|
|
|
|
out["error"] = e
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
var dkk []byte
|
|
|
|
|
|
if res.PortableKey != nil {
|
|
|
|
|
|
var b bytes.Buffer
|
|
|
|
|
|
must(0, accesskey.Encode(&b, res.PortableKey))
|
|
|
|
|
|
dkk = b.Bytes()
|
|
|
|
|
|
}
|
|
|
|
|
|
header, control, same := layers(r, dkc, res, dkk)
|
|
|
|
|
|
w := obj{
|
|
|
|
|
|
"length_dkc": len(dkc), "sha256": sum(dkc),
|
|
|
|
|
|
"round": res.DateKey.Round, "unlock_at": res.UnlockAt.UTC().Format(time.RFC3339Nano),
|
|
|
|
|
|
"capsule_id": h(res.CapsuleID[:]), "length": res.Length, "padding": int(res.Padding),
|
|
|
|
|
|
"padded_length": res.PaddedLength, "head": h(must(capsule.EncodeHead(res.Head))),
|
|
|
|
|
|
"header": h(header), "control": h(control), "reencoded": same,
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(dkc) <= 4096 {
|
|
|
|
|
|
w["dkc"] = h(dkc)
|
|
|
|
|
|
}
|
|
|
|
|
|
if dkk != nil {
|
|
|
|
|
|
w["dkk"] = h(dkk)
|
|
|
|
|
|
}
|
|
|
|
|
|
out["written"] = w
|
|
|
|
|
|
out["opens"] = opens(r, dkc, res, dkk)
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestWriteVectors(t *testing.T) {
|
|
|
|
|
|
if *sourceFlag == "" || *outFlag == "" {
|
|
|
|
|
|
t.Skip("run with -args -source COMMIT -out DIR")
|
|
|
|
|
|
}
|
|
|
|
|
|
cryptotest.SetGlobalRandom(t, 20261006)
|
|
|
|
|
|
c := newCerts()
|
|
|
|
|
|
var cases, node []obj
|
|
|
|
|
|
for _, r := range recipes() {
|
|
|
|
|
|
v := caseOf(r, c)
|
|
|
|
|
|
cases = append(cases, v)
|
|
|
|
|
|
if r.Node {
|
|
|
|
|
|
node = append(node, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
if e, ok := v["error"]; ok {
|
|
|
|
|
|
t.Logf("%s: %s", r.Name, e.(obj)["text"])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
t.Logf("%s: %d bytes", r.Name, v["written"].(obj)["length_dkc"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
rel := obj{}
|
|
|
|
|
|
for _, round := range []uint64{1000, 1001} {
|
|
|
|
|
|
rel[fmt.Sprint(round)] = h(testkit.Release(round).Signature)
|
|
|
|
|
|
}
|
|
|
|
|
|
doc := func(cases []obj) []byte {
|
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
|
enc := json.NewEncoder(&buf)
|
|
|
|
|
|
enc.SetEscapeHTML(false)
|
|
|
|
|
|
enc.SetIndent("", " ")
|
|
|
|
|
|
must(0, enc.Encode(obj{
|
|
|
|
|
|
"description": "What capsule.EncryptFiles of datekeys-go writes for each recipe while crypto/rand reads the keystream of SeededRandomSource (ChaCha20 under SHA-256(seed), zero nonce), with the size of each draw and what each hook was given and returned; and the text, the code and the bytes written of each error (tool/capsule_writer_go_vectors_test.go). A capsule written is opened with capsule.Open with each credential, all together and none. A file of n bytes of pattern has (31·i + 7) mod 256 as byte i; the raw secret of the identity of a label is SHA-256(\"identity \" + label).",
|
|
|
|
|
|
"source": *sourceFlag,
|
|
|
|
|
|
"go": runtime.Version(),
|
|
|
|
|
|
"releases": rel,
|
|
|
|
|
|
"cases": cases,
|
|
|
|
|
|
}))
|
|
|
|
|
|
if bytes.Contains(buf.Bytes(), []byte("'''")) {
|
|
|
|
|
|
t.Fatal("the JSON holds three quotes")
|
|
|
|
|
|
}
|
|
|
|
|
|
return buf.Bytes()
|
|
|
|
|
|
}
|
|
|
|
|
|
full := doc(cases)
|
|
|
|
|
|
path := filepath.Join(*outFlag, "capsule_writer.json")
|
|
|
|
|
|
must(0, os.WriteFile(path, full, 0o644))
|
|
|
|
|
|
t.Logf("wrote %s, %d bytes", path, len(full))
|
|
|
|
|
|
dart := "// Generated by tool/capsule_writer_go_vectors_test.go: the cases of\n" +
|
|
|
|
|
|
"// test/vectors/capsule_writer.json marked node, for the tests that also run\n" +
|
|
|
|
|
|
"// compiled to JavaScript, where no file can be read. Do not edit.\n\n" +
|
|
|
|
|
|
"/// Part of test/vectors/capsule_writer.json.\n" +
|
|
|
|
|
|
"const capsuleWriterJson = r'''\n" + string(doc(node)) + "''';\n"
|
|
|
|
|
|
dpath := filepath.Join(*outFlag, "capsule_writer.g.dart")
|
|
|
|
|
|
must(0, os.WriteFile(dpath, []byte(dart), 0o644))
|
|
|
|
|
|
t.Logf("wrote %s", dpath)
|
|
|
|
|
|
}
|