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 and // ResignAuthorized are this under the app mutation. // // authorize, when non-nil, is the caller's release authority over the // channel, asked of the pipeline read after the pointer, inside the same // admitted mutation, exactly as claimRawRollbackAuthorized asks it: resign // binds bytes to a version name, so it is a release decision (Astra r1 #2). // A refusal comes back as an AuthorizationError and nothing is written. func (sg Signer) claimRawResign(ctx context.Context, s Store, plan *ResignPlan, actor string, authorize func(*Pipeline) error) error { if plan == nil { return errors.New("resign: no plan to sign") } if !ValidAccount(plan.Account) || !ValidApp(plan.App) || !ValidChannel(plan.Channel) { return fmt.Errorf("resign: invalid %s/%s/%s", plan.Account, plan.App, plan.Channel) } // 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) } if authorize != nil { pipe, perr := GetPipeline(ctx, s, sg.verifyPub(), plan.Account, plan.App) if perr != nil { return fmt.Errorf("resign: cannot read the pipeline for %s/%s: %w", plan.Account, plan.App, perr) } if aerr := authorize(pipe); aerr != nil { return &AuthorizationError{Err: aerr} } } next := *old // A pointer from before the account binding carries no account, which the // verifier accepts and the signer refuses. A resigned pointer is a new // signed statement, so it is bound to the account it was verified under. next.Account = plan.Account 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 }