package release import ( "context" "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "fmt" "os" "time" ) // PromotedPrefix is every promotion record of an app: what PurgeApp sweeps. func PromotedPrefix(acct, app string) string { return "releases/" + acct + "/" + app + "/promoted/" } // PromotionRecordKey is the durable record of one promoted build (spec §4.1). // It sits outside AppPrefix, beside the account's pointer digest marker, so // PurgeApp sweeps it explicitly. func PromotionRecordKey(acct, app, version string) string { return PromotedPrefix(acct, app) + version } // PromotionRecord binds a version name to the exact manifest that was // promoted (or explicitly authorized). It keeps a build authorized after it // falls out of the pointer's history, and a reused name cannot inherit it: // the new name's manifest hashes to another digest. type PromotionRecord struct { Schema int `json:"schema"` // 1 Account string `json:"account"` App string `json:"app"` Version string `json:"version"` ManifestDigest string `json:"manifest_digest"` Channel string `json:"channel,omitempty"` Authorized bool `json:"authorized,omitempty"` Actor string `json:"actor,omitempty"` At time.Time `json:"at"` } // ErrNoPromotionRecord is a build with no record: promoted before records // existed, or never promoted. It wraps os.ErrNotExist. var ErrNoPromotionRecord = fmt.Errorf("release: no promotion record: %w", os.ErrNotExist) func validateRecord(r *PromotionRecord, acct, app, version string) error { if r.Schema != 1 { return fmt.Errorf("unsupported promotion record schema %d", r.Schema) } if r.Account != acct || r.App != app || r.Version != version { return fmt.Errorf("promotion record names %s/%s/%s, expected %s/%s/%s", r.Account, r.App, r.Version, acct, app, version) } if !ValidAccount(acct) || !ValidApp(app) || !ValidVersion(version) || version == "dev" { return fmt.Errorf("promotion record for invalid %s/%s/%s", acct, app, version) } if !validDigest(r.ManifestDigest) { return fmt.Errorf("promotion record has an invalid manifest digest") } if r.Channel != "" && !ValidChannel(r.Channel) { return fmt.Errorf("promotion record has an invalid channel %q", r.Channel) } if r.Channel == "" && !r.Authorized { return fmt.Errorf("promotion record names no channel and is not an authorization") } if r.At.IsZero() { return fmt.Errorf("promotion record has no time") } return nil } // WritePromotionRecord signs rec, stamped now, and writes it unless the key // already holds a verified record for the same digest: one record per // version, kept from its first promotion. A record for other bytes (a name // pruned and reshipped) is replaced. A scoped signer may write only a record // whose channel its grant covers, and never an authorization. func (sg Signer) WritePromotionRecord(ctx context.Context, s Store, rec PromotionRecord) error { if err := sg.usable(); err != nil { return err } rec.Schema = 1 rec.At = time.Now().UTC().Truncate(time.Second) if err := validateRecord(&rec, rec.Account, rec.App, rec.Version); err != nil { return err } if rec.Channel == "" || rec.Authorized { if sg.scope != nil { return fmt.Errorf("promotion record: an authorization needs the deployment's root key") } } else if err := sg.allows(rec.Channel, rec.At); err != nil { return fmt.Errorf("promotion record: %w", err) } key := PromotionRecordKey(rec.Account, rec.App, rec.Version) _, etag, err := s.Get(ctx, key) switch { case errors.Is(err, os.ErrNotExist): etag = "" case err != nil: return err default: if cur, rerr := ReadPromotionRecord(ctx, s, sg.verifyPub(), rec.Account, rec.App, rec.Version); rerr == nil && cur.ManifestDigest == rec.ManifestDigest { return nil } } payload, err := json.MarshalIndent(rec, "", " ") if err != nil { return err } b64 := base64.StdEncoding.EncodeToString(payload) wire, err := json.MarshalIndent(signedWrapper{ Payload: b64, Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(sg.key, []byte(b64))), Grant: sg.grant, }, "", " ") if err != nil { return err } _, err = s.Put(ctx, key, wire, etag) return err } // ReadPromotionRecord verifies the record at version's key: root -> grant -> // record, the chain verifyPointer checks, and the signed payload must name // this account, app and version. (nil, ErrNoPromotionRecord) when absent; any // other failure is an error that is never read as "absent". func ReadPromotionRecord(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*PromotionRecord, error) { raw, _, err := s.Get(ctx, PromotionRecordKey(acct, app, version)) if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("%s/%s: %w", app, version, ErrNoPromotionRecord) } if err != nil { return nil, err } var sw signedWrapper if err := json.Unmarshal(raw, &sw); err != nil { return nil, fmt.Errorf("promotion record wrapper: %v", err) } signer, scope := pub, (*Grant)(nil) if sw.Grant != nil { g, err := verifyGrant(pub, sw.Grant) if err != nil { return nil, err } gpub, err := ParsePublicKey(g.Pub, "key grant pub") if err != nil { return nil, err } signer, scope = gpub, g } sig, err := base64.StdEncoding.DecodeString(sw.Sig) if err != nil || len(signer) != ed25519.PublicKeySize || !ed25519.Verify(signer, []byte(sw.Payload), sig) { return nil, fmt.Errorf("promotion record %s/%s: signature verification failed", app, version) } payload, err := base64.StdEncoding.DecodeString(sw.Payload) if err != nil { return nil, fmt.Errorf("promotion record payload: %v", err) } var rec PromotionRecord if err := json.Unmarshal(payload, &rec); err != nil { return nil, fmt.Errorf("promotion record payload: %v", err) } if err := validateRecord(&rec, acct, app, version); err != nil { return nil, err } if scope != nil { if rec.Channel == "" || rec.Authorized { return nil, fmt.Errorf("promotion record %s/%s: an authorization signed by a scoped key", app, version) } if err := scope.allows(rec.Channel, rec.At); err != nil { return nil, fmt.Errorf("promotion record %s/%s: %w", app, version, err) } } return &rec, nil } // AuthorizedManifest is §4.1's evidence for every install the settings add: // the build's signed record, and its canonical manifest hashing to the // record's digest. The manifest it returns is the one it checked; the caller // stages from it and never re-reads it. func AuthorizedManifest(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*Manifest, error) { rec, err := ReadPromotionRecord(ctx, s, pub, acct, app, version) if err != nil { return nil, err } m, _, err := CheckManifestDigest(ctx, s, acct, app, version, rec.ManifestDigest) return m, err }