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-dart/tool/gen_age_vectors.go

950 lines
38 KiB

//go:build ignore
// gen_age_vectors writes the age vectors of stage 2 of datekeys-dart, every
// file and every result produced by filippo.io/age v1.3.2 and the agewrap
// package of datekeys-go:
//
// - test/vectors/age.json: age files with X25519 and scrypt recipients,
// their truncations and manipulations, a corpus of headers against the
// grammar and the limits of the parser, the stanza rules and identities
// of agewrap, and the text of Go's error for each;
// - test/vectors/age_fixtures.json: the PAYLOAD_AGE of every official
// capsule of testdata/ with its payload_identity, and the
// INNER_ACCESS_AGE of the time_and_key ones, which OUTER_TIME_AGE seals
// and this program opens with the release of the fixture.
//
// Large files are not stored: a file of more than one STREAM chunk is its
// header, its nonce and its file key, and the tests encrypt the documented
// plaintext again and check the SHA-256 of the whole file before reading it.
// Headers of megabytes are written as parts to repeat.
//
// It needs the module context of datekeys-go and changes nothing there:
//
// cd ../datekeys-go && go run ../datekeys-dart/tool/gen_age_vectors.go -out ../datekeys-dart/test/vectors
//
// The fixtures are those of the testdata/ of datekeys-dart (-testdata), the
// copy synced from datekeys-go at the version that this package implements.
//
// age draws its file keys, nonces and ephemeral shares from crypto/rand, so
// each run writes other files; the tests read whatever is committed.
package main
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"unicode/utf8"
"filippo.io/age"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/scrypt"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
type obj = map[string]any
// testdata is the synced copy of datekeys-dart, read only.
var testdata string
func h(b []byte) string { return hex.EncodeToString(b) }
func sum(b []byte) string {
s := sha256.Sum256(b)
return h(s[:])
}
func check(err error) {
if err != nil {
log.Fatal(err)
}
}
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
check(err)
return b
}
var b64 = base64.RawStdEncoding
// pattern is the plaintext of n bytes that the large files encrypt: byte i
// is (31·i + 7) mod 256.
func pattern(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(31*i + 7)
}
return b
}
// eph is the i-th ephemeral scalar of the crafted stanzas.
func eph(i int) []byte {
s := sha256.Sum256([]byte{byte(i)})
return s[:]
}
func encrypt(plain []byte, rs ...age.Recipient) []byte {
var buf bytes.Buffer
w, err := age.Encrypt(&buf, rs...)
check(err)
_, err = w.Write(plain)
check(err)
check(w.Close())
return buf.Bytes()
}
// fnRecipient wraps the file key with a function: stanzas that age would not
// write, with the file key that the header MAC uses.
type fnRecipient func(fileKey []byte) []*age.Stanza
func (f fnRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { return f(fileKey), nil }
// x25519Stanza wraps fileKey to the public key pub with the ephemeral
// scalar eph, as age's X25519Recipient, with the share and body given.
func x25519Stanza(fileKey, pub, eph []byte) *age.Stanza {
share, err := curve25519.X25519(eph, curve25519.Basepoint)
check(err)
secret, err := curve25519.X25519(eph, pub)
check(err)
salt := append(slices.Clone(share), pub...)
key := make([]byte, 32)
_, err = io.ReadFull(hkdf.New(sha256.New, secret, salt, []byte("age-encryption.org/v1/X25519")), key)
check(err)
a, err := chacha20poly1305.New(key)
check(err)
return &age.Stanza{Type: "X25519", Args: []string{b64.EncodeToString(share)}, Body: a.Seal(nil, make([]byte, 12), fileKey, nil)}
}
func scryptStanza(fileKey []byte, pass string, salt []byte, logN int, wf string) *age.Stanza {
k, err := scrypt.Key([]byte(pass), append([]byte("age-encryption.org/v1/scrypt"), salt...), 1<<logN, 8, 1, 32)
check(err)
a, err := chacha20poly1305.New(k)
check(err)
return &age.Stanza{Type: "scrypt", Args: []string{b64.EncodeToString(salt), wf}, Body: a.Seal(nil, make([]byte, 12), fileKey, nil)}
}
// An identity of a case, as the tests build it again.
type idSpec struct {
X25519 string `json:"x25519,omitempty"`
Scrypt string `json:"scrypt,omitempty"`
MaxWorkFactor int `json:"max_work_factor,omitempty"`
}
func (s idSpec) identity() age.Identity {
if s.X25519 != "" {
id, err := age.ParseX25519Identity(s.X25519)
check(err)
return id
}
id, err := age.NewScryptIdentity(s.Scrypt)
check(err)
id.SetMaxWorkFactor(s.MaxWorkFactor)
return id
}
// result decrypts file with ids as capsule.decryptAll does and returns the
// outcome: the SHA-256 of the plaintext, or the text of the error and its
// phase, header for age.Decrypt and payload for the STREAM.
func result(file []byte, ids ...age.Identity) obj {
r, err := age.Decrypt(bytes.NewReader(file), ids...)
if err != nil {
return obj{"error": err.Error(), "phase": "header"}
}
plain, err := io.ReadAll(r)
if err != nil {
return obj{"error": err.Error(), "phase": "payload"}
}
return obj{"plaintext_sha256": sum(plain), "plaintext_length": len(plain)}
}
// A part of a file: text or hex, repeated.
type part struct {
Text *string `json:"text,omitempty"`
Hex *string `json:"hex,omitempty"`
Repeat int `json:"repeat,omitempty"`
}
// txt is a text part, or a hex one when s is not UTF-8, which JSON cannot
// hold.
func txt(s string) part {
if !utf8.ValidString(s) {
return hx([]byte(s))
}
return part{Text: &s}
}
func hx(b []byte) part { s := h(b); return part{Hex: &s} }
func rep(s string, n int) part { return part{Text: &s, Repeat: n} }
func build(parts []part) []byte {
var buf bytes.Buffer
for _, p := range parts {
var b []byte
if p.Text != nil {
b = []byte(*p.Text)
} else {
b = mustHex(*p.Hex)
}
n := p.Repeat
if n == 0 {
n = 1
}
for range n {
buf.Write(b)
}
}
return buf.Bytes()
}
func stanzasOf(ss []*age.Stanza) []obj {
var out []obj
for _, s := range ss {
args := s.Args
if args == nil {
args = []string{}
}
out = append(out, obj{"type": s.Type, "args": args, "body_length": len(s.Body), "body_sha256": sum(s.Body)})
}
return out
}
// probe gives the stanzas of agewrap.Stanzas: all of them when they are
// few, else their number and the first and the last.
func probe(file []byte) obj {
ss, err := agewrap.Stanzas(bytes.NewReader(file))
if err != nil {
return obj{"error": err.Error()}
}
if len(ss) <= 16 {
return obj{"count": len(ss), "stanzas": stanzasOf(ss)}
}
return obj{"count": len(ss), "first": stanzasOf(ss[:1])[0], "last": stanzasOf(ss[len(ss)-1:])[0]}
}
func errText(err error) any {
if err == nil {
return nil
}
return err.Error()
}
func main() {
outDir := flag.String("out", "", "directory of the vectors")
flag.StringVar(&testdata, "testdata", "../datekeys-dart/testdata", "the testdata/ of datekeys-dart, synced from datekeys-go")
flag.Parse()
if *outDir == "" {
log.Fatal("-out is required")
}
ageJSON(*outDir)
fixturesJSON(*outDir)
}
func ageJSON(outDir string) {
primary, err := age.GenerateX25519Identity()
check(err)
other, err := age.GenerateX25519Identity()
check(err)
mainID := idSpec{X25519: primary.String()}
otherID := idSpec{X25519: other.String()}
const pass = "correct horse battery staple"
mainRaw, err := agewrap.RawX25519Recipient(primary.Recipient())
check(err)
doc := obj{
"description": "age files written by filippo.io/age v1.3.2 and read by it, with the text of Go's error for each case; see tool/gen_age_vectors.go. A file is hex, or parts (text or hex, repeated) to concatenate. A large file is its header, its nonce and its file key: its plaintext is pattern(length), byte i = (31 i + 7) mod 256, encrypted again by the test, which checks the SHA-256 of the whole file. An identity is {x25519: AGE-SECRET-KEY-1...} or {scrypt: passphrase, max_work_factor}. A result is the SHA-256 and length of the plaintext, or the error of age.Decrypt (phase header) or of reading its plaintext (phase payload).",
"generator": "tool/gen_age_vectors.go, " + runtime.Version(),
"identities": obj{"main": primary.String(), "main_recipient": primary.Recipient().String(), "main_recipient_raw": h(mainRaw), "other": other.String(), "passphrase": pass},
}
// Files.
files := map[string][]byte{}
var fileList []obj
addFile := func(name string, file []byte, plain []byte) {
files[name] = file
fileList = append(fileList, obj{"name": name, "hex": h(file), "plaintext": h(plain)})
}
for _, n := range []int{0, 1, 100} {
p := pattern(n)
addFile(fmt.Sprintf("x25519_%d", n), encrypt(p, primary.Recipient()), p)
}
addFile("x25519_two_recipients", encrypt(pattern(50), other.Recipient(), primary.Recipient()), pattern(50))
for _, logN := range []int{1, 2, 10, 16, 17} {
r, err := age.NewScryptRecipient(pass)
check(err)
r.SetWorkFactor(logN)
p := pattern(40)
addFile(fmt.Sprintf("scrypt_%d", logN), encrypt(p, r), p)
}
// An scrypt stanza beside an X25519 one, which age.Encrypt never writes.
addFile("x25519_and_scrypt", encrypt(pattern(10), primary.Recipient(), fnRecipient(func(fk []byte) []*age.Stanza {
return []*age.Stanza{scryptStanza(fk, pass, pattern(16), 1, "1")}
})), pattern(10))
// scrypt stanzas of every form that age refuses, each with a valid MAC.
scryptForms := []struct {
name string
s func(fk []byte) *age.Stanza
}{
{"scrypt_wf_leading_zero", func(fk []byte) *age.Stanza { return scryptStanza(fk, pass, pattern(16), 1, "01") }},
{"scrypt_wf_zero", func(fk []byte) *age.Stanza { return scryptStanza(fk, pass, pattern(16), 1, "0") }},
{"scrypt_wf_sign", func(fk []byte) *age.Stanza { return scryptStanza(fk, pass, pattern(16), 1, "+1") }},
{"scrypt_wf_letters", func(fk []byte) *age.Stanza { return scryptStanza(fk, pass, pattern(16), 1, "1a") }},
{"scrypt_wf_out_of_range", func(fk []byte) *age.Stanza {
return scryptStanza(fk, pass, pattern(16), 1, "99999999999999999999")
}},
{"scrypt_wf_max_int64", func(fk []byte) *age.Stanza {
return scryptStanza(fk, pass, pattern(16), 1, "9223372036854775807")
}},
{"scrypt_wf_23", func(fk []byte) *age.Stanza { return scryptStanza(fk, pass, pattern(16), 1, "23") }},
{"scrypt_one_arg", func(fk []byte) *age.Stanza {
s := scryptStanza(fk, pass, pattern(16), 1, "1")
s.Args = s.Args[:1]
return s
}},
{"scrypt_salt_15", func(fk []byte) *age.Stanza { return scryptStanza(fk, pass, pattern(15), 1, "1") }},
{"scrypt_salt_padded", func(fk []byte) *age.Stanza {
s := scryptStanza(fk, pass, pattern(16), 1, "1")
s.Args[0] += "=="
return s
}},
{"scrypt_body_31", func(fk []byte) *age.Stanza {
s := scryptStanza(fk, pass, pattern(16), 1, "1")
s.Body = s.Body[:31]
return s
}},
}
for _, f := range scryptForms {
addFile(f.name, encrypt(pattern(5), fnRecipient(func(fk []byte) []*age.Stanza { return []*age.Stanza{f.s(fk)} })), pattern(5))
}
// X25519 stanzas of every form that age refuses, each with a valid MAC.
// u = 1, a point of low order: X25519 of any scalar and it is zero.
lowOrder := append([]byte{1}, make([]byte, 31)...)
if _, err := curve25519.X25519(pattern(32), lowOrder); err == nil {
log.Fatal("u = 1 is not refused")
}
x25519Forms := []struct {
name string
s func(fk []byte) *age.Stanza
}{
{"x25519_no_arg", func(fk []byte) *age.Stanza {
s := x25519Stanza(fk, mainRaw, pattern(32))
s.Args = nil
return s
}},
{"x25519_two_args", func(fk []byte) *age.Stanza {
s := x25519Stanza(fk, mainRaw, pattern(32))
s.Args = append(s.Args, "x")
return s
}},
{"x25519_share_31", func(fk []byte) *age.Stanza {
s := x25519Stanza(fk, mainRaw, pattern(32))
s.Args[0] = b64.EncodeToString(pattern(31))
return s
}},
{"x25519_share_bad_base64", func(fk []byte) *age.Stanza {
s := x25519Stanza(fk, mainRaw, pattern(32))
s.Args[0] = s.Args[0][:42] + "B"
return s
}},
{"x25519_share_low_order", func(fk []byte) *age.Stanza {
s := x25519Stanza(fk, mainRaw, pattern(32))
s.Args[0] = b64.EncodeToString(lowOrder)
return s
}},
{"x25519_body_33", func(fk []byte) *age.Stanza {
s := x25519Stanza(fk, mainRaw, pattern(32))
s.Body = append(s.Body, 0)
return s
}},
{"x25519_body_tampered", func(fk []byte) *age.Stanza {
s := x25519Stanza(fk, mainRaw, pattern(32))
s.Body[3] ^= 1
return s
}},
{"x25519_custom_valid", func(fk []byte) *age.Stanza { return x25519Stanza(fk, mainRaw, pattern(32)) }},
}
for _, f := range x25519Forms {
addFile(f.name, encrypt(pattern(5), fnRecipient(func(fk []byte) []*age.Stanza { return []*age.Stanza{f.s(fk)} })), pattern(5))
}
doc["files"] = fileList
// Large files: more than one STREAM chunk.
var large []obj
largeFiles := map[string][]byte{}
for _, n := range []int{65535, 65536, 65537, 131072, 140000} {
file := encrypt(pattern(n), primary.Recipient())
hdr, err := age.ExtractHeader(bytes.NewReader(file))
check(err)
fk, err := age.DecryptHeader(hdr, primary)
check(err)
name := fmt.Sprintf("x25519_large_%d", n)
largeFiles[name] = file
files[name] = file
large = append(large, obj{"name": name, "header": h(hdr), "nonce": h(file[len(hdr) : len(hdr)+16]), "file_key": h(fk), "length": n, "file_length": len(file), "file_sha256": sum(file)})
}
doc["large"] = large
// Cases: a file, an edit, identities, and Go's result.
type edit struct {
Truncate *int `json:"truncate,omitempty"`
Append string `json:"append,omitempty"`
At *int `json:"at,omitempty"`
Xor string `json:"xor,omitempty"`
}
apply := func(b []byte, e *edit) []byte {
b = slices.Clone(b)
if e == nil {
return b
}
if e.Truncate != nil {
b = b[:*e.Truncate]
}
if e.At != nil {
x := mustHex(e.Xor)
for i := range x {
b[*e.At+i] ^= x[i]
}
}
return append(b, mustHex(e.Append)...)
}
intp := func(i int) *int { return &i }
var caseList []obj
addCase := func(name, file string, e *edit, ids ...idSpec) {
b := apply(files[file], e)
var ages []age.Identity
for _, s := range ids {
ages = append(ages, s.identity())
}
c := obj{"name": name, "file": file, "identities": ids, "result": result(b, ages...)}
if ids == nil {
c["identities"] = []idSpec{}
}
if e != nil {
c["edit"] = e
}
caseList = append(caseList, c)
}
scryptID := func(p string, max int) idSpec { return idSpec{Scrypt: p, MaxWorkFactor: max} }
for _, f := range fileList {
name := f["name"].(string)
switch {
case strings.HasPrefix(name, "scrypt_"), name == "x25519_and_scrypt":
addCase(name+", passphrase, max 16", name, nil, scryptID(pass, 16))
addCase(name+", passphrase, max 22", name, nil, scryptID(pass, 22))
addCase(name+", X25519 and passphrase", name, nil, mainID, scryptID(pass, 16))
default:
addCase(name+", main identity", name, nil, mainID)
}
}
addCase("x25519_1, other identity", "x25519_1", nil, otherID)
addCase("x25519_1, other and main", "x25519_1", nil, otherID, mainID)
addCase("x25519_1, two other identities", "x25519_1", nil, otherID, otherID)
addCase("x25519_1, passphrase", "x25519_1", nil, scryptID(pass, 16))
addCase("x25519_1, passphrase then main", "x25519_1", nil, scryptID(pass, 16), mainID)
addCase("x25519_1, no identity", "x25519_1", nil)
addCase("x25519_two_recipients, other", "x25519_two_recipients", nil, otherID)
addCase("scrypt_2, wrong passphrase", "scrypt_2", nil, scryptID("wrong", 16))
addCase("scrypt_2, wrong passphrase then main", "scrypt_2", nil, scryptID("wrong", 16), mainID)
addCase("scrypt_17, max 17", "scrypt_17", nil, scryptID(pass, 17))
// Truncations and manipulations of x25519_100 and of the large files.
small := files["x25519_100"]
hdrSmall, err := age.ExtractHeader(bytes.NewReader(small))
check(err)
hl := len(hdrSmall)
for _, n := range []int{0, 10, 21, 22, 23, hl - 2, hl - 1, hl, hl + 1, hl + 15, hl + 16, hl + 17, hl + 31, hl + 32, len(small) - 1} {
addCase(fmt.Sprintf("x25519_100 truncated to %d", n), "x25519_100", &edit{Truncate: intp(n)}, mainID)
}
addCase("x25519_100 and a byte", "x25519_100", &edit{Append: "00"}, mainID)
for _, at := range []int{0, 5, 21, 25, hl - 10, hl - 2, hl, hl + 15, hl + 16, hl + 50, len(small) - 1} {
addCase(fmt.Sprintf("x25519_100, byte %d changed", at), "x25519_100", &edit{At: intp(at), Xor: "01"}, mainID)
}
for _, name := range []string{"x25519_large_65535", "x25519_large_65536", "x25519_large_65537", "x25519_large_131072", "x25519_large_140000"} {
f := files[name]
hdr, err := age.ExtractHeader(bytes.NewReader(f))
check(err)
p := len(hdr) + 16
const enc = 65536 + 16
cuts := []int{p, p + 1, p + 16, p + enc - 1, p + enc, p + enc + 1, p + enc + 16, p + enc + 17, len(f) - 1, len(f) - 16, len(f) - 17}
for _, n := range cuts {
if n < 0 || n > len(f) {
continue
}
addCase(fmt.Sprintf("%s truncated to %d", name, n), name, &edit{Truncate: intp(n)}, mainID)
}
addCase(name+" and a byte", name, &edit{Append: "00"}, mainID)
addCase(name+" and 17 bytes", name, &edit{Append: h(make([]byte, 17))}, mainID)
for _, at := range []int{p, p + 100, p + enc - 1, p + enc, len(f) - 1} {
if at < len(f) {
addCase(fmt.Sprintf("%s, byte %d changed", name, at), name, &edit{At: intp(at), Xor: "80"}, mainID)
}
}
addCase(name+", nonce changed", name, &edit{At: intp(len(hdr)), Xor: "01"}, mainID)
}
doc["cases"] = caseList
// The grammar and the limits of the header. Each file is the header of
// x25519_1 or a variant of it, with its payload.
base := files["x25519_1"]
hdr1, err := age.ExtractHeader(bytes.NewReader(base))
check(err)
payload := base[len(hdr1):]
lines := strings.SplitAfter(string(hdr1), "\n")
intro, open, body, footer := lines[0], lines[1], lines[2], lines[3]
mac := strings.TrimSuffix(strings.TrimPrefix(footer, "--- "), "\n")
tail := []part{txt(footer), hx(payload)}
withBody := func(o, b string) []part { return append([]part{txt(intro), txt(o), txt(b)}, tail...) }
var headers []obj
addHeader := func(name string, parts []part) {
file := build(parts)
headers = append(headers, obj{"name": name, "parts": parts, "length": len(file), "sha256": sum(file), "result": result(file, primary), "probe": probe(file)})
}
full := strings.Repeat("A", 64) + "\n"
big := 2 << 20
addHeader("x25519_1 as written", []part{hx(base)})
addHeader("empty", nil)
addHeader("intro without its newline", []part{txt(strings.TrimSuffix(intro, "\n"))})
addHeader("a short intro", []part{txt("age")})
addHeader("an identity, no newline", []part{txt(primary.String())})
addHeader("an identity line", []part{txt(primary.String() + "\n")})
addHeader("a plugin identity line", []part{txt("AGE-PLUGIN-X-1QQQ\n")})
addHeader("intro v2", append([]part{txt("age-encryption.org/v2\n"), txt(open), txt(body)}, tail...))
addHeader("a long first line cut inside a rune", []part{txt("age-encryption.org/v1ñ\n")})
addHeader("a binary first line", []part{hx([]byte{0xff, 0xfe, 0, 1, '\n'})})
addHeader("intro only", []part{txt(intro)})
addHeader("intro and two bytes", []part{txt(intro), txt("--")})
addHeader("intro and ---", []part{txt(intro), txt("---")})
addHeader("no stanzas", []part{txt(intro), txt(footer), hx(payload)})
addHeader("the footer without newline", []part{txt(intro), txt(open), txt(body), txt(strings.TrimSuffix(footer, "\n"))})
addHeader("the footer without MAC", append([]part{txt(intro), txt(open), txt(body), txt("---\n")}, hx(payload)))
addHeader("the footer with an empty MAC", append([]part{txt(intro), txt(open), txt(body), txt("--- \n")}, hx(payload)))
addHeader("the footer with two spaces", append([]part{txt(intro), txt(open), txt(body), txt("--- " + mac + "\n")}, hx(payload)))
addHeader("the footer with two arguments", append([]part{txt(intro), txt(open), txt(body), txt("--- " + mac + " x\n")}, hx(payload)))
addHeader("a MAC of 31 bytes", append([]part{txt(intro), txt(open), txt(body), txt("--- " + b64.EncodeToString(pattern(31)) + "\n")}, hx(payload)))
addHeader("a MAC with padding", append([]part{txt(intro), txt(open), txt(body), txt("--- " + mac + "=\n")}, hx(payload)))
addHeader("a MAC with a CR", append([]part{txt(intro), txt(open), txt(body), txt("--- " + mac + "\r\n")}, hx(payload)))
addHeader("a changed MAC", append([]part{txt(intro), txt(open), txt(body), txt("--- " + b64.EncodeToString(pattern(32)) + "\n")}, hx(payload)))
addHeader("----", append([]part{txt(intro), txt(open), txt(body), txt("---- " + mac + "\n")}, hx(payload)))
addHeader("opening line ->", withBody("->\n", body))
addHeader("opening line -> and a space", withBody("-> \n", body))
addHeader("opening line with two spaces", withBody(strings.Replace(open, " ", " ", 1), body))
addHeader("opening line with a trailing space", withBody(strings.TrimSuffix(open, "\n")+" \n", body))
addHeader("opening line ->X25519", withBody(strings.Replace(open, "-> ", "->", 1), body))
addHeader("opening line - >", withBody(strings.Replace(open, "->", "- >", 1), body))
addHeader("opening line with a tab", withBody(strings.Replace(open, " ", "\t", 1), body))
addHeader("opening line with a CR", withBody(strings.TrimSuffix(open, "\n")+"\r\n", body))
addHeader("an argument with a byte above 0x7e", withBody("-> X25519 \x80\n", body))
addHeader("an argument with DEL", withBody("-> X25519 a\x7f\n", body))
addHeader("a type of non-ASCII letters", withBody("-> tlöck 1\n", body))
addHeader("129 tokens", withBody("->"+strings.Repeat(" a", 129)+"\n", "\n"))
addHeader("130 tokens", withBody("->"+strings.Repeat(" a", 130)+"\n", "\n"))
addHeader("a body line with a CR", withBody(open, strings.TrimSuffix(body, "\n")+"\r\n"))
addHeader("a body line with padding", withBody(open, strings.TrimSuffix(body, "\n")+"=\n"))
// The last character of 32 bytes in Base64 carries two zero bits.
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
last := strings.IndexByte(alphabet, body[len(body)-2])
addHeader("a body line with non-zero trailing bits", withBody(open, body[:len(body)-2]+string(alphabet[last|1])+"\n"))
addHeader("a body line of 41 bytes", withBody(open, strings.TrimSuffix(body, "\n")[:42]+"\n"))
addHeader("a body line with a space", withBody(open, " "+body))
addHeader("a body line of 68 columns", withBody(open, strings.Repeat("A", 68)+"\n"))
addHeader("a body line of 65 columns", withBody(open, strings.Repeat("A", 65)+"\n"))
addHeader("a full line, then the footer", withBody(open, full))
addHeader("a full line, then a stanza", append([]part{txt(intro), txt(open), txt(full), txt(open), txt(body)}, tail...))
addHeader("a full line and an empty one", withBody(open, full+"\n"))
addHeader("an empty body", withBody(open, "\n"))
addHeader("a stanza cut after its opening line", []part{txt(intro), txt(open)})
addHeader("a stanza cut in its body", []part{txt(intro), txt(open), txt(strings.TrimSuffix(body, "\n"))})
addHeader("two copies of the stanza", append([]part{txt(intro), txt(open), txt(body), txt(open), txt(body)}, tail...))
addHeader("1024 stanzas", append([]part{txt(intro), rep(open+body, 1024)}, tail...))
addHeader("1025 stanzas", append([]part{txt(intro), rep(open+body, 1025)}, tail...))
addHeader("1023 other stanzas and the stanza", append([]part{txt(intro), rep("-> other\n\n", 1023), txt(open), txt(body)}, tail...))
// The 2 MiB limit: a first line, a body, the Peek of the footer, the
// footer line, and a header that ends exactly at the limit.
addHeader("a first line of 2 MiB and a byte", []part{rep("a", big+1)})
addHeader("a first line of 2 MiB, no newline", []part{rep("a", big)})
addHeader("a first line of 2 MiB with its newline", []part{rep("a", big-1), txt("\n")})
addHeader("a body past 2 MiB", append([]part{txt(intro), txt("-> pad x\n"), rep(full, 33000)}, tail...))
// intro (22) + "-> pad " (7) + token + "\n" + k full lines + "\n" ends
// at 31 + t + 65k.
padStanza := func(end int) []part {
k := (end - 32) / 65
t := end - 31 - 65*k
if t < 1 {
k--
t += 65
}
return []part{txt(intro), txt("-> pad "), rep("t", t), txt("\n"), rep(full, k), txt("\n")}
}
footerLine := "--- " + mac + "\n"
addHeader("the footer peeked at 2 MiB - 2", append(padStanza(big-2), txt(footerLine), hx(payload)))
addHeader("the footer peeked at 2 MiB - 3", append(padStanza(big-3), txt(footerLine), hx(payload)))
addHeader("a header that ends at 2 MiB", append(padStanza(big-len(footerLine)), txt(footerLine), hx(payload)))
addHeader("a header that ends at 2 MiB + 1", append(padStanza(big-len(footerLine)+1), txt(footerLine), hx(payload)))
addHeader("a header that ends at 2 MiB, nothing after", append(padStanza(big-len(footerLine)), txt(footerLine)))
doc["headers"] = headers
// The stanza rules of agewrap, on stanzas given directly.
qn := profile.Quicknet()
st := func(typ string, args ...string) *age.Stanza {
if args == nil {
args = []string{}
}
return &age.Stanza{Type: typ, Args: args, Body: []byte{}}
}
chain := qn.ChainHashHex()
type rule struct {
name string
stanzas []*age.Stanza
}
stanzaJSON := func(ss []*age.Stanza) []obj {
var out []obj
for _, s := range ss {
out = append(out, obj{"type": s.Type, "args": s.Args})
}
if out == nil {
out = []obj{}
}
return out
}
var timeRules []obj
for _, r := range []rule{
{"one tlock stanza", []*age.Stanza{st("tlock", "1000", chain)}},
{"no stanza", nil},
{"two stanzas", []*age.Stanza{st("tlock", "1000", chain), st("tlock", "1000", chain)}},
{"an X25519 stanza", []*age.Stanza{st("X25519", "x")}},
{"tlock with one argument", []*age.Stanza{st("tlock", "1000")}},
{"tlock with three arguments", []*age.Stanza{st("tlock", "1000", chain, "x")}},
{"round 0999", []*age.Stanza{st("tlock", "0999", chain)}},
{"round 1001", []*age.Stanza{st("tlock", "1001", chain)}},
{"round with a sign", []*age.Stanza{st("tlock", "+1000", chain)}},
{"chain hash in uppercase", []*age.Stanza{st("tlock", "1000", strings.ToUpper(chain))}},
{"another chain hash", []*age.Stanza{st("tlock", "1000", strings.Repeat("ab", 32))}},
{"type TLOCK", []*age.Stanza{st("TLOCK", "1000", chain)}},
{"type with a quote", []*age.Stanza{st("t\"x", "1000", chain)}},
} {
timeRules = append(timeRules, obj{"name": r.name, "stanzas": stanzaJSON(r.stanzas), "round": 1000, "error": errText(agewrap.CheckTimeStanzas(r.stanzas, qn, 1000))})
}
var payloadRules, accessRules []obj
for _, r := range []rule{
{"one X25519 stanza", []*age.Stanza{st("X25519", "a")}},
{"no stanza", nil},
{"two X25519 stanzas", []*age.Stanza{st("X25519", "a"), st("X25519", "b")}},
{"a scrypt stanza", []*age.Stanza{st("scrypt", "a", "1")}},
{"type x25519", []*age.Stanza{st("x25519", "a")}},
} {
payloadRules = append(payloadRules, obj{"name": r.name, "stanzas": stanzaJSON(r.stanzas), "error": errText(agewrap.CheckPayloadStanzas(r.stanzas))})
}
sixteen := func(mod func([]*age.Stanza) []*age.Stanza) []*age.Stanza {
var ss []*age.Stanza
for i := range 16 {
ss = append(ss, st("X25519", fmt.Sprintf("share%d", i)))
}
if mod != nil {
ss = mod(ss)
}
return ss
}
for _, slots := range []int{0, 16} {
for _, r := range []rule{
{"one X25519 stanza", []*age.Stanza{st("X25519", "a")}},
{"no stanza", nil},
{"sixteen X25519 stanzas", sixteen(nil)},
{"fifteen", sixteen(func(s []*age.Stanza) []*age.Stanza { return s[:15] })},
{"seventeen", sixteen(func(s []*age.Stanza) []*age.Stanza { return append(s, st("X25519", "x")) })},
{"stanza 3 of type tlock", sixteen(func(s []*age.Stanza) []*age.Stanza { s[3] = st("tlock", "1", "2"); return s })},
{"stanza 9 repeats the share of stanza 2", sixteen(func(s []*age.Stanza) []*age.Stanza { s[9] = st("X25519", "share2"); return s })},
{"stanzas of two arguments with the same first one", sixteen(func(s []*age.Stanza) []*age.Stanza {
s[1] = st("X25519", "same", "x")
s[2] = st("X25519", "same", "y")
return s
})},
{"stanzas without arguments", sixteen(func(s []*age.Stanza) []*age.Stanza {
s[1] = st("X25519")
s[2] = st("X25519")
return s
})},
} {
accessRules = append(accessRules, obj{"name": r.name, "slots": slots, "stanzas": stanzaJSON(r.stanzas), "error": errText(agewrap.CheckAccessStanzas(r.stanzas, slots))})
}
}
doc["time_stanzas"] = obj{"profile_id": qn.ID, "chain_hash": chain, "cases": timeRules}
doc["payload_stanzas"] = payloadRules
doc["access_stanzas"] = accessRules
// The identities of agewrap on whole files.
var wrapCases []obj
addWrap := func(name string, file []byte, kind string, slots int, raws [][]byte, want error) {
var id age.Identity
var err error
switch kind {
case "payload":
id, err = agewrap.NewPayloadIdentity(raws[0])
case "access":
var ids []age.Identity
for _, r := range raws {
x, e := agewrap.X25519IdentityFromRaw(r)
check(e)
ids = append(ids, x)
}
id, err = agewrap.NewAccessIdentity(slots, ids...)
}
var rawHex []string
for _, r := range raws {
rawHex = append(rawHex, h(r))
}
if rawHex == nil {
rawHex = []string{}
}
c := obj{"name": name, "kind": kind, "slots": slots, "raw_identities": rawHex, "file": h(file)}
if err != nil {
c["result"] = obj{"error": err.Error(), "phase": "identity"}
} else {
c["result"] = result(file, id)
}
wrapCases = append(wrapCases, c)
}
newRaw := func() (*age.X25519Identity, []byte) {
id, err := age.GenerateX25519Identity()
check(err)
raw, err := agewrap.RawX25519Identity(id)
check(err)
return id, raw
}
pid, praw := newRaw()
_, wrongRaw := newRaw()
addWrap("PAYLOAD_AGE for R_PAYLOAD", encrypt(pattern(7), pid.Recipient()), "payload", 0, [][]byte{praw}, nil)
addWrap("PAYLOAD_AGE for another recipient", encrypt(pattern(7), pid.Recipient()), "payload", 0, [][]byte{wrongRaw}, nil)
addWrap("PAYLOAD_AGE with two stanzas", encrypt(pattern(7), pid.Recipient(), other.Recipient()), "payload", 0, [][]byte{praw}, nil)
sr, err := age.NewScryptRecipient(pass)
check(err)
sr.SetWorkFactor(1)
addWrap("PAYLOAD_AGE with a scrypt stanza", encrypt(pattern(7), sr), "payload", 0, [][]byte{praw}, nil)
ppub, err := agewrap.RawX25519Recipient(pid.Recipient())
check(err)
for _, f := range []struct {
name string
mod func(s *age.Stanza)
}{
{"a share of low order", func(s *age.Stanza) { s.Args[0] = b64.EncodeToString(lowOrder) }},
{"a share of 31 bytes", func(s *age.Stanza) { s.Args[0] = b64.EncodeToString(pattern(31)) }},
{"two arguments", func(s *age.Stanza) { s.Args = append(s.Args, "x") }},
{"a body of 33 bytes", func(s *age.Stanza) { s.Body = append(s.Body, 1) }},
} {
file := encrypt(pattern(7), fnRecipient(func(fk []byte) []*age.Stanza {
s := x25519Stanza(fk, ppub, pattern(32))
f.mod(s)
return []*age.Stanza{s}
}))
addWrap("PAYLOAD_AGE with "+f.name, file, "payload", 0, [][]byte{praw}, nil)
}
addWrap("a payload identity of 31 bytes", encrypt(pattern(7), pid.Recipient()), "payload", 0, [][]byte{praw[:31]}, nil)
// INNER_ACCESS_AGE: sixteen slots, three credentials.
var creds []*age.X25519Identity
var credRaws [][]byte
var recips []age.Recipient
for range 3 {
id, raw := newRaw()
creds = append(creds, id)
credRaws = append(credRaws, raw)
recips = append(recips, id.Recipient())
}
for range 13 {
d, _ := newRaw()
recips = append(recips, d.Recipient())
}
inner := encrypt(pattern(9), recips...)
addWrap("sixteen slots, the first credential", inner, "access", 16, [][]byte{credRaws[0]}, nil)
addWrap("sixteen slots, three credentials", inner, "access", 16, credRaws, nil)
addWrap("sixteen slots, a stranger and the third", inner, "access", 16, [][]byte{wrongRaw, credRaws[2]}, nil)
addWrap("sixteen slots, a stranger", inner, "access", 16, [][]byte{wrongRaw}, nil)
addWrap("sixteen slots read as format 1", inner, "access", 0, [][]byte{credRaws[1]}, nil)
addWrap("no identity", inner, "access", 16, nil, nil)
addWrap("fifteen slots", encrypt(pattern(9), recips[:15]...), "access", 16, [][]byte{credRaws[0]}, nil)
addWrap("one recipient, format 1", encrypt(pattern(9), creds[0].Recipient()), "access", 0, [][]byte{credRaws[0]}, nil)
dup := append(slices.Clone(recips[:15]), creds[1].Recipient())
addWrap("a credential in two slots", encrypt(pattern(9), dup...), "access", 16, [][]byte{credRaws[0], credRaws[1]}, nil)
addWrap("a credential in two slots, read by another", encrypt(pattern(9), dup...), "access", 16, [][]byte{credRaws[0]}, nil)
repeated := encrypt(pattern(9), fnRecipient(func(fk []byte) []*age.Stanza {
var ss []*age.Stanza
for i := range 15 {
ss = append(ss, x25519Stanza(fk, ppub, eph(i)))
}
return append(ss, x25519Stanza(fk, ppub, eph(0)))
}))
addWrap("a repeated ephemeral share", repeated, "access", 16, [][]byte{praw}, nil)
malformed := encrypt(pattern(9), fnRecipient(func(fk []byte) []*age.Stanza {
var ss []*age.Stanza
for i := range 16 {
ss = append(ss, x25519Stanza(fk, ppub, eph(100+i)))
}
ss[7].Args[0] = b64.EncodeToString(lowOrder)
return ss
}))
addWrap("a stanza with a share of low order", malformed, "access", 16, [][]byte{wrongRaw}, nil)
addWrap("INNER_ACCESS_AGE with a scrypt stanza", encrypt(pattern(9), sr), "access", 0, [][]byte{credRaws[0]}, nil)
doc["agewrap"] = wrapCases
// age.ParseX25519Identity.
var parseCases []obj
addParse := func(s string) {
id, err := age.ParseX25519Identity(s)
c := obj{"input": s}
if err != nil {
c["error"] = err.Error()
} else {
raw, err := agewrap.RawX25519Identity(id)
check(err)
c["raw"] = h(raw)
c["recipient"] = id.Recipient().String()
c["string"] = id.String()
}
parseCases = append(parseCases, c)
}
addParse(primary.String())
addParse(strings.ToLower(primary.String()))
addParse(primary.Recipient().String())
addParse(primary.String()[:len(primary.String())-1] + "Q")
addParse("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQSGNLJW")
addParse("AGE-SECRET-KEY-")
addParse("")
addParse("Age-secret-key-1qqq")
addParse(strings.Replace(primary.String(), "AGE-SECRET-KEY-1", "AGE-SECRET-KEY-2", 1))
doc["parse_identity"] = parseCases
write(filepath.Join(outDir, "age.json"), doc)
}
// fixturesJSON opens, in each official capsule of testdata/, its
// PAYLOAD_AGE with its payload_identity and, under time_and_key, its
// OUTER_TIME_AGE with the release of the fixture, to give the
// INNER_ACCESS_AGE that it seals.
func fixturesJSON(outDir string) {
matches, err := filepath.Glob(filepath.Join(testdata, "fixtures", "*.dkc"))
check(err)
qn := profile.Quicknet()
var out []obj
for _, path := range matches {
name := strings.TrimSuffix(filepath.Base(path), ".dkc")
dkc, err := os.ReadFile(path)
check(err)
var rec struct {
Format int
AccessPolicy string `json:"access_policy"`
PayloadIdentity string `json:"payload_identity"`
ControlCBOR string `json:"control_cbor"`
Identities []string
AccessKeyFile string `json:"access_key_file"`
Release struct {
Round uint64
Signature string
}
}
raw, err := os.ReadFile(strings.TrimSuffix(path, ".dkc") + ".json")
check(err)
check(json.Unmarshal(raw, &rec))
pre, err := capsule.ParsePrelude(dkc[:capsule.PreludeSize])
check(err)
off := int(pre.PayloadOffset())
payload := dkc[off:]
pid, err := agewrap.NewPayloadIdentity(mustHex(rec.PayloadIdentity))
check(err)
res := result(payload, pid)
if _, ok := res["error"]; ok {
log.Fatalf("%s: PAYLOAD_AGE: %v", name, res["error"])
}
f := obj{"name": name, "format": rec.Format, "access_policy": rec.AccessPolicy, "payload_offset": off, "payload_length": len(payload), "payload_sha256": sum(payload), "payload_identity": rec.PayloadIdentity, "payload_result": res}
// A stranger gets the error of spec §30.1.
wrong := strings.Repeat("0", 63) + "1"
wid, err := agewrap.NewPayloadIdentity(mustHex(wrong))
check(err)
f["payload_wrong_identity"] = obj{"raw": wrong, "result": result(payload, wid)}
sealedStart := capsule.PreludeSize + int(pre.PublicHeaderLen)
sealed := dkc[sealedStart:off]
if rec.AccessPolicy == "time_and_key" {
tid, err := agewrap.NewTimeIdentity(qn, rec.Release.Round, provider.Release{Round: rec.Release.Round, Signature: mustHex(rec.Release.Signature)})
check(err)
r, err := age.Decrypt(bytes.NewReader(sealed), tid)
check(err)
innerAge, err := io.ReadAll(r)
check(err)
slots := 0
if rec.Format >= 2 {
slots = agewrap.AccessSlots
}
var ids []obj
var all []age.Identity
addID := func(source string, id age.Identity) {
x := id.(*age.X25519Identity)
raw, err := agewrap.RawX25519Identity(x)
check(err)
aid, err := agewrap.NewAccessIdentity(slots, id)
check(err)
ids = append(ids, obj{"source": source, "raw": h(raw), "result": result(innerAge, aid)})
all = append(all, id)
}
if rec.AccessKeyFile != "" {
kf, err := os.Open(filepath.Join(testdata, "fixtures", rec.AccessKeyFile))
check(err)
k, err := accesskey.Decode(kf)
check(err)
kf.Close()
id, err := k.Identity()
check(err)
addID(rec.AccessKeyFile, id)
}
for i, s := range rec.Identities {
id, err := age.ParseX25519Identity(s)
check(err)
addID(fmt.Sprintf("identities[%d]", i), id)
}
aid, err := agewrap.NewAccessIdentity(slots, all...)
check(err)
allRes := result(innerAge, aid)
if allRes["plaintext_sha256"] != sum(mustHex(rec.ControlCBOR)) {
log.Fatalf("%s: INNER_ACCESS_AGE does not seal control_cbor", name)
}
stranger, err := age.GenerateX25519Identity()
check(err)
sid, err := agewrap.NewAccessIdentity(slots, stranger)
check(err)
f["inner_access_age"] = obj{"hex": h(innerAge), "slots": slots, "identities": ids, "all_identities_result": allRes, "stranger_result": result(innerAge, sid), "control_cbor_sha256": sum(mustHex(rec.ControlCBOR))}
}
out = append(out, f)
}
if len(out) == 0 {
log.Fatal("no fixtures: run it from datekeys-go")
}
write(filepath.Join(outDir, "age_fixtures.json"), obj{
"description": "The PAYLOAD_AGE of every official capsule of testdata/fixtures of datekeys-dart, opened by Go with its payload_identity (agewrap.PayloadIdentity) and with a stranger, and the INNER_ACCESS_AGE of the time_and_key ones, which this program took from OUTER_TIME_AGE with the release of the fixture, opened with each credential of the fixture and with a stranger (agewrap.AccessIdentity). Results as in age.json. See tool/gen_age_vectors.go.",
"generator": "tool/gen_age_vectors.go, " + runtime.Version(),
"fixtures": out,
})
}
func write(path string, doc obj) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
check(enc.Encode(doc))
check(os.WriteFile(path, buf.Bytes(), 0o644))
fmt.Printf("wrote %s, %d bytes\n", path, buf.Len())
}

Powered by TurnKey Linux.