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/cmd/datekeys/main_test.go

503 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
)
const fixtures = "../../testdata/fixtures"
// relay serves the known Quicknet releases like a drand HTTP relay.
func relay(t *testing.T) string {
t.Helper()
p := profile.Quicknet()
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, round := range testkit.Rounds {
if r.URL.Path == fmt.Sprintf("/v2/chains/%s/rounds/%d", p.ChainHashHex(), round) {
fmt.Fprintf(w, `{"round":%d,"signature":"%s"}`, round, hex.EncodeToString(testkit.Release(round).Signature))
return
}
}
http.NotFound(w, r)
}))
t.Cleanup(s.Close)
return s.URL
}
func cli(t *testing.T, now time.Time, args ...string) (string, string, error) {
t.Helper()
var out, errOut bytes.Buffer
err := run(args, &out, &errOut, func() time.Time { return now })
return out.String(), errOut.String(), err
}
var later = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
func TestOutputNotPublishedOnFailureOrOverwrite(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "output")
err := writeAtomic(out, func(w io.Writer) error {
_, _ = w.Write([]byte("partial plaintext"))
return errors.New("invalid authentication tag")
})
if err == nil {
t.Fatal("expected failure")
}
if _, err := os.Stat(out); !errors.Is(err, os.ErrNotExist) {
t.Fatal("published partial output")
}
if err := os.WriteFile(out, []byte("keep me"), 0o600); err != nil {
t.Fatal(err)
}
if err := writeAtomic(out, func(io.Writer) error { t.Fatal("should not run"); return nil }); err == nil {
t.Fatal("overwrote output")
}
if b, _ := os.ReadFile(out); string(b) != "keep me" {
t.Fatal("output changed")
}
if files, _ := filepath.Glob(filepath.Join(dir, ".datekeys-*")); len(files) != 0 {
t.Fatal("temporary file left behind")
}
if err := copyExclusive(out, out); err == nil {
t.Fatal("exclusive copy replaced a file")
}
}
func TestDecryptFixtures(t *testing.T) {
url := relay(t)
for _, tc := range []struct{ name, dkk string }{
{"time_only", ""},
{"time_only_extensions", ""},
{"empty_payload", ""},
{"time_and_key_portable", "time_and_key_portable.dkk"},
{"format2_time_only", ""},
{"format2_time_only_bloque256", ""},
{"format2_empty_payload", ""},
{"format2_time_only_extensions", ""},
{"format2_time_and_key_portable", "format2_time_and_key_portable.dkk"},
{"format2_time_and_key_recipients", "format2_time_and_key_recipients.dkk"},
} {
t.Run(tc.name, func(t *testing.T) {
out := filepath.Join(t.TempDir(), "plain")
args := []string{"decrypt", "-in", filepath.Join(fixtures, tc.name+".dkc"), "-out", out, "-relay", url}
if tc.dkk != "" {
args = append(args, "-dkk", filepath.Join(fixtures, tc.dkk))
}
_, stderr, err := cli(t, later, args...)
if err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
// Spec §70: the format is reported, with what format 1 reveals.
format1 := !strings.HasPrefix(tc.name, "format2_")
if strings.Contains(stderr, "format 1 does not hide") != format1 {
t.Fatalf("report:\n%s", stderr)
}
got, _ := os.ReadFile(out)
want, _ := os.ReadFile(filepath.Join(fixtures, tc.name+".plaintext"))
if !bytes.Equal(got, want) {
t.Fatal("plaintext differs")
}
})
}
}
func TestDecryptWithIdentityFile(t *testing.T) {
var f testkit.DKCFixture
if err := testkit.ReadJSON(filepath.Join(fixtures, "time_and_key_recipients.json"), &f); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
key := filepath.Join(dir, "key.txt")
os.WriteFile(key, []byte("# test identity\n"+f.Identities[1]+"\n"), 0o600)
out := filepath.Join(dir, "plain")
if _, stderr, err := cli(t, later, "decrypt", "-in", filepath.Join(fixtures, f.File), "-out", out, "-identity", key, "-relay", relay(t)); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
}
func TestDecryptFailuresLeaveNothing(t *testing.T) {
dir := t.TempDir()
b, _ := os.ReadFile(filepath.Join(fixtures, "time_only.dkc"))
bad := filepath.Join(dir, "bad.dkc")
b[len(b)-1] ^= 1
os.WriteFile(bad, b, 0o600)
out := filepath.Join(dir, "plain")
_, _, err := cli(t, later, "decrypt", "-in", bad, "-out", out, "-relay", relay(t))
if !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("got %v", err)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("left files behind: %v", entries)
}
// time_and_key without credentials fails before contacting any relay.
_, _, err = cli(t, later, "decrypt", "-in", filepath.Join(fixtures, "time_and_key_portable.dkc"), "-out", out, "-relay", "http://127.0.0.1:1")
if !errors.Is(err, datekeys.ErrAccessRequired) {
t.Fatalf("got %v", err)
}
}
// Spec §63 step 9.a, §69.1: the CLI hands the .dkk to capsule.Open still
// encoded, so its errors come at step 9 of a time_and_key capsule, after any
// failure of steps 1 to 8, and a time_only capsule ignores it.
func TestDecryptAccessKeyOrder(t *testing.T) {
dir := t.TempDir()
notDKK := filepath.Join(dir, "not.dkk")
os.WriteFile(notDKK, []byte("not a .dkk"), 0o600)
b, _ := os.ReadFile(filepath.Join(fixtures, "time_and_key_portable.dkc"))
b[5] = 1
flags := filepath.Join(dir, "flags.dkc")
os.WriteFile(flags, b, 0o600)
for _, tc := range []struct {
name, in string
want error
}{
{"time_and_key and bytes that are not a .dkk", filepath.Join(fixtures, "time_and_key_portable.dkc"), datekeys.ErrInvalidMagic},
{"FLAGS 1 and bytes that are not a .dkk", flags, datekeys.ErrInvalidFlags},
} {
_, _, err := cli(t, later, "decrypt", "-in", tc.in, "-out", filepath.Join(dir, "plain"), "-dkk", notDKK, "-relay", "http://127.0.0.1:1")
if !errors.Is(err, tc.want) {
t.Errorf("%s: got %v, want %s", tc.name, err, datekeys.Code(tc.want))
}
}
out := filepath.Join(dir, "plain")
if _, stderr, err := cli(t, later, "decrypt", "-in", filepath.Join(fixtures, "time_only.dkc"), "-out", out, "-dkk", notDKK, "-relay", relay(t)); err != nil {
t.Fatalf("time_only and bytes that are not a .dkk: %v\n%s", err, stderr)
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "secret.txt")
os.WriteFile(in, []byte("round trip through the CLI"), 0o600)
fotos := filepath.Join(dir, "fotos")
os.MkdirAll(filepath.Join(fotos, "sub"), 0o700)
os.MkdirAll(filepath.Join(fotos, "__MACOSX"), 0o700)
os.WriteFile(filepath.Join(fotos, "a.jpg"), []byte("jpeg"), 0o600)
os.WriteFile(filepath.Join(fotos, "sub", "b.txt"), []byte("b"), 0o600)
os.WriteFile(filepath.Join(fotos, ".DS_Store"), []byte("x"), 0o600)
os.WriteFile(filepath.Join(fotos, "__MACOSX", "._a.jpg"), []byte("x"), 0o600)
old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
os.Chtimes(in, old, old)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
genesis := time.Unix(p.GenesisTime, 0)
x, _ := age.GenerateX25519Identity()
key := filepath.Join(dir, "x.txt")
os.WriteFile(key, []byte(x.String()+"\n"), 0o600)
dkc, dkk := filepath.Join(dir, "s.dkc"), filepath.Join(dir, "s.dkk")
_, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-in", fotos, "-out", dkc,
"-comment", "Hola\tmundo", "-author", "Ana", "-policy", "time_and_key", "-recipient", x.Recipient().String(), "-dkk", dkk)
if err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if !strings.Contains(stderr, "round 1000") || !strings.Contains(stderr, "format 3: 3 files, ") ||
!strings.Contains(stderr, ".DS_Store, which the system creates on its own") || !strings.Contains(stderr, "__MACOSX, which the system") ||
strings.Contains(stderr, "not post-quantum") {
t.Fatalf("unexpected report:\n%s", stderr)
}
if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc); err == nil {
t.Fatal("overwrote an existing capsule")
}
// Spec §29.1: the padding rule is one of the two, never none.
small := filepath.Join(dir, "small.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", small, "-padding", "bloque256", "-no-mtime"); err != nil ||
!strings.Contains(stderr, "(bloque256)") {
t.Fatalf("-padding bloque256: %v\n%s", err, stderr)
}
if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", filepath.Join(dir, "none.dkc"), "-padding", "none"); err == nil {
t.Fatal("encrypted without a padding rule")
}
stdout, _, err := cli(t, later, "inspect", "-in", dkc, "-json")
if err != nil {
t.Fatal(err)
}
var v inspectview.View
if err := json.Unmarshal([]byte(stdout), &v); err != nil || !v.Valid || v.Round != 1000 || v.AccessPolicy != "time_and_key" || v.Format != 3 {
t.Fatalf("inspect: %+v %v", v, err)
}
for i, extra := range [][]string{{"-dkk", dkk}, {"-identity", key}} {
out := filepath.Join(dir, fmt.Sprintf("out%d", i))
args := append([]string{"decrypt", "-in", dkc, "-out", out, "-relay", relay(t)}, extra...)
stdout, stderr, err := cli(t, later, args...)
if err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
for name, want := range map[string]string{"secret.txt": "round trip through the CLI", "fotos/a.jpg": "jpeg", "fotos/sub/b.txt": "b"} {
if b, _ := os.ReadFile(filepath.Join(out, filepath.FromSlash(name))); string(b) != want {
t.Errorf("%s: %q", name, b)
}
}
if entries, _ := os.ReadDir(out); len(entries) != 2 {
t.Errorf("%d entries in the folder, want fotos and secret.txt", len(entries))
}
if info, err := os.Stat(filepath.Join(out, "secret.txt")); err != nil || !info.ModTime().Equal(old) {
t.Errorf("mtime of secret.txt: %v", err)
}
// Spec §29.7: the verdicts, the declared author and the comment, as
// text of the creator, the paths, and the verdicts again.
want := "Sin firma de autor.\n" + authorLabel + "\n│ Ana\n┌ " + commentTitle + "\n│ Hola mundo\n└\n" +
"Ficheros escritos en " + out + " (3):\n│ fotos/a.jpg\n│ fotos/sub/b.txt\n│ secret.txt\nSin firma de autor.\n"
if stdout != want {
t.Errorf("presentation:\n%s\nwant:\n%s", stdout, want)
}
}
// The folder exists: nothing is requested and nothing changes.
out := filepath.Join(dir, "out0")
if _, _, err := cli(t, later, "decrypt", "-in", dkc, "-out", out, "-dkk", dkk, "-relay", "http://127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("decrypted into an existing folder: %v", err)
}
// -no-mtime: the file gets the time of its extraction.
out = filepath.Join(dir, "small")
if _, stderr, err := cli(t, later, "decrypt", "-in", small, "-out", out, "-relay", relay(t)); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if info, err := os.Stat(filepath.Join(out, "secret.txt")); err != nil || info.ModTime().Equal(old) {
t.Errorf("-no-mtime kept the mtime: %v", err)
}
}
// Spec §56: a format 3 capsule that fails leaves no folder, and one without
// files creates none.
func TestDecryptFormat3LeavesNothing(t *testing.T) {
dir := t.TempDir()
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC()
genesis := time.Unix(p.GenesisTime, 0)
in := filepath.Join(dir, "a.txt")
os.WriteFile(in, []byte("a"), 0o600)
dkc, note := filepath.Join(dir, "a.dkc"), filepath.Join(dir, "note.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-comment", "Solo un comentario", "-out", note); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
b, _ := os.ReadFile(dkc)
b[len(b)-1] ^= 1
bad := filepath.Join(dir, "bad.dkc")
os.WriteFile(bad, b, 0o600)
out := filepath.Join(dir, "out")
if _, _, err := cli(t, later, "decrypt", "-in", bad, "-out", out, "-relay", relay(t)); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("got %v", err)
}
if _, err := os.Lstat(out); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the folder of a failed capsule remains: %v", err)
}
stdout, stderr, err := cli(t, later, "decrypt", "-in", note, "-out", out, "-relay", relay(t))
if err != nil || !strings.Contains(stdout, "│ Solo un comentario") || !strings.Contains(stderr, "no files") {
t.Fatalf("%v\n%s\n%s", err, stdout, stderr)
}
if _, err := os.Lstat(out); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("a capsule without files created its folder: %v", err)
}
}
// Spec §62.1 rule 15: a path that breaks a rule is refused with the rule and
// the character, and folders are walked without following links.
func TestEncryptRefusesPaths(t *testing.T) {
dir := t.TempDir()
p := profile.Quicknet()
at := time.Unix(p.GenesisTime+999*3, 0).UTC().Format(time.RFC3339)
genesis := time.Unix(p.GenesisTime, 0)
bidi := filepath.Join(dir, "a\u202eb.txt")
if err := os.WriteFile(bidi, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
_, _, err := cli(t, genesis, "encrypt", "-at", at, "-in", bidi, "-out", filepath.Join(dir, "1.dkc"))
if err == nil || !strings.Contains(err.Error(), "R4: segment 1: invisible U+202E") {
t.Fatalf("got %v", err)
}
linked := filepath.Join(dir, "linked")
os.Mkdir(linked, 0o700)
if err := os.Symlink(bidi, filepath.Join(linked, "link")); err != nil {
t.Skipf("no symbolic links here: %v", err)
}
if _, _, err := cli(t, genesis, "encrypt", "-at", at, "-in", linked, "-out", filepath.Join(dir, "2.dkc")); err == nil || !strings.Contains(err.Error(), "is not a regular file") {
t.Fatalf("got %v", err)
}
}
// Spec §29.7: every line of the creator goes in pieces of at most W - 3
// columns behind the prefix, counting 2 for any code point that is not
// printable ASCII; TABs of the comment go to the next multiple of 8; the
// verdicts come first and last; risky names get a warning.
func TestPresent(t *testing.T) {
o := &capsule.Opened{
Verdicts: capsule.Verdicts{Signature: capsule.VerdictUnreadable, Seal: capsule.VerdictUnreadable},
Head: &capsule.Head{
Author: strings.Repeat("ñ", 12),
Comment: "a\tb\n\n" + strings.Repeat("x", 40),
Files: []capsule.File{{Path: "Informe.LNK"}, {Path: ".GIT/config"}, {Path: "-rf"}, {Path: "setup.Exe"}, {Path: "fotos/Desktop.ini"}, {Path: "nota.txt"}},
},
}
var b bytes.Buffer
present(&b, o, "DIR", 20)
lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n")
x := capsule.VerdictUnreadable.Text()
if lines[0] != x || lines[len(lines)-1] != x {
t.Errorf("the verdict is not first and last:\n%s", b.String())
}
for _, l := range lines {
if rest, ok := strings.CutPrefix(l, prefix); ok {
w := 0
for _, r := range rest {
w += runeWidth(r)
}
if w > 20-prefixWidth || rest == "" && l != prefix {
t.Errorf("piece %q of %d columns", rest, w)
}
}
}
for _, want := range []string{
"│ ññññññññ\n│ ññññ\n", // 12 × 2 columns in pieces of 16
"│ a b\n│ \n│ xxxxxxxxxxxxxxxxx\n", // the TAB to column 8, an empty line
"│ Informe.LNK\n aviso: es un acceso directo de Windows",
"│ .GIT/config\n aviso: está dentro de una carpeta .git",
"│ -rf\n aviso: un nombre que empieza por '-'",
"│ setup.Exe\n aviso: es un programa o un script",
"│ fotos/Desktop.ini\n aviso: es la configuración de una carpeta de Windows",
"│ nota.txt\n" + x,
} {
if !strings.Contains(b.String(), want) {
t.Errorf("missing %q in:\n%s", want, b.String())
}
}
if pieces("", 17)[0] != "" || len(pieces("ab", 1)) != 2 {
t.Error("an empty line is one piece, and every piece holds a code point")
}
}
func TestInspectReportsFailures(t *testing.T) {
b, _ := os.ReadFile(filepath.Join(fixtures, "time_only.dkc"))
b = bytes.Replace(b, []byte("-> tlock 1000 "), []byte("-> tlock 1001 "), 1)
path := filepath.Join(t.TempDir(), "bad.dkc")
os.WriteFile(path, b, 0o600)
stdout, _, err := cli(t, later, "inspect", "-in", path)
if !errors.Is(err, datekeys.ErrRoundMismatch) || !strings.Contains(stdout, "[FAIL] step 8") {
t.Fatalf("%v\n%s", err, stdout)
}
stdout, _, err = cli(t, later, "inspect", "-in", filepath.Join(fixtures, "time_only.dkc"))
if err != nil || !strings.Contains(stdout, "valid before unlock") {
t.Fatalf("%v\n%s", err, stdout)
}
}
// The frozen inspect outputs (testdata/fixtures/<name>.inspect.json) are
// exactly what "datekeys inspect -json -in <name>.dkc" prints in the fixture
// directory.
func TestInspectJSONGoldens(t *testing.T) {
names, err := filepath.Glob(filepath.Join(fixtures, "*.inspect.json"))
if err != nil {
t.Fatal(err)
}
dkcs, err := filepath.Glob(filepath.Join(fixtures, "*.dkc"))
if err != nil {
t.Fatal(err)
}
if len(names) != len(dkcs) || len(dkcs) != 12 {
t.Fatalf("%d frozen inspect outputs, want one per official .dkc (%d, 12)", len(names), len(dkcs))
}
t.Chdir(fixtures)
for _, path := range names {
name := strings.TrimSuffix(filepath.Base(path), ".inspect.json")
t.Run(name, func(t *testing.T) {
want, err := os.ReadFile(name + ".inspect.json")
if err != nil {
t.Fatal(err)
}
stdout, _, err := cli(t, later, "inspect", "-json", "-in", name+".dkc")
if err != nil {
t.Fatal(err)
}
if stdout != string(want) {
t.Fatalf("output differs from %s.inspect.json:\n%s", name, stdout)
}
})
}
}
func TestResolveAndProfile(t *testing.T) {
stdout, _, err := cli(t, later, "datekey", "resolve", "-at", "2030-01-01T00:00:00Z")
if err != nil || !strings.Contains(stdout, `"round":66884212`) || !strings.Contains(stdout, `"unlock_at":"2030-01-01T00:00:00Z"`) {
t.Fatalf("%v %s", err, stdout)
}
if _, _, err := cli(t, later, "datekey", "resolve", "-at", "2030-01-01 00:00"); err == nil {
t.Fatal("accepted a time without zone")
}
stdout, _, err = cli(t, later, "profile", "hash")
if err != nil || !strings.Contains(stdout, profile.QuicknetProfileHash) || !strings.Contains(stdout, `"pinned":true`) {
t.Fatalf("%v %s", err, stdout)
}
b, _ := profile.Quicknet().CanonicalCBOR()
path := filepath.Join(t.TempDir(), "q.cbor")
os.WriteFile(path, b, 0o600)
if stdout, _, err = cli(t, later, "profile", "hash", "-in", path); err != nil || !strings.Contains(stdout, profile.QuicknetProfileHash) {
t.Fatalf("%v %s", err, stdout)
}
}
func TestUsage(t *testing.T) {
for _, args := range [][]string{nil, {"nope"}, {"datekey"}, {"profile", "x"}, {"encrypt", "-bogus"}, {"inspect", "extra"}, {"version", "extra"}} {
if _, _, err := cli(t, later, args...); err == nil {
t.Errorf("%v accepted", args)
}
}
}
// The version command names the module version, the specification and the
// toolchain; in a test the module version is unknown.
func TestVersion(t *testing.T) {
for _, arg := range []string{"version", "-version", "--version"} {
out, _, err := cli(t, later, arg)
if err != nil {
t.Fatal(err)
}
want := "datekeys " + datekeys.Version() + "\nspecification " + datekeys.SpecVersion + "\n" + runtime.Version() + " " + runtime.GOOS + "/" + runtime.GOARCH + "\n"
if out != want {
t.Errorf("%s: %q, want %q", arg, out, want)
}
}
if !strings.Contains(usage, "datekeys version") {
t.Error("the usage does not list the version command")
}
}
// Spec §53: long horizons get the harvest-now, decrypt-later warning.
func TestLongHorizonWarning(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "in")
os.WriteFile(in, []byte("x"), 0o600)
for i, tc := range []struct {
after time.Duration
warn bool
}{{time.Hour, false}, {2 * 365 * 24 * time.Hour, true}} {
out := filepath.Join(dir, fmt.Sprintf("%d.dkc", i))
_, stderr, err := cli(t, later, "encrypt", "-at", later.Add(tc.after).Format(time.RFC3339), "-in", in, "-out", out)
if err != nil || strings.Contains(stderr, "not post-quantum") != tc.warn {
t.Fatalf("%s: %v: %s", tc.after, err, stderr)
}
}
}

Powered by TurnKey Linux.