// Stateless server-side "state" (issue #550, Phase 0): the two short-lived // artefacts home used to hold between requests — ceremony proofs and in-flight // WebAuthn sessions — are now self-contained tokens the CLIENT carries, so no // process ever needs to remember them. Ceremony proofs are HMAC-signed // (uid + expiry, nothing secret inside); WebAuthn session state is AES-GCM // sealed into the ?session= query param. With both gone, home holds zero // per-request state and any replica behind a load balancer can finish what // another began. // // Both keys derive from ONE server secret, env LCHAT_HOME_SESSION_KEY. Like // the admin token, it is auth plumbing, NOT a decryption key for any vault — // it can mint/verify short-lived auth artefacts but cannot open a single byte // of anyone's blob. Unset, an ephemeral random key is generated at boot (with // a logged warning): single-process deployments — the self-hoster default — // keep working with zero config, and ceremonies just don't survive a restart, // which is exactly what the old in-memory map gave them. package main import ( "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "time" ) // ceremonyWindow is how long a ceremony proof stays "fresh". Destructive ops // (revoking devices, deleting the blob) demand a passkey ceremony completed // within this window, so a stolen link token can never reduce access. const ceremonyWindow = 5 * time.Minute // stateKeys holds the two keys behind home's stateless tokens, both derived // from the one master secret with distinct labels so a proof can never pass // as a session (or vice versa) even at the raw-bytes level. type stateKeys struct { mac []byte // HMAC-SHA256 key: ceremony proofs aead cipher.AEAD // AES-256-GCM: sealed WebAuthn sessions } // newStateKeys derives both keys from secret; an empty secret means "mint an // ephemeral one" (see the package comment for why that's fine). func newStateKeys(secret []byte) (*stateKeys, error) { if len(secret) == 0 { secret = make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, err } } block, err := aes.NewCipher(deriveKey(secret, "lchat-home session seal v1")) if err != nil { return nil, err } aead, err := cipher.NewGCM(block) if err != nil { return nil, err } return &stateKeys{mac: deriveKey(secret, "lchat-home ceremony proof v1"), aead: aead}, nil } // deriveKey is HKDF-Expand in miniature: HMAC-SHA256(secret, label). Two fixed // 32-byte outputs with distinct labels is all we need, so the full extract/ // expand dance would be ceremony for its own sake. func deriveKey(secret []byte, label string) []byte { m := hmac.New(sha256.New, secret) m.Write([]byte(label)) return m.Sum(nil) } // ---- ceremony proofs ---------------------------------------------------------- // // A proof is base64url( expiry(8, big-endian unix) || uid || HMAC-SHA256(expiry||uid) ). // The MAC covers both fields, so neither the expiry nor the user can be swapped // without the server key — a proof minted for user A can never validate as B, // and extending the window means forging the MAC. Nothing here is stored: the // old ceremony_proofs table (db.go) is retired in place. const proofMACLen = sha256.Size // mintCeremonyProof mints the short-lived proof returned by a verified passkey // ceremony — the "I just did Face ID" credential destructive ops demand. func (k *stateKeys) mintCeremonyProof(uid string) string { return k.mintCeremonyProofAt(uid, time.Now().Add(ceremonyWindow)) } // mintCeremonyProofAt is the expiry-injectable body of mintCeremonyProof, so // tests can mint already-stale proofs without a clock. func (k *stateKeys) mintCeremonyProofAt(uid string, expiry time.Time) string { payload := make([]byte, 8+len(uid)) binary.BigEndian.PutUint64(payload, uint64(expiry.Unix())) copy(payload[8:], uid) m := hmac.New(sha256.New, k.mac) m.Write(payload) return base64.RawURLEncoding.EncodeToString(m.Sum(payload)) } // ceremonyUser verifies a presented proof: MAC first (constant-time), expiry // second — nothing in the token is trusted until the MAC holds. func (k *stateKeys) ceremonyUser(proof string) (uid string, ok bool) { raw, err := base64.RawURLEncoding.DecodeString(proof) if err != nil || len(raw) <= 8+proofMACLen { return "", false } payload, tag := raw[:len(raw)-proofMACLen], raw[len(raw)-proofMACLen:] m := hmac.New(sha256.New, k.mac) m.Write(payload) if !hmac.Equal(tag, m.Sum(nil)) { return "", false } if time.Now().Unix() > int64(binary.BigEndian.Uint64(payload)) { return "", false } return string(payload[8:]), true } // ---- sealed byte blobs ---------------------------------------------------------- // // seal/open are the generic AES-GCM envelope under the WebAuthn session param // (webauthn.go wraps them with the JSON + kind + expiry layer). Format is // base64url( nonce(12) || ciphertext ) with a fresh random nonce per seal; GCM // authentication is what makes a tampered or wrong-key token fail closed. func (k *stateKeys) seal(plaintext []byte) (string, error) { nonce := make([]byte, k.aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(k.aead.Seal(nonce, nonce, plaintext, nil)), nil } func (k *stateKeys) open(token string) ([]byte, bool) { raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil || len(raw) < k.aead.NonceSize() { return nil, false } pt, err := k.aead.Open(nil, raw[:k.aead.NonceSize()], raw[k.aead.NonceSize():], nil) if err != nil { return nil, false } return pt, true }