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.
326 lines
11 KiB
326 lines
11 KiB
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/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"},
|
|
} {
|
|
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))
|
|
}
|
|
if _, stderr, err := cli(t, later, args...); err != nil {
|
|
t.Fatalf("%v\n%s", err, 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)
|
|
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, "-out", dkc,
|
|
"-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, "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")
|
|
}
|
|
|
|
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" {
|
|
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...)
|
|
if _, stderr, err := cli(t, later, args...); err != nil {
|
|
t.Fatalf("%v\n%s", err, stderr)
|
|
}
|
|
if b, _ := os.ReadFile(out); string(b) != "round trip through the CLI" {
|
|
t.Fatal("plaintext differs")
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if len(names) != 5 {
|
|
t.Fatalf("%d frozen inspect outputs, want one per official .dkc (5)", len(names))
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|