diff --git a/.gitattributes b/.gitattributes index ad48a77..a369d6c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.age binary +*.test binary diff --git a/age_test.go b/age_test.go index afb7ab3..3ae95bf 100644 --- a/age_test.go +++ b/age_test.go @@ -75,7 +75,7 @@ func ExampleDecrypt() { } func ExampleParseIdentities() { - keyFile, err := os.Open("testdata/keys.txt") + keyFile, err := os.Open("testdata/example_keys.txt") if err != nil { log.Fatalf("Failed to open private keys file: %v", err) } diff --git a/internal/testkit/testkit.go b/internal/testkit/testkit.go new file mode 100644 index 0000000..ab18141 --- /dev/null +++ b/internal/testkit/testkit.go @@ -0,0 +1,178 @@ +// Copyright 2022 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package testkit + +import ( + "bytes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "os" + "strings" + + "filippo.io/age/internal/bech32" + "golang.org/x/crypto/chacha20" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" +) + +var TestFileKey = []byte("YELLOW SUBMARINE") + +var _, TestX25519Identity, _ = bech32.Decode( + "AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0") + +var TestX25519Recipient, _ = curve25519.X25519(TestX25519Identity, curve25519.Basepoint) + +type TestFile struct { + Buf bytes.Buffer + rand io.Reader + + streamKey []byte + nonce [12]byte + payload bytes.Buffer + expect string + comment string + identities []string +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + for n := range p { + p[n] = 0 + } + return len(p), nil +} + +func NewTestFile() *TestFile { + c, _ := chacha20.NewUnauthenticatedCipher( + []byte("TEST RANDOMNESS TEST RANDOMNESS!"), make([]byte, chacha20.NonceSize)) + return &TestFile{rand: cipher.StreamReader{c, zeroReader{}}, expect: "success"} +} + +func (f *TestFile) TextLine(s string) { + f.Buf.WriteString(s) + f.Buf.WriteString("\n") +} + +func (f *TestFile) VersionLine(v string) { + f.TextLine("age-encryption.org/" + v) +} + +func (f *TestFile) ArgsLine(args ...string) { + f.TextLine(strings.Join(append([]string{"->"}, args...), " ")) +} + +var b64 = base64.RawStdEncoding.EncodeToString + +func (f *TestFile) Body(body []byte) { + for { + line := body + if len(line) > 48 { + line = line[:48] + } + f.TextLine(b64(line)) + body = body[len(line):] + if len(line) < 48 { + break + } + } +} + +func (f *TestFile) Stanza(args []string, body []byte) { + f.ArgsLine(args...) + f.Body(body) +} + +func (f *TestFile) AEADBody(key, body []byte) { + aead, _ := chacha20poly1305.New(key) + f.Body(aead.Seal(nil, make([]byte, chacha20poly1305.NonceSize), body, nil)) +} + +func (f *TestFile) X25519(identity []byte) { + id, _ := bech32.Encode("AGE-SECRET-KEY-", identity) + f.identities = append(f.identities, id) + recipient, _ := curve25519.X25519(identity, curve25519.Basepoint) + ephemeral := make([]byte, 32) + f.rand.Read(ephemeral) + share, _ := curve25519.X25519(ephemeral, curve25519.Basepoint) + f.ArgsLine("X25519", b64(share)) + secret, _ := curve25519.X25519(ephemeral, recipient) + key := make([]byte, 32) + hkdf.New(sha256.New, secret, append(share, recipient...), + []byte("age-encryption.org/v1/X25519")).Read(key) + f.AEADBody(key, TestFileKey) +} + +func (f *TestFile) HMACLine(h []byte) { + f.TextLine("--- " + b64(h)) +} + +func (f *TestFile) HMAC() { + key := make([]byte, 32) + hkdf.New(sha256.New, TestFileKey, nil, []byte("header")).Read(key) + h := hmac.New(sha256.New, key) + h.Write(f.Buf.Bytes()) + h.Write([]byte("---")) + f.HMACLine(h.Sum(nil)) +} + +func (f *TestFile) Nonce() { + nonce := make([]byte, 16) + f.rand.Read(nonce) + f.streamKey = make([]byte, 32) + hkdf.New(sha256.New, TestFileKey, nonce, []byte("payload")).Read(f.streamKey) + f.Buf.Write(nonce) +} + +func (f *TestFile) PayloadChunk(plaintext []byte) { + f.payload.Write(plaintext) + aead, _ := chacha20poly1305.New(f.streamKey) + f.Buf.Write(aead.Seal(nil, f.nonce[:], plaintext, nil)) + f.nonce[10]++ +} + +func (f *TestFile) PayloadChunkFinal(plaintext []byte) { + f.payload.Write(plaintext) + f.nonce[11] = 1 + aead, _ := chacha20poly1305.New(f.streamKey) + f.Buf.Write(aead.Seal(nil, f.nonce[:], plaintext, nil)) +} + +func (f *TestFile) Payload(plaintext string) { + f.Nonce() + f.PayloadChunkFinal([]byte(plaintext)) +} + +func (f *TestFile) ExpectHeaderFailure() { + f.expect = "header failure" +} + +func (f *TestFile) ExpectPayloadFailure() { + f.expect = "payload failure" +} + +func (f *TestFile) Comment(c string) { + f.comment = c +} + +func (f *TestFile) Generate() { + fmt.Printf("expect: %s\n", f.expect) + if f.expect == "success" { + fmt.Printf("payload: %x\n", sha256.Sum256(f.payload.Bytes())) + } + for _, id := range f.identities { + fmt.Printf("identity: %s\n", id) + } + if f.comment != "" { + fmt.Printf("comment: %s\n", f.comment) + } + fmt.Println() + io.Copy(os.Stdout, &f.Buf) +} diff --git a/testdata/keys.txt b/testdata/example_keys.txt similarity index 100% rename from testdata/keys.txt rename to testdata/example_keys.txt diff --git a/testdata/x25519.go b/testdata/x25519.go new file mode 100644 index 0000000..dd00564 --- /dev/null +++ b/testdata/x25519.go @@ -0,0 +1,18 @@ +// Copyright 2022 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build ignore + +package main + +import "filippo.io/age/internal/testkit" + +func main() { + f := testkit.NewTestFile() + f.VersionLine("v1") + f.X25519(testkit.TestX25519Recipient) + f.HMAC() + f.Payload("age") + f.Generate() +} diff --git a/testdata/x25519.test b/testdata/x25519.test new file mode 100644 index 0000000..d0444b4 --- /dev/null +++ b/testdata/x25519.test @@ -0,0 +1,9 @@ +expect: success +payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab +identity: AGE-SECRET-KEY-1XMWWC06LY3EE5RYTXM9MFLAZ2U56JJJ36S0MYPDRWSVLUL66MV4QX3S7F6 + +age-encryption.org/v1 +-> X25519 TEiF0ypqr+bpvcqXNyCVJpL7OuwPdVwPL7KQEbFDOCc +EmECAEcKN+n/Vs9SbWiV+Hu0r+E8R77DdWYyd83nw7U +--- Vn+54jqiiUCE+WZcEVY3f1sqHjlu/z1LCQ/T7Xm7qI0 +îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf \ No newline at end of file diff --git a/testkit_test.go b/testkit_test.go new file mode 100644 index 0000000..01fdda9 --- /dev/null +++ b/testkit_test.go @@ -0,0 +1,135 @@ +// Copyright 2022 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package age_test + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "filippo.io/age" +) + +//go:generate go test -generate -run ^$ + +func TestMain(m *testing.M) { + genFlag := flag.Bool("generate", false, "regenerate test files") + flag.Parse() + if *genFlag { + generators, err := filepath.Glob("testdata/*.go") + if err != nil { + log.Fatal(err) + } + for _, generator := range generators { + vector := strings.TrimSuffix(generator, ".go") + ".test" + fmt.Fprintf(os.Stderr, "%s -> %s\n", generator, vector) + out, err := exec.Command("go", "run", generator).Output() + if err != nil { + log.Fatal(err) + } + os.WriteFile(vector, out, 0664) + } + } + + os.Exit(m.Run()) +} + +func TestVectors(t *testing.T) { + tests, err := filepath.Glob("testdata/*.test") + if err != nil { + log.Fatal(err) + } + for _, test := range tests { + contents, err := os.ReadFile(test) + if err != nil { + t.Fatal(err) + } + name := strings.TrimPrefix(test, "testdata/") + name = strings.TrimSuffix(name, ".test") + t.Run(name, func(t *testing.T) { + testVector(t, contents) + }) + } +} + +func testVector(t *testing.T, test []byte) { + var ( + expectHeaderFailure bool + expectPayloadFailure bool + payloadHash *[32]byte + identities []age.Identity + ) + + for { + line, rest, ok := bytes.Cut(test, []byte("\n")) + if !ok { + t.Fatal("invalid test file: no payload") + } + test = rest + if len(line) == 0 { + break + } + key, value, _ := strings.Cut(string(line), ": ") + switch key { + case "expect": + switch value { + case "success": + case "header failure": + expectHeaderFailure = true + case "payload failure": + expectPayloadFailure = true + default: + t.Fatal("invalid test file: unknown expect value:", value) + } + case "payload": + h, err := hex.DecodeString(value) + if err != nil { + t.Fatal(err) + } + payloadHash = (*[32]byte)(h) + case "identity": + i, err := age.ParseX25519Identity(value) + if err != nil { + t.Fatal(err) + } + identities = append(identities, i) + case "comment": + t.Log(value) + default: + t.Fatal("invalid test file: unknown header key:", key) + } + } + + r, err := age.Decrypt(bytes.NewReader(test), identities...) + if err != nil { + if expectHeaderFailure { + return + } + t.Fatal("unexpected header error:", err) + } else if expectHeaderFailure { + t.Fatal("expected header error") + } + out, err := io.ReadAll(r) + if err != nil { + if expectPayloadFailure { + return + } + t.Fatal("unexpected payload error:", err) + } else if expectPayloadFailure { + t.Fatal("expected payload error") + } + if sha256.Sum256(out) != *payloadHash { + t.Error("payload hash mismatch") + } +}