| 1 | package gate |
| 2 | |
| 3 | import ( |
| 4 | "crypto/hmac" |
| 5 | "crypto/sha256" |
| 6 | "encoding/base64" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | // The cookie is v1.<expiry unix>.<base64url(HMAC-SHA256(key, host "\n" expiry))> |
| 13 | // with key derived from the stored bcrypt hash and nothing else. That is |
| 14 | // what makes the design stateless and gives it its one rotation rule: |
| 15 | // change or clear the password and every cookie minted under the old one |
| 16 | // stops verifying at once, on every box, with no key to mint or mirror. |
| 17 | // Anyone who can read the hash can mint a cookie — root on the box and a |
| 18 | // bucket reader, who already hold the tenant's sealed secrets (spec §4.3). |
| 19 | |
| 20 | const cookieVersion = "v1" |
| 21 | |
| 22 | func cookieKey(hash string) []byte { |
| 23 | m := hmac.New(sha256.New, []byte("carlos-gate-cookie-v1")) |
| 24 | m.Write([]byte(hash)) |
| 25 | return m.Sum(nil) |
| 26 | } |
| 27 | |
| 28 | func cookieMAC(key []byte, host string, expiry int64) []byte { |
| 29 | m := hmac.New(sha256.New, key) |
| 30 | m.Write([]byte(host)) |
| 31 | m.Write([]byte{'\n'}) |
| 32 | m.Write([]byte(strconv.FormatInt(expiry, 10))) |
| 33 | return m.Sum(nil) |
| 34 | } |
| 35 | |
| 36 | // Mint produces a cookie value for host, valid until expiry. |
| 37 | func Mint(host, hash string, expiry time.Time) string { |
| 38 | exp := expiry.Unix() |
| 39 | mac := cookieMAC(cookieKey(hash), host, exp) |
| 40 | return cookieVersion + "." + strconv.FormatInt(exp, 10) + "." + base64.RawURLEncoding.EncodeToString(mac) |
| 41 | } |
| 42 | |
| 43 | // Verify reports whether value was minted for host under hash and has not |
| 44 | // expired at now. One HMAC and a constant-time compare; no bcrypt. |
| 45 | func Verify(value, host, hash string, now time.Time) bool { |
| 46 | parts := strings.Split(value, ".") |
| 47 | if len(parts) != 3 || parts[0] != cookieVersion { |
| 48 | return false |
| 49 | } |
| 50 | exp, err := strconv.ParseInt(parts[1], 10, 64) |
| 51 | // One spelling per expiry: ParseInt accepts "+123" and "-0", which |
| 52 | // would MAC differently from what Mint wrote for the same instant. |
| 53 | if err != nil || parts[1] != strconv.FormatInt(exp, 10) || exp <= now.Unix() { |
| 54 | return false |
| 55 | } |
| 56 | got, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| 57 | if err != nil { |
| 58 | return false |
| 59 | } |
| 60 | return hmac.Equal(got, cookieMAC(cookieKey(hash), host, exp)) |
| 61 | } |
| 62 | |