Skip to content

Commit

Permalink
add support for multiple client certificates (#96)
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkr committed Dec 21, 2024
1 parent 2cd60e6 commit 9bafcf9
Show file tree
Hide file tree
Showing 15 changed files with 386 additions and 65 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import (
type Config struct {
DatabaseOptions string

RegistrationInterval time.Duration
RegistrationInterval time.Duration
CertificateApprovalTimeout time.Duration

MaxPostsLength int
MaxPostsPerDay int64
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions data/garbage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
54 changes: 54 additions & 0 deletions front/approve.go
Original file line number Diff line number Diff line change
@@ -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")
}
78 changes: 78 additions & 0 deletions front/certificates.go
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 14 additions & 2 deletions front/gemini/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 7 additions & 1 deletion front/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 8 additions & 44 deletions front/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
54 changes: 54 additions & 0 deletions front/revoke.go
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 2 additions & 0 deletions front/static/help.gmi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 9bafcf9

Please sign in to comment.