settings-1 task 10: resign with operator-confirmed digests, and the digest report
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+408
−1
internal/release/claim_app_writes.go+14 −1internal/release/digests.go+57 −0internal/release/resign.go+133 −0internal/release/resign_test.go+204 −0
diff --git a/internal/release/claim_app_writes.go b/internal/release/claim_app_writes.go| index 9fd02a729..41edc3114 100644 |
| --- a/internal/release/claim_app_writes.go |
| +++ b/internal/release/claim_app_writes.go |
| @@ -1,6 +1,9 @@ |
| package release |
| -import "context" |
| +import ( |
| + "context" |
| + "errors" |
| +) |
| // The signed-pointer and ship entry points below run as one app mutation: |
| // on an enabled console store the whole read, authorize and conditional |
| @@ -41,3 +44,13 @@ func (sg Signer) RollbackAuthorized(ctx context.Context, s Store, acct, app, cha |
| return sg.claimRawRollbackAuthorized(ctx, s, acct, app, channel, authorize) |
| }) |
| } |
| + |
| +// Resign is claimRawResign under one app mutation. |
| +func (sg Signer) Resign(ctx context.Context, s Store, plan *ResignPlan, actor string) error { |
| + if plan == nil { |
| + return errors.New("resign: no plan to sign") |
| + } |
| + return WithAppMutation(ctx, s, plan.Account, plan.App, "release.Resign", func(ctx context.Context) error { |
| + return sg.claimRawResign(ctx, s, plan, actor) |
| + }) |
| +} |
diff --git a/internal/release/digests.go b/internal/release/digests.go| index d13f827d5..040856b2b 100644 |
| --- a/internal/release/digests.go |
| +++ b/internal/release/digests.go |
| @@ -2,9 +2,12 @@ package release |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "crypto/sha256" |
| "errors" |
| "fmt" |
| + "strings" |
| + "time" |
| ) |
| // ManifestDigest is the sha256 of a manifest's canonical bytes: exactly what |
| @@ -125,3 +128,57 @@ type ResignNeededError struct{ App, Channel, Version string } |
| func (e *ResignNeededError) Error() string { |
| return fmt.Sprintf(TextRollbackResign, e.App, e.Channel, e.Version, e.App, e.Channel) |
| } |
| + |
| +// ChannelDigestState is one channel's line in the straggler report (spec |
| +// §4.6 "Watch for stragglers"). |
| +type ChannelDigestState struct { |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + PromotedAt time.Time `json:"promoted_at"` |
| + HeadDigest bool `json:"head_digest"` |
| + HistoryMissing int `json:"history_missing"` |
| + Straggler bool `json:"straggler"` |
| +} |
| + |
| +// DigestStateOf is a verified pointer's line. A straggler is a pointer |
| +// without digests signed after the marker went on: an old writer is still in |
| +// use. |
| +func DigestStateOf(p *ChannelPointer, marker *DigestMarker) ChannelDigestState { |
| + st := ChannelDigestState{Channel: p.Channel, Version: p.Version, PromotedAt: p.PromotedAt, HeadDigest: p.CarriesDigests()} |
| + hd := alignedHistoryDigests(p) |
| + for _, d := range hd { |
| + if d == "" { |
| + st.HistoryMissing++ |
| + } |
| + } |
| + st.Straggler = marker != nil && !st.HeadDigest && p.PromotedAt.After(marker.At) |
| + return st |
| +} |
| + |
| +// DigestReport is every channel of one app, plus the account's marker. A |
| +// pointer that does not verify is skipped here: `carlos channels` already |
| +// shows it as an error row. |
| +func DigestReport(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) ([]ChannelDigestState, *DigestMarker, error) { |
| + marker, err := ReadDigestMarker(ctx, s, pub, acct) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + prefix := ChannelsPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + var out []ChannelDigestState |
| + for _, k := range keys { |
| + name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json") |
| + if !ok || name == "" { |
| + continue |
| + } |
| + p, err := Channel(ctx, s, pub, acct, app, name) |
| + if err != nil { |
| + continue |
| + } |
| + out = append(out, DigestStateOf(p, marker)) |
| + } |
| + return out, marker, nil |
| +} |
diff --git a/internal/release/resign.go b/internal/release/resign.go| new file mode 100644 |
| index 000000000..3df884f2d |
| --- /dev/null |
| +++ b/internal/release/resign.go |
| @@ -0,0 +1,133 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "time" |
| +) |
| + |
| +// ErrResignChanged is a resign whose channel moved after its plan was shown. |
| +var ErrResignChanged = errors.New("release: the channel moved since the resign plan was made") |
| + |
| +// ResignPlan is what `carlos channels resign` shows the operator, and the |
| +// only thing Resign signs (spec §4.6). Computed[0] is the head, Computed[i+1] |
| +// History[i]: true where the digest was read from the bucket now rather than |
| +// copied from the signed pointer, which is the part the operator confirms. |
| +type ResignPlan struct { |
| + Account, App, Channel string |
| + Version string |
| + Digest string |
| + History []string |
| + HistoryDigests []string |
| + Computed []bool |
| + etag string |
| +} |
| + |
| +// PlanResign reads the channel's verified pointer and fills in a digest for |
| +// every entry it can: a digest the pointer already signs is copied, and any |
| +// other is the bucket's current manifest digest. A history entry whose |
| +// manifest is gone stays "", so rolling back onto it stays refused. A head |
| +// whose manifest is gone cannot be resigned. |
| +func (sg Signer) PlanResign(ctx context.Context, s Store, acct, app, channel string) (*ResignPlan, error) { |
| + if !ValidAccount(acct) || !ValidApp(app) || !ValidChannel(channel) { |
| + return nil, fmt.Errorf("resign: invalid %s/%s/%s", acct, app, channel) |
| + } |
| + if err := sg.allows(channel, time.Now().UTC()); err != nil { |
| + return nil, fmt.Errorf("resign: %w", err) |
| + } |
| + if err := resignMarker(ctx, s, sg, acct); err != nil { |
| + return nil, err |
| + } |
| + key := ChannelKey(acct, app, channel) |
| + cur, etag, err := GetQualified(ctx, s, key) |
| + if err != nil { |
| + return nil, fmt.Errorf("resign: %w", err) |
| + } |
| + old, err := verifyPointer(sg.verifyPub(), cur, acct, app, channel) |
| + if err != nil { |
| + return nil, fmt.Errorf("resign: existing pointer at %s: %w", key, err) |
| + } |
| + plan := &ResignPlan{Account: acct, App: app, Channel: channel, Version: old.Version, |
| + History: old.History, HistoryDigests: alignedHistoryDigests(old), |
| + Computed: make([]bool, len(old.History)+1), etag: etag} |
| + plan.Digest = old.Digest |
| + if plan.Digest == "" { |
| + _, raw, err := GetManifestRaw(ctx, s, acct, app, old.Version) |
| + if err != nil { |
| + return nil, fmt.Errorf(TextResignUnreadable, old.Version) |
| + } |
| + plan.Digest, plan.Computed[0] = ManifestDigest(raw), true |
| + } |
| + for i, v := range old.History { |
| + if plan.HistoryDigests[i] != "" { |
| + continue |
| + } |
| + if _, raw, err := GetManifestRaw(ctx, s, acct, app, v); err == nil { |
| + plan.HistoryDigests[i], plan.Computed[i+1] = ManifestDigest(raw), true |
| + } |
| + } |
| + return plan, nil |
| +} |
| + |
| +// claimRawResign signs the plan's pointer: the same version, history, audit |
| +// trail and fast-track waiver, with the plan's digests, conditional on the |
| +// pointer being exactly the one the plan was made from. Resign is this under |
| +// the app mutation. |
| +func (sg Signer) claimRawResign(ctx context.Context, s Store, plan *ResignPlan, actor string) error { |
| + if plan == nil { |
| + return errors.New("resign: no plan to sign") |
| + } |
| + // Checked again at signing: the marker may have been turned off since |
| + // the plan was shown. |
| + if err := resignMarker(ctx, s, sg, plan.Account); err != nil { |
| + return err |
| + } |
| + key := ChannelKey(plan.Account, plan.App, plan.Channel) |
| + cur, etag, err := GetQualified(ctx, s, key) |
| + if err != nil { |
| + return fmt.Errorf("resign: %w", err) |
| + } |
| + if etag != plan.etag { |
| + return fmt.Errorf("%w: %s", ErrResignChanged, fmt.Sprintf(TextResignChanged, plan.Channel)) |
| + } |
| + old, err := verifyPointer(sg.verifyPub(), cur, plan.Account, plan.App, plan.Channel) |
| + if err != nil { |
| + return fmt.Errorf("resign: existing pointer at %s: %w", key, err) |
| + } |
| + next := *old |
| + next.Digest, next.HistoryDigests = plan.Digest, plan.HistoryDigests |
| + if len(next.HistoryDigests) == 0 { |
| + next.HistoryDigests = nil |
| + } |
| + if old.FastTrack && sg.scope != nil { |
| + return fmt.Errorf("resign: %s carries a fast-track waiver, which only the root key may sign", plan.Channel) |
| + } |
| + wrapper, err := signPointer(sg, &next) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, wrapper, etag); err != nil { |
| + if errors.Is(err, ErrCASConflict) { |
| + return fmt.Errorf("%w: %s", ErrResignChanged, fmt.Sprintf(TextResignChanged, plan.Channel)) |
| + } |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// resignMarker is resign's gate (Astra I3 #4, spec §4.6 and §8): the |
| +// account's emission marker must exist and verify against the root, the |
| +// operator's statement that every writer is upgraded. Without it a resigned |
| +// pointer would arm the boxes' ratchets while old writers still write |
| +// pointers without digests, and every such write would then be refused. |
| +func resignMarker(ctx context.Context, s Store, sg Signer, acct string) error { |
| + m, err := ReadDigestMarker(ctx, s, sg.verifyPub(), acct) |
| + if err != nil { |
| + return fmt.Errorf(TextMarkerUnreadable, "resign", acct, err) |
| + } |
| + if m == nil { |
| + return fmt.Errorf(TextResignNoMarker, acct, acct) |
| + } |
| + return nil |
| +} |
diff --git a/internal/release/resign_test.go b/internal/release/resign_test.go| new file mode 100644 |
| index 000000000..9ec390d6a |
| --- /dev/null |
| +++ b/internal/release/resign_test.go |
| @@ -0,0 +1,204 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestResignSignsTheDigestsTheOperatorSaw(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two")) |
| + for _, v := range []string{"v1", "v2"} { |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", v); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + markerOn(t, s, priv, "bab") // every writer upgraded; the old pointer still has no digests |
| + sg := RootSigner(priv) |
| + plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if plan.Digest != manifestDigestOf(t, s, "bab", "km", "v2") || plan.HistoryDigests[0] != manifestDigestOf(t, s, "bab", "km", "v1") || |
| + !plan.Computed[0] || !plan.Computed[1] { |
| + t.Fatalf("plan = %+v", plan) |
| + } |
| + if p := readPtr(t, s, pub, "stable"); p.Digest != "" { |
| + t.Fatal("PlanResign wrote something") |
| + } |
| + if err := sg.Resign(ctx, s, plan, "ops@example.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := readPtr(t, s, pub, "stable") |
| + if p.Version != "v2" || p.Digest != plan.Digest || p.HistoryDigests[0] != plan.HistoryDigests[0] { |
| + t.Fatalf("resigned pointer = %+v", p) |
| + } |
| + // Once resigned, the rollback the spec refused is allowed. |
| + if err := Rollback(ctx, s, priv, "bab", "km", "stable"); err != nil { |
| + t.Fatalf("rollback after resign: %v", err) |
| + } |
| +} |
| + |
| +func TestResignCopiesSignedDigestsAndLeavesGoneBuildsEmpty(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two")) |
| + shipRelease(t, s, "bab", "km", "v3", adoptPlatform, []byte("three")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + markerOn(t, s, priv, "bab") |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v3"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + plan, err := RootSigner(priv).PlanResign(ctx, s, "bab", "km", "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // head v3 signed, v2 inherited "" (computed now), v1 gone. |
| + if plan.Computed[0] || !plan.Computed[1] || plan.HistoryDigests[1] != "" || plan.Computed[2] { |
| + t.Fatalf("plan = %+v", plan) |
| + } |
| +} |
| + |
| +func TestResignRefusesAChannelThatMoved(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + markerOn(t, s, priv, "bab") |
| + sg := RootSigner(priv) |
| + plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := sg.Resign(ctx, s, plan, "ops"); !errors.Is(err, ErrResignChanged) { |
| + t.Fatalf("resign over a moved channel = %v, want ErrResignChanged", err) |
| + } |
| +} |
| + |
| +func TestResignRefusesAHeadWithNoManifest(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + markerOn(t, s, priv, "bab") |
| + if _, err := RootSigner(priv).PlanResign(ctx, s, "bab", "km", "stable"); err == nil || err.Error() != fmt.Sprintf(TextResignUnreadable, "v1") { |
| + t.Fatalf("a head with no manifest was planned: %v", err) |
| + } |
| +} |
| + |
| +// Astra I3 #4: resign arms every box's ratchet for the channel, so it needs |
| +// the verified marker (every writer upgraded), at plan time and at signing. |
| +func TestResignRequiresTheVerifiedMarker(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + sg := RootSigner(priv) |
| + if _, err := sg.PlanResign(ctx, s, "bab", "km", "stable"); err == nil || err.Error() != fmt.Sprintf(TextResignNoMarker, "bab", "bab") { |
| + t.Fatalf("PlanResign with no marker = %v, want the no-marker refusal", err) |
| + } |
| + markerOn(t, s, priv, "bab") |
| + plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RemoveDigestMarker(ctx, s, "bab"); err != nil { // turned off between plan and confirmation |
| + t.Fatal(err) |
| + } |
| + if err := sg.Resign(ctx, s, plan, "ops"); err == nil || err.Error() != fmt.Sprintf(TextResignNoMarker, "bab", "bab") { |
| + t.Fatalf("Resign with the marker gone = %v, want the no-marker refusal", err) |
| + } |
| + foreign, _ := testKeys(t) |
| + markerOn(t, s, foreign, "bab") // present, but not root-signed |
| + if err := sg.Resign(ctx, s, plan, "ops"); err == nil || !strings.Contains(err.Error(), "marker") { |
| + t.Fatalf("Resign with an unverifiable marker = %v", err) |
| + } |
| + if p := readPtr(t, s, pub, "stable"); p.Digest != "" { |
| + t.Fatal("a refused resign wrote digests") |
| + } |
| +} |
| + |
| +func TestResignRefusesANilPlan(t *testing.T) { |
| + priv, _ := testKeys(t) |
| + if err := RootSigner(priv).Resign(context.Background(), NewDirStore(t.TempDir()), nil, "ops"); err == nil { |
| + t.Fatal("a nil plan was signed") |
| + } |
| +} |
| + |
| +func TestDigestStateOf(t *testing.T) { |
| + at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| + m := &DigestMarker{Schema: 1, Account: "bab", At: at} |
| + legacyBefore := &ChannelPointer{Channel: "a", Version: "v1", PromotedAt: at.Add(-time.Hour), History: []string{"v0"}} |
| + legacyAfter := &ChannelPointer{Channel: "b", Version: "v1", PromotedAt: at.Add(time.Hour)} |
| + signed := &ChannelPointer{Channel: "c", Version: "v2", Digest: dA, PromotedAt: at.Add(time.Hour), History: []string{"v1"}, HistoryDigests: []string{""}} |
| + if st := DigestStateOf(legacyBefore, m); st.HeadDigest || st.Straggler || st.HistoryMissing != 1 { |
| + t.Fatalf("before the marker: %+v", st) |
| + } |
| + if st := DigestStateOf(legacyAfter, m); !st.Straggler { |
| + t.Fatalf("written after the marker without digests: %+v", st) |
| + } |
| + if st := DigestStateOf(legacyAfter, nil); st.Straggler { |
| + t.Fatal("no marker, no stragglers") |
| + } |
| + if st := DigestStateOf(signed, m); !st.HeadDigest || st.Straggler || st.HistoryMissing != 1 { |
| + t.Fatalf("signed: %+v", st) |
| + } |
| +} |
| + |
| +func TestDigestReportListsEveryChannel(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "beta", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + markerOn(t, s, priv, "bab") |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + rows, marker, err := DigestReport(ctx, s, pub, "bab", "km") |
| + if err != nil || marker == nil || len(rows) != 2 { |
| + t.Fatalf("report = %+v, %+v, %v", rows, marker, err) |
| + } |
| + got := map[string]bool{} |
| + for _, r := range rows { |
| + got[r.Channel] = r.HeadDigest |
| + } |
| + if got["beta"] || !got["canary/x"] { |
| + t.Fatalf("report = %+v", rows) |
| + } |
| +} |