| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdh" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/appconfig" |
| 12 | "github.com/carlosframework/platform/internal/objstore" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | func scanSecretHash(v appconfig.Var) string { |
| 17 | if v.Kind != "secret" || v.Envelope == "" { |
| 18 | return "" |
| 19 | } |
| 20 | raw, _ := json.Marshal(v) |
| 21 | digest := sha256.Sum256(raw) |
| 22 | return hex.EncodeToString(digest[:]) |
| 23 | } |
| 24 | |
| 25 | func (s *Server) callbackKeyCurrent(ctx context.Context, acct, app string, rec *objstore.Record) (bool, error) { |
| 26 | if rec == nil || rec.Declared == nil || rec.Scanning == nil || rec.Scanning.Enabled == nil || |
| 27 | rec.Scanning.WebhookKeyID != s.cfg.CallbackSigner.CurrentKeyID() || rec.Scanning.WebhookConfigHash == "" { |
| 28 | return false, nil |
| 29 | } |
| 30 | manifest, err := appconfig.Get(ctx, s.cfg.Releases, acct, app) |
| 31 | if err != nil { |
| 32 | return false, err |
| 33 | } |
| 34 | return scanSecretHash(manifest.Vars[rec.Declared.EnvPrefix+"_SCAN_CALLBACK_SECRET"]) == rec.Scanning.WebhookConfigHash, nil |
| 35 | } |
| 36 | |
| 37 | func (s *Server) deliverCallbackRevision(ctx context.Context, pub *ecdh.PublicKey, acct, app string, rec *objstore.Record, gen int) (string, error) { |
| 38 | if s.cfg.CallbackSigner == nil || rec.Declared == nil { |
| 39 | return "", ErrNoCallbackKey |
| 40 | } |
| 41 | envelope, err := appconfig.Seal(pub, appconfig.Binding{Account: acct, App: app}, s.cfg.CallbackSigner.DerivedKey(acct, app, gen)) |
| 42 | if err != nil { |
| 43 | return "", err |
| 44 | } |
| 45 | v := appconfig.Var{Kind: "secret", Envelope: envelope} |
| 46 | _, err = appconfig.Apply(ctx, s.cfg.Releases, acct, app, map[string]appconfig.Var{ |
| 47 | rec.Declared.EnvPrefix + "_SCAN_CALLBACK_SECRET": v, |
| 48 | }, nil) |
| 49 | return scanSecretHash(v), err |
| 50 | } |
| 51 | |
| 52 | // The completion record names the exact sealed value. A concurrent console |
| 53 | // can overwrite config after a successful rotation, even when the master ID |
| 54 | // is unchanged; the next pass must detect and repair that incomplete state. |
| 55 | func (s *Server) convergeCallbackKey(ctx context.Context, pub *ecdh.PublicKey, acct *Account, app string) (changed bool, result *objstore.Record, err error) { |
| 56 | for attempt := 0; attempt < 4; attempt++ { |
| 57 | tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app) |
| 58 | if err != nil { |
| 59 | return changed, nil, err |
| 60 | } |
| 61 | if tomb != nil { |
| 62 | return changed, nil, objstore.ErrQueueNotPermitted |
| 63 | } |
| 64 | rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| 65 | if err != nil { |
| 66 | return changed, nil, err |
| 67 | } |
| 68 | if rec == nil || rec.Declared == nil || rec.Provisioned == nil || rec.Scanning == nil || rec.Scanning.Enabled == nil { |
| 69 | return changed, rec, objstore.ErrQueueNotPermitted |
| 70 | } |
| 71 | if current, err := s.callbackKeyCurrent(ctx, acct.Sqid, app, rec); err != nil || current { |
| 72 | return changed, rec, err |
| 73 | } |
| 74 | gen, prefix := rec.Scanning.WebhookGen, rec.Declared.EnvPrefix |
| 75 | hash, err := s.deliverCallbackRevision(ctx, pub, acct.Sqid, app, rec, gen) |
| 76 | if errors.Is(err, release.ErrCASConflict) { |
| 77 | continue |
| 78 | } |
| 79 | if err != nil { |
| 80 | return changed, rec, err |
| 81 | } |
| 82 | changed = true |
| 83 | _, err = s.writeScanRecord(ctx, acct, app, func(fresh *objstore.Record) error { |
| 84 | if fresh.Declared == nil || fresh.Scanning == nil || fresh.Scanning.Enabled == nil { |
| 85 | return objstore.ErrQueueNotPermitted |
| 86 | } |
| 87 | if fresh.Scanning.WebhookGen != gen || fresh.Declared.EnvPrefix != prefix { |
| 88 | return release.ErrCASConflict |
| 89 | } |
| 90 | fresh.Scanning.WebhookKeyID = s.cfg.CallbackSigner.CurrentKeyID() |
| 91 | fresh.Scanning.WebhookConfigHash = hash |
| 92 | return nil |
| 93 | }) |
| 94 | if errors.Is(err, objstore.ErrQueueNotPermitted) { |
| 95 | if _, cleanupErr := appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, nil, []string{prefix + "_SCAN_CALLBACK_SECRET"}); cleanupErr != nil { |
| 96 | return changed, rec, cleanupErr |
| 97 | } |
| 98 | continue |
| 99 | } |
| 100 | if err != nil && !errors.Is(err, release.ErrCASConflict) { |
| 101 | return changed, rec, err |
| 102 | } |
| 103 | } |
| 104 | return changed, nil, release.ErrCASConflict |
| 105 | } |
| 106 | |