Skip to content
Draft
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
33 changes: 19 additions & 14 deletions FEDERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ tootik's UI treats `Group` actors differently: `/outbox/$group` hides replies an
tootik implements [draft-cavage-http-signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) but only partially:
* It ignores query
* It always uses `rsa-sha256` and puts `algorithm="rsa-sha256"` in outgoing requests
* It `algorithm` is specified in an incoming request, it must be `rsa-sha256` or `hs2019`
* If `algorithm` is specified in an incoming request, it must be `rsa-sha256` or `hs2019`
* It validates `Host`, `Date` (see `MaxRequestAge`) and `Digest`
* Validation ensures that key size is between 2048 and 8192
* Incoming `POST` requests must have at least `headers="(request-target) host date digest"`
Expand All @@ -40,20 +40,23 @@ tootik implements [draft-cavage-http-signatures](https://datatracker.ietf.org/do
* All other outgoing requests have `headers="(request-target) host date"`

In addition, tootik partially implements [RFC9421](https://datatracker.ietf.org/doc/rfc9421/):
* It supports `rsa-v1_5-sha256` and `ed25519` signatures
* It supports `rsa-v1_5-sha256`, `ed25519` and [`ml-dsa-44`](https://c2sp.org/httpsig-pq@v1.0.0) signatures
* If `alg` is specified, tootik validates the signature only if the key type matches `alg`
* It obeys `expires` if specified, but also validates `created` using `MaxRequestAge`
* Incoming `POST` requests must have at least `("@method" "@target-uri" "content-type" "content-digest")`
* All other incoming requests must have at least `("@method" "@target-uri")`
* If query is not empty, `@query` must be signed

tootik's actors have a traditional RSA key under `publicKey` and an Ed25519 key under `assertionMethod`, as described in [FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md).
tootik's actors have a traditional RSA key under `publicKey` and two keys under `assertionMethod` (see [FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md)): Ed25519 and ML-DSA-44.

By default, tootik uses `draft-cavage-http-signatures` when it signs outgoing requests. It starts using RFC9421 (with Ed25519, if possible) when talking to a particular server once these capabilities are 'discovered' in one of several ways:
By default, tootik uses `draft-cavage-http-signatures` when it signs outgoing requests. It starts using RFC9421 (with Ed25519 or ML-DSA-44, if possible) when talking to a particular server once these capabilities are 'discovered' in one of several ways:
* When at least one actor on the server advertises support for these capabilities using [FEP-844e](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md); tootik assumes this information is true although it's perfectly possible for a server to be behind a reverse proxy that drops the `Signature-Input` header
* It remembers which servers responded with `200 OK` or `202 Accepted` to a `POST` request signed with RFC9421, with or without Ed25519
* When it accepts a RFC9421-signed (with or without Ed25519) request from another server, it assumes this server also supports incoming requests signed like this
* It does **not** implement ['double-knocking'](https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions) to detect RFC9421 support, because it's uncommon and this mechanism is very likely to double the number of outgoing requests; instead, tootik randomly (see `RFC9421Threshold` and `Ed25519Threshold`) tries RFC9421 and Ed25519 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too
* It remembers which servers responded with `200 OK` or `202 Accepted` to a `POST` request signed with RFC9421, Ed25519 or ML-DSA-44
* When it accepts a RFC9421-signed (with or without Ed25519 or ML-DSA-44) request from another server, it assumes this server also supports incoming requests signed like this

tootik does **not** implement ['double-knocking'](https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions) to detect RFC9421 support, because it's uncommon and this mechanism is very likely to double the number of outgoing requests. Instead, it breaks the deadlock from both ends:
* It occasionally (see `RFC9421Threshold`, `Ed25519Threshold` and `MLDSA44Threshold`) signs outgoing `POST` requests with RFC9421, Ed25519 or ML-DSA-44, to prevent deadlock if another server is waiting instead of advertising or demonstrating support
* It occasionally (see `CavageDraftFailureThreshold`) rejects incoming, `draft-cavage-http-signatures`-signed `POST` requests with `401 Unauthorized`, to encourage other servers to retry with RFC9421

## Collections

Expand Down Expand Up @@ -171,9 +174,11 @@ Support for data portability comes into play in 5 main areas:

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.
All portable actors have both Ed25519 and ML-DSA-44 keys. By default, tootik generates both, but it allows the user to supply a base58-encoded Ed25519 or base64url-encoded ML-DSA-44 private key during registration. This key determines the DID, while the other key is generated. Like the user's `preferredUsername`, this key must be unique per tootik instance.

Note that use of ML-DSA-44 DIDs may hinder interoperability, as it produces `did:key:ukC...` DIDs (forbidden by [FEP-ef61](https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md) at the time of writing), [`mldsa44-jcs-2024`](https://www.w3.org/TR/vc-di-quantum-resistant-1.0/#cryptosuite-mldsa44-jcs-2024) integrity proofs and large objects other servers may reject.

No matter if the key was generated by tootik or provided by the user, the user can recover it through the settings page.
No matter what key was used to derive the DID, 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.

Expand Down Expand Up @@ -243,19 +248,19 @@ The response points to a `https://` gateway that returns the actor object:
}
```

Portable actors have both Ed25519 and RSA keys, allowing them to interact with actors on ActivityPub servers that don't support Ed25519 signatures.
Portable actors have, RSA, Ed25519 and ML-DSA-44 keys, allowing them to interact with actors on a wide range of ActivityPub servers.

In addition, portable actors carry an [FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) integrity proof, allowing other servers to securely determine which servers were "approved" by the owner of `ap://did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor`.

Moreover, all objects and activities owned by a portable actor contain an integrity proof, allowing other servers to validate their authenticity and processes them without having to fetch them from their origin first.

## Delivery

When tootik receives a `POST` request to `inbox` from a portable actor, it requires a valid [FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) integrity proof generated using the actor's Ed25519 key and ability to fetch the actor, if not cached.
When tootik receives a `POST` request to `inbox` from a portable actor, it requires a valid [FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) integrity proof generated using the private key that matches the DID, and ability to fetch the actor, if not cached.

tootik validates the integrity proof using the Ed25519 public key extracted from the key ID, and doesn't need to fetch the actor first.
tootik validates the integrity proof using the public key extracted from the key ID, and doesn't need to fetch the actor first.

tootik's `inbox` doesn't validate HTTP signatures and simply ignores them when the sender is a portable actor. Other servers might do the same, therefore automatic detection of RFC9421 and Ed25519 support on other servers ignores `200 OK` or `202 Accepted` responses from `/.well-known/apgateway`.
tootik's `inbox` doesn't validate HTTP signatures and simply ignores them when the sender is a portable actor. Other servers might do the same, therefore automatic detection of RFC9421 and Ed25519 or ML-DSA-44 support on other servers ignores `200 OK` or `202 Accepted` responses from `/.well-known/apgateway`.

tootik forwards posts by actors that share the same DID with a local actor, and replies in threads started by such actors.

Expand All @@ -276,4 +281,4 @@ When tootik forwards activities, it assumes that other servers use the same URL
* tootik does not support `ap://` identifiers and location hints.
* tootik assumes that activity and object IDs don't change: for example, it assumes that `Update` activities for portable posts preserve the `id` field of the original object. This matches the expectation of servers that don't support data portability and simplifies the implementation.
* tootik provides limited support for fetching of objects (like posts) and activities from `/.well-known/apgateway`: replication of data across all actors with the same canonical ID is primarily achieved using forwarding.
* The RSA key under `publicKey` is generated during registration, so different actors owned by the same DID will use different RSA keys when they talk to servers that don't support Ed25519 signatures. Therefore, servers that cache only one RSA key for two actors with the same canonical ID (which shouldn't exist) might fail to validate some signatures.
* The RSA key under `publicKey` is generated during registration, so different actors owned by the same DID will use different RSA keys when they talk to servers that don't support Ed25519 and ML-DSA-44 signatures. Therefore, servers that cache only one RSA key for two actors with the same canonical ID (which shouldn't exist) might fail to validate some signatures.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ This makes tootik lightweight, private and accessible:
* With support for manual approval of follow requests
* With support for [Mastodon's follower synchronization mechanism](https://docs.joinmastodon.org/spec/activitypub/#follower-synchronization-mechanism), aka [FEP-8fcf](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
* [FEP-ef61](https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md) portable accounts
* Accounts on different servers use one Ed25519 keypair
* Accounts on different servers use one Ed25519 or ML-DSA-44 keypair
* User activity is replicated across all servers
* Multi-choice polls
* [Lemmy](https://join-lemmy.org/)-style communities
Expand Down
5 changes: 4 additions & 1 deletion ap/capability.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2025 Dima Krasner
Copyright 2025, 2026 Dima Krasner

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -28,4 +28,7 @@ const (

// RFC9421Ed25519Signatures is support for RFC9421 HTTP signatures, with Ed25119 keys.
RFC9421Ed25519Signatures

// RFC9421MLDSA44Signatures is support for RFC9421 HTTP signatures, with ML-DSA-44 keys.
RFC9421MLDSA44Signatures
)
23 changes: 18 additions & 5 deletions ap/id.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2025 Dima Krasner
Copyright 2025, 2026 Dima Krasner

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -22,15 +22,28 @@ import (
"regexp"
)

const (
ed25519PubBase58 = `z6Mk[a-km-zA-HJ-NP-Z1-9]{44}`
ed25519PubBase64 = `u7Q[A-Za-z0-9_-]{44}`

mldsa44PubBase58 = `z4sd[a-km-zA-HJ-NP-Z1-9]{1000}[a-km-zA-HJ-NP-Z1-9]{792}`

// MLDSA44PubBase64 matches a base64url-encoded ML-DSA-44 public key.
MLDSA44PubBase64 = `ukC[A-Za-z0-9_-]{1000}[A-Za-z0-9_-]{750}`

// PortableActorPubPattern matches public keys in portable actor did:key DIDs.
PortableActorPubPattern = ed25519PubBase58 + `|` + MLDSA44PubBase64
)

var (
// KeyRegex matches a Multibase-encoded Ed25519 public key.
KeyRegex = regexp.MustCompile(`\b(z6Mk[a-km-zA-HJ-NP-Z1-9]+|u7Q[A-Za-z0-9_-]+)\b`)
// KeyRegex matches any Multibase-encoded public key.
KeyRegex = regexp.MustCompile(`\b(` + PortableActorPubPattern + `|` + ed25519PubBase64 + `|` + mldsa44PubBase58 + `)(?:[\/#?]|$)`)

// apURLRegex matches an ap:// URL.
apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+)((?:[\/#?].*){0,1})`)
apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(` + PortableActorPubPattern + `)([\/#?].*|$)`)

// GatewayURLRegex matches an https:// gateway URL.
GatewayURLRegex = regexp.MustCompile(`^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\/\.well-known\/apgateway\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+)((?:[\/#?].*){0,1})`)
GatewayURLRegex = regexp.MustCompile(`^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\/\.well-known\/apgateway\/did:key:(` + PortableActorPubPattern + `)([\/#?].*|$)`)
)

// IsPortable determines whether or not an ActivityPub ID is portable.
Expand Down
8 changes: 4 additions & 4 deletions ap/resolver.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2024 - 2025 Dima Krasner
Copyright 2024 - 2026 Dima Krasner

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -38,7 +38,7 @@ const (

// Resolver retrieves [Actor], [Object] and [Activity] objects.
type Resolver interface {
ResolveID(ctx context.Context, keys [2]httpsig.Key, id string, flags ResolverFlag) (*Actor, error)
Resolve(ctx context.Context, keys [2]httpsig.Key, host, name string, flags ResolverFlag) (*Actor, error)
Get(ctx context.Context, keys [2]httpsig.Key, url string) (*http.Response, error)
ResolveID(ctx context.Context, keys [3]httpsig.Key, id string, flags ResolverFlag) (*Actor, error)
Resolve(ctx context.Context, keys [3]httpsig.Key, host, name string, flags ResolverFlag) (*Actor, error)
Get(ctx context.Context, keys [3]httpsig.Key, url string) (*http.Response, error)
}
31 changes: 31 additions & 0 deletions ap/slug.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
Copyright 2026 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 ap

import (
"crypto/sha256"
"encoding/base64"
"strings"

"github.com/dimkr/tootik/danger"
)

// Slug shortens an ActivityPub ID.
func Slug(id string) string {
sum := sha256.Sum256(danger.Bytes(strings.TrimPrefix(id, "https://")))
return base64.RawURLEncoding.EncodeToString(sum[:12])
}
11 changes: 11 additions & 0 deletions cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,11 @@ type Config struct {

FillNodeInfoUsage bool

CavageDraftFailureThreshold float32

RFC9421Threshold float32
Ed25519Threshold float32
MLDSA44Threshold float32

DisableIntegrityProofs bool
MaxGateways int
Expand Down Expand Up @@ -446,6 +449,10 @@ func (c *Config) FillDefaults() {
c.HistoryTTL = time.Hour * 24 * 30
}

if c.CavageDraftFailureThreshold <= 0 || c.CavageDraftFailureThreshold > 1 {
c.CavageDraftFailureThreshold = 0.995
}

if c.RFC9421Threshold <= 0 || c.RFC9421Threshold > 1 {
c.RFC9421Threshold = 0.95
}
Expand All @@ -454,6 +461,10 @@ func (c *Config) FillDefaults() {
c.Ed25519Threshold = 0.98
}

if c.MLDSA44Threshold <= 0 || c.MLDSA44Threshold > 1 {
c.MLDSA44Threshold = 0.998
}

if c.MaxGateways <= 0 {
c.MaxGateways = 10
}
Expand Down
2 changes: 1 addition & 1 deletion cluster/mention_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ func TestMention_AmbiguousGroupAndFollowed(t *testing.T) {
alice.
Follow("📣 New post").
FollowInput("📣 Anyone", "!bob post").
Contains(gmi.Line{Type: gmi.Link, Text: "bob", URL: "/users/outbox/" + group.ID[8:]})
Contains(gmi.Line{Type: gmi.Link, Text: "bob", URL: "/users/outbox/" + ap.Slug(group.ID)})
}

func TestMention_AmbiguousGroupAndGroup(t *testing.T) {
Expand Down
91 changes: 90 additions & 1 deletion cluster/portability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"testing"
"time"

"github.com/cloudflare/circl/sign/mldsa/mldsa44"
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/data"
"github.com/dimkr/tootik/front/text/gmi"
Expand Down Expand Up @@ -134,7 +135,7 @@ func TestCluster_Gateways(t *testing.T) {

bob.
Follow("⚡️ Follows").
Contains(gmi.Line{Type: gmi.Link, Text: "🚴 alice (alice@a.localdomain)", URL: "/users/outbox/a.localdomain/.well-known/apgateway/" + did + "/actor"})
Contains(gmi.Line{Type: gmi.Link, Text: "🚴 alice (alice@a.localdomain)", URL: "/users/outbox/" + ap.Slug("https://a.localdomain/.well-known/apgateway/"+did+"/actor")})

post := alice.
Follow("📣 New post").
Expand Down Expand Up @@ -311,6 +312,94 @@ func TestCluster_ClientSideSigningInboxHappyFlow(t *testing.T) {
Contains(gmi.Line{Type: gmi.Quote, Text: "hi"})
}

func TestCluster_MLDSA44ClientSideSigningInboxHappyFlow(t *testing.T) {
cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain")
defer cluster.Stop()

pub, priv, err := mldsa44.GenerateKey(nil)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
registerPortable := "/users/register?" + data.EncodeMLDSA44PrivateKey(priv)

did := "did:key:" + data.EncodeMLDSA44Publickey(pub)

alice := cluster["a.localdomain"].Handle(aliceKeypair, registerPortable).OK()
bob := cluster["b.localdomain"].Register(bobKeypair).OK()
carol := cluster["c.localdomain"].Handle(carolKeypair, registerPortable).OK()

alice.
Follow("⚙️ Settings").
Follow("🚲 Data portability").
FollowInput("➕ Add", "c.localdomain").
OK()

carol.
Follow("⚙️ Settings").
Follow("🚲 Data portability").
FollowInput("➕ Add", "a.localdomain").
OK()

bob.
FollowInput("🔭 View profile", "alice@a.localdomain").
Follow("⚡ Follow alice").
OK()
cluster.Settle(t)

actorID := "https://a.localdomain/.well-known/apgateway/" + did + "/actor"

to := ap.Audience{}
to.Add(ap.Public)

create := &ap.Activity{
Type: ap.Create,
ID: actorID + "/create/1",
Actor: actorID,
To: to,
CC: to,
Published: ap.Time{Time: time.Now()},
Object: &ap.Object{
Type: ap.Note,
ID: actorID + "/note/1",
Content: "hi",
AttributedTo: actorID,
To: to,
CC: to,
},
}

create.Proof, err = proof.Create(httpsig.Key{ID: actorID + "#ml-dsa-44-key", PrivateKey: priv}, create)
if err != nil {
t.Fatalf("Failed to generate proof: %v", err)
}

j, err := json.Marshal(create)
if err != nil {
t.Fatalf("Failed to marshal activity: %v", err)
}

r, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://c.localdomain/inbox", bytes.NewReader(j))
if err != nil {
t.Fatalf("Failed to create HTTP request: %v", err)
}

var w responseWriter
cluster["c.localdomain"].Backend.ServeHTTP(&w, r)
if w.StatusCode != http.StatusAccepted {
t.Fatalf("Failed to process activity: %d", w.StatusCode)
}

bob.
FollowInput("🔭 View profile", "alice@a.localdomain").
NotContains(gmi.Line{Type: gmi.Quote, Text: "hi"})

cluster.Settle(t)

bob.
FollowInput("🔭 View profile", "alice@a.localdomain").
Contains(gmi.Line{Type: gmi.Quote, Text: "hi"})
}

func TestCluster_ClientSideSigningOutboxHappyFlow(t *testing.T) {
cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain")
defer cluster.Stop()
Expand Down
4 changes: 3 additions & 1 deletion cluster/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ type Server struct {
Config *cfg.Config
DB *sql.DB
Resolver *fed.Resolver
AppActorKeys [2]httpsig.Key
AppActorKeys [3]httpsig.Key
Frontend gemini.Listener
Cache *sync.Map
Backend http.Handler
Expand Down Expand Up @@ -149,6 +149,8 @@ func NewServer(t T, domain string, client fed.Client) *Server {
cfg.ResolverCacheTTL = 0
cfg.ResolverRetryInterval = 0
cfg.FollowersSyncInterval = 0
cfg.CavageDraftFailureThreshold = 1
cfg.MLDSA44Threshold = 0.25
cfg.Ed25519Threshold = 0.25
cfg.RFC9421Threshold = 0.5
cfg.EnableNonPortableActorRegistration = true
Expand Down
Loading
Loading