Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions FEDERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,14 @@ Support for data portability comes into play in 5 main areas:

## Registration

Since v0.21.0, tootik no longer offers choice between 'traditional' and portable actors: all newly registered users are portable actors.

A portable actor is created by generating or supplying a pre-generated, base58-encoded Ed25519 private key during registration. The key, like the user's `preferredUsername`, must be unique per tootik instance.

No matter if the key was generated by tootik or provided by the user, the user can recover it through the settings page.

tootik does not support the [FEP-ae97](https://codeberg.org/fediverse/fep/src/branch/main/fep/ae97/fep-ae97.md) registration flow.

Registration of portable actors is disabled by default (see `EnablePortableActorRegistration`).

## Discovery

Portable actors can be looked up normally, over [WebFinger](https://www.rfc-editor.org/rfc/rfc7033):
Expand Down
22 changes: 11 additions & 11 deletions cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,17 @@ import (
type Config struct {
DatabaseOptions string

RequireRegistration bool
RequireInvitation bool
MaxInvitationsPerUser *int
InvitationTimeout time.Duration
RegistrationInterval time.Duration
CertificateApprovalTimeout time.Duration
UserNameRegex string
CompiledUserNameRegex *regexp.Regexp `json:"-"`
ForbiddenUserNameRegex string
CompiledForbiddenUserNameRegex *regexp.Regexp `json:"-"`
EnablePortableActorRegistration bool
RequireRegistration bool
RequireInvitation bool
MaxInvitationsPerUser *int
InvitationTimeout time.Duration
RegistrationInterval time.Duration
CertificateApprovalTimeout time.Duration
UserNameRegex string
CompiledUserNameRegex *regexp.Regexp `json:"-"`
ForbiddenUserNameRegex string
CompiledForbiddenUserNameRegex *regexp.Regexp `json:"-"`
EnableNonPortableActorRegistration bool

MaxPostsLength int
MaxPostsPerDay int64
Expand Down
4 changes: 0 additions & 4 deletions cluster/followers_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,6 @@ func TestCluster_FollowersSyncMissingRemoteFollowPortableActor(t *testing.T) {
cluster := NewCluster(t, "a.localdomain", "b.localdomain")
defer cluster.Stop()

cluster["b.localdomain"].Config.EnablePortableActorRegistration = true

pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
Expand Down Expand Up @@ -218,8 +216,6 @@ func TestCluster_FollowersSyncMissingLocalFollowPortableActor(t *testing.T) {
cluster := NewCluster(t, "a.localdomain", "b.localdomain")
defer cluster.Stop()

cluster["b.localdomain"].Config.EnablePortableActorRegistration = true

pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
Expand Down
38 changes: 28 additions & 10 deletions cluster/invitation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TestServer_InvitationHappyFlow(t *testing.T) {
alice := s.Register(aliceKeypair).OK()

s.Config.RequireInvitation = true
s.Config.EnableNonPortableActorRegistration = true

bobCode := "70bc9fdf-74a4-41e5-973d-08ba3fd23d74"
carolCode := "ded3626c-ea4b-44cc-adf3-18510e7634e1"
Expand All @@ -38,7 +39,9 @@ func TestServer_InvitationHappyFlow(t *testing.T) {
FollowInput("➕ Generate", bobCode).
Contains(Line{Type: Text, Text: "Code: " + bobCode})

s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode).Follow("😈 My profile").OK()
accept := s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode)
accept.Error("11 base58-encoded Ed25519 private key or 'generate' to generate")
s.HandleInput(bobKeypair, accept.Path, "n").Follow("😈 My profile").OK()

alice.
Follow("⚙️ Settings").
Expand All @@ -47,7 +50,9 @@ func TestServer_InvitationHappyFlow(t *testing.T) {
FollowInput("➕ Generate", carolCode).
Contains(Line{Type: Text, Text: "Code: " + carolCode})

s.HandleInput(carolKeypair, "/users/invitations/accept", carolCode).Follow("😈 My profile").OK()
accept = s.HandleInput(carolKeypair, "/users/invitations/accept", carolCode)
accept.Error("11 base58-encoded Ed25519 private key or 'generate' to generate")
s.HandleInput(carolKeypair, accept.Path, "generate").Follow("😈 My profile").OK()
}

func TestServer_WrongCode(t *testing.T) {
Expand All @@ -56,6 +61,7 @@ func TestServer_WrongCode(t *testing.T) {
alice := s.Register(aliceKeypair).OK()

s.Config.RequireInvitation = true
s.Config.EnableNonPortableActorRegistration = true

bobCode := "70bc9fdf-74a4-41e5-973d-08ba3fd23d74"
carolCode := "ded3626c-ea4b-44cc-adf3-18510e7634e1"
Expand All @@ -68,7 +74,9 @@ func TestServer_WrongCode(t *testing.T) {

s.HandleInput(bobKeypair, "/users/invitations/accept", carolCode).Error("40 Invalid invitation code")

s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode).Follow("😈 My profile").OK()
accept := s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode)
accept.Error("11 base58-encoded Ed25519 private key or 'generate' to generate")
s.HandleInput(bobKeypair, accept.Path, "generate").Follow("😈 My profile").OK()
}

func TestServer_ExpiredCode(t *testing.T) {
Expand All @@ -92,7 +100,10 @@ func TestServer_ExpiredCode(t *testing.T) {
s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode).Error("40 Invalid invitation code")

s.Config.InvitationTimeout = time.Hour
s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode).Follow("😈 My profile").OK()

accept := s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode)
accept.Error("11 base58-encoded Ed25519 private key or 'generate' to generate")
s.HandleInput(bobKeypair, accept.Path, "generate").Follow("😈 My profile").OK()

case <-t.Context().Done():
t.Fail()
Expand All @@ -114,7 +125,9 @@ func TestServer_CodeReuse(t *testing.T) {
FollowInput("➕ Generate", bobCode).
Contains(Line{Type: Text, Text: "Code: " + bobCode})

s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode).Follow("😈 My profile").OK()
accept := s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode)
accept.Error("11 base58-encoded Ed25519 private key or 'generate' to generate")
s.HandleInput(bobKeypair, accept.Path, "generate").Follow("😈 My profile").OK()

s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode).Error("40 Invalid invitation code")
s.HandleInput(carolKeypair, "/users/invitations/accept", bobCode).Error("40 Invalid invitation code")
Expand All @@ -128,6 +141,7 @@ func TestServer_InvitationLimit(t *testing.T) {
s.Config.RequireInvitation = true
limit := 1
s.Config.MaxInvitationsPerUser = &limit
s.Config.EnableNonPortableActorRegistration = true

bobCode := "70bc9fdf-74a4-41e5-973d-08ba3fd23d74"
carolCode := "ded3626c-ea4b-44cc-adf3-18510e7634e1"
Expand All @@ -145,7 +159,9 @@ func TestServer_InvitationLimit(t *testing.T) {
alice.Goto("/users/invitations/generate").
Error("40 Reached the maximum number of invitations")

s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode).Follow("😈 My profile").OK()
accept := s.HandleInput(bobKeypair, "/users/invitations/accept", bobCode)
accept.Error("11 base58-encoded Ed25519 private key or 'generate' to generate")
s.HandleInput(bobKeypair, accept.Path, "n").Follow("😈 My profile").OK()

alice.
Follow("⚙️ Settings").
Expand All @@ -156,7 +172,9 @@ func TestServer_InvitationLimit(t *testing.T) {
NotContains(Line{Type: Link, Text: "➕ Generate", URL: "/users/invitations/generate"}).
Contains(Line{Type: Text, Text: "Reached the maximum number of invitations."})

s.HandleInput(carolKeypair, "/users/invitations/accept", carolCode).Follow("😈 My profile").OK()
accept = s.HandleInput(carolKeypair, "/users/invitations/accept", carolCode)
accept.Error("11 base58-encoded Ed25519 private key or 'generate' to generate")
s.HandleInput(carolKeypair, accept.Path, "generate").Follow("😈 My profile").OK()

limit = 3
alice.
Expand Down Expand Up @@ -251,9 +269,9 @@ func TestServer_InvitationCreateAcceptDelete(t *testing.T) {
t.Fatalf("Not found")
}

s.
HandleInput(bobKeypair, "/users/invitations/accept", code).
OK()
accept := s.HandleInput(bobKeypair, "/users/invitations/accept", code)
accept.Error("11 base58-encoded Ed25519 private key or 'generate' to generate")
s.HandleInput(bobKeypair, accept.Path, "generate").OK()

page.
Follow("➖ Revoke").
Expand Down
13 changes: 0 additions & 13 deletions cluster/portability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ func TestCluster_ReplyForwardingPortableActors(t *testing.T) {
cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain")
defer cluster.Stop()

cluster["a.localdomain"].Config.EnablePortableActorRegistration = true
cluster["b.localdomain"].Config.EnablePortableActorRegistration = true
cluster["c.localdomain"].Config.EnablePortableActorRegistration = true

alice := cluster["a.localdomain"].RegisterPortable(aliceKeypair).OK()
bob := cluster["b.localdomain"].RegisterPortable(bobKeypair).OK()
carol := cluster["c.localdomain"].RegisterPortable(carolKeypair).OK()
Expand Down Expand Up @@ -103,10 +99,6 @@ func TestCluster_Gateways(t *testing.T) {
cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain")
defer cluster.Stop()

cluster["a.localdomain"].Config.EnablePortableActorRegistration = true
cluster["b.localdomain"].Config.EnablePortableActorRegistration = true
cluster["c.localdomain"].Config.EnablePortableActorRegistration = true

pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
Expand Down Expand Up @@ -195,11 +187,9 @@ func TestCluster_ForwardedLegacyReply(t *testing.T) {
cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain")
defer cluster.Stop()

cluster["a.localdomain"].Config.EnablePortableActorRegistration = true
cluster["b.localdomain"].Config.RFC9421Threshold = 1
cluster["b.localdomain"].Config.Ed25519Threshold = 1
cluster["b.localdomain"].Config.DisableIntegrityProofs = true
cluster["c.localdomain"].Config.EnablePortableActorRegistration = true

alice := cluster["a.localdomain"].RegisterPortable(aliceKeypair).OK()
bob := cluster["b.localdomain"].Register(bobKeypair).OK()
Expand Down Expand Up @@ -234,9 +224,6 @@ func TestCluster_ClientSideSigning(t *testing.T) {
cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain")
defer cluster.Stop()

cluster["a.localdomain"].Config.EnablePortableActorRegistration = true
cluster["c.localdomain"].Config.EnablePortableActorRegistration = true

pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
Expand Down
1 change: 1 addition & 0 deletions cluster/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ func NewServer(t *testing.T, domain string, client fed.Client) *Server {
cfg.FollowersSyncInterval = 0
cfg.Ed25519Threshold = 0.25
cfg.RFC9421Threshold = 0.5
cfg.EnableNonPortableActorRegistration = true

dbPath := filepath.Join(t.TempDir(), domain+".sqlite3")

Expand Down
2 changes: 1 addition & 1 deletion fed/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (l *Listener) newHandler() (*http.ServeMux, error) {
mux.HandleFunc("GET /user/{username}", l.handleUser)
mux.HandleFunc("GET /actor", l.handleActor)
mux.HandleFunc("GET /icon/{username}", l.handleIcon)
mux.HandleFunc("POST /inbox/{username}", l.handleInbox)
mux.HandleFunc("POST /inbox/{username}", l.handleInbox) // for compatibility with actors created by tootik<0.21.0
mux.HandleFunc("POST /inbox/nobody", l.handleSharedInbox)
mux.HandleFunc("POST /inbox", l.handleSharedInbox) // PieFed falls back https://$domain/inbox if it can't fetch instance actor
mux.HandleFunc("GET /outbox/{username}", l.handleOutbox)
Expand Down
31 changes: 6 additions & 25 deletions front/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,36 +93,22 @@ func (h *Handler) register(w text.Writer, r *Request, args ...string) {

switch r.URL.RawQuery {
case "":
if h.Config.EnablePortableActorRegistration {
w.Status(10, "Create portable user? (y/n)")
return
} else if _, _, err := user.Create(r.Context, h.Domain, h.DB, h.Config, 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")
w.Status(11, "base58-encoded Ed25519 private key or 'generate' to generate")
return

case "n":
if !h.Config.EnableNonPortableActorRegistration {
w.Status(40, "Registration of non-portable actors is disabled")
return
}

case "n":
if _, _, err := user.Create(r.Context, h.Domain, h.DB, h.Config, 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
}

case "y":
if h.Config.EnablePortableActorRegistration {
w.Status(11, "base58-encoded Ed25519 private key or 'generate' to generate")
} else {
w.Status(40, "Registration of portable actors is disabled")
}
return

case "generate":
if !h.Config.EnablePortableActorRegistration {
w.Status(40, "Registration of portable actors is disabled")
return
}

pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
r.Log.Warn("Failed to generate key", "error", err)
Expand All @@ -137,11 +123,6 @@ func (h *Handler) register(w text.Writer, r *Request, args ...string) {
}

default:
if !h.Config.EnablePortableActorRegistration {
w.Status(40, "Registration of portable actors is disabled")
return
}

key, err := data.DecodeEd25519PrivateKey(r.URL.RawQuery)
if err != nil {
r.Log.Warn("Failed to decode Ed25519 private key", "name", userName, "error", err)
Expand Down
15 changes: 2 additions & 13 deletions front/static/help.gmi
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,6 @@ The Common Name property of the client certificate ("identity") you use will det

You can add additional client certificates later, or revoke access for an old certificate.

There are two kinds of accounts:
* Traditional
* Portable
Settings → Data portability allows one to view the private key used to prove the ownership over an account and configure the list of gateways.

Traditional accounts are tied to this particular server, but provide good interoperability.

Portable accounts may suffer from interoperability issues but have several advantages:
* They're identified by an Ed25519 key pair and not tied to a particular server
* They allow replication of your data across multiple servers ("gateways"); this replication is not 100% reliable, though
* They allow migration away from this server even if it's offline

Settings → Data portability allows one to view the private key used to prove the ownership over a portable account and configure the list of gateways.

Only one portable account on this server can use a particular private key: if all client certificates associated with your account are lost or expired, you won't be able to register again with same key to restore access to your account.
Only one account on this server can use a particular private key: if all client certificates associated with your account are lost or expired, you won't be able to register again with same key to restore access to your account.
15 changes: 2 additions & 13 deletions front/static/users/help.gmi
Original file line number Diff line number Diff line change
Expand Up @@ -120,20 +120,9 @@ Polls must have between 2 and {{.Config.PollMaxOptions}} multi-choice options, a

The username of a newly created account is the Common Name property of the client certificate used.

There are two kinds of accounts:
* Traditional
* Portable
Settings → Data portability allows one to view the private key used to prove the ownership over an account and configure the list of gateways.

Traditional accounts are tied to this particular server, but provide good interoperability.

Portable accounts may suffer from interoperability issues but have several advantages:
* They're identified by an Ed25519 key pair and not tied to a particular server
* They allow replication of your data across multiple servers ("gateways"); this replication is not 100% reliable, though
* They allow migration away from this server even if it's offline

Settings → Data portability allows one to view the private key used to prove the ownership over a portable account and configure the list of gateways.

Only one portable account on this server can use a particular private key: if all client certificates associated with your account are lost or expired, you won't be able to register again with same key to restore access to your account.
Only one account on this server can use a particular private key: if all client certificates associated with your account are lost or expired, you won't be able to register again with same key to restore access to your account.

## Client Certificates ("Identities")

Expand Down
4 changes: 4 additions & 0 deletions front/user/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ func CreatePortable(
}

// Create creates a new user.
//
// Before v0.21.0, tootik offered users choice between 'traditional' and 'portable' accounts, and this function exists
// only because it's used by tests, to test backward compatibility with older tootik versions and interoperability with
// ActivityPub servers that don't support https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md.
func Create(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config, name string, actorType ap.ActorType, cert *x509.Certificate) (*ap.Actor, [2]httpsig.Key, error) {
rsaPriv, rsaPubPem, err := generateRSAKey()
if err != nil {
Expand Down
Loading
Loading