eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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.
17package main
18
19import (
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.
33const 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.
38type 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).
45func 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.
66func 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
80const 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.
84func (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.
90func (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.
101func (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
125func (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
133func (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