package main import ( "bytes" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "filippo.io/age" datekeys "github.com/datekeys/datekeys-go" "github.com/datekeys/datekeys-go/internal/testkit" "github.com/datekeys/datekeys-go/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) } } 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 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) } } 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"}} { if _, _, err := cli(t, later, args...); err == nil { t.Errorf("%v accepted", args) } } } // 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) } } }