Files
privatae-bastion/client-go/backupkey_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

207 lines
5.7 KiB
Go

package main
// Gate: the backup key is made here, kept as a plain age key file, never
// overwritten, and every string that names a key round-trips through age's own
// parser. The bech32 here is an encoding of public keys only; these tests hold
// it to age's.
import (
"bytes"
"crypto/rand"
"encoding/base64"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
)
// ageBinary is the real age command for the cross-language checks. AGE_BIN
// names one explicitly; otherwise `age` on PATH; otherwise the test skips.
func ageBinary(t *testing.T) string {
t.Helper()
if p := os.Getenv("AGE_BIN"); p != "" {
return p
}
if p, err := exec.LookPath("age"); err == nil {
return p
}
// CI installs a pinned age release and sets this, so the cross-language
// checks cannot pass there by skipping.
if os.Getenv("PRIVATAE_REQUIRE_AGE") != "" {
t.Fatal("PRIVATAE_REQUIRE_AGE is set and no age binary was found (AGE_BIN or PATH)")
}
t.Skip("no age binary (set AGE_BIN) — the cross-language half is skipped")
return ""
}
func withBackupKeysDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
prev := backupKeysDir
backupKeysDir = func() (string, error) { return dir, nil }
t.Cleanup(func() { backupKeysDir = prev })
return dir
}
func TestBech32RoundTripsAgeRecipients(t *testing.T) {
for i := 0; i < 64; i++ {
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
raw, err := recipientRaw(id.Recipient())
if err != nil {
t.Fatal(err)
}
if len(raw) != 32 {
t.Fatalf("raw key is %d bytes", len(raw))
}
enc, err := bech32Encode("age", raw)
if err != nil {
t.Fatal(err)
}
if enc != id.Recipient().String() {
t.Fatalf("bech32 of the raw key %s differs from age's %s", enc, id.Recipient())
}
}
// A random 32 bytes encodes to something age itself accepts.
raw := make([]byte, 32)
_, _ = rand.Read(raw)
enc, _ := bech32Encode("age", raw)
if _, err := age.ParseX25519Recipient(enc); err != nil {
t.Fatalf("age refuses our encoding: %v", err)
}
// One changed character is caught by the checksum.
bad := enc[:10] + string("qp"[boolIdx(enc[10] == 'q')]) + enc[11:]
if _, _, err := bech32Decode(bad); err == nil {
t.Fatal("a corrupted recipient decoded")
}
}
func boolIdx(b bool) int {
if b {
return 1
}
return 0
}
func TestParseBackupRecipientAcceptsBothForms(t *testing.T) {
id, _ := age.GenerateX25519Identity()
raw, _ := recipientRaw(id.Recipient())
a, err := parseBackupRecipient(id.Recipient().String())
if err != nil {
t.Fatal(err)
}
b, err := parseBackupRecipient(base64.StdEncoding.EncodeToString(raw))
if err != nil {
t.Fatal(err)
}
if a.String() != b.String() {
t.Fatalf("the two forms of one key disagree: %s vs %s", a, b)
}
for _, bad := range []string{"", "age1nope", base64.StdEncoding.EncodeToString(raw[:31]), "not base64 !!"} {
if _, err := parseBackupRecipient(bad); err == nil {
t.Errorf("%q was accepted as a key", bad)
}
}
}
func TestBackupKeyInitKeepsAPlainAgeKeyAndNeverOverwrites(t *testing.T) {
dir := withBackupKeysDir(t)
k1, err := backupKeyInit(false)
if err != nil {
t.Fatal(err)
}
fi, err := os.Stat(k1.Path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("key file mode %v, want 0600", fi.Mode().Perm())
}
// It is a plain age identity file: age's own parser reads it.
f, _ := os.Open(k1.Path)
ids, err := age.ParseIdentities(f)
f.Close()
if err != nil || len(ids) != 1 {
t.Fatalf("the key file is not an age identity file: %v", err)
}
raw, _ := base64.StdEncoding.DecodeString(k1.PubB64)
if backupKeyFingerprint(raw) != k1.Fingerprint || filepath.Base(k1.Path) != k1.Fingerprint+".txt" {
t.Fatal("the fingerprint does not name the key")
}
if _, err := backupKeyInit(false); err == nil {
t.Fatal("a second init without --replace overwrote the key")
}
k2, err := backupKeyInit(true)
if err != nil {
t.Fatal(err)
}
if k2.Fingerprint == k1.Fingerprint {
t.Fatal("--replace did not make a new key")
}
active, all, err := backupKeysLoad()
if err != nil {
t.Fatal(err)
}
if active == nil || active.Fingerprint != k2.Fingerprint {
t.Fatal("the new key is not the active one")
}
if len(all) != 2 {
t.Fatalf("the replaced key was not kept: %d keys", len(all))
}
// Both still open what was sealed to them.
got, err := backupIdentities()
if err != nil || len(got) != 2 {
t.Fatalf("identities: %v %d", err, len(got))
}
for _, k := range []*backupKeyFile{k1, k2} {
r, _ := age.ParseX25519Recipient(k.Recipient)
var ct bytes.Buffer
w, _ := age.Encrypt(&ct, r)
_, _ = w.Write([]byte("sealed to " + k.Fingerprint))
_ = w.Close()
rd, err := age.Decrypt(&ct, got...)
if err != nil {
t.Fatalf("key %s no longer opens its backups: %v", k.Fingerprint, err)
}
b, _ := io.ReadAll(rd)
if !strings.HasSuffix(string(b), k.Fingerprint) {
t.Fatal("wrong plaintext")
}
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".ctx-") {
t.Fatalf("a temporary file survived: %s", e.Name())
}
}
}
// Cross-language: the key file this bastion keeps is read by the real age
// command the same way filippo.io/age reads it here.
func TestBackupKeyFileOpensWithTheAgeCommand(t *testing.T) {
bin := ageBinary(t)
withBackupKeysDir(t)
k, err := backupKeyInit(false)
if err != nil {
t.Fatal(err)
}
r, _ := age.ParseX25519Recipient(k.Recipient)
ct := filepath.Join(t.TempDir(), "x.age")
f, _ := os.Create(ct)
w, _ := age.Encrypt(f, r)
_, _ = w.Write([]byte("a backup"))
_ = w.Close()
_ = f.Close()
out, err := exec.Command(bin, "-d", "-i", k.Path, ct).CombinedOutput()
if err != nil || string(out) != "a backup" {
t.Fatalf("age -d -i <key file> failed: %v %s", err, out)
}
}