| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | func hostRetryKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { |
| 15 | t.Helper() |
| 16 | pub, priv, err := ed25519.GenerateKey(nil) |
| 17 | if err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | return pub, priv |
| 21 | } |
| 22 | |
| 23 | func TestHostRetryGrantRoundTrip(t *testing.T) { |
| 24 | pub, priv := hostRetryKey(t) |
| 25 | now := time.Date(2026, 10, 11, 9, 30, 15, 500, time.UTC) |
| 26 | wire, err := SignHostRetry(RootSigner(priv), "bab", "blog", "a.example.com", "v2", "book-1", 42, now) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | g, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now.Add(time.Hour)) |
| 31 | if err != nil { |
| 32 | t.Fatalf("verify = %v", err) |
| 33 | } |
| 34 | 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"} |
| 35 | if g != want { |
| 36 | t.Fatalf("grant = %+v, want %+v", g, want) |
| 37 | } |
| 38 | // The whole 24-hour window is the backstop: the epoch is what binds. |
| 39 | if _, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now.Add(HostRetryGrantTTL-time.Second)); err != nil { |
| 40 | t.Fatalf("a grant inside its window = %v", err) |
| 41 | } |
| 42 | if len(wire) > MaxRootEnvelopeBytes { |
| 43 | t.Fatalf("wire is %d bytes", len(wire)) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | func TestHostRetryGrantRefusals(t *testing.T) { |
| 48 | pub, priv := hostRetryKey(t) |
| 49 | otherPub, _ := hostRetryKey(t) |
| 50 | now := time.Date(2026, 10, 11, 9, 30, 0, 0, time.UTC) |
| 51 | wire, err := SignHostRetry(RootSigner(priv), "bab", "blog", "a.example.com", "v2", "book-1", 7, now) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | cases := []struct { |
| 56 | name string |
| 57 | pub ed25519.PublicKey |
| 58 | wire string |
| 59 | acct, app, host string |
| 60 | at time.Time |
| 61 | }{ |
| 62 | {"wrong host", pub, wire, "bab", "blog", "b.example.com", now}, |
| 63 | {"wrong app", pub, wire, "bab", "shop", "a.example.com", now}, |
| 64 | {"wrong account", pub, wire, "kass", "blog", "a.example.com", now}, |
| 65 | {"expired", pub, wire, "bab", "blog", "a.example.com", now.Add(HostRetryGrantTTL + time.Second)}, |
| 66 | {"from the future", pub, wire, "bab", "blog", "a.example.com", now.Add(-2 * time.Minute)}, |
| 67 | {"another root", otherPub, wire, "bab", "blog", "a.example.com", now}, |
| 68 | {"no key", nil, wire, "bab", "blog", "a.example.com", now}, |
| 69 | {"empty", pub, "", "bab", "blog", "a.example.com", now}, |
| 70 | {"tampered payload", pub, tamperHostRetry(t, wire, func(g map[string]any) { g["build"] = "v3" }), "bab", "blog", "a.example.com", now}, |
| 71 | {"tampered epoch", pub, tamperHostRetry(t, wire, func(g map[string]any) { g["epoch"] = 8 }), "bab", "blog", "a.example.com", now}, |
| 72 | {"tampered book", pub, tamperHostRetry(t, wire, func(g map[string]any) { g["book"] = "book-2" }), "bab", "blog", "a.example.com", now}, |
| 73 | } |
| 74 | for _, c := range cases { |
| 75 | if g, err := VerifyHostRetry(c.pub, c.wire, c.acct, c.app, c.host, c.at); err == nil { |
| 76 | t.Errorf("%s: verified %+v", c.name, g) |
| 77 | } |
| 78 | } |
| 79 | // A minute of clock skew is allowed the console. |
| 80 | if _, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now.Add(-30*time.Second)); err != nil { |
| 81 | t.Fatalf("thirty seconds of skew = %v", err) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // A grant the root signed with a zero epoch is an older box's report read by |
| 86 | // the console: there is no restriction it can name, so no box acts on it. |
| 87 | func TestHostRetryGrantRefusesAZeroEpoch(t *testing.T) { |
| 88 | pub, priv := hostRetryKey(t) |
| 89 | now := time.Now() |
| 90 | wire, err := SignHostRetry(RootSigner(priv), "bab", "blog", "a.example.com", "v2", "book-1", 0, now) |
| 91 | if err != nil { |
| 92 | t.Fatalf("the console signs a zero epoch: %v", err) |
| 93 | } |
| 94 | if _, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now); !errors.Is(err, ErrHostRetryGrant) { |
| 95 | t.Fatalf("zero epoch = %v, want ErrHostRetryGrant", err) |
| 96 | } |
| 97 | wire, _ = SignHostRetry(RootSigner(priv), "bab", "blog", "a.example.com", "", "book-1", 3, now) |
| 98 | if _, err := VerifyHostRetry(pub, wire, "bab", "blog", "a.example.com", now); err == nil { |
| 99 | t.Fatal("a grant naming no build verified") |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func TestHostRetryGrantRefusesAScopedSigner(t *testing.T) { |
| 104 | pub, priv := hostRetryKey(t) |
| 105 | scoped := Signer{key: priv, scope: &Grant{Name: "ci"}, grant: &signedGrant{}, root: pub} |
| 106 | if _, err := SignHostRetry(scoped, "bab", "blog", "a.example.com", "v2", "book-1", 1, time.Now()); !errors.Is(err, ErrRootEnvelope) { |
| 107 | t.Fatalf("scoped signer = %v, want ErrRootEnvelope", err) |
| 108 | } |
| 109 | if _, err := SignHostRetry(Signer{}, "bab", "blog", "a.example.com", "v2", "book-1", 1, time.Now()); err == nil { |
| 110 | t.Fatal("a signer with no key signed a retry") |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // tamperHostRetry re-encodes wire's payload after edit, keeping the old |
| 115 | // signature. |
| 116 | func tamperHostRetry(t *testing.T, wire string, edit func(map[string]any)) string { |
| 117 | t.Helper() |
| 118 | var w struct { |
| 119 | Payload string `json:"payload"` |
| 120 | Sig string `json:"sig"` |
| 121 | } |
| 122 | if err := json.Unmarshal([]byte(wire), &w); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | raw, err := base64.StdEncoding.DecodeString(w.Payload) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | var g map[string]any |
| 130 | if err := json.Unmarshal(raw, &g); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | edit(g) |
| 134 | // Keep the struct's field order so only the signature can refuse it. |
| 135 | order := []string{"v", "account", "app", "host", "build", "epoch", "issued_at"} |
| 136 | var b strings.Builder |
| 137 | b.WriteString("{") |
| 138 | for i, k := range order { |
| 139 | if i > 0 { |
| 140 | b.WriteString(",") |
| 141 | } |
| 142 | kv, _ := json.Marshal(k) |
| 143 | vv, _ := json.Marshal(g[k]) |
| 144 | b.Write(kv) |
| 145 | b.WriteString(":") |
| 146 | b.Write(vv) |
| 147 | } |
| 148 | b.WriteString("}") |
| 149 | w.Payload = base64.StdEncoding.EncodeToString([]byte(b.String())) |
| 150 | out, _ := json.Marshal(w) |
| 151 | return string(out) |
| 152 | } |
| 153 | |
| 154 | // The restart-scope log takes no signature at all now (grants ride |
| 155 | // host-retry.json, TestValidRestartScopeRefusesARetryAndASignature), and |
| 156 | // the field stays additive for the legacy entries a box still reads. |
| 157 | func TestRestartScopeSignedIsAdditive(t *testing.T) { |
| 158 | if err := ValidRestartScope(RestartScope{Kind: RestartScopeHost, Value: "a.example.com", Signed: "x"}); err == nil { |
| 159 | t.Fatal("a host restart carrying a signature was valid") |
| 160 | } |
| 161 | if _, err := AppendHostRetry(context.Background(), NewDirStore(t.TempDir()), "bab", "blog", "a.example.com", strings.Repeat("x", MaxRootEnvelopeBytes+1)); err == nil { |
| 162 | t.Fatal("an oversized signature was appended") |
| 163 | } |
| 164 | // The field is additive: an older entry has none, and an older reader's |
| 165 | // lax parse steps over it. |
| 166 | raw, _ := json.Marshal(RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}) |
| 167 | if strings.Contains(string(raw), "signed") { |
| 168 | t.Fatalf("an unsigned scope wrote %s", raw) |
| 169 | } |
| 170 | } |
| 171 | |