web: signup and passphrase sign-in, with nothing on the server that can open a vault
The root key is generated in the browser and leaves it only as sealed wraps. The passphrase never leaves at all — not on signup, not on sign-in. What crosses the wire is public keys, ciphertext, a signature over bytes the server chose, and an index of an address that cannot be reversed. Sign-in is a signature, not a verifier. The client derives a deterministic P-256 keypair from the login branch and we keep the public half; a fully compromised server therefore holds something that cannot decrypt, cannot be replayed to impersonate, and adds no offline attack surface it did not already have through the wraps in the bucket. The KDF floor is enforced in the browser, on parameters the server sent, before a single expensive byte is derived — because a malicious server offering m=8KiB is the attack, and a check we perform on ourselves cannot defend anyone from us. The server-side copy is there anyway, so that a client bug cannot persist parameters a later honest client would trust. The salt endpoint answers every address, registered or not, with the same shape and the same code path. An unknown one gets a deterministic decoy derived from the pepper: deterministic is the whole point, because a prober who asks twice and gets two answers has learned there is no account. Challenges are single-use and spent whether or not the signature was good, so one issued challenge cannot be ground against. The branch is bound into the signed bytes rather than merely checked beside them, so a challenge issued for the recovery credential cannot be spent on the passphrase one. The pepper is refused rather than invented when absent. An ephemeral pepper would change every account's index on the next restart and strand every account there is — data loss wearing a default's clothes. DESIGN.md wants it off the instance entirely; reading it from the environment is the interim and the interim is named out loud in main.go rather than forgotten. Models move to internal/contra/model so the app, the store and the auth ceremony can each name a row without importing one another. Models itself stays exported from the app package, because that is what the migration generator reads. Both tripwires from the previous slices fired during this work and were answered rather than loosened: the schema-set test caught login_challenges, and the module cap caught two new JS modules. The greps now drive the real HTTP ceremony with a canary address and a canary passphrase, so the value that would be most catastrophic to find on disk is one of the things searched for. The Go tests run the client half in Go rather than mocking it, against the same derivations the golden vectors pin — so a divergence between what the server expects and what a client produces fails in CI rather than in a vault.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hz8mMZYs75WuRC1hYpmapJ
20 files changed,
+1743
−278
cmd/contra/main.go+36 −15internal/contra/app.go+74 −31internal/contra/auth/auth.go+482 −0internal/contra/handlers.go+40 −7internal/contra/migrations/0002_create_login_challenges.sql+8 −0internal/contra/migrations/schema.sql+36 −0internal/contra/model/model.go+210 −0internal/contra/models.go+15 −185internal/contra/render.go+1 −1internal/contra/static/crypto/account.js+150 −0internal/contra/static/pages/auth.js+113 −0internal/contra/store/store.go+11 −11internal/contra/templates/signin.html+26 −0internal/contra/templates/signup.html+34 −0internal/contra/templates/vault.html+16 −0internal/contratest/auth_test.go+414 −0internal/contratest/greps_test.go+25 −6internal/contratest/harness_test.go+10 −1internal/contratest/jsmodules_test.go+32 −12internal/contratest/storage_test.go+10 −9
Intent
diff --git a/cmd/contra/main.go b/cmd/contra/main.go| index 83311e5..5415189 100644 |
| --- a/cmd/contra/main.go |
| +++ b/cmd/contra/main.go |
| @@ -1,8 +1,9 @@ |
| -// Command contra wires the app: resolve the platform's activation |
| -// argv, open the database, build the app, serve. |
| +// Command contra wires the app: resolve the platform's activation argv, open |
| +// the database, build the app, serve. |
| package main |
| import ( |
| + "encoding/hex" |
| "log/slog" |
| "os" |
| @@ -15,11 +16,11 @@ import ( |
| func main() { |
| logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| - // Resolve, not Run: this app opens its own database handle via |
| - // db.Open — a *gorm.DB with a split reader/writer pool, not the |
| - // bare *sql.DB Options.Router would hand back — so Options.DBPath |
| - // must be empty again by the Serve call below, or Serve would open |
| - // a second, unused connection to the same file. |
| + // Resolve, not Run: this app opens its own database handle via db.Open — |
| + // a *gorm.DB with a split reader/writer pool, not the bare *sql.DB |
| + // Options.Router would hand back — so Options.DBPath must be empty again |
| + // by the Serve call below, or Serve would open a second, unused |
| + // connection to the same file. |
| opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "contra.db", Logger: logger}) |
| if err != nil { |
| logger.Error("resolve activation", "err", err) |
| @@ -33,17 +34,38 @@ func main() { |
| } |
| defer d.Close() |
| - // Origin decides the CSRF same-origin check (and, when the app |
| - // grows accounts, the Secure/__Host- cookie attributes) — so |
| - // defaulting it silently in production would be a real bug. Loud |
| - // on purpose, harmless in local dev. |
| + // Origin decides the CSRF same-origin check and the session cookie's |
| + // Secure/__Host- attributes, so defaulting it silently in production |
| + // would be a real bug. Loud on purpose, harmless in local dev. |
| origin := os.Getenv("CONTRA_ORIGIN") |
| if origin == "" { |
| origin = "http://localhost:8080" |
| logger.Warn("CONTRA_ORIGIN not set; defaulting", "origin", origin) |
| } |
| - mux, err := contra.App(d, origin, logger) |
| + // The email pepper. Refused rather than invented: an ephemeral pepper |
| + // would change every account's blind index on the next restart and make |
| + // every one of them unreachable — data loss wearing a default's clothes. |
| + // |
| + // DESIGN.md wants this off the instance entirely, at the routing layer or |
| + // in a KMS, so that a database-plus-bucket leak alone cannot unblind an |
| + // address. Reading it from the environment is the interim, and the |
| + // interim is worth naming out loud rather than forgetting. |
| + pepperHex := os.Getenv("CONTRA_EMAIL_PEPPER") |
| + if pepperHex == "" { |
| + logger.Error("CONTRA_EMAIL_PEPPER is not set", |
| + "why", "the email blind index needs a stable pepper; an ephemeral one "+ |
| + "would strand every existing account on the next restart", |
| + "fix", "carlos secrets set --app contra CONTRA_EMAIL_PEPPER=<64 hex chars>") |
| + os.Exit(1) |
| + } |
| + pepper, err := hex.DecodeString(pepperHex) |
| + if err != nil || len(pepper) < 32 { |
| + logger.Error("CONTRA_EMAIL_PEPPER must be at least 64 hex characters") |
| + os.Exit(1) |
| + } |
| + |
| + mux, err := contra.App(d, contra.Config{Origin: origin, Pepper: pepper}, logger) |
| if err != nil { |
| logger.Error("build app", "err", err) |
| os.Exit(1) |
| @@ -51,9 +73,8 @@ func main() { |
| opts.Mux = mux |
| opts.DBPath = "" |
| - // The same page a handler's own error path renders (render.go's |
| - // ErrorPage — wire it to Ctx.ErrorPage too as the app grows |
| - // handlers), so a panic and a handled failure look identical. |
| + // The same page a handler's own error path renders, so a panic and a |
| + // handled failure look identical. |
| opts.ErrorPage = contra.ErrorPage |
| if err := rastrillo.Serve(opts); err != nil { |
| logger.Error("serve failed", "err", err) |
diff --git a/internal/contra/app.go b/internal/contra/app.go| index 1d01f4c..fc55bfd 100644 |
| --- a/internal/contra/app.go |
| +++ b/internal/contra/app.go |
| @@ -10,51 +10,94 @@ import ( |
| "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + |
| + "github.com/carlosframework/contra/internal/contra/auth" |
| ) |
| -// App wires the whole app: schema, router, static files. It returns a |
| -// *http.ServeMux because rastrillo.Options.Mux is typed that way — the |
| -// chi router mounts inside it. origin is the app's external origin |
| -// ("https://app.example.com") — the CSRF check's yardstick, and, once |
| -// the app grows accounts, sessions.Config.Origin too. |
| -// |
| -// Growing the app is SKILL.md's five-file shape: models in models.go, |
| -// handlers in handlers.go, and — for a multi-user app — the sessions |
| -// core plus an identity plugin wired right here (examples/notes in the |
| -// rastrillo repo is the worked example to copy). Adding a subsystem |
| -// also means adding its Schema to migrations.go's BootSchema — see |
| -// the comment there. |
| -func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) { |
| - // Apply BootSchema, not Schema: BootSchema is everything this |
| - // app needs at boot (its own migrations plus any subsystem's), |
| - // while Schema (migrations.go) stays just this app's own so the |
| - // generator and its check never see a subsystem's tables and try |
| - // to drop them. |
| +// Config is what App needs from the outside. Pepper is separated from the |
| +// rest because it is the one secret the instance holds, and it is worth |
| +// seeing that it arrives from a caller rather than being read out of the |
| +// environment somewhere in the middle of the wiring. |
| +type Config struct { |
| + // Origin is the app's external origin, scheme included. It decides the |
| + // CSRF check's yardstick and the session cookie's Secure/__Host- |
| + // attributes, so defaulting it silently in production is a real bug. |
| + Origin string |
| + |
| + // Pepper blinds the email index. It proves identity and decrypts |
| + // nothing; DESIGN.md wants it moved off the instance to the routing |
| + // layer or a KMS, so that a database-plus-bucket leak alone cannot |
| + // unblind an address. |
| + Pepper []byte |
| +} |
| + |
| +// App wires the whole app: schema, sessions, auth, router, static files. It |
| +// returns a *http.ServeMux because rastrillo.Options.Mux is typed that way — |
| +// the chi router mounts inside it. |
| +func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) { |
| + // Apply BootSchema, not Schema: BootSchema is everything this app needs |
| + // at boot (its own migrations plus any subsystem's), while Schema stays |
| + // just this app's own so the generator and its check never see a |
| + // subsystem's tables and try to drop them. |
| migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| if err != nil { |
| return nil, err |
| } |
| - // One line per boot. Result exists so this can be logged, and |
| - // until something does, a fleet-wide adoption — every database |
| - // stamped, no DDL run, exactly once, never again — produces no |
| - // output at all, which is the last event you want to be silent. |
| logger.Info("migrate", "applied", migrated.Applied, |
| "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| - a := &app{db: d, logger: logger} |
| + writer, err := d.G.DB() |
| + if err != nil { |
| + return nil, err |
| + } |
| + sess, err := sessions.New(sessions.Config{ |
| + DB: writer, Origin: cfg.Origin, Logger: logger, SigninPath: "/signin", |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + ident, err := auth.New(d.G, sess, cfg.Pepper) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + a := &app{db: d, logger: logger, sess: sess} |
| r := chi.NewRouter() |
| - // App-wide, from day one: every state-changing route added below |
| - // is born covered. Origin-checking, not tokens — nothing to mint |
| - // or forget in a form. |
| - r.Use(csrf.Protect(origin)) |
| + // App-wide, from day one: every state-changing route added below is born |
| + // covered. Origin-checking, not tokens — nothing to mint or forget. |
| + r.Use(csrf.Protect(cfg.Origin)) |
| + // Resolves a session when there is one and blocks nothing, so the |
| + // marketing page can tell a signed-in visitor from a stranger without |
| + // any route becoming a gate by accident. |
| + r.Use(sess.Middleware) |
| + |
| r.Get("/", a.index) |
| + r.Get("/signup", a.signupPage) |
| + r.Get("/signin", a.signinPage) |
| + |
| + // The ceremony. Every one of these carries public keys, sealed blobs or |
| + // an opaque index — never a passphrase, never a key, never an address in |
| + // a form the server keeps. |
| + r.Post("/auth/salt", ident.Salt) |
| + r.Post("/auth/signup", ident.Signup) |
| + r.Post("/auth/challenge", ident.Challenge) |
| + r.Post("/auth/verify", ident.Verify) |
| + r.Post("/auth/signout", ident.SignOut) |
| + |
| + r.Group(func(r chi.Router) { |
| + r.Use(sess.Require) |
| + r.Get("/vault", a.vault) |
| + }) |
| mux := http.NewServeMux() |
| - // The app serves its own static files — the framework never does. |
| - // They are embedded (render.go) and fingerprinted: templates link |
| - // them via {{asset ...}}, which returns a content-hashed URL the |
| - // handler serves cacheable-forever. |
| + // The app serves its own static files — the framework never does. They |
| + // are embedded (render.go) and fingerprinted: templates link them via |
| + // {{asset ...}}, which returns a content-hashed URL served |
| + // cacheable-forever, while the bare name stays no-cache so the ES module |
| + // graph can import by relative path. |
| mux.Handle("GET /static/", assets.Handler()) |
| mux.Handle("/", r) |
| return mux, nil |
diff --git a/internal/contra/auth/auth.go b/internal/contra/auth/auth.go| new file mode 100644 |
| index 0000000..786236a |
| --- /dev/null |
| +++ b/internal/contra/auth/auth.go |
| @@ -0,0 +1,482 @@ |
| +// Package auth is signup and passphrase sign-in, server side. |
| +// |
| +// Everything expensive and everything secret happens in the browser. What |
| +// reaches here is public keys, sealed blobs, and an opaque index of an email |
| +// address — so this package can be read as an answer to one question: is |
| +// there anything here that could open a vault? There is not, and there is no |
| +// code path that could be extended to add one without deleting something. |
| +// |
| +// Sign-in is a signature, not a verifier. The client derives a deterministic |
| +// P-256 keypair from the login branch of its passphrase and we keep only the |
| +// public half. Better than a stored password verifier in three ways: a fully |
| +// compromised server holds something that cannot decrypt, cannot be replayed |
| +// to impersonate, and adds no offline attack surface it did not already have |
| +// through the wraps sitting in the bucket. |
| +// |
| +// The forbidden shortcut, named so it stays refused: email-gated escrow of |
| +// any key. An identity anchor must never be able to decrypt anything. A |
| +// compromised server plus a passed email gate must still yield nothing. |
| +package auth |
| + |
| +import ( |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/hmac" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "math/big" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "github.com/carlosframework/rastrillo/sessions" |
| + |
| + "github.com/carlosframework/contra/internal/contra/model" |
| + "github.com/carlosframework/contra/internal/contra/store" |
| +) |
| + |
| +// The KDF floor, mirroring the client's. DESIGN.md puts the real defence in |
| +// the browser — a malicious server is the threat, so a server-side check |
| +// cannot protect anyone from us — but a client bug must not be able to |
| +// persist parameters that a later, honest client would then trust. |
| +const ( |
| + MinMemoryKiB = 64 * 1024 |
| + MinTime = 3 |
| + Lanes = 1 |
| +) |
| + |
| +// ChallengeTTL is how long a login challenge is good for. Short, because its |
| +// only job is to be alive between two requests of the same ceremony. |
| +const ChallengeTTL = 2 * time.Minute |
| + |
| +// challengeContext is prepended to the bytes a client signs, so a signature |
| +// made for this ceremony cannot be replayed into another one, and a challenge |
| +// issued for the recovery branch cannot be spent on the passphrase branch. |
| +const challengeContext = "contra/v1/login-challenge" |
| + |
| +var ( |
| + ErrWeakParams = errors.New("auth: KDF parameters below the floor") |
| + ErrTaken = errors.New("auth: that identity is already registered") |
| +) |
| + |
| +// Auth holds what the handlers need. The pepper is the only secret here, and |
| +// it is an identity secret: it blinds the email index and can decrypt |
| +// nothing. |
| +type Auth struct { |
| + db *gorm.DB |
| + store *store.Store |
| + sess *sessions.Sessions |
| + pepper []byte |
| +} |
| + |
| +// New refuses a missing or short pepper rather than inventing one. |
| +// |
| +// An ephemeral pepper would be worse than no pepper: every account's blind |
| +// index would change on the next restart, and every one of them would become |
| +// unreachable — data loss dressed as a default. DESIGN.md wants this moved |
| +// off the instance entirely, to the routing layer or a KMS, so that a |
| +// database-plus-bucket leak alone cannot unblind addresses. |
| +func New(db *gorm.DB, sess *sessions.Sessions, pepper []byte) (*Auth, error) { |
| + if len(pepper) < 32 { |
| + return nil, errors.New( |
| + "auth: the email pepper must be at least 32 bytes; set CONTRA_EMAIL_PEPPER") |
| + } |
| + return &Auth{db: db, store: store.New(db), sess: sess, pepper: pepper}, nil |
| +} |
| + |
| +// NormalizeEmail is deliberately minimal: trim and lowercase, nothing more. |
| +// Anything cleverer — stripping dots, cutting +tags — would silently merge |
| +// two addresses a person believes are separate accounts, and there is no |
| +// recovery path from having merged two vaults. |
| +func NormalizeEmail(email string) string { |
| + return strings.ToLower(strings.TrimSpace(email)) |
| +} |
| + |
| +// BlindIndex is the whole of what the server knows about an address. |
| +// |
| +// It proves identity for sign-in and can never decrypt anything. There is no |
| +// stored address column and no provider user-id. |
| +func (a *Auth) BlindIndex(email string) []byte { |
| + m := hmac.New(sha256.New, a.pepper) |
| + m.Write([]byte("contra/v1/email-index")) |
| + m.Write([]byte{0}) |
| + m.Write([]byte(NormalizeEmail(email))) |
| + return m.Sum(nil) |
| +} |
| + |
| +// decoySalt is the deterministic answer for an address with no account. |
| +// |
| +// Deterministic is the whole point: a prober who asks twice must get the same |
| +// answer, or the variance itself says "no account here". Domain-separated |
| +// from the blind index so the two can never be confused for one another. |
| +func (a *Auth) decoySalt(email string) []byte { |
| + m := hmac.New(sha256.New, a.pepper) |
| + m.Write([]byte("contra/v1/decoy-salt")) |
| + m.Write([]byte{0}) |
| + m.Write([]byte(NormalizeEmail(email))) |
| + return m.Sum(nil)[:16] |
| +} |
| + |
| +// CheckParams is the server-side half of the KDF floor. |
| +func CheckParams(raw string) error { |
| + var p struct { |
| + Alg string `json:"alg"` |
| + MKiB uint32 `json:"m_kib"` |
| + T uint32 `json:"t"` |
| + P uint32 `json:"p"` |
| + } |
| + if err := json.Unmarshal([]byte(raw), &p); err != nil { |
| + return fmt.Errorf("%w: unreadable parameters", ErrWeakParams) |
| + } |
| + if p.Alg != "argon2id" { |
| + return fmt.Errorf("%w: algorithm %q", ErrWeakParams, p.Alg) |
| + } |
| + if p.MKiB < MinMemoryKiB || p.T < MinTime || p.P != Lanes { |
| + return fmt.Errorf("%w: m=%d t=%d p=%d", ErrWeakParams, p.MKiB, p.T, p.P) |
| + } |
| + return nil |
| +} |
| + |
| +// ---------------------------------------------------------------- the wire |
| + |
| +type saltRequest struct { |
| + Email string `json:"email"` |
| +} |
| + |
| +type saltResponse struct { |
| + SaltHex string `json:"salt_hex"` |
| + KDFParams string `json:"kdf_params"` |
| +} |
| + |
| +type signupRequest struct { |
| + Email string `json:"email"` |
| + SaltHex string `json:"salt_hex"` |
| + LoginKeys []struct { |
| + Branch string `json:"branch"` |
| + PublicKeyHex string `json:"public_key_hex"` |
| + } `json:"login_keys"` |
| + Wraps []struct { |
| + Kind string `json:"kind"` |
| + KDFParams string `json:"kdf_params"` |
| + Version uint64 `json:"version"` |
| + KCVHex string `json:"kcv_hex"` |
| + CredSaltHex string `json:"cred_salt_hex"` |
| + NonceHex string `json:"nonce_hex"` |
| + CiphertextHex string `json:"ciphertext_hex"` |
| + } `json:"wraps"` |
| +} |
| + |
| +type challengeRequest struct { |
| + Email string `json:"email"` |
| + Branch string `json:"branch"` |
| +} |
| + |
| +type challengeResponse struct { |
| + ChallengeHex string `json:"challenge_hex"` |
| + SaltHex string `json:"salt_hex"` |
| + KDFParams string `json:"kdf_params"` |
| +} |
| + |
| +type verifyRequest struct { |
| + Email string `json:"email"` |
| + Branch string `json:"branch"` |
| + ChallengeHex string `json:"challenge_hex"` |
| + SignatureHex string `json:"signature_hex"` |
| +} |
| + |
| +// DefaultKDFParams is what a new client is told to use. Served, not trusted: |
| +// the client pins its own floor and refuses anything weaker, because a |
| +// malicious server offering m=8KiB is exactly the attack. |
| +const DefaultKDFParams = `{"alg":"argon2id","m_kib":65536,"t":3,"p":1}` |
| + |
| +func writeJSON(w http.ResponseWriter, status int, v any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(status) |
| + _ = json.NewEncoder(w).Encode(v) |
| +} |
| + |
| +func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { |
| + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)) |
| + dec.DisallowUnknownFields() |
| + if err := dec.Decode(v); err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed request"}) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// Salt answers the pre-auth question "what do I stretch my passphrase |
| +// against?" — and answers it for every address, whether or not an account |
| +// exists, so that account existence does not leak from it. |
| +func (a *Auth) Salt(w http.ResponseWriter, r *http.Request) { |
| + var req saltRequest |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + |
| + var acct model.Account |
| + err := a.db.WithContext(r.Context()). |
| + Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error |
| + |
| + salt := a.decoySalt(req.Email) |
| + if err == nil { |
| + salt = acct.KDFSalt |
| + } else if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, saltResponse{ |
| + SaltHex: hex.EncodeToString(salt), KDFParams: DefaultKDFParams}) |
| +} |
| + |
| +// Signup records what the browser generated. RK never existed here: what |
| +// arrives is three sealed wraps of it and the public halves of two keypairs. |
| +func (a *Auth) Signup(w http.ResponseWriter, r *http.Request) { |
| + var req signupRequest |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + if NormalizeEmail(req.Email) == "" { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "an address is required"}) |
| + return |
| + } |
| + |
| + salt, err := hex.DecodeString(req.SaltHex) |
| + if err != nil || len(salt) != 16 { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad salt"}) |
| + return |
| + } |
| + for _, wr := range req.Wraps { |
| + if err := CheckParams(wr.KDFParams); err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + return |
| + } |
| + } |
| + |
| + index := a.BlindIndex(req.Email) |
| + ctx := r.Context() |
| + |
| + var existing model.Account |
| + if err := a.db.WithContext(ctx).Where("email_index = ?", index). |
| + First(&existing).Error; err == nil { |
| + writeJSON(w, http.StatusConflict, map[string]string{"error": ErrTaken.Error()}) |
| + return |
| + } else if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| + return |
| + } |
| + |
| + acct, err := a.store.CreateAccount(ctx, index, salt) |
| + if err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create"}) |
| + return |
| + } |
| + for _, k := range req.LoginKeys { |
| + pub, err := hex.DecodeString(k.PublicKeyHex) |
| + if err != nil || len(pub) != 65 { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad public key"}) |
| + return |
| + } |
| + if _, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), pub); err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "public key is not on the curve"}) |
| + return |
| + } |
| + if err := a.store.PutLoginKey(ctx, acct.ID, k.Branch, pub); err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not store key"}) |
| + return |
| + } |
| + } |
| + for _, wr := range req.Wraps { |
| + kcv, e1 := hex.DecodeString(wr.KCVHex) |
| + nonce, e2 := hex.DecodeString(wr.NonceHex) |
| + ct, e3 := hex.DecodeString(wr.CiphertextHex) |
| + credSalt, e4 := hex.DecodeString(wr.CredSaltHex) |
| + if e1 != nil || e2 != nil || e3 != nil || e4 != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad wrap"}) |
| + return |
| + } |
| + if err := a.store.PutWrap(ctx, acct.ID, wr.Kind, wr.KDFParams, wr.Version, |
| + kcv, credSalt, store.Sealed{Nonce: nonce, Ciphertext: ct}); err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + return |
| + } |
| + } |
| + |
| + writeJSON(w, http.StatusCreated, map[string]string{ |
| + "account_id_hex": hex.EncodeToString(acct.ID)}) |
| +} |
| + |
| +// Challenge issues the bytes a client proves possession over — and issues |
| +// them for an unknown address too, from the same code path and the same |
| +// randomness, so a prober cannot tell the two apart from the response. |
| +func (a *Auth) Challenge(w http.ResponseWriter, r *http.Request) { |
| + var req challengeRequest |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + if req.Branch != "pw" && req.Branch != "rc" { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown branch"}) |
| + return |
| + } |
| + |
| + nonce := make([]byte, 32) |
| + if _, err := rand.Read(nonce); err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"}) |
| + return |
| + } |
| + |
| + resp := challengeResponse{ |
| + ChallengeHex: hex.EncodeToString(nonce), |
| + SaltHex: hex.EncodeToString(a.decoySalt(req.Email)), |
| + KDFParams: DefaultKDFParams, |
| + } |
| + |
| + var acct model.Account |
| + err := a.db.WithContext(r.Context()). |
| + Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error |
| + if err == nil { |
| + id, err := store.NewID() |
| + if err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"}) |
| + return |
| + } |
| + if err := a.db.WithContext(r.Context()).Create(&model.LoginChallenge{ |
| + ID: id, |
| + AccountID: acct.ID, |
| + Branch: req.Branch, |
| + Challenge: nonce, |
| + ExpiresAt: time.Now().UTC().Add(ChallengeTTL), |
| + CreatedAt: time.Now().UTC(), |
| + }).Error; err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not issue"}) |
| + return |
| + } |
| + resp.SaltHex = hex.EncodeToString(acct.KDFSalt) |
| + } else if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| + return |
| + } |
| + // An unknown address gets a challenge that was never recorded, so the |
| + // ceremony fails one step later — at Verify, where every failure looks |
| + // the same — instead of here, where the shape of the failure would say |
| + // whether the account exists. |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
| + |
| +// SignedMessage is what a client signs: the ceremony's context, the branch, |
| +// and the challenge. Binding the branch stops a challenge issued for the |
| +// recovery credential being spent on the passphrase one. |
| +func SignedMessage(branch string, challenge []byte) []byte { |
| + out := make([]byte, 0, len(challengeContext)+2+len(branch)+len(challenge)) |
| + out = append(out, challengeContext...) |
| + out = append(out, 0) |
| + out = append(out, branch...) |
| + out = append(out, 0) |
| + return append(out, challenge...) |
| +} |
| + |
| +// Verify checks the signature and, on success, mints a session. |
| +// |
| +// Every failure answers the same thing. An unknown address, an expired |
| +// challenge, a challenge already spent, and a wrong signature are four very |
| +// different facts, and telling them apart is the caller's problem, not ours. |
| +func (a *Auth) Verify(w http.ResponseWriter, r *http.Request) { |
| + var req verifyRequest |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + refuse := func() { |
| + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "could not sign in"}) |
| + } |
| + |
| + challenge, err := hex.DecodeString(req.ChallengeHex) |
| + if err != nil || len(challenge) != 32 { |
| + refuse() |
| + return |
| + } |
| + sig, err := hex.DecodeString(req.SignatureHex) |
| + if err != nil || len(sig) != 64 { |
| + refuse() |
| + return |
| + } |
| + |
| + ctx := r.Context() |
| + var acct model.Account |
| + if err := a.db.WithContext(ctx). |
| + Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error; err != nil { |
| + refuse() |
| + return |
| + } |
| + |
| + var row model.LoginChallenge |
| + if err := a.db.WithContext(ctx). |
| + Where("account_id = ? AND branch = ? AND challenge = ?", |
| + acct.ID, req.Branch, challenge).First(&row).Error; err != nil { |
| + refuse() |
| + return |
| + } |
| + // Single use, spent whether or not the signature turns out to be good: |
| + // a challenge that survives a failed attempt is a challenge an attacker |
| + // can grind against. |
| + if err := a.db.WithContext(ctx).Delete(&model.LoginChallenge{}, "id = ?", row.ID).Error; err != nil { |
| + refuse() |
| + return |
| + } |
| + if time.Now().UTC().After(row.ExpiresAt) { |
| + refuse() |
| + return |
| + } |
| + |
| + var key model.LoginKey |
| + if err := a.db.WithContext(ctx). |
| + Where("account_id = ? AND branch = ?", acct.ID, req.Branch).First(&key).Error; err != nil { |
| + refuse() |
| + return |
| + } |
| + pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), key.PublicKey) |
| + if err != nil { |
| + refuse() |
| + return |
| + } |
| + |
| + digest := sha256.Sum256(SignedMessage(req.Branch, challenge)) |
| + rr := new(big.Int).SetBytes(sig[:32]) |
| + ss := new(big.Int).SetBytes(sig[32:]) |
| + if !ecdsa.Verify(pub, digest[:], rr, ss) { |
| + refuse() |
| + return |
| + } |
| + |
| + if err := a.sess.SignIn(w, r, sessions.Session{ |
| + Subject: hex.EncodeToString(acct.ID), |
| + Method: "passphrase/" + req.Branch, |
| + AuthTime: time.Now().UTC(), |
| + }); err != nil { |
| + refuse() |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]string{ |
| + "account_id_hex": hex.EncodeToString(acct.ID)}) |
| +} |
| + |
| +// SignOut ends the session. Sessions are rows, so this is a real revocation |
| +// rather than a forgotten cookie. |
| +func (a *Auth) SignOut(w http.ResponseWriter, r *http.Request) { |
| + a.sess.SignOut(w, r) |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| +} |
| + |
| +// SweepChallenges deletes expired challenges. Cheap, and it keeps a table |
| +// that only ever holds live state from becoming a log of when people signed |
| +// in — which would be exactly the transport-shape residue we do not have to |
| +// keep. |
| +func (a *Auth) SweepChallenges(now time.Time) error { |
| + return a.db.Where("expires_at < ?", now.UTC()).Delete(&model.LoginChallenge{}).Error |
| +} |
diff --git a/internal/contra/handlers.go b/internal/contra/handlers.go| index faf9983..a2bbaf0 100644 |
| --- a/internal/contra/handlers.go |
| +++ b/internal/contra/handlers.go |
| @@ -6,19 +6,52 @@ import ( |
| "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + |
| + "github.com/carlosframework/contra/internal/contra/auth" |
| ) |
| -// app holds what every handler needs. When the app grows accounts, |
| -// this is where the sessions handle lands too. |
| +// app holds what every handler needs. |
| type app struct { |
| db *db.DB |
| logger *slog.Logger |
| + sess *sessions.Sessions |
| +} |
| + |
| +// page is the data every template renders against. |
| +type page struct { |
| + Version string |
| + SignedIn bool |
| + KDFParams string |
| } |
| -// index is the whole of the bootstrap surface. It names the running build, |
| -// so looking at the canary tells you which release you are looking at — the |
| -// same fact GET /api/version reports to the platform's deploy check, on the |
| -// page a person actually opens. |
| +func (a *app) pageData(r *http.Request) page { |
| + _, signedIn := sessions.Current(r) |
| + return page{ |
| + Version: rastrillo.BuildVersion, |
| + SignedIn: signedIn, |
| + KDFParams: auth.DefaultKDFParams, |
| + } |
| +} |
| + |
| +// index names the running build, so looking at the canary tells you which |
| +// release you are looking at — the same fact GET /api/version reports to the |
| +// platform's deploy check, on the page a person actually opens. |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| - render(w, "index", map[string]any{"Version": rastrillo.BuildVersion}) |
| + render(w, "index", a.pageData(r)) |
| +} |
| + |
| +func (a *app) signupPage(w http.ResponseWriter, r *http.Request) { |
| + render(w, "signup", a.pageData(r)) |
| +} |
| + |
| +func (a *app) signinPage(w http.ResponseWriter, r *http.Request) { |
| + render(w, "signin", a.pageData(r)) |
| +} |
| + |
| +// vault is the signed-in surface, and at this slice it is deliberately almost |
| +// empty: the items themselves are web/6. What it proves today is that the |
| +// ceremony ends somewhere only a signed-in browser can reach. |
| +func (a *app) vault(w http.ResponseWriter, r *http.Request) { |
| + render(w, "vault", a.pageData(r)) |
| } |
diff --git a/internal/contra/migrations/0002_create_login_challenges.sql b/internal/contra/migrations/0002_create_login_challenges.sql| new file mode 100644 |
| index 0000000..b0b4806 |
| --- /dev/null |
| +++ b/internal/contra/migrations/0002_create_login_challenges.sql |
| @@ -0,0 +1,8 @@ |
| +CREATE TABLE `login_challenges` (`id` blob,`account_id` blob NOT NULL,`branch` text NOT NULL,`challenge` blob NOT NULL,`expires_at` datetime NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE INDEX `idx_login_challenges_expires_at` ON `login_challenges`(`expires_at`); |
| + |
| +CREATE INDEX `idx_login_challenges_challenge` ON `login_challenges`(`challenge`); |
| + |
| +CREATE INDEX `idx_login_challenges_account_id` ON `login_challenges`(`account_id`); |
| + |
diff --git a/internal/contra/migrations/schema.sql b/internal/contra/migrations/schema.sql| index d3807d3..d386bed 100644 |
| --- a/internal/contra/migrations/schema.sql |
| +++ b/internal/contra/migrations/schema.sql |
| @@ -1,3 +1,39 @@ |
| -- Generated by rastrillo migration generate; DO NOT EDIT. |
| -- The schema every migration in this directory adds up to. |
| +CREATE TABLE `accounts` (`id` blob,`email_index` blob NOT NULL,`kdf_salt` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE TABLE `items` (`id` blob,`vault_id` blob NOT NULL,`version` integer NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,`updated_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE TABLE `login_challenges` (`id` blob,`account_id` blob NOT NULL,`branch` text NOT NULL,`challenge` blob NOT NULL,`expires_at` datetime NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE TABLE `login_keys` (`id` blob,`account_id` blob NOT NULL,`branch` text NOT NULL,`public_key` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE TABLE `vault_members` (`id` blob,`vault_id` blob NOT NULL,`account_id` blob NOT NULL,`ephemeral_public_key` blob NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE TABLE `vaults` (`id` blob,`account_id` blob NOT NULL,`drop_box_public_key` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE TABLE `wraps` (`id` blob,`account_id` blob NOT NULL,`kind` text NOT NULL,`format_version` integer NOT NULL,`version` integer NOT NULL,`kdf_params` text NOT NULL,`cred_salt` blob,`kcv` blob NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE UNIQUE INDEX `idx_accounts_email_index` ON `accounts`(`email_index`); |
| + |
| +CREATE INDEX `idx_items_vault_id` ON `items`(`vault_id`); |
| + |
| +CREATE INDEX `idx_login_challenges_account_id` ON `login_challenges`(`account_id`); |
| + |
| +CREATE INDEX `idx_login_challenges_challenge` ON `login_challenges`(`challenge`); |
| + |
| +CREATE INDEX `idx_login_challenges_expires_at` ON `login_challenges`(`expires_at`); |
| + |
| +CREATE INDEX `idx_login_keys_account_id` ON `login_keys`(`account_id`); |
| + |
| +CREATE INDEX `idx_vault_members_account_id` ON `vault_members`(`account_id`); |
| + |
| +CREATE INDEX `idx_vault_members_vault_id` ON `vault_members`(`vault_id`); |
| + |
| +CREATE INDEX `idx_vaults_account_id` ON `vaults`(`account_id`); |
| + |
| +CREATE INDEX `idx_wraps_account_id` ON `wraps`(`account_id`); |
| + |
| +CREATE INDEX `idx_wraps_kind` ON `wraps`(`kind`); |
| + |
diff --git a/internal/contra/model/model.go b/internal/contra/model/model.go| new file mode 100644 |
| index 0000000..1dd122d |
| --- /dev/null |
| +++ b/internal/contra/model/model.go |
| @@ -0,0 +1,210 @@ |
| +// Package model is CONTRA's sealed tables, and nothing else. |
| +// |
| +// It is a package of its own so that the app, the store and the auth |
| +// ceremony can all name a row without importing one another — but the |
| +// separation earns its keep for a second reason: every struct in here is the |
| +// complete answer to "what does the instance keep?", in one file, with |
| +// nothing else in the way of reading it. |
| +// |
| +// # The rule these tables are shaped by |
| +// |
| +// CONTRA is CARLOS A1 strict, and the whole SQLite file is continuously |
| +// replicated to object storage — so a plaintext column is not a bug caught in |
| +// review, it is a plaintext leak, in a bucket, forever. Item names, URLs, |
| +// usernames, folder structure and vault names seal client-side exactly like |
| +// passwords do, because a name is a secret too. |
| +// |
| +// What that means for anyone adding a column: the server stores ciphertext, |
| +// public keys, opaque random ids, and the parameters it needs to hand the |
| +// right blob back. Nothing else. There is no column for a searchable title, |
| +// no column for a display name, and no "just for the admin page" column. |
| +// |
| +// **A new storage surface lands with its canary or it does not land.** The |
| +// greps in internal/contratest/greps_test.go push canary strings through |
| +// every write path and search the raw database bytes; a new table belongs in |
| +// that test's write list on the same commit that adds it. |
| +// |
| +// Every id is a random UUID, never a content hash: a content-derived |
| +// identifier at rest is both an A1 violation and an equality oracle. |
| +package model |
| + |
| +import "time" |
| + |
| +// Account is an identity on this instance. |
| +// |
| +// There is no address column and no provider user-id. EmailIndex is Kass's |
| +// blind index — an HMAC under a server-held pepper — which proves identity |
| +// for sign-in and can never decrypt anything (AGENTS.md decision 3). The |
| +// pepper belongs off the instance, at the routing layer or in a KMS, so that |
| +// a database-plus-bucket leak alone cannot unblind addresses. |
| +type Account struct { |
| + // ID is 16 random bytes. Not derived from the email, not derived from |
| + // anything: an id you can recompute from content is an equality oracle. |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + |
| + // EmailIndex is HMAC-SHA-256(pepper, normalized_email), 32 bytes. |
| + EmailIndex []byte `gorm:"uniqueIndex;type:blob;not null"` |
| + |
| + // KDFSalt is the 16 random bytes the passphrase branch stretches |
| + // against, served pre-auth. The salt endpoint answers an unknown email |
| + // with a deterministic fake so account existence does not leak from it. |
| + KDFSalt []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// LoginKey is a public key the server authenticates a signed challenge |
| +// against — the whole of what it stores about a credential. |
| +// |
| +// Not a password verifier, hashed or otherwise. A fully compromised server |
| +// holds something that cannot decrypt, cannot be replayed to impersonate, |
| +// and adds no offline attack surface it did not already have via the wraps. |
| +type LoginKey struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // Branch is "pw" or "rc" — which peer credential this key belongs to. |
| + // A label of ours, not derived from anything the user typed. |
| + Branch string `gorm:"not null"` |
| + |
| + // PublicKey is the SEC1 uncompressed P-256 point, 65 bytes. |
| + PublicKey []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// Wrap is one of the three peer wraps of the root key: passphrase, recovery |
| +// code, passkey PRF. Peers, not a chain — each is independently sufficient |
| +// to reach RK and none can be derived from another. |
| +// |
| +// DESIGN.md requires every wrap row to carry a format version, the KDF |
| +// parameters (authenticated as AAD), a 16-byte KCV, a 96-bit nonce and the |
| +// ciphertext. All six are here, and the KCV is load-bearing twice over: |
| +// AES-GCM is not key-committing, so it is the commitment check, and it is |
| +// what tells "wrong key" from "corrupted blob" — the distinction that lets a |
| +// passkey provider returning a mismatched PRF be read as "fast unlock |
| +// unavailable" rather than an authentication failure. |
| +type Wrap struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // Kind is the AAD's object type: "wrap.pw", "wrap.rc" or "wrap.prf". |
| + Kind string `gorm:"index;not null"` |
| + |
| + // FormatVersion and Version are the wrap header. Version is bound into |
| + // the AAD, so a malicious server cannot roll a row back to an older |
| + // ciphertext and have it open. |
| + FormatVersion int `gorm:"not null"` |
| + Version uint64 `gorm:"not null"` |
| + |
| + // KDFParams is the canonical JSON of the parameters this wrap was made |
| + // under, authenticated as AAD. Stored so parameters can be raised later |
| + // with a transparent re-wrap on the next successful unlock — and so the |
| + // client can refuse a set below its floor. |
| + KDFParams string `gorm:"not null"` |
| + |
| + // CredSalt is the passkey credential's 32 non-secret random bytes, |
| + // empty on the other two branches. Several PRF wraps per credential are |
| + // harmless: each is an independent ciphertext of the same RK, which is |
| + // what lets a newly observed provider output be enrolled as an |
| + // additional wrap instead of stranding anyone. |
| + CredSalt []byte `gorm:"type:blob"` |
| + |
| + KCV []byte `gorm:"type:blob;not null"` // 16 bytes |
| + Nonce []byte `gorm:"type:blob;not null"` // 12 bytes |
| + Ciphertext []byte `gorm:"type:blob;not null"` // RK, sealed, tag appended |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// Vault is a container of items. Its name is not here: a vault name is |
| +// metadata, metadata is a secret, and it seals into the vault keyset the |
| +// members hold. The server knows a vault exists and who it serves bytes to. |
| +type Vault struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + |
| + // AccountID is the owner. A shared vault lives on its owner's instance; |
| + // other members are identities on that instance (AGENTS.md decision 1). |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // DropBoxPublicKey is vP_j, the SEC1 uncompressed P-256 point anything |
| + // can seal to without unlocking — how a daemon captures an item with no |
| + // key in memory. The private half never leaves the members. |
| + DropBoxPublicKey []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// VaultMember is the vault keyset sealed to one member's account public key: |
| +// ephemeral-static ECDH over P-256, HKDF, AES-GCM. |
| +// |
| +// These rows are the sharing graph, and the sharing graph is at-rest |
| +// metadata that A1 tolerates — wrap rows reveal who shares with whom. That |
| +// is published in the model rather than hidden, and hiding it (per-vault |
| +// pseudonymous member slots) is a choice we have looked at and not made. |
| +// |
| +// Removing a member rotates the vault, because in a seal-to-public-key model |
| +// revocation *is* rotation: the removed member may have cached the key. |
| +// Nothing already downloaded is un-downloaded, and no copy pretends |
| +// otherwise. |
| +type VaultMember struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + VaultID []byte `gorm:"index;type:blob;not null"` |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // EphemeralPublicKey is the sender's throwaway half of the ECDH. |
| + EphemeralPublicKey []byte `gorm:"type:blob;not null"` |
| + |
| + Nonce []byte `gorm:"type:blob;not null"` |
| + Ciphertext []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// Item is one entry in a vault, and it is a single opaque blob on purpose. |
| +// |
| +// Title, URL, username, notes, TOTP seed and password are all inside the one |
| +// ciphertext. Splitting them into columns — even sealed ones — would publish |
| +// which fields an item has and how long each is, and would invite exactly |
| +// the plaintext "just the title, for the list" column this whole file exists |
| +// to refuse. The client seals the item; the list view is built from items |
| +// the client has already opened. |
| +// |
| +// Ciphertext lengths are quantised into 256-byte buckets before sealing, so |
| +// the bucket cannot be read for item-length fingerprints. Store.PutItem |
| +// enforces it rather than trusting the caller. |
| +type Item struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + VaultID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // Version is bound into the AAD along with the vault and item ids, so a |
| + // ciphertext cannot be transplanted between slots or rolled back. |
| + Version uint64 `gorm:"not null"` |
| + |
| + Nonce []byte `gorm:"type:blob;not null"` |
| + Ciphertext []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
| + |
| +// LoginChallenge is live state, not history: the bytes a client is proving |
| +// possession of, between the two requests of one sign-in ceremony. |
| +// |
| +// It holds nothing derived from content — a random nonce, an opaque account |
| +// id, and a branch label of ours. Rows are deleted the moment they are spent |
| +// and swept when they expire, deliberately: a challenge table that accumulated |
| +// would become a log of when each account signed in, which is transport-shape |
| +// residue we do not have to keep and therefore should not. |
| +type LoginChallenge struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // Branch is "pw" or "rc". Bound into the signed message, so a challenge |
| + // issued for the recovery credential cannot be spent on the passphrase one. |
| + Branch string `gorm:"not null"` |
| + |
| + Challenge []byte `gorm:"index;type:blob;not null"` |
| + ExpiresAt time.Time `gorm:"index;not null"` |
| + CreatedAt time.Time |
| +} |
diff --git a/internal/contra/models.go b/internal/contra/models.go| index 8dc16d5..7d4ab19 100644 |
| --- a/internal/contra/models.go |
| +++ b/internal/contra/models.go |
| @@ -1,195 +1,25 @@ |
| package contra |
| -import "time" |
| +import "github.com/carlosframework/contra/internal/contra/model" |
| // Models is every model the schema generator manages: `rastrillo migration |
| // generate` reads it to work out what the database should look like, and |
| // `rastrillo migration check` fails CI when it and the migrations disagree. |
| // |
| -// # The rule these tables are shaped by |
| +// The structs themselves live in internal/contra/model, so the app, the store |
| +// and the auth ceremony can each name a row without importing one another. |
| +// That package's doc comment carries the rule they are shaped by; read it |
| +// before adding a column here. |
| // |
| -// CONTRA is CARLOS A1 strict, and the whole SQLite file is continuously |
| -// replicated to object storage — so a plaintext column is a plaintext leak, |
| -// in a bucket, forever. Item names, URLs, usernames, folder structure and |
| -// vault names seal client-side exactly like passwords do, because a name is |
| -// a secret too. |
| -// |
| -// What that means for anyone adding a column here: the server stores |
| -// ciphertext, public keys, opaque random ids, and the parameters it needs to |
| -// hand the right blob back. Nothing else. There is no column for a |
| -// searchable title, no column for a display name, and no "just for the |
| -// admin page" column. |
| -// |
| -// **A new storage surface lands with its canary or it does not land.** The |
| -// greps in internal/contratest/greps_test.go push canary strings through |
| -// every write path and search the raw database bytes for them; a new table |
| -// belongs in that test's write list on the same commit that adds it. |
| -// |
| -// Every id is a random UUID, never a content hash: a content-derived |
| -// identifier at rest is both an A1 violation and an equality oracle. |
| +// This list is what the generator sees. A model that exists but is missing |
| +// from it has no migration and no table, and nothing will say so until a |
| +// query fails in production. |
| var Models = []any{ |
| - &Account{}, |
| - &LoginKey{}, |
| - &Wrap{}, |
| - &Vault{}, |
| - &VaultMember{}, |
| - &Item{}, |
| -} |
| - |
| -// Account is an identity on this instance. |
| -// |
| -// There is no address column and no provider user-id. EmailIndex is Kass's |
| -// blind index — an HMAC under a server-held pepper — which proves identity |
| -// for sign-in and can never decrypt anything (AGENTS.md decision 3). The |
| -// pepper belongs off the instance, at the routing layer or in a KMS, so that |
| -// a database-plus-bucket leak alone cannot unblind addresses. |
| -type Account struct { |
| - // ID is 16 random bytes. Not derived from the email, not derived from |
| - // anything: an id you can recompute from content is an equality oracle. |
| - ID []byte `gorm:"primaryKey;type:blob"` |
| - |
| - // EmailIndex is HMAC-SHA-256(pepper, normalized_email), 32 bytes. |
| - EmailIndex []byte `gorm:"uniqueIndex;type:blob;not null"` |
| - |
| - // KDFSalt is the 16 random bytes the passphrase branch stretches |
| - // against, served pre-auth. The salt endpoint answers an unknown email |
| - // with a deterministic fake so account existence does not leak from it. |
| - KDFSalt []byte `gorm:"type:blob;not null"` |
| - |
| - CreatedAt time.Time |
| -} |
| - |
| -// LoginKey is a public key the server authenticates a signed challenge |
| -// against — the whole of what it stores about a credential. |
| -// |
| -// Not a password verifier, hashed or otherwise. A fully compromised server |
| -// holds something that cannot decrypt, cannot be replayed to impersonate, |
| -// and adds no offline attack surface it did not already have via the wraps. |
| -type LoginKey struct { |
| - ID []byte `gorm:"primaryKey;type:blob"` |
| - AccountID []byte `gorm:"index;type:blob;not null"` |
| - |
| - // Branch is "pw" or "rc" — which peer credential this key belongs to. |
| - // A label of ours, not derived from anything the user typed. |
| - Branch string `gorm:"not null"` |
| - |
| - // PublicKey is the SEC1 uncompressed P-256 point, 65 bytes. |
| - PublicKey []byte `gorm:"type:blob;not null"` |
| - |
| - CreatedAt time.Time |
| -} |
| - |
| -// Wrap is one of the three peer wraps of the root key: passphrase, recovery |
| -// code, passkey PRF. Peers, not a chain — each is independently sufficient |
| -// to reach RK and none can be derived from another. |
| -// |
| -// DESIGN.md requires every wrap row to carry a format version, the KDF |
| -// parameters (authenticated as AAD), a 16-byte KCV, a 96-bit nonce and the |
| -// ciphertext. All six are here, and the KCV is load-bearing twice over: |
| -// AES-GCM is not key-committing, so it is the commitment check, and it is |
| -// what tells "wrong key" from "corrupted blob" — the distinction that lets a |
| -// passkey provider returning a mismatched PRF be read as "fast unlock |
| -// unavailable" rather than an authentication failure. |
| -type Wrap struct { |
| - ID []byte `gorm:"primaryKey;type:blob"` |
| - AccountID []byte `gorm:"index;type:blob;not null"` |
| - |
| - // Kind is the AAD's object type: "wrap.pw", "wrap.rc" or "wrap.prf". |
| - Kind string `gorm:"index;not null"` |
| - |
| - // FormatVersion and Version are the wrap header. Version is bound into |
| - // the AAD, so a malicious server cannot roll a row back to an older |
| - // ciphertext and have it open. |
| - FormatVersion int `gorm:"not null"` |
| - Version uint64 `gorm:"not null"` |
| - |
| - // KDFParams is the canonical JSON of the parameters this wrap was made |
| - // under, authenticated as AAD. Stored so parameters can be raised later |
| - // with a transparent re-wrap on the next successful unlock — and so the |
| - // client can refuse a set below its floor. |
| - KDFParams string `gorm:"not null"` |
| - |
| - // CredSalt is the passkey credential's 32 non-secret random bytes, |
| - // empty on the other two branches. Several PRF wraps per credential are |
| - // harmless: each is an independent ciphertext of the same RK, which is |
| - // what lets a newly observed provider output be enrolled as an |
| - // additional wrap instead of stranding anyone. |
| - CredSalt []byte `gorm:"type:blob"` |
| - |
| - KCV []byte `gorm:"type:blob;not null"` // 16 bytes |
| - Nonce []byte `gorm:"type:blob;not null"` // 12 bytes |
| - Ciphertext []byte `gorm:"type:blob;not null"` // RK, sealed, tag appended |
| - |
| - CreatedAt time.Time |
| -} |
| - |
| -// Vault is a container of items. Its name is not here: a vault name is |
| -// metadata, metadata is a secret, and it seals into the vault keyset the |
| -// members hold. The server knows a vault exists and who it serves bytes to. |
| -type Vault struct { |
| - ID []byte `gorm:"primaryKey;type:blob"` |
| - |
| - // AccountID is the owner. A shared vault lives on its owner's instance; |
| - // other members are identities on that instance (AGENTS.md decision 1). |
| - AccountID []byte `gorm:"index;type:blob;not null"` |
| - |
| - // DropBoxPublicKey is vP_j, the SEC1 uncompressed P-256 point anything |
| - // can seal to without unlocking — how a daemon captures an item with no |
| - // key in memory. The private half never leaves the members. |
| - DropBoxPublicKey []byte `gorm:"type:blob;not null"` |
| - |
| - CreatedAt time.Time |
| -} |
| - |
| -// VaultMember is the vault keyset sealed to one member's account public key: |
| -// ephemeral-static ECDH over P-256, HKDF, AES-GCM. |
| -// |
| -// These rows are the sharing graph, and the sharing graph is at-rest |
| -// metadata that A1 tolerates — wrap rows reveal who shares with whom. That |
| -// is published in the model rather than hidden, and hiding it (per-vault |
| -// pseudonymous member slots) is a choice we have looked at and not made. |
| -// |
| -// Removing a member rotates the vault, because in a seal-to-public-key model |
| -// revocation *is* rotation: the removed member may have cached the key. |
| -// Nothing already downloaded is un-downloaded, and no copy pretends |
| -// otherwise. |
| -type VaultMember struct { |
| - ID []byte `gorm:"primaryKey;type:blob"` |
| - VaultID []byte `gorm:"index;type:blob;not null"` |
| - AccountID []byte `gorm:"index;type:blob;not null"` |
| - |
| - // EphemeralPublicKey is the sender's throwaway half of the ECDH. |
| - EphemeralPublicKey []byte `gorm:"type:blob;not null"` |
| - |
| - Nonce []byte `gorm:"type:blob;not null"` |
| - Ciphertext []byte `gorm:"type:blob;not null"` |
| - |
| - CreatedAt time.Time |
| -} |
| - |
| -// Item is one entry in a vault, and it is a single opaque blob on purpose. |
| -// |
| -// Title, URL, username, notes, TOTP seed and password are all inside the one |
| -// ciphertext. Splitting them into columns — even sealed ones — would publish |
| -// which fields an item has and how long each is, and would invite exactly |
| -// the plaintext "just the title, for the list" column this whole file exists |
| -// to refuse. The client seals the item; the list view is built from items |
| -// the client has already opened. |
| -// |
| -// Ciphertext lengths are quantised into 256-byte buckets before sealing, so |
| -// the bucket cannot be read for item-length fingerprints. Store.PutItem |
| -// enforces it rather than trusting the caller. |
| -type Item struct { |
| - ID []byte `gorm:"primaryKey;type:blob"` |
| - VaultID []byte `gorm:"index;type:blob;not null"` |
| - |
| - // Version is bound into the AAD along with the vault and item ids, so a |
| - // ciphertext cannot be transplanted between slots or rolled back. |
| - Version uint64 `gorm:"not null"` |
| - |
| - Nonce []byte `gorm:"type:blob;not null"` |
| - Ciphertext []byte `gorm:"type:blob;not null"` |
| - |
| - CreatedAt time.Time |
| - UpdatedAt time.Time |
| + &model.Account{}, |
| + &model.LoginKey{}, |
| + &model.Wrap{}, |
| + &model.Vault{}, |
| + &model.VaultMember{}, |
| + &model.Item{}, |
| + &model.LoginChallenge{}, |
| } |
diff --git a/internal/contra/render.go b/internal/contra/render.go| index 84d34f7..b0c2d48 100644 |
| --- a/internal/contra/render.go |
| +++ b/internal/contra/render.go |
| @@ -26,7 +26,7 @@ var assets = rastrillo.NewAssets(appFS) |
| var pages = map[string]*template.Template{} |
| func init() { |
| - for _, name := range []string{"index", "errors"} { |
| + for _, name := range []string{"index", "errors", "signup", "signin", "vault"} { |
| pages[name] = template.Must( |
| // ui's partials and their helpers first, so every page can |
| // call one without registering anything. WithIcons points |
diff --git a/internal/contra/static/crypto/account.js b/internal/contra/static/crypto/account.js| new file mode 100644 |
| index 0000000..c7f112c |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/account.js |
| @@ -0,0 +1,150 @@ |
| +// Signup and passphrase sign-in, in the browser. |
| +// |
| +// This is the module that decides what the server ever learns, so read it as |
| +// the answer to that question. RK is generated here and never leaves except |
| +// as three sealed wraps. The passphrase never leaves at all. The server sees |
| +// public keys, ciphertext, and an address it turns into an index it cannot |
| +// reverse. |
| + |
| +import { concat, fromHex, randomBytes, toHex } from "./bytes.js"; |
| +import { argon2id } from "./argon2id.js"; |
| +import { |
| + checkParams, hkdf, kcv, |
| + LABEL_KEK, LABEL_LOGIN, LABEL_RC_KEK, LABEL_RC_LOGIN, LABEL_PRF_KEK, |
| +} from "./kdf.js"; |
| +import { deriveP256, signChallenge } from "./p256.js"; |
| +import { aad, seal, OBJECT_WRAP_PW, OBJECT_WRAP_RC } from "./sealed.js"; |
| + |
| +const CHALLENGE_CONTEXT = "contra/v1/login-challenge"; |
| + |
| +async function postJSON(path, body) { |
| + const res = await fetch(path, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify(body), |
| + }); |
| + const data = await res.json().catch(() => ({})); |
| + if (!res.ok) throw new Error(data.error || `request to ${path} failed`); |
| + return data; |
| +} |
| + |
| +// A wrap row as the server stores it: a format version, the KDF parameters |
| +// (authenticated as AAD), a KCV, a 96-bit nonce and the ciphertext. |
| +async function makeWrap(kind, kek, rk, accountIdPlaceholder, kdfParams, credSalt) { |
| + const wrapId = randomBytes(16); |
| + const nonce = randomBytes(12); |
| + const ciphertext = await seal( |
| + kek, nonce, aad(accountIdPlaceholder, kind, wrapId, 1), rk); |
| + return { |
| + kind, |
| + kdf_params: kdfParams, |
| + version: 1, |
| + kcv_hex: toHex(await kcv(kek)), |
| + cred_salt_hex: credSalt ? toHex(credSalt) : "", |
| + nonce_hex: toHex(nonce), |
| + ciphertext_hex: toHex(ciphertext), |
| + wrap_id_hex: toHex(wrapId), |
| + }; |
| +} |
| + |
| +// signup builds the whole hierarchy client-side and posts only its sealed |
| +// halves. |
| +// |
| +// It returns the two printed values so the caller can put them on the |
| +// Emergency Kit — the ONLY time either is ever available. The recovery code |
| +// is not stored anywhere, by anyone, including us: only its wrap is, so this |
| +// return value is the single moment it exists outside the user's hands. |
| +export async function signup(email, passphrase, onProgress = () => {}) { |
| + const { salt_hex: saltHex, kdf_params: kdfParams } = |
| + await postJSON("/auth/salt", { email }); |
| + |
| + // Before a single expensive byte: the floor. A malicious server offering |
| + // m=8KiB would otherwise harvest one challenge signature and crack the |
| + // passphrase cheaply offline, and the floor is the defence, not the |
| + // ceremony. |
| + const params = checkParams(JSON.parse(kdfParams)); |
| + |
| + // The root, and the two printed values. All three are generated here and |
| + // the first two never leave. |
| + const rk = randomBytes(32); |
| + const accountKey = randomBytes(16); |
| + const recoveryCode = randomBytes(16); |
| + |
| + // The salt the server offered is a decoy for an unregistered address, so a |
| + // fresh signup mints its own and tells the server which one it used. |
| + const salt = randomBytes(16); |
| + void saltHex; |
| + |
| + onProgress("stretching your passphrase"); |
| + const sPW = argon2id(new TextEncoder().encode(passphrase), salt, params, 32); |
| + |
| + onProgress("building your keys"); |
| + const seedLogin = await hkdf(sPW, null, LABEL_LOGIN, 32); |
| + const lkPW = await deriveP256(seedLogin); |
| + const kekPW = await hkdf(sPW, accountKey, LABEL_KEK, 32); |
| + |
| + const seedRC = await hkdf(recoveryCode, null, LABEL_RC_LOGIN, 32); |
| + const lkRC = await deriveP256(seedRC); |
| + const kekRC = await hkdf(recoveryCode, null, LABEL_RC_KEK, 32); |
| + |
| + // The AAD binds the account id, which the server has not minted yet. A |
| + // signup-time placeholder of zeroes keeps the two sides agreeing; when the |
| + // account id becomes known the wraps are rewritten under it. Recorded here |
| + // rather than hidden: this is the one place the binding is weaker than it |
| + // will be, and web/4 closes it by minting the id client-side. |
| + const placeholder = new Uint8Array(16); |
| + |
| + onProgress("sealing your vault key"); |
| + const wraps = [ |
| + await makeWrap(OBJECT_WRAP_PW, kekPW, rk, placeholder, kdfParams, null), |
| + await makeWrap(OBJECT_WRAP_RC, kekRC, rk, placeholder, kdfParams, null), |
| + ]; |
| + |
| + await postJSON("/auth/signup", { |
| + email, |
| + salt_hex: toHex(salt), |
| + login_keys: [ |
| + { branch: "pw", public_key_hex: toHex(lkPW.publicKey) }, |
| + { branch: "rc", public_key_hex: toHex(lkRC.publicKey) }, |
| + ], |
| + wraps, |
| + }); |
| + |
| + return { accountKey, recoveryCode, rk }; |
| +} |
| + |
| +// signIn runs the ceremony: ask for a challenge, stretch, derive, sign. |
| +// |
| +// The passphrase is never sent. What crosses the wire is a signature over |
| +// bytes the server chose, which is why a compromised server learns nothing it |
| +// could replay. |
| +export async function signIn(email, passphrase, onProgress = () => {}) { |
| + const { challenge_hex: challengeHex, salt_hex: saltHex, kdf_params: kdfParams } = |
| + await postJSON("/auth/challenge", { email, branch: "pw" }); |
| + |
| + const params = checkParams(JSON.parse(kdfParams)); |
| + |
| + onProgress("stretching your passphrase"); |
| + const sPW = argon2id( |
| + new TextEncoder().encode(passphrase), fromHex(saltHex), params, 32); |
| + |
| + onProgress("proving it is you"); |
| + const lkPW = await deriveP256(await hkdf(sPW, null, LABEL_LOGIN, 32)); |
| + |
| + const challenge = fromHex(challengeHex); |
| + const message = concat( |
| + new TextEncoder().encode(CHALLENGE_CONTEXT), new Uint8Array([0]), |
| + new TextEncoder().encode("pw"), new Uint8Array([0]), |
| + challenge, |
| + ); |
| + const signature = await signChallenge(lkPW.privateKey, message); |
| + |
| + return postJSON("/auth/verify", { |
| + email, |
| + branch: "pw", |
| + challenge_hex: challengeHex, |
| + signature_hex: toHex(signature), |
| + }); |
| +} |
| + |
| +export { LABEL_PRF_KEK }; |
diff --git a/internal/contra/static/pages/auth.js b/internal/contra/static/pages/auth.js| new file mode 100644 |
| index 0000000..ede27e5 |
| --- /dev/null |
| +++ b/internal/contra/static/pages/auth.js |
| @@ -0,0 +1,113 @@ |
| +// The signup and sign-in forms, wired to the crypto modules. |
| +// |
| +// Deliberately thin: every decision that matters is in crypto/account.js, and |
| +// this file only moves text between a form and that module. It builds DOM |
| +// nodes rather than assigning innerHTML — untrusted content never becomes |
| +// markup here, and the habit is cheaper to keep than to acquire. |
| + |
| +import { signIn, signup } from "../crypto/account.js"; |
| +import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js"; |
| + |
| +function el(tag, attrs = {}, ...children) { |
| + const node = document.createElement(tag); |
| + for (const [k, v] of Object.entries(attrs)) { |
| + if (k === "class") node.className = v; |
| + else node.setAttribute(k, v); |
| + } |
| + for (const c of children) { |
| + node.append(typeof c === "string" ? document.createTextNode(c) : c); |
| + } |
| + return node; |
| +} |
| + |
| +function status(form, text, kind = "working") { |
| + const box = form.querySelector("[data-status]"); |
| + box.replaceChildren(el("p", { "data-kind": kind }, text)); |
| +} |
| + |
| +function busy(form, on) { |
| + for (const c of form.querySelectorAll("input, button")) c.disabled = on; |
| +} |
| + |
| +// The kit is shown exactly once, here, and never again — the recovery code is |
| +// not stored by anyone, including us, so this is the only moment it exists |
| +// outside the person's hands. The copy says so plainly rather than leaving it |
| +// to be discovered. |
| +function showKit(form, accountKeyPrinted, recoveryPrinted) { |
| + const box = form.querySelector("[data-status]"); |
| + box.replaceChildren( |
| + el("h2", {}, "Your Emergency Kit"), |
| + el("p", {}, |
| + "These two values are shown once. Nobody can reissue the recovery " + |
| + "code, because nobody stores it — only a sealed copy of your vault key " + |
| + "that it opens."), |
| + el("h3", {}, "Account Key card — store somewhere safe"), |
| + el("p", {}, |
| + "Keep it where you can find it and nobody else goes: a drawer, a " + |
| + "wallet, a folder with your documents. It is useless to anyone who " + |
| + "does not also know your passphrase. A new device asks for it."), |
| + el("pre", { "data-kit": "account-key" }, accountKeyPrinted), |
| + el("h3", {}, "Recovery sheet — store securely"), |
| + el("p", {}, |
| + "Keep it where you would keep a passport or a will: a safe, a deposit " + |
| + "box, a trusted person's house. Never photograph it. This sheet alone " + |
| + "can open your vault. You should not need to touch it again."), |
| + el("pre", { "data-kit": "recovery" }, recoveryPrinted), |
| + el("p", {}, |
| + "Print both before you continue. If you lose the kit and forget your " + |
| + "passphrase, the data is gone — there is no reset link and no way for " + |
| + "us to let you back in."), |
| + el("button", { type: "button", "data-print": "" }, "Print both"), |
| + ); |
| + box.querySelector("[data-print]").addEventListener("click", () => window.print()); |
| +} |
| + |
| +function wireSignup(form) { |
| + form.addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + const email = form.querySelector("[name=email]").value; |
| + const passphrase = form.querySelector("[name=passphrase]").value; |
| + const again = form.querySelector("[name=passphrase_again]").value; |
| + |
| + if (passphrase !== again) { |
| + status(form, "The two passphrases do not match.", "error"); |
| + return; |
| + } |
| + busy(form, true); |
| + try { |
| + const kit = await signup(email, passphrase, (m) => status(form, m + "…")); |
| + showKit(form, |
| + await formatCode(TAG_ACCOUNT_KEY, kit.accountKey), |
| + await formatCode(TAG_RECOVERY, kit.recoveryCode)); |
| + } catch (err) { |
| + status(form, err.message, "error"); |
| + busy(form, false); |
| + } |
| + }); |
| +} |
| + |
| +function wireSignin(form) { |
| + form.addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + busy(form, true); |
| + try { |
| + await signIn( |
| + form.querySelector("[name=email]").value, |
| + form.querySelector("[name=passphrase]").value, |
| + (m) => status(form, m + "…")); |
| + window.location.assign("/vault"); |
| + } catch (err) { |
| + // One message for every failure. An unknown address, a wrong |
| + // passphrase and an expired challenge are different facts, and telling |
| + // them apart is the attacker's problem, not ours to solve for them. |
| + status(form, "Could not sign in. Check the address and passphrase.", "error"); |
| + busy(form, false); |
| + } |
| + }); |
| +} |
| + |
| +const signupForm = document.querySelector("[data-signup]"); |
| +if (signupForm) wireSignup(signupForm); |
| + |
| +const signinForm = document.querySelector("[data-signin]"); |
| +if (signinForm) wireSignin(signinForm); |
diff --git a/internal/contra/store/store.go b/internal/contra/store/store.go| index 0e11f7f..9752e50 100644 |
| --- a/internal/contra/store/store.go |
| +++ b/internal/contra/store/store.go |
| @@ -32,8 +32,8 @@ import ( |
| "gorm.io/gorm" |
| - "github.com/carlosframework/contra/internal/contra" |
| "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/model" |
| ) |
| // FormatVersion is the wrap header version this build writes. |
| @@ -76,7 +76,7 @@ func (s Sealed) valid() bool { return len(s.Nonce) == 12 && len(s.Ciphertext) > |
| // CreateAccount records an identity. emailIndex is the blind index — |
| // HMAC-SHA-256 under the server-held pepper — and this function neither |
| // takes nor could compute an address. |
| -func (s *Store) CreateAccount(ctx context.Context, emailIndex, kdfSalt []byte) (*contra.Account, error) { |
| +func (s *Store) CreateAccount(ctx context.Context, emailIndex, kdfSalt []byte) (*model.Account, error) { |
| if len(emailIndex) != 32 || len(kdfSalt) != 16 { |
| return nil, ErrShortField |
| } |
| @@ -84,7 +84,7 @@ func (s *Store) CreateAccount(ctx context.Context, emailIndex, kdfSalt []byte) ( |
| if err != nil { |
| return nil, err |
| } |
| - a := &contra.Account{ |
| + a := &model.Account{ |
| ID: id, |
| EmailIndex: emailIndex, |
| KDFSalt: kdfSalt, |
| @@ -103,7 +103,7 @@ func (s *Store) PutLoginKey(ctx context.Context, accountID []byte, branch string |
| if err != nil { |
| return err |
| } |
| - return s.db.WithContext(ctx).Create(&contra.LoginKey{ |
| + return s.db.WithContext(ctx).Create(&model.LoginKey{ |
| ID: id, |
| AccountID: accountID, |
| Branch: branch, |
| @@ -127,7 +127,7 @@ func (s *Store) PutWrap(ctx context.Context, accountID []byte, kind, kdfParams s |
| if err != nil { |
| return err |
| } |
| - return s.db.WithContext(ctx).Create(&contra.Wrap{ |
| + return s.db.WithContext(ctx).Create(&model.Wrap{ |
| ID: id, |
| AccountID: accountID, |
| Kind: kind, |
| @@ -146,7 +146,7 @@ func (s *Store) PutWrap(ctx context.Context, accountID []byte, kind, kdfParams s |
| // metadata, metadata is a secret, and it seals into the keyset the members |
| // hold. dropBoxPublicKey is vP_j — what a daemon seals a captured item to |
| // with no key in memory. |
| -func (s *Store) CreateVault(ctx context.Context, accountID, dropBoxPublicKey []byte) (*contra.Vault, error) { |
| +func (s *Store) CreateVault(ctx context.Context, accountID, dropBoxPublicKey []byte) (*model.Vault, error) { |
| if len(dropBoxPublicKey) != 65 { |
| return nil, ErrShortField |
| } |
| @@ -154,7 +154,7 @@ func (s *Store) CreateVault(ctx context.Context, accountID, dropBoxPublicKey []b |
| if err != nil { |
| return nil, err |
| } |
| - v := &contra.Vault{ |
| + v := &model.Vault{ |
| ID: id, |
| AccountID: accountID, |
| DropBoxPublicKey: dropBoxPublicKey, |
| @@ -175,7 +175,7 @@ func (s *Store) PutVaultMember(ctx context.Context, vaultID, accountID, |
| if err != nil { |
| return err |
| } |
| - return s.db.WithContext(ctx).Create(&contra.VaultMember{ |
| + return s.db.WithContext(ctx).Create(&model.VaultMember{ |
| ID: id, |
| VaultID: vaultID, |
| AccountID: accountID, |
| @@ -194,7 +194,7 @@ func (s *Store) PutVaultMember(ctx context.Context, vaultID, accountID, |
| // padding is the client's job, but "the client pads" is a hope; refusing |
| // here is a control. The check is scheme-independent — it says the plaintext |
| // was quantised, not how — so it holds for the JS and Swift clients too. |
| -func (s *Store) PutItem(ctx context.Context, vaultID []byte, version uint64, sealed Sealed) (*contra.Item, error) { |
| +func (s *Store) PutItem(ctx context.Context, vaultID []byte, version uint64, sealed Sealed) (*model.Item, error) { |
| if !sealed.valid() { |
| return nil, ErrShortField |
| } |
| @@ -206,7 +206,7 @@ func (s *Store) PutItem(ctx context.Context, vaultID []byte, version uint64, sea |
| return nil, err |
| } |
| now := time.Now().UTC() |
| - it := &contra.Item{ |
| + it := &model.Item{ |
| ID: id, |
| VaultID: vaultID, |
| Version: version, |
| @@ -229,7 +229,7 @@ func (s *Store) UpdateItem(ctx context.Context, itemID []byte, version uint64, s |
| if !crypto.IsBucketAligned(sealed.Ciphertext) { |
| return ErrNotAligned |
| } |
| - return s.db.WithContext(ctx).Model(&contra.Item{}). |
| + return s.db.WithContext(ctx).Model(&model.Item{}). |
| Where("id = ?", itemID). |
| Select("Version", "Nonce", "Ciphertext", "UpdatedAt"). |
| Updates(map[string]any{ |
diff --git a/internal/contra/templates/signin.html b/internal/contra/templates/signin.html| new file mode 100644 |
| index 0000000..ef97783 |
| --- /dev/null |
| +++ b/internal/contra/templates/signin.html |
| @@ -0,0 +1,26 @@ |
| +{{define "title"}}Sign in — CONTRA{{end}} |
| +{{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}} |
| +{{define "head"}}<script type="module" src="{{asset "static/pages/auth.js"}}"></script>{{end}} |
| + |
| +{{define "content"}} |
| +<h1>Sign in</h1> |
| + |
| +<p>Your passphrase is not sent. This page stretches it, derives a signing key |
| +from it, and signs a challenge the server chose.</p> |
| + |
| +<form data-signin method="post" action="/auth/verify"> |
| + <p> |
| + <label for="email">Email address</label> |
| + <input id="email" name="email" type="email" autocomplete="username" required> |
| + </p> |
| + <p> |
| + <label for="passphrase">Passphrase</label> |
| + <input id="passphrase" name="passphrase" type="password" |
| + autocomplete="current-password" required> |
| + </p> |
| + <p><button type="submit">Sign in</button></p> |
| + <div data-status aria-live="polite"></div> |
| +</form> |
| + |
| +<p>No account yet? <a href="/signup">Create one</a>.</p> |
| +{{end}} |
diff --git a/internal/contra/templates/signup.html b/internal/contra/templates/signup.html| new file mode 100644 |
| index 0000000..677446f |
| --- /dev/null |
| +++ b/internal/contra/templates/signup.html |
| @@ -0,0 +1,34 @@ |
| +{{define "title"}}Create an account — CONTRA{{end}} |
| +{{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}} |
| +{{define "head"}}<script type="module" src="{{asset "static/pages/auth.js"}}"></script>{{end}} |
| + |
| +{{define "content"}} |
| +<h1>Create an account</h1> |
| + |
| +<p>Your passphrase never leaves this page. Your vault key is generated here, |
| +in your browser, and the server only ever receives sealed copies of it that it |
| +cannot open.</p> |
| + |
| +<form data-signup method="post" action="/auth/signup"> |
| + <p> |
| + <label for="email">Email address</label> |
| + <input id="email" name="email" type="email" autocomplete="username" required> |
| + <small>Stored only as an index we cannot reverse. It proves who you are |
| + and can never decrypt anything.</small> |
| + </p> |
| + <p> |
| + <label for="passphrase">Passphrase</label> |
| + <input id="passphrase" name="passphrase" type="password" |
| + autocomplete="new-password" required minlength="12"> |
| + </p> |
| + <p> |
| + <label for="passphrase_again">Passphrase again</label> |
| + <input id="passphrase_again" name="passphrase_again" type="password" |
| + autocomplete="new-password" required minlength="12"> |
| + </p> |
| + <p><button type="submit">Create account</button></p> |
| + <div data-status aria-live="polite"></div> |
| +</form> |
| + |
| +<p>Already have an account? <a href="/signin">Sign in</a>.</p> |
| +{{end}} |
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html| new file mode 100644 |
| index 0000000..e096729 |
| --- /dev/null |
| +++ b/internal/contra/templates/vault.html |
| @@ -0,0 +1,16 @@ |
| +{{define "title"}}Vault — CONTRA{{end}} |
| +{{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}} |
| + |
| +{{define "content"}} |
| +<h1>Signed in</h1> |
| + |
| +<p>The ceremony worked: your browser proved possession of a key derived from |
| +your passphrase, and the server verified a signature against the public half |
| +it stores. It still holds nothing that can open a vault.</p> |
| + |
| +<p>Items land here in a later slice. Build <code>{{.Version}}</code>.</p> |
| + |
| +<form method="post" action="/auth/signout"> |
| + <button type="submit">Sign out</button> |
| +</form> |
| +{{end}} |
diff --git a/internal/contratest/auth_test.go b/internal/contratest/auth_test.go| new file mode 100644 |
| index 0000000..fcee8f1 |
| --- /dev/null |
| +++ b/internal/contratest/auth_test.go |
| @@ -0,0 +1,414 @@ |
| +package contratest |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/contra/internal/contra/auth" |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| +) |
| + |
| +// The signup and sign-in ceremonies, driven the way a browser drives them. |
| +// |
| +// The client half is written out here in Go rather than mocked, and that is |
| +// the point: it exercises the same derivations internal/contra/crypto pins to |
| +// the golden vectors, so a divergence between what the server expects and |
| +// what a client produces fails here rather than on somebody's laptop. The JS |
| +// client runs the identical sequence — test/crypto.test.mjs holds it to the |
| +// same vectors. |
| + |
| +// account is what one client ceremony produces and remembers. |
| +type account struct { |
| + email string |
| + passphrase string |
| + salt []byte |
| + accountKey []byte |
| + recovery []byte |
| + rk []byte |
| +} |
| + |
| +func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder { |
| + t.Helper() |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Origin", testOrigin) // the same-origin evidence csrf.Protect wants |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +func decode(t *testing.T, rec *httptest.ResponseRecorder, v any) { |
| + t.Helper() |
| + if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { |
| + t.Fatalf("decode %s: %v", rec.Body.String(), err) |
| + } |
| +} |
| + |
| +// doSignup runs the whole client side: generate RK and the two printed |
| +// values, stretch, derive, seal, and post only the sealed halves. |
| +func doSignup(t *testing.T, h http.Handler, email, passphrase string) account { |
| + t.Helper() |
| + |
| + a := account{email: email, passphrase: passphrase} |
| + for _, dst := range []*[]byte{&a.salt, &a.accountKey, &a.recovery} { |
| + b := make([]byte, 16) |
| + if _, err := rand.Read(b); err != nil { |
| + t.Fatal(err) |
| + } |
| + *dst = b |
| + } |
| + a.rk = make([]byte, 32) |
| + if _, err := rand.Read(a.rk); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + sPW := crypto.StretchPassphrase(passphrase, a.salt) |
| + seedLogin, err := crypto.Derive(sPW, nil, crypto.LabelLogin, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + lkPW, err := crypto.DeriveP256(seedLogin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + kekPW, err := crypto.Derive(sPW, a.accountKey, crypto.LabelKEK, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + seedRC, err := crypto.Derive(a.recovery, nil, crypto.LabelRCLogin, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + lkRC, err := crypto.DeriveP256(seedRC) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + kekRC, err := crypto.Derive(a.recovery, nil, crypto.LabelRCKEK, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + type wrapJSON struct { |
| + Kind string `json:"kind"` |
| + KDFParams string `json:"kdf_params"` |
| + Version uint64 `json:"version"` |
| + KCVHex string `json:"kcv_hex"` |
| + CredSaltHex string `json:"cred_salt_hex"` |
| + NonceHex string `json:"nonce_hex"` |
| + CiphertextHex string `json:"ciphertext_hex"` |
| + } |
| + placeholder := make([]byte, 16) |
| + seal := func(kind string, kek []byte) wrapJSON { |
| + wrapID := make([]byte, 16) |
| + nonce := make([]byte, 12) |
| + if _, err := rand.Read(wrapID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := rand.Read(nonce); err != nil { |
| + t.Fatal(err) |
| + } |
| + ct, err := crypto.Seal(kek, nonce, crypto.AAD(placeholder, kind, wrapID, 1), a.rk) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return wrapJSON{ |
| + Kind: kind, KDFParams: auth.DefaultKDFParams, Version: 1, |
| + KCVHex: hex.EncodeToString(crypto.KCV(kek)), |
| + NonceHex: hex.EncodeToString(nonce), CiphertextHex: hex.EncodeToString(ct), |
| + } |
| + } |
| + |
| + rec := postJSON(t, h, "/auth/signup", map[string]any{ |
| + "email": email, |
| + "salt_hex": hex.EncodeToString(a.salt), |
| + "login_keys": []map[string]string{ |
| + {"branch": "pw", "public_key_hex": hex.EncodeToString(lkPW.PublicKey().Bytes())}, |
| + {"branch": "rc", "public_key_hex": hex.EncodeToString(lkRC.PublicKey().Bytes())}, |
| + }, |
| + "wraps": []wrapJSON{seal(crypto.ObjectWrapPW, kekPW), seal(crypto.ObjectWrapRC, kekRC)}, |
| + }) |
| + if rec.Code != http.StatusCreated { |
| + t.Fatalf("signup: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + return a |
| +} |
| + |
| +// doSignIn runs the challenge ceremony and returns the response, so a test |
| +// can assert on a refusal as easily as on a success. |
| +func doSignIn(t *testing.T, h http.Handler, email, passphrase string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + |
| + var ch struct { |
| + ChallengeHex string `json:"challenge_hex"` |
| + SaltHex string `json:"salt_hex"` |
| + KDFParams string `json:"kdf_params"` |
| + } |
| + rec := postJSON(t, h, "/auth/challenge", map[string]string{"email": email, "branch": "pw"}) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("challenge: status %d", rec.Code) |
| + } |
| + decode(t, rec, &ch) |
| + |
| + // The client's floor, checked on parameters the server sent, before a |
| + // single expensive byte is derived. |
| + if err := auth.CheckParams(ch.KDFParams); err != nil { |
| + t.Fatalf("server offered parameters below the floor: %v", err) |
| + } |
| + |
| + salt, err := hex.DecodeString(ch.SaltHex) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + challenge, err := hex.DecodeString(ch.ChallengeHex) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + sPW := crypto.StretchPassphrase(passphrase, salt) |
| + seedLogin, err := crypto.Derive(sPW, nil, crypto.LabelLogin, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := crypto.DeriveP256(seedLogin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + priv, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), key.Bytes()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + digest := sha256.Sum256(auth.SignedMessage("pw", challenge)) |
| + r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sig := make([]byte, 64) |
| + r.FillBytes(sig[:32]) |
| + s.FillBytes(sig[32:]) |
| + |
| + return postJSON(t, h, "/auth/verify", map[string]string{ |
| + "email": email, |
| + "branch": "pw", |
| + "challenge_hex": ch.ChallengeHex, |
| + "signature_hex": hex.EncodeToString(sig), |
| + }) |
| +} |
| + |
| +func TestSignupThenSignIn(t *testing.T) { |
| + h := newApp(t) |
| + a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + rec := doSignIn(t, h, a.email, a.passphrase) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("sign in: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + cookie := rec.Header().Get("Set-Cookie") |
| + if cookie == "" { |
| + t.Fatal("sign in minted no session cookie") |
| + } |
| + |
| + // And the session actually opens the signed-in surface. |
| + req := httptest.NewRequest(http.MethodGet, "/vault", nil) |
| + req.Header.Set("Cookie", strings.SplitN(cookie, ";", 2)[0]) |
| + vault := httptest.NewRecorder() |
| + h.ServeHTTP(vault, req) |
| + if vault.Code != http.StatusOK { |
| + t.Fatalf("GET /vault with a session: status %d", vault.Code) |
| + } |
| +} |
| + |
| +func TestVaultRefusesAStranger(t *testing.T) { |
| + rec := get(t, newApp(t), "/vault") |
| + if rec.Code == http.StatusOK { |
| + t.Fatal("GET /vault served a signed-out request") |
| + } |
| +} |
| + |
| +func TestWrongPassphraseIsRefused(t *testing.T) { |
| + h := newApp(t) |
| + a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + rec := doSignIn(t, h, a.email, "a generated test passphrase 8f3a1d") |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("wrong passphrase: status %d, want 401", rec.Code) |
| + } |
| +} |
| + |
| +// A challenge is spent whether or not the signature was good, so an attacker |
| +// cannot grind attempts against one issued challenge. |
| +func TestChallengeIsSingleUse(t *testing.T) { |
| + h := newApp(t) |
| + a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + var ch struct { |
| + ChallengeHex string `json:"challenge_hex"` |
| + SaltHex string `json:"salt_hex"` |
| + } |
| + decode(t, postJSON(t, h, "/auth/challenge", |
| + map[string]string{"email": a.email, "branch": "pw"}), &ch) |
| + |
| + challenge, _ := hex.DecodeString(ch.ChallengeHex) |
| + salt, _ := hex.DecodeString(ch.SaltHex) |
| + sPW := crypto.StretchPassphrase(a.passphrase, salt) |
| + seed, _ := crypto.Derive(sPW, nil, crypto.LabelLogin, 32) |
| + key, _ := crypto.DeriveP256(seed) |
| + priv, _ := ecdsa.ParseRawPrivateKey(elliptic.P256(), key.Bytes()) |
| + digest := sha256.Sum256(auth.SignedMessage("pw", challenge)) |
| + r, s, _ := ecdsa.Sign(rand.Reader, priv, digest[:]) |
| + sig := make([]byte, 64) |
| + r.FillBytes(sig[:32]) |
| + s.FillBytes(sig[32:]) |
| + |
| + body := map[string]string{ |
| + "email": a.email, "branch": "pw", |
| + "challenge_hex": ch.ChallengeHex, "signature_hex": hex.EncodeToString(sig), |
| + } |
| + if rec := postJSON(t, h, "/auth/verify", body); rec.Code != http.StatusOK { |
| + t.Fatalf("first use: status %d", rec.Code) |
| + } |
| + if rec := postJSON(t, h, "/auth/verify", body); rec.Code != http.StatusUnauthorized { |
| + t.Errorf("replayed challenge: status %d, want 401", rec.Code) |
| + } |
| +} |
| + |
| +// A challenge issued for the recovery credential must not be spendable on the |
| +// passphrase one — which is why the branch is bound into the signed message |
| +// rather than only checked alongside it. |
| +func TestChallengeIsBoundToItsBranch(t *testing.T) { |
| + challenge := bytes.Repeat([]byte{0xab}, 32) |
| + if bytes.Equal(auth.SignedMessage("pw", challenge), auth.SignedMessage("rc", challenge)) { |
| + t.Fatal("the same bytes are signed for both branches") |
| + } |
| +} |
| + |
| +// The salt endpoint must not become an account-existence oracle. An unknown |
| +// address gets a deterministic decoy, so asking twice cannot tell it from a |
| +// real one by variance. |
| +func TestSaltDoesNotLeakAccountExistence(t *testing.T) { |
| + h := newApp(t) |
| + known := "vector-person@example.invalid" |
| + doSignup(t, h, known, "a generated test passphrase 8f3a1c") |
| + |
| + saltFor := func(email string) string { |
| + var r struct { |
| + SaltHex string `json:"salt_hex"` |
| + KDFParams string `json:"kdf_params"` |
| + } |
| + decode(t, postJSON(t, h, "/auth/salt", map[string]string{"email": email}), &r) |
| + if len(r.SaltHex) != 32 { |
| + t.Fatalf("salt for %q is %d hex chars, want 32 — a decoy that is the "+ |
| + "wrong shape is not a decoy", email, len(r.SaltHex)) |
| + } |
| + if r.KDFParams == "" { |
| + t.Fatalf("no parameters offered for %q", email) |
| + } |
| + return r.SaltHex |
| + } |
| + |
| + unknown := "nobody-here@example.invalid" |
| + first, second := saltFor(unknown), saltFor(unknown) |
| + if first != second { |
| + t.Error("an unknown address got two different salts; the variance itself " + |
| + "says there is no account") |
| + } |
| + if first == saltFor(known) { |
| + t.Error("the decoy collided with a real salt") |
| + } |
| +} |
| + |
| +// The KDF floor, server side. The browser is where this defence really lives |
| +// — a malicious server cannot protect anyone from itself — but a client bug |
| +// must not be able to persist parameters a later, honest client would trust. |
| +func TestServerRefusesDowngradedKDFParameters(t *testing.T) { |
| + for _, params := range []string{ |
| + `{"alg":"argon2id","m_kib":8,"t":1,"p":1}`, |
| + `{"alg":"argon2id","m_kib":65535,"t":3,"p":1}`, |
| + `{"alg":"argon2id","m_kib":65536,"t":2,"p":1}`, |
| + `{"alg":"argon2id","m_kib":65536,"t":3,"p":4}`, |
| + `{"alg":"pbkdf2","m_kib":65536,"t":3,"p":1}`, |
| + `not json`, |
| + } { |
| + if err := auth.CheckParams(params); err == nil { |
| + t.Errorf("CheckParams accepted %s", params) |
| + } |
| + } |
| + if err := auth.CheckParams(auth.DefaultKDFParams); err != nil { |
| + t.Errorf("CheckParams rejected its own default: %v", err) |
| + } |
| + |
| + // And the endpoint refuses them, not just the helper. |
| + h := newApp(t) |
| + rec := postJSON(t, h, "/auth/signup", map[string]any{ |
| + "email": "weak@example.invalid", |
| + "salt_hex": hex.EncodeToString(make([]byte, 16)), |
| + "login_keys": []map[string]string{}, |
| + "wraps": []map[string]any{{ |
| + "kind": crypto.ObjectWrapPW, |
| + "kdf_params": `{"alg":"argon2id","m_kib":8,"t":1,"p":1}`, |
| + "version": 1, |
| + "kcv_hex": hex.EncodeToString(make([]byte, 16)), |
| + "cred_salt_hex": "", |
| + "nonce_hex": hex.EncodeToString(make([]byte, 12)), |
| + "ciphertext_hex": hex.EncodeToString(make([]byte, 48)), |
| + }}, |
| + }) |
| + if rec.Code != http.StatusBadRequest { |
| + t.Errorf("signup with m=8KiB: status %d, want 400", rec.Code) |
| + } |
| +} |
| + |
| +// The blind index is the whole of what the server knows about an address. |
| +// Different addresses give different indices; the same address always gives |
| +// the same one; and nothing about the address survives in it. |
| +func TestBlindIndexIsStableAndOpaque(t *testing.T) { |
| + a := newAuthForIndexTest(t) |
| + one := a.BlindIndex("Person@Example.Invalid") |
| + two := a.BlindIndex(" person@example.invalid ") |
| + if !bytes.Equal(one, two) { |
| + t.Error("normalisation is not reaching the index") |
| + } |
| + if bytes.Equal(one, a.BlindIndex("other@example.invalid")) { |
| + t.Error("two addresses share an index") |
| + } |
| + if len(one) != 32 { |
| + t.Errorf("index is %d bytes, want 32", len(one)) |
| + } |
| + if bytes.Contains(one, []byte("person")) || bytes.Contains(one, []byte("example")) { |
| + t.Error("the address survives in its own index") |
| + } |
| +} |
| + |
| +func newAuthForIndexTest(t *testing.T) *auth.Auth { |
| + t.Helper() |
| + a, err := auth.New(nil, nil, testPepper) |
| + if err != nil { |
| + t.Fatalf("auth.New: %v", err) |
| + } |
| + return a |
| +} |
| + |
| +// A pepper that is absent or short is refused rather than invented: an |
| +// ephemeral one would change every account's index on the next restart and |
| +// strand every account there is. |
| +func TestAuthRefusesAWeakPepper(t *testing.T) { |
| + for _, p := range [][]byte{nil, []byte(""), []byte("too short")} { |
| + if _, err := auth.New(nil, nil, p); err == nil { |
| + t.Errorf("auth.New accepted a %d-byte pepper", len(p)) |
| + } |
| + } |
| +} |
diff --git a/internal/contratest/greps_test.go b/internal/contratest/greps_test.go| index f708c82..abe4ae7 100644 |
| --- a/internal/contratest/greps_test.go |
| +++ b/internal/contratest/greps_test.go |
| @@ -79,6 +79,7 @@ var canaries = map[string]string{ |
| "item totp": "CANARYTOTPSEEDJBSWY3DPEHPK3PXP", |
| "vault name": "CANARY-vault-Household-1a7c5e", |
| "email address": "CANARY-person-1a7c5e@example.invalid", |
| + "passphrase": "CANARY-passphrase-correct-horse-2b9d4f", |
| } |
| // secrets is key material. None of it may ever reach the disk. |
| @@ -312,9 +313,23 @@ func TestMercilessGreps(t *testing.T) { |
| if err != nil { |
| t.Fatalf("db.Open: %v", err) |
| } |
| - if _, err := contra.App(d, testOrigin, logger); err != nil { |
| + |
| + // The real ceremony, over the real handlers: signup and sign-in with a |
| + // canary address and a canary passphrase. This is the path a person |
| + // actually takes, and the passphrase is the value that would be most |
| + // catastrophic to find on disk. |
| + mux, err := contra.App(d, testConfig(), logger) |
| + if err != nil { |
| t.Fatalf("App: %v", err) |
| } |
| + doSignup(t, mux, canaries["email address"], canaries["passphrase"]) |
| + if rec := doSignIn(t, mux, canaries["email address"], canaries["passphrase"]); rec.Code != 200 { |
| + t.Fatalf("sign in during the greps: status %d", rec.Code) |
| + } |
| + // A live challenge row as well, so the table is grepped with something in |
| + // it rather than empty. |
| + postJSON(t, mux, "/auth/challenge", |
| + map[string]string{"email": canaries["email address"], "branch": "pw"}) |
| sec := writeEverything(t, store.New(d.G)) |
| ns := needles(sec) |
| @@ -323,8 +338,11 @@ func TestMercilessGreps(t *testing.T) { |
| // stops the whole test degrading into that quietly — a write path that |
| // silently stopped writing would otherwise look exactly like a pass. |
| for table, want := range map[string]int64{ |
| - "accounts": 1, "login_keys": 2, "wraps": 3, |
| + // One account from the ceremony plus one from the store paths; the |
| + // ceremony's two login keys plus two more; its two wraps plus three. |
| + "accounts": 2, "login_keys": 4, "wraps": 5, |
| "vaults": 1, "vault_members": 1, "items": 1, |
| + "login_challenges": 1, |
| } { |
| var n int64 |
| if err := d.G.Raw("SELECT count(*) FROM " + table).Scan(&n).Error; err != nil { |
| @@ -360,8 +378,9 @@ func TestMercilessGreps(t *testing.T) { |
| // until the check means nothing. |
| var ddl []string |
| if err := d.G.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master |
| - WHERE tbl_name IN (?, ?, ?, ?, ?, ?)`, |
| - "accounts", "login_keys", "wraps", "vaults", "vault_members", "items"). |
| + WHERE tbl_name IN (?, ?, ?, ?, ?, ?, ?)`, |
| + "accounts", "login_keys", "wraps", "vaults", "vault_members", "items", |
| + "login_challenges"). |
| Scan(&ddl).Error; err != nil { |
| t.Fatalf("read sqlite_master: %v", err) |
| } |
| @@ -425,7 +444,7 @@ func TestGrepsCatchAPlaintextColumn(t *testing.T) { |
| t.Fatalf("db.Open: %v", err) |
| } |
| defer d.Close() |
| - if _, err := contra.App(d, testOrigin, logger); err != nil { |
| + if _, err := contra.App(d, testConfig(), logger); err != nil { |
| t.Fatalf("App: %v", err) |
| } |
| @@ -464,7 +483,7 @@ func TestStoreRefusesUnquantisedItem(t *testing.T) { |
| t.Fatalf("db.Open: %v", err) |
| } |
| defer d.Close() |
| - if _, err := contra.App(d, testOrigin, logger); err != nil { |
| + if _, err := contra.App(d, testConfig(), logger); err != nil { |
| t.Fatalf("App: %v", err) |
| } |
| s := store.New(d.G) |
diff --git a/internal/contratest/harness_test.go b/internal/contratest/harness_test.go| index f9ccb47..de1296a 100644 |
| --- a/internal/contratest/harness_test.go |
| +++ b/internal/contratest/harness_test.go |
| @@ -25,6 +25,15 @@ import ( |
| // same-origin evidence — the pair a browser form submission would be. |
| const testOrigin = "http://app.test" |
| +// testPepper is generated, fixed, and obviously not real (AGENTS.md decision |
| +// 8: fixtures are generated, never real). It blinds the email index in tests |
| +// exactly as a deployed pepper would, and decrypts nothing either way. |
| +var testPepper = []byte("contra test pepper -- generated, not a secret, 0f3a") |
| + |
| +func testConfig() contra.Config { |
| + return contra.Config{Origin: testOrigin, Pepper: testPepper} |
| +} |
| + |
| // newApp builds the whole app per test over a fresh temp database, |
| // exactly as main.go does. |
| func newApp(t *testing.T) http.Handler { |
| @@ -35,7 +44,7 @@ func newApp(t *testing.T) http.Handler { |
| t.Fatalf("db.Open: %v", err) |
| } |
| t.Cleanup(func() { d.Close() }) |
| - mux, err := contra.App(d, testOrigin, logger) |
| + mux, err := contra.App(d, testConfig(), logger) |
| if err != nil { |
| t.Fatalf("App: %v", err) |
| } |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index 55432d3..e36ca00 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -25,6 +25,7 @@ const jsModuleCap = 300 |
| // cap — a new module in the crypto path is exactly the thing that should not |
| // arrive without somebody noticing. |
| var jsHighWater = map[string]int{ |
| + "account.js": 150, |
| "argon2id.js": 292, |
| "blake2b.js": 99, |
| "bytes.js": 61, |
| @@ -32,24 +33,43 @@ var jsHighWater = map[string]int{ |
| "kdf.js": 117, |
| "p256.js": 101, |
| "sealed.js": 90, |
| + |
| + // Page controllers. Thin on purpose: every decision that matters lives in |
| + // a crypto module, and these only move text between a form and one. |
| + "pages/auth.js": 113, |
| } |
| func TestJSModulesStaySmall(t *testing.T) { |
| - dir := filepath.Join("..", "contra", "static", "crypto") |
| - entries, err := os.ReadDir(dir) |
| - if err != nil { |
| - t.Fatalf("read %s: %v", dir, err) |
| - } |
| - |
| + root := filepath.Join("..", "contra", "static") |
| seen := map[string]bool{} |
| - for _, e := range entries { |
| - name := e.Name() |
| - if e.IsDir() || !strings.HasSuffix(name, ".js") { |
| - continue |
| + |
| + // Every module this app wrote — the vendored rastrillo files in static/ |
| + // itself are the library's and are pinned by vendored_test.go instead. |
| + var names []string |
| + for _, sub := range []string{"crypto", "pages"} { |
| + entries, err := os.ReadDir(filepath.Join(root, sub)) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", sub, err) |
| } |
| - seen[name] = true |
| + for _, e := range entries { |
| + if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") { |
| + continue |
| + } |
| + key := e.Name() |
| + if sub != "crypto" { |
| + key = sub + "/" + e.Name() |
| + } |
| + names = append(names, key) |
| + } |
| + } |
| - raw, err := os.ReadFile(filepath.Join(dir, name)) |
| + for _, name := range names { |
| + seen[name] = true |
| + rel := name |
| + if !strings.Contains(rel, "/") { |
| + rel = "crypto/" + rel |
| + } |
| + raw, err := os.ReadFile(filepath.Join(root, rel)) |
| if err != nil { |
| t.Errorf("read %s: %v", name, err) |
| continue |
diff --git a/internal/contratest/storage_test.go b/internal/contratest/storage_test.go| index d1a86b2..0145ebb 100644 |
| --- a/internal/contratest/storage_test.go |
| +++ b/internal/contratest/storage_test.go |
| @@ -72,12 +72,12 @@ func TestMigrationsApplyOnce(t *testing.T) { |
| d := newDB(t) |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| - if _, err := contra.App(d, testOrigin, logger); err != nil { |
| + if _, err := contra.App(d, testConfig(), logger); err != nil { |
| t.Fatalf("first boot: %v", err) |
| } |
| before := tableNames(t, d) |
| - if _, err := contra.App(d, testOrigin, logger); err != nil { |
| + if _, err := contra.App(d, testConfig(), logger); err != nil { |
| t.Fatalf("second boot: %v", err) |
| } |
| after := tableNames(t, d) |
| @@ -98,7 +98,7 @@ func TestMigrationsApplyOnce(t *testing.T) { |
| func TestSchemaIsTheDeclaredSet(t *testing.T) { |
| d := newDB(t) |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| - if _, err := contra.App(d, testOrigin, logger); err != nil { |
| + if _, err := contra.App(d, testConfig(), logger); err != nil { |
| t.Fatalf("App: %v", err) |
| } |
| @@ -109,12 +109,13 @@ func TestSchemaIsTheDeclaredSet(t *testing.T) { |
| // CONTRA's own. Every one holds ciphertext, public keys, opaque |
| // random ids, or the parameters needed to hand the right blob back. |
| - "accounts": "an identity: a blind index, never an address", |
| - "login_keys": "public keys to verify signed challenges against", |
| - "wraps": "the three peer wraps of RK", |
| - "vaults": "a vault's existence and its drop-box public key", |
| - "vault_members": "vault keysets sealed to members — the sharing graph", |
| - "items": "one opaque blob per item, quantised into buckets", |
| + "accounts": "an identity: a blind index, never an address", |
| + "login_keys": "public keys to verify signed challenges against", |
| + "wraps": "the three peer wraps of RK", |
| + "vaults": "a vault's existence and its drop-box public key", |
| + "vault_members": "vault keysets sealed to members — the sharing graph", |
| + "items": "one opaque blob per item, quantised into buckets", |
| + "login_challenges": "live sign-in state: a random nonce, deleted when spent", |
| } |
| found := map[string]bool{} |