| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "crypto/hmac" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | const ( |
| 14 | callbackKeyDomain = "carlos-scan-webhook-v1" |
| 15 | callbackKeyID = "carlos-scan-webhook-key-id" |
| 16 | callbackMaxSkew = 5 * time.Minute |
| 17 | ) |
| 18 | |
| 19 | // CallbackSigner holds the deployment masters used to sign callbacks. The |
| 20 | // per-app keys are derived only when they are needed: retaining them here |
| 21 | // would turn one app's delivered secret into unnecessary console state. |
| 22 | type CallbackSigner struct { |
| 23 | current callbackMaster |
| 24 | previous *callbackMaster |
| 25 | } |
| 26 | |
| 27 | type callbackMaster struct { |
| 28 | secret []byte |
| 29 | id string |
| 30 | } |
| 31 | |
| 32 | // ErrNoCallbackKey refuses a signer that could only produce unauthenticated |
| 33 | // callbacks. The caller should refuse callback enrollment when this happens. |
| 34 | var ErrNoCallbackKey = errors.New("console: no scan webhook key (CARLOS_SCAN_WEBHOOK_KEY)") |
| 35 | |
| 36 | // NewCallbackSigner builds a signer from CARLOS_SCAN_WEBHOOK_KEY and the |
| 37 | // optional CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS values supplied by configuration. |
| 38 | func NewCallbackSigner(current, previous string) (*CallbackSigner, error) { |
| 39 | if strings.TrimSpace(current) == "" { |
| 40 | return nil, ErrNoCallbackKey |
| 41 | } |
| 42 | if previous != "" && previous == current { |
| 43 | // A rotation that rotated to itself. Accepting it would tell an |
| 44 | // operator that the old master was being retired when it was not. |
| 45 | return nil, errors.New("console: the previous scan webhook key is identical to the current one — that is not a rotation") |
| 46 | } |
| 47 | s := &CallbackSigner{current: newCallbackMaster(current)} |
| 48 | if previous != "" { |
| 49 | p := newCallbackMaster(previous) |
| 50 | s.previous = &p |
| 51 | } |
| 52 | return s, nil |
| 53 | } |
| 54 | |
| 55 | func newCallbackMaster(secret string) callbackMaster { |
| 56 | h := hmac.New(sha256.New, []byte(callbackKeyID)) |
| 57 | h.Write([]byte(secret)) |
| 58 | return callbackMaster{secret: []byte(secret), id: hex.EncodeToString(h.Sum(nil))[:16]} |
| 59 | } |
| 60 | |
| 61 | func (s *CallbackSigner) CurrentKeyID() string { return s.current.id } |
| 62 | |
| 63 | func (s *CallbackSigner) DerivedKey(sqid, app string, gen int) []byte { |
| 64 | // EnvironmentFile values are text. Hex keeps every derived byte intact |
| 65 | // without allowing a random newline or NUL to break the app's whole render. |
| 66 | return []byte(hex.EncodeToString(DeriveCallbackKey(string(s.current.secret), sqid, app, gen))) |
| 67 | } |
| 68 | |
| 69 | // DeriveCallbackKey returns the one key delivered to an app. The domain label |
| 70 | // prevents another future HMAC(master, value) use from producing the same key; |
| 71 | // gen lets one tenant rotate a leaked key without rotating the deployment. |
| 72 | func DeriveCallbackKey(master, sqid, app string, gen int) []byte { |
| 73 | h := hmac.New(sha256.New, []byte(master)) |
| 74 | h.Write([]byte(callbackKeyDomain)) |
| 75 | h.Write([]byte{0}) |
| 76 | h.Write([]byte(sqid + "/" + app + "/" + strconv.Itoa(gen))) |
| 77 | return h.Sum(nil) |
| 78 | } |
| 79 | |
| 80 | // Sign returns the Carlos-Signature value. During deployment-key rotation it |
| 81 | // signs with both masters so apps may move to the new derived key gradually. |
| 82 | func (s *CallbackSigner) Sign(sqid, app string, gen int, body []byte, now time.Time) string { |
| 83 | timestamp := strconv.FormatInt(now.Unix(), 10) |
| 84 | values := []string{"t=" + timestamp, s.signWith(s.current, sqid, app, gen, timestamp, body)} |
| 85 | if s.previous != nil { |
| 86 | values = append(values, s.signWith(*s.previous, sqid, app, gen, timestamp, body)) |
| 87 | } |
| 88 | return strings.Join(values, ",") |
| 89 | } |
| 90 | |
| 91 | func (s *CallbackSigner) signWith(master callbackMaster, sqid, app string, gen int, timestamp string, body []byte) string { |
| 92 | key := DeriveCallbackKey(string(master.secret), sqid, app, gen) |
| 93 | return "v1=" + master.id + ":" + hex.EncodeToString(callbackMAC(key, timestamp, body)) |
| 94 | } |
| 95 | |
| 96 | // VerifyCallback is the reference verifier tenant apps run. The key is the |
| 97 | // app's single derived key, never a deployment master. It tries every v1 entry |
| 98 | // because key ids identify masters to operators, while an app neither has nor |
| 99 | // needs the master from which its key came. |
| 100 | func VerifyCallback(key []byte, header string, body []byte, now time.Time) error { |
| 101 | var timestamp string |
| 102 | var signatures [][]byte |
| 103 | for _, field := range strings.Split(header, ",") { |
| 104 | switch { |
| 105 | case strings.HasPrefix(field, "t="): |
| 106 | if timestamp != "" || len(field) == 2 { |
| 107 | return errors.New("console: malformed callback signature timestamp") |
| 108 | } |
| 109 | timestamp = field[2:] |
| 110 | case strings.HasPrefix(field, "v1="): |
| 111 | parts := strings.Split(field[3:], ":") |
| 112 | if len(parts) != 2 || len(parts[0]) != 16 { |
| 113 | return errors.New("console: malformed callback signature") |
| 114 | } |
| 115 | if _, err := hex.DecodeString(parts[0]); err != nil { |
| 116 | return errors.New("console: malformed callback key id") |
| 117 | } |
| 118 | mac, err := hex.DecodeString(parts[1]) |
| 119 | if err != nil || len(mac) != sha256.Size { |
| 120 | return errors.New("console: malformed callback MAC") |
| 121 | } |
| 122 | signatures = append(signatures, mac) |
| 123 | default: |
| 124 | // AN UNKNOWN FIELD IS IGNORED, and that is a forward-compatibility |
| 125 | // decision rather than laxness. Every tenant app runs a copy of |
| 126 | // this verifier, and the copies are frozen the day they are |
| 127 | // written; a verifier that refused what it did not recognise would |
| 128 | // make adding a `v2=` signature alongside `v1=` a breaking change |
| 129 | // for every app on the platform. Ignoring it is safe because the |
| 130 | // MAC still has to verify — an attacker gains nothing by appending |
| 131 | // fields. What is NOT optional is below: t and at least one v1. |
| 132 | } |
| 133 | } |
| 134 | if timestamp == "" || len(signatures) == 0 { |
| 135 | return errors.New("console: callback signature needs t and v1") |
| 136 | } |
| 137 | unix, err := strconv.ParseInt(timestamp, 10, 64) |
| 138 | if err != nil || strconv.FormatInt(unix, 10) != timestamp { |
| 139 | return errors.New("console: malformed callback timestamp") |
| 140 | } |
| 141 | age := now.Sub(time.Unix(unix, 0)) |
| 142 | if age < -callbackMaxSkew || age > callbackMaxSkew { |
| 143 | return errors.New("console: callback timestamp outside five-minute skew") |
| 144 | } |
| 145 | |
| 146 | want := callbackMAC(key, timestamp, body) |
| 147 | for _, got := range signatures { |
| 148 | if hmac.Equal(got, want) { |
| 149 | return nil |
| 150 | } |
| 151 | } |
| 152 | return errors.New("console: callback signature does not verify") |
| 153 | } |
| 154 | |
| 155 | // callbackMAC uses a delimiter because timestamp is decimal digits and cannot |
| 156 | // contain '.': the first dot therefore splits timestamp and raw body without |
| 157 | // ambiguity. Verdict tags need length prefixes because an object key can hold |
| 158 | // any delimiter; copying that encoding here would burden non-Go verifiers for |
| 159 | // no safety gain. |
| 160 | // |
| 161 | // There is deliberately no nonce. A replay within the skew window verifies, |
| 162 | // but the body is idempotent on (account, app, key, etag, verdict), so a nonce |
| 163 | // without a durable tenant-side nonce store would add ceremony, not safety. |
| 164 | func callbackMAC(key []byte, timestamp string, body []byte) []byte { |
| 165 | h := hmac.New(sha256.New, key) |
| 166 | h.Write([]byte(timestamp)) |
| 167 | h.Write([]byte{'.'}) |
| 168 | h.Write(body) |
| 169 | return h.Sum(nil) |
| 170 | } |
| 171 | |