diff --git a/README.md b/README.md index c8e577fc..c14fc140 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ This makes tootik lightweight, private and accessible: * Full-text search within posts * Upload of posts and user avatars, over [Titan](gemini://transjovian.org/titan) * Account migration, in both directions +* Support for multiple client certificates ## Using tootik diff --git a/cfg/cfg.go b/cfg/cfg.go index 096dbd9b..e6b82793 100644 --- a/cfg/cfg.go +++ b/cfg/cfg.go @@ -26,7 +26,8 @@ import ( type Config struct { DatabaseOptions string - RegistrationInterval time.Duration + RegistrationInterval time.Duration + CertificateApprovalTimeout time.Duration MaxPostsLength int MaxPostsPerDay int64 @@ -132,6 +133,10 @@ func (c *Config) FillDefaults() { c.RegistrationInterval = time.Hour } + if c.CertificateApprovalTimeout <= 0 { + c.CertificateApprovalTimeout = time.Hour * 48 + } + if c.MaxPostsLength <= 0 { c.MaxPostsLength = 500 } diff --git a/data/garbage.go b/data/garbage.go index ee9365fb..169083d8 100644 --- a/data/garbage.go +++ b/data/garbage.go @@ -105,5 +105,13 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to invisible bookmarks: %w", err) } + if _, err := gc.DB.ExecContext(ctx, `delete from certificates where approved = 0 and inserted < ?`, now.Add(-gc.Config.CertificateApprovalTimeout).Unix()); err != nil { + return fmt.Errorf("failed to remove timed out certificate approval requests: %w", err) + } + + if _, err := gc.DB.ExecContext(ctx, `delete from certificates where expires < unixepoch()`); err != nil { + return fmt.Errorf("failed to remove expired certificates: %w", err) + } + return nil } diff --git a/front/approve.go b/front/approve.go new file mode 100644 index 00000000..2bf39dd1 --- /dev/null +++ b/front/approve.go @@ -0,0 +1,54 @@ +/* +Copyright 2024 Dima Krasner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package front + +import "github.com/dimkr/tootik/front/text" + +func (h *Handler) approve(w text.Writer, r *Request, args ...string) { + if r.User == nil { + w.Redirect("/users") + return + } + + hash := args[1] + + r.Log.Info("Approving certificate", "user", r.User.PreferredUsername, "hash", hash) + + if res, err := h.DB.ExecContext( + r.Context, + ` + update certificates set approved = 1 + where user = ? and hash = ? and approved = 0 + `, + r.User.PreferredUsername, + hash, + ); err != nil { + r.Log.Warn("Failed to approve certificate", "user", r.User.PreferredUsername, "hash", hash, "error", err) + w.Error() + return + } else if n, err := res.RowsAffected(); err != nil { + r.Log.Warn("Failed to approve certificate", "user", r.User.PreferredUsername, "hash", hash, "error", err) + w.Error() + return + } else if n == 0 { + r.Log.Warn("Certificate doesn't exist or already approved", "user", r.User.PreferredUsername, "hash", hash) + w.Status(40, "Cannot approve certificate") + return + } + + w.Redirect("/users/certificates") +} diff --git a/front/certificates.go b/front/certificates.go new file mode 100644 index 00000000..889ed751 --- /dev/null +++ b/front/certificates.go @@ -0,0 +1,78 @@ +/* +Copyright 2024 Dima Krasner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package front + +import ( + "time" + + "github.com/dimkr/tootik/front/text" +) + +func (h *Handler) certificates(w text.Writer, r *Request, args ...string) { + if r.User == nil { + w.Redirect("/users") + return + } + + rows, err := h.DB.QueryContext( + r.Context, + ` + select inserted, hash, approved, expires from certificates + where user = ? + order by inserted + `, + r.User.PreferredUsername, + ) + if err != nil { + r.Log.Warn("Failed to fetch certificates", "user", r.User.PreferredUsername, "error", err) + w.Error() + return + } + + defer rows.Close() + + w.OK() + w.Title("🎓 Certificates") + + first := true + for rows.Next() { + var inserted, expires int64 + var hash string + var approved int + if err := rows.Scan(&inserted, &hash, &approved, &expires); err != nil { + r.Log.Warn("Failed to fetch certificate", "user", r.User.PreferredUsername, "error", err) + continue + } + + if !first { + w.Empty() + } + + w.Item("SHA-256: " + hash) + w.Item("Added: " + time.Unix(inserted, 0).Format(time.DateOnly)) + w.Item("Expires: " + time.Unix(expires, 0).Format(time.DateOnly)) + + if approved == 0 { + w.Link("/users/certificates/approve/"+hash, "🟢 Approve") + w.Link("/users/certificates/revoke/"+hash, "🔴 Deny") + } else { + w.Link("/users/certificates/revoke/"+hash, "🔴 Revoke") + } + + first = false + } +} diff --git a/front/gemini/gemini.go b/front/gemini/gemini.go index b9272455..fb5e6dfc 100644 --- a/front/gemini/gemini.go +++ b/front/gemini/gemini.go @@ -57,16 +57,25 @@ func (gl *Listener) getUser(ctx context.Context, tlsConn *tls.Conn) (*ap.Actor, clientCert := state.PeerCertificates[0] - certHash := fmt.Sprintf("%x", sha256.Sum256(clientCert.Raw)) + if time.Now().After(clientCert.NotAfter) { + return nil, httpsig.Key{}, nil + } + + certHash := fmt.Sprintf("%X", sha256.Sum256(clientCert.Raw)) var id, privKeyPem string var actor ap.Actor - if err := gl.DB.QueryRowContext(ctx, `select id, actor, privkey from persons where host = ? and certhash = ?`, gl.Domain, certHash).Scan(&id, &actor, &privKeyPem); err != nil && errors.Is(err, sql.ErrNoRows) { + var approved int + if err := gl.DB.QueryRowContext(ctx, `select persons.id, persons.actor, persons.privkey, certificates.approved from certificates join persons on persons.actor->>'$.preferredUsername' = certificates.user where persons.host = ? and certificates.hash = ? and certificates.expires > unixepoch()`, gl.Domain, certHash).Scan(&id, &actor, &privKeyPem, &approved); err != nil && errors.Is(err, sql.ErrNoRows) { return nil, httpsig.Key{}, front.ErrNotRegistered } else if err != nil { return nil, httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, err) } + if approved == 0 { + return nil, httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, front.ErrNotApproved) + } + privKey, err := data.ParsePrivateKey(privKeyPem) if err != nil { return nil, httpsig.Key{}, fmt.Errorf("failed to parse private key for %s: %w", certHash, err) @@ -141,6 +150,9 @@ func (gl *Listener) Handle(ctx context.Context, conn net.Conn) { slog.Info("Redirecting new user") w.Redirect("/users/register") return + } else if errors.Is(err, front.ErrNotApproved) { + w.Status(40, "Client certificate is awaiting approval") + return } else if err != nil && !errors.Is(err, front.ErrNotRegistered) { slog.Warn("Failed to get user", "error", err) w.Error() diff --git a/front/handler.go b/front/handler.go index cf4b2f4b..1e960761 100644 --- a/front/handler.go +++ b/front/handler.go @@ -38,7 +38,10 @@ type Handler struct { DB *sql.DB } -var ErrNotRegistered = errors.New("user is not registered") +var ( + ErrNotRegistered = errors.New("user is not registered") + ErrNotApproved = errors.New("client certificate is not approved") +) func serveStaticFile(lines []string, w text.Writer, _ *Request, _ ...string) { w.OK() @@ -85,6 +88,9 @@ func NewHandler(domain string, closed bool, cfg *cfg.Config, resolver ap.Resolve h.handlers[regexp.MustCompile(`^/users/name$`)] = h.name h.handlers[regexp.MustCompile(`^/users/alias$`)] = h.alias h.handlers[regexp.MustCompile(`^/users/move$`)] = h.move + h.handlers[regexp.MustCompile(`^/users/certificates$`)] = withUserMenu(h.certificates) + h.handlers[regexp.MustCompile(`^/users/certificates/approve/(\S+)$`)] = withUserMenu(h.approve) + h.handlers[regexp.MustCompile(`^/users/certificates/revoke/(\S+)$`)] = withUserMenu(h.revoke) h.handlers[regexp.MustCompile(`^/view/(\S+)$`)] = withUserMenu(h.view) h.handlers[regexp.MustCompile(`^/users/view/(\S+)$`)] = withUserMenu(h.view) diff --git a/front/register.go b/front/register.go index 32957cd2..8f7d12ed 100644 --- a/front/register.go +++ b/front/register.go @@ -17,14 +17,11 @@ limitations under the License. package front import ( - "crypto/sha256" "crypto/tls" "database/sql" - "fmt" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text" "github.com/dimkr/tootik/front/user" - "net/url" "regexp" "time" ) @@ -54,59 +51,26 @@ func (h *Handler) register(w text.Writer, r *Request, args ...string) { } clientCert := state.PeerCertificates[0] - certHash := fmt.Sprintf("%x", sha256.Sum256(clientCert.Raw)) - - var taken int - if err := h.DB.QueryRowContext(r.Context, `select exists (select 1 from persons where host = ? and certhash = ?)`, h.Domain, certHash).Scan(&taken); err != nil { - r.Log.Warn("Failed to check if cerificate hash is already in use", "hash", certHash, "error", err) - w.Error() - return - } - - if taken == 1 { - r.Log.Warn("Cerificate hash is already in use", "hash", certHash) - w.Status(40, "Client certificate is already in use") - return - } - userName := clientCert.Subject.CommonName - if r.URL.RawQuery != "" { - altName, err := url.QueryUnescape(r.URL.RawQuery) - if err != nil { - r.Log.Info("Failed to decode user name", "query", r.URL.RawQuery, "error", err) - w.Status(40, "Bad input") - return - } - if altName != "" { - userName = altName - } + if time.Now().After(clientCert.NotAfter) { + r.Log.Warn("Client certificate has expired", "name", userName, "expired", clientCert.NotAfter) + w.Status(40, "Client certificate has expired") + return } if userName == "" { - w.Status(10, "New user name") + w.Status(40, "Invalid user name") return } if !userNameRegex.MatchString(userName) { - w.Statusf(10, "%s is invalid, enter user name", userName) - return - } - - if err := h.DB.QueryRowContext(r.Context, `select exists (select 1 from persons where actor->>'$.preferredUsername' = ? and host = ?)`, userName, h.Domain).Scan(&taken); err != nil { - r.Log.Warn("Failed to check if username is taken", "name", userName, "error", err) - w.Error() - return - } - - if taken == 1 { - r.Log.Warn("Username is already taken", "name", userName) - w.Statusf(10, "%s is already taken, enter user name", userName) + w.Status(40, "Invalid user name") return } var lastRegister sql.NullInt64 - if err := h.DB.QueryRowContext(r.Context, `select max(inserted) from persons where host = ?`, h.Domain).Scan(&lastRegister); err != nil { + if err := h.DB.QueryRowContext(r.Context, `select max(inserted) from certificates`).Scan(&lastRegister); err != nil { r.Log.Warn("Failed to check last registration time", "name", userName, "error", err) w.Error() return @@ -122,7 +86,7 @@ func (h *Handler) register(w text.Writer, r *Request, args ...string) { r.Log.Info("Creating new user", "name", userName) - if _, _, err := user.Create(r.Context, h.Domain, h.DB, userName, ap.Person, &certHash); err != nil { + if _, _, err := user.Create(r.Context, h.Domain, h.DB, userName, ap.Person, clientCert); err != nil { r.Log.Warn("Failed to create new user", "name", userName, "error", err) w.Status(40, "Failed to create new user") return diff --git a/front/revoke.go b/front/revoke.go new file mode 100644 index 00000000..c305aec5 --- /dev/null +++ b/front/revoke.go @@ -0,0 +1,54 @@ +/* +Copyright 2024 Dima Krasner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package front + +import "github.com/dimkr/tootik/front/text" + +func (h *Handler) revoke(w text.Writer, r *Request, args ...string) { + if r.User == nil { + w.Redirect("/users") + return + } + + hash := args[1] + + r.Log.Info("Revoking certificate", "user", r.User.PreferredUsername, "hash", hash) + + if res, err := h.DB.ExecContext( + r.Context, + ` + delete from certificates + where user = $1 and hash = $2 and exists (select 1 from certificates others where others.user = $1 and others.hash != $2 and others.approved = 1) + `, + r.User.PreferredUsername, + hash, + ); err != nil { + r.Log.Warn("Failed to revoke certificate", "user", r.User.PreferredUsername, "hash", hash, "error", err) + w.Error() + return + } else if n, err := res.RowsAffected(); err != nil { + r.Log.Warn("Failed to revoke certificate", "user", r.User.PreferredUsername, "hash", hash, "error", err) + w.Error() + return + } else if n == 0 { + r.Log.Warn("Certificate doesn't exist or already revoked", "user", r.User.PreferredUsername, "hash", hash) + w.Status(40, "Cannot revoke certificate") + return + } + + w.Redirect("/users/certificates") +} diff --git a/front/static/help.gmi b/front/static/help.gmi index 4c63dabf..739f85ee 100644 --- a/front/static/help.gmi +++ b/front/static/help.gmi @@ -31,6 +31,8 @@ This page shows various statistics about this server and the parts of the fedive Follow this link to sign in or create an account on this server. +The Common Name property of the client certificate ("identity") you use will determine your username. + Registered users can: * Publish posts * Reply to posts diff --git a/front/static/users/help.gmi b/front/static/users/help.gmi index bb353774..e59056ed 100644 --- a/front/static/users/help.gmi +++ b/front/static/users/help.gmi @@ -64,6 +64,7 @@ This page allows you to: * Set an account alias, to allow account migration to this instance * Notify followers about account migration from this instance * Upload a .png, .jpg or .gif image to serve as your avatar (use your client certificate for authentication): up to {{.Config.MaxAvatarWidth}}x{{.Config.MaxAvatarHeight}} and {{.Config.MaxAvatarSize}} bytes, downscaled to {{.Config.AvatarWidth}}x{{.Config.AvatarHeight}} +* Manage client certificates associated with your account > 📊 Status @@ -101,6 +102,18 @@ For example: Polls must have between 2 and {{.Config.PollMaxOptions}} multi-choice options, and end after {{printf "%s" .Config.PollDuration}}. +## Client Certificates ("Identities") + +The username of a newly created account is the Common Name property of the client certificate used during registration. + +To associate an additional client certificate with your account: +* Create a new client certificate with the same Common Name as your current certificate +* Register using the new certificate: this will add the certificate to the list of certificates waiting for your approval +* While authenticated using the old certificate, use Settings → Certificates to approve the new one +* To secure your account, use Settings → Certificates to revoke access for certificates you don't use anymore + +Client certificates get rejected automatically after {{.Config.CertificateApprovalTimeout}} without approval. + ## Account Migration Successful migration should preserve followers by moving them from the old account to the new account. Posts and other user actions are not migrated. diff --git a/front/static/users/settings.gmi b/front/static/users/settings.gmi index 2010dfde..5e6a3303 100644 --- a/front/static/users/settings.gmi +++ b/front/static/users/settings.gmi @@ -7,6 +7,10 @@ => titan://{{.Domain}}/users/upload/bio Upload bio => titan://{{.Domain}}/users/upload/avatar Upload avatar +## Account + +=> /users/certificates 🎓 Certificates + ## Migration => /users/alias 🔗 Set account alias diff --git a/front/user/create.go b/front/user/create.go index 108efca3..3bb2db6c 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -21,6 +21,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" "database/sql" "encoding/pem" @@ -63,7 +64,7 @@ func gen() (*rsa.PrivateKey, []byte, []byte, error) { } // Create creates a new user. -func Create(ctx context.Context, domain string, db *sql.DB, name string, actorType ap.ActorType, certHash *string) (*ap.Actor, httpsig.Key, error) { +func Create(ctx context.Context, domain string, db *sql.DB, name string, actorType ap.ActorType, cert *x509.Certificate) (*ap.Actor, httpsig.Key, error) { priv, privPem, pubPem, err := gen() if err != nil { return nil, httpsig.Key{}, fmt.Errorf("failed to generate key pair: %w", err) @@ -101,16 +102,51 @@ func Create(ctx context.Context, domain string, db *sql.DB, name string, actorTy Published: &ap.Time{Time: time.Now()}, } - if _, err = db.ExecContext( + key := httpsig.Key{ID: actor.PublicKey.ID, PrivateKey: priv} + + if cert == nil { + if _, err = db.ExecContext( + ctx, + `INSERT INTO persons (id, actor, privkey) VALUES(?,?,?)`, + id, + &actor, + string(privPem), + ); err != nil { + return nil, httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) + } + + return &actor, key, nil + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return nil, httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) + } + defer tx.Rollback() + + if _, err = tx.ExecContext( ctx, - `INSERT INTO persons (id, actor, privkey, certhash) VALUES(?,?,?,?)`, + `INSERT OR IGNORE INTO persons (id, actor, privkey) VALUES(?,?,?)`, id, &actor, string(privPem), - certHash, ); err != nil { return nil, httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) } - return &actor, httpsig.Key{ID: actor.PublicKey.ID, PrivateKey: priv}, nil + if _, err = tx.ExecContext( + ctx, + `INSERT OR IGNORE INTO certificates (user, hash, approved, expires) VALUES($1, $2, (SELECT NOT EXISTS (SELECT 1 FROM certificates WHERE user = $1)), $3)`, + name, + fmt.Sprintf("%X", sha256.Sum256(cert.Raw)), + cert.NotAfter.Unix(), + ); err != nil { + return nil, httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) + } + + if err := tx.Commit(); err != nil { + return nil, httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) + } + + return &actor, key, nil } diff --git a/migrations/035_certificates.go b/migrations/035_certificates.go new file mode 100644 index 00000000..97c82908 --- /dev/null +++ b/migrations/035_certificates.go @@ -0,0 +1,39 @@ +package migrations + +import ( + "context" + "database/sql" +) + +func certificates(ctx context.Context, domain string, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `CREATE TABLE certificates(user TEXT NOT NULL, hash TEXT NOT NULL, approved INTEGER DEFAULT 0, expires INTEGER, inserted INTEGER DEFAULT (UNIXEPOCH()))`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO certificates(user, hash, expires, approved) SELECT actor->>'$.preferredUsername', UPPER(certhash), 4102444800, 1 FROM persons WHERE certhash IS NOT NULL`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `DROP INDEX personscerthash`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE persons DROP COLUMN certhash`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE INDEX certificatesinserted ON certificates(inserted)`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE INDEX certificateswaiting ON certificates(inserted) WHERE approved = 0`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE INDEX certificatesexpires ON certificates(expires)`); err != nil { + return err + } + + _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX certificateshash ON certificates(hash)`) + return err +} diff --git a/test/register_test.go b/test/register_test.go index 6dfe6948..e5f9c8de 100644 --- a/test/register_test.go +++ b/test/register_test.go @@ -81,6 +81,27 @@ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwpYr2U3MW256QQjk /KbIgszEUXJ7ccMctWelADHc5dJjrf/nbXhvf/NAy0ibEVc6KM97dRMP -----END PRIVATE KEY-----` + erinCertHash = "EB108D8A0EF3051B2830FEAA87AA79ADA1C5B60DD8A7CECCC8EC25BD086DC11A" + + erinOtherCert = `-----BEGIN CERTIFICATE----- +MIIBdDCCARmgAwIBAgIUbFZSevby3dlfix1x1rSPo97pmwEwCgYIKoZIzj0EAwIw +DzENMAsGA1UEAwwEZXJpbjAeFw0yNDEyMTExNzAzMDJaFw0zNDEyMDkxNzAzMDJa +MA8xDTALBgNVBAMMBGVyaW4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS1Kb1E +6KuqOe+7igiQ5A6P5xL26TRaGEA5K95MyP1+0tNsd+JkjBazHstMmJn02HVfFCmE +59xK4lMFLDlnQQJVo1MwUTAdBgNVHQ4EFgQUymF2MzroPIh9yRRfvW70d078ZjMw +HwYDVR0jBBgwFoAUymF2MzroPIh9yRRfvW70d078ZjMwDwYDVR0TAQH/BAUwAwEB +/zAKBggqhkjOPQQDAgNJADBGAiEAuBkYquBho1QVLFnhXn4E8SW89IpdRhJlchCO +AE2Vgr0CIQDXm/PrYlc/oGnUL4HDL44RS8Sz3Hecb098uAIACKlghw== +-----END CERTIFICATE-----` + + erinOtherKey = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgFuJwYfaV8bUaZEDc +GBjiDGYOI7qKaHNQd6X2uXvrM0KhRANCAAS1Kb1E6KuqOe+7igiQ5A6P5xL26TRa +GEA5K95MyP1+0tNsd+JkjBazHstMmJn02HVfFCmE59xK4lMFLDlnQQJV +-----END PRIVATE KEY-----` + + erinOtherCertHash = "4561F7655D75BC965B6204AAED6CB1CE2047E1CA79397A81B41AC26281D42EFF" + /* openssl ecparam -name prime256v1 -genkey -out /tmp/ec.pem openssl req -new -x509 -key /tmp/ec.pem -sha256 -nodes -subj "/CN=david" -out cert.pem -keyout key.pem -days 3650 @@ -522,6 +543,7 @@ func TestRegister_AlreadyRegistered(t *testing.T) { var cfg cfg.Config cfg.FillDefaults() + cfg.RegistrationInterval = 0 assert.NoError(migrations.Run(context.Background(), domain, db)) @@ -576,7 +598,7 @@ func TestRegister_AlreadyRegistered(t *testing.T) { _, err = tlsReader.Write([]byte("gemini://localhost.localdomain:8965/users/register\r\n")) assert.NoError(err) - _, _, err = user.Create(context.Background(), domain, db, "erin", ap.Person, nil) + _, _, err = user.Create(context.Background(), domain, db, "erin", ap.Person, erinKeyPair.Leaf) assert.NoError(err) handler, err := front.NewHandler(domain, false, &cfg, fed.NewResolver(nil, domain, &cfg, &http.Client{}, db), db) @@ -595,7 +617,7 @@ func TestRegister_AlreadyRegistered(t *testing.T) { resp, err := io.ReadAll(tlsReader) assert.NoError(err) - assert.Equal("10 erin is already taken, enter user name\r\n", string(resp)) + assert.Equal("40 Already registered as erin\r\n", string(resp)) } func TestRegister_Twice(t *testing.T) { @@ -608,6 +630,7 @@ func TestRegister_Twice(t *testing.T) { var cfg cfg.Config cfg.FillDefaults() + cfg.RegistrationInterval = 0 assert.NoError(migrations.Run(context.Background(), domain, db)) @@ -883,7 +906,7 @@ func TestRegister_Throttling30Minutes(t *testing.T) { assert.Regexp(data.pattern, string(resp)) - _, err = db.Exec(`update persons set inserted = unixepoch() - 1800`) + _, err = db.Exec(`update certificates set inserted = unixepoch() - 1800`) assert.NoError(err) } } @@ -985,12 +1008,12 @@ func TestRegister_Throttling1Hour(t *testing.T) { assert.Regexp(data.pattern, string(resp)) - _, err = db.Exec(`update persons set inserted = unixepoch() - 3600`) + _, err = db.Exec(`update certificates set inserted = unixepoch() - 3600`) assert.NoError(err) } } -func TestRegister_RedirectTwice(t *testing.T) { +func TestRegister_TwoCertificates(t *testing.T) { assert := assert.New(t) dbPath := fmt.Sprintf("/tmp/%s.sqlite3?_journal_mode=WAL", t.Name()) @@ -1000,6 +1023,7 @@ func TestRegister_RedirectTwice(t *testing.T) { var cfg cfg.Config cfg.FillDefaults() + cfg.RegistrationInterval = 0 assert.NoError(migrations.Run(context.Background(), domain, db)) @@ -1020,6 +1044,14 @@ func TestRegister_RedirectTwice(t *testing.T) { InsecureSkipVerify: true, } + erinOtherKeyPair, err := tls.X509KeyPair([]byte(erinOtherCert), []byte(erinOtherKey)) + assert.NoError(err) + + otherClientCfg := tls.Config{ + Certificates: []tls.Certificate{erinOtherKeyPair}, + InsecureSkipVerify: true, + } + socketPath := fmt.Sprintf("/tmp/%s.socket", t.Name()) localListener, err := net.Listen("unix", socketPath) @@ -1030,13 +1062,26 @@ func TestRegister_RedirectTwice(t *testing.T) { defer tlsListener.Close() for _, data := range []struct { - url string - expected string + url string + expected string + clientCfg *tls.Config }{ - {"gemini://localhost.localdomain:8965/users\r\n", "^30 /users/register\r\n$"}, - {"gemini://localhost.localdomain:8965/users/register\r\n", "^30 /users\r\n$"}, - {"gemini://localhost.localdomain:8965/users\r\n", "^20 text/gemini\r\n.+"}, - {"gemini://localhost.localdomain:8965/users/register\r\n", "^40 Already registered as erin\r\n$"}, + {"gemini://localhost.localdomain:8965/users\r\n", "^30 /users/register\r\n$", &clientCfg}, + {"gemini://localhost.localdomain:8965/users/register\r\n", "^30 /users\r\n$", &clientCfg}, + {"gemini://localhost.localdomain:8965/users\r\n", "^20 text/gemini\r\n.+", &clientCfg}, + {"gemini://localhost.localdomain:8965/users/register\r\n", "^40 Already registered as erin\r\n$", &clientCfg}, + {"gemini://localhost.localdomain:8965/users\r\n", "^30 /users/register\r\n$", &otherClientCfg}, + {"gemini://localhost.localdomain:8965/users/register\r\n", "^30 /users\r\n$", &otherClientCfg}, + {"gemini://localhost.localdomain:8965/users\r\n", "^40 Client certificate is awaiting approval\r\n$", &otherClientCfg}, + {"gemini://localhost.localdomain:8965/users/register\r\n", "^40 Client certificate is awaiting approval\r\n$", &otherClientCfg}, + {fmt.Sprintf("gemini://localhost.localdomain:8965/users/certificates/approve/%s\r\n", erinOtherCertHash), "^30 /users/certificates\r\n$", &clientCfg}, + {"gemini://localhost.localdomain:8965/users/register\r\n", "^40 Already registered as erin\r\n$", &otherClientCfg}, + {"gemini://localhost.localdomain:8965/users\r\n", "^20 text/gemini\r\n.+", &otherClientCfg}, + {fmt.Sprintf("gemini://localhost.localdomain:8965/users/certificates/revoke/%s\r\n", erinCertHash), "^30 /users/certificates\r\n$", &otherClientCfg}, + {"gemini://localhost.localdomain:8965/users\r\n", "^30 /users/register\r\n$", &clientCfg}, + {"gemini://localhost.localdomain:8965/users/register\r\n", "^30 /users\r\n$", &clientCfg}, + {"gemini://localhost.localdomain:8965/users\r\n", "^40 Client certificate is awaiting approval\r\n$", &clientCfg}, + {"gemini://localhost.localdomain:8965/users\r\n", "^20 text/gemini\r\n.+", &otherClientCfg}, } { unixReader, err := net.Dial("unix", socketPath) assert.NoError(err) @@ -1045,7 +1090,7 @@ func TestRegister_RedirectTwice(t *testing.T) { tlsWriter, err := tlsListener.Accept() assert.NoError(err) - tlsReader := tls.Client(unixReader, &clientCfg) + tlsReader := tls.Client(unixReader, data.clientCfg) defer tlsReader.Close() var wg sync.WaitGroup