| 1 | // Stateless server-side "state" (issue #550, Phase 0): the two short-lived |
| 2 | // artefacts home used to hold between requests — ceremony proofs and in-flight |
| 3 | // WebAuthn sessions — are now self-contained tokens the CLIENT carries, so no |
| 4 | // process ever needs to remember them. Ceremony proofs are HMAC-signed |
| 5 | // (uid + expiry, nothing secret inside); WebAuthn session state is AES-GCM |
| 6 | // sealed into the ?session= query param. With both gone, home holds zero |
| 7 | // per-request state and any replica behind a load balancer can finish what |
| 8 | // another began. |
| 9 | // |
| 10 | // Both keys derive from ONE server secret, env LCHAT_HOME_SESSION_KEY. Like |
| 11 | // the admin token, it is auth plumbing, NOT a decryption key for any vault — |
| 12 | // it can mint/verify short-lived auth artefacts but cannot open a single byte |
| 13 | // of anyone's blob. Unset, an ephemeral random key is generated at boot (with |
| 14 | // a logged warning): single-process deployments — the self-hoster default — |
| 15 | // keep working with zero config, and ceremonies just don't survive a restart, |
| 16 | // which is exactly what the old in-memory map gave them. |
| 17 | package main |
| 18 | |
| 19 | import ( |
| 20 | "crypto/aes" |
| 21 | "crypto/cipher" |
| 22 | "crypto/hmac" |
| 23 | "crypto/rand" |
| 24 | "crypto/sha256" |
| 25 | "encoding/base64" |
| 26 | "encoding/binary" |
| 27 | "time" |
| 28 | ) |
| 29 | |
| 30 | // ceremonyWindow is how long a ceremony proof stays "fresh". Destructive ops |
| 31 | // (revoking devices, deleting the blob) demand a passkey ceremony completed |
| 32 | // within this window, so a stolen link token can never reduce access. |
| 33 | const ceremonyWindow = 5 * time.Minute |
| 34 | |
| 35 | // stateKeys holds the two keys behind home's stateless tokens, both derived |
| 36 | // from the one master secret with distinct labels so a proof can never pass |
| 37 | // as a session (or vice versa) even at the raw-bytes level. |
| 38 | type stateKeys struct { |
| 39 | mac []byte // HMAC-SHA256 key: ceremony proofs |
| 40 | aead cipher.AEAD // AES-256-GCM: sealed WebAuthn sessions |
| 41 | } |
| 42 | |
| 43 | // newStateKeys derives both keys from secret; an empty secret means "mint an |
| 44 | // ephemeral one" (see the package comment for why that's fine). |
| 45 | func newStateKeys(secret []byte) (*stateKeys, error) { |
| 46 | if len(secret) == 0 { |
| 47 | secret = make([]byte, 32) |
| 48 | if _, err := rand.Read(secret); err != nil { |
| 49 | return nil, err |
| 50 | } |
| 51 | } |
| 52 | block, err := aes.NewCipher(deriveKey(secret, "lchat-home session seal v1")) |
| 53 | if err != nil { |
| 54 | return nil, err |
| 55 | } |
| 56 | aead, err := cipher.NewGCM(block) |
| 57 | if err != nil { |
| 58 | return nil, err |
| 59 | } |
| 60 | return &stateKeys{mac: deriveKey(secret, "lchat-home ceremony proof v1"), aead: aead}, nil |
| 61 | } |
| 62 | |
| 63 | // deriveKey is HKDF-Expand in miniature: HMAC-SHA256(secret, label). Two fixed |
| 64 | // 32-byte outputs with distinct labels is all we need, so the full extract/ |
| 65 | // expand dance would be ceremony for its own sake. |
| 66 | func deriveKey(secret []byte, label string) []byte { |
| 67 | m := hmac.New(sha256.New, secret) |
| 68 | m.Write([]byte(label)) |
| 69 | return m.Sum(nil) |
| 70 | } |
| 71 | |
| 72 | // ---- ceremony proofs ---------------------------------------------------------- |
| 73 | // |
| 74 | // A proof is base64url( expiry(8, big-endian unix) || uid || HMAC-SHA256(expiry||uid) ). |
| 75 | // The MAC covers both fields, so neither the expiry nor the user can be swapped |
| 76 | // without the server key — a proof minted for user A can never validate as B, |
| 77 | // and extending the window means forging the MAC. Nothing here is stored: the |
| 78 | // old ceremony_proofs table (db.go) is retired in place. |
| 79 | |
| 80 | const proofMACLen = sha256.Size |
| 81 | |
| 82 | // mintCeremonyProof mints the short-lived proof returned by a verified passkey |
| 83 | // ceremony — the "I just did Face ID" credential destructive ops demand. |
| 84 | func (k *stateKeys) mintCeremonyProof(uid string) string { |
| 85 | return k.mintCeremonyProofAt(uid, time.Now().Add(ceremonyWindow)) |
| 86 | } |
| 87 | |
| 88 | // mintCeremonyProofAt is the expiry-injectable body of mintCeremonyProof, so |
| 89 | // tests can mint already-stale proofs without a clock. |
| 90 | func (k *stateKeys) mintCeremonyProofAt(uid string, expiry time.Time) string { |
| 91 | payload := make([]byte, 8+len(uid)) |
| 92 | binary.BigEndian.PutUint64(payload, uint64(expiry.Unix())) |
| 93 | copy(payload[8:], uid) |
| 94 | m := hmac.New(sha256.New, k.mac) |
| 95 | m.Write(payload) |
| 96 | return base64.RawURLEncoding.EncodeToString(m.Sum(payload)) |
| 97 | } |
| 98 | |
| 99 | // ceremonyUser verifies a presented proof: MAC first (constant-time), expiry |
| 100 | // second — nothing in the token is trusted until the MAC holds. |
| 101 | func (k *stateKeys) ceremonyUser(proof string) (uid string, ok bool) { |
| 102 | raw, err := base64.RawURLEncoding.DecodeString(proof) |
| 103 | if err != nil || len(raw) <= 8+proofMACLen { |
| 104 | return "", false |
| 105 | } |
| 106 | payload, tag := raw[:len(raw)-proofMACLen], raw[len(raw)-proofMACLen:] |
| 107 | m := hmac.New(sha256.New, k.mac) |
| 108 | m.Write(payload) |
| 109 | if !hmac.Equal(tag, m.Sum(nil)) { |
| 110 | return "", false |
| 111 | } |
| 112 | if time.Now().Unix() > int64(binary.BigEndian.Uint64(payload)) { |
| 113 | return "", false |
| 114 | } |
| 115 | return string(payload[8:]), true |
| 116 | } |
| 117 | |
| 118 | // ---- sealed byte blobs ---------------------------------------------------------- |
| 119 | // |
| 120 | // seal/open are the generic AES-GCM envelope under the WebAuthn session param |
| 121 | // (webauthn.go wraps them with the JSON + kind + expiry layer). Format is |
| 122 | // base64url( nonce(12) || ciphertext ) with a fresh random nonce per seal; GCM |
| 123 | // authentication is what makes a tampered or wrong-key token fail closed. |
| 124 | |
| 125 | func (k *stateKeys) seal(plaintext []byte) (string, error) { |
| 126 | nonce := make([]byte, k.aead.NonceSize()) |
| 127 | if _, err := rand.Read(nonce); err != nil { |
| 128 | return "", err |
| 129 | } |
| 130 | return base64.RawURLEncoding.EncodeToString(k.aead.Seal(nonce, nonce, plaintext, nil)), nil |
| 131 | } |
| 132 | |
| 133 | func (k *stateKeys) open(token string) ([]byte, bool) { |
| 134 | raw, err := base64.RawURLEncoding.DecodeString(token) |
| 135 | if err != nil || len(raw) < k.aead.NonceSize() { |
| 136 | return nil, false |
| 137 | } |
| 138 | pt, err := k.aead.Open(nil, raw[:k.aead.NonceSize()], raw[k.aead.NonceSize():], nil) |
| 139 | if err != nil { |
| 140 | return nil, false |
| 141 | } |
| 142 | return pt, true |
| 143 | } |
| 144 | |