| 1 | // Package auth is signup and passphrase sign-in, server side. |
| 2 | // |
| 3 | // Everything expensive and everything secret happens in the browser. What |
| 4 | // reaches here is public keys, sealed blobs, and an opaque index of an email |
| 5 | // address — so this package can be read as an answer to one question: is |
| 6 | // there anything here that could open a vault? There is not, and there is no |
| 7 | // code path that could be extended to add one without deleting something. |
| 8 | // |
| 9 | // Sign-in is a signature, not a verifier. The client derives a deterministic |
| 10 | // P-256 keypair from the login branch of its passphrase and we keep only the |
| 11 | // public half. Better than a stored password verifier in three ways: a fully |
| 12 | // compromised server holds something that cannot decrypt, cannot be replayed |
| 13 | // to impersonate, and adds no offline attack surface it did not already have |
| 14 | // through the wraps sitting in the bucket. |
| 15 | // |
| 16 | // The forbidden shortcut, named so it stays refused: email-gated escrow of |
| 17 | // any key. An identity anchor must never be able to decrypt anything. A |
| 18 | // compromised server plus a passed email gate must still yield nothing. |
| 19 | package auth |
| 20 | |
| 21 | import ( |
| 22 | "context" |
| 23 | "crypto/ecdsa" |
| 24 | "crypto/elliptic" |
| 25 | "crypto/hmac" |
| 26 | "crypto/rand" |
| 27 | "crypto/sha256" |
| 28 | "encoding/hex" |
| 29 | "encoding/json" |
| 30 | "errors" |
| 31 | "fmt" |
| 32 | "log/slog" |
| 33 | "math/big" |
| 34 | "net/http" |
| 35 | "strings" |
| 36 | "time" |
| 37 | |
| 38 | "gorm.io/gorm" |
| 39 | |
| 40 | "github.com/carlosframework/rastrillo/sessions" |
| 41 | |
| 42 | "github.com/carlosframework/contra/internal/contra/mail" |
| 43 | "github.com/carlosframework/contra/internal/contra/model" |
| 44 | "github.com/carlosframework/contra/internal/contra/store" |
| 45 | ) |
| 46 | |
| 47 | // The KDF floor, mirroring the client's. PROTOCOL.md puts the real defence in |
| 48 | // the browser — a malicious server is the threat, so a server-side check |
| 49 | // cannot protect anyone from us — but a client bug must not be able to |
| 50 | // persist parameters that a later, honest client would then trust. |
| 51 | const ( |
| 52 | MinMemoryKiB = 64 * 1024 |
| 53 | MinTime = 3 |
| 54 | Lanes = 1 |
| 55 | ) |
| 56 | |
| 57 | // ChallengeTTL is how long a login challenge is good for. Short, because its |
| 58 | // only job is to be alive between two requests of the same ceremony. |
| 59 | const ChallengeTTL = 2 * time.Minute |
| 60 | |
| 61 | // challengeContext is prepended to the bytes a client signs, so a signature |
| 62 | // made for this ceremony cannot be replayed into another one, and a challenge |
| 63 | // issued for the recovery branch cannot be spent on the passphrase branch. |
| 64 | const challengeContext = "contra/v1/login-challenge" |
| 65 | |
| 66 | var ( |
| 67 | ErrWeakParams = errors.New("auth: KDF parameters below the floor") |
| 68 | ErrTaken = errors.New("auth: that identity is already registered") |
| 69 | ) |
| 70 | |
| 71 | // Auth holds what the handlers need. The pepper is the only secret here, and |
| 72 | // it is an identity secret: it blinds the email index and can decrypt |
| 73 | // nothing. |
| 74 | type Auth struct { |
| 75 | db *gorm.DB |
| 76 | store *store.Store |
| 77 | sess *sessions.Sessions |
| 78 | pepper []byte |
| 79 | |
| 80 | // mailer sends the signup link and nothing else. Nil is allowed and |
| 81 | // logged loudly rather than refused, so a local run without SMTP still |
| 82 | // boots — but a deployment without it cannot sign anybody up, which is |
| 83 | // why New says so at startup instead of at the first attempt. |
| 84 | mailer mail.Sender |
| 85 | |
| 86 | // origin is what goes in the link. It has to be the address the person |
| 87 | // will actually open, not a guess. |
| 88 | origin string |
| 89 | logger *slog.Logger |
| 90 | } |
| 91 | |
| 92 | // New refuses a missing or short pepper rather than inventing one. |
| 93 | // |
| 94 | // An ephemeral pepper would be worse than no pepper: every account's blind |
| 95 | // index would change on the next restart, and every one of them would become |
| 96 | // unreachable — data loss dressed as a default. PROTOCOL.md wants this moved |
| 97 | // off the instance entirely, to the routing layer or a KMS, so that a |
| 98 | // database-plus-bucket leak alone cannot unblind addresses. |
| 99 | func New(db *gorm.DB, sess *sessions.Sessions, pepper []byte, mailer mail.Sender, |
| 100 | origin string, logger *slog.Logger) (*Auth, error) { |
| 101 | if len(pepper) < 32 { |
| 102 | return nil, errors.New( |
| 103 | "auth: the email pepper must be at least 32 bytes; set CONTRA_EMAIL_PEPPER") |
| 104 | } |
| 105 | if logger == nil { |
| 106 | logger = slog.Default() |
| 107 | } |
| 108 | if mailer == nil { |
| 109 | logger.Warn("auth: no mailer; signup links cannot be sent", |
| 110 | "fix", "carlos email enable --app contra") |
| 111 | } |
| 112 | return &Auth{ |
| 113 | db: db, store: store.New(db), sess: sess, pepper: pepper, |
| 114 | mailer: mailer, origin: origin, logger: logger, |
| 115 | }, nil |
| 116 | } |
| 117 | |
| 118 | // NormalizeEmail is deliberately minimal: trim and lowercase, nothing more. |
| 119 | // Anything cleverer — stripping dots, cutting +tags — would silently merge |
| 120 | // two addresses a person believes are separate accounts, and there is no |
| 121 | // recovery path from having merged two vaults. |
| 122 | func NormalizeEmail(email string) string { |
| 123 | return strings.ToLower(strings.TrimSpace(email)) |
| 124 | } |
| 125 | |
| 126 | // BlindIndex is the whole of what the server knows about an address. |
| 127 | // |
| 128 | // It proves identity for sign-in and can never decrypt anything. There is no |
| 129 | // stored address column and no provider user-id. |
| 130 | func (a *Auth) BlindIndex(email string) []byte { |
| 131 | m := hmac.New(sha256.New, a.pepper) |
| 132 | m.Write([]byte("contra/v1/email-index")) |
| 133 | m.Write([]byte{0}) |
| 134 | m.Write([]byte(NormalizeEmail(email))) |
| 135 | return m.Sum(nil) |
| 136 | } |
| 137 | |
| 138 | // decoySalt is the deterministic answer for an address with no account. |
| 139 | // |
| 140 | // Deterministic is the whole point: a prober who asks twice must get the same |
| 141 | // answer, or the variance itself says "no account here". Domain-separated |
| 142 | // from the blind index so the two can never be confused for one another. |
| 143 | func (a *Auth) decoySalt(email string) []byte { |
| 144 | m := hmac.New(sha256.New, a.pepper) |
| 145 | m.Write([]byte("contra/v1/decoy-salt")) |
| 146 | m.Write([]byte{0}) |
| 147 | m.Write([]byte(NormalizeEmail(email))) |
| 148 | return m.Sum(nil)[:16] |
| 149 | } |
| 150 | |
| 151 | // CheckParams is the server-side half of the KDF floor. |
| 152 | func CheckParams(raw string) error { |
| 153 | var p struct { |
| 154 | Alg string `json:"alg"` |
| 155 | MKiB uint32 `json:"m_kib"` |
| 156 | T uint32 `json:"t"` |
| 157 | P uint32 `json:"p"` |
| 158 | } |
| 159 | if err := json.Unmarshal([]byte(raw), &p); err != nil { |
| 160 | return fmt.Errorf("%w: unreadable parameters", ErrWeakParams) |
| 161 | } |
| 162 | if p.Alg != "argon2id" { |
| 163 | return fmt.Errorf("%w: algorithm %q", ErrWeakParams, p.Alg) |
| 164 | } |
| 165 | if p.MKiB < MinMemoryKiB || p.T < MinTime || p.P != Lanes { |
| 166 | return fmt.Errorf("%w: m=%d t=%d p=%d", ErrWeakParams, p.MKiB, p.T, p.P) |
| 167 | } |
| 168 | return nil |
| 169 | } |
| 170 | |
| 171 | // ---------------------------------------------------------------- the wire |
| 172 | |
| 173 | type saltRequest struct { |
| 174 | Email string `json:"email"` |
| 175 | } |
| 176 | |
| 177 | type saltResponse struct { |
| 178 | SaltHex string `json:"salt_hex"` |
| 179 | KDFParams string `json:"kdf_params"` |
| 180 | } |
| 181 | |
| 182 | type signupRequest struct { |
| 183 | Email string `json:"email"` |
| 184 | // AccountIDHex is minted by the client, because the client binds it into |
| 185 | // the AAD of the wraps below before the server has seen any of them. |
| 186 | AccountIDHex string `json:"account_id_hex"` |
| 187 | SaltHex string `json:"salt_hex"` |
| 188 | LoginKeys []struct { |
| 189 | Branch string `json:"branch"` |
| 190 | PublicKeyHex string `json:"public_key_hex"` |
| 191 | } `json:"login_keys"` |
| 192 | Wraps []struct { |
| 193 | Kind string `json:"kind"` |
| 194 | WrapIDHex string `json:"wrap_id_hex"` |
| 195 | KDFParams string `json:"kdf_params"` |
| 196 | Version uint64 `json:"version"` |
| 197 | KCVHex string `json:"kcv_hex"` |
| 198 | CredSaltHex string `json:"cred_salt_hex"` |
| 199 | NonceHex string `json:"nonce_hex"` |
| 200 | CiphertextHex string `json:"ciphertext_hex"` |
| 201 | } `json:"wraps"` |
| 202 | } |
| 203 | |
| 204 | type challengeRequest struct { |
| 205 | Email string `json:"email"` |
| 206 | Branch string `json:"branch"` |
| 207 | } |
| 208 | |
| 209 | type challengeResponse struct { |
| 210 | ChallengeHex string `json:"challenge_hex"` |
| 211 | SaltHex string `json:"salt_hex"` |
| 212 | KDFParams string `json:"kdf_params"` |
| 213 | } |
| 214 | |
| 215 | type verifyRequest struct { |
| 216 | Email string `json:"email"` |
| 217 | Branch string `json:"branch"` |
| 218 | ChallengeHex string `json:"challenge_hex"` |
| 219 | SignatureHex string `json:"signature_hex"` |
| 220 | } |
| 221 | |
| 222 | // DefaultKDFParams is what a new client is told to use. Served, not trusted: |
| 223 | // the client pins its own floor and refuses anything weaker, because a |
| 224 | // malicious server offering m=8KiB is exactly the attack. |
| 225 | const DefaultKDFParams = `{"alg":"argon2id","m_kib":65536,"t":3,"p":1}` |
| 226 | |
| 227 | func writeJSON(w http.ResponseWriter, status int, v any) { |
| 228 | w.Header().Set("Content-Type", "application/json") |
| 229 | w.Header().Set("Cache-Control", "no-store") |
| 230 | w.WriteHeader(status) |
| 231 | _ = json.NewEncoder(w).Encode(v) |
| 232 | } |
| 233 | |
| 234 | func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { |
| 235 | dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)) |
| 236 | dec.DisallowUnknownFields() |
| 237 | if err := dec.Decode(v); err != nil { |
| 238 | writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed request"}) |
| 239 | return false |
| 240 | } |
| 241 | return true |
| 242 | } |
| 243 | |
| 244 | // Salt answers the pre-auth question "what do I stretch my passphrase |
| 245 | // against?" — and answers it for every address, whether or not an account |
| 246 | // exists, so that account existence does not leak from it. |
| 247 | func (a *Auth) Salt(w http.ResponseWriter, r *http.Request) { |
| 248 | var req saltRequest |
| 249 | if !readJSON(w, r, &req) { |
| 250 | return |
| 251 | } |
| 252 | |
| 253 | var acct model.Account |
| 254 | err := a.db.WithContext(r.Context()). |
| 255 | Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error |
| 256 | |
| 257 | salt := a.decoySalt(req.Email) |
| 258 | if err == nil { |
| 259 | salt = acct.KDFSalt |
| 260 | } else if !errors.Is(err, gorm.ErrRecordNotFound) { |
| 261 | writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| 262 | return |
| 263 | } |
| 264 | writeJSON(w, http.StatusOK, saltResponse{ |
| 265 | SaltHex: hex.EncodeToString(salt), KDFParams: DefaultKDFParams}) |
| 266 | } |
| 267 | |
| 268 | // storeCredentials writes the public halves and the sealed wraps a completed |
| 269 | // signup produces. Shared by the flow that creates an account so that there |
| 270 | // is one definition of what a credential set is, rather than two that drift. |
| 271 | func (a *Auth) storeCredentials(ctx context.Context, accountID []byte, req signupRequest) error { |
| 272 | for _, k := range req.LoginKeys { |
| 273 | pub, err := hex.DecodeString(k.PublicKeyHex) |
| 274 | if err != nil || len(pub) != 65 { |
| 275 | return errors.New("bad public key") |
| 276 | } |
| 277 | if _, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), pub); err != nil { |
| 278 | return errors.New("public key is not on the curve") |
| 279 | } |
| 280 | if err := a.store.PutLoginKey(ctx, accountID, k.Branch, pub); err != nil { |
| 281 | return errors.New("could not store a login key") |
| 282 | } |
| 283 | } |
| 284 | for _, wr := range req.Wraps { |
| 285 | kcv, e1 := hex.DecodeString(wr.KCVHex) |
| 286 | nonce, e2 := hex.DecodeString(wr.NonceHex) |
| 287 | ct, e3 := hex.DecodeString(wr.CiphertextHex) |
| 288 | credSalt, e4 := hex.DecodeString(wr.CredSaltHex) |
| 289 | wrapID, e5 := hex.DecodeString(wr.WrapIDHex) |
| 290 | if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil { |
| 291 | return errors.New("bad wrap") |
| 292 | } |
| 293 | if err := a.store.PutWrap(ctx, accountID, wrapID, wr.Kind, wr.KDFParams, |
| 294 | wr.Version, kcv, credSalt, store.Sealed{Nonce: nonce, Ciphertext: ct}); err != nil { |
| 295 | return err |
| 296 | } |
| 297 | } |
| 298 | return nil |
| 299 | } |
| 300 | |
| 301 | // Challenge issues the bytes a client proves possession over — and issues |
| 302 | // them for an unknown address too, from the same code path and the same |
| 303 | // randomness, so a prober cannot tell the two apart from the response. |
| 304 | func (a *Auth) Challenge(w http.ResponseWriter, r *http.Request) { |
| 305 | var req challengeRequest |
| 306 | if !readJSON(w, r, &req) { |
| 307 | return |
| 308 | } |
| 309 | if req.Branch != "pw" && req.Branch != "rc" { |
| 310 | writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown branch"}) |
| 311 | return |
| 312 | } |
| 313 | |
| 314 | nonce := make([]byte, 32) |
| 315 | if _, err := rand.Read(nonce); err != nil { |
| 316 | writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"}) |
| 317 | return |
| 318 | } |
| 319 | |
| 320 | resp := challengeResponse{ |
| 321 | ChallengeHex: hex.EncodeToString(nonce), |
| 322 | SaltHex: hex.EncodeToString(a.decoySalt(req.Email)), |
| 323 | KDFParams: DefaultKDFParams, |
| 324 | } |
| 325 | |
| 326 | var acct model.Account |
| 327 | err := a.db.WithContext(r.Context()). |
| 328 | Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error |
| 329 | if err == nil { |
| 330 | id, err := store.NewID() |
| 331 | if err != nil { |
| 332 | writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"}) |
| 333 | return |
| 334 | } |
| 335 | if err := a.db.WithContext(r.Context()).Create(&model.LoginChallenge{ |
| 336 | ID: id, |
| 337 | AccountID: acct.ID, |
| 338 | Branch: req.Branch, |
| 339 | Challenge: nonce, |
| 340 | ExpiresAt: time.Now().UTC().Add(ChallengeTTL), |
| 341 | CreatedAt: time.Now().UTC(), |
| 342 | }).Error; err != nil { |
| 343 | writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not issue"}) |
| 344 | return |
| 345 | } |
| 346 | resp.SaltHex = hex.EncodeToString(acct.KDFSalt) |
| 347 | } else if !errors.Is(err, gorm.ErrRecordNotFound) { |
| 348 | writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| 349 | return |
| 350 | } |
| 351 | // An unknown address gets a challenge that was never recorded, so the |
| 352 | // ceremony fails one step later — at Verify, where every failure looks |
| 353 | // the same — instead of here, where the shape of the failure would say |
| 354 | // whether the account exists. |
| 355 | writeJSON(w, http.StatusOK, resp) |
| 356 | } |
| 357 | |
| 358 | // SignedMessage is what a client signs: the ceremony's context, the branch, |
| 359 | // and the challenge. Binding the branch stops a challenge issued for the |
| 360 | // recovery credential being spent on the passphrase one. |
| 361 | func SignedMessage(branch string, challenge []byte) []byte { |
| 362 | out := make([]byte, 0, len(challengeContext)+2+len(branch)+len(challenge)) |
| 363 | out = append(out, challengeContext...) |
| 364 | out = append(out, 0) |
| 365 | out = append(out, branch...) |
| 366 | out = append(out, 0) |
| 367 | return append(out, challenge...) |
| 368 | } |
| 369 | |
| 370 | // Verify checks the signature and, on success, mints a session. |
| 371 | // |
| 372 | // Every failure answers the same thing. An unknown address, an expired |
| 373 | // challenge, a challenge already spent, and a wrong signature are four very |
| 374 | // different facts, and telling them apart is the caller's problem, not ours. |
| 375 | func (a *Auth) Verify(w http.ResponseWriter, r *http.Request) { |
| 376 | var req verifyRequest |
| 377 | if !readJSON(w, r, &req) { |
| 378 | return |
| 379 | } |
| 380 | refuse := func() { |
| 381 | writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "could not sign in"}) |
| 382 | } |
| 383 | |
| 384 | challenge, err := hex.DecodeString(req.ChallengeHex) |
| 385 | if err != nil || len(challenge) != 32 { |
| 386 | refuse() |
| 387 | return |
| 388 | } |
| 389 | sig, err := hex.DecodeString(req.SignatureHex) |
| 390 | if err != nil || len(sig) != 64 { |
| 391 | refuse() |
| 392 | return |
| 393 | } |
| 394 | |
| 395 | ctx := r.Context() |
| 396 | var acct model.Account |
| 397 | if err := a.db.WithContext(ctx). |
| 398 | Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error; err != nil { |
| 399 | refuse() |
| 400 | return |
| 401 | } |
| 402 | |
| 403 | var row model.LoginChallenge |
| 404 | if err := a.db.WithContext(ctx). |
| 405 | Where("account_id = ? AND branch = ? AND challenge = ?", |
| 406 | acct.ID, req.Branch, challenge).First(&row).Error; err != nil { |
| 407 | refuse() |
| 408 | return |
| 409 | } |
| 410 | // Single use, spent whether or not the signature turns out to be good: |
| 411 | // a challenge that survives a failed attempt is a challenge an attacker |
| 412 | // can grind against. |
| 413 | if err := a.db.WithContext(ctx).Delete(&model.LoginChallenge{}, "id = ?", row.ID).Error; err != nil { |
| 414 | refuse() |
| 415 | return |
| 416 | } |
| 417 | if time.Now().UTC().After(row.ExpiresAt) { |
| 418 | refuse() |
| 419 | return |
| 420 | } |
| 421 | |
| 422 | var key model.LoginKey |
| 423 | if err := a.db.WithContext(ctx). |
| 424 | Where("account_id = ? AND branch = ?", acct.ID, req.Branch).First(&key).Error; err != nil { |
| 425 | refuse() |
| 426 | return |
| 427 | } |
| 428 | pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), key.PublicKey) |
| 429 | if err != nil { |
| 430 | refuse() |
| 431 | return |
| 432 | } |
| 433 | |
| 434 | digest := sha256.Sum256(SignedMessage(req.Branch, challenge)) |
| 435 | rr := new(big.Int).SetBytes(sig[:32]) |
| 436 | ss := new(big.Int).SetBytes(sig[32:]) |
| 437 | if !ecdsa.Verify(pub, digest[:], rr, ss) { |
| 438 | refuse() |
| 439 | return |
| 440 | } |
| 441 | |
| 442 | if err := a.sess.SignIn(w, r, sessions.Session{ |
| 443 | Subject: hex.EncodeToString(acct.ID), |
| 444 | Method: "passphrase/" + req.Branch, |
| 445 | AuthTime: time.Now().UTC(), |
| 446 | }); err != nil { |
| 447 | refuse() |
| 448 | return |
| 449 | } |
| 450 | writeJSON(w, http.StatusOK, map[string]string{ |
| 451 | "account_id_hex": hex.EncodeToString(acct.ID)}) |
| 452 | } |
| 453 | |
| 454 | // SignOut ends the session. Sessions are rows, so this is a real revocation |
| 455 | // rather than a forgotten cookie. |
| 456 | func (a *Auth) SignOut(w http.ResponseWriter, r *http.Request) { |
| 457 | a.sess.SignOut(w, r) |
| 458 | http.Redirect(w, r, "/", http.StatusSeeOther) |
| 459 | } |
| 460 | |
| 461 | // SweepChallenges deletes expired challenges. Cheap, and it keeps a table |
| 462 | // that only ever holds live state from becoming a log of when people signed |
| 463 | // in — which would be exactly the transport-shape residue we do not have to |
| 464 | // keep. |
| 465 | func (a *Auth) SweepChallenges(now time.Time) error { |
| 466 | return a.db.Where("expires_at < ?", now.UTC()).Delete(&model.LoginChallenge{}).Error |
| 467 | } |
| 468 | |