package console import ( "context" "crypto/ecdh" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) func scanSecretHash(v appconfig.Var) string { if v.Kind != "secret" || v.Envelope == "" { return "" } raw, _ := json.Marshal(v) digest := sha256.Sum256(raw) return hex.EncodeToString(digest[:]) } func (s *Server) callbackKeyCurrent(ctx context.Context, acct, app string, rec *objstore.Record) (bool, error) { if rec == nil || rec.Declared == nil || rec.Scanning == nil || rec.Scanning.Enabled == nil || rec.Scanning.WebhookKeyID != s.cfg.CallbackSigner.CurrentKeyID() || rec.Scanning.WebhookConfigHash == "" { return false, nil } manifest, err := appconfig.Get(ctx, s.cfg.Releases, acct, app) if err != nil { return false, err } return scanSecretHash(manifest.Vars[rec.Declared.EnvPrefix+"_SCAN_CALLBACK_SECRET"]) == rec.Scanning.WebhookConfigHash, nil } func (s *Server) deliverCallbackRevision(ctx context.Context, pub *ecdh.PublicKey, acct, app string, rec *objstore.Record, gen int) (string, error) { if s.cfg.CallbackSigner == nil || rec.Declared == nil { return "", ErrNoCallbackKey } envelope, err := appconfig.Seal(pub, appconfig.Binding{Account: acct, App: app}, s.cfg.CallbackSigner.DerivedKey(acct, app, gen)) if err != nil { return "", err } v := appconfig.Var{Kind: "secret", Envelope: envelope} _, err = appconfig.Apply(ctx, s.cfg.Releases, acct, app, map[string]appconfig.Var{ rec.Declared.EnvPrefix + "_SCAN_CALLBACK_SECRET": v, }, nil) return scanSecretHash(v), err } // The completion record names the exact sealed value. A concurrent console // can overwrite config after a successful rotation, even when the master ID // is unchanged; the next pass must detect and repair that incomplete state. func (s *Server) convergeCallbackKey(ctx context.Context, pub *ecdh.PublicKey, acct *Account, app string) (changed bool, result *objstore.Record, err error) { for attempt := 0; attempt < 4; attempt++ { tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { return changed, nil, err } if tomb != nil { return changed, nil, objstore.ErrQueueNotPermitted } rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { return changed, nil, err } if rec == nil || rec.Declared == nil || rec.Provisioned == nil || rec.Scanning == nil || rec.Scanning.Enabled == nil { return changed, rec, objstore.ErrQueueNotPermitted } if current, err := s.callbackKeyCurrent(ctx, acct.Sqid, app, rec); err != nil || current { return changed, rec, err } gen, prefix := rec.Scanning.WebhookGen, rec.Declared.EnvPrefix hash, err := s.deliverCallbackRevision(ctx, pub, acct.Sqid, app, rec, gen) if errors.Is(err, release.ErrCASConflict) { continue } if err != nil { return changed, rec, err } changed = true _, err = s.writeScanRecord(ctx, acct, app, func(fresh *objstore.Record) error { if fresh.Declared == nil || fresh.Scanning == nil || fresh.Scanning.Enabled == nil { return objstore.ErrQueueNotPermitted } if fresh.Scanning.WebhookGen != gen || fresh.Declared.EnvPrefix != prefix { return release.ErrCASConflict } fresh.Scanning.WebhookKeyID = s.cfg.CallbackSigner.CurrentKeyID() fresh.Scanning.WebhookConfigHash = hash return nil }) if errors.Is(err, objstore.ErrQueueNotPermitted) { if _, cleanupErr := appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, nil, []string{prefix + "_SCAN_CALLBACK_SECRET"}); cleanupErr != nil { return changed, rec, cleanupErr } continue } if err != nil && !errors.Is(err, release.ErrCASConflict) { return changed, rec, err } } return changed, nil, release.ErrCASConflict }