528 lines
19 KiB
Go
528 lines
19 KiB
Go
package main
|
|
|
|
// Gate: a reseal hands a host the key to ONE backup, sealed to that host
|
|
// alone, only after the person approves it here; and it refuses anything that
|
|
// is not the backup it claims to be. The fixture sidecar was written by the
|
|
// real age command (testdata/backup), in the shape hosts write.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"filippo.io/age/armor"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
func armorSeal(t *testing.T, plain []byte, to age.Recipient) []byte {
|
|
t.Helper()
|
|
var out bytes.Buffer
|
|
aw := armor.NewWriter(&out)
|
|
w, err := age.Encrypt(aw, to)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = w.Write(plain)
|
|
_ = w.Close()
|
|
_ = aw.Close()
|
|
return out.Bytes()
|
|
}
|
|
|
|
func armorOpen(t *testing.T, armored []byte, id age.Identity) []byte {
|
|
t.Helper()
|
|
r, err := age.Decrypt(armor.NewReader(bytes.NewReader(armored)), id)
|
|
if err != nil {
|
|
t.Fatalf("the resealed sidecar does not open with the host's key: %v", err)
|
|
}
|
|
b, _ := io.ReadAll(r)
|
|
return b
|
|
}
|
|
|
|
// Cross-language, both directions: a sidecar the age command wrote is opened
|
|
// and resealed here, and what this code wrote opens with the age command.
|
|
func TestResealOpensAnAgeWrittenSidecarAndSealsToTheHostAlone(t *testing.T) {
|
|
bin := ageBinary(t)
|
|
fx := newBackupFixture(t, bin)
|
|
host, _ := age.GenerateX25519Identity()
|
|
other, _ := age.GenerateX25519Identity()
|
|
out, sc, err := resealSidecar(fx.sidecar(t), []age.Identity{fx.recovery}, host.Recipient(), fixtureStamp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sc.Stamp != fixtureStamp || sc.EntityID != 42 {
|
|
t.Fatalf("read the wrong record: %+v", sc)
|
|
}
|
|
if got := armorOpen(t, out, host); !bytes.Equal(got, fx.plain) {
|
|
t.Fatalf("the resealed record is not the sealed one, byte for byte:\n%s\n%s", got, fx.plain)
|
|
}
|
|
if _, err := age.Decrypt(armor.NewReader(bytes.NewReader(out)), other); err == nil {
|
|
t.Fatal("a host that was not asked for opened the resealed key")
|
|
}
|
|
if _, err := age.Decrypt(armor.NewReader(bytes.NewReader(out)), fx.recovery); err == nil {
|
|
t.Fatal("the resealed copy is also sealed to the key that opened it; it must go to the host alone")
|
|
}
|
|
if strings.Contains(string(out), "AGE-SECRET-KEY") {
|
|
t.Fatal("the identity travels in clear")
|
|
}
|
|
dir := t.TempDir()
|
|
idFile := filepath.Join(dir, "host.txt")
|
|
_ = os.WriteFile(idFile, []byte(host.String()+"\n"), 0o600)
|
|
scFile := filepath.Join(dir, "resealed.age")
|
|
_ = os.WriteFile(scFile, out, 0o600)
|
|
got, err := exec.Command(bin, "-d", "-i", idFile, scFile).Output()
|
|
if err != nil {
|
|
t.Fatalf("the age command cannot open the resealed sidecar: %v", err)
|
|
}
|
|
if !bytes.Equal(got, fx.plain) {
|
|
t.Fatal("the age command reads a different record")
|
|
}
|
|
}
|
|
|
|
func TestResealRefusesWhatIsNotTheBackupItClaims(t *testing.T) {
|
|
user, _ := age.GenerateX25519Identity()
|
|
host, _ := age.GenerateX25519Identity()
|
|
pb, _ := age.GenerateX25519Identity()
|
|
ids := []age.Identity{user}
|
|
good := backupSidecar{V: 1, Kind: sidecarKind, Identity: pb.String(),
|
|
Object: "b/" + fixtureStamp + ".tar.zst.age", EntityID: 7, Stamp: fixtureStamp, MadeAt: "2026-09-28T02:00:00Z"}
|
|
seal := func(sc any) []byte {
|
|
b, _ := json.Marshal(sc)
|
|
return armorSeal(t, b, user.Recipient())
|
|
}
|
|
if _, _, err := resealSidecar(seal(good), ids, host.Recipient(), fixtureStamp); err != nil {
|
|
t.Fatalf("a good sidecar was refused: %v", err)
|
|
}
|
|
cases := map[string][]byte{}
|
|
b := good
|
|
b.Kind = "something-else"
|
|
cases["wrong kind"] = seal(b)
|
|
b = good
|
|
b.V = 2
|
|
cases["unknown version"] = seal(b)
|
|
b = good
|
|
b.Object = "b/20260101T000000Z.tar.zst.age"
|
|
cases["object names another backup"] = seal(b)
|
|
b = good
|
|
b.Stamp = "yesterday"
|
|
b.Object = "b/yesterday.tar.zst.age"
|
|
cases["stamp is not a stamp"] = seal(b)
|
|
b = good
|
|
b.MadeAt = "yesterday\x1b[2J"
|
|
cases["made_at is not a time"] = seal(b)
|
|
b = good
|
|
b.MadeAt = ""
|
|
cases["no made_at"] = seal(b)
|
|
b = good
|
|
b.Identity = "AGE-SECRET-KEY-1NOTAKEY"
|
|
cases["identity does not parse"] = seal(b)
|
|
cases["not JSON"] = armorSeal(t, []byte("hello"), user.Recipient())
|
|
cases["sealed to someone else"] = armorSeal(t, sidecarJSON(good), host.Recipient())
|
|
cases["too large"] = bytes.Repeat([]byte("A"), sidecarMaxBytes+1)
|
|
for name, in := range cases {
|
|
if _, _, err := resealSidecar(in, ids, host.Recipient(), ""); err == nil {
|
|
t.Errorf("%s: resealed", name)
|
|
}
|
|
}
|
|
// A sidecar for one backup cannot be resealed as another.
|
|
if _, _, err := resealSidecar(seal(good), ids, host.Recipient(), "20250101T000000Z"); err == nil {
|
|
t.Error("a sidecar was resealed for a backup it does not name")
|
|
}
|
|
}
|
|
|
|
// A host writes a backup's key file naming b/<stamp> when the backup is kept
|
|
// in platform storage and s/<stamp> when it is staged there only until a
|
|
// second copy exists (a backup kept only on a bastion). Both open and reseal,
|
|
// written by the age command and by this code alike.
|
|
func TestResealTakesBothPlacesABackupIsKept(t *testing.T) {
|
|
bins := []string{""}
|
|
if p, err := exec.LookPath("age"); err == nil {
|
|
bins = append(bins, p)
|
|
} else if p := os.Getenv("AGE_BIN"); p != "" {
|
|
bins = append(bins, p)
|
|
} else if os.Getenv("PRIVATAE_REQUIRE_AGE") != "" {
|
|
t.Fatal("PRIVATAE_REQUIRE_AGE is set and no age binary was found (AGE_BIN or PATH)")
|
|
}
|
|
for _, bin := range bins {
|
|
for _, sub := range []string{"b", "s"} {
|
|
fx := newBackupFixtureAs(t, bin, sub, fixtureEntity)
|
|
host, _ := age.GenerateX25519Identity()
|
|
out, sc, err := resealSidecar(fx.sidecar(t), []age.Identity{fx.recovery}, host.Recipient(), fixtureStamp)
|
|
if err != nil {
|
|
t.Fatalf("%s/ key file (age command: %v) refused: %v", sub, bin != "", err)
|
|
}
|
|
if sc.Object != sub+"/"+fixtureStamp+".tar.zst.age" {
|
|
t.Fatalf("read the wrong record: %+v", sc)
|
|
}
|
|
if !bytes.Equal(armorOpen(t, out, host), fx.plain) {
|
|
t.Fatalf("%s/: the resealed record is not the sealed one", sub)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only those two places, and only the sidecar's own stamp: any other folder,
|
|
// a path that climbs or nests, or another backup's name is refused.
|
|
func TestResealRefusesAnObjectOutsideBothPlaces(t *testing.T) {
|
|
user, _ := age.GenerateX25519Identity()
|
|
host, _ := age.GenerateX25519Identity()
|
|
pb, _ := age.GenerateX25519Identity()
|
|
name := fixtureStamp + ".tar.zst.age"
|
|
for _, obj := range []string{
|
|
"x/" + name, "../b/" + name, "b/../b/" + name, "s/../b/" + name, "b/b/" + name, "b//" + name,
|
|
"/b/" + name, "./b/" + name, "B/" + name, "S/" + name, "bs/" + name, "exports/" + name,
|
|
"b\\" + name, name, "b/", "s/", "", "b/20260101T000000Z.tar.zst.age", "s/" + fixtureStamp + ".tar.zst",
|
|
"s/" + fixtureStamp + ".tar.zst.age/x", " s/" + name, "s/" + name + "\x00",
|
|
} {
|
|
sc := backupSidecar{V: 1, Kind: sidecarKind, Identity: pb.String(), Object: obj, EntityID: fixtureEntity,
|
|
Stamp: fixtureStamp, MadeAt: "2026-09-28T02:00:00Z"}
|
|
in := armorSeal(t, sidecarJSON(sc), user.Recipient())
|
|
if _, _, err := resealSidecar(in, []age.Identity{user}, host.Recipient(), ""); err == nil ||
|
|
!strings.Contains(err.Error(), "does not match its stamp") {
|
|
t.Errorf("object %q: %v", obj, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sidecarJSON(v any) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
// driveBackupOp runs one job through the real management frame path, from
|
|
// an anonymous caller.
|
|
func driveBackupOp(t *testing.T, op, dcnDir, backupRoot string, arg any) (string, string, int) {
|
|
t.Helper()
|
|
return driveBackupOpAs(t, "", op, dcnDir, backupRoot, arg)
|
|
}
|
|
|
|
// driveBackupOpAs is driveBackupOp from the pairing self.
|
|
func driveBackupOpAs(t *testing.T, self, op, dcnDir, backupRoot string, arg any) (string, string, int) {
|
|
t.Helper()
|
|
raw, _ := json.Marshal(arg)
|
|
up := websocket.Upgrader{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ws, err := up.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer ws.Close()
|
|
handleMgmtConnFor(ws, self, "", dcnDir, backupRoot)
|
|
}))
|
|
defer srv.Close()
|
|
c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
job, _ := json.Marshal(mgmtJob{Op: op, Args: []string{string(raw)}})
|
|
if err := c.WriteMessage(websocket.BinaryMessage, append([]byte("C"), job...)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
var out, errOut string
|
|
for {
|
|
_, data, err := c.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
switch data[0] {
|
|
case 'O':
|
|
out += string(data[1:])
|
|
case 'R':
|
|
errOut += string(data[1:])
|
|
case 'X':
|
|
var code int
|
|
fmt.Sscanf(string(data[1:]), "%d", &code)
|
|
return out, errOut, code
|
|
}
|
|
}
|
|
}
|
|
|
|
func resealStatus(t *testing.T, dcn, id string) map[string]string {
|
|
t.Helper()
|
|
out, errOut, code := driveBackupOp(t, "backup_reseal", dcn, "", resealReq{Op: "status", JobID: id})
|
|
if code != 0 {
|
|
t.Fatalf("status exited %d: %s", code, errOut)
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal([]byte(out), &m); err != nil {
|
|
t.Fatalf("status is not JSON: %s", out)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// The whole lane: the host files, nothing is opened until the person
|
|
// approves here, then the host collects a key only it can open.
|
|
func TestBackupResealWaitsForThePersonThenDeliversToTheHostOnly(t *testing.T) {
|
|
fx := newBackupFixture(t, "")
|
|
fx.installAsBastionKey(t)
|
|
|
|
dcn := withFixturePairings(t, map[string]int64{fixtureHost: fixtureEntity})[fixtureHost]
|
|
host, _ := age.GenerateX25519Identity()
|
|
req := resealReq{Op: "start", Sidecar: string(fx.sidecar(t)), To: host.Recipient().String(),
|
|
Stamp: fixtureStamp, HostLabel: "the host being restored"}
|
|
out, errOut, code := driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", req)
|
|
if code != 0 {
|
|
t.Fatalf("start exited %d: %s", code, errOut)
|
|
}
|
|
var started map[string]string
|
|
_ = json.Unmarshal([]byte(out), &started)
|
|
if started["state"] != "pending" || !strings.HasPrefix(started["job_id"], "br-") {
|
|
t.Fatalf("start did not file a pending request: %s", out)
|
|
}
|
|
// Asking again files nothing new.
|
|
out2, _, _ := driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", req)
|
|
var again map[string]string
|
|
_ = json.Unmarshal([]byte(out2), &again)
|
|
if again["job_id"] != started["job_id"] {
|
|
t.Fatal("the same ask filed a second request")
|
|
}
|
|
st := resealStatus(t, dcn, started["job_id"])
|
|
if st["state"] != "pending" || st["sidecar"] != "" {
|
|
t.Fatalf("a key was handed out before the person approved: %v", st)
|
|
}
|
|
// Another pairing's state directory knows nothing of it.
|
|
if o := resealStatus(t, t.TempDir(), started["job_id"]); o["state"] != "unknown" {
|
|
t.Fatalf("another pairing read this request: %v", o)
|
|
}
|
|
|
|
if _, err := resealDecide(dcn, fixtureWho, started["job_id"], true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st = resealStatus(t, dcn, started["job_id"])
|
|
if st["state"] != "done" {
|
|
t.Fatalf("approved request is %v", st)
|
|
}
|
|
if got := armorOpen(t, []byte(st["sidecar"]), host); !bytes.Equal(got, fx.plain) {
|
|
t.Fatal("the host received a different record")
|
|
}
|
|
if _, err := resealDecide(dcn, fixtureWho, started["job_id"], true); err == nil {
|
|
t.Fatal("a decided request was decided again")
|
|
}
|
|
|
|
// A refusal hands over nothing.
|
|
req.To = mustRecipient(t)
|
|
out, _, _ = driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", req)
|
|
var second map[string]string
|
|
_ = json.Unmarshal([]byte(out), &second)
|
|
if _, err := resealDecide(dcn, fixtureWho, second["job_id"], false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st := resealStatus(t, dcn, second["job_id"]); st["state"] != "refused" || st["sidecar"] != "" {
|
|
t.Fatalf("a refused request handed something over: %v", st)
|
|
}
|
|
}
|
|
|
|
func mustRecipient(t *testing.T) string {
|
|
id, _ := age.GenerateX25519Identity()
|
|
return id.Recipient().String()
|
|
}
|
|
|
|
func TestBackupResealStartRefusesMalformedAsks(t *testing.T) {
|
|
dcn := withFixturePairings(t, map[string]int64{fixtureHost: fixtureEntity})[fixtureHost]
|
|
fx := newBackupFixture(t, "")
|
|
good := resealReq{Op: "start", Sidecar: string(fx.sidecar(t)), To: mustRecipient(t), Stamp: fixtureStamp}
|
|
bad := map[string]resealReq{}
|
|
r := good
|
|
r.Sidecar = "not armored"
|
|
bad["unarmored sidecar"] = r
|
|
r = good
|
|
r.Sidecar = strings.Repeat("A", sidecarMaxBytes+1)
|
|
bad["oversized sidecar"] = r
|
|
r = good
|
|
r.To = "age1notakey"
|
|
bad["bad key"] = r
|
|
r = good
|
|
r.Stamp = "../etc"
|
|
bad["bad stamp"] = r
|
|
for name, label := range map[string]string{
|
|
"multi-line label": "two\nlines", "escape sequence": "host\x1b[31m red", "C1 control": "ho\u0085st",
|
|
"reversed text": "host\u202etxt.exe", "line separator": "a\u2028b", "bell": "ho\x07st",
|
|
"too long": strings.Repeat("x", resealLabelMax+1),
|
|
} {
|
|
r = good
|
|
r.HostLabel = label
|
|
bad[name] = r
|
|
}
|
|
for name, req := range bad {
|
|
if _, _, code := driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", req); code == 0 {
|
|
t.Errorf("%s: accepted", name)
|
|
}
|
|
}
|
|
// Not from a pairing, or from one that is not paired here: refused too.
|
|
for _, self := range []string{"", "other.host.privatae.ai"} {
|
|
if _, _, code := driveBackupOpAs(t, self, "backup_reseal", dcn, "", good); code == 0 {
|
|
t.Errorf("an ask from %q was accepted", self)
|
|
}
|
|
}
|
|
if m, _ := resealRead(dcn); len(m) != 0 {
|
|
t.Fatalf("refused asks were filed: %d", len(m))
|
|
}
|
|
// The same ask, well formed and from the pairing, is filed: the refusals
|
|
// above were for what they say.
|
|
if _, errOut, code := driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", good); code != 0 {
|
|
t.Fatalf("a good ask was refused: %s", errOut)
|
|
}
|
|
}
|
|
|
|
// The op table carries both new ops under the backup family, each with its
|
|
// own approval, so the generic per-run gate never asks twice for one act.
|
|
func TestBackupOpsAreClassified(t *testing.T) {
|
|
for _, op := range []string{"backup_push", "backup_reseal", "backup_list"} {
|
|
if _, ok := handlerOps[op]; !ok {
|
|
t.Errorf("%s has no handler", op)
|
|
}
|
|
if opFamily(op) != "dcn" {
|
|
t.Errorf("%s is in family %q", op, opFamily(op))
|
|
}
|
|
}
|
|
for _, op := range []string{"backup_push", "backup_reseal"} {
|
|
if !opWrites(op) || !ownApproval(op) {
|
|
t.Errorf("%s: a write that carries its own approval (writes=%v own=%v)", op, opWrites(op), ownApproval(op))
|
|
}
|
|
}
|
|
if opWrites("backup_list") {
|
|
t.Error("backup_list is a read")
|
|
}
|
|
if handlerOps["backup_reseal"].root != rootDcn || handlerOps["backup_push"].root != rootBackup ||
|
|
handlerOps["backup_list"].root != rootBackup {
|
|
t.Error("the ops run against the wrong root")
|
|
}
|
|
}
|
|
|
|
// A host cannot fill this machine's state with requests: past a count, or
|
|
// past a total size, start is refused with a sentence saying what to do.
|
|
func TestBackupResealQueueIsBounded(t *testing.T) {
|
|
armored := func(i, size int) string {
|
|
head := "-----BEGIN AGE ENCRYPTED FILE-----\n"
|
|
body := fmt.Sprintf("%08d", i) + strings.Repeat("A", size-len(head)-8)
|
|
return head + body
|
|
}
|
|
to := mustRecipient(t)
|
|
|
|
dir := t.TempDir()
|
|
for i := 0; i < resealMaxRequests; i++ {
|
|
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(i, 200), To: to, Stamp: fixtureStamp}); err != nil {
|
|
t.Fatalf("request %d refused: %v", i, err)
|
|
}
|
|
}
|
|
_, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(99, 200), To: to, Stamp: fixtureStamp})
|
|
if err == nil || !strings.Contains(err.Error(), "backup requests") {
|
|
t.Fatalf("request past the count was not refused as such: %v", err)
|
|
}
|
|
// The same ask again is still answered: it files nothing new.
|
|
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(0, 200), To: to, Stamp: fixtureStamp}); err != nil {
|
|
t.Fatalf("a repeated ask was refused: %v", err)
|
|
}
|
|
|
|
dir = t.TempDir()
|
|
big := sidecarMaxBytes
|
|
n := 0
|
|
for ; (n+1)*big <= resealMaxBytes; n++ {
|
|
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(n, big), To: to, Stamp: fixtureStamp}); err != nil {
|
|
t.Fatalf("request %d of %d bytes refused: %v", n, big, err)
|
|
}
|
|
}
|
|
if n >= resealMaxRequests {
|
|
t.Fatalf("the size bound (%d) is never reached before the count (%d)", resealMaxBytes, resealMaxRequests)
|
|
}
|
|
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(n, big), To: to, Stamp: fixtureStamp}); err == nil ||
|
|
!strings.Contains(err.Error(), "KiB") {
|
|
t.Fatalf("request past the size bound was not refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// What the person reads before approving: the host's words, marked as the
|
|
// host's, beside what the sidecar itself says when opened here.
|
|
func TestResealPreviewShowsWhatTheSidecarSays(t *testing.T) {
|
|
fx := newBackupFixture(t, "")
|
|
r := &resealRequest{ID: "br-1", Stamp: fixtureStamp, Pairing: fixtureHost, HostNumber: fixtureEntity,
|
|
HostLabel: "my new host", To: mustRecipient(t), Sidecar: string(fx.sidecar(t)), State: "pending"}
|
|
p, refusal := resealPreview(r, []age.Identity{fx.recovery}, fixtureWho)
|
|
if refusal != "" {
|
|
t.Fatalf("the host's own backup is refused: %s", refusal)
|
|
}
|
|
for _, want := range []string{"Sent by the pairing " + fixtureHost + ", host number 42", "What the host says",
|
|
`"my new host"`, "host number 42",
|
|
`"2026-09-28T02:00:00Z"`, `"b/` + fixtureStamp + `.tar.zst.age"`} {
|
|
if !strings.Contains(p, want) {
|
|
t.Errorf("the preview does not show %q:\n%s", want, p)
|
|
}
|
|
}
|
|
if strings.Contains(p, "approving will fail") {
|
|
t.Errorf("a matching sidecar was flagged:\n%s", p)
|
|
}
|
|
r.Stamp = "20250101T000000Z"
|
|
if p, _ := resealPreview(r, []age.Identity{fx.recovery}, fixtureWho); !strings.Contains(p, "approving will fail") {
|
|
t.Errorf("a sidecar for another backup was not flagged:\n%s", p)
|
|
}
|
|
other, _ := age.GenerateX25519Identity()
|
|
if p, _ := resealPreview(r, []age.Identity{other}, fixtureWho); !strings.Contains(p, "does not open here") {
|
|
t.Errorf("a sidecar this bastion cannot open was not said so:\n%s", p)
|
|
}
|
|
}
|
|
|
|
// With a second factor enrolled for the pairing, approving needs its code:
|
|
// no code or a wrong one opens nothing and leaves the request pending.
|
|
func TestBackupApproveNeedsTheEnrolledFactor(t *testing.T) {
|
|
fx := newBackupFixture(t, "")
|
|
fx.installAsBastionKey(t)
|
|
dir := t.TempDir()
|
|
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
|
if err := stepupSave(dir, &stepupFile{SecretB32: secret, EnrolledAt: "2026-09-28T00:00:00Z", LastCounter: -1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
host, _ := age.GenerateX25519Identity()
|
|
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: string(fx.sidecar(t)), To: host.Recipient().String(), Stamp: fixtureStamp})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := b32decode(secret)
|
|
now := time.Now()
|
|
good := hotp(raw, now.Unix()/stepupPeriod)
|
|
wrong := "000000"
|
|
if wrong == good {
|
|
wrong = "111111"
|
|
}
|
|
for _, code := range []string{"", wrong} {
|
|
if _, err := resealApprove(dir, fixtureWho, r.ID, code); err == nil {
|
|
t.Fatalf("approved with code %q", code)
|
|
}
|
|
m, _ := resealRead(dir)
|
|
if st := m[r.ID]; st.State != "pending" || st.Resealed != "" {
|
|
t.Fatalf("a refused approval changed the request: %+v", st)
|
|
}
|
|
}
|
|
done, err := resealApprove(dir, fixtureWho, r.ID, good)
|
|
if err != nil {
|
|
t.Fatalf("the right code was refused: %v", err)
|
|
}
|
|
if done.State != "done" || !bytes.Equal(armorOpen(t, []byte(done.Resealed), host), fx.plain) {
|
|
t.Fatalf("approved with the right code, but: %+v", done.State)
|
|
}
|
|
}
|
|
|
|
// Labels that cannot ride the JSON frame intact are refused by the check
|
|
// itself: invalid UTF-8 is not a label.
|
|
func TestResealLabelCheck(t *testing.T) {
|
|
for _, ok := range []string{"", "my new host", "hôte n°2 — été"} {
|
|
if !resealLabelOK(ok) {
|
|
t.Errorf("%q refused", ok)
|
|
}
|
|
}
|
|
for _, bad := range []string{"host\xff", "ho\x1bst", "ho\u200bst", "ho\u2066st", "a\u2029b", strings.Repeat("é", 61)} {
|
|
if resealLabelOK(bad) {
|
|
t.Errorf("%q accepted", bad)
|
|
}
|
|
}
|
|
}
|