carlos / contra Public

web: signup in three steps, each of which can fail on its own

One button did six seconds of work and reported whichever exception happened
to surface. That is why "nothing happened" and "Data provided to an operation
does not meet requirements" were both accurate and both useless: neither said
which of a dozen operations had died, and there was no smaller thing to retry.

So the ceremony is three screens.

**Prove the address.** Type it, get a link, follow it. This is the half that
was missing entirely rather than merely awkward: nothing proved the address
before, so anyone could claim any address's blind index and the person who
owned it could then never register. AGENTS.md decision 3 already committed to
this — "email + magic link for signup, then passkey for quick sign-in
thereafter, is intended to become the CARLOS default. CONTRA is where it gets
built first."

**Choose a passphrase.** All the expensive work, on a screen that does nothing
else, reporting what it is doing as it goes: stretch, build, seal, create,
open. A failure now says which of those it was.

**Add a passkey, or don't.** A third wrap of the same root key, never a
shortcut around it. It asks for the passphrase once, because wrapping RK
requires RK and there is no honest way to have it after a page load. Every
failure path on that screen ends in "carry on" — a provider with no PRF, a
browser with no passkeys, a device with no cached Account Key. None of them
can strand anybody, because the passphrase and recovery wraps are never
touched.

The server learns nothing new. The address is used to address one message and
is not written down; the row holds a blind index and a SHA-256 of the emailed
token, so a database-plus-bucket leak yields nothing anyone can click. There
is no send log and no "last emailed at" column, because a second trace would
undo the reason the first one is blind.

/auth/begin answers identically whether or not an account exists. What
differs is which message gets sent — the person who owns the address is told
they already have an account, and a prober is told nothing.

Two bugs found by tests while building this, both worth naming. The browser
rig caught the literal word "null" rendered onto the Emergency Kit screen:
replaceChildren stringifies a null child, and the one call not going through
the el() helper passed one. And the greps' email canary was upper case, so it
was searching the database for a form that normalisation means could never be
stored — a grep that could not fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev ad84b34bec0f59abddabb38e915a33ec6e0d9208 parent c0596f7
30 files changed, +1429 −397
  • cmd/contra/main.go +15 −1
  • internal/contra/app.go +20 −4
  • internal/contra/auth/auth.go +40 −65
  • internal/contra/auth/signup.go +302 −0
  • internal/contra/handlers.go +43 −2
  • internal/contra/mail/mail.go +122 −0
  • internal/contra/migrations/0004_create_signup_sessions.sql +8 −0
  • internal/contra/migrations/0005_alter_wraps.sql +4 −0
  • internal/contra/migrations/schema.sql +11 −1
  • internal/contra/model/model.go +32 −0
  • internal/contra/models.go +1 −0
  • internal/contra/render.go +1 −1
  • internal/contra/static/crypto/account.js +32 −48
  • internal/contra/static/pages/auth.js +0 −191
  • internal/contra/static/pages/passkey.js +170 −0
  • internal/contra/static/pages/signin.js +49 −0
  • internal/contra/static/pages/signup.js +198 −0
  • internal/contra/store/store.go +29 −0
  • internal/contra/templates/signin.html +1 −1
  • internal/contra/templates/signup.html +8 −18
  • internal/contra/templates/signup_passkey.html +23 −0
  • internal/contra/templates/signup_passphrase.html +30 −0
  • internal/contra/templates/signup_sent.html +20 −0
  • internal/contra/vault/vault.go +42 −0
  • internal/contratest/auth_test.go +52 −9
  • internal/contratest/browser_test.go +59 −33
  • internal/contratest/greps_test.go +21 −17
  • internal/contratest/harness_test.go +90 −3
  • internal/contratest/jsmodules_test.go +5 −3
  • internal/contratest/storage_test.go +1 −0
diff --git a/cmd/contra/main.go b/cmd/contra/main.go
index 5415189..ddce9c8 100644
--- a/cmd/contra/main.go
+++ b/cmd/contra/main.go
@@ -11,6 +11,7 @@ import (
"github.com/carlosframework/rastrillo/db"
contra "github.com/carlosframework/contra/internal/contra"
+ "github.com/carlosframework/contra/internal/contra/mail"
)
func main() {
@@ -65,7 +66,20 @@ func main() {
os.Exit(1)
}
- mux, err := contra.App(d, contra.Config{Origin: origin, Pepper: pepper}, logger)
+ // The mailer. A deployment without it boots and warns rather than
+ // refusing: everything except signup still works, and a hard failure here
+ // would take a running instance down over a feature it may not be using
+ // yet. `carlos email enable --app contra` is what fills it in.
+ mailer, err := mail.FromEnv()
+ if err != nil {
+ logger.Warn("no email configured; signup links cannot be sent",
+ "err", err, "fix", "carlos email enable --app contra")
+ mailer = nil
+ }
+
+ mux, err := contra.App(d, contra.Config{
+ Origin: origin, Pepper: pepper, Mailer: mailer,
+ }, logger)
if err != nil {
logger.Error("build app", "err", err)
os.Exit(1)
diff --git a/internal/contra/app.go b/internal/contra/app.go
index 841b07b..521b47b 100644
--- a/internal/contra/app.go
+++ b/internal/contra/app.go
@@ -13,6 +13,7 @@ import (
"github.com/carlosframework/rastrillo/sessions"
"github.com/carlosframework/contra/internal/contra/auth"
+ "github.com/carlosframework/contra/internal/contra/mail"
"github.com/carlosframework/contra/internal/contra/vault"
)
@@ -26,6 +27,11 @@ type Config struct {
// attributes, so defaulting it silently in production is a real bug.
Origin string
+ // Mailer sends the signup link. Nil boots with a loud warning rather than
+ // refusing, so a local run without SMTP still works — but nobody can sign
+ // up, which is worth saying at startup rather than at the first attempt.
+ Mailer mail.Sender
+
// 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
@@ -59,12 +65,12 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
return nil, err
}
- ident, err := auth.New(d.G, sess, cfg.Pepper)
+ ident, err := auth.New(d.G, sess, cfg.Pepper, cfg.Mailer, cfg.Origin, logger)
if err != nil {
return nil, err
}
- a := &app{db: d, logger: logger, sess: sess}
+ a := &app{db: d, logger: logger, sess: sess, auth: ident}
r := chi.NewRouter()
// App-wide, from day one: every state-changing route added below is born
@@ -76,14 +82,23 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
r.Use(sess.Middleware)
r.Get("/", a.index)
- r.Get("/signup", a.signupPage)
r.Get("/signin", a.signinPage)
+ // Signup, in steps. Each page is its own screen with its own errors,
+ // because the single screen this replaced did six seconds of work behind
+ // one button and reported whatever happened to throw.
+ r.Get("/signup", a.signupStart) // 1. the address
+ r.Get("/signup/sent", a.signupSent) // 1a. go and read your mail
+ r.Get("/signup/verify", ident.VerifyLink) // 1b. the link lands here
+ r.Get("/signup/passphrase", a.signupPassphrase) // 2. choose one
+ r.Get("/signup/passkey", a.signupPasskey) // 3. optional, and skippable
+
// 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/begin", ident.Begin)
+ r.Post("/auth/complete", ident.Complete)
r.Post("/auth/challenge", ident.Challenge)
r.Post("/auth/verify", ident.Verify)
r.Post("/auth/signout", ident.SignOut)
@@ -102,6 +117,7 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
r.Post("/api/items", items.CreateItem)
r.Put("/api/items", items.UpdateItem)
r.Post("/api/items/delete", items.DeleteItem)
+ r.Post("/api/passkey", items.AddPasskey)
})
mux := http.NewServeMux()
diff --git a/internal/contra/auth/auth.go b/internal/contra/auth/auth.go
index 1adf3c1..ca52461 100644
--- a/internal/contra/auth/auth.go
+++ b/internal/contra/auth/auth.go
@@ -19,6 +19,7 @@
package auth
import (
+ "context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
@@ -28,6 +29,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log/slog"
"math/big"
"net/http"
"strings"
@@ -37,6 +39,7 @@ import (
"github.com/carlosframework/rastrillo/sessions"
+ "github.com/carlosframework/contra/internal/contra/mail"
"github.com/carlosframework/contra/internal/contra/model"
"github.com/carlosframework/contra/internal/contra/store"
)
@@ -73,6 +76,17 @@ type Auth struct {
store *store.Store
sess *sessions.Sessions
pepper []byte
+
+ // mailer sends the signup link and nothing else. Nil is allowed and
+ // logged loudly rather than refused, so a local run without SMTP still
+ // boots — but a deployment without it cannot sign anybody up, which is
+ // why New says so at startup instead of at the first attempt.
+ mailer mail.Sender
+
+ // origin is what goes in the link. It has to be the address the person
+ // will actually open, not a guess.
+ origin string
+ logger *slog.Logger
}
// New refuses a missing or short pepper rather than inventing one.
@@ -82,12 +96,23 @@ type Auth struct {
// 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) {
+func New(db *gorm.DB, sess *sessions.Sessions, pepper []byte, mailer mail.Sender,
+ origin string, logger *slog.Logger) (*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
+ if logger == nil {
+ logger = slog.Default()
+ }
+ if mailer == nil {
+ logger.Warn("auth: no mailer; signup links cannot be sent",
+ "fix", "carlos email enable --app contra")
+ }
+ return &Auth{
+ db: db, store: store.New(db), sess: sess, pepper: pepper,
+ mailer: mailer, origin: origin, logger: logger,
+ }, nil
}
// NormalizeEmail is deliberately minimal: trim and lowercase, nothing more.
@@ -240,66 +265,20 @@ func (a *Auth) Salt(w http.ResponseWriter, r *http.Request) {
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
- }
- accountID, err := hex.DecodeString(req.AccountIDHex)
- if err != nil || len(accountID) != 16 {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad account id"})
- 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, accountID, index, salt)
- if err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create"})
- return
- }
+// storeCredentials writes the public halves and the sealed wraps a completed
+// signup produces. Shared by the flow that creates an account so that there
+// is one definition of what a credential set is, rather than two that drift.
+func (a *Auth) storeCredentials(ctx context.Context, accountID []byte, req signupRequest) error {
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
+ return errors.New("bad public key")
}
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
+ return errors.New("public key is not on the curve")
}
- 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
+ if err := a.store.PutLoginKey(ctx, accountID, k.Branch, pub); err != nil {
+ return errors.New("could not store a login key")
}
}
for _, wr := range req.Wraps {
@@ -309,18 +288,14 @@ func (a *Auth) Signup(w http.ResponseWriter, r *http.Request) {
credSalt, e4 := hex.DecodeString(wr.CredSaltHex)
wrapID, e5 := hex.DecodeString(wr.WrapIDHex)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad wrap"})
- return
+ return errors.New("bad wrap")
}
- if err := a.store.PutWrap(ctx, acct.ID, wrapID, 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
+ if err := a.store.PutWrap(ctx, accountID, wrapID, wr.Kind, wr.KDFParams,
+ wr.Version, kcv, credSalt, store.Sealed{Nonce: nonce, Ciphertext: ct}); err != nil {
+ return err
}
}
-
- writeJSON(w, http.StatusCreated, map[string]string{
- "account_id_hex": hex.EncodeToString(acct.ID)})
+ return nil
}
// Challenge issues the bytes a client proves possession over — and issues
diff --git a/internal/contra/auth/signup.go b/internal/contra/auth/signup.go
new file mode 100644
index 0000000..284d95f
--- /dev/null
+++ b/internal/contra/auth/signup.go
@@ -0,0 +1,302 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "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"
+)
+
+// Signup in three steps: prove the address, choose a passphrase, then
+// optionally add a passkey.
+//
+// It replaced a single screen that did everything between one button press
+// and one result. That screen was wrong for two reasons and both matter.
+//
+// The small one: it took six seconds and could fail in a dozen places, and
+// when it did the person got one sentence from whichever operation happened
+// to throw. Steps that each report their own outcome are diagnosable.
+//
+// The large one: nothing proved the address. Anyone could claim any address's
+// blind index, and the person who owned it could then never register. This is
+// the direction AGENTS.md decision 3 already committed to — "email + magic
+// link for signup, then passkey for quick sign-in thereafter, is intended to
+// become the CARLOS default. CONTRA is where it gets built first."
+//
+// # What the server learns
+//
+// Nothing new. The address is used to address one message and is not written
+// down; the row holds the blind index and a hash of the emailed token. A
+// database-plus-bucket leak yields nothing anyone can click.
+
+// SignupTTL is how long a link is good for. Long enough to walk to another
+// device and open the mail, short enough that an old message in an archive is
+// not a way in.
+const SignupTTL = 30 * time.Minute
+
+// signupSubject prefixes the session Subject of somebody who has proved an
+// address but has no account yet.
+//
+// Deliberately not a bare hex id: every other subject in this app is an
+// account id, and vault.accountID decodes exactly 16 bytes — so a signup
+// session cannot reach a vault route by accident. It fails the length check
+// and answers 404, which is the correct answer to "show me the vault of an
+// account that does not exist".
+const signupSubject = "signup:"
+
+var ErrNoSignupSession = errors.New("auth: no verified signup in progress")
+
+func newToken() (token string, hash []byte, err error) {
+ raw := make([]byte, 32)
+ if _, err := rand.Read(raw); err != nil {
+ return "", nil, err
+ }
+ sum := sha256.Sum256(raw)
+ return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil
+}
+
+func hashToken(token string) ([]byte, error) {
+ raw, err := base64.RawURLEncoding.DecodeString(token)
+ if err != nil || len(raw) != 32 {
+ return nil, errors.New("auth: malformed token")
+ }
+ sum := sha256.Sum256(raw)
+ return sum[:], nil
+}
+
+type beginRequest struct {
+ Email string `json:"email"`
+}
+
+// Begin takes an address, emails it a link, and says the same thing either
+// way.
+//
+// The response never varies on whether an account exists, whether a signup is
+// already in flight, or whether the message was delivered — because all three
+// are facts about somebody else's account when the asker is not that person.
+// What varies is which message gets sent.
+func (a *Auth) Begin(w http.ResponseWriter, r *http.Request) {
+ var req beginRequest
+ if !readJSON(w, r, &req) {
+ return
+ }
+ email := NormalizeEmail(req.Email)
+ if email == "" || !strings.Contains(email, "@") {
+ writeJSON(w, http.StatusBadRequest,
+ map[string]string{"error": "that does not look like an email address"})
+ return
+ }
+
+ ctx := r.Context()
+ index := a.BlindIndex(email)
+
+ // Whatever happens below, the answer is this.
+ answer := func() {
+ writeJSON(w, http.StatusOK, map[string]string{
+ "status": "sent",
+ "detail": "If that address can be signed up, a link is on its way.",
+ })
+ }
+
+ var existing model.Account
+ err := a.db.WithContext(ctx).Where("email_index = ?", index).First(&existing).Error
+ if err == nil {
+ // Already registered. Say so to the address itself and nowhere else:
+ // the person who owns it learns something useful, and a prober learns
+ // nothing at all.
+ a.send(ctx, email, "Your CONTRA account",
+ "Somebody asked to create a CONTRA account with this address, and "+
+ "one already exists.\n\nIf that was you, sign in instead:\n\n "+
+ a.origin+"/signin\n\nIf it was not you, nothing has happened and "+
+ "there is nothing you need to do.\n")
+ answer()
+ return
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"})
+ return
+ }
+
+ // One live signup per address: a second request replaces the first, so an
+ // older link in an inbox stops working.
+ if err := a.db.WithContext(ctx).
+ Where("email_index = ?", index).Delete(&model.SignupSession{}).Error; err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start"})
+ return
+ }
+
+ token, hash, err := newToken()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"})
+ return
+ }
+ id, err := store.NewID()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"})
+ return
+ }
+ if err := a.db.WithContext(ctx).Create(&model.SignupSession{
+ ID: id, EmailIndex: index, TokenHash: hash,
+ ExpiresAt: time.Now().UTC().Add(SignupTTL), CreatedAt: time.Now().UTC(),
+ }).Error; err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start"})
+ return
+ }
+
+ a.send(ctx, email, "Finish setting up CONTRA",
+ "Open this link to carry on setting up your CONTRA account:\n\n "+
+ a.origin+"/signup/verify?t="+token+"\n\nIt works once and expires in "+
+ "30 minutes.\n\nIf you did not ask for this, ignore it — nothing has "+
+ "been created, and this address has not been stored.\n")
+ answer()
+}
+
+// send never fails a request. A person who did not get the mail retries; a
+// person who did should not be told that delivery worked before it has, and a
+// 500 here would tell a prober that this address was the interesting one.
+func (a *Auth) send(ctx context.Context, to, subject, body string) {
+ if a.mailer == nil {
+ a.logger.Error("auth: no mailer configured; a signup link was not sent")
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
+ defer cancel()
+ if err := a.mailer.Send(ctx, to, subject, body); err != nil {
+ // The address is not logged. Delivery failures are a real operational
+ // signal and still not worth keeping a list of who we mailed.
+ a.logger.Error("auth: sending a signup link failed", "err", err)
+ }
+}
+
+// VerifyLink is the link's landing. It marks the signup verified and mints a
+// session that can do exactly one thing: finish signing up.
+func (a *Auth) VerifyLink(w http.ResponseWriter, r *http.Request) {
+ hash, err := hashToken(r.URL.Query().Get("t"))
+ if err != nil {
+ http.Redirect(w, r, "/signup?e=badlink", http.StatusSeeOther)
+ return
+ }
+
+ ctx := r.Context()
+ var row model.SignupSession
+ if err := a.db.WithContext(ctx).Where("token_hash = ?", hash).First(&row).Error; err != nil {
+ http.Redirect(w, r, "/signup?e=badlink", http.StatusSeeOther)
+ return
+ }
+ if time.Now().UTC().After(row.ExpiresAt) {
+ a.db.WithContext(ctx).Delete(&model.SignupSession{}, "id = ?", row.ID)
+ http.Redirect(w, r, "/signup?e=expired", http.StatusSeeOther)
+ return
+ }
+ if err := a.db.WithContext(ctx).Model(&model.SignupSession{}).
+ Where("id = ?", row.ID).Update("verified", true).Error; err != nil {
+ http.Redirect(w, r, "/signup?e=failed", http.StatusSeeOther)
+ return
+ }
+
+ if err := a.sess.SignIn(w, r, sessions.Session{
+ Subject: signupSubject + hex.EncodeToString(row.ID),
+ Method: "signup-link",
+ AuthTime: time.Now().UTC(),
+ }); err != nil {
+ http.Redirect(w, r, "/signup?e=failed", http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, "/signup/passphrase", http.StatusSeeOther)
+}
+
+// SignupSessionOf returns the verified signup behind this request, if any.
+func (a *Auth) SignupSessionOf(r *http.Request) (*model.SignupSession, error) {
+ sess, ok := sessions.Current(r)
+ if !ok || !strings.HasPrefix(sess.Subject, signupSubject) {
+ return nil, ErrNoSignupSession
+ }
+ id, err := hex.DecodeString(strings.TrimPrefix(sess.Subject, signupSubject))
+ if err != nil || len(id) != 16 {
+ return nil, ErrNoSignupSession
+ }
+ var row model.SignupSession
+ if err := a.db.WithContext(r.Context()).
+ Where("id = ? AND verified = ?", id, true).First(&row).Error; err != nil {
+ return nil, ErrNoSignupSession
+ }
+ if time.Now().UTC().After(row.ExpiresAt) {
+ return nil, ErrNoSignupSession
+ }
+ return &row, nil
+}
+
+// Complete is step two: the browser has done all the crypto and posts only
+// sealed material. The account is created here, against the address that was
+// actually proved, and the signup session is spent.
+func (a *Auth) Complete(w http.ResponseWriter, r *http.Request) {
+ pending, err := a.SignupSessionOf(r)
+ if err != nil {
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": "That link has expired or was already used. Start again."})
+ return
+ }
+
+ var req signupRequest
+ if !readJSON(w, r, &req) {
+ return
+ }
+ salt, e1 := hex.DecodeString(req.SaltHex)
+ accountID, e2 := hex.DecodeString(req.AccountIDHex)
+ if e1 != nil || e2 != nil || len(salt) != 16 || len(accountID) != 16 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad account material"})
+ return
+ }
+ for _, wr := range req.Wraps {
+ if err := CheckParams(wr.KDFParams); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ }
+
+ ctx := r.Context()
+ acct, err := a.store.CreateAccount(ctx, accountID, pending.EmailIndex, salt)
+ if err != nil {
+ // The unique index on email_index is what makes this a conflict
+ // rather than a second account for one address.
+ writeJSON(w, http.StatusConflict,
+ map[string]string{"error": ErrTaken.Error()})
+ return
+ }
+ if err := a.storeCredentials(ctx, acct.ID, req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+
+ // Spent. The link cannot be followed again and the row stops being a
+ // record that this address ever signed up.
+ a.db.WithContext(ctx).Delete(&model.SignupSession{}, "id = ?", pending.ID)
+
+ if err := a.sess.SignIn(w, r, sessions.Session{
+ Subject: hex.EncodeToString(acct.ID),
+ Method: "signup",
+ AuthTime: time.Now().UTC(),
+ }); err != nil {
+ writeJSON(w, http.StatusInternalServerError,
+ map[string]string{"error": "the account was created but the session was not"})
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]string{
+ "account_id_hex": hex.EncodeToString(acct.ID)})
+}
+
+// SweepSignups deletes expired rows.
+func (a *Auth) SweepSignups(now time.Time) error {
+ return a.db.Where("expires_at < ?", now.UTC()).Delete(&model.SignupSession{}).Error
+}
diff --git a/internal/contra/handlers.go b/internal/contra/handlers.go
index a2bbaf0..db567ac 100644
--- a/internal/contra/handlers.go
+++ b/internal/contra/handlers.go
@@ -16,6 +16,7 @@ type app struct {
db *db.DB
logger *slog.Logger
sess *sessions.Sessions
+ auth *auth.Auth
}
// page is the data every template renders against.
@@ -23,6 +24,7 @@ type page struct {
Version string
SignedIn bool
KDFParams string
+ LinkError string
}
func (a *app) pageData(r *http.Request) page {
@@ -41,8 +43,47 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
render(w, "index", a.pageData(r))
}
-func (a *app) signupPage(w http.ResponseWriter, r *http.Request) {
- render(w, "signup", a.pageData(r))
+// The three signup screens. Each one knows only what it needs, so a failure
+// on any of them says which step it was.
+func (a *app) signupStart(w http.ResponseWriter, r *http.Request) {
+ d := a.pageData(r)
+ d.LinkError = linkErrors[r.URL.Query().Get("e")]
+ render(w, "signup", d)
+}
+
+func (a *app) signupSent(w http.ResponseWriter, r *http.Request) {
+ render(w, "signup_sent", a.pageData(r))
+}
+
+// signupPassphrase is only reachable with a verified signup session. Anyone
+// else goes back to the start rather than being shown a form that cannot
+// possibly work.
+func (a *app) signupPassphrase(w http.ResponseWriter, r *http.Request) {
+ if _, err := a.auth.SignupSessionOf(r); err != nil {
+ http.Redirect(w, r, "/signup?e=expired", http.StatusSeeOther)
+ return
+ }
+ render(w, "signup_passphrase", a.pageData(r))
+}
+
+// signupPasskey is the optional third step, and it is behind a real session:
+// by here the account exists.
+func (a *app) signupPasskey(w http.ResponseWriter, r *http.Request) {
+ if _, ok := sessions.Current(r); !ok {
+ http.Redirect(w, r, "/signin", http.StatusSeeOther)
+ return
+ }
+ render(w, "signup_passkey", a.pageData(r))
+}
+
+// linkErrors turns the query parameter a redirect carries into a sentence.
+// The redirect cannot carry the sentence itself: a message passed through a
+// URL is a message an attacker can choose.
+var linkErrors = map[string]string{
+ "badlink": "That link did not work. It may have been used already, or copied " +
+ "incompletely from the email.",
+ "expired": "That link has expired. Links last thirty minutes; ask for a new one.",
+ "failed": "Something went wrong following that link. Try asking for a new one.",
}
func (a *app) signinPage(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/contra/mail/mail.go b/internal/contra/mail/mail.go
new file mode 100644
index 0000000..d2c0aef
--- /dev/null
+++ b/internal/contra/mail/mail.go
@@ -0,0 +1,122 @@
+// Package mail sends the one message CONTRA sends: a signup link.
+//
+// Stdlib net/smtp against the credentials the platform delivers
+// (CARLOS_SMTP_*). No dependency, because one message with one link does not
+// need a mail library, and because everything this package touches is an
+// address we have promised not to keep.
+//
+// # What it must not do
+//
+// The address is used to address one message and is never written down. There
+// is no send log, no bounce table, no "last emailed at" column — the blind
+// index is the only trace a signup leaves, and adding a second one here would
+// undo the reason the first one is blind.
+package mail
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net"
+ "net/smtp"
+ "os"
+ "strings"
+ "time"
+)
+
+// Sender delivers a message. An interface so tests take a fake and never open
+// a socket, and so a deployment with no email configured fails loudly at boot
+// rather than at the first signup.
+type Sender interface {
+ Send(ctx context.Context, to, subject, body string) error
+}
+
+var ErrNotConfigured = errors.New("mail: no SMTP configuration (CARLOS_SMTP_HOST and friends)")
+
+type smtpSender struct {
+ host, port, user, pass, from string
+}
+
+// FromEnv reads the platform's delivered configuration. It returns
+// ErrNotConfigured rather than a silent no-op sender: a signup flow whose
+// email quietly goes nowhere is worse than one that refuses to start.
+func FromEnv() (Sender, error) {
+ s := smtpSender{
+ host: os.Getenv("CARLOS_SMTP_HOST"),
+ port: os.Getenv("CARLOS_SMTP_PORT"),
+ user: os.Getenv("CARLOS_SMTP_USER"),
+ pass: os.Getenv("CARLOS_SMTP_PASS"),
+ from: os.Getenv("CARLOS_SMTP_FROM"),
+ }
+ if s.host == "" || s.from == "" {
+ return nil, ErrNotConfigured
+ }
+ if s.port == "" {
+ s.port = "587"
+ }
+ return s, nil
+}
+
+func (s smtpSender) Send(ctx context.Context, to, subject, body string) error {
+ // One header set, built here rather than assembled from user input: `to`
+ // is the only variable, and a newline in it would be header injection.
+ if strings.ContainsAny(to, "\r\n") || strings.ContainsAny(subject, "\r\n") {
+ return errors.New("mail: newline in a header")
+ }
+
+ msg := strings.Join([]string{
+ "From: " + s.from,
+ "To: " + to,
+ "Subject: " + subject,
+ "MIME-Version: 1.0",
+ "Content-Type: text/plain; charset=utf-8",
+ "", body,
+ }, "\r\n")
+
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ deadline = time.Now().Add(20 * time.Second)
+ }
+ conn, err := (&net.Dialer{Deadline: deadline}).DialContext(
+ ctx, "tcp", net.JoinHostPort(s.host, s.port))
+ if err != nil {
+ return fmt.Errorf("mail: dial: %w", err)
+ }
+ defer conn.Close()
+
+ c, err := smtp.NewClient(conn, s.host)
+ if err != nil {
+ return fmt.Errorf("mail: smtp: %w", err)
+ }
+ defer c.Close()
+
+ // STARTTLS with the server name verified. The credentials are a bearer
+ // secret and the recipient is an address we have promised not to keep;
+ // neither goes over the wire in the clear.
+ if err := c.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil {
+ return fmt.Errorf("mail: starttls: %w", err)
+ }
+ if s.user != "" {
+ if err := c.Auth(smtp.PlainAuth("", s.user, s.pass, s.host)); err != nil {
+ return fmt.Errorf("mail: auth: %w", err)
+ }
+ }
+ if err := c.Mail(s.from); err != nil {
+ return fmt.Errorf("mail: from: %w", err)
+ }
+ if err := c.Rcpt(to); err != nil {
+ return fmt.Errorf("mail: rcpt: %w", err)
+ }
+ w, err := c.Data()
+ if err != nil {
+ return fmt.Errorf("mail: data: %w", err)
+ }
+ if _, err := w.Write([]byte(msg)); err != nil {
+ return fmt.Errorf("mail: write: %w", err)
+ }
+ if err := w.Close(); err != nil {
+ return fmt.Errorf("mail: close: %w", err)
+ }
+ return c.Quit()
+}
diff --git a/internal/contra/migrations/0004_create_signup_sessions.sql b/internal/contra/migrations/0004_create_signup_sessions.sql
new file mode 100644
index 0000000..517648e
--- /dev/null
+++ b/internal/contra/migrations/0004_create_signup_sessions.sql
@@ -0,0 +1,8 @@
+CREATE TABLE `signup_sessions` (`id` blob,`email_index` blob NOT NULL,`token_hash` blob NOT NULL,`verified` numeric NOT NULL,`expires_at` datetime NOT NULL,`created_at` datetime,PRIMARY KEY (`id`));
+
+CREATE INDEX `idx_signup_sessions_expires_at` ON `signup_sessions`(`expires_at`);
+
+CREATE INDEX `idx_signup_sessions_token_hash` ON `signup_sessions`(`token_hash`);
+
+CREATE INDEX `idx_signup_sessions_email_index` ON `signup_sessions`(`email_index`);
+
diff --git a/internal/contra/migrations/0005_alter_wraps.sql b/internal/contra/migrations/0005_alter_wraps.sql
new file mode 100644
index 0000000..b38b8cc
--- /dev/null
+++ b/internal/contra/migrations/0005_alter_wraps.sql
@@ -0,0 +1,4 @@
+ALTER TABLE `wraps` ADD `credential_id` blob;
+
+CREATE INDEX `idx_wraps_credential_id` ON `wraps`(`credential_id`);
+
diff --git a/internal/contra/migrations/schema.sql b/internal/contra/migrations/schema.sql
index 020e3d2..64ab8c9 100644
--- a/internal/contra/migrations/schema.sql
+++ b/internal/contra/migrations/schema.sql
@@ -9,11 +9,13 @@ CREATE TABLE `login_challenges` (`id` blob,`account_id` blob NOT NULL,`branch` t
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 `signup_sessions` (`id` blob,`email_index` blob NOT NULL,`token_hash` blob NOT NULL,`verified` numeric NOT NULL,`expires_at` datetime 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, `keyset_nonce` blob, `keyset_ciphertext` blob,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 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, `credential_id` blob,PRIMARY KEY (`id`));
CREATE UNIQUE INDEX `idx_accounts_email_index` ON `accounts`(`email_index`);
@@ -27,6 +29,12 @@ 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_signup_sessions_email_index` ON `signup_sessions`(`email_index`);
+
+CREATE INDEX `idx_signup_sessions_expires_at` ON `signup_sessions`(`expires_at`);
+
+CREATE INDEX `idx_signup_sessions_token_hash` ON `signup_sessions`(`token_hash`);
+
CREATE INDEX `idx_vault_members_account_id` ON `vault_members`(`account_id`);
CREATE INDEX `idx_vault_members_vault_id` ON `vault_members`(`vault_id`);
@@ -35,5 +43,7 @@ CREATE INDEX `idx_vaults_account_id` ON `vaults`(`account_id`);
CREATE INDEX `idx_wraps_account_id` ON `wraps`(`account_id`);
+CREATE INDEX `idx_wraps_credential_id` ON `wraps`(`credential_id`);
+
CREATE INDEX `idx_wraps_kind` ON `wraps`(`kind`);
diff --git a/internal/contra/model/model.go b/internal/contra/model/model.go
index a5e5ca9..383ab94 100644
--- a/internal/contra/model/model.go
+++ b/internal/contra/model/model.go
@@ -122,6 +122,15 @@ type Wrap struct {
// additional wrap instead of stranding anyone.
CredSalt []byte `gorm:"type:blob"`
+ // CredentialID is the passkey this wrap belongs to, empty on the other two
+ // branches. It lives here rather than in a credentials table because a
+ // passkey, to this product, IS a wrap of RK — there is nothing else to
+ // know about one. Several rows per credential are expected and harmless:
+ // a provider that returns a different PRF for the same credential gets
+ // another wrap rather than an error, which is how DESIGN.md says to
+ // tolerate the 1Password-class mismatch.
+ CredentialID []byte `gorm:"index;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
@@ -233,3 +242,26 @@ type LoginChallenge struct {
ExpiresAt time.Time `gorm:"index;not null"`
CreatedAt time.Time
}
+
+// SignupSession is an address that has proved itself and an account that does
+// not exist yet — the gap between "we emailed you a link" and "you chose a
+// passphrase".
+//
+// It holds the blind index, never the address: the email is used to send one
+// message and is not kept. TokenHash is SHA-256 of the emailed token, so a
+// database-plus-bucket leak yields nothing anyone can click — the token itself
+// exists only in that message and in the link the person follows.
+//
+// Rows are deleted the moment they are spent and swept when they expire. A
+// table of these that accumulated would become a log of who tried to sign up
+// and when, which is transport-shape residue we do not have to keep.
+type SignupSession struct {
+ ID []byte `gorm:"primaryKey;type:blob"`
+ EmailIndex []byte `gorm:"index;type:blob;not null"`
+ TokenHash []byte `gorm:"index;type:blob;not null"`
+
+ // Verified is false until the link is followed.
+ Verified bool `gorm:"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 7d4ab19..3403ba0 100644
--- a/internal/contra/models.go
+++ b/internal/contra/models.go
@@ -22,4 +22,5 @@ var Models = []any{
&model.VaultMember{},
&model.Item{},
&model.LoginChallenge{},
+ &model.SignupSession{},
}
diff --git a/internal/contra/render.go b/internal/contra/render.go
index b0c2d48..961fc9a 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", "signup", "signin", "vault"} {
+ for _, name := range []string{"index", "errors", "signup", "signup_sent", "signup_passphrase", "signup_passkey", "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
index c6f0281..a13581e 100644
--- a/internal/contra/static/crypto/account.js
+++ b/internal/contra/static/crypto/account.js
@@ -63,35 +63,34 @@ async function makeWrap(kind, kek, rk, accountId, kdfParams, credSalt) {
// 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, storage, net, onProgress = () => {}) {
- const { salt_hex: saltHex, kdf_params: kdfParams } =
- await postJSON("/auth/salt", { email });
-
- // Before a single expensive byte: the floor. A malicious server offering
+// By the time this runs the address is already proved and the session already
+// says which one — so there is no email argument here, and no way for this
+// step to create an account for an address nobody confirmed.
+//
+// It returns the two printed values because this is the ONLY time either is
+// available. The recovery code is not stored anywhere, by anyone, including
+// us: only the wrap it opens. The caller's next job is to put them in front
+// of a person and refuse to move on until they say they have them.
+//
+// The progress messages read as actions, because the failure path turns them
+// into "Could not <message>" — so a person is told what did not happen rather
+// than which function threw.
+export async function signup(passphrase, kdfParams, storage, net, onProgress = () => {}) {
+ // The floor, before a single expensive byte. 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.
+ // passphrase cheaply offline.
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;
+ const accountId = randomBytes(16);
- onProgress("stretching your passphrase");
+ onProgress("stretch your passphrase");
const sPW = argon2id(new TextEncoder().encode(passphrase), salt, params, 32);
- onProgress("building your keys");
+ onProgress("build 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);
@@ -100,19 +99,14 @@ export async function signup(email, passphrase, storage, net, onProgress = () =>
const lkRC = await deriveP256(seedRC);
const kekRC = await hkdf(recoveryCode, null, LABEL_RC_KEK, 32);
- // The account id is minted here, like every other id in this protocol, so
- // that the AAD binding is real from the first byte rather than filled in
- // later. The server refuses a collision through the primary key.
- const accountId = randomBytes(16);
-
- onProgress("sealing your vault key");
+ onProgress("seal your vault key");
const wraps = [
await makeWrap(OBJECT_WRAP_PW, kekPW, rk, accountId, kdfParams, null),
await makeWrap(OBJECT_WRAP_RC, kekRC, rk, accountId, kdfParams, null),
];
- await postJSON("/auth/signup", {
- email,
+ onProgress("create your account");
+ await postJSON("/auth/complete", {
account_id_hex: toHex(accountId),
salt_hex: toHex(salt),
login_keys: [
@@ -122,21 +116,16 @@ export async function signup(email, passphrase, storage, net, onProgress = () =>
wraps,
});
- // Sign in to get a session, then provision the account keyset and the first
- // vault — both sealed here, both posted as ciphertext.
- onProgress("opening your vault");
- await signIn(email, passphrase, () => {}, sPW);
+ // /auth/complete signed us in, so provisioning needs no second ceremony —
+ // and no second Argon2id.
+ onProgress("open your vault");
const vault = await provision(net, accountId, rk);
- // Cache the Account Key for this device. From here the passphrase alone
- // unlocks on this browser; a fresh one asks for the card. DESIGN.md's
- // ladder — handoff, scan, type — is web/5.
- //
- // Best-effort, and deliberately so. Safari refuses localStorage outright in
- // some configurations, and a throw here would happen AFTER the account
- // exists — losing the one screen on which the recovery code is ever shown,
- // for an optimisation. A device that cannot remember the Account Key is a
- // device that asks for the card next time, which is a working state.
+ // Best effort. Safari refuses localStorage outright in some
+ // configurations, and a throw here would happen AFTER the account exists,
+ // losing the one screen the recovery code is ever shown on. A device that
+ // cannot remember the Account Key is a device that asks for the card next
+ // time, which is a working state.
let cached = true;
try {
accountKeys(storage).set(toHex(accountId), accountKey);
@@ -152,19 +141,14 @@ export async function signup(email, passphrase, storage, net, onProgress = () =>
// 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 = () => {}, knownSPW = null) {
+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));
- // Signup has already paid for this once; making it pay twice would double
- // the slowest thing in the product for no security at all.
- let sPW = knownSPW;
- if (!sPW) {
- onProgress("stretching your passphrase");
- sPW = argon2id(new TextEncoder().encode(passphrase), fromHex(saltHex), params, 32);
- }
+ 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));
diff --git a/internal/contra/static/pages/auth.js b/internal/contra/static/pages/auth.js
deleted file mode 100644
index b312e62..0000000
--- a/internal/contra/static/pages/auth.js
+++ /dev/null
@@ -1,191 +0,0 @@
-// 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 { AlreadyRegisteredError, signIn, signup } from "../crypto/account.js";
-import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js";
-import { toHex } from "../crypto/bytes.js";
-import { net } from "../crypto/net.js";
-import { accountKeyCard, recoverySheet } from "../crypto/kit.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;
-}
-
-// download hands the browser a file. Both documents are built here and the
-// bytes never leave: a kit that touched the server would put the recovery
-// code somewhere it must never be, for the length of one request.
-function download(bytes, filename) {
- const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
- const a = el("a", { href: url, download: filename });
- document.body.append(a);
- a.click();
- a.remove();
- // Revoked on the next tick rather than immediately: some browsers have not
- // started reading the blob by the time click() returns.
- setTimeout(() => URL.revokeObjectURL(url), 30_000);
-}
-
-// 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, accountIdHex) {
- 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("p", {},
- el("button", { type: "button", "data-save-kit": "" }, "Save both documents"),
- " ",
- el("button", { type: "button", "data-print": "" }, "Print this page")),
- el("p", {},
- el("label", {},
- el("input", { type: "checkbox", "data-confirmed": "" }),
- " I have saved both documents somewhere I can find them.")),
- el("p", { "data-onward": "", hidden: "hidden" },
- el("a", { href: "/vault" }, "Continue to your vault")),
- );
-
- // Two files, downloaded by one gesture. One file would put both secrets in
- // the same folder forever, which is the whole reason there are two — and
- // the risk being managed is storage, not printing.
- box.querySelector("[data-save-kit]").addEventListener("click", () => {
- download(accountKeyCard(accountIdHex, accountKeyPrinted),
- `contra-account-key-${accountIdHex.slice(0, 8)}.pdf`);
- download(recoverySheet(accountIdHex, recoveryPrinted),
- `contra-recovery-sheet-${accountIdHex.slice(0, 8)}.pdf`);
- });
- box.querySelector("[data-print]").addEventListener("click", () => window.print());
-
- // No way onward until the person says they have the documents. The
- // recovery code exists nowhere else, so walking past this screen without it
- // is the one mistake that cannot be undone later.
- const onward = box.querySelector("[data-onward]");
- box.querySelector("[data-confirmed]").addEventListener("change", (e) => {
- onward.hidden = !e.target.checked;
- });
-}
-
-// storageFor hands back a key-value store that works, or one that quietly
-// does nothing. Safari refuses localStorage entirely in some configurations,
-// and merely TOUCHING window.localStorage can throw there — so the access
-// itself is guarded, not just the write.
-function storageFor() {
- try {
- const s = window.localStorage;
- s.setItem("contra/v1/probe", "1");
- s.removeItem("contra/v1/probe");
- return s;
- } catch {
- return { getItem: () => null, setItem: () => {}, removeItem: () => {} };
- }
-}
-
-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);
-
- // The step being attempted when something throws. A bare DOMException
- // message — "Data provided to an operation does not meet requirements" —
- // says nothing about which of a dozen operations produced it, and that is
- // the difference between a bug report and a guess.
- let step = "starting";
- try {
- const kit = await signup(email, passphrase, storageFor(), net, (m) => {
- step = m;
- status(form, m + "…");
- });
- showKit(form,
- await formatCode(TAG_ACCOUNT_KEY, kit.accountKey),
- await formatCode(TAG_RECOVERY, kit.recoveryCode),
- toHex(kit.accountId));
- } catch (err) {
- // Everything, to the console, because the message alone is not enough
- // to act on and this is the one place a person can copy it from.
- console.error("CONTRA signup failed while", step, err);
- if (err instanceof AlreadyRegisteredError) {
- status(form, `${err.message} Sign in instead, or use another address.`, "error");
- } else {
- const name = err && err.name && err.name !== "Error" ? `${err.name}: ` : "";
- status(form, `Failed while ${step}. ${name}${err.message} ` +
- "(the browser console has the full detail).", "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/static/pages/passkey.js b/internal/contra/static/pages/passkey.js
new file mode 100644
index 0000000..975c753
--- /dev/null
+++ b/internal/contra/static/pages/passkey.js
@@ -0,0 +1,170 @@
+// Step 3: add a passkey, optionally.
+//
+// A passkey here is a third wrap of the same root key — never a shortcut
+// around it. The passphrase wrap and the recovery wrap are not touched and
+// are never removed, so a passkey that is lost, deleted, or that stops
+// returning the same PRF cannot strand anybody. That is why every failure on
+// this screen ends with "carry on" rather than "try again".
+//
+// It asks for the passphrase because it genuinely needs to: wrapping RK
+// requires RK, and getting RK means unlocking. That is the same rule the
+// vault screen follows, and pretending otherwise would mean holding the root
+// key somewhere across a page load.
+
+import { net } from "../crypto/net.js";
+import { randomBytes, toHex } from "../crypto/bytes.js";
+import { hkdf, kcv, LABEL_PRF_KEK } from "../crypto/kdf.js";
+import { aad, seal, OBJECT_WRAP_PRF } from "../crypto/sealed.js";
+import { accountKeys, unlock } from "../crypto/vaultkeys.js";
+
+const box = document.querySelector("[data-status]");
+const button = document.querySelector("[data-add-passkey]");
+
+function say(text, kind = "working") {
+ if (!box) return;
+ const p = document.createElement("p");
+ p.setAttribute("data-kind", kind);
+ p.append(document.createTextNode(text));
+ box.replaceChildren(p);
+}
+
+// A passkey step that cannot work should say so before asking for anything.
+if (button && !window.PublicKeyCredential) {
+ button.disabled = true;
+ say("This browser does not support passkeys. Your passphrase works exactly " +
+ "as it does now — carry on.", "notice");
+}
+
+function ask(label) {
+ return new Promise((resolve) => {
+ const form = document.createElement("form");
+ const input = document.createElement("input");
+ input.type = "password";
+ input.name = "passphrase";
+ input.autocomplete = "current-password";
+ input.required = true;
+ const p = document.createElement("p");
+ p.append(document.createTextNode(label + " "), input, " ");
+ const go = document.createElement("button");
+ go.type = "submit";
+ go.append(document.createTextNode("Unlock"));
+ p.append(go);
+ form.append(p);
+ form.addEventListener("submit", (e) => {
+ e.preventDefault();
+ resolve(input.value);
+ form.remove();
+ });
+ box.replaceChildren(form);
+ input.focus();
+ });
+}
+
+async function enrol() {
+ button.disabled = true;
+
+ const bootstrap = await net.get("/api/bootstrap");
+ const accountKey = accountKeys(window.localStorage).get(bootstrap.account_id_hex);
+ if (!accountKey) {
+ say("This device does not have your Account Key, so it cannot open your " +
+ "vault to add a passkey. Your passphrase still works — carry on.", "notice");
+ return;
+ }
+
+ const passphrase = await ask("Your passphrase, once, to unlock:");
+ say("Unlocking…");
+ const opened = await unlock(bootstrap, passphrase, accountKey);
+
+ // cred_salt is 32 non-secret random bytes, stored server-side and used as
+ // the PRF's input. It is not a secret and does not need to be one.
+ const credSalt = randomBytes(32);
+
+ say("Waiting for your passkey…");
+ // userVerification is required in every ceremony. CTAP2 keeps distinct
+ // with-UV and without-UV secrets, and mixed UV policy is a known cause of a
+ // provider returning a different PRF for the same credential.
+ const created = await navigator.credentials.create({
+ publicKey: {
+ challenge: randomBytes(32),
+ rp: { name: "CONTRA" },
+ user: {
+ id: opened.accountId,
+ name: "CONTRA vault",
+ displayName: "CONTRA vault",
+ },
+ pubKeyCredParams: [
+ { type: "public-key", alg: -7 },
+ { type: "public-key", alg: -257 },
+ ],
+ authenticatorSelection: { userVerification: "required", residentKey: "preferred" },
+ extensions: { prf: {} },
+ },
+ });
+ if (!created) throw new Error("no credential was created");
+
+ const supported = created.getClientExtensionResults?.().prf?.enabled;
+ if (supported === false) {
+ say("Your passkey provider cannot do the part we need (a PRF). The passkey " +
+ "was not kept. Your passphrase works exactly as it does now — carry on.",
+ "notice");
+ return;
+ }
+
+ // One assertion, both to obtain the PRF output and to prove the credential
+ // is present. The output is what the fast-unlock wrap is keyed on.
+ say("Confirming with your passkey…");
+ const asserted = await navigator.credentials.get({
+ publicKey: {
+ challenge: randomBytes(32),
+ allowCredentials: [{ type: "public-key", id: created.rawId }],
+ userVerification: "required",
+ extensions: { prf: { eval: { first: credSalt } } },
+ },
+ });
+ const prf = asserted?.getClientExtensionResults?.().prf?.results?.first;
+ if (!prf) {
+ say("Your passkey provider did not return a PRF, so a fast unlock is not " +
+ "available with it. Nothing has changed and your passphrase still " +
+ "works — carry on.", "notice");
+ return;
+ }
+
+ say("Saving…");
+ const kekPRF = await hkdf(new Uint8Array(prf), credSalt, LABEL_PRF_KEK, 32);
+ const wrapId = randomBytes(16);
+ const nonce = randomBytes(12);
+ const sealed = await seal(kekPRF, nonce,
+ aad(opened.accountId, OBJECT_WRAP_PRF, wrapId, 1), opened.rk);
+
+ await net.post("/api/passkey", {
+ wrap_id_hex: toHex(wrapId),
+ credential_id_hex: toHex(new Uint8Array(created.rawId)),
+ cred_salt_hex: toHex(credSalt),
+ kcv_hex: toHex(await kcv(kekPRF)),
+ // The PRF branch has no Argon2id and no server-supplied parameters at
+ // all: the input is already full-entropy, so there is nothing to tune and
+ // nothing for a malicious server to downgrade.
+ kdf_params: '{"alg":"prf-hkdf","v":1}',
+ sealed: { nonce_hex: toHex(nonce), ciphertext_hex: toHex(sealed) },
+ });
+
+ say("Passkey added. It is an extra way in, not a replacement — your " +
+ "passphrase and your recovery sheet are unchanged.", "done");
+}
+
+if (button) {
+ button.addEventListener("click", async () => {
+ try {
+ await enrol();
+ } catch (err) {
+ // Never a dead end. Every screen that offers a passkey offers the
+ // passphrase beside it, and this one is skippable by design.
+ console.error("CONTRA passkey enrolment failed", err);
+ const name = err && err.name && err.name !== "Error" ? `${err.name}: ` : "";
+ say(`Could not add a passkey. ${name}${err.message} Your passphrase works ` +
+ "exactly as it does now — carry on to your vault.", "error");
+ } finally {
+ button.disabled = false;
+ }
+ });
+}
diff --git a/internal/contra/static/pages/signin.js b/internal/contra/static/pages/signin.js
new file mode 100644
index 0000000..ef990bc
--- /dev/null
+++ b/internal/contra/static/pages/signin.js
@@ -0,0 +1,49 @@
+// Sign in: one form, one ceremony.
+//
+// Signing in is not unlocking. This proves who you are to the server, which
+// then serves you ciphertext it cannot read; the vault asks for the
+// passphrase again to open it.
+
+import { signIn } from "../crypto/account.js";
+
+function say(form, text, kind = "working") {
+ const p = document.createElement("p");
+ p.setAttribute("data-kind", kind);
+ p.append(document.createTextNode(text));
+ form.querySelector("[data-status]").replaceChildren(p);
+}
+
+function busy(form, on) {
+ for (const c of form.querySelectorAll("input, button")) c.disabled = on;
+}
+
+const form = document.querySelector("[data-signin]");
+if (form) {
+ form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ busy(form, true);
+ let step = "start";
+ try {
+ await signIn(
+ form.querySelector("[name=email]").value,
+ form.querySelector("[name=passphrase]").value,
+ (m) => {
+ step = m;
+ say(form, m + "…");
+ });
+ window.location.assign("/vault");
+ } catch (err) {
+ // The full error goes to the console; the screen says one thing.
+ //
+ // An unknown address, a wrong passphrase and an expired challenge are
+ // three different facts, and telling them apart is the attacker's
+ // problem, not ours to solve for them. What the screen does say is
+ // which step it reached, because "it failed" with no location is what
+ // made the old signup impossible to report.
+ console.error("CONTRA sign-in failed while", step, err);
+ say(form, "Could not sign in. Check the address and passphrase. " +
+ `(Failed while: ${step}. The browser console has the detail.)`, "error");
+ busy(form, false);
+ }
+ });
+}
diff --git a/internal/contra/static/pages/signup.js b/internal/contra/static/pages/signup.js
new file mode 100644
index 0000000..2c29782
--- /dev/null
+++ b/internal/contra/static/pages/signup.js
@@ -0,0 +1,198 @@
+// Signup, one step per screen.
+//
+// The screen this replaced did six seconds of work behind one button and, when
+// anything went wrong, printed whichever DOMException happened to surface. So
+// the shape here is deliberate: each step is small, each says what it is
+// doing, and each failure names the operation it died in and leaves the whole
+// error in the console.
+
+import { signIn, signup, AlreadyRegisteredError } from "../crypto/account.js";
+import { accountKeyCard, recoverySheet } from "../crypto/kit.js";
+import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js";
+import { net, RequestError } from "../crypto/net.js";
+import { toHex } from "../crypto/bytes.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) {
+ if (c == null) continue;
+ node.append(typeof c === "string" ? document.createTextNode(c) : c);
+ }
+ return node;
+}
+
+// replace drops absent children rather than letting the DOM turn them into
+// the word "null" on the screen.
+function replace(node, ...children) {
+ node.replaceChildren(...children.filter((c) => c != null));
+}
+
+function say(form, text, kind = "working") {
+ form.querySelector("[data-status]")
+ .replaceChildren(el("p", { "data-kind": kind }, text));
+}
+
+function busy(form, on) {
+ for (const c of form.querySelectorAll("input, button")) c.disabled = on;
+}
+
+// report is the failure path every step shares.
+//
+// A bare DOMException message — "Data provided to an operation does not meet
+// requirements" — names none of the dozen operations that could have raised
+// it. Saying which step died is the difference between a bug report and a
+// guess, and the console keeps the stack for the report.
+function report(form, step, err) {
+ console.error("CONTRA signup failed while", step, err);
+ const name = err && err.name && err.name !== "Error" ? `${err.name}: ` : "";
+ say(form, `Could not ${step}. ${name}${err.message} ` +
+ "The browser console has the full detail.", "error");
+ busy(form, false);
+}
+
+// Storage that works, or storage that quietly does nothing. Safari refuses
+// localStorage entirely in some configurations, and merely touching
+// window.localStorage throws there — so the access is probed, not assumed.
+function storageFor() {
+ try {
+ const s = window.localStorage;
+ s.setItem("contra/v1/probe", "1");
+ s.removeItem("contra/v1/probe");
+ return s;
+ } catch {
+ return { getItem: () => null, setItem: () => {}, removeItem: () => {} };
+ }
+}
+
+// ---------------------------------------------------- step 1: the address
+
+const beginForm = document.querySelector("[data-begin]");
+if (beginForm) {
+ beginForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const email = beginForm.querySelector("[name=email]").value.trim();
+ busy(beginForm, true);
+ say(beginForm, "Sending your link…");
+ try {
+ await net.post("/auth/begin", { email });
+ window.location.assign("/signup/sent");
+ } catch (err) {
+ if (err instanceof RequestError && err.status === 400) {
+ say(beginForm, err.message, "error");
+ busy(beginForm, false);
+ return;
+ }
+ report(beginForm, "send that link", err);
+ }
+ });
+}
+
+// ------------------------------------------------- step 2: the passphrase
+
+// The kit is shown exactly once, here. The recovery code is not stored by
+// anyone, including us — only a sealed copy of the vault key that it opens —
+// so this is the single moment it exists outside the person's hands.
+function showKit(form, accountKeyPrinted, recoveryPrinted, accountIdHex, cached) {
+ const box = form.querySelector("[data-status]");
+ // Filtered, because replaceChildren stringifies a null child into the word
+ // "null" on the page. el() skips nulls among its own children; this is the
+ // one call that does not go through it.
+ replace(box,
+ el("h2", {}, "Your Emergency Kit"),
+ el("p", {},
+ "Two documents, shown once. Nobody can reissue the recovery code, " +
+ "because nobody stores it."),
+ 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."),
+ el("pre", { "data-kit": "recovery" }, recoveryPrinted),
+ cached ? null : el("p", { "data-kind": "warning" },
+ "This browser will not remember your Account Key, so it will ask for " +
+ "the card next time. That is safe — just keep the card."),
+ el("p", {},
+ el("button", { type: "button", "data-save-kit": "" }, "Save both documents")),
+ el("p", {},
+ el("label", {},
+ el("input", { type: "checkbox", "data-confirmed": "" }),
+ " I have saved both documents somewhere I can find them.")),
+ el("p", { "data-onward": "", hidden: "hidden" },
+ el("a", { href: "/signup/passkey" }, "Continue")),
+ );
+
+ box.querySelector("[data-save-kit]").addEventListener("click", () => {
+ for (const [bytes, name] of [
+ [accountKeyCard(accountIdHex, accountKeyPrinted), "account-key"],
+ [recoverySheet(accountIdHex, recoveryPrinted), "recovery-sheet"],
+ ]) {
+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
+ const a = el("a", { href: url, download: `contra-${name}-${accountIdHex.slice(0, 8)}.pdf` });
+ document.body.append(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 30_000);
+ }
+ });
+
+ // No way onward until the person says they have the documents. Walking
+ // past this screen without the recovery code is the one mistake in this
+ // product that cannot be undone later.
+ const onward = box.querySelector("[data-onward]");
+ box.querySelector("[data-confirmed]").addEventListener("change", (e) => {
+ onward.hidden = !e.target.checked;
+ });
+}
+
+const passphraseForm = document.querySelector("[data-passphrase]");
+if (passphraseForm) {
+ passphraseForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const passphrase = passphraseForm.querySelector("[name=passphrase]").value;
+ const again = passphraseForm.querySelector("[name=passphrase_again]").value;
+ if (passphrase !== again) {
+ say(passphraseForm, "The two passphrases do not match.", "error");
+ return;
+ }
+ busy(passphraseForm, true);
+
+ let step = "start";
+ try {
+ const kit = await signup(passphrase, passphraseForm.dataset.kdfParams, storageFor(), net, (m) => {
+ step = m;
+ say(passphraseForm, m + "…");
+ });
+ showKit(passphraseForm,
+ await formatCode(TAG_ACCOUNT_KEY, kit.accountKey),
+ await formatCode(TAG_RECOVERY, kit.recoveryCode),
+ toHex(kit.accountId), kit.cached);
+ } catch (err) {
+ if (err instanceof AlreadyRegisteredError) {
+ say(passphraseForm,
+ `${err.message} Sign in instead.`, "error");
+ busy(passphraseForm, false);
+ return;
+ }
+ if (err instanceof RequestError && err.status === 403) {
+ say(passphraseForm,
+ "That link has expired or was already used. Start again from /signup.",
+ "error");
+ busy(passphraseForm, false);
+ return;
+ }
+ report(passphraseForm, step, err);
+ }
+ });
+}
+
+export { signIn };
diff --git a/internal/contra/store/store.go b/internal/contra/store/store.go
index 26a4ec8..b95295a 100644
--- a/internal/contra/store/store.go
+++ b/internal/contra/store/store.go
@@ -146,6 +146,35 @@ func (s *Store) PutWrap(ctx context.Context, accountID, wrapID []byte, kind, kdf
}).Error
}
+// PutPasskeyWrap stores a passkey's wrap of RK: the credential it belongs to,
+// the non-secret salt the PRF was evaluated under, and the sealed root key.
+//
+// No attestation is checked and none is wanted. The wrap either opens or it
+// does not, and its KCV says which — a signature from the authenticator would
+// prove the credential exists, which is not the question. What matters is
+// whether the key it yields is the key that opens this account.
+func (s *Store) PutPasskeyWrap(ctx context.Context, accountID, wrapID, credentialID,
+ credSalt, kcv []byte, kdfParams string, sealed Sealed) error {
+ if len(credentialID) == 0 || len(credSalt) != 32 || len(kcv) != 16 ||
+ len(wrapID) != 16 || !sealed.valid() {
+ return ErrShortField
+ }
+ return s.db.WithContext(ctx).Create(&model.Wrap{
+ ID: wrapID,
+ AccountID: accountID,
+ Kind: crypto.ObjectWrapPRF,
+ FormatVersion: FormatVersion,
+ Version: 1,
+ KDFParams: kdfParams,
+ CredSalt: credSalt,
+ CredentialID: credentialID,
+ KCV: kcv,
+ Nonce: sealed.Nonce,
+ Ciphertext: sealed.Ciphertext,
+ CreatedAt: time.Now().UTC(),
+ }).Error
+}
+
// CreateVault records a vault. Its name is not a parameter: a vault name is
// metadata, metadata is a secret, and it seals into the keyset. The id is the
// client's, for the reason PutWrap's is — it is bound into the keyset's AAD
diff --git a/internal/contra/templates/signin.html b/internal/contra/templates/signin.html
index ef97783..b6fef46 100644
--- a/internal/contra/templates/signin.html
+++ b/internal/contra/templates/signin.html
@@ -1,6 +1,6 @@
{{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 "head"}}<script type="module" src="{{asset "static/pages/signin.js"}}"></script>{{end}}
{{define "content"}}
<h1>Sign in</h1>
diff --git a/internal/contra/templates/signup.html b/internal/contra/templates/signup.html
index 677446f..fe13e75 100644
--- a/internal/contra/templates/signup.html
+++ b/internal/contra/templates/signup.html
@@ -1,32 +1,22 @@
{{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 "head"}}<script type="module" src="{{asset "static/pages/signup.js"}}"></script>{{end}}
{{define "content"}}
<h1>Create an account</h1>
+<p rst-steps>Step 1 of 3 &middot; Confirm your address</p>
-<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>
+<p>We will email you a link. Nothing is created until you follow it, and your
+address is stored only as an index we cannot reverse.</p>
-<form data-signup method="post" action="/auth/signup">
+{{if .LinkError}}<p data-link-error>{{.LinkError}}</p>{{end}}
+
+<form data-begin method="post" action="/auth/begin">
<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>
+ <p><button type="submit">Email me a link</button></p>
<div data-status aria-live="polite"></div>
</form>
diff --git a/internal/contra/templates/signup_passkey.html b/internal/contra/templates/signup_passkey.html
new file mode 100644
index 0000000..fc07735
--- /dev/null
+++ b/internal/contra/templates/signup_passkey.html
@@ -0,0 +1,23 @@
+{{define "title"}}Add a passkey — CONTRA{{end}}
+{{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}}
+{{define "head"}}<script type="module" src="{{asset "static/pages/passkey.js"}}"></script>{{end}}
+
+{{define "content"}}
+<h1>Add a passkey?</h1>
+<p rst-steps>Step 3 of 3 &middot; Optional</p>
+
+<p>A passkey is a faster way to unlock on this device: one touch instead of
+typing your passphrase. It is an addition, never a replacement. Your
+passphrase and your recovery sheet keep working exactly as they do now, and
+neither is ever removed.</p>
+
+<p>Some passkey providers cannot do the part we need. If yours cannot, we will
+say so plainly and carry on — nothing on this screen can leave you locked
+out.</p>
+
+<p>
+ <button type="button" data-add-passkey>Add a passkey</button>
+ <a href="/vault" data-skip>Skip for now</a>
+</p>
+<div data-status aria-live="polite"></div>
+{{end}}
diff --git a/internal/contra/templates/signup_passphrase.html b/internal/contra/templates/signup_passphrase.html
new file mode 100644
index 0000000..ccca82e
--- /dev/null
+++ b/internal/contra/templates/signup_passphrase.html
@@ -0,0 +1,30 @@
+{{define "title"}}Choose a passphrase — CONTRA{{end}}
+{{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}}
+{{define "head"}}<script type="module" src="{{asset "static/pages/signup.js"}}"></script>{{end}}
+
+{{define "content"}}
+<h1>Choose a passphrase</h1>
+<p rst-steps>Step 2 of 3 &middot; Your address is confirmed</p>
+
+<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>
+
+<p>This is the slow step. Stretching a passphrase deliberately takes a couple
+of seconds, and that is what makes it expensive to guess.</p>
+
+<form data-passphrase data-kdf-params="{{.KDFParams}}" method="post" action="/auth/complete">
+ <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 my vault</button></p>
+ <div data-status aria-live="polite"></div>
+</form>
+{{end}}
diff --git a/internal/contra/templates/signup_sent.html b/internal/contra/templates/signup_sent.html
new file mode 100644
index 0000000..96444ba
--- /dev/null
+++ b/internal/contra/templates/signup_sent.html
@@ -0,0 +1,20 @@
+{{define "title"}}Check your email — CONTRA{{end}}
+{{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}}
+
+{{define "content"}}
+<h1 data-sent>Check your email</h1>
+<p rst-steps>Step 1 of 3 &middot; Confirm your address</p>
+
+<p>If that address can be signed up, a link is on its way. It works once and
+expires in thirty minutes.</p>
+
+<p>We say &ldquo;if&rdquo; on purpose: this page looks the same whether or not
+an account already exists, because telling you would tell anyone who typed
+your address here too.</p>
+
+<p>Open the link in this browser if you can. You can open it in another one —
+you will just choose your passphrase there instead.</p>
+
+<p><a href="/signup">Use a different address</a> &middot;
+<a href="/signin">Sign in instead</a></p>
+{{end}}
diff --git a/internal/contra/vault/vault.go b/internal/contra/vault/vault.go
index 33b7ed8..1a8a3fd 100644
--- a/internal/contra/vault/vault.go
+++ b/internal/contra/vault/vault.go
@@ -298,6 +298,48 @@ func (v *Vault) DeleteItem(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
+// AddPasskey stores a passkey wrap. The browser has already done the whole
+// ceremony — created the credential, evaluated the PRF, derived the KEK and
+// sealed RK under it — so what arrives here is ciphertext and two public
+// identifiers.
+//
+// A passkey is additive, always. It never replaces the passphrase wrap or the
+// recovery wrap, and neither is ever removed, so losing or deleting a passkey
+// cannot strand anybody.
+func (v *Vault) AddPasskey(w http.ResponseWriter, r *http.Request) {
+ acct, ok := accountID(r)
+ if !ok {
+ notFound(w)
+ return
+ }
+ var req struct {
+ WrapIDHex string `json:"wrap_id_hex"`
+ CredentialIDHex string `json:"credential_id_hex"`
+ CredSaltHex string `json:"cred_salt_hex"`
+ KCVHex string `json:"kcv_hex"`
+ KDFParams string `json:"kdf_params"`
+ Sealed sealedJSON `json:"sealed"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ wrapID, e1 := hex.DecodeString(req.WrapIDHex)
+ credentialID, e2 := hex.DecodeString(req.CredentialIDHex)
+ credSalt, e3 := hex.DecodeString(req.CredSaltHex)
+ kcv, e4 := hex.DecodeString(req.KCVHex)
+ sealed, e5 := req.Sealed.decode()
+ if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad passkey wrap"})
+ return
+ }
+ if err := v.store.PutPasskeyWrap(r.Context(), acct, wrapID, credentialID,
+ credSalt, kcv, req.KDFParams, sealed); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]string{"wrap_id_hex": req.WrapIDHex})
+}
+
// Provision is the one write that is not an item: it records the account
// keyset and the first vault, both sealed in the browser at signup.
//
diff --git a/internal/contratest/auth_test.go b/internal/contratest/auth_test.go
index a5a41f5..16ca10b 100644
--- a/internal/contratest/auth_test.go
+++ b/internal/contratest/auth_test.go
@@ -31,6 +31,7 @@ import (
type account struct {
email string
passphrase string
+ cookie string
id []byte
salt []byte
accountKey []byte
@@ -70,10 +71,32 @@ func decode(t *testing.T, rec *httptest.ResponseRecorder, v any) {
// doSignup runs the whole client side: generate RK and the two printed
// values, stretch, derive, seal, and post only the sealed halves.
+// doSignup walks the whole staged ceremony the way a person does: ask for a
+// link, follow the link, then choose a passphrase. It needs the outbox, so it
+// takes the app and the box together.
func doSignup(t *testing.T, h http.Handler, email, passphrase string) account {
t.Helper()
+ box := mailboxFor(t, h)
- a := account{email: email, passphrase: passphrase, wraps: map[string]sealedWrap{}}
+ // Step 1: the address.
+ if rec := postJSON(t, h, "/auth/begin", map[string]string{"email": email}); rec.Code != http.StatusOK {
+ t.Fatalf("begin: status %d, body %s", rec.Code, rec.Body.String())
+ }
+ // Step 1b: follow the link, which mints the signup session.
+ link := box.linkFor(t, email)
+ req := httptest.NewRequest(http.MethodGet, link, nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("verify link: status %d", rec.Code)
+ }
+ signupCookie := strings.SplitN(rec.Header().Get("Set-Cookie"), ";", 2)[0]
+ if signupCookie == "" {
+ t.Fatal("following the link minted no signup session")
+ }
+
+ a := account{email: email, passphrase: passphrase, wraps: map[string]sealedWrap{},
+ cookie: signupCookie}
for _, dst := range []*[]byte{&a.id, &a.salt, &a.accountKey, &a.recovery} {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
@@ -147,8 +170,7 @@ func doSignup(t *testing.T, h http.Handler, email, passphrase string) account {
}
}
- rec := postJSON(t, h, "/auth/signup", map[string]any{
- "email": email,
+ rec = postAs(t, h, a.cookie, "/auth/complete", map[string]any{
"account_id_hex": hex.EncodeToString(a.id),
"salt_hex": hex.EncodeToString(a.salt),
"login_keys": []map[string]string{
@@ -158,11 +180,27 @@ func doSignup(t *testing.T, h http.Handler, email, passphrase string) account {
"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())
+ t.Fatalf("complete: status %d, body %s", rec.Code, rec.Body.String())
}
return a
}
+// postAs is postJSON carrying a cookie.
+func postAs(t *testing.T, h http.Handler, cookie, path string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(raw)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", testOrigin)
+ req.Header.Set("Cookie", cookie)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
// 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 {
@@ -368,9 +406,14 @@ func TestServerRefusesDowngradedKDFParameters(t *testing.T) {
}
// 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",
+ h, _, box := newAppWithMail(t)
+ const weak = "weak@example.invalid"
+ postJSON(t, h, "/auth/begin", map[string]string{"email": weak})
+ vr := httptest.NewRecorder()
+ h.ServeHTTP(vr, httptest.NewRequest(http.MethodGet, box.linkFor(t, weak), nil))
+ cookie := strings.SplitN(vr.Header().Get("Set-Cookie"), ";", 2)[0]
+
+ rec := postAs(t, h, cookie, "/auth/complete", map[string]any{
"account_id_hex": hex.EncodeToString(make([]byte, 16)),
"salt_hex": hex.EncodeToString(make([]byte, 16)),
"login_keys": []map[string]string{},
@@ -413,7 +456,7 @@ func TestBlindIndexIsStableAndOpaque(t *testing.T) {
func newAuthForIndexTest(t *testing.T) *auth.Auth {
t.Helper()
- a, err := auth.New(nil, nil, testPepper)
+ a, err := auth.New(nil, nil, testPepper, nil, testOrigin, nil)
if err != nil {
t.Fatalf("auth.New: %v", err)
}
@@ -425,7 +468,7 @@ func newAuthForIndexTest(t *testing.T) *auth.Auth {
// 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 {
+ if _, err := auth.New(nil, nil, p, nil, testOrigin, nil); err == nil {
t.Errorf("auth.New accepted a %d-byte pepper", len(p))
}
}
diff --git a/internal/contratest/browser_test.go b/internal/contratest/browser_test.go
index 1938b2d..d9ea76e 100644
--- a/internal/contratest/browser_test.go
+++ b/internal/contratest/browser_test.go
@@ -42,36 +42,83 @@ func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
return p.WithAwaitPromise(true)
}
-func newRig(t *testing.T) *harness.Rig {
+func newRig(t *testing.T) (*harness.Rig, *sentMail) {
t.Helper()
- return harness.New(t, func(origin string) http.Handler {
+ box := &sentMail{}
+ rig := harness.New(t, func(origin string) http.Handler {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { d.Close() })
- mux, err := contra.App(d, contra.Config{Origin: origin, Pepper: testPepper}, logger)
+ mux, err := contra.App(d, contra.Config{
+ Origin: origin, Pepper: testPepper, Mailer: box,
+ }, logger)
if err != nil {
t.Fatalf("App: %v", err)
}
return mux
})
+ return rig, box
+}
+
+// signUpInBrowser walks the three steps a person walks: type an address,
+// follow the link out of the message, choose a passphrase, confirm the kit.
+//
+// The link is the point. It is what makes signup something only the owner of
+// an address can finish, and driving it here is the only way to know the two
+// halves of that actually meet.
+func signUpInBrowser(t *testing.T, rig *harness.Rig, box *sentMail,
+ email, passphrase string) {
+ t.Helper()
+
+ rig.Run(
+ chromedp.Navigate(rig.Origin+"/signup"),
+ chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery),
+ chromedp.Click(`button[type=submit]`, chromedp.ByQuery),
+ // Wait for the next screen, not for the click. The button fires an
+ // async request and only then navigates, so checking the outbox any
+ // earlier is a race the test would lose about half the time.
+ chromedp.WaitVisible(`[data-sent]`, chromedp.ByQuery),
+ )
+
+ link := box.linkFor(t, email)
+ rig.Run(
+ chromedp.Navigate(rig.Origin+link),
+ chromedp.WaitVisible(`[data-passphrase]`, chromedp.ByQuery),
+ chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery),
+ chromedp.SendKeys(`input[name=passphrase_again]`, passphrase, chromedp.ByQuery),
+ chromedp.Click(`button[type=submit]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery),
+ )
}
// TestBrowserWalk is the loud walk over every page a signed-out visitor can
// reach. Each one loads the crypto module graph, so a syntax error anywhere
// in it fails here.
func TestBrowserWalk(t *testing.T) {
- rig := newRig(t)
+ rig, box := newRig(t)
rig.Run(chromedp.Navigate(rig.Origin + "/"))
rig.Screen("body", "home")
rig.Run(chromedp.Navigate(rig.Origin + "/signup"))
rig.Screen("body", "signup")
+ rig.Run(chromedp.Navigate(rig.Origin + "/signup/sent"))
+ rig.Screen("body", "check your email")
+
rig.Run(chromedp.Navigate(rig.Origin + "/signin"))
rig.Screen("body", "signin")
+
+ // The passphrase step is not reachable without a link.
+ rig.Run(chromedp.Navigate(rig.Origin + "/signup/passphrase"))
+ var path string
+ rig.Run(chromedp.Location(&path))
+ if !strings.Contains(path, "/signup") || strings.Contains(path, "passphrase") {
+ t.Errorf("the passphrase step was reachable without a link: %s", path)
+ }
+ _ = box
}
// TestBrowserSignupAndSignIn is the ceremony in a real engine: Argon2id at
@@ -82,21 +129,14 @@ func TestBrowserWalk(t *testing.T) {
// scalar, in particular, is a place engines have differed, and a difference
// there is not a slow login — it is an account that cannot be signed into.
func TestBrowserSignupAndSignIn(t *testing.T) {
- rig := newRig(t)
- rig.Run(chromedp.Navigate(rig.Origin + "/signup"))
+ rig, box := newRig(t)
const email = "browser-walk@example.invalid"
const passphrase = "a generated test passphrase 8f3a1c"
- rig.Run(
- chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery),
- chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery),
- chromedp.SendKeys(`input[name=passphrase_again]`, passphrase, chromedp.ByQuery),
- chromedp.Click(`button[type=submit]`, chromedp.ByQuery),
- // Argon2id at 64 MiB takes a couple of seconds in a real engine, so
- // this waits on the kit rather than on a timer.
- chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery),
- )
+ // Argon2id at 64 MiB takes a couple of seconds in a real engine, so the
+ // helper waits on the kit appearing rather than on a timer.
+ signUpInBrowser(t, rig, box, email, passphrase)
rig.Screen("[data-status]", "emergency kit")
// Both printed values must actually be on the page: this is the only
@@ -136,21 +176,14 @@ func TestBrowserSignupAndSignIn(t *testing.T) {
// halves agree in a real engine, over a real session, with the real
// localStorage the Account Key cache depends on.
func TestBrowserVaultRoundTrip(t *testing.T) {
- rig := newRig(t)
+ rig, box := newRig(t)
const email = "vault-walk@example.invalid"
const passphrase = "a generated test passphrase 8f3a1c"
// Signup provisions the account keyset and the first vault, and caches
// the Account Key for this browser.
- rig.Run(
- chromedp.Navigate(rig.Origin+"/signup"),
- chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery),
- chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery),
- chromedp.SendKeys(`input[name=passphrase_again]`, passphrase, chromedp.ByQuery),
- chromedp.Click(`button[type=submit]`, chromedp.ByQuery),
- chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery),
- )
+ signUpInBrowser(t, rig, box, email, passphrase)
unlockVault := []chromedp.Action{
chromedp.Navigate(rig.Origin + "/vault"),
@@ -219,19 +252,12 @@ func TestBrowserVaultRoundTrip(t *testing.T) {
// the whole product that cannot be undone later, so the gate is worth a test
// rather than a comment.
func TestBrowserEmergencyKit(t *testing.T) {
- rig := newRig(t)
+ rig, box := newRig(t)
const email = "kit-walk@example.invalid"
const passphrase = "a generated test passphrase 8f3a1c"
- rig.Run(
- chromedp.Navigate(rig.Origin+"/signup"),
- chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery),
- chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery),
- chromedp.SendKeys(`input[name=passphrase_again]`, passphrase, chromedp.ByQuery),
- chromedp.Click(`button[type=submit]`, chromedp.ByQuery),
- chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery),
- )
+ signUpInBrowser(t, rig, box, email, passphrase)
// Both printed values are on screen, and they are different values.
var accountKey, recovery string
diff --git a/internal/contratest/greps_test.go b/internal/contratest/greps_test.go
index bfbf14b..4d199b6 100644
--- a/internal/contratest/greps_test.go
+++ b/internal/contratest/greps_test.go
@@ -70,6 +70,10 @@ import (
// canaries are what a person would have typed. Generated, never real
// (AGENTS.md decision 8), and distinctive enough that a match cannot be a
// coincidence.
+//
+// The addresses are lower case on purpose: they are normalised before they
+// are indexed, so an upper-case canary would be searching for a form that
+// could never have been stored — a grep that cannot fail.
var canaries = map[string]string{
"item title": "CANARY-title-Jamaica-Bakery-8f3a1c",
"item url": "https://CANARY-host-4d2e9b.example.invalid/login",
@@ -78,7 +82,8 @@ var canaries = map[string]string{
"item notes": "CANARY-notes-the-spare-key-is-under-the-pot",
"item totp": "CANARYTOTPSEEDJBSWY3DPEHPK3PXP",
"vault name": "CANARY-vault-Household-1a7c5e",
- "email address": "CANARY-person-1a7c5e@example.invalid",
+ "email address": "canary-person-1a7c5e@example.invalid",
+ "second email": "canary-abandoned-6f2b8d@example.invalid",
"passphrase": "CANARY-passphrase-correct-horse-2b9d4f",
}
@@ -324,21 +329,12 @@ func grepEverything(t *testing.T, dbPath string, ns []needle) []string {
func TestMercilessGreps(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "contra.db")
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- d, err := db.Open(dbPath, logger)
- if err != nil {
- t.Fatalf("db.Open: %v", err)
- }
-
- // 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)
- }
+ // The real ceremony, over the real handlers: the staged signup and a
+ // 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, d, _ := newAppAt(t, dbPath)
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)
@@ -348,6 +344,12 @@ func TestMercilessGreps(t *testing.T) {
postJSON(t, mux, "/auth/challenge",
map[string]string{"email": canaries["email address"], "branch": "pw"})
+ // And a signup that was started and never finished, so signup_sessions is
+ // grepped holding a row for an address nobody confirmed — which is the
+ // state that table spends most of its life in.
+ postJSON(t, mux, "/auth/begin",
+ map[string]string{"email": canaries["second email"]})
+
sec := writeEverything(t, store.New(d.G))
ns := needles(sec)
@@ -360,6 +362,8 @@ func TestMercilessGreps(t *testing.T) {
"accounts": 2, "login_keys": 4, "wraps": 5,
"vaults": 1, "vault_members": 1, "items": 1,
"login_challenges": 1,
+ // One abandoned signup: the row exists, and holds nothing readable.
+ "signup_sessions": 1,
} {
var n int64
if err := d.G.Raw("SELECT count(*) FROM " + table).Scan(&n).Error; err != nil {
@@ -395,9 +399,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 (?, ?, ?, ?, ?, ?, ?)`,
+ WHERE tbl_name IN (?, ?, ?, ?, ?, ?, ?, ?)`,
"accounts", "login_keys", "wraps", "vaults", "vault_members", "items",
- "login_challenges").
+ "login_challenges", "signup_sessions").
Scan(&ddl).Error; err != nil {
t.Fatalf("read sqlite_master: %v", err)
}
diff --git a/internal/contratest/harness_test.go b/internal/contratest/harness_test.go
index f6cebff..0b9c5fe 100644
--- a/internal/contratest/harness_test.go
+++ b/internal/contratest/harness_test.go
@@ -7,6 +7,7 @@
package contratest
import (
+ "context"
"io"
"log/slog"
"net/http"
@@ -14,6 +15,7 @@ import (
"net/url"
"path/filepath"
"strings"
+ "sync"
"testing"
"gorm.io/gorm"
@@ -32,6 +34,63 @@ const testOrigin = "http://app.test"
// exactly as a deployed pepper would, and decrypts nothing either way.
var testPepper = []byte("contra test pepper -- generated, not a secret, 0f3a")
+// sentMail records what the app would have posted. A test never opens a
+// socket, and the link a step-one signup emails is the thing step two needs —
+// so capturing it here is what makes the whole ceremony testable end to end.
+type sentMail struct {
+ mu sync.Mutex
+ sent []struct{ To, Subject, Body string }
+}
+
+func (m *sentMail) Send(_ context.Context, to, subject, body string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.sent = append(m.sent, struct{ To, Subject, Body string }{to, subject, body})
+ return nil
+}
+
+// linkFor returns the signup link sent to an address, and says so plainly if
+// there is none: "no mail" and "mail without a link" are different bugs.
+func (m *sentMail) linkFor(t *testing.T, to string) string {
+ t.Helper()
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ // Case-insensitively: addresses are normalised to lower case before they
+ // are mailed, so a test that typed mixed case is still looking for its
+ // own message.
+ for i := len(m.sent) - 1; i >= 0; i-- {
+ if !strings.EqualFold(m.sent[i].To, to) {
+ continue
+ }
+ if at := strings.Index(m.sent[i].Body, "/signup/verify?t="); at >= 0 {
+ rest := m.sent[i].Body[at:]
+ return strings.TrimSpace(strings.SplitN(rest, "\n", 2)[0])
+ }
+ t.Fatalf("the message to %s carries no signup link:\n%s", to, m.sent[i].Body)
+ }
+ t.Fatalf("no message was sent to %s", to)
+ return ""
+}
+
+// mailboxes lets a helper find the outbox for an app it was only handed the
+// handler for. Every app a test builds gets one, so doSignup can follow the
+// link without every call site having to thread it through.
+var (
+ mailboxMu sync.Mutex
+ mailboxes = map[http.Handler]*sentMail{}
+)
+
+func mailboxFor(t *testing.T, h http.Handler) *sentMail {
+ t.Helper()
+ mailboxMu.Lock()
+ defer mailboxMu.Unlock()
+ box, ok := mailboxes[h]
+ if !ok {
+ t.Fatal("this app was not built by the test harness, so it has no outbox")
+ }
+ return box
+}
+
func testConfig() contra.Config {
return contra.Config{Origin: testOrigin, Pepper: testPepper}
}
@@ -47,18 +106,46 @@ func newApp(t *testing.T) http.Handler {
// newAppWithDB is newApp plus the handle, for the tests that need to look at
// the row that was actually stored rather than at what a handler said.
func newAppWithDB(t *testing.T) (http.Handler, *gorm.DB) {
+ t.Helper()
+ h, db, _ := newAppWithMail(t)
+ return h, db
+}
+
+// newAppWithMail is the whole app plus the outbox, for the tests that need to
+// follow a link the app "sent".
+func newAppWithMail(t *testing.T) (http.Handler, *gorm.DB, *sentMail) {
+ t.Helper()
+ h, d, box := newAppAt(t, filepath.Join(t.TempDir(), "app.db"))
+ return h, d.G, box
+}
+
+// newAppAt is newAppWithMail over a named file, handing back the database
+// itself — for the tests that go and read its bytes afterwards.
+func newAppAt(t *testing.T, dbPath string) (http.Handler, *db.DB, *sentMail) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ d, err := db.Open(dbPath, logger)
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { d.Close() })
- mux, err := contra.App(d, testConfig(), logger)
+
+ box := &sentMail{}
+ cfg := testConfig()
+ cfg.Mailer = box
+ mux, err := contra.App(d, cfg, logger)
if err != nil {
t.Fatalf("App: %v", err)
}
- return mux, d.G
+ mailboxMu.Lock()
+ mailboxes[mux] = box
+ mailboxMu.Unlock()
+ t.Cleanup(func() {
+ mailboxMu.Lock()
+ delete(mailboxes, mux)
+ mailboxMu.Unlock()
+ })
+ return mux, d, box
}
func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go
index ac9a9e4..73761ab 100644
--- a/internal/contratest/jsmodules_test.go
+++ b/internal/contratest/jsmodules_test.go
@@ -26,7 +26,7 @@ const jsModuleCap = 300
// arrive without somebody noticing.
var jsHighWater = map[string]int{
// The crypto core.
- "account.js": 188,
+ "account.js": 172,
"argon2id.js": 292,
"blake2b.js": 99,
"bytes.js": 61,
@@ -45,8 +45,10 @@ var jsHighWater = map[string]int{
// 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": 191,
- "pages/vault.js": 170,
+ "pages/passkey.js": 170,
+ "pages/signin.js": 49,
+ "pages/signup.js": 198,
+ "pages/vault.js": 170,
}
func TestJSModulesStaySmall(t *testing.T) {
diff --git a/internal/contratest/storage_test.go b/internal/contratest/storage_test.go
index 0145ebb..29ca835 100644
--- a/internal/contratest/storage_test.go
+++ b/internal/contratest/storage_test.go
@@ -116,6 +116,7 @@ func TestSchemaIsTheDeclaredSet(t *testing.T) {
"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",
+ "signup_sessions": "an address proved but no account yet: a blind index and a token hash",
}
found := map[string]bool{}