package release import ( "context" "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "strings" "testing" "time" ) func hostRetryKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { t.Helper() pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } return pub, priv } func TestHostRetryGrantRoundTrip(t *testing.T) { pub, priv := hostRetryKey(t) now := time.Date(2026, 10, 11, 9, 30, 15, 500, time.UTC) wire, err := SignHostRetry(RootSigner(priv), "bab", "blog", "a.example.com", "v2", "book-1", 42, now) if err != nil { t.Fatal(err) } g, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now.Add(time.Hour)) if err != nil { t.Fatalf("verify = %v", err) } want := HostRetryGrant{V: 1, Account: "bab", App: "blog", Host: "a.example.com", Build: "v2", Epoch: 42, Book: "book-1", IssuedAt: "2026-10-11T09:30:15Z"} if g != want { t.Fatalf("grant = %+v, want %+v", g, want) } // The whole 24-hour window is the backstop: the epoch is what binds. if _, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now.Add(HostRetryGrantTTL-time.Second)); err != nil { t.Fatalf("a grant inside its window = %v", err) } if len(wire) > MaxRootEnvelopeBytes { t.Fatalf("wire is %d bytes", len(wire)) } } func TestHostRetryGrantRefusals(t *testing.T) { pub, priv := hostRetryKey(t) otherPub, _ := hostRetryKey(t) now := time.Date(2026, 10, 11, 9, 30, 0, 0, time.UTC) wire, err := SignHostRetry(RootSigner(priv), "bab", "blog", "a.example.com", "v2", "book-1", 7, now) if err != nil { t.Fatal(err) } cases := []struct { name string pub ed25519.PublicKey wire string acct, app, host string at time.Time }{ {"wrong host", pub, wire, "bab", "blog", "b.example.com", now}, {"wrong app", pub, wire, "bab", "shop", "a.example.com", now}, {"wrong account", pub, wire, "kass", "blog", "a.example.com", now}, {"expired", pub, wire, "bab", "blog", "a.example.com", now.Add(HostRetryGrantTTL + time.Second)}, {"from the future", pub, wire, "bab", "blog", "a.example.com", now.Add(-2 * time.Minute)}, {"another root", otherPub, wire, "bab", "blog", "a.example.com", now}, {"no key", nil, wire, "bab", "blog", "a.example.com", now}, {"empty", pub, "", "bab", "blog", "a.example.com", now}, {"tampered payload", pub, tamperHostRetry(t, wire, func(g map[string]any) { g["build"] = "v3" }), "bab", "blog", "a.example.com", now}, {"tampered epoch", pub, tamperHostRetry(t, wire, func(g map[string]any) { g["epoch"] = 8 }), "bab", "blog", "a.example.com", now}, {"tampered book", pub, tamperHostRetry(t, wire, func(g map[string]any) { g["book"] = "book-2" }), "bab", "blog", "a.example.com", now}, } for _, c := range cases { if g, err := VerifyHostRetry(c.pub, c.wire, c.acct, c.app, c.host, c.at); err == nil { t.Errorf("%s: verified %+v", c.name, g) } } // A minute of clock skew is allowed the console. if _, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now.Add(-30*time.Second)); err != nil { t.Fatalf("thirty seconds of skew = %v", err) } } // A grant the root signed with a zero epoch is an older box's report read by // the console: there is no restriction it can name, so no box acts on it. func TestHostRetryGrantRefusesAZeroEpoch(t *testing.T) { pub, priv := hostRetryKey(t) now := time.Now() wire, err := SignHostRetry(RootSigner(priv), "bab", "blog", "a.example.com", "v2", "book-1", 0, now) if err != nil { t.Fatalf("the console signs a zero epoch: %v", err) } if _, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now); !errors.Is(err, ErrHostRetryGrant) { t.Fatalf("zero epoch = %v, want ErrHostRetryGrant", err) } wire, _ = SignHostRetry(RootSigner(priv), "bab", "blog", "a.example.com", "", "book-1", 3, now) if _, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now); err == nil { t.Fatal("a grant naming no build verified") } } func TestHostRetryGrantRefusesAScopedSigner(t *testing.T) { pub, priv := hostRetryKey(t) scoped := Signer{key: priv, scope: &Grant{Name: "ci"}, grant: &signedGrant{}, root: pub} if _, err := SignHostRetry(scoped, "bab", "blog", "a.example.com", "v2", "book-1", 1, time.Now()); !errors.Is(err, ErrRootEnvelope) { t.Fatalf("scoped signer = %v, want ErrRootEnvelope", err) } if _, err := SignHostRetry(Signer{}, "bab", "blog", "a.example.com", "v2", "book-1", 1, time.Now()); err == nil { t.Fatal("a signer with no key signed a retry") } } // tamperHostRetry re-encodes wire's payload after edit, keeping the old // signature. func tamperHostRetry(t *testing.T, wire string, edit func(map[string]any)) string { t.Helper() var w struct { Payload string `json:"payload"` Sig string `json:"sig"` } if err := json.Unmarshal([]byte(wire), &w); err != nil { t.Fatal(err) } raw, err := base64.StdEncoding.DecodeString(w.Payload) if err != nil { t.Fatal(err) } var g map[string]any if err := json.Unmarshal(raw, &g); err != nil { t.Fatal(err) } edit(g) // Keep the struct's field order so only the signature can refuse it. order := []string{"v", "account", "app", "host", "build", "epoch", "issued_at"} var b strings.Builder b.WriteString("{") for i, k := range order { if i > 0 { b.WriteString(",") } kv, _ := json.Marshal(k) vv, _ := json.Marshal(g[k]) b.Write(kv) b.WriteString(":") b.Write(vv) } b.WriteString("}") w.Payload = base64.StdEncoding.EncodeToString([]byte(b.String())) out, _ := json.Marshal(w) return string(out) } // The restart-scope log takes no signature at all now (grants ride // host-retry.json, TestValidRestartScopeRefusesARetryAndASignature), and // the field stays additive for the legacy entries a box still reads. func TestRestartScopeSignedIsAdditive(t *testing.T) { if err := ValidRestartScope(RestartScope{Kind: RestartScopeHost, Value: "a.example.com", Signed: "x"}); err == nil { t.Fatal("a host restart carrying a signature was valid") } if _, err := AppendHostRetry(context.Background(), NewDirStore(t.TempDir()), "bab", "blog", "a.example.com", strings.Repeat("x", MaxRootEnvelopeBytes+1)); err == nil { t.Fatal("an oversized signature was appended") } // The field is additive: an older entry has none, and an older reader's // lax parse steps over it. raw, _ := json.Marshal(RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}) if strings.Contains(string(raw), "signed") { t.Fatalf("an unsigned scope wrote %s", raw) } }