| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // ErrResignChanged is a resign whose channel moved after its plan was shown. |
| 11 | var ErrResignChanged = errors.New("release: the channel moved since the resign plan was made") |
| 12 | |
| 13 | // ResignPlan is what `carlos channels resign` shows the operator, and the |
| 14 | // only thing Resign signs (spec §4.6). Computed[0] is the head, Computed[i+1] |
| 15 | // History[i]: true where the digest was read from the bucket now rather than |
| 16 | // copied from the signed pointer, which is the part the operator confirms. |
| 17 | type ResignPlan struct { |
| 18 | Account, App, Channel string |
| 19 | Version string |
| 20 | Digest string |
| 21 | History []string |
| 22 | HistoryDigests []string |
| 23 | Computed []bool |
| 24 | etag string |
| 25 | } |
| 26 | |
| 27 | // PlanResign reads the channel's verified pointer and fills in a digest for |
| 28 | // every entry it can: a digest the pointer already signs is copied, and any |
| 29 | // other is the bucket's current manifest digest. A history entry whose |
| 30 | // manifest is gone stays "", so rolling back onto it stays refused. A head |
| 31 | // whose manifest is gone cannot be resigned. |
| 32 | func (sg Signer) PlanResign(ctx context.Context, s Store, acct, app, channel string) (*ResignPlan, error) { |
| 33 | if !ValidAccount(acct) || !ValidApp(app) || !ValidChannel(channel) { |
| 34 | return nil, fmt.Errorf("resign: invalid %s/%s/%s", acct, app, channel) |
| 35 | } |
| 36 | if err := sg.allows(channel, time.Now().UTC()); err != nil { |
| 37 | return nil, fmt.Errorf("resign: %w", err) |
| 38 | } |
| 39 | if err := resignMarker(ctx, s, sg, acct); err != nil { |
| 40 | return nil, err |
| 41 | } |
| 42 | key := ChannelKey(acct, app, channel) |
| 43 | cur, etag, err := GetQualified(ctx, s, key) |
| 44 | if err != nil { |
| 45 | return nil, fmt.Errorf("resign: %w", err) |
| 46 | } |
| 47 | old, err := verifyPointer(sg.verifyPub(), cur, acct, app, channel) |
| 48 | if err != nil { |
| 49 | return nil, fmt.Errorf("resign: existing pointer at %s: %w", key, err) |
| 50 | } |
| 51 | plan := &ResignPlan{Account: acct, App: app, Channel: channel, Version: old.Version, |
| 52 | History: old.History, HistoryDigests: alignedHistoryDigests(old), |
| 53 | Computed: make([]bool, len(old.History)+1), etag: etag} |
| 54 | plan.Digest = old.Digest |
| 55 | if plan.Digest == "" { |
| 56 | _, raw, err := GetManifestRaw(ctx, s, acct, app, old.Version) |
| 57 | if err != nil { |
| 58 | return nil, fmt.Errorf(TextResignUnreadable, old.Version) |
| 59 | } |
| 60 | plan.Digest, plan.Computed[0] = ManifestDigest(raw), true |
| 61 | } |
| 62 | for i, v := range old.History { |
| 63 | if plan.HistoryDigests[i] != "" { |
| 64 | continue |
| 65 | } |
| 66 | if _, raw, err := GetManifestRaw(ctx, s, acct, app, v); err == nil { |
| 67 | plan.HistoryDigests[i], plan.Computed[i+1] = ManifestDigest(raw), true |
| 68 | } |
| 69 | } |
| 70 | return plan, nil |
| 71 | } |
| 72 | |
| 73 | // claimRawResign signs the plan's pointer: the same version, history, audit |
| 74 | // trail and fast-track waiver, with the plan's digests, conditional on the |
| 75 | // pointer being exactly the one the plan was made from. Resign and |
| 76 | // ResignAuthorized are this under the app mutation. |
| 77 | // |
| 78 | // authorize, when non-nil, is the caller's release authority over the |
| 79 | // channel, asked of the pipeline read after the pointer, inside the same |
| 80 | // admitted mutation, exactly as claimRawRollbackAuthorized asks it: resign |
| 81 | // binds bytes to a version name, so it is a release decision (Astra r1 #2). |
| 82 | // A refusal comes back as an AuthorizationError and nothing is written. |
| 83 | func (sg Signer) claimRawResign(ctx context.Context, s Store, plan *ResignPlan, actor string, authorize func(*Pipeline) error) error { |
| 84 | if plan == nil { |
| 85 | return errors.New("resign: no plan to sign") |
| 86 | } |
| 87 | if !ValidAccount(plan.Account) || !ValidApp(plan.App) || !ValidChannel(plan.Channel) { |
| 88 | return fmt.Errorf("resign: invalid %s/%s/%s", plan.Account, plan.App, plan.Channel) |
| 89 | } |
| 90 | // Checked again at signing: the marker may have been turned off since |
| 91 | // the plan was shown. |
| 92 | if err := resignMarker(ctx, s, sg, plan.Account); err != nil { |
| 93 | return err |
| 94 | } |
| 95 | key := ChannelKey(plan.Account, plan.App, plan.Channel) |
| 96 | cur, etag, err := GetQualified(ctx, s, key) |
| 97 | if err != nil { |
| 98 | return fmt.Errorf("resign: %w", err) |
| 99 | } |
| 100 | if etag != plan.etag { |
| 101 | return fmt.Errorf("%w: %s", ErrResignChanged, fmt.Sprintf(TextResignChanged, plan.Channel)) |
| 102 | } |
| 103 | old, err := verifyPointer(sg.verifyPub(), cur, plan.Account, plan.App, plan.Channel) |
| 104 | if err != nil { |
| 105 | return fmt.Errorf("resign: existing pointer at %s: %w", key, err) |
| 106 | } |
| 107 | if authorize != nil { |
| 108 | pipe, perr := GetPipeline(ctx, s, sg.verifyPub(), plan.Account, plan.App) |
| 109 | if perr != nil { |
| 110 | return fmt.Errorf("resign: cannot read the pipeline for %s/%s: %w", plan.Account, plan.App, perr) |
| 111 | } |
| 112 | if aerr := authorize(pipe); aerr != nil { |
| 113 | return &AuthorizationError{Err: aerr} |
| 114 | } |
| 115 | } |
| 116 | next := *old |
| 117 | // A pointer from before the account binding carries no account, which the |
| 118 | // verifier accepts and the signer refuses. A resigned pointer is a new |
| 119 | // signed statement, so it is bound to the account it was verified under. |
| 120 | next.Account = plan.Account |
| 121 | next.Digest, next.HistoryDigests = plan.Digest, plan.HistoryDigests |
| 122 | if len(next.HistoryDigests) == 0 { |
| 123 | next.HistoryDigests = nil |
| 124 | } |
| 125 | if old.FastTrack && sg.scope != nil { |
| 126 | return fmt.Errorf("resign: %s carries a fast-track waiver, which only the root key may sign", plan.Channel) |
| 127 | } |
| 128 | wrapper, err := signPointer(sg, &next) |
| 129 | if err != nil { |
| 130 | return err |
| 131 | } |
| 132 | if _, err := s.Put(ctx, key, wrapper, etag); err != nil { |
| 133 | if errors.Is(err, ErrCASConflict) { |
| 134 | return fmt.Errorf("%w: %s", ErrResignChanged, fmt.Sprintf(TextResignChanged, plan.Channel)) |
| 135 | } |
| 136 | return err |
| 137 | } |
| 138 | return nil |
| 139 | } |
| 140 | |
| 141 | // resignMarker is resign's gate (Astra I3 #4, spec §4.6 and §8): the |
| 142 | // account's emission marker must exist and verify against the root, the |
| 143 | // operator's statement that every writer is upgraded. Without it a resigned |
| 144 | // pointer would arm the boxes' ratchets while old writers still write |
| 145 | // pointers without digests, and every such write would then be refused. |
| 146 | func resignMarker(ctx context.Context, s Store, sg Signer, acct string) error { |
| 147 | m, err := ReadDigestMarker(ctx, s, sg.verifyPub(), acct) |
| 148 | if err != nil { |
| 149 | return fmt.Errorf(TextMarkerUnreadable, "resign", acct, err) |
| 150 | } |
| 151 | if m == nil { |
| 152 | return fmt.Errorf(TextResignNoMarker, acct, acct) |
| 153 | } |
| 154 | return nil |
| 155 | } |
| 156 | |