package console import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "strconv" "strings" "time" ) const ( callbackKeyDomain = "carlos-scan-webhook-v1" callbackKeyID = "carlos-scan-webhook-key-id" callbackMaxSkew = 5 * time.Minute ) // CallbackSigner holds the deployment masters used to sign callbacks. The // per-app keys are derived only when they are needed: retaining them here // would turn one app's delivered secret into unnecessary console state. type CallbackSigner struct { current callbackMaster previous *callbackMaster } type callbackMaster struct { secret []byte id string } // ErrNoCallbackKey refuses a signer that could only produce unauthenticated // callbacks. The caller should refuse callback enrollment when this happens. var ErrNoCallbackKey = errors.New("console: no scan webhook key (CARLOS_SCAN_WEBHOOK_KEY)") // NewCallbackSigner builds a signer from CARLOS_SCAN_WEBHOOK_KEY and the // optional CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS values supplied by configuration. func NewCallbackSigner(current, previous string) (*CallbackSigner, error) { if strings.TrimSpace(current) == "" { return nil, ErrNoCallbackKey } if previous != "" && previous == current { // A rotation that rotated to itself. Accepting it would tell an // operator that the old master was being retired when it was not. return nil, errors.New("console: the previous scan webhook key is identical to the current one — that is not a rotation") } s := &CallbackSigner{current: newCallbackMaster(current)} if previous != "" { p := newCallbackMaster(previous) s.previous = &p } return s, nil } func newCallbackMaster(secret string) callbackMaster { h := hmac.New(sha256.New, []byte(callbackKeyID)) h.Write([]byte(secret)) return callbackMaster{secret: []byte(secret), id: hex.EncodeToString(h.Sum(nil))[:16]} } func (s *CallbackSigner) CurrentKeyID() string { return s.current.id } func (s *CallbackSigner) DerivedKey(sqid, app string, gen int) []byte { // EnvironmentFile values are text. Hex keeps every derived byte intact // without allowing a random newline or NUL to break the app's whole render. return []byte(hex.EncodeToString(DeriveCallbackKey(string(s.current.secret), sqid, app, gen))) } // DeriveCallbackKey returns the one key delivered to an app. The domain label // prevents another future HMAC(master, value) use from producing the same key; // gen lets one tenant rotate a leaked key without rotating the deployment. func DeriveCallbackKey(master, sqid, app string, gen int) []byte { h := hmac.New(sha256.New, []byte(master)) h.Write([]byte(callbackKeyDomain)) h.Write([]byte{0}) h.Write([]byte(sqid + "/" + app + "/" + strconv.Itoa(gen))) return h.Sum(nil) } // Sign returns the Carlos-Signature value. During deployment-key rotation it // signs with both masters so apps may move to the new derived key gradually. func (s *CallbackSigner) Sign(sqid, app string, gen int, body []byte, now time.Time) string { timestamp := strconv.FormatInt(now.Unix(), 10) values := []string{"t=" + timestamp, s.signWith(s.current, sqid, app, gen, timestamp, body)} if s.previous != nil { values = append(values, s.signWith(*s.previous, sqid, app, gen, timestamp, body)) } return strings.Join(values, ",") } func (s *CallbackSigner) signWith(master callbackMaster, sqid, app string, gen int, timestamp string, body []byte) string { key := DeriveCallbackKey(string(master.secret), sqid, app, gen) return "v1=" + master.id + ":" + hex.EncodeToString(callbackMAC(key, timestamp, body)) } // VerifyCallback is the reference verifier tenant apps run. The key is the // app's single derived key, never a deployment master. It tries every v1 entry // because key ids identify masters to operators, while an app neither has nor // needs the master from which its key came. func VerifyCallback(key []byte, header string, body []byte, now time.Time) error { var timestamp string var signatures [][]byte for _, field := range strings.Split(header, ",") { switch { case strings.HasPrefix(field, "t="): if timestamp != "" || len(field) == 2 { return errors.New("console: malformed callback signature timestamp") } timestamp = field[2:] case strings.HasPrefix(field, "v1="): parts := strings.Split(field[3:], ":") if len(parts) != 2 || len(parts[0]) != 16 { return errors.New("console: malformed callback signature") } if _, err := hex.DecodeString(parts[0]); err != nil { return errors.New("console: malformed callback key id") } mac, err := hex.DecodeString(parts[1]) if err != nil || len(mac) != sha256.Size { return errors.New("console: malformed callback MAC") } signatures = append(signatures, mac) default: // AN UNKNOWN FIELD IS IGNORED, and that is a forward-compatibility // decision rather than laxness. Every tenant app runs a copy of // this verifier, and the copies are frozen the day they are // written; a verifier that refused what it did not recognise would // make adding a `v2=` signature alongside `v1=` a breaking change // for every app on the platform. Ignoring it is safe because the // MAC still has to verify — an attacker gains nothing by appending // fields. What is NOT optional is below: t and at least one v1. } } if timestamp == "" || len(signatures) == 0 { return errors.New("console: callback signature needs t and v1") } unix, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || strconv.FormatInt(unix, 10) != timestamp { return errors.New("console: malformed callback timestamp") } age := now.Sub(time.Unix(unix, 0)) if age < -callbackMaxSkew || age > callbackMaxSkew { return errors.New("console: callback timestamp outside five-minute skew") } want := callbackMAC(key, timestamp, body) for _, got := range signatures { if hmac.Equal(got, want) { return nil } } return errors.New("console: callback signature does not verify") } // callbackMAC uses a delimiter because timestamp is decimal digits and cannot // contain '.': the first dot therefore splits timestamp and raw body without // ambiguity. Verdict tags need length prefixes because an object key can hold // any delimiter; copying that encoding here would burden non-Go verifiers for // no safety gain. // // There is deliberately no nonce. A replay within the skew window verifies, // but the body is idempotent on (account, app, key, etag, verdict), so a nonce // without a durable tenant-side nonce store would add ceremony, not safety. func callbackMAC(key []byte, timestamp string, body []byte) []byte { h := hmac.New(sha256.New, key) h.Write([]byte(timestamp)) h.Write([]byte{'.'}) h.Write(body) return h.Sum(nil) }