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

791 lines
33 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"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/wordkey"
)
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)
// joined undoes the rows of writeVerdicts: each row after the first of a line
// goes back after the space that its break dropped. No line of the tests
// breaks inside a word at 80 columns.
func joined(s string) string { return strings.ReplaceAll(s, "\n"+contMark, " ") }
// The lines of the reader break at the last space that fits, behind the mark
// of a continuation; a word longer than a row breaks inside; and the
// indentation of a line is not a place to break it.
func TestRows(t *testing.T) {
for _, c := range []struct {
line string
first, rest int
want []string
}{
{"abc def ghi", 20, 20, []string{"abc def ghi"}},
{"abc def ghi", 7, 7, []string{"abc def", "ghi"}},
{"abc def ghi", 6, 6, []string{"abc", "def", "ghi"}},
{"abcdefghij k", 4, 3, []string{"abcd", "efg", "hij", "k"}},
{" abcdefgh", 5, 5, []string{" abc", "defgh"}},
{"ñañañaña", 4, 4, []string{"ña", "ña", "ña", "ña"}},
} {
if got := rows(c.line, c.first, c.rest); !reflect.DeepEqual(got, c.want) {
t.Errorf("rows(%q, %d, %d) = %q, want %q", c.line, c.first, c.rest, got, c.want)
}
}
var b strings.Builder
writeVerdicts(&b, []string{"Firmado con la clave que guardaste como Mamá."}, 20)
if got := b.String(); got != "Firmado con la\n"+contMark+"clave que\n"+contMark+"guardaste\n"+contMark+"como Mamá.\n" {
t.Errorf("writeVerdicts at 20 columns:\n%s", got)
}
}
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")
}
})
}
}
// Spec §56, §67: the format 3 fixtures open through the CLI into a new
// folder, with their mtimes, and the CLI shows their verdicts first and
// last (spec §29.7).
func TestDecryptFormat3Fixtures(t *testing.T) {
url := relay(t)
for _, tc := range []struct{ name, dkk string }{
{"format3_single", ""},
{"format3_tree", ""},
{"format3_comment_only", ""},
{"format3_time_and_key_portable", "format3_time_and_key_portable.dkk"},
{"format3_seal_unsupported", ""},
{"format3_unsigned", ""},
{"format3_signed", ""},
{"format3_signed_cms", ""},
{"format3_sealed", ""},
{"format3_note", ""},
} {
t.Run(tc.name, func(t *testing.T) {
var f testkit.DKCFixture
if err := testkit.ReadJSON(filepath.Join(fixtures, tc.name+".json"), &f); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(fixtures, f.PlaintextFile))
if err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "out")
args := []string{"decrypt", "-in", filepath.Join(fixtures, f.File), "-out", out, "-relay", url}
if tc.dkk != "" {
args = append(args, "-dkk", filepath.Join(fixtures, tc.dkk))
}
stdout, stderr, err := cli(t, later, args...)
if err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
for _, file := range f.Files {
name := filepath.Join(out, filepath.FromSlash(file.Path))
got, err := os.ReadFile(name)
if err != nil || !bytes.Equal(got, body[f.ContentOffset+file.Start:f.ContentOffset+file.End]) {
t.Errorf("%s: %v", file.Path, err)
}
if info, err := os.Stat(name); file.MTime != nil && (err != nil || info.ModTime().Unix() != int64(*file.MTime)) {
t.Errorf("%s: mtime %v", file.Path, err)
}
}
if _, err := os.Lstat(out); len(f.Files) == 0 && !errors.Is(err, os.ErrNotExist) {
t.Error("a capsule without files created its folder")
}
lines := strings.Split(strings.TrimSuffix(joined(stdout), "\n"), "\n")
n := len(f.Verdicts.Lines)
if len(lines) < 2*n || !reflect.DeepEqual(lines[:n], f.Verdicts.Lines) || !reflect.DeepEqual(lines[len(lines)-n:], f.Verdicts.Lines) {
t.Errorf("the verdicts are not first and last:\n%s", stdout)
}
})
}
}
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)
}
// A folder whose parent does not exist fails before any request: the
// relay cannot be reached.
missing := filepath.Join(dir, "missing", "out")
if _, _, err := cli(t, later, "decrypt", "-in", dkc, "-out", missing, "-relay", "http://127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "is not a folder") {
t.Fatalf("a folder without its parent: %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, in rows of at most W - 3 columns; 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)
out := joined(b.String())
lines := strings.Split(strings.TrimSuffix(out, "\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())
}
verdictRows := strings.Count(b.String(), contMark) / 2
for i, l := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") {
w := 0
for _, r := range l {
w += runeWidth(r)
}
rest, creator := strings.CutPrefix(l, prefix)
switch {
case creator && (w > 20 || rest == "" && l != prefix):
t.Errorf("piece %q of %d columns", rest, w)
case i <= verdictRows && w > 20-prefixWidth:
t.Errorf("row %q of the verdicts of %d columns", l, 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(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
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) != 26 {
t.Fatalf("%d frozen inspect outputs, want one per official .dkc (%d, 26)", 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)
}
}
}
func TestKeyOfWords(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("abierta con palabras"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
genesis := time.Unix(p.GenesisTime, 0)
at := unlock.Format(time.RFC3339)
dkc := filepath.Join(dir, "carta.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", at, "-policy", "time_and_key", "-words", "Perro luna casa verde trén mar", "-in", in, "-out", dkc); err != nil {
t.Fatalf("encrypt: %v\n%s", err, stderr)
}
words := filepath.Join(dir, "palabras.txt")
os.WriteFile(words, []byte(" perro LUNA casa\nverde tren mar\n"), 0o600)
out := filepath.Join(dir, "abierta")
if _, stderr, err := cli(t, later, "decrypt", "-in", dkc, "-out", out, "-words-file", words, "-relay", relay(t)); err != nil {
t.Fatalf("decrypt: %v\n%s", err, stderr)
}
if b, err := os.ReadFile(filepath.Join(out, "carta.txt")); err != nil || string(b) != "abierta con palabras" {
t.Fatalf("carta.txt = %q, %v", b, err)
}
if _, _, err := cli(t, later, "decrypt", "-in", dkc, "-out", filepath.Join(dir, "otra"), "-words", "gato luna casa verde tren mar", "-relay", relay(t)); err == nil {
t.Fatal("other words opened the capsule")
}
for _, tc := range []struct {
args []string
want string
}{
{[]string{"-policy", "time_and_key", "-words", "uno dos tres"}, "at least 6 different words of 3 or more letters, not 3"},
{[]string{"-policy", "time_and_key", "-words", "de la casa al mar en tren verde"}, "not 4"},
{[]string{"-policy", "time_and_key", "-words", "perro luna casa verde tren mar" + string(rune(0x200B))}, "invisible character U+200B"},
{[]string{"-words", "uno dos tres cuatro cinco seis"}, "need -policy time_and_key"},
{[]string{"-policy", "time_and_key", "-words", "a", "-words-file", words}, "are exclusive"},
{[]string{"-policy", "time_and_key", "-words", "a", "-new-words", filepath.Join(dir, "n.txt")}, "-new-words excludes -words and -words-file"},
{[]string{"-policy", "time_and_key", "-new-words", words}, "already exists"},
{[]string{"-policy", "time_and_key", "-new-words", filepath.Join(dir, "n.txt"), "-dic", "xx"}, `no word list for "xx"`},
{[]string{"-policy", "time_and_key", "-new-words", filepath.Join(dir, "n.txt"), "-word-count", "5"}, "at least 6 words, not 5"},
{[]string{"-new-words", filepath.Join(dir, "n.txt")}, "-words, -words-file, -new-words and -dice need -policy time_and_key"},
{[]string{"-dice", "11111 11112 11113 11114 11115 11116"}, "-words, -words-file, -new-words and -dice need -policy time_and_key"},
{[]string{"-policy", "time_and_key", "-dice", "11111", "-dice-file", words}, "-dice and -dice-file are exclusive"},
{[]string{"-policy", "time_and_key", "-dice", "11111", "-words", "a"}, "-dice and -dice-file exclude -words, -words-file and -new-words"},
{[]string{"-policy", "time_and_key", "-dice-file", words, "-new-words", filepath.Join(dir, "n.txt")}, "-dice and -dice-file exclude -words, -words-file and -new-words"},
{[]string{"-policy", "time_and_key", "-dice", "11111 11112 11113 11114 11115"}, "at least 6 words, not 5"},
{[]string{"-policy", "time_and_key", "-dice", "11111 11112 11113 11114 11115 11171"}, `"11171" is not five dice: five digits from 1 to 6`},
{[]string{"-policy", "time_and_key", "-dice", "11111 11112 11113 11114 11115 11111"}, `the dice 11111 give "abacus" a second time; roll them again`},
{[]string{"-policy", "time_and_key", "-dice", "11111 11112 11113 11114 11115 11116", "-dic", "xx"}, `no word list for "xx"`},
} {
args := append([]string{"encrypt", "-at", at, "-in", in, "-out", filepath.Join(dir, "x.dkc")}, tc.args...)
if _, _, err := cli(t, genesis, args...); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%v: %v, want %q", tc.args, err, tc.want)
}
}
if _, err := os.Stat(filepath.Join(dir, "n.txt")); err == nil {
t.Error("a refused encrypt left its words file")
}
}
// The words of dice, as the list numbered for dice gives them: the capsule
// opens with them.
func TestDice(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("abierta con dados"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
dkc := filepath.Join(dir, "carta.dkc")
dice := filepath.Join(dir, "dados.txt")
os.WriteFile(dice, []byte("35214 11111\n64253 11112 11113\n66666 11121\n"), 0o600)
_, stderr, err := cli(t, time.Unix(p.GenesisTime, 0), "encrypt", "-at", unlock.Format(time.RFC3339), "-policy", "time_and_key",
"-dice-file", dice, "-dic", "es", "-in", in, "-out", dkc)
if err != nil {
t.Fatalf("encrypt: %v\n%s", err, stderr)
}
const words = "glaciar abad tocar abadía abandonar útil abdomen"
if !strings.Contains(stderr, "words "+words+": the 7 words of the dice in the list es, 90 bits; keep these words") {
t.Errorf("stderr: %s", stderr)
}
out := filepath.Join(dir, "abierta")
if _, stderr, err := cli(t, later, "decrypt", "-in", dkc, "-out", out, "-words", "Glaciar abad tocar abadia abandonar util abdomen", "-relay", relay(t)); err != nil {
t.Fatalf("decrypt: %v\n%s", err, stderr)
}
if b, err := os.ReadFile(filepath.Join(out, "carta.txt")); err != nil || string(b) != "abierta con dados" {
t.Fatalf("carta.txt = %q, %v", b, err)
}
}
// datekeys wordlist writes the list numbered for dice, and its SHA-256: for
// en, the file of the EFF.
func TestWordList(t *testing.T) {
for lang, want := range map[string]string{
"en": "addd35536511597a02fa0a9ff1e5284677b8883b83e986e43f15a3db996b903e",
"es": "611f779a33df74587e9dfb486bb0185a9dfd4d1384e75993a21247ad31cefddb",
} {
stdout, stderr, err := cli(t, time.Now(), "wordlist", "-dic", lang)
if err != nil {
t.Fatalf("wordlist -dic %s: %v", lang, err)
}
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(stdout))); got != want {
t.Errorf("wordlist -dic %s: SHA-256 %s, want %s", lang, got, want)
}
if stderr != "the list "+lang+" numbered for dice, 7776 words, SHA-256 "+want+"\n" {
t.Errorf("wordlist -dic %s: stderr %q", lang, stderr)
}
}
if stdout, _, err := cli(t, time.Now(), "wordlist"); err != nil || !strings.HasPrefix(stdout, "11111\tabacus\n11112\tabdomen\n") {
t.Errorf("wordlist: %.40q, %v", stdout, err)
}
if _, _, err := cli(t, time.Now(), "wordlist", "-dic", "xx"); err == nil || !strings.Contains(err.Error(), `wordlist: wordkey: no word list for "xx"`) {
t.Errorf("wordlist -dic xx: %v", err)
}
}
func TestNewWords(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("abierta con palabras al azar"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
dkc := filepath.Join(dir, "carta.dkc")
words := filepath.Join(dir, "palabras.txt")
_, stderr, err := cli(t, time.Unix(p.GenesisTime, 0), "encrypt", "-at", unlock.Format(time.RFC3339), "-policy", "time_and_key",
"-new-words", words, "-word-count", "8", "-in", in, "-out", dkc)
if err != nil {
t.Fatalf("encrypt: %v\n%s", err, stderr)
}
// The list of the EFF by default.
if !strings.Contains(stderr, "words "+words+": 8 words of the list en, 103 bits; keep them secret") {
t.Errorf("stderr: %s", stderr)
}
inList := func(lang, file string, n int) {
t.Helper()
b, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
list, _ := wordkey.List(lang)
known := map[string]bool{}
for _, w := range list {
known[w] = true
}
drawn := strings.Fields(string(b))
if len(drawn) != n || !strings.HasSuffix(string(b), "\n") {
t.Fatalf("words file %q", b)
}
for _, w := range drawn {
if !known[w] {
t.Errorf("%q is not in the list %s", w, lang)
}
}
}
inList("en", words, 8)
// The Spanish list with -dic es.
spanish := filepath.Join(dir, "espanol.txt")
_, stderr, err = cli(t, time.Unix(p.GenesisTime, 0), "encrypt", "-at", unlock.Format(time.RFC3339), "-policy", "time_and_key",
"-new-words", spanish, "-dic", "es", "-in", in, "-out", filepath.Join(dir, "espanol.dkc"))
if err != nil || !strings.Contains(stderr, "words "+spanish+": 7 words of the list es, 90 bits;") {
t.Fatalf("encrypt -dic es: %v\n%s", err, stderr)
}
inList("es", spanish, 7)
out := filepath.Join(dir, "abierta")
if _, stderr, err := cli(t, later, "decrypt", "-in", dkc, "-out", out, "-words-file", words, "-relay", relay(t)); err != nil {
t.Fatalf("decrypt: %v\n%s", err, stderr)
}
if b, err := os.ReadFile(filepath.Join(out, "carta.txt")); err != nil || string(b) != "abierta con palabras al azar" {
t.Fatalf("carta.txt = %q, %v", b, err)
}
}
// Spec v0.11 §24.1: decrypt does not show a public note that breaks the rules
// of text, and says so.
func TestPresentUnusableNote(t *testing.T) {
tab := capsule.Header{Noncritical: []extension.Extension{{ID: extension.NoteID, Version: 1, Data: []byte("a\tb")}}}
o := &capsule.Opened{
Verdicts: capsule.Verdicts{Signature: capsule.VerdictNoSignature, Seal: capsule.VerdictNoSeal},
Head: &capsule.Head{Files: []capsule.File{{Path: "nota.txt"}}},
Inspection: &capsule.Inspection{Header: &tab},
}
var b bytes.Buffer
present(&b, o, "DIR", 80)
if !strings.Contains(joined(b.String()), unusableNote+"\n") || strings.Contains(b.String(), noteTitle) {
t.Errorf("an unusable note:\n%s", b.String())
}
}

Powered by TurnKey Linux.