bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user