carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "strconv"
9 "strings"
10 "time"
11)
12
13const (
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.
22type CallbackSigner struct {
23 current callbackMaster
24 previous *callbackMaster
25}
26
27type 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.
34var 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.
38func 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
55func 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
61func (s *CallbackSigner) CurrentKeyID() string { return s.current.id }
62
63func (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.
72func 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.
82func (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
91func (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.
100func 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.
164func 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