From c84f385ab9dcdca20406729143bb1e2395df4a68 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 08:19:27 +0300 Subject: [PATCH 01/41] x --- ap/capability.go | 5 +- ap/id.go | 10 ++-- ap/resolver.go | 8 +-- cfg/cfg.go | 5 ++ cluster/portability_test.go | 89 +++++++++++++++++++++++++++ cluster/server.go | 2 +- data/key.go | 91 ++++++++++++++++++++-------- data/key_test.go | 34 +++++++++-- fed/apgateway.go | 12 ++-- fed/deliver.go | 24 ++++---- fed/followers.go | 4 +- fed/inbox.go | 18 ++++-- fed/listener.go | 2 +- fed/resolve.go | 12 ++-- fed/send.go | 17 ++++-- fed/verify.go | 13 ++-- front/gemini/gemini.go | 28 +++++---- front/register.go | 20 +++++-- front/request.go | 4 +- front/shell.go | 10 +++- front/status.go | 17 +++--- front/user/app.go | 19 +++--- front/user/create.go | 110 +++++++++++++++++++++++++++------- go.mod | 1 + go.sum | 2 + httpsig/rfc9421.go | 9 ++- httpsig/rfc9421_test.go | 38 +++++++++++- httpsig/verify.go | 11 ++++ inbox/backfill.go | 4 +- inbox/queue.go | 2 +- migrations/046_proofs.go | 6 +- migrations/047_contexts.go | 6 +- migrations/049_pembegin.go | 2 +- migrations/053_ed25519blob.go | 2 +- migrations/076_mldsa44seed.go | 61 +++++++++++++++++++ outbox/mover.go | 2 +- proof/proof.go | 94 ++++++++++++++++++++++------- proof/proof_test.go | 35 ++++++++++- test/server.go | 2 +- 39 files changed, 653 insertions(+), 178 deletions(-) create mode 100644 migrations/076_mldsa44seed.go diff --git a/ap/capability.go b/ap/capability.go index 7f1bbb3a..a54f03c4 100644 --- a/ap/capability.go +++ b/ap/capability.go @@ -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. @@ -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 ) diff --git a/ap/id.go b/ap/id.go index ff4dd58b..e74e1037 100644 --- a/ap/id.go +++ b/ap/id.go @@ -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. @@ -23,14 +23,14 @@ import ( ) 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 a Multibase-encoded Ed25519 or ML-DSA-44 public key. + KeyRegex = regexp.MustCompile(`\b(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)\b`) // 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:(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) // 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:(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) ) // IsPortable determines whether or not an ActivityPub ID is portable. diff --git a/ap/resolver.go b/ap/resolver.go index a1e07395..098753b0 100644 --- a/ap/resolver.go +++ b/ap/resolver.go @@ -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. @@ -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) } diff --git a/cfg/cfg.go b/cfg/cfg.go index 7c09a3ca..312572d6 100644 --- a/cfg/cfg.go +++ b/cfg/cfg.go @@ -134,6 +134,7 @@ type Config struct { RFC9421Threshold float32 Ed25519Threshold float32 + MLDSA44Threshold float32 DisableIntegrityProofs bool MaxGateways int @@ -454,6 +455,10 @@ func (c *Config) FillDefaults() { c.Ed25519Threshold = 0.98 } + if c.MLDSA44Threshold <= 0 || c.MLDSA44Threshold > 1 { + c.MLDSA44Threshold = 0.01 + } + if c.MaxGateways <= 0 { c.MaxGateways = 10 } diff --git a/cluster/portability_test.go b/cluster/portability_test.go index b51e92a0..effc8bc0 100644 --- a/cluster/portability_test.go +++ b/cluster/portability_test.go @@ -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" @@ -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() diff --git a/cluster/server.go b/cluster/server.go index d6d65ccd..904ca9e2 100644 --- a/cluster/server.go +++ b/cluster/server.go @@ -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 diff --git a/data/key.go b/data/key.go index 97c83242..197f33c4 100644 --- a/data/key.go +++ b/data/key.go @@ -17,49 +17,78 @@ limitations under the License. package data import ( + "crypto" "crypto/ed25519" "encoding/base64" "errors" "fmt" "github.com/btcsuite/btcutil/base58" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" ) +type PrivateKey interface { + crypto.PrivateKey + + Public() crypto.PublicKey +} + // EncodeEd25519PrivateKey encodes an Ed25519 private key. func EncodeEd25519PrivateKey(key ed25519.PrivateKey) string { return "z" + base58.Encode(append([]byte{0x80, 0x26}, key.Seed()...)) } -// DecodeEd25519PrivateKey decodes an Ed25519 private key encoded by [EncodeEd25519PrivateKey]. -func DecodeEd25519PrivateKey(key string) (ed25519.PrivateKey, error) { +// EncodeEd25519PublicKey encodes an Ed25519 public key. +func EncodeEd25519PublicKey(key ed25519.PublicKey) string { + return "z" + base58.Encode(append([]byte{0xed, 0x01}, key...)) +} + +// EncodeMLDSA44PrivateKey encodes a ML-DSA-44 private key. +func EncodeMLDSA44PrivateKey(key *mldsa44.PrivateKey) string { + return "u" + base64.RawURLEncoding.EncodeToString(append([]byte{0x13, 0x1a}, key.Seed()...)) +} + +// EncodeMLDSA44Publickey encodes a ML-DSA-44 public key. +func EncodeMLDSA44Publickey(key *mldsa44.PublicKey) string { + return "u" + base64.RawURLEncoding.EncodeToString(append([]byte{0x90, 0x24}, key.Bytes()...)) +} + +// DecodePrivateKey decodes a public key encoded by [EncodeEd25519PrivateKey] or [EncodeMLDSA44PrivateKey]. +func DecodePrivateKey(key string) (PrivateKey, error) { if len(key) == 0 { - return nil, errors.New("empty key") + return nil, errors.New("key is empty") } - if key[0] != 'z' { - return nil, fmt.Errorf("invalid key prefix: %c", key[0]) - } + var rawKey []byte + switch key[0] { + case 'z': + rawKey = base58.Decode(key[1:]) - rawKey := base58.Decode(key[1:]) + case 'u': + var err error + rawKey, err = base64.RawURLEncoding.DecodeString(key[1:]) + if err != nil { + return nil, fmt.Errorf("failed to decode key: %w", err) + } - if len(rawKey) != ed25519.SeedSize+2 { - return nil, fmt.Errorf("invalid key length: %d", len(rawKey)) + default: + return nil, fmt.Errorf("invalid prefix: %c", key[0]) } - if rawKey[0] != 0x80 || rawKey[1] != 0x26 { + if len(rawKey) == 2+ed25519.SeedSize && rawKey[0] == 0x80 && rawKey[1] == 0x26 { + return ed25519.NewKeyFromSeed(rawKey[2:]), nil + } else if len(rawKey) == 2+mldsa44.SeedSize && rawKey[0] == 0x13 && rawKey[1] == 0x1a { + _, priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(rawKey[2:])) + return priv, nil + } else if len(rawKey) >= 2 { return nil, fmt.Errorf("invalid key prefix: %02x%02x", rawKey[0], rawKey[1]) + } else { + return nil, fmt.Errorf("invalid key length: %d", len(rawKey)) } - - return ed25519.NewKeyFromSeed(rawKey[2:]), nil -} - -// EncodeEd25519PublicKey encodes an Ed25519 public key. -func EncodeEd25519PublicKey(key ed25519.PublicKey) string { - return "z" + base58.Encode(append([]byte{0xed, 0x01}, key...)) } -// DecodeEd25519PublicKey decodes an Ed25519 public key encoded by [EncodeEd25519PublicKey]. -func DecodeEd25519PublicKey(key string) (ed25519.PublicKey, error) { +// DecodePublicKey decodes a public key encoded by [EncodeEd25519PublicKey] or [EncodeMLDSA44PublicKey]. +func DecodePublicKey(key string) (crypto.PublicKey, error) { if len(key) == 0 { return nil, errors.New("key is empty") } @@ -80,13 +109,23 @@ func DecodeEd25519PublicKey(key string) (ed25519.PublicKey, error) { return nil, fmt.Errorf("invalid prefix: %c", key[0]) } - if len(rawKey) != ed25519.PublicKeySize+2 { - return nil, fmt.Errorf("invalid key length: %d", len(rawKey)) - } + switch len(rawKey) { + case 2 + ed25519.PublicKeySize: + if rawKey[0] != 0xed || rawKey[1] != 0x01 { + return nil, fmt.Errorf("invalid prefix: %02x%02x", rawKey[0], rawKey[1]) + } - if rawKey[0] != 0xed || rawKey[1] != 0x01 { - return nil, fmt.Errorf("invalid prefix: %x%x", rawKey[0], rawKey[1]) - } + return ed25519.PublicKey(rawKey[2:]), nil - return ed25519.PublicKey(rawKey[2:]), nil + case 2 + mldsa44.PublicKeySize: + if rawKey[0] != 0x90 || rawKey[1] != 0x24 { + return nil, fmt.Errorf("invalid prefix: %02x%02x", rawKey[0], rawKey[1]) + } + + pub := &mldsa44.PublicKey{} + return pub, pub.UnmarshalBinary(rawKey[2:]) + + default: + return nil, fmt.Errorf("invalid key length: %d", len(rawKey)) + } } diff --git a/data/key_test.go b/data/key_test.go index 92c6cd3a..986b161a 100644 --- a/data/key_test.go +++ b/data/key_test.go @@ -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. @@ -18,22 +18,48 @@ package data import ( "bytes" + "crypto/ed25519" "testing" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" ) // https://codeberg.org/fediverse/fep/src/commit/480415584237eb19cb7373b6a25faa6fa6e3a200/fep/521b/fep-521b.md func Test_FEP521b(t *testing.T) { - a, err := DecodeEd25519PublicKey("u7QGwDY2Tjn93PVFWWq02piP1NE9_XRlg-c8-jhJiDqKBDw") + a, err := DecodePublicKey("u7QGwDY2Tjn93PVFWWq02piP1NE9_XRlg-c8-jhJiDqKBDw") if err != nil { t.Fatalf("Failed to decode base64-encoded key: %v", err) } - b, err := DecodeEd25519PublicKey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2") + b, err := DecodePublicKey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2") if err != nil { t.Fatalf("Failed to decode base58-encoded key: %v", err) } - if !bytes.Equal(a, b) { + if !bytes.Equal(a.(ed25519.PublicKey), b.(ed25519.PublicKey)) { + t.Fatal("Keys are different") + } +} + +func Test_MLDSA44(t *testing.T) { + pub, priv, err := mldsa44.GenerateKey(nil) + if err != nil { + t.Fatalf("Failed to generate: %v", err) + } + + decodedPriv, err := DecodePrivateKey(EncodeMLDSA44PrivateKey(priv)) + if err != nil { + t.Fatalf("Failed to decode private key: %v", err) + } + if !decodedPriv.(*mldsa44.PrivateKey).Equal(priv) { + t.Fatal("Private keys are different") + } + + decodedPub, err := DecodePublicKey(EncodeMLDSA44Publickey(pub)) + if err != nil { + t.Fatalf("Failed to decode public key: %v", err) + } + if !decodedPub.(*mldsa44.PublicKey).Equal(pub) { t.Fatal("Keys are different") } } diff --git a/fed/apgateway.go b/fed/apgateway.go index ce4213d4..1adb90cd 100644 --- a/fed/apgateway.go +++ b/fed/apgateway.go @@ -30,6 +30,7 @@ import ( "regexp" "strconv" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/data" @@ -43,8 +44,8 @@ var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key: func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Request, did string) { var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey []byte - if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519privkey from persons where cid = 'ap://' || ? || '/actor' and ed25519privkey is not null`, did).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey); errors.Is(err, sql.ErrNoRows) { + var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte + if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where cid = 'ap://' || ? || '/actor' and ed25519privkey is not null`, did).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Debug("Receiving user does not exist", "did", did) w.WriteHeader(http.StatusNotFound) return @@ -61,9 +62,12 @@ func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Reque return } - l.doHandleInbox(w, r, [2]httpsig.Key{ + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + + l.doHandleInbox(w, r, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }) } @@ -115,7 +119,7 @@ func (l *Listener) handleApGatewayOutboxPost(w http.ResponseWriter, r *http.Requ return } - publicKey, err := data.DecodeEd25519PublicKey(expectedPublicKey) + publicKey, err := data.DecodePublicKey(expectedPublicKey) if err != nil { slog.Warn("Failed to decode key to verify proof", "activity", activity.ID, "error", err) w.WriteHeader(http.StatusForbidden) diff --git a/fed/deliver.go b/fed/deliver.go index 31747682..6bf7f1c4 100644 --- a/fed/deliver.go +++ b/fed/deliver.go @@ -32,6 +32,7 @@ import ( "sync" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/danger" @@ -54,7 +55,7 @@ type deliveryJob struct { type deliveryTask struct { Job deliveryJob - Keys [2]httpsig.Key + Keys [3]httpsig.Key Request *http.Request Inbox string } @@ -91,11 +92,11 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { slog.Debug("Polling delivery queue") rows, err := dbx.QueryCollectCountIgnore[struct { - DeliveryAttempts int - Activity ap.Activity - RawActivity string - Actor ap.Actor - RsaPrivKeyDer, Ed25519PrivKey []byte + DeliveryAttempts int + Activity ap.Activity + RawActivity string + Actor ap.Actor + RsaPrivKeyDer, Ed25519PrivKey, MLDSA44Seed []byte }]( ctx, q.DB, @@ -104,7 +105,7 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { slog.Error("Failed to fetch post to deliver", "error", err) return true }, - `select outbox.attempts, json(outbox.activity) as x, json(outbox.activity) as y, json(persons.actor), persons.rsaprivkey, persons.ed25519privkey from + `select outbox.attempts, json(outbox.activity) as x, json(outbox.activity) as y, json(persons.actor), persons.rsaprivkey, persons.ed25519privkey, persons.mldsa44seed from outbox join persons on @@ -175,9 +176,12 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { continue } - keys := [2]httpsig.Key{ + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[32]byte)(row.MLDSA44Seed)) + + keys := [3]httpsig.Key{ {ID: row.Actor.PublicKey.ID, PrivateKey: rsaPrivKey}, {ID: row.Actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey)}, + {ID: row.Actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, } if _, err := q.DB.ExecContext( @@ -318,7 +322,7 @@ func (q *Queue) consume(ctx context.Context, requests <-chan *deliveryTask, even func (q *Queue) queueTask( ctx context.Context, job deliveryJob, - keys [2]httpsig.Key, + keys [3]httpsig.Key, inbox, contentLength string, followers *partialFollowers, tasks []chan *deliveryTask, @@ -361,7 +365,7 @@ func (q *Queue) queueTask( func (q *Queue) queueTasks( ctx context.Context, job deliveryJob, - keys [2]httpsig.Key, + keys [3]httpsig.Key, followers *partialFollowers, tasks []chan *deliveryTask, events chan<- deliveryEvent, diff --git a/fed/followers.go b/fed/followers.go index 1a8e103f..fd990998 100644 --- a/fed/followers.go +++ b/fed/followers.go @@ -46,7 +46,7 @@ type Syncer struct { Config *cfg.Config DB *sql.DB Resolver *Resolver - Keys [2]httpsig.Key + Keys [3]httpsig.Key Inbox ap.Inbox } @@ -241,7 +241,7 @@ func (l *Listener) saveFollowersDigest(ctx context.Context, sender *ap.Actor, he return nil } -func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Config, db *sql.DB, resolver *Resolver, keys [2]httpsig.Key) error { +func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Config, db *sql.DB, resolver *Resolver, keys [3]httpsig.Key) error { if digest, err := digestFollowers(ctx, db, d.Followed, domain); err != nil { return err } else if digest == d.Digest { diff --git a/fed/inbox.go b/fed/inbox.go index ddf7e15f..207c1b3e 100644 --- a/fed/inbox.go +++ b/fed/inbox.go @@ -30,6 +30,7 @@ import ( "net/http" "strings" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/data" @@ -68,7 +69,7 @@ func (l *Listener) getActivityOrigin(activity *ap.Activity, sender *ap.Actor) (s return activityOrigin, senderOrigin, senderHost, nil } -func (l *Listener) fetchObject(ctx context.Context, id string, keys [2]httpsig.Key) (bool, []byte, error) { +func (l *Listener) fetchObject(ctx context.Context, id string, keys [3]httpsig.Key) (bool, []byte, error) { resp, err := l.Resolver.Get(ctx, keys, id) if err != nil { if resp != nil && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone) { @@ -119,7 +120,7 @@ func (l *Listener) fetchObject(ctx context.Context, id string, keys [2]httpsig.K return true, nil, fmt.Errorf("key %s does not belong to %s", m[1], origin) } - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return true, nil, fmt.Errorf("failed to verify proof using %s: %w", withProof.Proof.VerificationMethod, err) } @@ -139,8 +140,8 @@ func (l *Listener) handleInbox(w http.ResponseWriter, r *http.Request) { receiver := r.PathValue("username") var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey []byte - if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519privkey from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, receiver).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey); errors.Is(err, sql.ErrNoRows) { + var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte + if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, receiver).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Debug("Receiving user does not exist", "receiver", receiver) w.WriteHeader(http.StatusNotFound) return @@ -157,13 +158,16 @@ func (l *Listener) handleInbox(w http.ResponseWriter, r *http.Request) { return } - l.doHandleInbox(w, r, [2]httpsig.Key{ + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + + l.doHandleInbox(w, r, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }) } -func (l *Listener) doHandleInbox(w http.ResponseWriter, r *http.Request, keys [2]httpsig.Key) { +func (l *Listener) doHandleInbox(w http.ResponseWriter, r *http.Request, keys [3]httpsig.Key) { if r.ContentLength > l.Config.MaxRequestBodySize { slog.Warn("Ignoring big request", "size", r.ContentLength) w.WriteHeader(http.StatusRequestEntityTooLarge) @@ -443,6 +447,8 @@ func (l *Listener) doHandleInbox(w http.ResponseWriter, r *http.Request, keys [2 capabilities = ap.RFC9421RSASignatures case "ed25519": capabilities = ap.RFC9421Ed25519Signatures + case "ml-dsa-44": + capabilities = ap.RFC9421MLDSA44Signatures } } diff --git a/fed/listener.go b/fed/listener.go index 94e29f69..23123333 100644 --- a/fed/listener.go +++ b/fed/listener.go @@ -45,7 +45,7 @@ type Listener struct { DB *sql.DB Resolver *Resolver AppActor *ap.Actor - AppActorKeys [2]httpsig.Key + AppActorKeys [3]httpsig.Key Addr string Cert string Key string diff --git a/fed/resolve.go b/fed/resolve.go index ddabbd4b..934a08eb 100644 --- a/fed/resolve.go +++ b/fed/resolve.go @@ -83,7 +83,7 @@ func NewResolver(blockedDomains *BlockList, domain string, cfg *cfg.Config, clie } // ResolveID retrieves an actor object by its ID. -func (r *Resolver) ResolveID(ctx context.Context, keys [2]httpsig.Key, id string, flags ap.ResolverFlag) (*ap.Actor, error) { +func (r *Resolver) ResolveID(ctx context.Context, keys [3]httpsig.Key, id string, flags ap.ResolverFlag) (*ap.Actor, error) { if id == "" { return nil, errors.New("empty ID") } @@ -109,7 +109,7 @@ func (r *Resolver) ResolveID(ctx context.Context, keys [2]httpsig.Key, id string } // Resolve retrieves an actor object by host and name. -func (r *Resolver) Resolve(ctx context.Context, keys [2]httpsig.Key, host, name string, flags ap.ResolverFlag) (*ap.Actor, error) { +func (r *Resolver) Resolve(ctx context.Context, keys [3]httpsig.Key, host, name string, flags ap.ResolverFlag) (*ap.Actor, error) { if actor, err := r.validate(func() (*ap.Actor, *ap.Actor, error) { return r.tryResolve(ctx, keys, host, name, flags) }); err != nil { return nil, err } else if actor.Suspended { @@ -202,7 +202,7 @@ func (r *Resolver) handleFetchFailure(ctx context.Context, fetched string, cache return nil, cachedActor, fmt.Errorf("failed to fetch %s: %w", fetched, err) } -func (r *Resolver) tryResolve(ctx context.Context, keys [2]httpsig.Key, host, name string, flags ap.ResolverFlag) (*ap.Actor, *ap.Actor, error) { +func (r *Resolver) tryResolve(ctx context.Context, keys [3]httpsig.Key, host, name string, flags ap.ResolverFlag) (*ap.Actor, *ap.Actor, error) { slog.Debug("Resolving actor", "host", host, "name", name) if r.BlockedDomains != nil && r.BlockedDomains.Contains(host) { @@ -351,7 +351,7 @@ func (r *Resolver) tryResolve(ctx context.Context, keys [2]httpsig.Key, host, na return nil, cachedActor, fmt.Errorf("no profile link in %s response", finger) } -func (r *Resolver) tryResolveID(ctx context.Context, keys [2]httpsig.Key, u *url.URL, id string, flags ap.ResolverFlag) (*ap.Actor, *ap.Actor, error) { +func (r *Resolver) tryResolveID(ctx context.Context, keys [3]httpsig.Key, u *url.URL, id string, flags ap.ResolverFlag) (*ap.Actor, *ap.Actor, error) { slog.Debug("Resolving actor", "id", id) if r.BlockedDomains != nil && r.BlockedDomains.Contains(u.Host) { @@ -444,7 +444,7 @@ func discoverCapabilities(implements []ap.Implement) ap.Capability { return capabilities } -func (r *Resolver) fetchActor(ctx context.Context, keys [2]httpsig.Key, host, profile string, cachedActor *ap.Actor, sinceLastUpdate time.Duration) (*ap.Actor, *ap.Actor, error) { +func (r *Resolver) fetchActor(ctx context.Context, keys [3]httpsig.Key, host, profile string, cachedActor *ap.Actor, sinceLastUpdate time.Duration) (*ap.Actor, *ap.Actor, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, profile, nil) if err != nil { return nil, cachedActor, fmt.Errorf("failed to send request to %s: %w", profile, err) @@ -545,7 +545,7 @@ func (r *Resolver) fetchActor(ctx context.Context, keys [2]httpsig.Key, host, pr } if m := ap.GatewayURLRegex.FindStringSubmatch(actor.ID); m != nil { - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return nil, cachedActor, fmt.Errorf("failed to parse key %s for %s to verify proof: %w", m[1], actor.ID, err) } diff --git a/fed/send.go b/fed/send.go index 23a41c8b..a133afdb 100644 --- a/fed/send.go +++ b/fed/send.go @@ -43,7 +43,7 @@ type sender struct { var userAgent = "tootik/" + buildinfo.Version -func (s *sender) send(keys [2]httpsig.Key, req *http.Request, body []byte) (*http.Response, error) { +func (s *sender) send(keys [3]httpsig.Key, req *http.Request, body []byte) (*http.Response, error) { urlString := req.URL.String() if req.URL.Scheme != "https" { @@ -67,7 +67,10 @@ func (s *sender) send(keys [2]httpsig.Key, req *http.Request, body []byte) (*htt return nil, fmt.Errorf("failed to query server capabilities for %s: %w", req.URL.Host, err) } - if capabilities&ap.RFC9421Ed25519Signatures == 0 && req.Method == http.MethodPost && rand.Float32() > s.Config.Ed25519Threshold { + if capabilities&ap.RFC9421MLDSA44Signatures == 0 && req.Method == http.MethodPost && rand.Float32() > s.Config.MLDSA44Threshold { + slog.Debug("Randomly enabling RFC9421 with ML-DSA-44", "server", req.URL.Host) + capabilities = ap.RFC9421MLDSA44Signatures + } else if capabilities&ap.RFC9421Ed25519Signatures == 0 && req.Method == http.MethodPost && rand.Float32() > s.Config.Ed25519Threshold { slog.Debug("Randomly enabling RFC9421 with Ed25519", "server", req.URL.Host) capabilities = ap.RFC9421Ed25519Signatures } else if capabilities&ap.RFC9421RSASignatures == 0 && req.Method == http.MethodPost && rand.Float32() > s.Config.RFC9421Threshold { @@ -75,7 +78,13 @@ func (s *sender) send(keys [2]httpsig.Key, req *http.Request, body []byte) (*htt capabilities = ap.RFC9421RSASignatures } - if capabilities&ap.RFC9421Ed25519Signatures > 0 { + if capabilities&ap.RFC9421MLDSA44Signatures > 0 { + slog.Debug("Signing request using RFC9421 with ML-DSA-44", "method", req.Method, "url", urlString, "key", keys[2].ID) + + if err := httpsig.SignRFC9421(req, body, keys[2], time.Now(), time.Time{}, httpsig.RFC9421DigestSHA256, "ml-dsa-44", nil); err != nil { + return nil, fmt.Errorf("failed to sign request for %s: %w", urlString, err) + } + } else if capabilities&ap.RFC9421Ed25519Signatures > 0 { slog.Debug("Signing request using RFC9421 with Ed25519", "method", req.Method, "url", urlString, "key", keys[1].ID) if err := httpsig.SignRFC9421(req, body, keys[1], time.Now(), time.Time{}, httpsig.RFC9421DigestSHA256, "ed25519", nil); err != nil { @@ -134,7 +143,7 @@ func (s *sender) send(keys [2]httpsig.Key, req *http.Request, body []byte) (*htt return resp, nil } -func (s *sender) Get(ctx context.Context, keys [2]httpsig.Key, url string) (*http.Response, error) { +func (s *sender) Get(ctx context.Context, keys [3]httpsig.Key, url string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to send request to %s: %w", url, err) diff --git a/fed/verify.go b/fed/verify.go index 3b4878fb..b09eaa3d 100644 --- a/fed/verify.go +++ b/fed/verify.go @@ -19,7 +19,6 @@ package fed import ( "context" "crypto" - "crypto/ed25519" "crypto/x509" "encoding/pem" "errors" @@ -37,7 +36,7 @@ import ( var errNoKeyInKeyID = errors.New("key origin does not contain a key") -func getKeyByID(actor *ap.Actor, keyID string) (ed25519.PublicKey, error) { +func getKeyByID(actor *ap.Actor, keyID string) (crypto.PublicKey, error) { for _, key := range actor.AssertionMethod { if key.ID != keyID { continue @@ -51,7 +50,7 @@ func getKeyByID(actor *ap.Actor, keyID string) (ed25519.PublicKey, error) { continue } - raw, err := data.DecodeEd25519PublicKey(key.PublicKeyMultibase) + raw, err := data.DecodePublicKey(key.PublicKeyMultibase) if err != nil { return nil, fmt.Errorf("failed to parse %s: %w", key.ID, err) } @@ -91,7 +90,7 @@ func (l *Listener) verifyEd25519RequestSignatureUsingKeyID(sig *httpsig.Signatur return "", errors.New("key origin is not portable") } - raw, err := data.DecodeEd25519PublicKey(m[1]) + raw, err := data.DecodePublicKey(m[1]) if err != nil { return "", fmt.Errorf("failed to parse %s: %w", sig.KeyID, err) } @@ -113,7 +112,7 @@ func (l *Listener) verifyRequestUsingKeyID(r *http.Request, body []byte) (*https return sig, key, err } -func (l *Listener) verifyRequest(r *http.Request, body []byte, flags ap.ResolverFlag, keys [2]httpsig.Key) (*httpsig.Signature, *ap.Actor, error) { +func (l *Listener) verifyRequest(r *http.Request, body []byte, flags ap.ResolverFlag, keys [3]httpsig.Key) (*httpsig.Signature, *ap.Actor, error) { sig, err := l.extractRequestSignature(r, body) if err != nil { return nil, nil, err @@ -168,14 +167,14 @@ func (l *Listener) verifyRequest(r *http.Request, body []byte, flags ap.Resolver return sig, actor, nil } -func (l *Listener) verifyProof(ctx context.Context, activity *ap.Activity, raw []byte, flags ap.ResolverFlag, keys [2]httpsig.Key) (*ap.Actor, error) { +func (l *Listener) verifyProof(ctx context.Context, activity *ap.Activity, raw []byte, flags ap.ResolverFlag, keys [3]httpsig.Key) (*ap.Actor, error) { if m := ap.KeyRegex.FindStringSubmatch(activity.Proof.VerificationMethod); m != nil { if m2 := ap.GatewayURLRegex.FindStringSubmatch(activity.Actor); m2 != nil { if m2[1] != m[1] { return nil, fmt.Errorf("key %s does not belong to %s", m[1], activity.Actor) } - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return nil, fmt.Errorf("failed to decode key %s to verify proof: %w", activity.Proof.VerificationMethod, err) } diff --git a/front/gemini/gemini.go b/front/gemini/gemini.go index 53bdacc7..dd2da183 100644 --- a/front/gemini/gemini.go +++ b/front/gemini/gemini.go @@ -40,6 +40,7 @@ import ( "sync" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/danger" @@ -60,52 +61,55 @@ type Listener struct { KeyPath string } -func (gl *Listener) getUser(ctx context.Context, tlsConn *tls.Conn, cfg *cfg.Config) (*ap.Actor, [2]httpsig.Key, error) { +func (gl *Listener) getUser(ctx context.Context, tlsConn *tls.Conn, cfg *cfg.Config) (*ap.Actor, [3]httpsig.Key, error) { state := tlsConn.ConnectionState() if len(state.PeerCertificates) == 0 { - return nil, [2]httpsig.Key{}, nil + return nil, [3]httpsig.Key{}, nil } clientCert := state.PeerCertificates[0] if time.Now().After(clientCert.NotAfter) { - return nil, [2]httpsig.Key{}, nil + return nil, [3]httpsig.Key{}, nil } certHash := fmt.Sprintf("%X", sha256.Sum256(clientCert.Raw)) - var rsaPrivKeyDer, ed25519PrivKey []byte + var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte var actor ap.Actor var approved int - if err := gl.DB.QueryRowContext(ctx, `select json(persons.actor), persons.rsaprivkey, persons.ed25519privkey, 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(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &approved); err != nil && errors.Is(err, sql.ErrNoRows) { + if err := gl.DB.QueryRowContext(ctx, `select json(persons.actor), persons.rsaprivkey, persons.ed25519privkey, persons.mldsa44seed, 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(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &mldsa44Seed, &approved); err != nil && errors.Is(err, sql.ErrNoRows) { if cfg.RequireInvitation { var accepted int if err := gl.DB.QueryRowContext(ctx, `select exists (select 1 from invites where certhash = ?)`, certHash).Scan(&accepted); err != nil { - return nil, [2]httpsig.Key{}, err + return nil, [3]httpsig.Key{}, err } else if accepted == 0 { - return nil, [2]httpsig.Key{}, front.ErrNotInvited + return nil, [3]httpsig.Key{}, front.ErrNotInvited } } - return nil, [2]httpsig.Key{}, front.ErrNotRegistered + return nil, [3]httpsig.Key{}, front.ErrNotRegistered } else if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, err) } if approved == 0 { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, front.ErrNotApproved) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, front.ErrNotApproved) } rsaPrivKey, err := x509.ParsePKCS1PrivateKey(rsaPrivKeyDer) if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to parse RSA private key for %s: %w", certHash, err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to parse RSA private key for %s: %w", certHash, err) } + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[32]byte)(mldsa44Seed)) + slog.Debug("Found existing user", "hash", certHash, "user", actor.ID) - return &actor, [2]httpsig.Key{ + return &actor, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, nil } diff --git a/front/register.go b/front/register.go index 93b40c4f..9b5ebf5a 100644 --- a/front/register.go +++ b/front/register.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 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. @@ -20,8 +20,10 @@ import ( "crypto/ed25519" "crypto/tls" "database/sql" + "reflect" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/front/text" @@ -116,16 +118,24 @@ func (h *Handler) register(w text.Writer, r *Request, args ...string) { } default: - key, err := data.DecodeEd25519PrivateKey(r.URL.RawQuery) + key, err := data.DecodePrivateKey(r.URL.RawQuery) if err != nil { r.Log.Warn("Failed to decode Ed25519 private key", "name", userName, "error", err) w.Statusf(40, "Invalid key: %s", err.Error()) return } - if _, _, err := user.CreatePortableWithKey(r.Context, h.Domain, h.DB, h.Config, userName, ap.Person, clientCert, key, key.Public().(ed25519.PublicKey)); err != nil { - r.Log.Warn("Failed to create new portable user", "name", userName, "error", err) - w.Status(40, "Failed to create new user") + switch v := key.(type) { + case ed25519.PrivateKey, *mldsa44.PrivateKey: + if _, _, err := user.CreatePortableWithKey(r.Context, h.Domain, h.DB, h.Config, userName, ap.Person, clientCert, v); err != nil { + r.Log.Warn("Failed to create new portable user", "name", userName, "error", err) + w.Status(40, "Failed to create new user") + return + } + + default: + r.Log.Warn("Key type is unsupported", "name", userName, "type", reflect.TypeOf(key).String()) + w.Status(40, "Invalid key type") return } } diff --git a/front/request.go b/front/request.go index c2fed2ae..5d7f1f5a 100644 --- a/front/request.go +++ b/front/request.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 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. @@ -44,5 +44,5 @@ type Request struct { User *ap.Actor // Keys optionally specifies the signing keys associated with User. - Keys [2]httpsig.Key + Keys [3]httpsig.Key } diff --git a/front/shell.go b/front/shell.go index f8c4cd3e..b9d38fcf 100644 --- a/front/shell.go +++ b/front/shell.go @@ -25,6 +25,7 @@ import ( "log/slog" "net/url" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text/gmi" "github.com/dimkr/tootik/httpsig" @@ -39,10 +40,10 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { } var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey []byte + var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte if err := h.DB.QueryRowContext( ctx, - `select json(actor), rsaprivkey, ed25519privkey from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, + `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, user, ).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey); err != nil { panic(err) @@ -53,6 +54,8 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { panic(err) } + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[32]byte)(mldsa44Seed)) + var buf bytes.Buffer return shell.Run(ctx, domain, u, func(ctx context.Context, u *url.URL) (*url.URL, string, error) { @@ -65,9 +68,10 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { URL: u, Log: slog.Default(), User: &actor, - Keys: [2]httpsig.Key{ + Keys: [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, }, w, diff --git a/front/status.go b/front/status.go index 8498aa3c..cfc2ec58 100644 --- a/front/status.go +++ b/front/status.go @@ -95,25 +95,28 @@ func (h *Handler) getActiveUsersGraph(r *Request) string { } func (h *Handler) getInstanceCapabilitiesGraph(r *Request) string { - keys := make([]string, 6) - values := make([]int64, 6) + keys := make([]string, 7) + values := make([]int64, 7) return h.getGraph( r, keys, values, ` - select 'RFC9421 with Ed25519', (select count(*) from servers where capabilities & $1 > 0) + select 'RFC9421 with ML-DSA-44', (select count(*) from servers where capabilities & $1 > 0) + union all + select 'RFC9421 with Ed25519', (select count(*) from servers where capabilities & $2 > 0) union all - select 'RFC9421 with RSA but without Ed25519', (select count(*) from servers where capabilities & ($1 | $2) = $2) + select 'RFC9421 with RSA but without Ed25519 or ML-DSA-44', (select count(*) from servers where capabilities & ($1 | $2 | $3) = $3) union all - select 'RFC9421 without draft-cavage-http-signatures', (select count(*) from servers where capabilities & $3 = 0 and capabilities & ($1 | $2) > 0) + select 'RFC9421 without draft-cavage-http-signatures', (select count(*) from servers where capabilities & $4 = 0 and capabilities & ($1 | $2 | $3) > 0) union all - select 'draft-cavage-http-signatures without RFC9421', (select count(*) from servers where capabilities & $3 > 0 and capabilities & ($1 | $2) = 0) + select 'draft-cavage-http-signatures without RFC9421', (select count(*) from servers where capabilities & $4 > 0 and capabilities & ($1 | $2 | $3) = 0) union all - select 'draft-cavage-http-signatures', (select count(*) from servers where capabilities & $3 > 0) + select 'draft-cavage-http-signatures', (select count(*) from servers where capabilities & $4 > 0) union all select 'Total', (select count(*) from servers) `, + ap.RFC9421MLDSA44Signatures, ap.RFC9421Ed25519Signatures, ap.RFC9421RSASignatures, ap.CavageDraftSignatures, diff --git a/front/user/app.go b/front/user/app.go index 2d26ce60..6070a70a 100644 --- a/front/user/app.go +++ b/front/user/app.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 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. @@ -24,6 +24,7 @@ import ( "errors" "fmt" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/httpsig" @@ -31,30 +32,34 @@ import ( // CreateApplicationActor creates the special "actor" user. // This user is used to sign outgoing requests not initiated by a particular user. -func CreateApplicationActor(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config) (*ap.Actor, [2]httpsig.Key, error) { +func CreateApplicationActor(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config) (*ap.Actor, [3]httpsig.Key, error) { var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey []byte + var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte if err := db.QueryRowContext( ctx, - `select json(actor), rsaprivkey, ed25519privkey from persons where actor->>'$.preferredUsername' = 'actor' and host = ?`, + `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where actor->>'$.preferredUsername' = 'actor' and host = ?`, domain, ).Scan( &actor, &rsaPrivKeyDer, &ed25519PrivKey, + &mldsa44Seed, ); errors.Is(err, sql.ErrNoRows) { return CreatePortable(ctx, domain, db, cfg, "actor", ap.Application, nil) } else if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to fetch application actor: %w", err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to fetch application actor: %w", err) } rsaPrivKey, err := x509.ParsePKCS1PrivateKey(rsaPrivKeyDer) if err != nil { - return nil, [2]httpsig.Key{}, err + return nil, [3]httpsig.Key{}, err } - return &actor, [2]httpsig.Key{ + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[32]byte)(mldsa44Seed)) + + return &actor, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, err } diff --git a/front/user/create.go b/front/user/create.go index 36c0cd32..3aca138e 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -28,6 +28,7 @@ import ( "fmt" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/data" @@ -66,7 +67,8 @@ func insertActor( actor *ap.Actor, rsaPriv *rsa.PrivateKey, ed25519Priv ed25519.PrivateKey, - keys [2]httpsig.Key, + mldsa44Priv *mldsa44.PrivateKey, + keys [3]httpsig.Key, cert *x509.Certificate, db *sql.DB, cfg *cfg.Config, @@ -86,11 +88,12 @@ func insertActor( if _, err := tx.ExecContext( ctx, - `INSERT OR IGNORE INTO persons (id, actor, rsaprivkey, ed25519privkey) VALUES (?, JSONB(?), ?, ?)`, + `INSERT OR IGNORE INTO persons (id, actor, rsaprivkey, ed25519privkey, mldsa44seed) VALUES (?, JSONB(?), ?, ?, ?)`, actor.ID, actor, x509.MarshalPKCS1PrivateKey(rsaPriv), ed25519Priv.Seed(), + mldsa44Priv.Seed(), ); err != nil { return err } @@ -136,6 +139,15 @@ func insertActor( return err } + if _, err := tx.ExecContext( + ctx, + `INSERT OR IGNORE INTO keys (id, actor) VALUES (?, ?)`, + actor.AssertionMethod[1].ID, + actor.ID, + ); err != nil { + return err + } + return tx.Commit() } @@ -148,10 +160,10 @@ func CreatePortable( name string, actorType ap.ActorType, cert *x509.Certificate, -) (*ap.Actor, [2]httpsig.Key, error) { - pub, priv, err := ed25519.GenerateKey(nil) +) (*ap.Actor, [3]httpsig.Key, error) { + _, priv, err := ed25519.GenerateKey(nil) if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key for %s: %w", name, err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key for %s: %w", name, err) } return CreatePortableWithKey( @@ -163,7 +175,6 @@ func CreatePortable( actorType, cert, priv, - pub, ) } @@ -176,17 +187,55 @@ func CreatePortableWithKey( name string, actorType ap.ActorType, cert *x509.Certificate, - ed25519Priv ed25519.PrivateKey, - ed25519Pub ed25519.PublicKey, -) (*ap.Actor, [2]httpsig.Key, error) { + priv data.PrivateKey, +) (*ap.Actor, [3]httpsig.Key, error) { rsaPriv, rsaPubPem, err := generateRSAKey() if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to generate RSA key pair: %w", err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate RSA key pair: %w", err) } - ed25519PubMultibase := data.EncodeEd25519PublicKey(ed25519Pub) + var ( + ed25519Priv ed25519.PrivateKey + ed25519Pub ed25519.PublicKey + + mldsa44Priv *mldsa44.PrivateKey + mldsa44Pub *mldsa44.PublicKey + + ed25519PubMultibase, mldsa44PubMultibase, id string + ) + + switch v := priv.(type) { + case ed25519.PrivateKey: + mldsa44Pub, mldsa44Priv, err = mldsa44.GenerateKey(nil) + if err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate ML-DSA-44 key pair: %w", err) + } + + ed25519Priv = v + ed25519Pub = v.Public().(ed25519.PublicKey) + + ed25519PubMultibase = data.EncodeEd25519PublicKey(ed25519Pub) + + id = fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, ed25519PubMultibase) + + mldsa44PubMultibase = data.EncodeMLDSA44Publickey(mldsa44Pub) + + case *mldsa44.PrivateKey: + ed25519Pub, ed25519Priv, err = ed25519.GenerateKey(nil) + if err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key pair: %w", err) + } + + mldsa44Priv = v + mldsa44Pub = v.Public().(*mldsa44.PublicKey) + + mldsa44PubMultibase = data.EncodeMLDSA44Publickey(mldsa44Pub) + + id = fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, mldsa44PubMultibase) + + ed25519PubMultibase = data.EncodeEd25519PublicKey(ed25519Pub) + } - id := fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, ed25519PubMultibase) actor := ap.Actor{ Context: []string{ "https://www.w3.org/ns/activitystreams", @@ -219,6 +268,12 @@ func CreatePortableWithKey( Controller: id, PublicKeyMultibase: ed25519PubMultibase, }, + { + ID: id + "#ml-dsa-44-key", + Type: "Multikey", + Controller: id, + PublicKeyMultibase: mldsa44PubMultibase, + }, }, } @@ -235,13 +290,14 @@ func CreatePortableWithKey( } } - keys := [2]httpsig.Key{ + keys := [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPriv}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519Priv}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, } - if err := insertActor(ctx, &actor, rsaPriv, ed25519Priv, keys, cert, db, cfg); err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) + if err := insertActor(ctx, &actor, rsaPriv, ed25519Priv, mldsa44Priv, keys, cert, db, cfg); err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) } return &actor, keys, nil @@ -252,15 +308,20 @@ func CreatePortableWithKey( // 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, cert *x509.Certificate) (*ap.Actor, [2]httpsig.Key, error) { +func Create(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config, name string, cert *x509.Certificate) (*ap.Actor, [3]httpsig.Key, error) { rsaPriv, rsaPubPem, err := generateRSAKey() if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to generate RSA key pair: %w", err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate RSA key pair: %w", err) } ed25519Pub, ed25519Priv, err := ed25519.GenerateKey(nil) if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key pair: %w", err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key pair: %w", err) + } + + mldsa44Pub, mldsa44Priv, err := mldsa44.GenerateKey(nil) + if err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate ML-DSA-44 key pair: %w", err) } id := fmt.Sprintf("https://%s/user/%s", domain, name) @@ -298,18 +359,25 @@ func Create(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config, nam Controller: id, PublicKeyMultibase: data.EncodeEd25519PublicKey(ed25519Pub), }, + { + ID: fmt.Sprintf("https://%s/user/%s#ml-dsa-44-key", domain, name), + Type: "Multikey", + Controller: id, + PublicKeyMultibase: data.EncodeMLDSA44Publickey(mldsa44Pub), + }, }, ManuallyApprovesFollowers: false, Published: ap.Time{Time: time.Now()}, } - keys := [2]httpsig.Key{ + keys := [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPriv}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519Priv}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, } - if err := insertActor(ctx, &actor, rsaPriv, ed25519Priv, keys, cert, db, cfg); err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) + if err := insertActor(ctx, &actor, rsaPriv, ed25519Priv, mldsa44Priv, keys, cert, db, cfg); err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) } return &actor, keys, nil diff --git a/go.mod b/go.mod index bc321d03..b10abb59 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.5 require ( github.com/btcsuite/btcutil v1.0.2 + github.com/cloudflare/circl v1.6.5 github.com/creack/pty v1.1.24 github.com/dimkr/slopline v0.0.0-20260327144222-f21b275f569f github.com/fsnotify/fsnotify v1.10.1 diff --git a/go.sum b/go.sum index 085c2cf3..097df3f8 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= +github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= diff --git a/httpsig/rfc9421.go b/httpsig/rfc9421.go index 64955800..ef35618c 100644 --- a/httpsig/rfc9421.go +++ b/httpsig/rfc9421.go @@ -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. @@ -33,6 +33,7 @@ import ( "strings" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/data" ) @@ -211,6 +212,10 @@ func SignRFC9421( case ed25519.PrivateKey: sig = ed25519.Sign(v, danger.Bytes(s)) + case *mldsa44.PrivateKey: + sig = make([]byte, mldsa44.SignatureSize) + err = mldsa44.SignTo(v, danger.Bytes(s), nil, true, sig) + default: return errors.New("invalid private key") } @@ -345,7 +350,7 @@ func rfc9421Extract( return nil, errors.New("invalid signature input: " + input) } - if alg != "" && alg != "rsa-v1_5-sha256" && alg != "ed25519" { + if alg != "" && alg != "rsa-v1_5-sha256" && alg != "ed25519" && alg != "ml-dsa-44" { return nil, errors.New("unsupported alg: " + alg) } diff --git a/httpsig/rfc9421_test.go b/httpsig/rfc9421_test.go index d859cf48..c44bf699 100644 --- a/httpsig/rfc9421_test.go +++ b/httpsig/rfc9421_test.go @@ -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. @@ -19,12 +19,15 @@ package httpsig import ( "crypto/rsa" "crypto/x509" + "encoding/base64" "encoding/pem" "math/big" "net/http" "strings" "testing" "time" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" ) // B.1.4. Example Ed25519 Test Key @@ -710,3 +713,36 @@ func TestRFC9421_VerifySignatureAge(t *testing.T) { }) } } + +// https://github.com/C2SP/C2SP/blob/3bc97b2329fee167f7ff39efbbbc316c84876105/httpsig-pq.md?plain=1#L253 +func TestRFC9421_MLDSA44(t *testing.T) { + t.Parallel() + + rawPub, err := base64.StdEncoding.DecodeString("guWbbXQe/Y1a83/qIeU8MjIrRseICPuOyWzoGXk27Fia2lpk2uPxbEfh/iEmvY1z/LTtxSBFZS8zbgV04qa038ZokwE+QoBcISX0H4F9hEY3Oxg8SRtzrek0E+mBOc6R4ilBje5vodImgSMTMSXKuDz2PWqvrLY20AfLmMkLXZqVERYcW+S7iKyTP53cvtenYwealt3MGcZxLKRAMnCGBY3pRfj1xn6czY6OuMYNuJaW1rAeL2BeVpNA+XBA+HUHS1yTYOGw93ddFe9lw0RDex7Pw+tht/n31gggrR4D0kmE8L88Q5c2qWuQtxk9+cmHTm3u1WuG/vCRiubTA3DmY0pK1fO1etarscNRnZ06q5UfTgSQOMqR/PLXtqT9cZiN8GWQvum15uVuxXCubB4r7T3nz2ijyBPQfo+Ywd10QxI8dpGpsBd9SEslLo+esyFDt7+9t4c702PNC0121oI18PriNlKxjxshQljNnrXSZ4eCvhHrY5Le8B/mTwsb/LYPbBvktCecvkRxboTjhyq2anblDt5Hp55gn+0YRWUz7myuDjBD/+a2riHzOXCoLOtGvsfVTLAwLPYj/AIBbdVFrGq9MKldtcgiqTWhG6JWD/dxFrfUT4YIf2yWO9AbhkgkgPynKmV82sLda4L3JaABwSXVAB1vzDmxfQ17orBFxX2pK23G5EmrG8Ix/1O9NJ35LSxkZhUmNs+4zm9GRaChPbw04q//Gc9CPiE3xvv/BrRms5mIiRl7zT8o2mgNs/APM45mJrKClmYRQllKVwnKBj+njZkDv1CG2WeBv01COmic4P4EEraSmKO8I5sJ98gpTiA9jeBuvloLYdAInPyZui01GyQO4P3OETyAVqggBGIvajxWuio7eL+4XZmkX3AMb5XrfWtlfYCKUzhodcv7NM6M4LizEh0KZ8qSHsnVZxqwb5J9hb4oiR5/N7sgxjjtiBsbg/vJ1UmBZZR6hPjnLbLDTZ6PUMjeQNdoYGL0Cv4ESloaEtc4vhW/Aa2roIyoAM/kimrHYztNknxZK3nLQ9PXVp9Np1okp4VPUkOfHvy6Bk2WPQd8vdwrfE3xOPGrkhzdmlcsVEp3rIAW4s4Hem2A99CFSkbPePEEPG4uB3sVl73X+sr8+X7jFkiiy686eV73zQTrqL4OnrYJIkaZr6zLXHccvm1txmftmtS3WH3Ny0hGVjwxnu1femv2++2pZpZr67//WBksxPOG4xk6AA7xrHEAjRFA0b0g6AC3+WuZmaNCNMd8YdibEt/WHKg7uGWYetMf45cWxPxZmNl+jddlf1btTAfYZ+eXla3Tx/pg5fU2EWTXyTOVIRdRwAOpZHG7jFYCxRE2SWFFM2lwjLxzso69uABhM1zraUxo6TfOalw1x7S1NkTowesaJPlTMmr43N5hdbRyDD6qmcCSNNjDICvK/fv3ijSY0lueglt1Jf7O1fi9/s0CNGf8E/pQohdb/pzGhSlJRo73hC/wTmLEjFukAYNdNeJO8Fxge7j6rLHIOu22q8lK0DSsWqXXeFYt5mKmdTOFHN6soJQ7Uk43HgwoMreCn6xJQhl87CZu4SUzr5XztrsOV/YF5u5IYY8cDneoZW+0ldE+/8bvI8gi352YmfL9ZY5TacDexbl0S+hGbR7IPHh6av0N+odj2uBNt4Cjb3Mt3aYwQi2Yh9vD+CaGcz5AO67GAf7pIpS8naBrzQeNjdOYLxu6VcXT4zLO9Gu1Uv+RvkLPaalcAQ==") + if err != nil { + t.Fatalf("Failed to decode public key: %v", err) + } + + var pub mldsa44.PublicKey + if err := pub.UnmarshalBinary(rawPub[:]); err != nil { + t.Fatalf("Failed to parse public key: %v", err) + } + + r, err := http.NewRequest(http.MethodGet, "https://example.com/foo?param=Value&Pet=dog", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + r.Header.Set("Host", "example.com") + r.Header.Set("Date", "Mon, 06 Jul 2026 20:00:00 GMT") + r.Header.Set("Signature", "sig1=:apZ5/ADQOgYFPWs2iqmiwKjWK7MyWOQj0ItgYx+14iDa5XNdcB/nHICBEONDRISfvvFIDf7u4UjEKVZRUxLxF7BK1932ydZzQZlU4lv0UwB2zPmDCSHV+dqF/vqP5AdlGN3VX8if4P3Z34S0kYMA3ECKEKCT4kcdL4zA4TSzomhHF0S/qcfam/Mz1Ss6W0CyzLPMvJdPJ4rLJkIWlBKB7aWzFfKrI6zZx1asvgPht0RjCc/IIMNuCXmPPusyAmi3NBFfrn/eQIkjOxBujePKXFx6k2FEdfYJRugcHvLOEhgu8kBLZzULW4t9qytaTw1ItKoXOwksdt8yQbTKzsTWjBrY43scbWp6hYpo6Mom6QcQxy8Qc4sMe/D/V9OZh/yvQ0Z7F+d9om3XgTmrTO23Gs715KC5AFH5EX15orv2xeGUg6HI17pp5seCpALemy5yev2CNBDBQISxeeA3WQH6xvt2p00CN7ZSjrSk3fgu4Siu/gbb2UYqhNICXqa2JqJL9/hQQOA2olg1fGyIkJC9MK0SxgfPGD05WkGTN3/cNnapT+sa1tRoDz1jEj3joalN3HH3GOtltOXtzfxvbraVBIRaGWYaDiOQxJHvzuMQy4ioYTGXhlMO5/mgkX2GUdAoBQ+agRlJyn1M3SMMQ1x70/Gpz3ykO0mhyjnwfUhL8hzpt0fFFuSctT0QpSvbqdS4rTQR0Nls2enkvam09txQugXi1y/F4v73OenS/X3GqfWgtVOn5Ww+AL4dCrs+8VFhKzLqiebkez64HxAPMD3STWuezcdq71vv08XMc7CX4d/Hi/TwU25OGrubccM8Ueefh1Glr7cPA9qly7Ev1MsmtlidNxbv/0ryD9WiYIssJsqpfoFP8ag5W3HCjKeHzGX/BhCrOPF9+Q3uSRefxp13dR/Hmkk4kbFePwYZp6eG+q2OJA0B/NAec2JI/Ikp3c2+R+6K2+caWSIRMYwUdq2HdTwgnw6e0IeMj0tAT940rVW+uIp2j2p+taNFZQ8QvFaYevAfR5Do9wmIDWUSThpJaRRTfg3obvZkq9MxTWvhhiuIueOnM4lFUxCBjPXcvVgBKoEXuECmnfDWHXfspiTZfEUaBPn3gRsaVOQTO2zH12+mtXK5K3sgbZGUwUnF1h33g1uAe7YlZrlK867IccnjgoFRpKliwV0UFvlbIVXg9mT06yv7NvP0sqk8fJyj7wI9QdMaX7N0LMcG8ArFqwR5RlK8fqrD0hXFpYwev3ubpIFNrQbkM2otS19BeHy4LSW9R+TVQgrmWlxQFFuOqUdUFs3C0UoZJPnlgVy3mp+1ule6JWho/LiXW7oBLY4tRmpVLO1zfBPraBlAS7dUqk3jvFMrI955Tc7gJ5AK07eJiKeyqS5QtO3BMXZ+V1YVRSeWtG4zNzZtVWxIqxRndh5eO2wgdWxRb8E89k0WvOtuSvKwCP+V9MKOIYPgfY+VCVn4B+6Qi4QXjQpbYBPwpQMXPsB74ju6Wl/wmuHDp2jhG4OeOXkENSd4S312v0Bn//jc4Ia2cEg+T97sJPF0fA8iASFAueVowIw00tYPb9DWGWte8wDhfwm+y3C8yWlOhM4Ko7AxJpq5hsN+GvrmC+UgA+Zj4GnngnsQmglVm7EABe5hKay9bxtqAjFZAkrhi9d96xPRVDUVtZtHoyu8WCZKrPUQ9K/cCyC7lllCETOVycxNF8Vx32/5OG4D1K1bDSmU1E70M8J9dxRoo7+f55AxjdAgi/GG4QcEuJ/Qn2PqfF9zv0Z3pLwKpDchsq9VNWs1//DlrgK3Bh2p+H5Wh7rcKHWrtq7/i/ea+8cThRH9zcm5I6uphYo4UBiulsP3efGG0gVznoNqfFG9bW1+kBYymULSaSwIDH2FfSdq5kkLGq87N7E0QQ+1cwcG78lujNjda2qq3tM6DRelh5wjfe64IMsU8pkqLPSV3nfhQMRrL2opv8A0YO3ZC7B8kOfNfsJZINZgQHp14dWA73QDNMAOf/JwsyxTGoi4hEzSnGTlhmg4Yeg/YgEZd1q9T83UmMEU43pOBQnzaOieOfpLCAyzP/RE46wVwUOOzRzJeSIX5qnFN/6yKCUItFW0l6cKr2MiklLPF5h4RyWEXFR9B8AOozq2nLIfZ4c9vRVIkN87yU/bWE6ZGVfHjyiunSMW4VrppKXHLHCKEpmYtb5RNgSt9Mjh8kFpWzlzPnb+DngdOv3tV2Gm3TaCAK4PRkZmSCg7xtSG7CPF1unGknvqStYJLkOjlijiYnenEv+6+A5HPwUMnbpzZ7EHI8hbGSW0xTJtDJt7oREuXByNZmzVOmmnp/kzVn4OFpMRJY45l1L3pIk53XSpxQkgICQEuFHCjEyZIav76seENULFeLWS6TVjQCpoDIiQCh8BFC6ULFVaC06HPXBuuMHWW6vIXPls6wQNe3NkZqu9nQNEnQlSqdl71L8KA66m7gUHrB6tidalXBRT9cISB/ntnRS/p/3IBm8+Nf9KHTVQGmojEARreVrkf6PLIRMI1cvvJRWHvhDt1qnXibwqKQCvWTp2G9XbC95wl/fwSNxNfayguheTgTDwtn6liHq2Q9T/wMotE/uSMgxEz493qAtUi5QVZwYN3Dgdz109plrZ+44WYAn+CYZi0ucbE8Oyw3tPJ2eAvL87qtOx9OnGMFGlWrn2hqkf2ezL5jW04b8TUjO8xWNBjspyoOz7cRczdCAdOH4deseJfZn5OjWUHFDfGsCuufO6lPpTucKFwrK+g0CNSO8hx47SguuTz63zvtpZoaefxRehQ+2fm9Vu/ti/ROopsL14yJ310OlQEcJU35IC++sKPaSOaG3d/SyZYVeprVcLaSHLvSRIU/5jD4NLHFpmXT05QGkRwQbClhENHF7M83wuTLaHrdGL65rPlUAmTjNKLzmO2uLKhFwUENr9b4m+D4ZzLCuxc1JCCjAHWuY9rgk2/IsYj2lfXw6EBt+OpnaMpjRTemwHA2Q5Di54hrhKULzvz3k6NT/MeiyWD7qdrhsJCVTpowtN7IA/HGsj0vA15d87ExoAyYDIW72wJVIiCf9piIRdM3RFrdjaPUze6cVa26RDcFG21cKHVfLrhezq85TKkvQNGioxP5aarrq829/g8PsGJE1hfIaPkJalqcbW2gkKNkFlZm11gYOKjs7Y9BEaOk9fdX6CpK/D1eX3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8dLDo=:") + + sig, err := rfc9421Extract(r, `sig1=("@method" "@target-uri" "host" "date");created=1783368000;keyid="test-key-mldsa44";alg="ml-dsa-44"`, nil, r.URL.Host, time.Unix(1783368000, 0), time.Minute, nil) + if err != nil { + t.Fatalf("Failed to extract: %v", err) + } + + if err := sig.Verify(&pub); err != nil { + t.Fatalf("Failed to verify: %v", err) + } +} diff --git a/httpsig/verify.go b/httpsig/verify.go index e6610ec9..7d475081 100644 --- a/httpsig/verify.go +++ b/httpsig/verify.go @@ -30,6 +30,8 @@ import ( "strings" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" + "github.com/dimkr/tootik/danger" ) @@ -268,6 +270,15 @@ func (s *Signature) Verify(key crypto.PublicKey) error { return errors.New("invalid ed25519 signature") } + case *mldsa44.PublicKey: + if s.Alg != "" && s.Alg != "ml-dsa-44" { + return errors.New("alg is not ML-DSA-44: " + s.Alg) + } + + if !mldsa44.Verify(v, danger.Bytes(s.s), nil, s.signature) { + return errors.New("invalid ML-DSA-44 signature") + } + default: return fmt.Errorf(`cannot verify alg="%s" with %T`, s.Alg, key) } diff --git a/inbox/backfill.go b/inbox/backfill.go index a039bd9c..ee6b7d65 100644 --- a/inbox/backfill.go +++ b/inbox/backfill.go @@ -215,7 +215,7 @@ func (q *Queue) fetchPost(ctx context.Context, id string) (*ap.Object, error) { return nil, fmt.Errorf("key %s does not belong to %s", m[1], origin) } - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return nil, fmt.Errorf("failed to verify proof using %s: %w", post.Proof.VerificationMethod, err) } @@ -394,7 +394,7 @@ func (q *Queue) fetchContext(ctx context.Context, post *ap.Object) error { return fmt.Errorf("key %s does not belong to %s", m[1], contextOrigin) } - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return fmt.Errorf("failed to verify proof using %s: %w", collection.Proof.VerificationMethod, err) } diff --git a/inbox/queue.go b/inbox/queue.go index ca6e4b86..93e3c942 100644 --- a/inbox/queue.go +++ b/inbox/queue.go @@ -35,7 +35,7 @@ type Queue struct { DB *sql.DB Inbox ap.Inbox Resolver ap.Resolver - Keys [2]httpsig.Key + Keys [3]httpsig.Key } type batchItem struct { diff --git a/migrations/046_proofs.go b/migrations/046_proofs.go index 47a5bf61..f606abaf 100644 --- a/migrations/046_proofs.go +++ b/migrations/046_proofs.go @@ -23,7 +23,7 @@ func proofs(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } @@ -56,7 +56,7 @@ func proofs(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } @@ -89,7 +89,7 @@ func proofs(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } diff --git a/migrations/047_contexts.go b/migrations/047_contexts.go index f9b4c9c1..f4b58fe4 100644 --- a/migrations/047_contexts.go +++ b/migrations/047_contexts.go @@ -23,7 +23,7 @@ func contexts(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } @@ -62,7 +62,7 @@ func contexts(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } @@ -101,7 +101,7 @@ func contexts(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } diff --git a/migrations/049_pembegin.go b/migrations/049_pembegin.go index f8a34314..3e152352 100644 --- a/migrations/049_pembegin.go +++ b/migrations/049_pembegin.go @@ -26,7 +26,7 @@ func pembegin(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } diff --git a/migrations/053_ed25519blob.go b/migrations/053_ed25519blob.go index 8fa1d4ea..d5a7b917 100644 --- a/migrations/053_ed25519blob.go +++ b/migrations/053_ed25519blob.go @@ -23,7 +23,7 @@ func ed25519blob(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } diff --git a/migrations/076_mldsa44seed.go b/migrations/076_mldsa44seed.go new file mode 100644 index 00000000..19cb16c4 --- /dev/null +++ b/migrations/076_mldsa44seed.go @@ -0,0 +1,61 @@ +package migrations + +import ( + "context" + "database/sql" + "strings" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/data" +) + +func mldsa44seed(ctx context.Context, domain string, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN mldsa44seed TEXT`); err != nil { + return err + } + + rows, err := tx.QueryContext(ctx, `SELECT id, JSON(actor) FROM persons WHERE ed25519privkey IS NOT NULL`, domain) + if err != nil { + return err + } + + defer rows.Close() + + for rows.Next() { + var id string + var actor ap.Actor + if err := rows.Scan(&id, &actor); err != nil { + return err + } + + if len(actor.AssertionMethod) == 0 { + continue + } + + last := actor.AssertionMethod[len(actor.AssertionMethod)-1] + + prefix, ok := strings.CutSuffix(last.ID, "#ed25519-key") + if !ok { + continue + } + + mldsa44Pub, mldsa44Priv, err := mldsa44.GenerateKey(nil) + if err != nil { + return err + } + + actor.AssertionMethod = append(actor.AssertionMethod, ap.AssertionMethod{ + ID: prefix + "#ml-dsa-44-key", + Type: "Multikey", + Controller: last.Controller, + PublicKeyMultibase: data.EncodeMLDSA44Publickey(mldsa44Pub), + }) + + if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = JSONB(?), mldsa44seed = ? WHERE id = ?`, &actor, mldsa44Priv.Seed(), id); err != nil { + return err + } + } + + return rows.Err() +} diff --git a/outbox/mover.go b/outbox/mover.go index ed4e492a..b97614f1 100644 --- a/outbox/mover.go +++ b/outbox/mover.go @@ -32,7 +32,7 @@ type Mover struct { Domain string DB *sql.DB Resolver ap.Resolver - Keys [2]httpsig.Key + Keys [3]httpsig.Key Inbox ap.Inbox } diff --git a/proof/proof.go b/proof/proof.go index 7f37f8b7..3ee7a1de 100644 --- a/proof/proof.go +++ b/proof/proof.go @@ -23,12 +23,14 @@ import ( "crypto" "crypto/ed25519" "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" "time" "github.com/btcsuite/btcutil/base58" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/httpsig" "github.com/gowebpki/jcs" @@ -43,7 +45,7 @@ func normalizeJSON(v any) ([]byte, error) { return jcs.Transform(j) } -// Create creates an eddsa-jcs-2022 integrity proof for a JSON object. +// Create creates an integrity proof for a JSON object. func Create(key httpsig.Key, doc any) (ap.Proof, error) { switch v := doc.(type) { case *ap.Activity: @@ -77,11 +79,6 @@ func Create(key httpsig.Key, doc any) (ap.Proof, error) { } func create(key httpsig.Key, now time.Time, doc, context any) (ap.Proof, error) { - edKey, ok := key.PrivateKey.(ed25519.PrivateKey) - if !ok { - return ap.Proof{}, fmt.Errorf("wrong key type: %T", key.PrivateKey) - } - created := now.UTC().Format(time.RFC3339) keyID := key.ID @@ -92,12 +89,22 @@ func create(key httpsig.Key, now time.Time, doc, context any) (ap.Proof, error) proof := ap.Proof{ Context: context, Type: "DataIntegrityProof", - CryptoSuite: "eddsa-jcs-2022", Created: created, Purpose: "assertionMethod", VerificationMethod: keyID, } + switch key.PrivateKey.(type) { + case ed25519.PrivateKey: + proof.CryptoSuite = "eddsa-jcs-2022" + + case *mldsa44.PrivateKey: + proof.CryptoSuite = "mldsa44-jcs-2024" + + default: + return ap.Proof{}, fmt.Errorf("wrong key type: %T", key.PrivateKey) + } + cfg, err := normalizeJSON(proof) if err != nil { return ap.Proof{}, err @@ -111,11 +118,23 @@ func create(key httpsig.Key, now time.Time, doc, context any) (ap.Proof, error) cfgHash := sha256.Sum256(cfg) docHash := sha256.Sum256(data) - proof.Value = "z" + base58.Encode(ed25519.Sign(edKey, append(cfgHash[:], docHash[:]...))) + switch v := key.PrivateKey.(type) { + case ed25519.PrivateKey: + proof.Value = "z" + base58.Encode(ed25519.Sign(v, append(cfgHash[:], docHash[:]...))) + + case *mldsa44.PrivateKey: + sig := make([]byte, mldsa44.SignatureSize) + if err := mldsa44.SignTo(nil, append(cfgHash[:], docHash[:]...), nil, true, sig); err != nil { + return ap.Proof{}, err + } + + proof.Value = "u" + base64.RawURLEncoding.EncodeToString(sig) + } + return proof, nil } -// Add adds an eddsa-jcs-2022 integrity proof to a JSON object. +// Add adds an integrity proof to a JSON object. func Add(key httpsig.Key, now time.Time, raw []byte) ([]byte, error) { var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { @@ -133,24 +152,15 @@ func Add(key httpsig.Key, now time.Time, raw []byte) ([]byte, error) { // Verify verifies an integrity proof. func Verify(key crypto.PublicKey, proof ap.Proof, context any, raw []byte) error { - edKey, ok := key.(ed25519.PublicKey) - if !ok { - return fmt.Errorf("wrong key type: %T", key) - } - if proof.Type != "DataIntegrityProof" { return errors.New("invalid type: " + proof.Type) } - if proof.CryptoSuite != "eddsa-jcs-2022" { - return errors.New("invalid cryptosuite: " + proof.CryptoSuite) - } - if proof.Purpose != "assertionMethod" { return errors.New("invalid purpose: " + proof.Purpose) } - if len(proof.Value) <= 1 || proof.Value[0] != 'z' { + if len(proof.Value) <= 1 { return errors.New("invalid value: " + proof.Value) } @@ -176,8 +186,17 @@ func Verify(key crypto.PublicKey, proof ap.Proof, context any, raw []byte) error options := proof options.Value = "" - if options.Context == nil { + switch proof.CryptoSuite { + case "eddsa-jcs-2022": + if options.Context == nil { + options.Context = context + } + + case "mldsa44-jcs-2024": options.Context = context + + default: + return fmt.Errorf("invalid cryptosuite: %s/%T", proof.CryptoSuite, key) } cfg, err := normalizeJSON(options) @@ -187,8 +206,39 @@ func Verify(key crypto.PublicKey, proof ap.Proof, context any, raw []byte) error cfgHash := sha256.Sum256(cfg) - if !ed25519.Verify(edKey, append(cfgHash[:], docHash[:]...), base58.Decode(proof.Value[1:])) { - return errors.New("proof verification failed") + switch proof.CryptoSuite { + case "eddsa-jcs-2022": + if proof.Value[0] != 'z' { + return errors.New("invalid value: " + proof.Value) + } + + edKey, ok := key.(ed25519.PublicKey) + if !ok { + return fmt.Errorf("wrong key type: %T", key) + } + + if !ed25519.Verify(edKey, append(cfgHash[:], docHash[:]...), base58.Decode(proof.Value[1:])) { + return errors.New("proof verification failed") + } + + case "mldsa44-jcs-2024": + if proof.Value[0] != 'u' { + return errors.New("invalid value: " + proof.Value) + } + + mlKey, ok := key.(*mldsa44.PublicKey) + if !ok { + return fmt.Errorf("wrong key type: %T", key) + } + + sig, err := base64.RawURLEncoding.DecodeString(proof.Value[1:]) + if err != nil { + return fmt.Errorf("failed to decode proof: %w", err) + } + + if !mldsa44.Verify(mlKey, append(cfgHash[:], docHash[:]...), nil, sig) { + return errors.New("proof verification failed") + } } return nil diff --git a/proof/proof_test.go b/proof/proof_test.go index 9fe89169..f12af0cb 100644 --- a/proof/proof_test.go +++ b/proof/proof_test.go @@ -18,17 +18,19 @@ package proof import ( "crypto/ed25519" + "encoding/base64" "encoding/json" "testing" "time" "github.com/btcsuite/btcutil/base58" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/httpsig" ) // https://codeberg.org/fediverse/fep/src/commit/3a5942066f989d8317befe6457b48237bc61efe0/fep/8b32/fep-8b32.feature#L3 -func TestProof_Sign(t *testing.T) { +func TestProof_SignEd25519(t *testing.T) { t.Parallel() raw := []byte(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/data-integrity/v1"],"id":"https://server.example/activities/1","type":"Create","actor":"https://server.example/users/alice","object":{"id":"https://server.example/objects/1","type":"Note","attributedTo":"https://server.example/users/alice","content":"Hello world","location":{"type":"Place","longitude":-71.184902,"latitude":25.273962}}}`) @@ -57,7 +59,7 @@ func TestProof_Sign(t *testing.T) { } // https://codeberg.org/fediverse/fep/src/commit/3a5942066f989d8317befe6457b48237bc61efe0/fep/8b32/fep-8b32.feature#L67 -func TestProof_Verify(t *testing.T) { +func TestProof_VerifyEd25519(t *testing.T) { t.Parallel() raw := []byte(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/data-integrity/v1"],"id":"https://server.example/activities/1","type":"Create","actor":"https://server.example/users/alice","object":{"id":"https://server.example/objects/1","type":"Note","attributedTo":"https://server.example/users/alice","content":"Hello world","location":{"type":"Place","longitude":-71.184902,"latitude":25.273962}},"proof":{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/data-integrity/v1"],"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","verificationMethod":"https://server.example/users/alice#ed25519-key","proofPurpose":"assertionMethod","proofValue":"zLaewdp4H9kqtwyrLatK4cjY5oRHwVcw4gibPSUDYDMhi4M49v8pcYk3ZB6D69dNpAPbUmY8ocuJ3m9KhKJEEg7z","created":"2023-02-24T23:36:38Z"}}`) @@ -71,3 +73,32 @@ func TestProof_Verify(t *testing.T) { t.Fatalf("Failed to verify proof: %v", err) } } + +// https://www.w3.org/TR/vc-di-quantum-resistant-1.0/#cryptosuite-mldsa44-jcs-2024 +func TestProof_VerifyMLDSA(t *testing.T) { + t.Parallel() + + raw := []byte(`{"@context":["https://www.w3.org/ns/credentials/v2","https://w3id.org/citizenship/v4rc1"],"type":["VerifiableCredential","EmploymentAuthorizationDocumentCredential"],"issuer":{"id":"did:key:zDnaegE6RR3atJtHKwTRTWHsJ3kNHqFwv7n9YjTgmU7TyfU76","image":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgUPr/HwADaAIhG61j/AAAAABJRU5ErkJggg=="},"credentialSubject":{"type":["Person","EmployablePerson"],"givenName":"JOHN","additionalName":"JACOB","familyName":"SMITH","image":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2Ng+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg==","gender":"Male","residentSince":"2015-01-01","birthCountry":"Bahamas","birthDate":"1999-07-17","employmentAuthorizationDocument":{"type":"EmploymentAuthorizationDocument","identifier":"83627465","lprCategory":"C09","lprNumber":"999-999-999"}},"name":"Employment Authorization Document","description":"Example Employment Authorization Document.","validFrom":"2019-12-03T00:00:00Z","validUntil":"2029-12-03T00:00:00Z","proof":{"type":"DataIntegrityProof","cryptosuite":"mldsa44-jcs-2024","created":"2023-02-24T23:36:38Z","verificationMethod":"did:key:ukCRKDtY8Do_dXzYGyuX7BY-1dDYM4FuSiw0gFdO-eJXFH0eqlt4_CP4sEISGAzNlKDzLpUJWoInRywXOpd7FCp_QAJAlL7iRo4cepKhzhlq8xt6qd5jkhYF9tNH8z3RGDl9aunNy_06fWLYNScWd5RmGg46Po8T-kIjMMkJftaqqZcGDxktpu9Et2bnaZMx4K98YyG1urUpM9lgvldgg2qv-6XCrm2uXlJ9U-HN4xtQKn4Ug-5xPwbhPGR2pbcBScTFotkhBqLc2eQLL6zPutWF83sSZbOhD_11BjMkeiLyJbMeHCIhz5GDIbPksEFIaSho3MdFo5fpQ8QZoqCit3Jn4ddfuShfIoLU1Hw5EZ0xBiqOU7e-TINd7-7HsgHLmYMGnpqljm1ot3c3cfalYsg87WQuSscO7XNH3Ewa-cgU6Bnj1SGn0plTy6Yq-GxU8XUBPwsK_IoIJbXWC0UD97c9UYpNiZi0-ECnbB-Y5_SM8auMfoIeap_buAOdXlJZmcp8xNCXI59AN9-96Sdhks5L-JmsELzyjAgjqNx8Zt3KPFc2jSwNjDVC1fEa8FdDdDT3WNkF6KTt65lb5_aIkFh20nOvT7kIJcKTgmhRGNJZgGSPYVbMypaQoaac8dtEoQjvYgnO-rM_RcsiWMHNc29br3o5wdiLXdr63MoX1lEWu_THBfeP1JuxrSbUmHOByepWbubbSM4iVQITCxBHZT0Mj2bWwIxd3nZUajzebyEnsfitV01kpzlO7bzY2uxSzyplTkRfppc_7YH0y0PHaggw0cIXNSh73wVqNZmzmJx5W0_akrvy5oSz9ZB1Io2p_fTxzibefwO700bUqbElV_yuCjD7EJ_Hfqbog80y_g9TK6koX7wYwqFNQxBVavKC-HbcT7yPdvzs9hlC2MNWCT3W7gVgeYr4AFgbV9EgMcH0GtJDKYw8vkpB_vTsaSTGZAj3TNKalAwiGO50VAmF5tknF96kOrWmNL0MdkXhnm1vXgDpP68bMt4r2Qr-hNdJ4s3_nqmSDYTnZRA4qjXjrgKQfO19txt0tX7LifE1GZ1bQyS7NqHWXyMEhw6_F8pc_tS16VhvJO_FM7CX51mLLkLCGl7DsmbnEIsVUW9qlCxb6bj53UyijTYdu6uLZW9JISE2B4EevxzwDu9UGcJPHmJYi1rRQAP__jH97GiQC8FvkdAEfKqcwV9jAbBPQPG6lUkBLcoijgR3Bcwd-ta92oeZmcpoJ97PzzBbCL-NrppJ2HHQ1SMsYWoPveZTmZc66YBA0P9YfT4hZ0RQiP2gxB4snTvMFI0Ot6Q2nQ0p5DMxmWqIaCKW53rqn16AVXQeqC2TJjlbjA9sC6pr8GEGY2OQUgEmWu5GmnOSz1lNY7fNHJypChnieI_hyYiy06qouUpoHA5z_IUtfzZoMIG0yJiGUUpF9BJvYChDECCqaUM1kWnO5tKcohSKq5Hqwu_EWDRYF2tj7igSimZkS4Pts41tu8nIaVk5EkzAX9gCR2EX3Lk869mIxSyBS3MyG_NotPcbm6uXDn_YkV5Z0HkxUxYRA9hIG-UhKhK3VOaHZP8GcQN8noOMa2CnPd208X6HOzlIlxs7SRbzppUs_fHN1eROglNy-2oJWGmo-xOy0Qd44TtY0S_bYhu6iH6inrx3-yncSrWFxEiYosvYJD4ZBSyrV4d6UsfeNSHYS0ODTsdPqz4SYTeloZbIx8XWz7fxLXlNyLr3s9tp-Q25f1vTIrmQL","proofPurpose":"assertionMethod","proofValue":"uTSucVLvXmOpmjGGNB-B9rM-u4HzBxN8ZIuZbpTHrjOTNBnahoE4PSdkeD-IzLLXykJn0aYq_APExy-Ka0BcJNMvKgkdjbbP33WmUwkzljno3szRUDrN9KX2DMH7j0iOBakU4ByjD-hTSO1iR6rlxsZPHJM1H-WLMhzVSggBILAuglItzstl663Gz5bFjEfbKAgfe50L4v4PjLFSDbJYcg65GtCKRXISkWrnJRuToWwvTVdcnIBOQwPBFKsvApPJMKrUTkIuZf4-V1uJ81zzct4o20O-DqLQ5bHfOR2n5Y4DSy6e5zg0-S3ADKtMtuPaQ8cAPUTEKRXGRQnSndnrtgMh2dimvpSaaw0TDy7zY6vrDxJa1tkrS0ulKf3Xz8xsNrNIkx4SaKYWPTjhRvdKqjdrpbGRt3mRSFFc0VE8vK44F_EVFIhwouL-4Rm4mXU2QkiO0YkwuAJM-QdWUACqzJ7TSf2QrrU8zAwOLbrGS5uZ1qLGD1PcgWfg2d0zTAYmcWP4LP63fTnFxwr-L0N_3MLFXixHNEp5osMlo2lhl5noDCmQpqCgluxkd5gXs1NSpOBbWVQyYWcj0WMBtMam8AeqXpA39L7oqvYxqbEpiwvKrmHsXIEZrnsHKCk2P0yc10AFCCtsIapvTHwIAjbDhX11HFU5cci4X5vCdG2BUzRsgmGeiYiUClCHmqsBW4z2GA9r0d9jtHZ03nMie_qS95XPsXuAFqypsP1HOfcIUAHHS9Wn4XGFz3hXoqMsmoUGRg9vEpC2j_nkcYZQphYLs54veWq5BBzoMqPuvYhhRdawdCnn-LTf7AxQgVGoRTpTy4IkXxr_pC1LUJZJkdKeG-2TuQzyHSkbMPu3YbWsGy2KxdGeFN2yUI8TTQ-MFHl-_jDCRBrAYyCVOgML4NAtbGqvs7h2tGZYI-m9MfjG0vjp7CUyIPD8BV-Yhku_bHd0hcrseKtYYyUjxISf2wveo4dfQ2AnCVdDAbBmznjPIDlkqx0316sRc-vXJGRQmfXOW1dNk-7WNBrJVQbnT9m6cf0UEl3mEgagk1_lLOxTgjzZRpWcOB827VB3hPi7RdI6U6knXuOflHPt9BZN7i6OAl76k69uMFH2KNH3Abm0GDhOv_nu1lEaOH8aXdOqL3U8Yo0cp5roOoTw5fJP2gxwI3DY0TOWeNOCfLXmnodgoGaKG2Vns4_-gN_Mg7g0ZinguwJMwKACx07H__ffh8jYQdc87EjCNyH4m8hJICvcC81J7CKb89YTZm7IM0D1_qTR5t-DkuU4ypxNuFOCxWpN9y2QiLAAobDdc1Y_S3nXFFkLmsn7hUNhcgXxPC3jLifiM0IV7DAqmQpk2ZGE59l0VTKb3F2Ualj9JcqKtLg_b2KqprUol9WtjFlkbxqJPYyCKnSEitzDnDsfxTRFEIViTx5-1SFb0NjPE_hv5MewCkizNpfo0b-m-FxvyWJnDYt4Igv8JtgF0K_xMRC9Tf3NaQgFHb1OgkBz04C3wsxoPLqTgMWoxcZ2-2x7TRRvX2Nh1Ye-ZrpmF5hVeRMK0ECj_t5HLPaq2md18rqnhwsZv84-V0eReDyXVIhkE2eAKedCM9t13UjTfF1qFoUQ3D8xQ6MfR7zFwf6X78Tb0EFs0cBQ1TatzWysbE2b_0k-YbT78G4Ko8FlmTljBN1b0StKxzOE1Kp1h4nDBY9jZYYPNnVrtAGn2AKN3HWr0bhhF8fW_G4SA_MGu2r6LnobB3MLCSj1lbVZSk5YtCtMnkldAsDagoI8lRBlKW1R7PFvXatSHcwbe352nVuvZYxs9QJVSylf2QS1xeUQUMS4AHd4h8Y9HqmnGPr5JX65uch8sr1bWcpGiNlwnMlFy89pnEZU2v7IiC_foLi8JbxMud1k2XRDwThhepEf5bxqlBcgjF8vAbGEKAJg5Oahl0GCBffuHhuUXmuF6XwOxLovlJUM8oFCTayQL42NGz_Z2cuFltmiy-cbI8NmEpvlOPnGZBj00ZIrp9tAgwepYvlt5ticFn9ufU-f8xZpBpZb-rf5sVTNqYmoOYvhKVhE5cCPpCbzZ5GvW5ukB8yLLJC5sc6df9oSoujovfA_VAXqsvmBuKU4cHcNTNEqzsGEg_l0ln5FIHKH3CRUTDemKN2vbtmTz1snn4VfdBFAlhJhBItpBmd3HibH-1q2WD313j-cE5nW_QgQeDn_JFJ7zwlORQVRUkeB942HJjkHWXCJMXz9LKxdncKalaVNm-yVjDkQdgA1tKl9bc_QnAL7HWhtHFc_XhzJvQxqLJ0aLUkkrnphrqscG6D_Kdh7aTTkDjDSA-dmgRQBh51YVBnfnp5V28AwmGXXglBAWCWChGmAtac6xeLbxW143426J4HMAUIpLgNhjetQoQqKzVTIpzcyo-GK8L3C0calt57orTswSRqDjxg_6zAQ6RPNoThToRqb2QgHr-gOop6NEJkEy0K8GwPg3nYAFgVVjcCyDtMEbIrHXJ9WKg3oTd14eC7GNZgQ75aP3HpUAqT5gqWdxer35Ohs3n1FylwreS1kOZ5Z4OVW5PVJkNHhKPfaNq4mQT5vBAWlWphOotPHwTbN7oMiOGYu-AMTnsLPCn0A3VEXx16EROpu0zlVisEZo1sya8nQaJYiI_i3MEWqvV2ypvcMDYt_ArFjxMU3tjV5tNcJgIoE5E5UCGyo2HQMvN03T0GdHZr7txswg9HpRJqntiJzm0iAr9BhRPfErg4HLQyc92gOH6UdczP4hvbweP3mcW67yUT3lH31vznZ5lIJ0pth3H_7khwUff5daIROar2usWxoMWTItY5HC7v5HBjnfqj4EoVi1A4Uw7RvHhaCMkfDrqqntNM0TDDSfDP1uCB1RwZtcuWpNfLWYyP1B9XqwKg3EworHhIq1vI74gZROvebyYqx8UCFeiLubTfXHJrC2evT99ha6jf7vg8zKgew6Cj3Jz_RSRE5rF5uQQno6PsevbKKtZsQmn0PQphfNzWqacMpred2yrmetGOEG_JvYxx5Scmu8w8Yg-3V7yhMeDsOh0DZ8sjTw5d-w3zKUNeU2IZzz5wvaJ7-8RRxyJqxFsUFaBv7WxUZ0bmWg4pRXdBUKNW53mEUGDigpRF6Fla-6wc4BCDhwfJWdpaixu8Lf4fkRKCs0Y4-lrsTH1-zwNkKBudbd6v4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwbKDA"}}`) + + var a struct { + Context any `json:"@context"` + Proof ap.Proof `json:"proof"` + } + if err := json.Unmarshal(raw, &a); err != nil { + t.Fatalf("Failed to unmarshal activity: %v", err) + } + + pubBytes, err := base64.RawURLEncoding.DecodeString("kCRKDtY8Do_dXzYGyuX7BY-1dDYM4FuSiw0gFdO-eJXFH0eqlt4_CP4sEISGAzNlKDzLpUJWoInRywXOpd7FCp_QAJAlL7iRo4cepKhzhlq8xt6qd5jkhYF9tNH8z3RGDl9aunNy_06fWLYNScWd5RmGg46Po8T-kIjMMkJftaqqZcGDxktpu9Et2bnaZMx4K98YyG1urUpM9lgvldgg2qv-6XCrm2uXlJ9U-HN4xtQKn4Ug-5xPwbhPGR2pbcBScTFotkhBqLc2eQLL6zPutWF83sSZbOhD_11BjMkeiLyJbMeHCIhz5GDIbPksEFIaSho3MdFo5fpQ8QZoqCit3Jn4ddfuShfIoLU1Hw5EZ0xBiqOU7e-TINd7-7HsgHLmYMGnpqljm1ot3c3cfalYsg87WQuSscO7XNH3Ewa-cgU6Bnj1SGn0plTy6Yq-GxU8XUBPwsK_IoIJbXWC0UD97c9UYpNiZi0-ECnbB-Y5_SM8auMfoIeap_buAOdXlJZmcp8xNCXI59AN9-96Sdhks5L-JmsELzyjAgjqNx8Zt3KPFc2jSwNjDVC1fEa8FdDdDT3WNkF6KTt65lb5_aIkFh20nOvT7kIJcKTgmhRGNJZgGSPYVbMypaQoaac8dtEoQjvYgnO-rM_RcsiWMHNc29br3o5wdiLXdr63MoX1lEWu_THBfeP1JuxrSbUmHOByepWbubbSM4iVQITCxBHZT0Mj2bWwIxd3nZUajzebyEnsfitV01kpzlO7bzY2uxSzyplTkRfppc_7YH0y0PHaggw0cIXNSh73wVqNZmzmJx5W0_akrvy5oSz9ZB1Io2p_fTxzibefwO700bUqbElV_yuCjD7EJ_Hfqbog80y_g9TK6koX7wYwqFNQxBVavKC-HbcT7yPdvzs9hlC2MNWCT3W7gVgeYr4AFgbV9EgMcH0GtJDKYw8vkpB_vTsaSTGZAj3TNKalAwiGO50VAmF5tknF96kOrWmNL0MdkXhnm1vXgDpP68bMt4r2Qr-hNdJ4s3_nqmSDYTnZRA4qjXjrgKQfO19txt0tX7LifE1GZ1bQyS7NqHWXyMEhw6_F8pc_tS16VhvJO_FM7CX51mLLkLCGl7DsmbnEIsVUW9qlCxb6bj53UyijTYdu6uLZW9JISE2B4EevxzwDu9UGcJPHmJYi1rRQAP__jH97GiQC8FvkdAEfKqcwV9jAbBPQPG6lUkBLcoijgR3Bcwd-ta92oeZmcpoJ97PzzBbCL-NrppJ2HHQ1SMsYWoPveZTmZc66YBA0P9YfT4hZ0RQiP2gxB4snTvMFI0Ot6Q2nQ0p5DMxmWqIaCKW53rqn16AVXQeqC2TJjlbjA9sC6pr8GEGY2OQUgEmWu5GmnOSz1lNY7fNHJypChnieI_hyYiy06qouUpoHA5z_IUtfzZoMIG0yJiGUUpF9BJvYChDECCqaUM1kWnO5tKcohSKq5Hqwu_EWDRYF2tj7igSimZkS4Pts41tu8nIaVk5EkzAX9gCR2EX3Lk869mIxSyBS3MyG_NotPcbm6uXDn_YkV5Z0HkxUxYRA9hIG-UhKhK3VOaHZP8GcQN8noOMa2CnPd208X6HOzlIlxs7SRbzppUs_fHN1eROglNy-2oJWGmo-xOy0Qd44TtY0S_bYhu6iH6inrx3-yncSrWFxEiYosvYJD4ZBSyrV4d6UsfeNSHYS0ODTsdPqz4SYTeloZbIx8XWz7fxLXlNyLr3s9tp-Q25f1vTIrmQL") + if err != nil { + t.Fatalf("Failed to decode public key: %v", err) + } + + var pub mldsa44.PublicKey + if err := pub.UnmarshalBinary(pubBytes[2:]); err != nil { + t.Fatalf("Failed to decode key: %v", err) + } + + if err := Verify(&pub, a.Proof, a.Context, raw); err != nil { + t.Fatalf("Failed to verify proof: %v", err) + } +} diff --git a/test/server.go b/test/server.go index d5494232..7edddea8 100644 --- a/test/server.go +++ b/test/server.go @@ -49,7 +49,7 @@ type server struct { Alice *ap.Actor Bob *ap.Actor Carol *ap.Actor - AppActorKeys [2]httpsig.Key + AppActorKeys [3]httpsig.Key } func (s *server) Shutdown() { From f513dc6a28489bb84042793d89951cb891e98883 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 08:24:20 +0300 Subject: [PATCH 02/41] x --- fed/deliver.go | 2 +- front/gemini/gemini.go | 2 +- front/shell.go | 4 ++-- front/user/app.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fed/deliver.go b/fed/deliver.go index 6bf7f1c4..c75f57f5 100644 --- a/fed/deliver.go +++ b/fed/deliver.go @@ -176,7 +176,7 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { continue } - _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[32]byte)(row.MLDSA44Seed)) + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(row.MLDSA44Seed)) keys := [3]httpsig.Key{ {ID: row.Actor.PublicKey.ID, PrivateKey: rsaPrivKey}, diff --git a/front/gemini/gemini.go b/front/gemini/gemini.go index dd2da183..12badbce 100644 --- a/front/gemini/gemini.go +++ b/front/gemini/gemini.go @@ -103,7 +103,7 @@ func (gl *Listener) getUser(ctx context.Context, tlsConn *tls.Conn, cfg *cfg.Con return nil, [3]httpsig.Key{}, fmt.Errorf("failed to parse RSA private key for %s: %w", certHash, err) } - _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[32]byte)(mldsa44Seed)) + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) slog.Debug("Found existing user", "hash", certHash, "user", actor.ID) return &actor, [3]httpsig.Key{ diff --git a/front/shell.go b/front/shell.go index b9d38fcf..f00dfa9d 100644 --- a/front/shell.go +++ b/front/shell.go @@ -45,7 +45,7 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { ctx, `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, user, - ).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey); err != nil { + ).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &mldsa44Seed); err != nil { panic(err) } @@ -54,7 +54,7 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { panic(err) } - _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[32]byte)(mldsa44Seed)) + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) var buf bytes.Buffer diff --git a/front/user/app.go b/front/user/app.go index 6070a70a..764160b0 100644 --- a/front/user/app.go +++ b/front/user/app.go @@ -55,7 +55,7 @@ func CreateApplicationActor(ctx context.Context, domain string, db *sql.DB, cfg return nil, [3]httpsig.Key{}, err } - _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[32]byte)(mldsa44Seed)) + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) return &actor, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, From 878a91b998a9611eac7918ac66888d4f0a1dd2bf Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 08:51:24 +0300 Subject: [PATCH 03/41] x --- migrations/053_ed25519blob.go | 3 +- migrations/076_mldsa44seed.go | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/migrations/053_ed25519blob.go b/migrations/053_ed25519blob.go index d5a7b917..ea94cc1e 100644 --- a/migrations/053_ed25519blob.go +++ b/migrations/053_ed25519blob.go @@ -2,6 +2,7 @@ package migrations import ( "context" + "crypto/ed25519" "database/sql" "github.com/dimkr/tootik/data" @@ -28,7 +29,7 @@ func ed25519blob(ctx context.Context, domain string, tx *sql.Tx) error { return err } - if _, err := tx.ExecContext(ctx, `UPDATE persons SET ed25519privkeyblob = ? WHERE id = ?`, ed25519PrivKey.Seed(), id); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE persons SET ed25519privkeyblob = ? WHERE id = ?`, ed25519PrivKey.(ed25519.PrivateKey).Seed(), id); err != nil { return err } } diff --git a/migrations/076_mldsa44seed.go b/migrations/076_mldsa44seed.go index 19cb16c4..1aa85444 100644 --- a/migrations/076_mldsa44seed.go +++ b/migrations/076_mldsa44seed.go @@ -11,6 +11,78 @@ import ( ) func mldsa44seed(ctx context.Context, domain string, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `DROP INDEX notescid`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN cid`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END)`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX notescid ON notes(cid)`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `DROP INDEX personscid`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `DROP INDEX personscidlocal`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE persons DROP COLUMN cid`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END)`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE INDEX personscid ON persons(cid)`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519privkey IS NOT NULL`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `DROP INDEX outboxhostinserted`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `DROP INDEX outboxcidsender`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox DROP COLUMN host`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox ADD COLUMN host TEXT AS (substr(substr(activity->>'$.id', 9), 0, instr(substr(activity->>'$.id', 9), '/')))`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox DROP COLUMN cid`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN activity->>'$.id' LIKE 'https://%' AND (activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22, CASE WHEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') - 1 ELSE LENGTH(activity->>'$.id') END) WHEN activity->>'$.id' LIKE 'https://%' THEN activity->>'$.id' ELSE NULL END)`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxhostinserted ON outbox(host, inserted)`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxcidsender ON outbox(cid, sender)`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN mldsa44seed TEXT`); err != nil { return err } From 6e8a20ec4b67807a62ddd969895a648b1f997bcc Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 09:54:34 +0300 Subject: [PATCH 04/41] x --- ap/id.go | 6 +++--- fed/apgateway.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ap/id.go b/ap/id.go index e74e1037..51c3d2c7 100644 --- a/ap/id.go +++ b/ap/id.go @@ -24,13 +24,13 @@ import ( var ( // KeyRegex matches a Multibase-encoded Ed25519 or ML-DSA-44 public key. - KeyRegex = regexp.MustCompile(`\b(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)\b`) + KeyRegex = regexp.MustCompile(`\b(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)\b`) // apURLRegex matches an ap:// URL. - apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) + apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) // GatewayURLRegex matches an https:// gateway URL. - GatewayURLRegex = regexp.MustCompile(`^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\/\.well-known\/apgateway\/did:key:(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) + GatewayURLRegex = regexp.MustCompile(`^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\/\.well-known\/apgateway\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) ) // IsPortable determines whether or not an ActivityPub ID is portable. diff --git a/fed/apgateway.go b/fed/apgateway.go index 1adb90cd..af68177e 100644 --- a/fed/apgateway.go +++ b/fed/apgateway.go @@ -40,7 +40,7 @@ import ( "github.com/dimkr/tootik/proof" ) -var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key:z6Mk[a-km-zA-HJ-NP-Z1-9]+)(\/actor(?:\/[^\/]+)?)(\/.+)?`) +var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key:(?:z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+))(\/actor(?:\/[^\/]+)?)(\/.+)?`) func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Request, did string) { var actor ap.Actor From ce44f34362c2750f7ac85f9ef6c28f3a6f0c1b30 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 09:56:07 +0300 Subject: [PATCH 05/41] x --- proof/proof.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proof/proof.go b/proof/proof.go index 3ee7a1de..cbe48768 100644 --- a/proof/proof.go +++ b/proof/proof.go @@ -124,7 +124,7 @@ func create(key httpsig.Key, now time.Time, doc, context any) (ap.Proof, error) case *mldsa44.PrivateKey: sig := make([]byte, mldsa44.SignatureSize) - if err := mldsa44.SignTo(nil, append(cfgHash[:], docHash[:]...), nil, true, sig); err != nil { + if err := mldsa44.SignTo(v, append(cfgHash[:], docHash[:]...), nil, true, sig); err != nil { return ap.Proof{}, err } From 799ee5ceb697e9f10afa79d78a8e3967412d976e Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 15:43:28 +0300 Subject: [PATCH 06/41] x --- ap/id.go | 2 +- ap/slug.go | 27 +++++++ cluster/mention_test.go | 2 +- cluster/portability_test.go | 2 +- cmd/tootik/main.go | 17 ++-- data/garbage.go | 6 +- fed/apgateway.go | 12 ++- fed/deliver_test.go | 97 ++++++++++++++-------- fed/followers.go | 8 +- fed/resolve.go | 5 +- front/accept.go | 17 ++-- front/alias.go | 7 +- front/avatar.go | 5 +- front/bio.go | 5 +- front/bookmark.go | 26 +++--- front/bookmarks.go | 4 +- front/communities.go | 14 ++-- front/delete.go | 15 ++-- front/edit.go | 10 +-- front/follow.go | 19 ++--- front/followers.go | 3 +- front/follows.go | 16 ++-- front/fts.go | 22 ++--- front/hashtag.go | 4 +- front/id.go | 35 ++++++++ front/invitations.go | 6 +- front/local.go | 10 +-- front/me.go | 6 +- front/mentions.go | 2 +- front/metadata.go | 7 +- front/move.go | 7 +- front/name.go | 5 +- front/outbox.go | 91 +++++++++++---------- front/portability.go | 24 ++++-- front/post.go | 9 ++- front/print.go | 8 +- front/reject.go | 22 ++--- front/reply.go | 14 ++-- front/resolve.go | 5 +- front/share.go | 19 ++--- front/unbookmark.go | 10 +-- front/unfollow.go | 17 ++-- front/unshare.go | 17 ++-- front/user/create.go | 5 +- front/users.go | 2 +- front/view.go | 156 +++++++++++++++++++----------------- inbox/forward.go | 17 ++-- inbox/inbox.go | 24 +++--- inbox/note/insert.go | 14 ++-- migrations/077_slug.go | 47 +++++++++++ outbox/deleter.go | 19 ++--- outbox/mover.go | 11 ++- outbox/poller.go | 17 ++-- proof/key.go | 66 +++++++++++++++ test/community_test.go | 30 ++++--- test/forward_test.go | 54 ++++++++----- test/move_test.go | 43 ++++++---- test/outbox_test.go | 105 ++++++++++++++++-------- test/poll_test.go | 40 ++++++--- test/users_test.go | 15 ++-- test/view_test.go | 55 ++++++++----- 61 files changed, 865 insertions(+), 514 deletions(-) create mode 100644 ap/slug.go create mode 100644 front/id.go create mode 100644 migrations/077_slug.go create mode 100644 proof/key.go diff --git a/ap/id.go b/ap/id.go index 51c3d2c7..d260cbe6 100644 --- a/ap/id.go +++ b/ap/id.go @@ -24,7 +24,7 @@ import ( var ( // KeyRegex matches a Multibase-encoded Ed25519 or ML-DSA-44 public key. - KeyRegex = regexp.MustCompile(`\b(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)\b`) + KeyRegex = regexp.MustCompile(`\b(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)`) // apURLRegex matches an ap:// URL. apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) diff --git a/ap/slug.go b/ap/slug.go new file mode 100644 index 00000000..02192261 --- /dev/null +++ b/ap/slug.go @@ -0,0 +1,27 @@ +/* +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" +) + +func Slug(id string) string { + sum := sha256.Sum256([]byte(id)) + return base64.RawURLEncoding.EncodeToString(sum[:9]) +} diff --git a/cluster/mention_test.go b/cluster/mention_test.go index f0c21ce1..5fe98542 100644 --- a/cluster/mention_test.go +++ b/cluster/mention_test.go @@ -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) { diff --git a/cluster/portability_test.go b/cluster/portability_test.go index effc8bc0..c03a3529 100644 --- a/cluster/portability_test.go +++ b/cluster/portability_test.go @@ -135,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"). diff --git a/cmd/tootik/main.go b/cmd/tootik/main.go index 0fbd2186..cc76babd 100644 --- a/cmd/tootik/main.go +++ b/cmd/tootik/main.go @@ -18,13 +18,13 @@ package main import ( "context" - "crypto/ed25519" "crypto/tls" "database/sql" "encoding/json" "errors" "flag" "fmt" + "github.com/dimkr/tootik/proof" "log/slog" "net/http" "os" @@ -46,7 +46,6 @@ import ( "github.com/dimkr/tootik/front/gemini" tplain "github.com/dimkr/tootik/front/text/plain" "github.com/dimkr/tootik/front/user" - "github.com/dimkr/tootik/httpsig" "github.com/dimkr/tootik/icon" "github.com/dimkr/tootik/inbox" "github.com/dimkr/tootik/migrations" @@ -246,19 +245,19 @@ func main() { defer tx.Rollback() var actor ap.Actor - var ed25519PrivKey []byte + var ed25519PrivKey, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, - `select json(actor), ed25519privkey from persons where ed25519privkey is not null and actor->>'$.preferredUsername' = ?`, + `select json(actor), ed25519privkey, mldsa44seed from persons where ed25519privkey is not null and actor->>'$.preferredUsername' = ?`, flag.Arg(1), - ).Scan(&actor, &ed25519PrivKey); err != nil { + ).Scan(&actor, &ed25519PrivKey, &mldsa44Seed); err != nil { panic(err) } actor.Summary = tplain.ToHTML(string(summary), nil) actor.Updated.Time = time.Now() - if err := localInbox.UpdateActorTx(ctx, tx, &actor, httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}); err != nil { + if err := localInbox.UpdateActorTx(ctx, tx, &actor, proof.SigningSeed(&actor, ed25519PrivKey, mldsa44Seed)); err != nil { panic(err) } @@ -288,12 +287,12 @@ func main() { userName := flag.Arg(1) var actor ap.Actor - var ed25519PrivKey []byte + var ed25519PrivKey, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, `select select json(actor), ed25519privkey from persons where ed25519privkey is not null and actor->>'$.preferredUsername' = ?`, userName, - ).Scan(&actor, &ed25519PrivKey); err != nil { + ).Scan(&actor, &ed25519PrivKey, &mldsa44Seed); err != nil { panic(err) } @@ -312,7 +311,7 @@ func main() { }) actor.Updated.Time = now - if err := localInbox.UpdateActorTx(ctx, tx, &actor, httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}); err != nil { + if err := localInbox.UpdateActorTx(ctx, tx, &actor, proof.SigningSeed(&actor, ed25519PrivKey, mldsa44Seed)); err != nil { panic(err) } diff --git a/data/garbage.go b/data/garbage.go index a883c3e1..5fb15fde 100644 --- a/data/garbage.go +++ b/data/garbage.go @@ -35,7 +35,7 @@ type GarbageCollector struct { func (gc *GarbageCollector) Run(ctx context.Context) error { now := time.Now() - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select notes.rowid from notes left join follows on follows.followed in (notes.author, notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'$.to') where value = follows.followed)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = follows.followed)) where follows.accepted = 1 and notes.inserted < $1 and notes.host != $2 and follows.id is null and not exists (select 1 from bookmarks where bookmarks.note = notes.id) and not exists (select 1 from shares where shares.note = notes.id and exists (select 1 from persons where persons.id = shares.by and persons.host = $2)))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where slug in (select notes.slug from notes left join follows on follows.followed in (notes.author, notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'$.to') where value = follows.followed)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = follows.followed)) where follows.accepted = 1 and notes.inserted < $1 and notes.host != $2 and follows.id is null and not exists (select 1 from bookmarks where bookmarks.note = notes.id) and not exists (select 1 from shares where shares.note = notes.id and exists (select 1 from persons where persons.id = shares.by and persons.host = $2)))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove invisible posts: %w", err) } @@ -43,7 +43,7 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove invisible posts: %w", err) } - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select rowid from notes where inserted < $1 and author not in (select followed from follows where accepted = 1) and host != $2 and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where slug in (select slug from notes where inserted < $1 and author not in (select followed from follows where accepted = 1) and host != $2 and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove posts by authors without followers: %w", err) } @@ -51,7 +51,7 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove posts by authors without followers: %w", err) } - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select rowid from notes where inserted < ? and host != ? and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.NotesTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where slug in (select slug from notes where inserted < ? and host != ? and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.NotesTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove old posts: %w", err) } diff --git a/fed/apgateway.go b/fed/apgateway.go index af68177e..1641d9e0 100644 --- a/fed/apgateway.go +++ b/fed/apgateway.go @@ -316,12 +316,13 @@ func (l *Listener) handleApGatewayContext(w http.ResponseWriter, r *http.Request } var postID string - var ed25519PrivKey []byte + var author ap.Actor + var ed25519PrivKey, mldsa44Seed []byte if err := l.DB.QueryRowContext( r.Context(), - `select notes.id, notes.author, persons.ed25519privkey from notes join persons on persons.id = notes.author where notes.object->>'$.context' = ? and notes.object->>'$.inReplyTo' is null and persons.ed25519privkey is not null`, + `select notes.id, notes.author, json(persons.actor), persons.ed25519privkey, persons.mldsa44seed from notes join persons on persons.id = notes.author where notes.object->>'$.context' = ? and notes.object->>'$.inReplyTo' is null and persons.ed25519privkey is not null`, contextID, - ).Scan(&postID, &collection.AttributedTo, &ed25519PrivKey); errors.Is(err, sql.ErrNoRows) { + ).Scan(&postID, &collection.AttributedTo, &author, &ed25519PrivKey, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Warn("Context does not exist", "id", contextID) w.WriteHeader(http.StatusNotFound) return @@ -368,10 +369,7 @@ func (l *Listener) handleApGatewayContext(w http.ResponseWriter, r *http.Request var err error collection.Proof, err = proof.Create( - httpsig.Key{ - ID: collection.AttributedTo + "#ed25519-key", - PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey), - }, + proof.SigningSeed(&author, ed25519PrivKey, mldsa44Seed), collection, ) if err != nil { diff --git a/fed/deliver_test.go b/fed/deliver_test.go index 4b22bd48..d19ff8b3 100644 --- a/fed/deliver_test.go +++ b/fed/deliver_test.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "database/sql" + "github.com/dimkr/tootik/ap" "io" "net/http" "os" @@ -70,14 +71,16 @@ func TestDeliver_TwoUsersTwoPosts(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -176,14 +179,16 @@ func TestDeliver_ForwardedPost(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -277,14 +282,16 @@ func TestDeliver_OneFailed(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -389,14 +396,16 @@ func TestDeliver_OneFailedRetry(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -483,14 +492,16 @@ func TestDeliver_OneInvalidURLRetry(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes:inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -577,14 +588,16 @@ func TestDeliver_MaxAttempts(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -672,21 +685,24 @@ func TestDeliver_SharedInbox(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -759,21 +775,24 @@ func TestDeliver_SharedInboxRetry(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -869,14 +888,16 @@ func TestDeliver_SharedInboxUnknownActor(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -958,21 +979,24 @@ func TestDeliver_SharedInboxSingleWorker(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -1045,21 +1069,24 @@ func TestDeliver_SameInbox(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/frank"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -1135,14 +1162,16 @@ func TestDeliver_ToAndCCDuplicates(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -1241,14 +1270,16 @@ func TestDeliver_PublicInTo(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -1347,14 +1378,16 @@ func TestDeliver_AuthorInTo(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) diff --git a/fed/followers.go b/fed/followers.go index fd990998..a0f77776 100644 --- a/fed/followers.go +++ b/fed/followers.go @@ -18,12 +18,12 @@ package fed import ( "context" - "crypto/ed25519" "crypto/sha256" "database/sql" "encoding/json" "errors" "fmt" + "github.com/dimkr/tootik/proof" "io" "log/slog" "net/http" @@ -315,8 +315,8 @@ func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Conf slog.Info("Found unknown remote follow", "followed", d.Followed, "follower", follower) var actor ap.Actor - var ed25519PrivKey []byte - if err := db.QueryRowContext(ctx, `SELECT JSON(persons.actor), persons.ed25519privkey FROM persons WHERE id = ? AND persons.ed25519privkey IS NOT NULL`, follower).Scan(&actor, &ed25519PrivKey); errors.Is(err, sql.ErrNoRows) { + var ed25519PrivKey, mldsa44Seed []byte + if err := db.QueryRowContext(ctx, `SELECT JSON(persons.actor), persons.ed25519privkey, persons.mldsa44seed FROM persons WHERE id = ? AND persons.ed25519privkey IS NOT NULL`, follower).Scan(&actor, &ed25519PrivKey, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Info("Follower does not exist", "followed", d.Followed, "follower", follower) continue } else if err != nil { @@ -337,7 +337,7 @@ func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Conf continue } - if err := d.Inbox.Unfollow(ctx, &actor, httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, d.Followed, followID); err != nil { + if err := d.Inbox.Unfollow(ctx, &actor, proof.SigningSeed(&actor, ed25519PrivKey, mldsa44Seed), d.Followed, followID); err != nil { slog.Warn("Failed to remove remote follow", "followed", d.Followed, "follower", follower, "error", err) } } diff --git a/fed/resolve.go b/fed/resolve.go index 934a08eb..65f6fdf2 100644 --- a/fed/resolve.go +++ b/fed/resolve.go @@ -138,7 +138,7 @@ func (r *Resolver) validate(try func() (*ap.Actor, *ap.Actor, error)) (*ap.Actor } func deleteActor(ctx context.Context, db *sql.DB, id string) { - if _, err := db.ExecContext(ctx, `delete from notesfts where exists (select 1 from notes where notes.author = ? and notes.rowid = notesfts.rowid)`, id); err != nil { + if _, err := db.ExecContext(ctx, `delete from notesfts where exists (select 1 from notes where notes.author = ? and notes.slug = notesfts.slug)`, id); err != nil { slog.Warn("Failed to delete notes by actor", "id", id, "error", err) } @@ -567,7 +567,8 @@ func (r *Resolver) fetchActor(ctx context.Context, keys [3]httpsig.Key, host, pr if _, err := tx.ExecContext( ctx, - `INSERT INTO persons(id, actor, fetched) VALUES ($1, JSONB($2), UNIXEPOCH()) ON CONFLICT(id) DO UPDATE SET actor = JSONB($2), updated = UNIXEPOCH()`, + `INSERT INTO persons(slug, id, actor, fetched) VALUES ($1, $2, JSONB($3), UNIXEPOCH()) ON CONFLICT(id) DO UPDATE SET actor = JSONB($3), updated = UNIXEPOCH()`, + ap.Slug(actor.ID), actor.ID, bodyString, ); err != nil { diff --git a/front/accept.go b/front/accept.go index 35c3d303..8549bda3 100644 --- a/front/accept.go +++ b/front/accept.go @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/front/text" ) @@ -29,24 +30,24 @@ func (h *Handler) accept(w text.Writer, r *Request, args ...string) { return } - follower := "https://" + args[1] + arg := args[1] tx, err := h.DB.BeginTx(r.Context, nil) if err != nil { - r.Log.Warn("Failed to accept follow request", "follower", follower, "error", err) + r.Log.Warn("Failed to accept follow request", "follower", arg, "error", err) w.Error() return } defer tx.Rollback() - var followID string + var follower, followID string if err := tx.QueryRowContext( r.Context, - `SELECT id FROM follows WHERE followed = ? AND follower = ? AND accepted IS NULL`, + `SELECT follows.follower, follows.id FROM follows JOIN persons ON persons.id = follows.follower WHERE follows.followed = $1 AND (persons.id = 'https://' || $2 OR persons.slug = $2) AND follows.accepted IS NULL`, r.User.ID, - follower, - ).Scan(&followID); errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Failed to fetch follow request to approve", "follower", follower) + arg, + ).Scan(&follower, &followID); errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Failed to fetch follow request to approve", "follower", arg) w.Status(40, "No such follow request") return } else if err != nil { @@ -55,7 +56,7 @@ func (h *Handler) accept(w text.Writer, r *Request, args ...string) { return } - if err := h.Inbox.AcceptFollow(r.Context, r.User, r.Keys[1], follower, followID, tx); err != nil { + if err := h.Inbox.AcceptFollow(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), follower, followID, tx); err != nil { r.Log.Warn("Failed to accept follow request", "follower", follower, "error", err) w.Error() return diff --git a/front/alias.go b/front/alias.go index 71689575..9633cf75 100644 --- a/front/alias.go +++ b/front/alias.go @@ -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. @@ -17,6 +17,7 @@ limitations under the License. package front import ( + "github.com/dimkr/tootik/proof" "net/url" "strings" "time" @@ -72,11 +73,11 @@ func (h *Handler) alias(w text.Writer, r *Request, args ...string) { r.User.AlsoKnownAs.Add(actor.ID) r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to update alias", "error", err) w.Error() return } - w.Redirect("/users/outbox/" + strings.TrimPrefix(actor.ID, "https://")) + w.Redirect("/users/outbox/" + idLink(actor.ID)) } diff --git a/front/avatar.go b/front/avatar.go index 71ae3125..4cd298d0 100644 --- a/front/avatar.go +++ b/front/avatar.go @@ -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. @@ -18,6 +18,7 @@ package front import ( "fmt" + "github.com/dimkr/tootik/proof" "io" "strconv" "time" @@ -144,7 +145,7 @@ func (h *Handler) uploadAvatar(w text.Writer, r *Request, args ...string) { } r.User.Updated.Time = now - if err := h.Inbox.UpdateActorTx(r.Context, tx, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActorTx(r.Context, tx, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to set avatar", "error", err) w.Error() return diff --git a/front/bio.go b/front/bio.go index 21c68141..ab3723f4 100644 --- a/front/bio.go +++ b/front/bio.go @@ -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. @@ -18,6 +18,7 @@ package front import ( "fmt" + "github.com/dimkr/tootik/proof" "time" "unicode/utf8" @@ -94,7 +95,7 @@ func (h *Handler) doSetBio(w text.Writer, r *Request, readInput func(text.Writer r.User.Summary = plain.ToHTML(bio, nil) r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to update bio", "error", err) w.Error() return diff --git a/front/bookmark.go b/front/bookmark.go index 95d18630..c285f758 100644 --- a/front/bookmark.go +++ b/front/bookmark.go @@ -29,7 +29,7 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] tx, err := h.DB.BeginTx(r.Context, nil) if err != nil { @@ -39,13 +39,13 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { } defer tx.Rollback() - var exists int + var postID sql.NullString if err := tx.QueryRowContext( r.Context, - `select exists ( - select 1 from notes + `select ( + select notes.id from notes where - notes.id = $1 and + (notes.id = 'https://' || $1 or notes.slug = $1) and notes.deleted = 0 and ( notes.author = $2 or @@ -57,14 +57,14 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { ) )`, - postID, + arg, r.User.ID, - ).Scan(&exists); err != nil { - r.Log.Warn("Failed to check if bookmarked post exists", "post", postID, "error", err) + ).Scan(&postID); err != nil { + r.Log.Warn("Failed to check if bookmarked post exists", "post", arg, "error", err) w.Error() return - } else if exists == 0 { - r.Log.Info("Post was not found", "post", postID) + } else if !postID.Valid { + r.Log.Info("Post was not found", "post", arg) w.Status(40, "Post not found") return } @@ -80,7 +80,7 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { } if count >= h.Config.MaxBookmarksPerUser { - r.Log.Warn("User has reached bookmarks limit", "post", postID) + r.Log.Warn("User has reached bookmarks limit", "post", postID.String) w.Status(40, "Reached bookmarks limit") return } @@ -94,7 +94,7 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { } } - if _, err := tx.ExecContext(r.Context, `insert into bookmarks(note, by) values(?, ?)`, postID, r.User.ID); err != nil { + if _, err := tx.ExecContext(r.Context, `insert into bookmarks(note, by) values(?, ?)`, postID.String, r.User.ID); err != nil { r.Log.Warn("Failed to insert bookmark", "error", err) w.Error() return @@ -106,5 +106,5 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { return } - w.Redirectf("/users/view/" + args[1]) + w.Redirectf("/users/view/" + arg) } diff --git a/front/bookmarks.go b/front/bookmarks.go index 4c6479ef..27dc7bb3 100644 --- a/front/bookmarks.go +++ b/front/bookmarks.go @@ -35,8 +35,8 @@ func (h *Handler) bookmarks(w text.Writer, r *Request, args ...string) { func(offset int) (*sql.Rows, error) { return h.DB.QueryContext( r.Context, - `select json(page.object), json(authors.actor), null as sharer, page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( - select notes.id, notes.object, notes.author, notes.nreplies, notes.nquotes, notes.nshares, bookmarks.inserted from bookmarks + `select page.slug, json(page.object), json(authors.actor), null as sharer, page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( + select notes.slug, notes.object, notes.author, notes.nreplies, notes.nquotes, notes.nshares, bookmarks.inserted from bookmarks join notes on notes.id = bookmarks.note diff --git a/front/communities.go b/front/communities.go index 23ea80a0..5299ceef 100644 --- a/front/communities.go +++ b/front/communities.go @@ -17,7 +17,6 @@ limitations under the License. package front import ( - "strings" "time" "github.com/dimkr/tootik/dbx" @@ -26,6 +25,7 @@ import ( func (h *Handler) communities(w text.Writer, r *Request, args ...string) { rows, err := dbx.QueryCollectIgnore[struct { + Slug string ID, Username string Last int64 }]( @@ -36,8 +36,8 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { return true }, ` - select u.id, u.username, max(u.inserted) from ( - select persons.id, persons.actor->>'preferredUsername' as username, shares.inserted from shares + select u.slug, u.id, u.username, max(u.inserted) from ( + select persons.slug, persons.id, persons.actor->>'preferredUsername' as username, shares.inserted from shares join persons on persons.id = shares.by @@ -45,7 +45,7 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { persons.host = $1 and persons.actor->>'$.type' = 'Group' union all - select persons.id, persons.actor->>'preferredUsername' as username, notes.inserted from notes + select persons.slug, persons.id, persons.actor->>'preferredUsername' as username, notes.inserted from notes join persons on persons.id = notes.author @@ -54,7 +54,7 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { persons.actor->>'$.type' = 'Group' ) u group by - u.id + u.slug order by max(u.inserted) desc `, @@ -76,9 +76,9 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { for _, row := range rows { if r.User == nil { - w.Linkf("/outbox/"+strings.TrimPrefix(row.ID, "https://"), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) + w.Linkf("/outbox/"+link(row.ID, row.Slug), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) } else { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.ID, "https://"), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) + w.Linkf("/users/outbox/"+link(row.ID, row.Slug), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) } } } diff --git a/front/delete.go b/front/delete.go index 5ea3af07..0e57f841 100644 --- a/front/delete.go +++ b/front/delete.go @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text" @@ -30,28 +31,28 @@ func (h *Handler) delete(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and deleted = 0 and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Attempted to delete a non-existing post", "post", postID, "error", err) + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where (id = 'https://' || $1 or slug = $1) and deleted = 0 and author in (select id from persons where cid = $2)`, arg, ap.Canonical(r.User.ID)).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to delete a non-existing post", "post", arg, "error", err) w.Error() return } else if err != nil { - r.Log.Warn("Failed to fetch post to delete", "post", postID, "error", err) + r.Log.Warn("Failed to fetch post to delete", "post", arg, "error", err) w.Error() return } - if err := h.Inbox.Delete(r.Context, r.User, r.Keys[1], ¬e); err != nil { + if err := h.Inbox.Delete(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), ¬e); err != nil { r.Log.Error("Failed to delete post", "note", note.ID, "error", err) w.Error() return } if r.User == nil { - w.Redirect("/view/" + args[1]) + w.Redirect("/view/" + arg) } else { - w.Redirect("/users/view/" + args[1]) + w.Redirect("/users/view/" + arg) } } diff --git a/front/edit.go b/front/edit.go index 75128665..fed204cb 100644 --- a/front/edit.go +++ b/front/edit.go @@ -32,15 +32,15 @@ func (h *Handler) doEdit(w text.Writer, r *Request, args []string, readInput inp return } - postID := "https://" + args[1] + arg := args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and deleted = 0 and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Attempted to edit non-existing post", "post", postID, "error", err) + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where (id = 'https://' || $1 or slug = $1) and deleted = 0 and author in (select id from persons where cid = $2)`, arg, ap.Canonical(r.User.ID)).Scan(¬e); errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to edit non-existing post", "post", arg, "error", err) w.Error() return } else if err != nil { - r.Log.Warn("Failed to fetch post to edit", "post", postID, "error", err) + r.Log.Warn("Failed to fetch post to edit", "post", arg, "error", err) w.Error() return } @@ -53,7 +53,7 @@ func (h *Handler) doEdit(w text.Writer, r *Request, args []string, readInput inp var edits int if err := h.DB.QueryRowContext(r.Context, `select count(*) from outbox where activity->>'$.object.id' = ? and sender = ? and (activity->>'$.type' = 'Update' or activity->>'$.type' = 'Create')`, note.ID, r.User.ID).Scan(&edits); err != nil { - r.Log.Warn("Failed to count post edits", "post", postID, "error", err) + r.Log.Warn("Failed to count post edits", "post", note.ID, "error", err) w.Error() return } diff --git a/front/follow.go b/front/follow.go index 426a94fd..bdc3229d 100644 --- a/front/follow.go +++ b/front/follow.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 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. @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/front/text" ) @@ -29,17 +30,17 @@ func (h *Handler) follow(w text.Writer, r *Request, args ...string) { return } - followed := "https://" + args[1] + arg := args[1] - var exists int - if err := h.DB.QueryRowContext(r.Context, `select exists (select 1 from persons where id = ?)`, followed).Scan(&exists); err != nil { - r.Log.Warn("Failed to check if user exists", "followed", followed, "error", err) + var followed string + if err := h.DB.QueryRowContext(r.Context, `select id from persons where id = 'https://' || $1 or slug = $1`, arg).Scan(&followed); err != nil && !errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Failed to check if user exists", "followed", arg, "error", err) w.Error() return } - if exists == 0 { - r.Log.Warn("Cannot follow a non-existing user", "followed", followed) + if followed == "" { + r.Log.Warn("Cannot follow a non-existing user", "followed", arg) w.Status(40, "No such user") return } @@ -69,11 +70,11 @@ func (h *Handler) follow(w text.Writer, r *Request, args ...string) { return } - if err := h.Inbox.Follow(r.Context, r.User, r.Keys[1], followed); err != nil { + if err := h.Inbox.Follow(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), followed); err != nil { r.Log.Warn("Failed to follow user", "followed", followed, "error", err) w.Error() return } - w.Redirectf("/users/outbox/" + args[1]) + w.Redirectf("/users/outbox/" + arg) } diff --git a/front/followers.go b/front/followers.go index 5117d3d1..0d56d735 100644 --- a/front/followers.go +++ b/front/followers.go @@ -18,6 +18,7 @@ package front import ( "database/sql" + "github.com/dimkr/tootik/proof" "net/url" "strings" "time" @@ -52,7 +53,7 @@ func (h *Handler) followers(w text.Writer, r *Request, args ...string) { return } - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Warn("Failed to toggle manual approval", "error", err) w.Error() return diff --git a/front/follows.go b/front/follows.go index 3aad0641..71e259a7 100644 --- a/front/follows.go +++ b/front/follows.go @@ -18,7 +18,6 @@ package front import ( "database/sql" - "strings" "time" "github.com/dimkr/tootik/ap" @@ -33,6 +32,7 @@ func (h *Handler) follows(w text.Writer, r *Request, args ...string) { } rows, err := dbx.QueryCollectIgnore[struct { + Slug string Actor ap.Actor Last sql.NullInt64 Accepted sql.NullInt32 @@ -44,7 +44,7 @@ func (h *Handler) follows(w text.Writer, r *Request, args ...string) { return true }, ` - select json(persons.actor), g.inserted/(24*60*60), follows.accepted from + select persons.slug, json(persons.actor), g.inserted/(24*60*60), follows.accepted from follows left join ( @@ -96,17 +96,17 @@ func (h *Handler) follows(w text.Writer, r *Request, args ...string) { displayName := h.getActorDisplayName(&row.Actor) if !row.Accepted.Valid && row.Last.Valid { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s %s - pending approval", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s %s - pending approval", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else if !row.Accepted.Valid { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s - pending approval", displayName) + w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s - pending approval", displayName) } else if row.Last.Valid && row.Accepted.Int32 == 1 { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s %s", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s %s", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else if row.Accepted.Int32 == 1 { - w.Link("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), displayName) + w.Link("/users/outbox/"+link(row.Actor.ID, row.Slug), displayName) } else if row.Last.Valid { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s %s - rejected", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s %s - rejected", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s - rejected", displayName) + w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s - rejected", displayName) } } diff --git a/front/fts.go b/front/fts.go index a466c211..c9e30cbe 100644 --- a/front/fts.go +++ b/front/fts.go @@ -59,10 +59,10 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { rows, err = h.DB.QueryContext( r.Context, ` - select json(notes.object), json(authors.actor), json(groups.actor), notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from - (select rowid, rank from notesfts where content match $1 order by rank limit $2) top + select notes.slug, json(notes.object), json(authors.actor), json(groups.actor), notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from + (select slug, rank from notesfts where content match $1 order by rank limit $2) top join notes on - notes.rowid = top.rowid + notes.slug = top.slug join persons authors on authors.id = notes.author and coalesce(authors.actor->>'$.discoverable', 1) left join notes parent_notes on @@ -87,18 +87,18 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { r.Context, ` with top as ( - select rowid, rank from notesfts where content match $1 order by rank limit $2 + select slug, rank from notesfts where content match $1 order by rank limit $2 ) - select json(u.object), json(authors.actor), json(groups.actor), u.inserted, u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from + select u.slug, json(u.object), json(authors.actor), json(groups.actor), u.inserted, u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( - select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 2 as aud from + select notes.slug, notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 2 as aud from top join notes on - notes.rowid = top.rowid + notes.slug = top.slug where notes.public = 1 union all - select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 1 as aud from + select notes.slug, notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 1 as aud from follows join persons @@ -114,15 +114,15 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { ) join top on - top.rowid = notes.rowid + top.slug = notes.slug where follows.follower = $3 and follows.accepted = 1 union all - select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 0 as aud from + select notes.slug, notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 0 as aud from top join notes on - notes.rowid = top.rowid + notes.slug = top.slug where ( $3 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or diff --git a/front/hashtag.go b/front/hashtag.go index bad4bfe9..91c7fd49 100644 --- a/front/hashtag.go +++ b/front/hashtag.go @@ -32,8 +32,8 @@ func (h *Handler) hashtag(w text.Writer, r *Request, args ...string) { func(offset int) (*sql.Rows, error) { return h.DB.QueryContext( r.Context, - `select json(page.object), json(persons.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( - select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares from + `select page.slug, json(page.object), json(persons.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( + select notes.slug, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares from notes join hashtags on notes.id = hashtags.note diff --git a/front/id.go b/front/id.go new file mode 100644 index 00000000..7463f6ab --- /dev/null +++ b/front/id.go @@ -0,0 +1,35 @@ +/* +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 front + +import ( + "strings" + + "github.com/dimkr/tootik/ap" +) + +func link(id, slug string) string { + if !ap.IsPortable(id) { + return strings.TrimPrefix(id, "https://") + } + + return slug +} + +func idLink(id string) string { + return link(id, ap.Slug(id)) +} diff --git a/front/invitations.go b/front/invitations.go index 5b49c0d2..fd76eea4 100644 --- a/front/invitations.go +++ b/front/invitations.go @@ -21,7 +21,6 @@ import ( "crypto/tls" "database/sql" "fmt" - "strings" "time" "github.com/dimkr/tootik/ap" @@ -39,6 +38,7 @@ func (h *Handler) invitations(w text.Writer, r *Request, args ...string) { rows, err := dbx.QueryCollectIgnore[struct { Code string InviteInsertedSec int64 + ActorSlug sql.NullString Actor sql.Null[ap.Actor] ActorInserted sql.NullInt64 }]( @@ -49,7 +49,7 @@ func (h *Handler) invitations(w text.Writer, r *Request, args ...string) { return true }, ` - SELECT invites.code, invites.inserted, JSON(persons.actor), persons.inserted + SELECT invites.code, invites.inserted, persons.slug, JSON(persons.actor), persons.inserted FROM invites LEFT JOIN persons ON persons.id = invites.invited WHERE invites.inviter = $1 @@ -82,7 +82,7 @@ func (h *Handler) invitations(w text.Writer, r *Request, args ...string) { if row.Actor.Valid { w.Text("Used: " + time.Unix(row.ActorInserted.Int64, 0).Format(time.DateOnly)) - w.Link("/users/outbox/"+strings.TrimPrefix(row.Actor.V.ID, "https://"), "Used by: "+row.Actor.V.PreferredUsername) + w.Link("/users/outbox/"+link(row.Actor.V.ID, row.ActorSlug.String), "Used by: "+row.Actor.V.PreferredUsername) } else { if expires := inserted.Add(h.Config.InvitationTimeout); now.After(expires) { w.Text("Expired: " + expires.Format(time.DateOnly)) diff --git a/front/local.go b/front/local.go index 87acdddf..d66a310f 100644 --- a/front/local.go +++ b/front/local.go @@ -31,15 +31,15 @@ func (h *Handler) local(w text.Writer, r *Request, args ...string) { return h.DB.QueryContext( r.Context, ` - select json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( - select id, author, sharer, inserted from + select page.slug, json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( + select slug, author, sharer, inserted from ( - select notes.id, notes.author, null as sharer, notes.inserted from persons + select notes.slug, notes.author, null as sharer, notes.inserted from persons join notes on notes.author = persons.id where notes.public = 1 and persons.host = $1 union all - select notes.id, notes.author, sharers.id as sharer, shares.inserted from persons sharers + select notes.slug, notes.author, sharers.id as sharer, shares.inserted from persons sharers join shares on shares.by = sharers.id join notes @@ -51,7 +51,7 @@ func (h *Handler) local(w text.Writer, r *Request, args ...string) { offset $3 ) page join notes on - notes.id = page.id + notes.slug = page.slug join persons authors on authors.id = page.author left join notes parent_notes on diff --git a/front/me.go b/front/me.go index ea37833e..2f732f93 100644 --- a/front/me.go +++ b/front/me.go @@ -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. @@ -17,8 +17,6 @@ limitations under the License. package front import ( - "strings" - "github.com/dimkr/tootik/front/text" ) @@ -28,5 +26,5 @@ func me(w text.Writer, r *Request, args ...string) { return } - w.Redirect("/users/outbox/" + strings.TrimPrefix(r.User.ID, "https://")) + w.Redirect("/users/outbox/" + idLink(r.User.ID)) } diff --git a/front/mentions.go b/front/mentions.go index 3c702301..d57e5653 100644 --- a/front/mentions.go +++ b/front/mentions.go @@ -35,7 +35,7 @@ func (h *Handler) mentions(w text.Writer, r *Request, args ...string) { func(offset int) (*sql.Rows, error) { return h.DB.QueryContext( r.Context, - `select json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( + `select notes.slug, json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( select note, author, sharer, inserted from feed where follower = $1 diff --git a/front/metadata.go b/front/metadata.go index 0d593750..3f204b3f 100644 --- a/front/metadata.go +++ b/front/metadata.go @@ -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. @@ -17,6 +17,7 @@ limitations under the License. package front import ( + "github.com/dimkr/tootik/proof" "html" "net/url" "regexp" @@ -126,7 +127,7 @@ func (h *Handler) metadataAdd(w text.Writer, r *Request, args ...string) { r.User.Attachment = append(r.User.Attachment, attachment) r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to add metadata field", "name", attachment.Name, "error", err) w.Error() return @@ -171,7 +172,7 @@ found: r.User.Attachment = slices.Delete(r.User.Attachment, id, id+1) r.User.Updated.Time = time.Now() - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to remove metadata field", "key", key, "id", id, "error", err) w.Error() return diff --git a/front/move.go b/front/move.go index 2572a707..6fa1fd3e 100644 --- a/front/move.go +++ b/front/move.go @@ -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. @@ -17,6 +17,7 @@ limitations under the License. package front import ( + "github.com/dimkr/tootik/proof" "net/url" "strings" "time" @@ -87,11 +88,11 @@ func (h *Handler) move(w text.Writer, r *Request, args ...string) { return } - if err := h.Inbox.Move(r.Context, r.User, r.Keys[1], actor.ID); err != nil { + if err := h.Inbox.Move(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), actor.ID); err != nil { r.Log.Error("Failed to move user", "error", err) w.Error() return } - w.Redirect("/users/outbox/" + strings.TrimPrefix(actor.ID, "https://")) + w.Redirect("/users/outbox/" + idLink(actor.ID)) } diff --git a/front/name.go b/front/name.go index 8df8818d..ecb4bfe6 100644 --- a/front/name.go +++ b/front/name.go @@ -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. @@ -17,6 +17,7 @@ limitations under the License. package front import ( + "github.com/dimkr/tootik/proof" "net/url" "strings" "time" @@ -92,7 +93,7 @@ func (h *Handler) setName(w text.Writer, r *Request, args ...string) { r.User.Name = plainDisplayName r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to update name", "error", err) w.Error() return diff --git a/front/outbox.go b/front/outbox.go index 8245763b..0bc7fd2a 100644 --- a/front/outbox.go +++ b/front/outbox.go @@ -20,7 +20,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "time" "github.com/dimkr/tootik/ap" @@ -52,15 +51,15 @@ func writeMetadataField(field ap.Attachment, w text.Writer) { } func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { - actorID := "https://" + args[1] + arg := args[1] var actor ap.Actor - if err := h.DB.QueryRowContext(r.Context, `select json(actor) from persons where id = ?`, actorID).Scan(&actor); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Info("Person was not found", "actor", actorID) + if err := h.DB.QueryRowContext(r.Context, `select json(actor) from persons where id = 'https://' || $1 or slug = $1`, arg).Scan(&actor); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Info("Person was not found", "actor", arg) w.Status(40, "User not found") return } else if err != nil { - r.Log.Warn("Failed to find person by ID", "actor", actorID, "error", err) + r.Log.Warn("Failed to find person by ID", "actor", arg, "error", err) w.Error() return } @@ -72,29 +71,29 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { return } - r.Log.Info("Viewing outbox", "actor", actorID, "offset", offset) + r.Log.Info("Viewing outbox", "actor", actor.ID, "offset", offset) var rows *sql.Rows if actor.Type == ap.Group && r.User == nil { // unauthenticated users can only see public posts in a group rows, err = h.DB.QueryContext( r.Context, - `select json(page.object), json(authors.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, null from ( - select u.id, u.object, u.author, max(u.inserted) as inserted, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.pulse) as pulse from ( - select notes.id, notes.object, notes.author, shares.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from shares + `select page.slug, json(page.object), json(authors.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, null from ( + select u.slug, u.object, u.author, max(u.inserted) as inserted, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.pulse) as pulse from ( + select notes.slug, notes.object, notes.author, shares.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from shares join notes on notes.id = shares.note where shares.by = $1 and notes.public = 1 and notes.object->>'$.inReplyTo' is null union all - select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from notes + select notes.slug, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from notes where notes.author = $1 and notes.public = 1 and notes.object->>'$.inReplyTo' is null ) u - group by u.id + group by u.slug order by max(pulse) / 86400 desc, nreplies desc, pulse desc limit $2 offset $3 ) page join persons authors on authors.id = page.author order by page.pulse / 86400 desc, page.nreplies desc, page.pulse desc`, - actorID, + actor.ID, h.Config.PostsPerPage, offset, ) @@ -102,9 +101,9 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // users can see public posts in a group and non-public posts if they follow the group rows, err = h.DB.QueryContext( r.Context, - `select json(page.object), json(authors.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, null from ( - select u.id, u.object, u.author, u.inserted, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.pulse) as pulse from ( - select notes.id, notes.object, notes.author, shares.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from shares + `select page.slug, json(page.object), json(authors.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, null from ( + select u.slug, u.object, u.author, u.inserted, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.pulse) as pulse from ( + select notes.slug, notes.object, notes.author, shares.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from shares join notes on notes.id = shares.note where shares.by = $1 and @@ -114,7 +113,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) and notes.object->>'$.inReplyTo' is null union all - select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from notes + select notes.slug, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from notes where notes.author = $1 and ( @@ -123,13 +122,13 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) and notes.object->>'$.inReplyTo' is null ) u - group by u.id + group by u.slug order by max(pulse) / 86400 desc, nreplies desc, pulse desc limit $3 offset $4 ) page join persons authors on authors.id = page.author order by page.pulse / 86400 desc, page.nreplies desc, page.pulse desc`, - actorID, + actor.ID, r.User.ID, h.Config.PostsPerPage, offset, @@ -138,12 +137,12 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // unauthenticated users can only see public posts rows, err = h.DB.QueryContext( r.Context, - `select json(u.object), json(u.actor), json(u.sharer), max(u.inserted), u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( - select notes.id, persons.actor, notes.object, notes.inserted, null as sharer, notes.nreplies, notes.nquotes, notes.nshares from notes + `select u.slug, json(u.object), json(u.actor), json(u.sharer), max(u.inserted), u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( + select notes.slug, persons.actor, notes.object, notes.inserted, null as sharer, notes.nreplies, notes.nquotes, notes.nshares from notes join persons on persons.id = $1 where notes.author = $1 and notes.public = 1 union all - select notes.id, authors.actor, notes.object, shares.inserted, sharers.actor as by, notes.nreplies, notes.nquotes, notes.nshares from + select notes.slug, authors.actor, notes.object, shares.inserted, sharers.actor as by, notes.nreplies, notes.nquotes, notes.nshares from shares join notes on notes.id = shares.note join persons authors on authors.id = notes.author @@ -152,22 +151,22 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) u left join notes parent_notes on parent_notes.id = u.object->>'$.inReplyTo' left join persons parent_authors on parent_authors.id = parent_notes.author - group by u.id + group by u.slug order by max(u.inserted) desc limit $2 offset $3`, - actorID, + actor.ID, h.Config.PostsPerPage, offset, ) - } else if r.User.ID == actorID { + } else if r.User.ID == actor.ID { // users can see all their posts rows, err = h.DB.QueryContext( r.Context, - `select json(u.object), json(u.actor), json(u.sharer), max(u.inserted), u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( - select notes.id, persons.actor, notes.object, notes.inserted, null as sharer, notes.nreplies, notes.nquotes, notes.nshares from notes + `select u.slug, json(u.object), json(u.actor), json(u.sharer), max(u.inserted), u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( + select notes.slug, persons.actor, notes.object, notes.inserted, null as sharer, notes.nreplies, notes.nquotes, notes.nshares from notes join persons on persons.id = notes.author where notes.author = $1 union all - select notes.id, authors.actor, notes.object, shares.inserted, sharers.actor as by, notes.nreplies, notes.nquotes, notes.nshares from shares + select notes.slug, authors.actor, notes.object, shares.inserted, sharers.actor as by, notes.nreplies, notes.nquotes, notes.nshares from shares join notes on notes.id = shares.note join persons authors on authors.id = notes.author join persons sharers on sharers.id = $1 @@ -175,9 +174,9 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) u left join notes parent_notes on parent_notes.id = u.object->>'$.inReplyTo' left join persons parent_authors on parent_authors.id = parent_notes.author - group by u.id + group by u.slug order by max(u.inserted) desc limit $2 offset $3`, - actorID, + actor.ID, h.Config.PostsPerPage, offset, ) @@ -185,12 +184,12 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // users can see only public posts by others, posts to followers if following, and DMs rows, err = h.DB.QueryContext( r.Context, - `select json(page.object), json(authors.actor), json(sharers.actor), page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( - select u.id, u.object, u.author, u.sharer_id, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.inserted) as inserted from ( - select notes.id, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes + `select page.slug, json(page.object), json(authors.actor), json(sharers.actor), page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( + select u.slug, u.object, u.author, u.sharer_id, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.inserted) as inserted from ( + select notes.slug, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes where notes.author = $1 and notes.public = 1 union - select notes.id, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes + select notes.slug, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes where notes.author = $1 and ( $2 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or @@ -198,7 +197,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = $2)) ) union - select notes.id, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes + select notes.slug, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes where notes.public = 0 and notes.author = $1 and @@ -209,12 +208,12 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { )) and exists (select 1 from follows where follower = $2 and followed = $1 and accepted = 1) union all - select notes.id, notes.author, notes.object, shares.inserted, $1 as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from + select notes.slug, notes.author, notes.object, shares.inserted, $1 as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from shares join notes on notes.id = shares.note where shares.by = $1 and notes.public = 1 ) u - group by u.id + group by u.slug order by max(u.inserted) desc limit $3 offset $4 ) page join persons authors on authors.id = page.author @@ -222,14 +221,14 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { left join notes parent_notes on parent_notes.id = page.object->>'$.inReplyTo' left join persons parent_authors on parent_authors.id = parent_notes.author order by page.inserted desc`, - actorID, + actor.ID, r.User.ID, h.Config.PostsPerPage, offset, ) } if err != nil { - r.Log.Warn("Failed to fetch posts", "actor", actorID, "error", err) + r.Log.Warn("Failed to fetch posts", "actor", actor.ID, "error", err) w.Error() return } @@ -266,7 +265,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { } if offset == 0 && actor.MovedTo != "" { - w.Linkf("/users/outbox/"+strings.TrimPrefix(actor.MovedTo, "https://"), "Moved to %s", actor.MovedTo) + w.Linkf("/users/outbox/"+idLink(actor.MovedTo), "Moved to %s", actor.MovedTo) } if offset == 0 { @@ -324,21 +323,21 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { w.Linkf(fmt.Sprintf("%s?%d", r.URL.Path, offset+h.Config.PostsPerPage), "Next page (%d-%d)", offset+h.Config.PostsPerPage, offset+2*h.Config.PostsPerPage) } - if r.User != nil && actorID != r.User.ID { + if r.User != nil && actor.ID != r.User.ID { w.Empty() w.Subtitle("Actions") var accepted sql.NullInt32 - if err := h.DB.QueryRowContext(r.Context, `select accepted from follows where follower = ? and followed = ?`, r.User.ID, actorID).Scan(&accepted); actor.ManuallyApprovesFollowers && errors.Is(err, sql.ErrNoRows) { - w.Linkf("/users/follow/"+strings.TrimPrefix(actorID, "https://"), "⚡ Follow %s (requires approval)", actor.PreferredUsername) + if err := h.DB.QueryRowContext(r.Context, `select accepted from follows where follower = ? and followed = ?`, r.User.ID, actor.ID).Scan(&accepted); actor.ManuallyApprovesFollowers && errors.Is(err, sql.ErrNoRows) { + w.Linkf("/users/follow/"+arg, "⚡ Follow %s (requires approval)", actor.PreferredUsername) } else if errors.Is(err, sql.ErrNoRows) { - w.Linkf("/users/follow/"+strings.TrimPrefix(actorID, "https://"), "⚡ Follow %s", actor.PreferredUsername) + w.Linkf("/users/follow/"+arg, "⚡ Follow %s", actor.PreferredUsername) } else if err != nil { - r.Log.Warn("Failed to check if user is followed", "actor", actorID, "error", err) + r.Log.Warn("Failed to check if user is followed", "actor", actor.ID, "error", err) } else if accepted.Valid && accepted.Int32 == 0 { - w.Linkf("/users/unfollow/"+strings.TrimPrefix(actorID, "https://"), "🔌 Unfollow %s (rejected)", actor.PreferredUsername) + w.Linkf("/users/unfollow/"+arg, "🔌 Unfollow %s (rejected)", actor.PreferredUsername) } else { - w.Linkf("/users/unfollow/"+strings.TrimPrefix(actorID, "https://"), "🔌 Unfollow %s", actor.PreferredUsername) + w.Linkf("/users/unfollow/"+arg, "🔌 Unfollow %s", actor.PreferredUsername) } } } diff --git a/front/portability.go b/front/portability.go index f8b74353..ce0f3d1c 100644 --- a/front/portability.go +++ b/front/portability.go @@ -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. @@ -24,9 +24,11 @@ import ( "strings" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/front/text" + "github.com/dimkr/tootik/proof" ) var gatewayRegex = regexp.MustCompile(`[a-z0-9-]+(?:\.[a-z0-9-]+)+`) @@ -42,14 +44,26 @@ func (h *Handler) portability(w text.Writer, r *Request, args ...string) { return } + var algo, priv string + switch v := proof.SigningKey(r.User.ID, r.Keys).PrivateKey.(type) { + case ed25519.PrivateKey: + algo, priv = "Ed25519", data.EncodeEd25519PrivateKey(v) + case *mldsa44.PrivateKey: + algo, priv = "ML-DSA-44", data.EncodeMLDSA44PrivateKey(v) + default: + r.Log.Warn("Account has no exportable private key", "user", r.User.ID) + w.Error() + return + } + w.OK() w.Title("🚲 Data Portability") w.Subtitle("Private Key") - w.Text("To register this account on another server, use this Ed25519 private key:") + w.Textf("To register this account on another server, use this %s private key:", algo) w.Empty() if r.URL.RawQuery == "show" { - w.Text(data.EncodeEd25519PrivateKey(r.Keys[1].PrivateKey.(ed25519.PrivateKey))) + w.Text(priv) } else { w.Text("********") w.Link("/users/portability?show", "Show") @@ -138,7 +152,7 @@ func (h *Handler) gatewayAdd(w text.Writer, r *Request, args ...string) { r.User.Gateways = append(r.User.Gateways, "https://"+gw) r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to add gateway", "gateway", gw, "error", err) w.Error() return @@ -195,7 +209,7 @@ found: r.User.Gateways = slices.Delete(r.User.Gateways, id, id+1) r.User.Updated.Time = time.Now() - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to remove gateway", "gateway", gw, "id", id, "error", err) w.Error() return diff --git a/front/post.go b/front/post.go index 0ecadcd6..be4913ca 100644 --- a/front/post.go +++ b/front/post.go @@ -20,6 +20,7 @@ import ( "database/sql" "errors" "fmt" + "github.com/dimkr/tootik/proof" "regexp" "strings" "time" @@ -383,9 +384,9 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo note.Updated = now - err = h.Inbox.UpdateNote(r.Context, r.User, r.Keys[1], ¬e) + err = h.Inbox.UpdateNote(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), ¬e) } else { - err = h.Inbox.Create(r.Context, h.Config, ¬e, r.User, r.Keys[1]) + err = h.Inbox.Create(r.Context, h.Config, ¬e, r.User, proof.SigningKey(r.User.ID, r.Keys)) } if err != nil { r.Log.Error("Failed to insert post", "error", err) @@ -398,8 +399,8 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo } if r.URL.Scheme == "titan" { - w.Redirectf("gemini://%s/users/view/%s", h.Domain, strings.TrimPrefix(postID, "https://")) + w.Redirectf("gemini://%s/users/view/%s", h.Domain, idLink(postID)) } else { - w.Redirectf("/users/view/%s", strings.TrimPrefix(postID, "https://")) + w.Redirectf("/users/view/%s", idLink(postID)) } } diff --git a/front/print.go b/front/print.go index 6a714eec..5947d6a5 100644 --- a/front/print.go +++ b/front/print.go @@ -244,6 +244,7 @@ func (h *Handler) getNoteContent(note *ap.Object, compact bool) ([]string, data. func (h *Handler) printCompactNote( w text.Writer, r *Request, + slug string, note *ap.Object, author *ap.Actor, sharer *ap.Actor, @@ -310,9 +311,9 @@ func (h *Handler) printCompactNote( } if r.User == nil { - w.Link("/view/"+strings.TrimPrefix(note.ID, "https://"), title.String()) + w.Link("/view/"+link(note.ID, slug), title.String()) } else { - w.Link("/users/view/"+strings.TrimPrefix(note.ID, "https://"), title.String()) + w.Link("/users/view/"+link(note.ID, slug), title.String()) } for _, line := range contentLines { @@ -322,6 +323,7 @@ func (h *Handler) printCompactNote( func (h *Handler) PrintNotes(w text.Writer, r *Request, rows *sql.Rows, printParentAuthor, printDaySeparators bool, fallback string) int { scanned, err := dbx.CollectRows[struct { + Slug string Note ap.Object Author, Sharer sql.Null[ap.Actor] Published int64 @@ -365,6 +367,7 @@ func (h *Handler) PrintNotes(w text.Writer, r *Request, rows *sql.Rows, printPar h.printCompactNote( w, r, + row.Slug, &row.Note, &row.Author.V, &row.Sharer.V, @@ -379,6 +382,7 @@ func (h *Handler) PrintNotes(w text.Writer, r *Request, rows *sql.Rows, printPar h.printCompactNote( w, r, + row.Slug, &row.Note, &row.Author.V, nil, diff --git a/front/reject.go b/front/reject.go index 0bc5ccf1..7dced80c 100644 --- a/front/reject.go +++ b/front/reject.go @@ -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. @@ -20,6 +20,8 @@ import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" + "github.com/dimkr/tootik/front/text" ) @@ -29,33 +31,33 @@ func (h *Handler) reject(w text.Writer, r *Request, args ...string) { return } - follower := "https://" + args[1] + arg := args[1] tx, err := h.DB.BeginTx(r.Context, nil) if err != nil { - r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) + r.Log.Warn("Failed to reject follow request", "follower", arg, "error", err) w.Error() return } defer tx.Rollback() - var followID string + var follower, followID string if err := tx.QueryRowContext( r.Context, - `SELECT id FROM follows WHERE follower = ? AND followed = ?`, - follower, + `SELECT follows.follower, follows.id FROM follows JOIN persons ON persons.id = follows.follower WHERE (persons.id = 'https://' || $1 OR persons.slug = $1) AND follows.followed = $2`, + arg, r.User.ID, - ).Scan(&followID); errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Failed to fetch follow request to reject", "follower", follower) + ).Scan(&follower, &followID); errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Failed to fetch follow request to reject", "follower", arg) w.Status(40, "No such follow request") return } else if err != nil { - r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) + r.Log.Warn("Failed to reject follow request", "follower", arg, "error", err) w.Error() return } - if err := h.Inbox.Reject(r.Context, r.User, r.Keys[1], follower, followID, tx); err != nil { + if err := h.Inbox.Reject(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), follower, followID, tx); err != nil { r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) w.Error() return diff --git a/front/reply.go b/front/reply.go index 819d83a7..6c818d55 100644 --- a/front/reply.go +++ b/front/reply.go @@ -30,7 +30,7 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b return } - postID := "https://" + args[1] + arg := args[1] var note ap.Object if err := h.DB.QueryRowContext( @@ -39,7 +39,7 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b select json(notes.object) from notes join persons on persons.id = notes.author where - notes.id = $1 and + (notes.id = 'https://' || $1 or notes.slug = $1) and notes.deleted = 0 and ( notes.public = 1 or @@ -63,14 +63,14 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b ) ) `, - postID, + arg, r.User.ID, ).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Post does not exist", "post", postID) + r.Log.Warn("Post does not exist", "post", arg) w.Status(40, "Post not found") return } else if err != nil { - r.Log.Warn("Failed to find post by ID", "post", postID, "error", err) + r.Log.Warn("Failed to find post by ID", "post", arg, "error", err) w.Error() return } @@ -82,12 +82,12 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b r.Log.Info("Quoting post", "post", note.ID) if !note.CanQuote() { - r.Log.Warn("Post cannot be quoted", "post", postID) + r.Log.Warn("Post cannot be quoted", "post", note.ID) w.Status(40, "Post cannot be quoted") return } - note.Quote = postID + note.Quote = note.ID to.Add(note.AttributedTo) cc.Add(r.User.Followers) diff --git a/front/resolve.go b/front/resolve.go index 0b95f1a1..cd72baca 100644 --- a/front/resolve.go +++ b/front/resolve.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 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. @@ -19,7 +19,6 @@ package front import ( "net/url" "regexp" - "strings" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text" @@ -72,5 +71,5 @@ func (h *Handler) resolve(w text.Writer, r *Request, args ...string) { return } - w.Redirect("/users/outbox/" + strings.TrimPrefix(person.ID, "https://")) + w.Redirect("/users/outbox/" + idLink(person.ID)) } diff --git a/front/share.go b/front/share.go index bd3671a1..8782aba8 100644 --- a/front/share.go +++ b/front/share.go @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "time" "github.com/dimkr/tootik/ap" @@ -48,15 +49,15 @@ func (h *Handler) share(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = $1 and deleted = 0 and public = 1 and author != $2 and not exists (select 1 from shares where note = notes.id and by = $2)`, postID, r.User.ID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Attempted to share non-existing post", "post", postID, "error", err) + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where (id = 'https://' || $1 or slug = $1) and deleted = 0 and public = 1 and author != $2 and not exists (select 1 from shares where note = notes.id and by = $2)`, arg, r.User.ID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to share non-existing post", "post", arg, "error", err) w.Error() return } else if err != nil { - r.Log.Warn("Failed to fetch post to share", "post", postID, "error", err) + r.Log.Warn("Failed to fetch post to share", "post", arg, "error", err) w.Error() return } @@ -73,23 +74,23 @@ func (h *Handler) share(w text.Writer, r *Request, args ...string) { tx, err := h.DB.BeginTx(r.Context, nil) if err != nil { - r.Log.Warn("Failed to share post", "post", postID, "error", err) + r.Log.Warn("Failed to share post", "post", note.ID, "error", err) w.Error() return } defer tx.Rollback() - if err := h.Inbox.Announce(r.Context, tx, r.User, r.Keys[1], ¬e); err != nil { - r.Log.Warn("Failed to share post", "post", postID, "error", err) + if err := h.Inbox.Announce(r.Context, tx, r.User, proof.SigningKey(r.User.ID, r.Keys), ¬e); err != nil { + r.Log.Warn("Failed to share post", "post", note.ID, "error", err) w.Error() return } if err := tx.Commit(); err != nil { - r.Log.Warn("Failed to share post", "post", postID, "error", err) + r.Log.Warn("Failed to share post", "post", note.ID, "error", err) w.Error() return } - w.Redirectf("/users/view/" + args[1]) + w.Redirectf("/users/view/" + arg) } diff --git a/front/unbookmark.go b/front/unbookmark.go index ae14df01..adc68694 100644 --- a/front/unbookmark.go +++ b/front/unbookmark.go @@ -1,5 +1,5 @@ /* -Copyright 2024 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. @@ -24,13 +24,13 @@ func (h *Handler) unbookmark(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] - if _, err := h.DB.ExecContext(r.Context, `delete from bookmarks where note = ? and by = ?`, postID, r.User.ID); err != nil { - r.Log.Warn("Failed to delete bookmark", "post", postID, "error", err) + if _, err := h.DB.ExecContext(r.Context, `delete from bookmarks where note in (select id from notes where id = 'https://' || $1 or slug = $1) and by = $2`, arg, r.User.ID); err != nil { + r.Log.Warn("Failed to delete bookmark", "post", arg, "error", err) w.Error() return } - w.Redirectf("/users/view/" + args[1]) + w.Redirectf("/users/view/" + arg) } diff --git a/front/unfollow.go b/front/unfollow.go index 40966430..33bae350 100644 --- a/front/unfollow.go +++ b/front/unfollow.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 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. @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/front/text" ) @@ -29,24 +30,24 @@ func (h *Handler) unfollow(w text.Writer, r *Request, args ...string) { return } - followed := "https://" + args[1] + arg := args[1] - var followID string - if err := h.DB.QueryRowContext(r.Context, `select follows.id from persons join follows on persons.id = follows.followed where persons.id = ? and follows.follower = ?`, followed, r.User.ID).Scan(&followID); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Cannot undo a non-existing follow", "followed", followed, "error", err) + var followed, followID string + if err := h.DB.QueryRowContext(r.Context, `select persons.id, follows.id from persons join follows on persons.id = follows.followed where (persons.id = 'https://' || $1 or persons.slug = $1) and follows.follower = $2`, arg, r.User.ID).Scan(&followed, &followID); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Cannot undo a non-existing follow", "followed", arg, "error", err) w.Status(40, "No such follow") return } else if err != nil { - r.Log.Warn("Failed to find followed user", "followed", followed, "error", err) + r.Log.Warn("Failed to find followed user", "followed", arg, "error", err) w.Error() return } - if err := h.Inbox.Unfollow(r.Context, r.User, r.Keys[1], followed, followID); err != nil { + if err := h.Inbox.Unfollow(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), followed, followID); err != nil { r.Log.Warn("Failed undo follow", "followed", followed, "error", err) w.Error() return } - w.Redirect("/users/outbox/" + args[1]) + w.Redirect("/users/outbox/" + arg) } diff --git a/front/unshare.go b/front/unshare.go index bf7fe33e..572b99b4 100644 --- a/front/unshare.go +++ b/front/unshare.go @@ -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. @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text" @@ -30,24 +31,24 @@ func (h *Handler) unshare(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] var share ap.Activity - if err := h.DB.QueryRowContext(r.Context, `select json(activity) from outbox where activity->>'$.actor' = $1 and sender = $1 and activity->>'$.type' = 'Announce' and activity->>'$.object' = $2`, r.User.ID, postID).Scan(&share); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Attempted to unshare non-existing share", "post", postID, "error", err) + if err := h.DB.QueryRowContext(r.Context, `select json(activity) from outbox where activity->>'$.actor' = $1 and sender = $1 and activity->>'$.type' = 'Announce' and activity->>'$.object' in (select id from notes where id = 'https://' || $2 or slug = $2)`, r.User.ID, arg).Scan(&share); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to unshare non-existing share", "post", arg, "error", err) w.Error() return } else if err != nil { - r.Log.Warn("Failed to fetch share to unshare", "post", postID, "error", err) + r.Log.Warn("Failed to fetch share to unshare", "post", arg, "error", err) w.Error() return } - if err := h.Inbox.Undo(r.Context, r.User, r.Keys[1], &share); err != nil { - r.Log.Warn("Failed to unshare post", "post", postID, "error", err) + if err := h.Inbox.Undo(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), &share); err != nil { + r.Log.Warn("Failed to unshare post", "post", arg, "error", err) w.Error() return } - w.Redirectf("/users/view/" + args[1]) + w.Redirectf("/users/view/" + arg) } diff --git a/front/user/create.go b/front/user/create.go index 3aca138e..1faefee3 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -75,7 +75,7 @@ func insertActor( ) error { if !cfg.DisableIntegrityProofs { var err error - if actor.Proof, err = proof.Create(keys[1], actor); err != nil { + if actor.Proof, err = proof.Create(proof.SigningKey(actor.ID, keys), actor); err != nil { return err } } @@ -88,7 +88,8 @@ func insertActor( if _, err := tx.ExecContext( ctx, - `INSERT OR IGNORE INTO persons (id, actor, rsaprivkey, ed25519privkey, mldsa44seed) VALUES (?, JSONB(?), ?, ?, ?)`, + `INSERT INTO persons (slug, id, actor, rsaprivkey, ed25519privkey, mldsa44seed) VALUES (?, ?, JSONB(?), ?, ?, ?) ON CONFLICT(id) DO NOTHING`, + ap.Slug(actor.ID), actor.ID, actor, x509.MarshalPKCS1PrivateKey(rsaPriv), diff --git a/front/users.go b/front/users.go index 74c25447..d985f691 100644 --- a/front/users.go +++ b/front/users.go @@ -36,7 +36,7 @@ func (h *Handler) users(w text.Writer, r *Request, args ...string) { return h.DB.QueryContext( r.Context, ` - select json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( + select notes.slug, json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( select note, sharer, inserted from feed where follower = $1 diff --git a/front/view.go b/front/view.go index 934dd601..8ec64bd1 100644 --- a/front/view.go +++ b/front/view.go @@ -31,8 +31,6 @@ import ( ) func (h *Handler) view(w text.Writer, r *Request, args ...string) { - postID := "https://" + args[1] - offset, err := getOffset(r.URL) if err != nil { r.Log.Info("Failed to parse query", "error", err) @@ -40,34 +38,38 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { return } - r.Log.Info("Viewing post", "post", postID) + arg := args[1] + + r.Log.Info("Viewing post", "post", arg) var note ap.Object var author ap.Actor + var authorSlug string var group sql.Null[ap.Actor] + var groupSlug sql.NullString if r.User == nil { err = h.DB.QueryRowContext( r.Context, ` - select json(notes.object), json(persons.actor), json(groups.actor) from notes + select json(notes.object), persons.slug, json(persons.actor), groups.slug, json(groups.actor) from notes join persons on persons.id = notes.author - left join (select id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = $1) + left join (select slug, id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) where - notes.id = $1 and + (notes.id = 'https://' || $1 or notes.slug = $1) and notes.public = 1 `, - postID, - ).Scan(¬e, &author, &group) + arg, + ).Scan(¬e, &authorSlug, &author, &groupSlug, &group) } else { err = h.DB.QueryRowContext( r.Context, ` - select json(notes.object), json(persons.actor), json(groups.actor) from notes + select json(notes.object), persons.slug, json(persons.actor), groups.slug, json(groups.actor) from notes join persons on persons.id = notes.author - left join (select id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = $1) + left join (select slug, id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) where - notes.id = $1 and + (notes.id = 'https://' || $1 or notes.slug = $1) and ( notes.public = 1 or notes.author = $2 or @@ -90,20 +92,22 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { ) ) `, - postID, + arg, r.User.ID, - ).Scan(¬e, &author, &group) + ).Scan(¬e, &authorSlug, &author, &groupSlug, &group) } if err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Info("Post was not found", "post", postID) + r.Log.Info("Post was not found", "post", arg) w.Status(40, "Post not found") return } else if err != nil { - r.Log.Info("Failed to find post", "post", postID, "error", err) + r.Log.Info("Failed to find post", "post", arg, "error", err) w.Error() return } + r.Log.Info("Viewing post", "post", note.ID) + w.OK() if offset > 0 { @@ -115,6 +119,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Subtitle("Context") if rows, err := dbx.QueryCollect[struct { + Slug string Note ap.Object Author ap.Actor Depth int @@ -122,25 +127,25 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { r.Context, h.DB, ` - select json(note), json(author), max_depth from + select slug, json(note), json(author), max_depth from ( - with recursive thread(id, note, author, depth) as ( - select notes.id, notes.object as note, persons.actor as author, 1 as depth + with recursive thread(id, slug, note, author, depth) as ( + select notes.id, notes.slug, notes.object as note, persons.actor as author, 1 as depth from notes join persons on persons.id = notes.author where notes.id = ? union all - select notes.id, notes.object as note, persons.actor as author, 0 as depth + select notes.id, notes.slug, notes.object as note, persons.actor as author, 0 as depth from notes join persons on persons.id = notes.author where notes.object->>'$.context' = ? and notes.object->>'$.inReplyTo' is null union all - select notes.id, notes.object as note, persons.actor as author, t.depth + 1 + select notes.id, notes.slug, notes.object as note, persons.actor as author, t.depth + 1 from thread t join notes on notes.id = t.note->>'$.inReplyTo' join persons on persons.id = notes.author ) - select note, author, max(depth) as max_depth from thread group by id order by note->'$.inReplyTo' is null desc, max_depth limit ? + select slug, note, author, max(depth) as max_depth from thread group by slug, id order by note->'$.inReplyTo' is null desc, max_depth limit ? ) order by max_depth desc `, @@ -162,9 +167,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Empty() if r.User == nil { - w.Link("/view/"+strings.TrimPrefix(rows[i].Note.InReplyTo, "https://"), "[1 reply]") + w.Link("/view/"+idLink(rows[i].Note.InReplyTo), "[1 reply]") } else { - w.Link("/users/view/"+strings.TrimPrefix(rows[i].Note.InReplyTo, "https://"), "[1 reply]") + w.Link("/users/view/"+idLink(rows[i].Note.InReplyTo), "[1 reply]") } w.Empty() @@ -172,9 +177,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Empty() if r.User == nil { - w.Linkf("/view/"+strings.TrimPrefix(rows[i].Note.InReplyTo, "https://"), "[%d replies]", rows[0].Depth-rows[i].Depth-1) + w.Linkf("/view/"+idLink(rows[i].Note.InReplyTo), "[%d replies]", rows[0].Depth-rows[i].Depth-1) } else { - w.Linkf("/users/view/"+strings.TrimPrefix(rows[i].Note.InReplyTo, "https://"), "[%d replies]", rows[0].Depth-rows[i].Depth-1) + w.Linkf("/users/view/"+idLink(rows[i].Note.InReplyTo), "[%d replies]", rows[0].Depth-rows[i].Depth-1) } w.Empty() @@ -184,9 +189,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil { - w.Linkf("/view/"+strings.TrimPrefix(rows[i].Note.ID, "https://"), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) + w.Linkf("/view/"+link(rows[i].Note.ID, rows[i].Slug), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) } else { - w.Linkf("/users/view/"+strings.TrimPrefix(rows[i].Note.ID, "https://"), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) + w.Linkf("/users/view/"+link(rows[i].Note.ID, rows[i].Slug), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) } contentLines, _ := h.getCompactNoteContent(&rows[i].Note) @@ -198,17 +203,17 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Empty() if r.User == nil { - w.Linkf("/view/"+strings.TrimPrefix(note.InReplyTo, "https://"), "[%d replies]", rows[0].Depth-1) + w.Linkf("/view/"+idLink(note.InReplyTo), "[%d replies]", rows[0].Depth-1) } else { - w.Linkf("/users/view/"+strings.TrimPrefix(note.InReplyTo, "https://"), "[%d replies]", rows[0].Depth-1) + w.Linkf("/users/view/"+idLink(note.InReplyTo), "[%d replies]", rows[0].Depth-1) } } else if len(rows) == 1 && rows[0].Note.InReplyTo == "" && rows[0].Depth == 2 { w.Empty() if r.User == nil { - w.Link("/view/"+strings.TrimPrefix(note.InReplyTo, "https://"), "[1 reply]") + w.Link("/view/"+idLink(note.InReplyTo), "[1 reply]") } else { - w.Link("/users/view/"+strings.TrimPrefix(note.InReplyTo, "https://"), "[1 reply]") + w.Link("/users/view/"+idLink(note.InReplyTo), "[1 reply]") } } else if rows[i].Depth == 0 { w.Empty() @@ -238,43 +243,43 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil { - links.Store("/outbox/"+strings.TrimPrefix(mentionID, "https://"), mentionUserName) + links.Store("/outbox/"+idLink(mentionID), mentionUserName) } else { - links.Store("/users/outbox/"+strings.TrimPrefix(mentionID, "https://"), mentionUserName) + links.Store("/users/outbox/"+idLink(mentionID), mentionUserName) } } if r.User == nil && group.Valid { - links.Store("/outbox/"+strings.TrimPrefix(group.V.ID, "https://"), "🔄 "+group.V.PreferredUsername) + links.Store("/outbox/"+link(group.V.ID, groupSlug.String), "🔄 "+group.V.PreferredUsername) } else if group.Valid { - links.Store("/users/outbox/"+strings.TrimPrefix(group.V.ID, "https://"), "🔄️ "+group.V.PreferredUsername) + links.Store("/users/outbox/"+link(group.V.ID, groupSlug.String), "🔄️ "+group.V.PreferredUsername) } else if note.IsPublic() { var rows *sql.Rows var err error if r.User == nil { rows, err = h.DB.QueryContext( r.Context, - `select id, username from + `select slug, id, username from ( - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 1 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 1 as rank from shares join notes on notes.id = shares.note join persons on persons.id = shares.by where shares.note = $1 and persons.actor->>'$.type' = 'Group' union all - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 2 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 2 as rank from shares join notes on notes.id = shares.note join persons on persons.id = shares.by where shares.note = $1 union all - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 3 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 3 as rank from shares join persons on persons.id = shares.by where shares.note = $1 and persons.host = $2 union all - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 4 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 4 as rank from shares join persons on persons.id = shares.by where shares.note = $1 and persons.host != $2 ) - group by id + group by slug, id order by min(rank), inserted limit $3`, note.ID, h.Domain, @@ -283,32 +288,32 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } else { rows, err = h.DB.QueryContext( r.Context, - `select id, username from + `select slug, id, username from ( - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 1 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 1 as rank from shares join notes on notes.id = shares.note join persons on persons.id = shares.by where shares.note = $1 and persons.actor->>'$.type' = 'Group' union all - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 2 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 2 as rank from shares join notes on notes.id = shares.note join persons on persons.id = shares.by where shares.note = $1 union all - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 3 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 3 as rank from shares join follows on follows.followed = shares.by join persons on persons.id = follows.followed where shares.note = $1 and follows.follower = $2 and follows.accepted = 1 union all - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 4 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 4 as rank from shares join persons on persons.id = shares.by where shares.note = $1 and persons.host = $3 union all - select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 5 as rank from shares + select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 5 as rank from shares join persons on persons.id = shares.by where shares.note = $1 and persons.host != $3 ) - group by id + group by slug, id order by min(rank), inserted limit $4`, note.ID, r.User.ID, @@ -320,6 +325,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { r.Log.Warn("Failed to query sharers", "error", err) } else if err == nil { if rows, err := dbx.CollectRows[struct { + SharerSlug string SharerID, SharerName string }]( rows, @@ -332,7 +338,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { r.Log.Warn("Failed to query sharers", "error", err) } else { for _, row := range rows { - links.Store("/users/outbox/"+strings.TrimPrefix(row.SharerID, "https://"), "🔄 "+row.SharerName) + links.Store("/users/outbox/"+link(row.SharerID, row.SharerSlug), "🔄 "+row.SharerName) } } rows.Close() @@ -340,6 +346,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if quotes, err := dbx.QueryCollectIgnore[struct { + QuoteSlug string QuoteID, Quoter string }]( r.Context, @@ -349,7 +356,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { return true }, ` - select notes.id, persons.actor->>'$.preferredUsername' from + select notes.slug, notes.id, persons.actor->>'$.preferredUsername' from notes join persons on persons.id = notes.author where notes.object->>'$.quote' = ? order by notes.inserted desc @@ -362,9 +369,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } else { for _, row := range quotes { if r.User == nil { - links.Store("/view/"+strings.TrimPrefix(row.QuoteID, "https://"), "♻️ "+row.Quoter) + links.Store("/view/"+link(row.QuoteID, row.QuoteSlug), "♻️ "+row.Quoter) } else { - links.Store("/users/view/"+strings.TrimPrefix(row.QuoteID, "https://"), "♻️ "+row.Quoter) + links.Store("/users/view/"+link(row.QuoteID, row.QuoteSlug), "♻️ "+row.Quoter) } } } @@ -386,9 +393,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil { - w.Link("/outbox/"+strings.TrimPrefix(author.ID, "https://"), author.PreferredUsername) + w.Link("/outbox/"+link(author.ID, authorSlug), author.PreferredUsername) } else { - w.Link("/users/outbox/"+strings.TrimPrefix(author.ID, "https://"), author.PreferredUsername) + w.Link("/users/outbox/"+link(author.ID, authorSlug), author.PreferredUsername) } for link, alt := range links.All() { @@ -418,11 +425,11 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User != nil && ap.Canonical(note.AttributedTo) == ap.Canonical(r.User.ID) && note.Type != ap.Question && note.Name == "" { // polls and votes cannot be edited - w.Link("/users/edit/"+strings.TrimPrefix(note.ID, "https://"), "🩹 Edit") - w.Link(fmt.Sprintf("titan://%s/users/upload/edit/%s", h.Domain, strings.TrimPrefix(note.ID, "https://")), "Upload edited post") + w.Link("/users/edit/"+arg, "🩹 Edit") + w.Link(fmt.Sprintf("titan://%s/users/upload/edit/%s", h.Domain, arg), "Upload edited post") } if r.User != nil && ap.Canonical(note.AttributedTo) == ap.Canonical(r.User.ID) { - w.Link("/users/delete/"+strings.TrimPrefix(note.ID, "https://"), "💣 Delete") + w.Link("/users/delete/"+arg, "💣 Delete") } if r.User != nil && note.Type == ap.Question && note.Closed == (ap.Time{}) && (note.EndTime == (ap.Time{}) || time.Now().Before(note.EndTime.Time)) { options := note.OneOf @@ -430,7 +437,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { options = note.AnyOf } for _, option := range options { - w.Linkf(fmt.Sprintf("/users/reply/%s?%s", strings.TrimPrefix(note.ID, "https://"), url.PathEscape(option.Name)), "📮 Vote %s", option.Name) + w.Linkf(fmt.Sprintf("/users/reply/%s?%s", arg, url.PathEscape(option.Name)), "📮 Vote %s", option.Name) } } @@ -439,9 +446,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { if err := h.DB.QueryRowContext(r.Context, `select exists (select 1 from shares where note = ? and by = ?)`, note.ID, r.User.ID).Scan(&shared); err != nil { r.Log.Warn("Failed to check if post is shared", "id", note.ID, "error", err) } else if shared == 0 { - w.Link("/users/share/"+strings.TrimPrefix(note.ID, "https://"), "🔁 Share") + w.Link("/users/share/"+arg, "🔁 Share") } else { - w.Link("/users/unshare/"+strings.TrimPrefix(note.ID, "https://"), "🔄️ Unshare") + w.Link("/users/unshare/"+arg, "🔄️ Unshare") } } @@ -450,20 +457,20 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { if err := h.DB.QueryRowContext(r.Context, `select exists (select 1 from bookmarks where note = ? and by = ?)`, note.ID, r.User.ID).Scan(&bookmarked); err != nil { r.Log.Warn("Failed to check if post is bookmarked", "id", note.ID, "error", err) } else if bookmarked == 0 { - w.Link("/users/bookmark/"+strings.TrimPrefix(note.ID, "https://"), "🔖 Bookmark") + w.Link("/users/bookmark/"+arg, "🔖 Bookmark") } else { - w.Link("/users/unbookmark/"+strings.TrimPrefix(note.ID, "https://"), "🔖 Unbookmark") + w.Link("/users/unbookmark/"+arg, "🔖 Unbookmark") } } if r.User != nil { if note.CanQuote() { - w.Link("/users/quote/"+strings.TrimPrefix(note.ID, "https://"), "♻️ Quote") - w.Link(fmt.Sprintf("titan://%s/users/upload/quote/%s", h.Domain, strings.TrimPrefix(note.ID, "https://")), "Upload quote") + w.Link("/users/quote/"+arg, "♻️ Quote") + w.Link(fmt.Sprintf("titan://%s/users/upload/quote/%s", h.Domain, arg), "Upload quote") } - w.Link("/users/reply/"+strings.TrimPrefix(note.ID, "https://"), "💬 Reply") - w.Link(fmt.Sprintf("titan://%s/users/upload/reply/%s", h.Domain, strings.TrimPrefix(note.ID, "https://")), "Upload reply") + w.Link("/users/reply/"+arg, "💬 Reply") + w.Link(fmt.Sprintf("titan://%s/users/upload/reply/%s", h.Domain, arg), "Upload reply") } if note.Type == ap.Question && offset == 0 { @@ -498,25 +505,26 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Subtitle("Quote") var quote ap.Object + var quoteSlug string var quoteAuthor string if err := h.DB.QueryRowContext( r.Context, ` - select json(notes.object), persons.actor->>'$.preferredUsername' from notes + select notes.slug, json(notes.object), persons.actor->>'$.preferredUsername' from notes join persons on persons.id = notes.author where notes.id = ? `, note.Quote, - ).Scan("e, "eAuthor); errors.Is(err, sql.ErrNoRows) { + ).Scan("eSlug, "e, "eAuthor); errors.Is(err, sql.ErrNoRows) { w.Text("[Missing]") } else if err != nil { r.Log.Warn("Failed to scan quote", "error", err) w.Text("[Error]") } else { if r.User == nil { - w.Linkf("/view/"+strings.TrimPrefix(quote.ID, "https://"), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) + w.Linkf("/view/"+link(quote.ID, quoteSlug), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) } else { - w.Linkf("/users/view/"+strings.TrimPrefix(quote.ID, "https://"), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) + w.Linkf("/users/view/"+link(quote.ID, quoteSlug), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) } quoteLines, _ := h.getCompactNoteContent("e) @@ -541,14 +549,14 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { replies, err = h.DB.QueryContext( r.Context, ` - select json(replies.object), json(persons.actor), null as sharer, replies.inserted, replies.nreplies, replies.nquotes, replies.nshares, null from notes join notes replies on replies.object->>'$.inReplyTo' = notes.id + select replies.slug, json(replies.object), json(persons.actor), null as sharer, replies.inserted, replies.nreplies, replies.nquotes, replies.nshares, null from notes join notes replies on replies.object->>'$.inReplyTo' = notes.id left join persons on persons.id = replies.author where notes.id = $1 and replies.public = 1 order by replies.inserted desc limit $2 offset $3 `, - postID, + note.ID, h.Config.RepliesPerPage, offset, ) @@ -556,7 +564,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { replies, err = h.DB.QueryContext( r.Context, ` - select json(replies.object), json(persons.actor), null as sharer, replies.inserted, replies.nreplies, replies.nquotes, replies.nshares, null from + select replies.slug, json(replies.object), json(persons.actor), null as sharer, replies.inserted, replies.nreplies, replies.nquotes, replies.nshares, null from notes join notes replies on replies.object->>'$.inReplyTo' = notes.id left join persons on persons.id = replies.author where @@ -583,7 +591,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { ) order by replies.inserted desc limit $3 offset $4 `, - postID, + note.ID, r.User.ID, h.Config.RepliesPerPage, offset, diff --git a/inbox/forward.go b/inbox/forward.go index 739b07c2..18056a10 100644 --- a/inbox/forward.go +++ b/inbox/forward.go @@ -18,26 +18,25 @@ package inbox import ( "context" - "crypto/ed25519" "database/sql" "errors" "fmt" + "github.com/dimkr/tootik/proof" "log/slog" "time" "github.com/dimkr/tootik/ap" - "github.com/dimkr/tootik/httpsig" ) func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Object, activity *ap.Activity, rawActivity, firstPostID string) (bool, error) { var group ap.Actor - var ed25519PrivKey []byte + var ed25519PrivKey, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, ` - select json(actor), ed25519privkey from + select json(actor), ed25519privkey, mldsa44seed from ( - select persons.actor, ed25519privkey, 1 as rank + select persons.actor, ed25519privkey, mldsa44seed, 1 as rank from persons join notes on @@ -47,7 +46,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj persons.host = $2 and persons.actor->>'$.type' = 'Group' union all - select persons.actor, ed25519privkey, 2 as rank + select persons.actor, ed25519privkey, mldsa44seed, 2 as rank from persons join notes on @@ -58,7 +57,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj persons.host = $2 and persons.actor->>'$.type' = 'Group' union all - select persons.actor, ed25519privkey, 3 as rank + select persons.actor, ed25519privkey, mldsa44seed, 3 as rank from persons join notes on @@ -74,7 +73,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj `, firstPostID, inbox.Domain, - ).Scan(&group, &ed25519PrivKey); err != nil && errors.Is(err, sql.ErrNoRows) { + ).Scan(&group, &ed25519PrivKey, &mldsa44Seed); err != nil && errors.Is(err, sql.ErrNoRows) { return false, nil } else if err != nil { return false, err @@ -106,7 +105,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj } // if this is a new post and we're passing the Create activity to followers, also share the post - if err := inbox.Announce(ctx, tx, &group, httpsig.Key{ID: group.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, note); err != nil { + if err := inbox.Announce(ctx, tx, &group, proof.SigningSeed(&group, ed25519PrivKey, mldsa44Seed), note); err != nil { return true, err } diff --git a/inbox/inbox.go b/inbox/inbox.go index 609ea3e8..476c5aa5 100644 --- a/inbox/inbox.go +++ b/inbox/inbox.go @@ -21,10 +21,10 @@ package inbox import ( "context" - "crypto/ed25519" "database/sql" "errors" "fmt" + "github.com/dimkr/tootik/proof" "log/slog" "net/url" "time" @@ -32,7 +32,6 @@ import ( "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/data" - "github.com/dimkr/tootik/httpsig" "github.com/dimkr/tootik/inbox/note" ) @@ -163,7 +162,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return fmt.Errorf("failed to delete %s: %w", deleted, err) } - if _, err := tx.ExecContext(ctx, `delete from notesfts where rowid = (select rowid from notes where id = ?)`, deleted); err != nil { + if _, err := tx.ExecContext(ctx, `delete from notesfts where slug = (select slug from notes where id = ?)`, deleted); err != nil { return fmt.Errorf("cannot delete %s: %w", deleted, err) } if _, err := tx.ExecContext(ctx, `update notes set object = jsonb_set(jsonb_remove(object, '$.name', '$.summary', '$.tag', '$.attachment', '$.votersCount', '$.oneOf', '$.anyOf'), '$.content', '[deleted]'), deleted = 1 where id = ?`, deleted); err != nil { @@ -180,9 +179,9 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return errors.New("received an invalid follow request") } - var ed25519PrivKey []byte + var ed25519PrivKey, mldsa44Seed []byte var followed ap.Actor - if err := tx.QueryRowContext(ctx, `select ed25519privkey, json(actor) from persons where cid = ? order by ed25519privkey is not null desc limit 1`, ap.Canonical(followedID)).Scan(&ed25519PrivKey, &followed); errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select ed25519privkey, mldsa44seed, json(actor) from persons where cid = ? order by ed25519privkey is not null desc limit 1`, ap.Canonical(followedID)).Scan(&ed25519PrivKey, &mldsa44Seed, &followed); errors.Is(err, sql.ErrNoRows) { var localFollowerID string if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519privkey is not null`, ap.Canonical(activity.Actor)).Scan(&localFollowerID); errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("received an invalid follow request for %s by %s", followedID, activity.Actor) @@ -231,7 +230,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return fmt.Errorf("failed to insert follow %s: %w", activity.ID, err) } - if err := inbox.AcceptFollow(ctx, &followed, httpsig.Key{ID: followed.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, activity.Actor, activity.ID, tx); err != nil { + if err := inbox.AcceptFollow(ctx, &followed, proof.SigningSeed(&followed, ed25519PrivKey, mldsa44Seed), activity.Actor, activity.ID, tx); err != nil { return fmt.Errorf("failed to accept %s: %w", activity.ID, err) } } else { @@ -422,7 +421,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu if post.Content != oldPost.Content { if _, err := tx.ExecContext( ctx, - `update notesfts set content = ? where rowid = (select rowid from notes where id = ?)`, + `update notesfts set content = ? where slug = (select slug from notes where id = ?)`, note.Flatten(post), post.ID, ); err != nil { @@ -444,16 +443,16 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu } var actor ap.Actor - var ed25519PrivKey []byte + var ed25519PrivKey, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, ` - select ed25519privkey, json(actor) from notes + select ed25519privkey, mldsa44seed, json(actor) from notes join persons on persons.id = notes.author where notes.id = ? and notes.public = 1 and notes.deleted = 0 and persons.ed25519privkey is not null `, postID, - ).Scan(&ed25519PrivKey, &actor); errors.Is(err, sql.ErrNoRows) { + ).Scan(&ed25519PrivKey, &mldsa44Seed, &actor); errors.Is(err, sql.ErrNoRows) { slog.Debug("Received invalid quote request", "activity", activity) return nil } else if err != nil { @@ -463,10 +462,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu if err := inbox.acceptRequest( ctx, &actor, - httpsig.Key{ - ID: actor.AssertionMethod[0].ID, - PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey), - }, + proof.SigningSeed(&actor, ed25519PrivKey, mldsa44Seed), activity, tx, ); err != nil { diff --git a/inbox/note/insert.go b/inbox/note/insert.go index bafcdc76..7fadfb39 100644 --- a/inbox/note/insert.go +++ b/inbox/note/insert.go @@ -63,22 +63,24 @@ func Insert(ctx context.Context, tx *sql.Tx, note *ap.Object) error { public = 1 } - var rowID int64 - if err := tx.QueryRowContext( + slug := ap.Slug(note.ID) + + if _, err := tx.ExecContext( ctx, - `INSERT INTO notes (id, author, object, public) VALUES (?, ?, JSONB(?), ?) RETURNING rowid`, + `INSERT INTO notes (slug, id, author, object, public) VALUES (?, ?, ?, JSONB(?), ?)`, + slug, note.ID, note.AttributedTo, ¬e, public, - ).Scan(&rowID); err != nil { + ); err != nil { return fmt.Errorf("failed to insert note %s: %w", note.ID, err) } if _, err := tx.ExecContext( ctx, - `INSERT INTO notesfts (rowid, content) VALUES(?,?)`, - rowID, + `INSERT INTO notesfts (slug, content) VALUES(?,?)`, + slug, Flatten(note), ); err != nil { return fmt.Errorf("failed to insert note %s: %w", note.ID, err) diff --git a/migrations/077_slug.go b/migrations/077_slug.go new file mode 100644 index 00000000..c9536eae --- /dev/null +++ b/migrations/077_slug.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "context" + "database/sql" +) + +func slug(ctx context.Context, domain string, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN slug TEXT`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `UPDATE persons SET slug = id`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ALTER COLUMN slug SET NOT NULL`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN slug TEXT`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `UPDATE notes SET slug = id`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ALTER COLUMN slug SET NOT NULL`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE VIRTUAL TABLE nnotesfts USING fts5(slug, content, tokenize = "unicode61 tokenchars '#@'")`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO nnotesfts(slug, content) SELECT notes.slug, notesfts.content FROM notesfts JOIN notes ON notes.id = notesfts.rowid`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `DROP TABLE notesfts`); err != nil { + return err + } + + _, err := tx.ExecContext(ctx, `ALTER TABLE nnotesfts RENAME TO notesfts`) + return err +} diff --git a/outbox/deleter.go b/outbox/deleter.go index 416a36f6..2c34647f 100644 --- a/outbox/deleter.go +++ b/outbox/deleter.go @@ -18,13 +18,12 @@ package outbox import ( "context" - "crypto/ed25519" "database/sql" + "github.com/dimkr/tootik/proof" "log/slog" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/dbx" - "github.com/dimkr/tootik/httpsig" ) const batchSize = 512 @@ -38,12 +37,13 @@ func (d *Deleter) undoShares(ctx context.Context) (bool, error) { rows, err := dbx.QueryCollect[struct { Sharer ap.Actor Ed25519PrivKey []byte + MLDSA44Seed []byte Share ap.Activity }]( ctx, d.DB, ` - select json(persons.actor), persons.ed25519privkey, json(outbox.activity) from persons + select json(persons.actor), persons.ed25519privkey, persons.mldsa44seed, json(outbox.activity) from persons join shares on shares.by = persons.id join outbox on outbox.activity->>'$.actor' = shares.by and outbox.activity->>'$.object' = shares.note where @@ -64,10 +64,7 @@ func (d *Deleter) undoShares(ctx context.Context) (bool, error) { if err := d.Inbox.Undo( ctx, &row.Sharer, - httpsig.Key{ - ID: row.Sharer.AssertionMethod[0].ID, - PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey), - }, + proof.SigningSeed(&row.Sharer, row.Ed25519PrivKey, row.MLDSA44Seed), &row.Share, ); err != nil { return false, err @@ -88,12 +85,13 @@ func (d *Deleter) deletePosts(ctx context.Context) (bool, error) { rows, err := dbx.QueryCollect[struct { Author ap.Actor Ed25519PrivKey []byte + MLDSA44Seed []byte Note ap.Object }]( ctx, d.DB, ` - select json(persons.actor), persons.ed25519privkey, json(notes.object) from persons + select json(persons.actor), persons.ed25519privkey, persons.mldsa44seed, json(notes.object) from persons join notes on notes.author = persons.id where persons.ttl is not null and @@ -114,10 +112,7 @@ func (d *Deleter) deletePosts(ctx context.Context) (bool, error) { if err := d.Inbox.Delete( ctx, &row.Author, - httpsig.Key{ - ID: row.Author.AssertionMethod[0].ID, - PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey), - }, + proof.SigningSeed(&row.Author, row.Ed25519PrivKey, row.MLDSA44Seed), &row.Note, ); err != nil { return false, err diff --git a/outbox/mover.go b/outbox/mover.go index b97614f1..0f23b54b 100644 --- a/outbox/mover.go +++ b/outbox/mover.go @@ -18,9 +18,9 @@ package outbox import ( "context" - "crypto/ed25519" "database/sql" "fmt" + "github.com/dimkr/tootik/proof" "log/slog" "github.com/dimkr/tootik/ap" @@ -81,6 +81,7 @@ func (m *Mover) Run(ctx context.Context) error { rows, err := dbx.QueryCollectIgnore[struct { Actor ap.Actor Ed25519PrivKey []byte + MLDSA44Seed []byte OldID, NewID, OldFollowID string OnlyRemove bool }]( @@ -91,7 +92,7 @@ func (m *Mover) Run(ctx context.Context) error { return true }, ` - select json(persons.actor), persons.ed25519privkey, old.id, new.id, follows.id, new.id = follows.follower or exists (select 1 from follows where follower = persons.id and followed = new.id) from + select json(persons.actor), persons.ed25519privkey, persons.mldsa44seed, old.id, new.id, follows.id, new.id = follows.follower or exists (select 1 from follows where follower = persons.id and followed = new.id) from persons old join persons new @@ -116,18 +117,16 @@ func (m *Mover) Run(ctx context.Context) error { } for _, row := range rows { - key := httpsig.Key{ID: row.Actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey)} - if row.OnlyRemove { slog.Info("Removing follow of moved actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID) } else { slog.Info("Moving follow", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID) - if err := m.Inbox.Follow(ctx, &row.Actor, key, row.NewID); err != nil { + if err := m.Inbox.Follow(ctx, &row.Actor, proof.SigningSeed(&row.Actor, row.Ed25519PrivKey, row.MLDSA44Seed), row.NewID); err != nil { slog.Warn("Failed to follow new actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID, "error", err) continue } } - if err := m.Inbox.Unfollow(ctx, &row.Actor, key, row.OldID, row.OldFollowID); err != nil { + if err := m.Inbox.Unfollow(ctx, &row.Actor, proof.SigningSeed(&row.Actor, row.Ed25519PrivKey, row.MLDSA44Seed), row.OldID, row.OldFollowID); err != nil { slog.Warn("Failed to unfollow old actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID, "error", err) } } diff --git a/outbox/poller.go b/outbox/poller.go index c9a4359b..3ebaf3b0 100644 --- a/outbox/poller.go +++ b/outbox/poller.go @@ -18,8 +18,8 @@ package outbox import ( "context" - "crypto/ed25519" "database/sql" + "github.com/dimkr/tootik/proof" "log/slog" "time" @@ -43,6 +43,7 @@ func (p *Poller) Run(ctx context.Context) error { Object ap.Object Actor ap.Actor ED25519PrivKey []byte + MLDSA44Seed []byte }]( ctx, p.DB, @@ -52,7 +53,7 @@ func (p *Poller) Run(ctx context.Context) error { }, ` with polls as ( - select notes.id, notes.object, persons.actor as author, persons.ed25519privkey + select notes.id, notes.object, persons.actor as author, persons.ed25519privkey, persons.mldsa44seed from notes join persons on persons.id = notes.author where @@ -69,7 +70,8 @@ func (p *Poller) Run(ctx context.Context) error { coalesce(voter_counts.count, 0), json(polls.object), json(polls.author), - polls.ed25519privkey + polls.ed25519privkey, + polls.mldsa44seed from polls join json_each(polls.object->'$.anyOf') as anyof left join ( @@ -99,7 +101,7 @@ func (p *Poller) Run(ctx context.Context) error { ap.Object Author ap.Actor - Key ed25519.PrivateKey + Key httpsig.Key CurrentVotersCount int64 CurrentVotes map[string]int64 } @@ -111,7 +113,7 @@ func (p *Poller) Run(ctx context.Context) error { info = &poll{ Object: row.Object, Author: row.Actor, - Key: ed25519.NewKeyFromSeed(row.ED25519PrivKey), + Key: proof.SigningSeed(&row.Actor, row.ED25519PrivKey, row.MLDSA44Seed), CurrentVotersCount: row.VotersCount, CurrentVotes: make(map[string]int64, len(row.Object.AnyOf)), } @@ -156,10 +158,7 @@ func (p *Poller) Run(ctx context.Context) error { if err := p.Inbox.UpdateNote( ctx, &poll.Author, - httpsig.Key{ - ID: poll.Author.AssertionMethod[0].ID, - PrivateKey: poll.Key, - }, + poll.Key, &poll.Object, ); err != nil { slog.Warn("Failed to update poll results", "poll", poll.ID, "error", err) diff --git a/proof/key.go b/proof/key.go new file mode 100644 index 00000000..5befe582 --- /dev/null +++ b/proof/key.go @@ -0,0 +1,66 @@ +/* +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 proof creates and verifies integrity proofs. +// +// See https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md for more details. +package proof + +import ( + "crypto/ed25519" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/data" + "github.com/dimkr/tootik/httpsig" +) + +func SigningKey(id string, keys [3]httpsig.Key) httpsig.Key { + m := ap.GatewayURLRegex.FindStringSubmatch(id) + if m == nil { + return keys[1] + } + + pub, err := data.DecodePublicKey(m[1]) + if err != nil { + return keys[1] + } + + if _, ok := pub.(*mldsa44.PublicKey); ok { + return keys[2] + } + + return keys[1] +} + +func SigningSeed(actor *ap.Actor, ed25519Seed, mldsa44Seed []byte) httpsig.Key { + if m := ap.GatewayURLRegex.FindStringSubmatch(actor.ID); m != nil { + if pub, err := data.DecodePublicKey(m[1]); err == nil { + if _, ok := pub.(*mldsa44.PublicKey); ok { + _, priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + return httpsig.Key{ + ID: actor.AssertionMethod[1].ID, + PrivateKey: priv, + } + } + } + } + + return httpsig.Key{ + ID: actor.AssertionMethod[0].ID, + PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed), + } +} diff --git a/test/community_test.go b/test/community_test.go index dc916f63..f7147ee2 100644 --- a/test/community_test.go +++ b/test/community_test.go @@ -41,7 +41,8 @@ func TestCommunity_NewThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -103,7 +104,8 @@ func TestCommunity_NewThreadNotFollowing(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -162,7 +164,8 @@ func TestCommunity_NewThreadNotPublic(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -224,7 +227,8 @@ func TestCommunity_ReplyInThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -316,7 +320,8 @@ func TestCommunity_ReplyInThreadAuthorNotFollowing(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -379,7 +384,8 @@ func TestCommunity_ReplyInThreadSenderNotFollowing(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -412,7 +418,8 @@ func TestCommunity_ReplyInThreadSenderNotFollowing(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"type":"Person","preferredUsername":"erin"}`, ) @@ -475,7 +482,8 @@ func TestCommunity_DuplicateReplyInThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -578,7 +586,8 @@ func TestCommunity_EditedReplyInThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -702,7 +711,8 @@ func TestCommunity_UnknownEditedReplyInThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) diff --git a/test/forward_test.go b/test/forward_test.go index 27d24249..3898c234 100644 --- a/test/forward_test.go +++ b/test/forward_test.go @@ -70,7 +70,8 @@ func TestForward_ReplyToPostByFollower(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -139,7 +140,8 @@ func TestForward_ReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -187,7 +189,8 @@ func TestForward_LocalReplyToLocalPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -260,7 +263,8 @@ func TestForward_ReplyToReplyToPostByFollower(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -325,7 +329,8 @@ func TestForward_ReplyToUnknownPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -390,7 +395,8 @@ func TestForward_ReplyToDM(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -444,7 +450,8 @@ func TestForward_NotFollowingAuthor(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -509,7 +516,8 @@ func TestForward_NotReplyToLocalPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -563,7 +571,8 @@ func TestForward_ReplyToFederatedPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -673,7 +682,8 @@ func TestForward_MaxDepth(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -798,7 +808,8 @@ func TestForward_MaxDepthPlusOne(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -849,7 +860,8 @@ func TestForward_ReplyToLocalPostByLocalFollower(t *testing.T) { assert.Regexp(`^30 /users/view/\S+\r\n$`, whisper) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -890,7 +902,8 @@ func TestForward_EditedReplyToLocalPostByLocalFollower(t *testing.T) { assert.Regexp(`^30 /users/view/\S+\r\n$`, whisper) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -938,7 +951,8 @@ func TestForward_DeletedReplyToLocalPostByLocalFollower(t *testing.T) { assert.Regexp(`^30 /users/view/\S+\r\n$`, whisper) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -1004,7 +1018,8 @@ func TestForward_EditedReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","id":"https://127.0.0.1/user/dan","preferredUsername":"dan"}`, ) @@ -1104,7 +1119,8 @@ func TestForward_ResentEditedReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","id":"https://127.0.0.1/user/dan","preferredUsername":"dan"}`, ) @@ -1215,7 +1231,8 @@ func TestForward_DeletedReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","id":"https://127.0.0.1/user/dan","preferredUsername":"dan"}`, ) @@ -1293,7 +1310,8 @@ func TestForward_DeletedDeletedReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","id":"https://127.0.0.1/user/dan","preferredUsername":"dan"}`, ) diff --git a/test/move_test.go b/test/move_test.go index 15c657b1..0e6809bd 100644 --- a/test/move_test.go +++ b/test/move_test.go @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "net/http" "strings" "testing" @@ -36,7 +37,8 @@ func TestMove_FederatedToFederated(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://::1/user/dan"}`, ) @@ -55,7 +57,8 @@ func TestMove_FederatedToFederated(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://::1/user/dan"), "https://::1/user/dan", `{"id":"https://::1/user/dan","type":"Person","preferredUsername":"dan","alsoKnownAs":"https://127.0.0.1/user/dan"}`, ) @@ -84,14 +87,16 @@ func TestMove_FederatedToFederatedTwoAccounts(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://::1/user/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://::1/user/dan"), "https://::1/user/dan", `{"id":"https://::1/user/dan","type":"Person","preferredUsername":"dan","alsoKnownAs":["https://::1/user/dan","https://127.0.0.1/user/dan"]}`, ) @@ -132,14 +137,16 @@ func TestMove_FederatedToFederatedNotLinked(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://::1/user/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://::1/user/dan"), "https://::1/user/dan", `{"id":"https://::1/user/dan","type":"Person","preferredUsername":"dan"}`, ) @@ -180,7 +187,8 @@ func TestMove_FederatedToLocal(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://localhost.localdomain:8443/user/bob"}`, ) @@ -221,7 +229,8 @@ func TestMove_FederatedToLocalLinked(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://localhost.localdomain:8443/user/bob"}`, ) @@ -265,14 +274,16 @@ func TestMove_FollowingBoth(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://::1/user/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://::1/user/dan"), "https://::1/user/dan", `{"id":"https://::1/user/dan","type":"Person","preferredUsername":"dan","alsoKnownAs":"https://127.0.0.1/user/dan"}`, ) @@ -411,7 +422,8 @@ func TestMove_LocalToFederated(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/alice"), "https://127.0.0.1/user/alice", `{"id":"https://127.0.0.1/user/alice","type":"Person","preferredUsername":"alice","alsoKnownAs":["https://localhost.localdomain:8443/user/alice"]}`, ) @@ -459,7 +471,8 @@ func TestMove_LocalToFederatedNoSourceToTargetAlias(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/alice"), "https://127.0.0.1/user/alice", `{"id":"https://127.0.0.1/user/alice","type":"Person","preferredUsername":"alice","alsoKnownAs":["https://localhost.localdomain:8443/user/alice"]}`, ) @@ -487,7 +500,8 @@ func TestMove_LocalToFederatedNoTargetToSourceAlias(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/alice"), "https://127.0.0.1/user/alice", `{"id":"https://127.0.0.1/user/alice","type":"Person","preferredUsername":"alice","alsoKnownAs":[]}`, ) @@ -520,7 +534,8 @@ func TestMove_LocalToFederatedAlreadyMoved(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/alice"), "https://127.0.0.1/user/alice", `{"id":"https://127.0.0.1/user/alice","type":"Person","preferredUsername":"alice","alsoKnownAs":["https://localhost.localdomain:8443/user/alice"]}`, ) diff --git a/test/outbox_test.go b/test/outbox_test.go index 554592d7..7a03dc29 100644 --- a/test/outbox_test.go +++ b/test/outbox_test.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 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. @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "strings" "testing" @@ -217,14 +218,16 @@ func TestOutbox_PublicPostInGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -253,14 +256,16 @@ func TestOutbox_PublicPostInGroupUnauthenticatedUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -288,14 +293,16 @@ func TestOutbox_PublicPostInGroupAudienceSetByUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -337,14 +344,16 @@ func TestOutbox_PublicPostInGroupAudienceSetByGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -386,14 +395,16 @@ func TestOutbox_PublicPostInGroupDeletedByUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -435,21 +446,24 @@ func TestOutbox_PublicPostInGroupDeletedByAnotherUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"id":"https://127.0.0.1/user/erin","type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -491,14 +505,16 @@ func TestOutbox_PublicPostInGroupDeletedByGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -540,21 +556,24 @@ func TestOutbox_PublicPostInGroupForwardedDelete(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -596,14 +615,16 @@ func TestOutbox_PublicPostInGroupEditedByUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -645,14 +666,16 @@ func TestOutbox_PostToFollowersInGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -686,14 +709,16 @@ func TestOutbox_PostToFollowersInGroupNotFollowingGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -727,14 +752,16 @@ func TestOutbox_PostToFollowersInGroupNotAccepted(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -765,14 +792,16 @@ func TestOutbox_PostToFollowersInGroupFollowingAuthor(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -806,14 +835,16 @@ func TestOutbox_PostToFollowersInGroupUnauthenticatedUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -847,14 +878,16 @@ func TestOutbox_DMInGroupNotFollowingGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -888,14 +921,16 @@ func TestOutbox_DMInGroupAnotherUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) diff --git a/test/poll_test.go b/test/poll_test.go index f3cd3314..ffd68e8c 100644 --- a/test/poll_test.go +++ b/test/poll_test.go @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "strings" "testing" @@ -33,7 +34,8 @@ func TestPoll_TwoOptions(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -66,7 +68,8 @@ func TestPoll_TwoOptionsZeroVotes(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -99,7 +102,8 @@ func TestPoll_TwoOptionsOnlyZeroVotes(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -132,7 +136,8 @@ func TestPoll_OneOption(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -164,7 +169,8 @@ func TestPoll_Vote(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -204,7 +210,8 @@ func TestPoll_VoteClosedPoll(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -238,7 +245,8 @@ func TestPoll_VoteEndedPoll(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -272,7 +280,8 @@ func TestPoll_Reply(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -312,7 +321,8 @@ func TestPoll_ReplyClosedPoll(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -352,7 +362,8 @@ func TestPoll_EditVote(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -395,7 +406,8 @@ func TestPoll_DeleteReply(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -438,7 +450,8 @@ func TestPoll_Update(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -490,7 +503,8 @@ func TestPoll_OldUpdate(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) diff --git a/test/users_test.go b/test/users_test.go index aa5ac0e6..76cbaf49 100644 --- a/test/users_test.go +++ b/test/users_test.go @@ -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. @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "strings" "github.com/dimkr/tootik/inbox" @@ -163,14 +164,16 @@ func TestUsers_PublicPostShared(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"id":"https://127.0.0.1/user/erin","type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) @@ -206,14 +209,16 @@ func TestUsers_PublicPostSharedNotFollowing(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"id":"https://127.0.0.1/user/erin","type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) diff --git a/test/view_test.go b/test/view_test.go index a4107101..303c7837 100644 --- a/test/view_test.go +++ b/test/view_test.go @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "strings" "testing" @@ -307,7 +308,8 @@ func TestView_Update(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -357,7 +359,8 @@ func TestView_OldUpdate(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -507,14 +510,16 @@ func TestView_PostInGroupPublicAndGroupFollowed(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -550,14 +555,16 @@ func TestView_PostInGroupNotPublicAndGroupFollowed(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -593,14 +600,16 @@ func TestView_PostInGroupNotPublicAndGroupFollowedButNotAccepted(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -633,14 +642,16 @@ func TestView_PostInGroupNotPublicAndAuthorFollowed(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -676,14 +687,16 @@ func TestView_PostInGroupNotPublicAndAuthorFollowedButNotAccepted(t *testing.T) assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -716,21 +729,24 @@ func TestView_PostInGroupNotPublicAndGroupFollowedWithReply(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) @@ -776,21 +792,24 @@ func TestView_PostInGroupNotPublicAndGroupFollowedWithPrivateReply(t *testing.T) assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) From 90c88a87f1dadce4490b428ca07de6012535cece Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 16:04:20 +0300 Subject: [PATCH 07/41] x --- front/bookmarks.go | 4 +- front/communities.go | 13 +++---- front/follows.go | 15 ++++---- front/fts.go | 10 ++--- front/hashtag.go | 4 +- front/id.go | 8 +--- front/invitations.go | 5 +-- front/local.go | 10 ++--- front/mentions.go | 2 +- front/outbox.go | 58 ++++++++++++++--------------- front/print.go | 8 +--- front/users.go | 2 +- front/view.go | 88 +++++++++++++++++++++----------------------- 13 files changed, 105 insertions(+), 122 deletions(-) diff --git a/front/bookmarks.go b/front/bookmarks.go index 27dc7bb3..4c6479ef 100644 --- a/front/bookmarks.go +++ b/front/bookmarks.go @@ -35,8 +35,8 @@ func (h *Handler) bookmarks(w text.Writer, r *Request, args ...string) { func(offset int) (*sql.Rows, error) { return h.DB.QueryContext( r.Context, - `select page.slug, json(page.object), json(authors.actor), null as sharer, page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( - select notes.slug, notes.object, notes.author, notes.nreplies, notes.nquotes, notes.nshares, bookmarks.inserted from bookmarks + `select json(page.object), json(authors.actor), null as sharer, page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( + select notes.id, notes.object, notes.author, notes.nreplies, notes.nquotes, notes.nshares, bookmarks.inserted from bookmarks join notes on notes.id = bookmarks.note diff --git a/front/communities.go b/front/communities.go index 5299ceef..32543d04 100644 --- a/front/communities.go +++ b/front/communities.go @@ -25,7 +25,6 @@ import ( func (h *Handler) communities(w text.Writer, r *Request, args ...string) { rows, err := dbx.QueryCollectIgnore[struct { - Slug string ID, Username string Last int64 }]( @@ -36,8 +35,8 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { return true }, ` - select u.slug, u.id, u.username, max(u.inserted) from ( - select persons.slug, persons.id, persons.actor->>'preferredUsername' as username, shares.inserted from shares + select u.id, u.username, max(u.inserted) from ( + select persons.id, persons.actor->>'preferredUsername' as username, shares.inserted from shares join persons on persons.id = shares.by @@ -45,7 +44,7 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { persons.host = $1 and persons.actor->>'$.type' = 'Group' union all - select persons.slug, persons.id, persons.actor->>'preferredUsername' as username, notes.inserted from notes + select persons.id, persons.actor->>'preferredUsername' as username, notes.inserted from notes join persons on persons.id = notes.author @@ -54,7 +53,7 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { persons.actor->>'$.type' = 'Group' ) u group by - u.slug + u.id order by max(u.inserted) desc `, @@ -76,9 +75,9 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { for _, row := range rows { if r.User == nil { - w.Linkf("/outbox/"+link(row.ID, row.Slug), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) + w.Linkf("/outbox/"+idLink(row.ID), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) } else { - w.Linkf("/users/outbox/"+link(row.ID, row.Slug), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) + w.Linkf("/users/outbox/"+idLink(row.ID), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) } } } diff --git a/front/follows.go b/front/follows.go index 71e259a7..7ec94077 100644 --- a/front/follows.go +++ b/front/follows.go @@ -32,7 +32,6 @@ func (h *Handler) follows(w text.Writer, r *Request, args ...string) { } rows, err := dbx.QueryCollectIgnore[struct { - Slug string Actor ap.Actor Last sql.NullInt64 Accepted sql.NullInt32 @@ -44,7 +43,7 @@ func (h *Handler) follows(w text.Writer, r *Request, args ...string) { return true }, ` - select persons.slug, json(persons.actor), g.inserted/(24*60*60), follows.accepted from + select json(persons.actor), g.inserted/(24*60*60), follows.accepted from follows left join ( @@ -96,17 +95,17 @@ func (h *Handler) follows(w text.Writer, r *Request, args ...string) { displayName := h.getActorDisplayName(&row.Actor) if !row.Accepted.Valid && row.Last.Valid { - w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s %s - pending approval", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s %s - pending approval", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else if !row.Accepted.Valid { - w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s - pending approval", displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s - pending approval", displayName) } else if row.Last.Valid && row.Accepted.Int32 == 1 { - w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s %s", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s %s", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else if row.Accepted.Int32 == 1 { - w.Link("/users/outbox/"+link(row.Actor.ID, row.Slug), displayName) + w.Link("/users/outbox/"+idLink(row.Actor.ID), displayName) } else if row.Last.Valid { - w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s %s - rejected", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s %s - rejected", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else { - w.Linkf("/users/outbox/"+link(row.Actor.ID, row.Slug), "%s - rejected", displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s - rejected", displayName) } } diff --git a/front/fts.go b/front/fts.go index c9e30cbe..b2b05aa3 100644 --- a/front/fts.go +++ b/front/fts.go @@ -59,7 +59,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { rows, err = h.DB.QueryContext( r.Context, ` - select notes.slug, json(notes.object), json(authors.actor), json(groups.actor), notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from + select json(notes.object), json(authors.actor), json(groups.actor), notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from (select slug, rank from notesfts where content match $1 order by rank limit $2) top join notes on notes.slug = top.slug @@ -89,16 +89,16 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { with top as ( select slug, rank from notesfts where content match $1 order by rank limit $2 ) - select u.slug, json(u.object), json(authors.actor), json(groups.actor), u.inserted, u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from + select json(u.object), json(authors.actor), json(groups.actor), u.inserted, u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( - select notes.slug, notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 2 as aud from + select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 2 as aud from top join notes on notes.slug = top.slug where notes.public = 1 union all - select notes.slug, notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 1 as aud from + select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 1 as aud from follows join persons @@ -119,7 +119,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { follows.follower = $3 and follows.accepted = 1 union all - select notes.slug, notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 0 as aud from + select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 0 as aud from top join notes on notes.slug = top.slug diff --git a/front/hashtag.go b/front/hashtag.go index 91c7fd49..bad4bfe9 100644 --- a/front/hashtag.go +++ b/front/hashtag.go @@ -32,8 +32,8 @@ func (h *Handler) hashtag(w text.Writer, r *Request, args ...string) { func(offset int) (*sql.Rows, error) { return h.DB.QueryContext( r.Context, - `select page.slug, json(page.object), json(persons.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( - select notes.slug, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares from + `select json(page.object), json(persons.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( + select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares from notes join hashtags on notes.id = hashtags.note diff --git a/front/id.go b/front/id.go index 7463f6ab..7e316ac5 100644 --- a/front/id.go +++ b/front/id.go @@ -22,14 +22,10 @@ import ( "github.com/dimkr/tootik/ap" ) -func link(id, slug string) string { +func idLink(id string) string { if !ap.IsPortable(id) { return strings.TrimPrefix(id, "https://") } - return slug -} - -func idLink(id string) string { - return link(id, ap.Slug(id)) + return ap.Slug(id) } diff --git a/front/invitations.go b/front/invitations.go index fd76eea4..fb59e51b 100644 --- a/front/invitations.go +++ b/front/invitations.go @@ -38,7 +38,6 @@ func (h *Handler) invitations(w text.Writer, r *Request, args ...string) { rows, err := dbx.QueryCollectIgnore[struct { Code string InviteInsertedSec int64 - ActorSlug sql.NullString Actor sql.Null[ap.Actor] ActorInserted sql.NullInt64 }]( @@ -49,7 +48,7 @@ func (h *Handler) invitations(w text.Writer, r *Request, args ...string) { return true }, ` - SELECT invites.code, invites.inserted, persons.slug, JSON(persons.actor), persons.inserted + SELECT invites.code, invites.inserted, JSON(persons.actor), persons.inserted FROM invites LEFT JOIN persons ON persons.id = invites.invited WHERE invites.inviter = $1 @@ -82,7 +81,7 @@ func (h *Handler) invitations(w text.Writer, r *Request, args ...string) { if row.Actor.Valid { w.Text("Used: " + time.Unix(row.ActorInserted.Int64, 0).Format(time.DateOnly)) - w.Link("/users/outbox/"+link(row.Actor.V.ID, row.ActorSlug.String), "Used by: "+row.Actor.V.PreferredUsername) + w.Link("/users/outbox/"+idLink(row.Actor.V.ID), "Used by: "+row.Actor.V.PreferredUsername) } else { if expires := inserted.Add(h.Config.InvitationTimeout); now.After(expires) { w.Text("Expired: " + expires.Format(time.DateOnly)) diff --git a/front/local.go b/front/local.go index d66a310f..87acdddf 100644 --- a/front/local.go +++ b/front/local.go @@ -31,15 +31,15 @@ func (h *Handler) local(w text.Writer, r *Request, args ...string) { return h.DB.QueryContext( r.Context, ` - select page.slug, json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( - select slug, author, sharer, inserted from + select json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( + select id, author, sharer, inserted from ( - select notes.slug, notes.author, null as sharer, notes.inserted from persons + select notes.id, notes.author, null as sharer, notes.inserted from persons join notes on notes.author = persons.id where notes.public = 1 and persons.host = $1 union all - select notes.slug, notes.author, sharers.id as sharer, shares.inserted from persons sharers + select notes.id, notes.author, sharers.id as sharer, shares.inserted from persons sharers join shares on shares.by = sharers.id join notes @@ -51,7 +51,7 @@ func (h *Handler) local(w text.Writer, r *Request, args ...string) { offset $3 ) page join notes on - notes.slug = page.slug + notes.id = page.id join persons authors on authors.id = page.author left join notes parent_notes on diff --git a/front/mentions.go b/front/mentions.go index d57e5653..3c702301 100644 --- a/front/mentions.go +++ b/front/mentions.go @@ -35,7 +35,7 @@ func (h *Handler) mentions(w text.Writer, r *Request, args ...string) { func(offset int) (*sql.Rows, error) { return h.DB.QueryContext( r.Context, - `select notes.slug, json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( + `select json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( select note, author, sharer, inserted from feed where follower = $1 diff --git a/front/outbox.go b/front/outbox.go index 0bc7fd2a..2bd72e75 100644 --- a/front/outbox.go +++ b/front/outbox.go @@ -78,16 +78,16 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // unauthenticated users can only see public posts in a group rows, err = h.DB.QueryContext( r.Context, - `select page.slug, json(page.object), json(authors.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, null from ( - select u.slug, u.object, u.author, max(u.inserted) as inserted, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.pulse) as pulse from ( - select notes.slug, notes.object, notes.author, shares.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from shares + `select json(page.object), json(authors.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, null from ( + select u.id, u.object, u.author, max(u.inserted) as inserted, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.pulse) as pulse from ( + select notes.id, notes.object, notes.author, shares.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from shares join notes on notes.id = shares.note where shares.by = $1 and notes.public = 1 and notes.object->>'$.inReplyTo' is null union all - select notes.slug, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from notes + select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from notes where notes.author = $1 and notes.public = 1 and notes.object->>'$.inReplyTo' is null ) u - group by u.slug + group by u.id order by max(pulse) / 86400 desc, nreplies desc, pulse desc limit $2 offset $3 ) page @@ -101,9 +101,9 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // users can see public posts in a group and non-public posts if they follow the group rows, err = h.DB.QueryContext( r.Context, - `select page.slug, json(page.object), json(authors.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, null from ( - select u.slug, u.object, u.author, u.inserted, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.pulse) as pulse from ( - select notes.slug, notes.object, notes.author, shares.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from shares + `select json(page.object), json(authors.actor), null, page.inserted, page.nreplies, page.nquotes, page.nshares, null from ( + select u.id, u.object, u.author, u.inserted, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.pulse) as pulse from ( + select notes.id, notes.object, notes.author, shares.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from shares join notes on notes.id = shares.note where shares.by = $1 and @@ -113,7 +113,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) and notes.object->>'$.inReplyTo' is null union all - select notes.slug, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from notes + select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, notes.pulse from notes where notes.author = $1 and ( @@ -122,7 +122,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) and notes.object->>'$.inReplyTo' is null ) u - group by u.slug + group by u.id order by max(pulse) / 86400 desc, nreplies desc, pulse desc limit $3 offset $4 ) page @@ -137,12 +137,12 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // unauthenticated users can only see public posts rows, err = h.DB.QueryContext( r.Context, - `select u.slug, json(u.object), json(u.actor), json(u.sharer), max(u.inserted), u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( - select notes.slug, persons.actor, notes.object, notes.inserted, null as sharer, notes.nreplies, notes.nquotes, notes.nshares from notes + `select json(u.object), json(u.actor), json(u.sharer), max(u.inserted), u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( + select notes.id, persons.actor, notes.object, notes.inserted, null as sharer, notes.nreplies, notes.nquotes, notes.nshares from notes join persons on persons.id = $1 where notes.author = $1 and notes.public = 1 union all - select notes.slug, authors.actor, notes.object, shares.inserted, sharers.actor as by, notes.nreplies, notes.nquotes, notes.nshares from + select notes.id, authors.actor, notes.object, shares.inserted, sharers.actor as by, notes.nreplies, notes.nquotes, notes.nshares from shares join notes on notes.id = shares.note join persons authors on authors.id = notes.author @@ -151,7 +151,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) u left join notes parent_notes on parent_notes.id = u.object->>'$.inReplyTo' left join persons parent_authors on parent_authors.id = parent_notes.author - group by u.slug + group by u.id order by max(u.inserted) desc limit $2 offset $3`, actor.ID, h.Config.PostsPerPage, @@ -161,12 +161,12 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // users can see all their posts rows, err = h.DB.QueryContext( r.Context, - `select u.slug, json(u.object), json(u.actor), json(u.sharer), max(u.inserted), u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( - select notes.slug, persons.actor, notes.object, notes.inserted, null as sharer, notes.nreplies, notes.nquotes, notes.nshares from notes + `select json(u.object), json(u.actor), json(u.sharer), max(u.inserted), u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( + select notes.id, persons.actor, notes.object, notes.inserted, null as sharer, notes.nreplies, notes.nquotes, notes.nshares from notes join persons on persons.id = notes.author where notes.author = $1 union all - select notes.slug, authors.actor, notes.object, shares.inserted, sharers.actor as by, notes.nreplies, notes.nquotes, notes.nshares from shares + select notes.id, authors.actor, notes.object, shares.inserted, sharers.actor as by, notes.nreplies, notes.nquotes, notes.nshares from shares join notes on notes.id = shares.note join persons authors on authors.id = notes.author join persons sharers on sharers.id = $1 @@ -174,7 +174,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) u left join notes parent_notes on parent_notes.id = u.object->>'$.inReplyTo' left join persons parent_authors on parent_authors.id = parent_notes.author - group by u.slug + group by u.id order by max(u.inserted) desc limit $2 offset $3`, actor.ID, h.Config.PostsPerPage, @@ -184,12 +184,12 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // users can see only public posts by others, posts to followers if following, and DMs rows, err = h.DB.QueryContext( r.Context, - `select page.slug, json(page.object), json(authors.actor), json(sharers.actor), page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( - select u.slug, u.object, u.author, u.sharer_id, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.inserted) as inserted from ( - select notes.slug, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes + `select json(page.object), json(authors.actor), json(sharers.actor), page.inserted, page.nreplies, page.nquotes, page.nshares, json(parent_authors.actor) from ( + select u.id, u.object, u.author, u.sharer_id, max(u.nreplies) as nreplies, max(u.nquotes) as nquotes, max(u.nshares) as nshares, max(u.inserted) as inserted from ( + select notes.id, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes where notes.author = $1 and notes.public = 1 union - select notes.slug, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes + select notes.id, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes where notes.author = $1 and ( $2 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or @@ -197,7 +197,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = $2)) ) union - select notes.slug, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes + select notes.id, notes.author, notes.object, notes.inserted, null as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from notes where notes.public = 0 and notes.author = $1 and @@ -208,12 +208,12 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { )) and exists (select 1 from follows where follower = $2 and followed = $1 and accepted = 1) union all - select notes.slug, notes.author, notes.object, shares.inserted, $1 as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from + select notes.id, notes.author, notes.object, shares.inserted, $1 as sharer_id, notes.nreplies, notes.nquotes, notes.nshares from shares join notes on notes.id = shares.note where shares.by = $1 and notes.public = 1 ) u - group by u.slug + group by u.id order by max(u.inserted) desc limit $3 offset $4 ) page join persons authors on authors.id = page.author @@ -329,15 +329,15 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { var accepted sql.NullInt32 if err := h.DB.QueryRowContext(r.Context, `select accepted from follows where follower = ? and followed = ?`, r.User.ID, actor.ID).Scan(&accepted); actor.ManuallyApprovesFollowers && errors.Is(err, sql.ErrNoRows) { - w.Linkf("/users/follow/"+arg, "⚡ Follow %s (requires approval)", actor.PreferredUsername) + w.Linkf("/users/follow/"+idLink(actor.ID), "⚡ Follow %s (requires approval)", actor.PreferredUsername) } else if errors.Is(err, sql.ErrNoRows) { - w.Linkf("/users/follow/"+arg, "⚡ Follow %s", actor.PreferredUsername) + w.Linkf("/users/follow/"+idLink(actor.ID), "⚡ Follow %s", actor.PreferredUsername) } else if err != nil { r.Log.Warn("Failed to check if user is followed", "actor", actor.ID, "error", err) } else if accepted.Valid && accepted.Int32 == 0 { - w.Linkf("/users/unfollow/"+arg, "🔌 Unfollow %s (rejected)", actor.PreferredUsername) + w.Linkf("/users/unfollow/"+idLink(actor.ID), "🔌 Unfollow %s (rejected)", actor.PreferredUsername) } else { - w.Linkf("/users/unfollow/"+arg, "🔌 Unfollow %s", actor.PreferredUsername) + w.Linkf("/users/unfollow/"+idLink(actor.ID), "🔌 Unfollow %s", actor.PreferredUsername) } } } diff --git a/front/print.go b/front/print.go index 5947d6a5..3faffaaf 100644 --- a/front/print.go +++ b/front/print.go @@ -244,7 +244,6 @@ func (h *Handler) getNoteContent(note *ap.Object, compact bool) ([]string, data. func (h *Handler) printCompactNote( w text.Writer, r *Request, - slug string, note *ap.Object, author *ap.Actor, sharer *ap.Actor, @@ -311,9 +310,9 @@ func (h *Handler) printCompactNote( } if r.User == nil { - w.Link("/view/"+link(note.ID, slug), title.String()) + w.Link("/view/"+idLink(note.ID), title.String()) } else { - w.Link("/users/view/"+link(note.ID, slug), title.String()) + w.Link("/users/view/"+idLink(note.ID), title.String()) } for _, line := range contentLines { @@ -323,7 +322,6 @@ func (h *Handler) printCompactNote( func (h *Handler) PrintNotes(w text.Writer, r *Request, rows *sql.Rows, printParentAuthor, printDaySeparators bool, fallback string) int { scanned, err := dbx.CollectRows[struct { - Slug string Note ap.Object Author, Sharer sql.Null[ap.Actor] Published int64 @@ -367,7 +365,6 @@ func (h *Handler) PrintNotes(w text.Writer, r *Request, rows *sql.Rows, printPar h.printCompactNote( w, r, - row.Slug, &row.Note, &row.Author.V, &row.Sharer.V, @@ -382,7 +379,6 @@ func (h *Handler) PrintNotes(w text.Writer, r *Request, rows *sql.Rows, printPar h.printCompactNote( w, r, - row.Slug, &row.Note, &row.Author.V, nil, diff --git a/front/users.go b/front/users.go index d985f691..74c25447 100644 --- a/front/users.go +++ b/front/users.go @@ -36,7 +36,7 @@ func (h *Handler) users(w text.Writer, r *Request, args ...string) { return h.DB.QueryContext( r.Context, ` - select notes.slug, json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( + select json(notes.object), json(authors.actor), json(sharers.actor), page.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from ( select note, sharer, inserted from feed where follower = $1 diff --git a/front/view.go b/front/view.go index 8ec64bd1..40f186b0 100644 --- a/front/view.go +++ b/front/view.go @@ -44,30 +44,28 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { var note ap.Object var author ap.Actor - var authorSlug string var group sql.Null[ap.Actor] - var groupSlug sql.NullString if r.User == nil { err = h.DB.QueryRowContext( r.Context, ` - select json(notes.object), persons.slug, json(persons.actor), groups.slug, json(groups.actor) from notes + select json(notes.object), json(persons.actor), json(groups.actor) from notes join persons on persons.id = notes.author - left join (select slug, id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) + left join (select id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) where (notes.id = 'https://' || $1 or notes.slug = $1) and notes.public = 1 `, arg, - ).Scan(¬e, &authorSlug, &author, &groupSlug, &group) + ).Scan(¬e, &author, &group) } else { err = h.DB.QueryRowContext( r.Context, ` - select json(notes.object), persons.slug, json(persons.actor), groups.slug, json(groups.actor) from notes + select json(notes.object), json(persons.actor), json(groups.actor) from notes join persons on persons.id = notes.author - left join (select slug, id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) + left join (select id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) where (notes.id = 'https://' || $1 or notes.slug = $1) and ( @@ -94,7 +92,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { `, arg, r.User.ID, - ).Scan(¬e, &authorSlug, &author, &groupSlug, &group) + ).Scan(¬e, &author, &group) } if err != nil && errors.Is(err, sql.ErrNoRows) { r.Log.Info("Post was not found", "post", arg) @@ -119,7 +117,6 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Subtitle("Context") if rows, err := dbx.QueryCollect[struct { - Slug string Note ap.Object Author ap.Actor Depth int @@ -127,25 +124,25 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { r.Context, h.DB, ` - select slug, json(note), json(author), max_depth from + select json(note), json(author), max_depth from ( - with recursive thread(id, slug, note, author, depth) as ( - select notes.id, notes.slug, notes.object as note, persons.actor as author, 1 as depth + with recursive thread(id, note, author, depth) as ( + select notes.id, notes.object as note, persons.actor as author, 1 as depth from notes join persons on persons.id = notes.author where notes.id = ? union all - select notes.id, notes.slug, notes.object as note, persons.actor as author, 0 as depth + select notes.id, notes.object as note, persons.actor as author, 0 as depth from notes join persons on persons.id = notes.author where notes.object->>'$.context' = ? and notes.object->>'$.inReplyTo' is null union all - select notes.id, notes.slug, notes.object as note, persons.actor as author, t.depth + 1 + select notes.id, notes.object as note, persons.actor as author, t.depth + 1 from thread t join notes on notes.id = t.note->>'$.inReplyTo' join persons on persons.id = notes.author ) - select slug, note, author, max(depth) as max_depth from thread group by slug, id order by note->'$.inReplyTo' is null desc, max_depth limit ? + select note, author, max(depth) as max_depth from thread group by id order by note->'$.inReplyTo' is null desc, max_depth limit ? ) order by max_depth desc `, @@ -189,9 +186,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil { - w.Linkf("/view/"+link(rows[i].Note.ID, rows[i].Slug), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) + w.Linkf("/view/"+idLink(rows[i].Note.ID), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) } else { - w.Linkf("/users/view/"+link(rows[i].Note.ID, rows[i].Slug), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) + w.Linkf("/users/view/"+idLink(rows[i].Note.ID), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) } contentLines, _ := h.getCompactNoteContent(&rows[i].Note) @@ -250,36 +247,36 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil && group.Valid { - links.Store("/outbox/"+link(group.V.ID, groupSlug.String), "🔄 "+group.V.PreferredUsername) + links.Store("/outbox/"+idLink(group.V.ID), "🔄 "+group.V.PreferredUsername) } else if group.Valid { - links.Store("/users/outbox/"+link(group.V.ID, groupSlug.String), "🔄️ "+group.V.PreferredUsername) + links.Store("/users/outbox/"+idLink(group.V.ID), "🔄️ "+group.V.PreferredUsername) } else if note.IsPublic() { var rows *sql.Rows var err error if r.User == nil { rows, err = h.DB.QueryContext( r.Context, - `select slug, id, username from + `select id, username from ( - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 1 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 1 as rank from shares join notes on notes.id = shares.note join persons on persons.id = shares.by where shares.note = $1 and persons.actor->>'$.type' = 'Group' union all - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 2 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 2 as rank from shares join notes on notes.id = shares.note join persons on persons.id = shares.by where shares.note = $1 union all - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 3 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 3 as rank from shares join persons on persons.id = shares.by where shares.note = $1 and persons.host = $2 union all - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 4 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 4 as rank from shares join persons on persons.id = shares.by where shares.note = $1 and persons.host != $2 ) - group by slug, id + group by id order by min(rank), inserted limit $3`, note.ID, h.Domain, @@ -288,32 +285,32 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } else { rows, err = h.DB.QueryContext( r.Context, - `select slug, id, username from + `select id, username from ( - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 1 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 1 as rank from shares join notes on notes.id = shares.note join persons on persons.id = shares.by where shares.note = $1 and persons.actor->>'$.type' = 'Group' union all - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 2 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 2 as rank from shares join notes on notes.id = shares.note join persons on persons.id = shares.by where shares.note = $1 union all - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 3 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 3 as rank from shares join follows on follows.followed = shares.by join persons on persons.id = follows.followed where shares.note = $1 and follows.follower = $2 and follows.accepted = 1 union all - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 4 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 4 as rank from shares join persons on persons.id = shares.by where shares.note = $1 and persons.host = $3 union all - select persons.slug, persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 5 as rank from shares + select persons.id, persons.actor->>'$.preferredUsername' as username, shares.inserted, 5 as rank from shares join persons on persons.id = shares.by where shares.note = $1 and persons.host != $3 ) - group by slug, id + group by id order by min(rank), inserted limit $4`, note.ID, r.User.ID, @@ -325,7 +322,6 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { r.Log.Warn("Failed to query sharers", "error", err) } else if err == nil { if rows, err := dbx.CollectRows[struct { - SharerSlug string SharerID, SharerName string }]( rows, @@ -338,7 +334,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { r.Log.Warn("Failed to query sharers", "error", err) } else { for _, row := range rows { - links.Store("/users/outbox/"+link(row.SharerID, row.SharerSlug), "🔄 "+row.SharerName) + links.Store("/users/outbox/"+idLink(row.SharerID), "🔄 "+row.SharerName) } } rows.Close() @@ -346,7 +342,6 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if quotes, err := dbx.QueryCollectIgnore[struct { - QuoteSlug string QuoteID, Quoter string }]( r.Context, @@ -356,7 +351,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { return true }, ` - select notes.slug, notes.id, persons.actor->>'$.preferredUsername' from + select notes.id, persons.actor->>'$.preferredUsername' from notes join persons on persons.id = notes.author where notes.object->>'$.quote' = ? order by notes.inserted desc @@ -369,9 +364,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } else { for _, row := range quotes { if r.User == nil { - links.Store("/view/"+link(row.QuoteID, row.QuoteSlug), "♻️ "+row.Quoter) + links.Store("/view/"+idLink(row.QuoteID), "♻️ "+row.Quoter) } else { - links.Store("/users/view/"+link(row.QuoteID, row.QuoteSlug), "♻️ "+row.Quoter) + links.Store("/users/view/"+idLink(row.QuoteID), "♻️ "+row.Quoter) } } } @@ -393,9 +388,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil { - w.Link("/outbox/"+link(author.ID, authorSlug), author.PreferredUsername) + w.Link("/outbox/"+idLink(author.ID), author.PreferredUsername) } else { - w.Link("/users/outbox/"+link(author.ID, authorSlug), author.PreferredUsername) + w.Link("/users/outbox/"+idLink(author.ID), author.PreferredUsername) } for link, alt := range links.All() { @@ -505,26 +500,25 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Subtitle("Quote") var quote ap.Object - var quoteSlug string var quoteAuthor string if err := h.DB.QueryRowContext( r.Context, ` - select notes.slug, json(notes.object), persons.actor->>'$.preferredUsername' from notes + select json(notes.object), persons.actor->>'$.preferredUsername' from notes join persons on persons.id = notes.author where notes.id = ? `, note.Quote, - ).Scan("eSlug, "e, "eAuthor); errors.Is(err, sql.ErrNoRows) { + ).Scan("e, "eAuthor); errors.Is(err, sql.ErrNoRows) { w.Text("[Missing]") } else if err != nil { r.Log.Warn("Failed to scan quote", "error", err) w.Text("[Error]") } else { if r.User == nil { - w.Linkf("/view/"+link(quote.ID, quoteSlug), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) + w.Linkf("/view/"+idLink(quote.ID), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) } else { - w.Linkf("/users/view/"+link(quote.ID, quoteSlug), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) + w.Linkf("/users/view/"+idLink(quote.ID), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) } quoteLines, _ := h.getCompactNoteContent("e) @@ -549,7 +543,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { replies, err = h.DB.QueryContext( r.Context, ` - select replies.slug, json(replies.object), json(persons.actor), null as sharer, replies.inserted, replies.nreplies, replies.nquotes, replies.nshares, null from notes join notes replies on replies.object->>'$.inReplyTo' = notes.id + select json(replies.object), json(persons.actor), null as sharer, replies.inserted, replies.nreplies, replies.nquotes, replies.nshares, null from notes join notes replies on replies.object->>'$.inReplyTo' = notes.id left join persons on persons.id = replies.author where notes.id = $1 and @@ -564,7 +558,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { replies, err = h.DB.QueryContext( r.Context, ` - select replies.slug, json(replies.object), json(persons.actor), null as sharer, replies.inserted, replies.nreplies, replies.nquotes, replies.nshares, null from + select json(replies.object), json(persons.actor), null as sharer, replies.inserted, replies.nreplies, replies.nquotes, replies.nshares, null from notes join notes replies on replies.object->>'$.inReplyTo' = notes.id left join persons on persons.id = replies.author where From 49f3a8060325aa7790a9ed1e8b68852250b69d41 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 17:48:01 +0300 Subject: [PATCH 08/41] x --- migrations/076_mldsa44seed.go | 133 ------------------ migrations/076_mldsa44slug.go | 256 ++++++++++++++++++++++++++++++++++ migrations/077_slug.go | 47 ------- 3 files changed, 256 insertions(+), 180 deletions(-) delete mode 100644 migrations/076_mldsa44seed.go create mode 100644 migrations/076_mldsa44slug.go delete mode 100644 migrations/077_slug.go diff --git a/migrations/076_mldsa44seed.go b/migrations/076_mldsa44seed.go deleted file mode 100644 index 1aa85444..00000000 --- a/migrations/076_mldsa44seed.go +++ /dev/null @@ -1,133 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "strings" - - "github.com/cloudflare/circl/sign/mldsa/mldsa44" - "github.com/dimkr/tootik/ap" - "github.com/dimkr/tootik/data" -) - -func mldsa44seed(ctx context.Context, domain string, tx *sql.Tx) error { - if _, err := tx.ExecContext(ctx, `DROP INDEX notescid`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE notes DROP COLUMN cid`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END)`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX notescid ON notes(cid)`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `DROP INDEX personscid`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `DROP INDEX personscidlocal`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE persons DROP COLUMN cid`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END)`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `CREATE INDEX personscid ON persons(cid)`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519privkey IS NOT NULL`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `DROP INDEX outboxhostinserted`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `DROP INDEX outboxcidsender`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox DROP COLUMN host`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox ADD COLUMN host TEXT AS (substr(substr(activity->>'$.id', 9), 0, instr(substr(activity->>'$.id', 9), '/')))`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox DROP COLUMN cid`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE outbox ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN activity->>'$.id' LIKE 'https://%' AND (activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22, CASE WHEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') - 1 ELSE LENGTH(activity->>'$.id') END) WHEN activity->>'$.id' LIKE 'https://%' THEN activity->>'$.id' ELSE NULL END)`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxhostinserted ON outbox(host, inserted)`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxcidsender ON outbox(cid, sender)`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN mldsa44seed TEXT`); err != nil { - return err - } - - rows, err := tx.QueryContext(ctx, `SELECT id, JSON(actor) FROM persons WHERE ed25519privkey IS NOT NULL`, domain) - if err != nil { - return err - } - - defer rows.Close() - - for rows.Next() { - var id string - var actor ap.Actor - if err := rows.Scan(&id, &actor); err != nil { - return err - } - - if len(actor.AssertionMethod) == 0 { - continue - } - - last := actor.AssertionMethod[len(actor.AssertionMethod)-1] - - prefix, ok := strings.CutSuffix(last.ID, "#ed25519-key") - if !ok { - continue - } - - mldsa44Pub, mldsa44Priv, err := mldsa44.GenerateKey(nil) - if err != nil { - return err - } - - actor.AssertionMethod = append(actor.AssertionMethod, ap.AssertionMethod{ - ID: prefix + "#ml-dsa-44-key", - Type: "Multikey", - Controller: last.Controller, - PublicKeyMultibase: data.EncodeMLDSA44Publickey(mldsa44Pub), - }) - - if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = JSONB(?), mldsa44seed = ? WHERE id = ?`, &actor, mldsa44Priv.Seed(), id); err != nil { - return err - } - } - - return rows.Err() -} diff --git a/migrations/076_mldsa44slug.go b/migrations/076_mldsa44slug.go new file mode 100644 index 00000000..dd720dba --- /dev/null +++ b/migrations/076_mldsa44slug.go @@ -0,0 +1,256 @@ +package migrations + +import ( + "context" + "database/sql" + "strings" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/data" +) + +func insertSlugs(ctx context.Context, tx *sql.Tx, query string) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM slugs`); err != nil { + return err + } + + rows, err := tx.QueryContext(ctx, query) + if err != nil { + return err + } + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + + ids = append(ids, id) + } + rows.Close() + + if err := rows.Err(); err != nil { + return err + } + + for _, id := range ids { + if _, err := tx.ExecContext(ctx, `INSERT INTO slugs(id, slug) VALUES(?,?)`, id, ap.Slug(id)); err != nil { + return err + } + } + + return nil +} + +func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE slugs(id TEXT NOT NULL PRIMARY KEY, slug TEXT NOT NULL)`); err != nil { + return err + } + + if err := insertSlugs(ctx, tx, `select id from notes`); err != nil { + return err + } + + for _, stmt := range []string{ + `DROP TRIGGER nshares_insert`, + `DROP TRIGGER nshares_delete`, + + `CREATE TABLE nnotes(slug TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL UNIQUE, author TEXT NOT NULL, object JSONB NOT NULL, public INTEGER NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT 0, host TEXT AS (substr(substr(author, 9), 0, instr(substr(author, 9), '/'))), to0 TEXT AS (object->>'$.to[0]'), to1 TEXT AS (object->>'$.to[1]'), to2 TEXT AS (object->>'$.to[2]'), cc0 TEXT AS (object->>'$.cc[0]'), cc1 TEXT AS (object->>'$.cc[1]'), cc2 TEXT AS (object->>'$.cc[2]'), deleted INTEGER NOT NULL DEFAULT 0, nreplies INTEGER DEFAULT 0, nquotes INTEGER DEFAULT 0, nshares INTEGER DEFAULT 0, pulse INTEGER DEFAULT 0, cid TEXT NOT NULL UNIQUE AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`, + `INSERT INTO nnotes(slug, id, author, object, public, inserted, updated, deleted, nreplies, nquotes, nshares, pulse) SELECT slugs.slug, notes.id, author, object, public, inserted, updated, deleted, nreplies, nquotes, nshares, pulse FROM notes JOIN slugs ON slugs.id = notes.id`, + `CREATE VIRTUAL TABLE nnotesfts USING fts5(slug UNINDEXED, content, tokenize = "unicode61 tokenchars '#@'")`, + `INSERT INTO nnotesfts(slug, content) SELECT slugs.slug, notesfts.content FROM notesfts JOIN notes ON notes.rowid = notesfts.rowid JOIN slugs ON slugs.id = notes.id`, + `DROP TABLE notesfts`, + `ALTER TABLE nnotesfts RENAME TO notesfts`, + `DROP TABLE notes`, + `ALTER TABLE nnotes RENAME TO notes`, + + `CREATE INDEX notesinserted ON notes(inserted)`, + `CREATE INDEX notespublicauthor ON notes(public, author)`, + `CREATE INDEX noteshostinserted on notes(host, inserted)`, + `CREATE INDEX notesaudience ON notes(object->>'$.audience')`, + `CREATE INDEX notesquote ON notes(object->>'$.quote') WHERE object->>'$.quote' IS NOT NULL`, + `CREATE INDEX localnotescontext ON notes(object->>'$.context') WHERE object->>'$.context' IS NOT NULL`, + `CREATE INDEX notesopenpolls ON notes(id) WHERE object->>'$.type' = 'Question' AND deleted = 0 AND object->>'$.closed' IS NULL`, + `CREATE TRIGGER nreplies_insert AFTER INSERT ON notes + WHEN NEW.object->>'$.inReplyTo' IS NOT NULL + BEGIN + UPDATE notes + SET nreplies = nreplies + 1 + WHERE id = NEW.object->>'$.inReplyTo'; + + UPDATE notes + SET pulse = MAX(pulse, NEW.inserted) + WHERE id IN ( + WITH RECURSIVE thread(id, depth) AS ( + SELECT NEW.object->>'$.inReplyTo', 1 + UNION ALL + SELECT n.object->>'$.inReplyTo', t.depth + 1 + FROM notes n + JOIN thread t ON n.id = t.id + WHERE n.object->>'$.inReplyTo' IS NOT NULL AND t.depth <= 5 + ) + SELECT id FROM thread WHERE id IS NOT NULL + ); + END`, + `CREATE TRIGGER nreplies_delete AFTER DELETE ON notes + WHEN OLD.object->>'$.inReplyTo' IS NOT NULL + BEGIN + UPDATE notes + SET nreplies = MAX(0, nreplies - 1) + WHERE id = OLD.object->>'$.inReplyTo'; + END`, + `CREATE TRIGGER nquotes_insert AFTER INSERT ON notes + WHEN NEW.object->>'$.quote' IS NOT NULL + BEGIN + UPDATE notes + SET nquotes = nquotes + 1, pulse = MAX(pulse, NEW.inserted) + WHERE id = NEW.object->>'$.quote'; + END`, + `CREATE TRIGGER nquotes_delete AFTER DELETE ON notes + WHEN OLD.object->>'$.quote' IS NOT NULL + BEGIN + UPDATE notes + SET nquotes = MAX(0, nquotes - 1) + WHERE id = OLD.object->>'$.quote'; + END`, + `CREATE TRIGGER notes_insert AFTER INSERT ON notes + BEGIN + UPDATE notes SET + nreplies = (SELECT COUNT(*) FROM notes WHERE object->>'$.inReplyTo' = NEW.id), + nquotes = (SELECT COUNT(*) FROM notes WHERE object->>'$.quote' = NEW.id), + nshares = (SELECT COUNT(*) FROM shares WHERE note = NEW.id AND shares.by IS NOT NEW.object->>'$.audience'), + pulse = COALESCE( + (SELECT MAX(v) FROM ( + SELECT MAX(replies.inserted) as v FROM notes replies WHERE replies.object->>'$.inReplyTo' = NEW.id + UNION ALL + SELECT MAX(quotes.inserted) as v FROM notes quotes WHERE quotes.object->>'$.quote' = NEW.id + )), + NEW.inserted + ) + WHERE id = NEW.id; + END`, + `CREATE TRIGGER nshares_insert AFTER INSERT ON shares + BEGIN + UPDATE notes + SET nshares = nshares + 1 + WHERE id = NEW.note AND NEW.by IS NOT object->>'$.audience'; + END`, + `CREATE TRIGGER nshares_delete AFTER DELETE ON shares + BEGIN + UPDATE notes + SET nshares = MAX(0, nshares - 1) + WHERE id = OLD.note AND OLD.by IS NOT object->>'$.audience'; + END`, + `CREATE INDEX notesinreplytoinserted ON notes(object->>'$.inReplyTo', inserted) WHERE object->>'$.inReplyTo' IS NOT NULL`, + `CREATE INDEX notesauthorinserted ON notes(author, inserted)`, + `CREATE TRIGGER noteshashtagsinserted AFTER INSERT ON notes + BEGIN + INSERT INTO hashtags (note, hashtag) + SELECT DISTINCT new.id, CASE WHEN SUBSTR(value->>'$.name', 1, 1) = '#' THEN SUBSTR(value->>'$.name', 2) ELSE value->>'$.name' END COLLATE NOCASE + FROM JSON_EACH(new.object->'$.tag') + WHERE new.deleted = 0 AND value->>'$.type' = 'Hashtag' AND value->>'$.name' IS NOT NULL AND value->>'$.name' != ''; + END`, + `CREATE TRIGGER noteshashtagsupdated AFTER UPDATE ON notes + BEGIN + DELETE FROM hashtags WHERE note = new.id AND hashtag NOT IN ( + SELECT CASE WHEN SUBSTR(value->>'$.name', 1, 1) = '#' THEN SUBSTR(value->>'$.name', 2) ELSE value->>'$.name' END COLLATE NOCASE + FROM JSON_EACH(new.object->'$.tag') + WHERE new.deleted = 0 AND value->>'$.type' = 'Hashtag' AND value->>'$.name' IS NOT NULL AND value->>'$.name' != '' + ); + + INSERT INTO hashtags (note, hashtag) + SELECT candidates.note, candidates.hashtag FROM ( + SELECT DISTINCT new.id AS note, CASE WHEN SUBSTR(value->>'$.name', 1, 1) = '#' THEN SUBSTR(value->>'$.name', 2) ELSE value->>'$.name' END COLLATE NOCASE AS hashtag + FROM JSON_EACH(new.object->'$.tag') + WHERE new.deleted = 0 AND value->>'$.type' = 'Hashtag' AND value->>'$.name' IS NOT NULL AND value->>'$.name' != '' + ) candidates + WHERE candidates.hashtag NOT IN (SELECT hashtag COLLATE NOCASE FROM hashtags WHERE hashtags.note = candidates.note); + END`, + `CREATE TRIGGER noteshashtagsdeleted AFTER DELETE ON notes + BEGIN + DELETE FROM hashtags WHERE note = old.id; + END`, + } { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return err + } + } + + if err := insertSlugs(ctx, tx, `select id from persons`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE TABLE npersons(slug TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL UNIQUE, actor JSONB NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT (UNIXEPOCH()), host TEXT AS (substr(substr(id, 9), 0, instr(substr(id, 9), '/'))), fetched INTEGER, ttl INTEGER, rsaprivkey BLOB, ed25519privkey BLOB, mldsa44seed BLOB, cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO npersons(slug, id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey, mldsa44seed) SELECT slugs.slug, persons.id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey, mldsa44seed FROM persons JOIN slugs ON slugs.id = persons.id`); err != nil { + return err + } + + rows, err := tx.QueryContext(ctx, `SELECT id, JSON(actor) FROM npersons WHERE ed25519privkey IS NOT NULL`, domain) + if err != nil { + return err + } + + defer rows.Close() + + for rows.Next() { + var id string + var actor ap.Actor + if err := rows.Scan(&id, &actor); err != nil { + return err + } + + if len(actor.AssertionMethod) == 0 { + continue + } + + last := actor.AssertionMethod[len(actor.AssertionMethod)-1] + + prefix, ok := strings.CutSuffix(last.ID, "#ed25519-key") + if !ok { + continue + } + + mldsa44Pub, mldsa44Priv, err := mldsa44.GenerateKey(nil) + if err != nil { + return err + } + + actor.AssertionMethod = append(actor.AssertionMethod, ap.AssertionMethod{ + ID: prefix + "#ml-dsa-44-key", + Type: "Multikey", + Controller: last.Controller, + PublicKeyMultibase: data.EncodeMLDSA44Publickey(mldsa44Pub), + }) + + if _, err := tx.ExecContext(ctx, `UPDATE npersons SET actor = JSONB(?), mldsa44seed = ? WHERE id = ?`, &actor, mldsa44Priv.Seed(), id); err != nil { + return err + } + } + + if err := rows.Err(); err != nil { + return err + } + + for _, stmt := range []string{ + `DROP TABLE persons`, + `ALTER TABLE npersons RENAME TO persons`, + `CREATE INDEX personstypeid ON persons(actor->>'$.type', id)`, + `CREATE INDEX personsmovedto ON persons(actor->>'$.movedTo') WHERE actor->>'$.movedTo' IS NOT NULL`, + `CREATE UNIQUE INDEX personspreferredusernamehosttype ON persons(actor->>'$.preferredUsername', host, actor->>'$.type')`, + `CREATE INDEX personscid ON persons(cid)`, + `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519privkey IS NOT NULL`, + } { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return err + } + } + + _, err = tx.ExecContext(ctx, `DROP TABLE slugs`) + return err +} diff --git a/migrations/077_slug.go b/migrations/077_slug.go deleted file mode 100644 index c9536eae..00000000 --- a/migrations/077_slug.go +++ /dev/null @@ -1,47 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" -) - -func slug(ctx context.Context, domain string, tx *sql.Tx) error { - if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN slug TEXT`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `UPDATE persons SET slug = id`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ALTER COLUMN slug SET NOT NULL`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN slug TEXT`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `UPDATE notes SET slug = id`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ALTER COLUMN slug SET NOT NULL`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `CREATE VIRTUAL TABLE nnotesfts USING fts5(slug, content, tokenize = "unicode61 tokenchars '#@'")`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `INSERT INTO nnotesfts(slug, content) SELECT notes.slug, notesfts.content FROM notesfts JOIN notes ON notes.id = notesfts.rowid`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `DROP TABLE notesfts`); err != nil { - return err - } - - _, err := tx.ExecContext(ctx, `ALTER TABLE nnotesfts RENAME TO notesfts`) - return err -} From 17151bb28b5a496bcb049d927f20266626ca8080 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 18:09:17 +0300 Subject: [PATCH 09/41] x --- cmd/tootik/main.go | 16 ++++++++-------- data/garbage.go | 2 +- fed/apgateway.go | 26 +++++++++++++------------- fed/deliver.go | 4 ++-- fed/followers.go | 6 +++--- fed/inbox.go | 6 +++--- front/gemini/gemini.go | 6 +++--- front/post.go | 6 +++--- front/shell.go | 8 ++++---- front/user/app.go | 8 ++++---- front/user/create.go | 2 +- inbox/backfill.go | 4 ++-- inbox/forward.go | 16 ++++++++-------- inbox/inbox.go | 22 +++++++++++----------- migrations/076_mldsa44slug.go | 8 ++++---- outbox/deleter.go | 4 ++-- outbox/mover.go | 2 +- outbox/poller.go | 4 ++-- 18 files changed, 75 insertions(+), 75 deletions(-) diff --git a/cmd/tootik/main.go b/cmd/tootik/main.go index cc76babd..a3ad9517 100644 --- a/cmd/tootik/main.go +++ b/cmd/tootik/main.go @@ -245,19 +245,19 @@ func main() { defer tx.Rollback() var actor ap.Actor - var ed25519PrivKey, mldsa44Seed []byte + var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, - `select json(actor), ed25519privkey, mldsa44seed from persons where ed25519privkey is not null and actor->>'$.preferredUsername' = ?`, + `select json(actor), ed25519seed, mldsa44seed from persons where ed25519seed is not null and actor->>'$.preferredUsername' = ?`, flag.Arg(1), - ).Scan(&actor, &ed25519PrivKey, &mldsa44Seed); err != nil { + ).Scan(&actor, &ed25519Seed, &mldsa44Seed); err != nil { panic(err) } actor.Summary = tplain.ToHTML(string(summary), nil) actor.Updated.Time = time.Now() - if err := localInbox.UpdateActorTx(ctx, tx, &actor, proof.SigningSeed(&actor, ed25519PrivKey, mldsa44Seed)); err != nil { + if err := localInbox.UpdateActorTx(ctx, tx, &actor, proof.SigningSeed(&actor, ed25519Seed, mldsa44Seed)); err != nil { panic(err) } @@ -287,12 +287,12 @@ func main() { userName := flag.Arg(1) var actor ap.Actor - var ed25519PrivKey, mldsa44Seed []byte + var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, - `select select json(actor), ed25519privkey from persons where ed25519privkey is not null and actor->>'$.preferredUsername' = ?`, + `select select json(actor), ed25519seed from persons where ed25519seed is not null and actor->>'$.preferredUsername' = ?`, userName, - ).Scan(&actor, &ed25519PrivKey, &mldsa44Seed); err != nil { + ).Scan(&actor, &ed25519Seed, &mldsa44Seed); err != nil { panic(err) } @@ -311,7 +311,7 @@ func main() { }) actor.Updated.Time = now - if err := localInbox.UpdateActorTx(ctx, tx, &actor, proof.SigningSeed(&actor, ed25519PrivKey, mldsa44Seed)); err != nil { + if err := localInbox.UpdateActorTx(ctx, tx, &actor, proof.SigningSeed(&actor, ed25519Seed, mldsa44Seed)); err != nil { panic(err) } diff --git a/data/garbage.go b/data/garbage.go index 5fb15fde..ef88fc3d 100644 --- a/data/garbage.go +++ b/data/garbage.go @@ -67,7 +67,7 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove old posts: %w", err) } - if _, err := gc.DB.ExecContext(ctx, `delete from persons where updated < ? and ed25519privkey is null and not exists (select 1 from follows where followed = persons.id) and not exists (select 1 from follows where follower = persons.id) and not exists (select 1 from notes where notes.author = persons.id) and not exists (select 1 from shares where shares.by = persons.id)`, now.Add(-gc.Config.ActorTTL).Unix()); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from persons where updated < ? and ed25519seed is null and not exists (select 1 from follows where followed = persons.id) and not exists (select 1 from follows where follower = persons.id) and not exists (select 1 from notes where notes.author = persons.id) and not exists (select 1 from shares where shares.by = persons.id)`, now.Add(-gc.Config.ActorTTL).Unix()); err != nil { return fmt.Errorf("failed to remove idle actors: %w", err) } diff --git a/fed/apgateway.go b/fed/apgateway.go index 1641d9e0..dba8f7d5 100644 --- a/fed/apgateway.go +++ b/fed/apgateway.go @@ -44,8 +44,8 @@ var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key: func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Request, did string) { var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte - if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where cid = 'ap://' || ? || '/actor' and ed25519privkey is not null`, did).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte + if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519seed, mldsa44seed from persons where cid = 'ap://' || ? || '/actor' and ed25519seed is not null`, did).Scan(&actor, &rsaPrivKeyDer, &ed25519Seed, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Debug("Receiving user does not exist", "did", did) w.WriteHeader(http.StatusNotFound) return @@ -66,7 +66,7 @@ func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Reque l.doHandleInbox(w, r, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }) } @@ -163,7 +163,7 @@ func (l *Listener) handleApGatewayInboxGet(w http.ResponseWriter, r *http.Reques var inbox string if err := l.DB.QueryRowContext( r.Context(), - `select actor->>'$.inbox' from persons where cid = 'ap://' || ? || '/actor' and ed25519privkey is not null`, + `select actor->>'$.inbox' from persons where cid = 'ap://' || ? || '/actor' and ed25519seed is not null`, did, ).Scan(&inbox); errors.Is(err, sql.ErrNoRows) { slog.Warn("Inbox does not exist", "did", did) @@ -317,12 +317,12 @@ func (l *Listener) handleApGatewayContext(w http.ResponseWriter, r *http.Request var postID string var author ap.Actor - var ed25519PrivKey, mldsa44Seed []byte + var ed25519Seed, mldsa44Seed []byte if err := l.DB.QueryRowContext( r.Context(), - `select notes.id, notes.author, json(persons.actor), persons.ed25519privkey, persons.mldsa44seed from notes join persons on persons.id = notes.author where notes.object->>'$.context' = ? and notes.object->>'$.inReplyTo' is null and persons.ed25519privkey is not null`, + `select notes.id, notes.author, json(persons.actor), persons.ed25519seed, persons.mldsa44seed from notes join persons on persons.id = notes.author where notes.object->>'$.context' = ? and notes.object->>'$.inReplyTo' is null and persons.ed25519seed is not null`, contextID, - ).Scan(&postID, &collection.AttributedTo, &author, &ed25519PrivKey, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { + ).Scan(&postID, &collection.AttributedTo, &author, &ed25519Seed, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Warn("Context does not exist", "id", contextID) w.WriteHeader(http.StatusNotFound) return @@ -369,7 +369,7 @@ func (l *Listener) handleApGatewayContext(w http.ResponseWriter, r *http.Request var err error collection.Proof, err = proof.Create( - proof.SigningSeed(&author, ed25519PrivKey, mldsa44Seed), + proof.SigningSeed(&author, ed25519Seed, mldsa44Seed), collection, ) if err != nil { @@ -407,7 +407,7 @@ func (l *Listener) handleApGatewayOutboxGet(w http.ResponseWriter, r *http.Reque var outbox string if err := l.DB.QueryRowContext( r.Context(), - `select actor->>'$.outbox' from persons where cid = ? and ed25519privkey is not null`, + `select actor->>'$.outbox' from persons where cid = ? and ed25519seed is not null`, actorCID, ).Scan(&outbox); errors.Is(err, sql.ErrNoRows) { slog.Warn("Outbox does not exist", "did", did) @@ -608,7 +608,7 @@ func (l *Listener) fetchSenderFollowers( var actor ap.Actor if err := l.DB.QueryRowContext( r.Context(), - `select json(actor) from persons where cid = 'ap://' || ? || '/actor' and ed25519privkey is not null`, + `select json(actor) from persons where cid = 'ap://' || ? || '/actor' and ed25519seed is not null`, did, ).Scan(&actor); errors.Is(err, sql.ErrNoRows) { slog.Warn("Denying followers request for non-existing user", "did", did) @@ -700,15 +700,15 @@ func (l *Listener) handleAPGatewayGetObject(w http.ResponseWriter, r *http.Reque select raw from ( select json(actor) as raw from persons - where cid = $1 and ed25519privkey is not null + where cid = $1 and ed25519seed is not null union all select json(notes.object) as raw from notes join persons on notes.author = persons.id - where notes.cid = $1 and notes.deleted = 0 and notes.public = 1 and persons.ed25519privkey is not null + where notes.cid = $1 and notes.deleted = 0 and notes.public = 1 and persons.ed25519seed is not null union all select json(outbox.activity) as raw from outbox join persons on outbox.activity->>'$.actor' = persons.id - where outbox.cid = $1 and (exists (select 1 from json_each(outbox.activity->'$.cc') where value = $2) or exists (select 1 from json_each(outbox.activity->'$.to') where value = $2)) and persons.ed25519privkey is not null + where outbox.cid = $1 and (exists (select 1 from json_each(outbox.activity->'$.cc') where value = $2) or exists (select 1 from json_each(outbox.activity->'$.to') where value = $2)) and persons.ed25519seed is not null ) limit 1 `, diff --git a/fed/deliver.go b/fed/deliver.go index c75f57f5..fc4e844a 100644 --- a/fed/deliver.go +++ b/fed/deliver.go @@ -105,7 +105,7 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { slog.Error("Failed to fetch post to deliver", "error", err) return true }, - `select outbox.attempts, json(outbox.activity) as x, json(outbox.activity) as y, json(persons.actor), persons.rsaprivkey, persons.ed25519privkey, persons.mldsa44seed from + `select outbox.attempts, json(outbox.activity) as x, json(outbox.activity) as y, json(persons.actor), persons.rsaprivkey, persons.ed25519seed, persons.mldsa44seed from outbox join persons on @@ -408,7 +408,7 @@ func (q *Queue) queueTasks( slog.Warn("Skipped an inbox", "activity", job.Activity.ID, "error", err) return true }, - `select distinct coalesce(persons.actor->>'$.endpoints.sharedInbox', persons.actor->>'$.inbox') as inbox from persons join follows on follows.follower = persons.id where follows.followed = ? and follows.accepted = 1 and follows.follower not like ? and persons.ed25519privkey is null order by persons.actor->>'$.endpoints.sharedInbox' is not null desc, inbox`, + `select distinct coalesce(persons.actor->>'$.endpoints.sharedInbox', persons.actor->>'$.inbox') as inbox from persons join follows on follows.follower = persons.id where follows.followed = ? and follows.accepted = 1 and follows.follower not like ? and persons.ed25519seed is null order by persons.actor->>'$.endpoints.sharedInbox' is not null desc, inbox`, job.Sender.ID, fmt.Sprintf("https://%s/%%", activityID.Host), ) diff --git a/fed/followers.go b/fed/followers.go index a0f77776..e74f1218 100644 --- a/fed/followers.go +++ b/fed/followers.go @@ -315,8 +315,8 @@ func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Conf slog.Info("Found unknown remote follow", "followed", d.Followed, "follower", follower) var actor ap.Actor - var ed25519PrivKey, mldsa44Seed []byte - if err := db.QueryRowContext(ctx, `SELECT JSON(persons.actor), persons.ed25519privkey, persons.mldsa44seed FROM persons WHERE id = ? AND persons.ed25519privkey IS NOT NULL`, follower).Scan(&actor, &ed25519PrivKey, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { + var ed25519Seed, mldsa44Seed []byte + if err := db.QueryRowContext(ctx, `SELECT JSON(persons.actor), persons.ed25519seed, persons.mldsa44seed FROM persons WHERE id = ? AND persons.ed25519seed IS NOT NULL`, follower).Scan(&actor, &ed25519Seed, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Info("Follower does not exist", "followed", d.Followed, "follower", follower) continue } else if err != nil { @@ -337,7 +337,7 @@ func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Conf continue } - if err := d.Inbox.Unfollow(ctx, &actor, proof.SigningSeed(&actor, ed25519PrivKey, mldsa44Seed), d.Followed, followID); err != nil { + if err := d.Inbox.Unfollow(ctx, &actor, proof.SigningSeed(&actor, ed25519Seed, mldsa44Seed), d.Followed, followID); err != nil { slog.Warn("Failed to remove remote follow", "followed", d.Followed, "follower", follower, "error", err) } } diff --git a/fed/inbox.go b/fed/inbox.go index 207c1b3e..b0b8f1b4 100644 --- a/fed/inbox.go +++ b/fed/inbox.go @@ -140,8 +140,8 @@ func (l *Listener) handleInbox(w http.ResponseWriter, r *http.Request) { receiver := r.PathValue("username") var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte - if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, receiver).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte + if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519seed, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519seed is not null`, receiver).Scan(&actor, &rsaPrivKeyDer, &ed25519Seed, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Debug("Receiving user does not exist", "receiver", receiver) w.WriteHeader(http.StatusNotFound) return @@ -162,7 +162,7 @@ func (l *Listener) handleInbox(w http.ResponseWriter, r *http.Request) { l.doHandleInbox(w, r, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }) } diff --git a/front/gemini/gemini.go b/front/gemini/gemini.go index 12badbce..61a8c02d 100644 --- a/front/gemini/gemini.go +++ b/front/gemini/gemini.go @@ -76,10 +76,10 @@ func (gl *Listener) getUser(ctx context.Context, tlsConn *tls.Conn, cfg *cfg.Con certHash := fmt.Sprintf("%X", sha256.Sum256(clientCert.Raw)) - var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte var actor ap.Actor var approved int - if err := gl.DB.QueryRowContext(ctx, `select json(persons.actor), persons.rsaprivkey, persons.ed25519privkey, persons.mldsa44seed, 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(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &mldsa44Seed, &approved); err != nil && errors.Is(err, sql.ErrNoRows) { + if err := gl.DB.QueryRowContext(ctx, `select json(persons.actor), persons.rsaprivkey, persons.ed25519seed, persons.mldsa44seed, 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(&actor, &rsaPrivKeyDer, &ed25519Seed, &mldsa44Seed, &approved); err != nil && errors.Is(err, sql.ErrNoRows) { if cfg.RequireInvitation { var accepted int if err := gl.DB.QueryRowContext(ctx, `select exists (select 1 from invites where certhash = ?)`, certHash).Scan(&accepted); err != nil { @@ -108,7 +108,7 @@ func (gl *Listener) getUser(ctx context.Context, tlsConn *tls.Conn, cfg *cfg.Con slog.Debug("Found existing user", "hash", certHash, "user", actor.ID) return &actor, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, nil } diff --git a/front/post.go b/front/post.go index be4913ca..44806b41 100644 --- a/front/post.go +++ b/front/post.go @@ -137,7 +137,7 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo parents.object->>'$.attributedTo' = persons.id or exists (select 1 from json_each(parents.object->'$.to') where value = persons.id) or exists (select 1 from json_each(parents.object->'$.cc') where value = persons.id) - ) or ed25519privkey is not null + ) or ed25519seed is not null or id in (select followed from follows where follower = $4 and accepted = 1) ) limit 2 @@ -177,7 +177,7 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo parents.object->>'$.attributedTo' = persons.id or exists (select 1 from json_each(parents.object->'$.to') where value = persons.id) or exists (select 1 from json_each(parents.object->'$.cc') where value = persons.id) - ) or ed25519privkey is not null + ) or ed25519seed is not null or id in (select followed from follows where follower = $5 and accepted = 1) ) limit 2 @@ -198,7 +198,7 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo actor->>'$.preferredUsername' = $1 and ((actor->>'$.type' = 'Group') is $2) and ( - ed25519privkey is not null + ed25519seed is not null or id in (select followed from follows where follower = $3 and accepted = 1) ) limit 2 diff --git a/front/shell.go b/front/shell.go index f00dfa9d..de110aae 100644 --- a/front/shell.go +++ b/front/shell.go @@ -40,12 +40,12 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { } var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte if err := h.DB.QueryRowContext( ctx, - `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, + `select json(actor), rsaprivkey, ed25519seed, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519seed is not null`, user, - ).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &mldsa44Seed); err != nil { + ).Scan(&actor, &rsaPrivKeyDer, &ed25519Seed, &mldsa44Seed); err != nil { panic(err) } @@ -70,7 +70,7 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { User: &actor, Keys: [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, }, diff --git a/front/user/app.go b/front/user/app.go index 764160b0..2cbbcb36 100644 --- a/front/user/app.go +++ b/front/user/app.go @@ -34,15 +34,15 @@ import ( // This user is used to sign outgoing requests not initiated by a particular user. func CreateApplicationActor(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config) (*ap.Actor, [3]httpsig.Key, error) { var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey, mldsa44Seed []byte + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte if err := db.QueryRowContext( ctx, - `select json(actor), rsaprivkey, ed25519privkey, mldsa44seed from persons where actor->>'$.preferredUsername' = 'actor' and host = ?`, + `select json(actor), rsaprivkey, ed25519seed, mldsa44seed from persons where actor->>'$.preferredUsername' = 'actor' and host = ?`, domain, ).Scan( &actor, &rsaPrivKeyDer, - &ed25519PrivKey, + &ed25519Seed, &mldsa44Seed, ); errors.Is(err, sql.ErrNoRows) { return CreatePortable(ctx, domain, db, cfg, "actor", ap.Application, nil) @@ -59,7 +59,7 @@ func CreateApplicationActor(ctx context.Context, domain string, db *sql.DB, cfg return &actor, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, err } diff --git a/front/user/create.go b/front/user/create.go index 1faefee3..4c29a879 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -88,7 +88,7 @@ func insertActor( if _, err := tx.ExecContext( ctx, - `INSERT INTO persons (slug, id, actor, rsaprivkey, ed25519privkey, mldsa44seed) VALUES (?, ?, JSONB(?), ?, ?, ?) ON CONFLICT(id) DO NOTHING`, + `INSERT INTO persons (slug, id, actor, rsaprivkey, ed25519seed, mldsa44seed) VALUES (?, ?, JSONB(?), ?, ?, ?) ON CONFLICT(id) DO NOTHING`, ap.Slug(actor.ID), actor.ID, actor, diff --git a/inbox/backfill.go b/inbox/backfill.go index ee6b7d65..4363e5ef 100644 --- a/inbox/backfill.go +++ b/inbox/backfill.go @@ -53,13 +53,13 @@ func (q *Queue) fetchCachedPost(ctx context.Context, id string) (*ap.Object, err or exists ( select 1 from persons where persons.id = notes.author - and persons.ed25519privkey is not null + and persons.ed25519seed is not null ) or ( not exists ( select 1 from persons where persons.id = notes.author - and persons.ed25519privkey is not null + and persons.ed25519seed is not null ) and ( max(inserted, updated) > $2 or exists ( diff --git a/inbox/forward.go b/inbox/forward.go index 18056a10..30cb0588 100644 --- a/inbox/forward.go +++ b/inbox/forward.go @@ -30,13 +30,13 @@ import ( func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Object, activity *ap.Activity, rawActivity, firstPostID string) (bool, error) { var group ap.Actor - var ed25519PrivKey, mldsa44Seed []byte + var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, ` - select json(actor), ed25519privkey, mldsa44seed from + select json(actor), ed25519seed, mldsa44seed from ( - select persons.actor, ed25519privkey, mldsa44seed, 1 as rank + select persons.actor, ed25519seed, mldsa44seed, 1 as rank from persons join notes on @@ -46,7 +46,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj persons.host = $2 and persons.actor->>'$.type' = 'Group' union all - select persons.actor, ed25519privkey, mldsa44seed, 2 as rank + select persons.actor, ed25519seed, mldsa44seed, 2 as rank from persons join notes on @@ -57,7 +57,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj persons.host = $2 and persons.actor->>'$.type' = 'Group' union all - select persons.actor, ed25519privkey, mldsa44seed, 3 as rank + select persons.actor, ed25519seed, mldsa44seed, 3 as rank from persons join notes on @@ -73,7 +73,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj `, firstPostID, inbox.Domain, - ).Scan(&group, &ed25519PrivKey, &mldsa44Seed); err != nil && errors.Is(err, sql.ErrNoRows) { + ).Scan(&group, &ed25519Seed, &mldsa44Seed); err != nil && errors.Is(err, sql.ErrNoRows) { return false, nil } else if err != nil { return false, err @@ -105,7 +105,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj } // if this is a new post and we're passing the Create activity to followers, also share the post - if err := inbox.Announce(ctx, tx, &group, proof.SigningSeed(&group, ed25519PrivKey, mldsa44Seed), note); err != nil { + if err := inbox.Announce(ctx, tx, &group, proof.SigningSeed(&group, ed25519Seed, mldsa44Seed), note); err != nil { return true, err } @@ -162,7 +162,7 @@ func (inbox *Inbox) forwardActivity(ctx context.Context, tx *sql.Tx, note *ap.Ob return nil } - if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519privkey is not null`, ap.Canonical(threadStarterID)).Scan(&threadStarterID); errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519seed is not null`, ap.Canonical(threadStarterID)).Scan(&threadStarterID); errors.Is(err, sql.ErrNoRows) { slog.Debug("Thread starter is federated", "activity", activity.ID, "note", note.ID) return nil } else if err != nil { diff --git a/inbox/inbox.go b/inbox/inbox.go index 476c5aa5..20f33e25 100644 --- a/inbox/inbox.go +++ b/inbox/inbox.go @@ -179,11 +179,11 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return errors.New("received an invalid follow request") } - var ed25519PrivKey, mldsa44Seed []byte + var ed25519Seed, mldsa44Seed []byte var followed ap.Actor - if err := tx.QueryRowContext(ctx, `select ed25519privkey, mldsa44seed, json(actor) from persons where cid = ? order by ed25519privkey is not null desc limit 1`, ap.Canonical(followedID)).Scan(&ed25519PrivKey, &mldsa44Seed, &followed); errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select ed25519seed, mldsa44seed, json(actor) from persons where cid = ? order by ed25519seed is not null desc limit 1`, ap.Canonical(followedID)).Scan(&ed25519Seed, &mldsa44Seed, &followed); errors.Is(err, sql.ErrNoRows) { var localFollowerID string - if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519privkey is not null`, ap.Canonical(activity.Actor)).Scan(&localFollowerID); errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519seed is not null`, ap.Canonical(activity.Actor)).Scan(&localFollowerID); errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("received an invalid follow request for %s by %s", followedID, activity.Actor) } else if err != nil { return fmt.Errorf("failed to validate follow request for %s by %s: %w", followedID, activity.Actor, err) @@ -203,7 +203,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return fmt.Errorf("failed to fetch %s: %w", followed.ID, err) } - if ed25519PrivKey == nil || followed.ManuallyApprovesFollowers { + if ed25519Seed == nil || followed.ManuallyApprovesFollowers { slog.Info("Not approving follow request", "activity", activity, "follower", activity.Actor, "followed", followed.ID) if _, err := tx.ExecContext( @@ -216,7 +216,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu ); err != nil { return fmt.Errorf("failed to insert follow %s: %w", activity.ID, err) } - } else if ed25519PrivKey != nil && !followed.ManuallyApprovesFollowers { + } else if ed25519Seed != nil && !followed.ManuallyApprovesFollowers { slog.Info("Approving follow request", "activity", activity, "follower", activity.Actor, "followed", followed.ID) if _, err := tx.ExecContext( @@ -230,7 +230,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return fmt.Errorf("failed to insert follow %s: %w", activity.ID, err) } - if err := inbox.AcceptFollow(ctx, &followed, proof.SigningSeed(&followed, ed25519PrivKey, mldsa44Seed), activity.Actor, activity.ID, tx); err != nil { + if err := inbox.AcceptFollow(ctx, &followed, proof.SigningSeed(&followed, ed25519Seed, mldsa44Seed), activity.Actor, activity.ID, tx); err != nil { return fmt.Errorf("failed to accept %s: %w", activity.ID, err) } } else { @@ -443,16 +443,16 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu } var actor ap.Actor - var ed25519PrivKey, mldsa44Seed []byte + var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, ` - select ed25519privkey, mldsa44seed, json(actor) from notes + select ed25519seed, mldsa44seed, json(actor) from notes join persons on persons.id = notes.author - where notes.id = ? and notes.public = 1 and notes.deleted = 0 and persons.ed25519privkey is not null + where notes.id = ? and notes.public = 1 and notes.deleted = 0 and persons.ed25519seed is not null `, postID, - ).Scan(&ed25519PrivKey, &mldsa44Seed, &actor); errors.Is(err, sql.ErrNoRows) { + ).Scan(&ed25519Seed, &mldsa44Seed, &actor); errors.Is(err, sql.ErrNoRows) { slog.Debug("Received invalid quote request", "activity", activity) return nil } else if err != nil { @@ -462,7 +462,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu if err := inbox.acceptRequest( ctx, &actor, - proof.SigningSeed(&actor, ed25519PrivKey, mldsa44Seed), + proof.SigningSeed(&actor, ed25519Seed, mldsa44Seed), activity, tx, ); err != nil { diff --git a/migrations/076_mldsa44slug.go b/migrations/076_mldsa44slug.go index dd720dba..2903217c 100644 --- a/migrations/076_mldsa44slug.go +++ b/migrations/076_mldsa44slug.go @@ -183,15 +183,15 @@ func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { return err } - if _, err := tx.ExecContext(ctx, `CREATE TABLE npersons(slug TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL UNIQUE, actor JSONB NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT (UNIXEPOCH()), host TEXT AS (substr(substr(id, 9), 0, instr(substr(id, 9), '/'))), fetched INTEGER, ttl INTEGER, rsaprivkey BLOB, ed25519privkey BLOB, mldsa44seed BLOB, cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`); err != nil { + if _, err := tx.ExecContext(ctx, `CREATE TABLE npersons(slug TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL UNIQUE, actor JSONB NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT (UNIXEPOCH()), host TEXT AS (substr(substr(id, 9), 0, instr(substr(id, 9), '/'))), fetched INTEGER, ttl INTEGER, rsaprivkey BLOB, ed25519seed BLOB, mldsa44seed BLOB, cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`); err != nil { return err } - if _, err := tx.ExecContext(ctx, `INSERT INTO npersons(slug, id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey, mldsa44seed) SELECT slugs.slug, persons.id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey, mldsa44seed FROM persons JOIN slugs ON slugs.id = persons.id`); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO npersons(slug, id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519seed, mldsa44seed) SELECT slugs.slug, persons.id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey, mldsa44seed FROM persons JOIN slugs ON slugs.id = persons.id`); err != nil { return err } - rows, err := tx.QueryContext(ctx, `SELECT id, JSON(actor) FROM npersons WHERE ed25519privkey IS NOT NULL`, domain) + rows, err := tx.QueryContext(ctx, `SELECT id, JSON(actor) FROM npersons WHERE ed25519seed IS NOT NULL`, domain) if err != nil { return err } @@ -244,7 +244,7 @@ func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { `CREATE INDEX personsmovedto ON persons(actor->>'$.movedTo') WHERE actor->>'$.movedTo' IS NOT NULL`, `CREATE UNIQUE INDEX personspreferredusernamehosttype ON persons(actor->>'$.preferredUsername', host, actor->>'$.type')`, `CREATE INDEX personscid ON persons(cid)`, - `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519privkey IS NOT NULL`, + `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519seed IS NOT NULL`, } { if _, err := tx.ExecContext(ctx, stmt); err != nil { return err diff --git a/outbox/deleter.go b/outbox/deleter.go index 2c34647f..df5cca56 100644 --- a/outbox/deleter.go +++ b/outbox/deleter.go @@ -43,7 +43,7 @@ func (d *Deleter) undoShares(ctx context.Context) (bool, error) { ctx, d.DB, ` - select json(persons.actor), persons.ed25519privkey, persons.mldsa44seed, json(outbox.activity) from persons + select json(persons.actor), persons.ed25519seed, persons.mldsa44seed, json(outbox.activity) from persons join shares on shares.by = persons.id join outbox on outbox.activity->>'$.actor' = shares.by and outbox.activity->>'$.object' = shares.note where @@ -91,7 +91,7 @@ func (d *Deleter) deletePosts(ctx context.Context) (bool, error) { ctx, d.DB, ` - select json(persons.actor), persons.ed25519privkey, persons.mldsa44seed, json(notes.object) from persons + select json(persons.actor), persons.ed25519seed, persons.mldsa44seed, json(notes.object) from persons join notes on notes.author = persons.id where persons.ttl is not null and diff --git a/outbox/mover.go b/outbox/mover.go index 0f23b54b..affe86c9 100644 --- a/outbox/mover.go +++ b/outbox/mover.go @@ -92,7 +92,7 @@ func (m *Mover) Run(ctx context.Context) error { return true }, ` - select json(persons.actor), persons.ed25519privkey, persons.mldsa44seed, old.id, new.id, follows.id, new.id = follows.follower or exists (select 1 from follows where follower = persons.id and followed = new.id) from + select json(persons.actor), persons.ed25519seed, persons.mldsa44seed, old.id, new.id, follows.id, new.id = follows.follower or exists (select 1 from follows where follower = persons.id and followed = new.id) from persons old join persons new diff --git a/outbox/poller.go b/outbox/poller.go index 3ebaf3b0..cd598d1b 100644 --- a/outbox/poller.go +++ b/outbox/poller.go @@ -53,7 +53,7 @@ func (p *Poller) Run(ctx context.Context) error { }, ` with polls as ( - select notes.id, notes.object, persons.actor as author, persons.ed25519privkey, persons.mldsa44seed + select notes.id, notes.object, persons.actor as author, persons.ed25519seed, persons.mldsa44seed from notes join persons on persons.id = notes.author where @@ -70,7 +70,7 @@ func (p *Poller) Run(ctx context.Context) error { coalesce(voter_counts.count, 0), json(polls.object), json(polls.author), - polls.ed25519privkey, + polls.ed25519seed, polls.mldsa44seed from polls join json_each(polls.object->'$.anyOf') as anyof From 7afa8fa155416f80fcfb86635096184c037be08d Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 18:23:11 +0300 Subject: [PATCH 10/41] x --- migrations/076_mldsa44slug.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/076_mldsa44slug.go b/migrations/076_mldsa44slug.go index 2903217c..5a3ea653 100644 --- a/migrations/076_mldsa44slug.go +++ b/migrations/076_mldsa44slug.go @@ -187,7 +187,7 @@ func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { return err } - if _, err := tx.ExecContext(ctx, `INSERT INTO npersons(slug, id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519seed, mldsa44seed) SELECT slugs.slug, persons.id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey, mldsa44seed FROM persons JOIN slugs ON slugs.id = persons.id`); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO npersons(slug, id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519seed) SELECT slugs.slug, persons.id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey FROM persons JOIN slugs ON slugs.id = persons.id`); err != nil { return err } From 983c94dd03f79920674248d857f90a49793f9d42 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 18:30:51 +0300 Subject: [PATCH 11/41] x --- migrations/076_mldsa44slug.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/migrations/076_mldsa44slug.go b/migrations/076_mldsa44slug.go index 5a3ea653..8523ef58 100644 --- a/migrations/076_mldsa44slug.go +++ b/migrations/076_mldsa44slug.go @@ -245,6 +245,10 @@ func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { `CREATE UNIQUE INDEX personspreferredusernamehosttype ON persons(actor->>'$.preferredUsername', host, actor->>'$.type')`, `CREATE INDEX personscid ON persons(cid)`, `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519seed IS NOT NULL`, + `DROP INDEX outboxcidsender`, + `ALTER TABLE outbox DROP COLUMN cid`, + `ALTER TABLE outbox ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN activity->>'$.id' LIKE 'https://%' AND (activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22, CASE WHEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') - 1 ELSE LENGTH(activity->>'$.id') END) WHEN activity->>'$.id' LIKE 'https://%' THEN activity->>'$.id' ELSE NULL END)`, + `CREATE INDEX outboxcidsender ON outbox(cid, sender)`, } { if _, err := tx.ExecContext(ctx, stmt); err != nil { return err From 5d2f13b33e6036092cb23ab4e524dbf1808d3ab2 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 18:37:13 +0300 Subject: [PATCH 12/41] x --- ap/id.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ap/id.go b/ap/id.go index d260cbe6..a08d69f2 100644 --- a/ap/id.go +++ b/ap/id.go @@ -24,7 +24,7 @@ import ( var ( // KeyRegex matches a Multibase-encoded Ed25519 or ML-DSA-44 public key. - KeyRegex = regexp.MustCompile(`\b(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)`) + KeyRegex = regexp.MustCompile(`\b(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|u(?:7Q|kC)[A-Za-z0-9_-]+)`) // apURLRegex matches an ap:// URL. apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) From 1c285a19815fdc1d315def411eea6366303dec80 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 18:44:17 +0300 Subject: [PATCH 13/41] x --- migrations/076_mldsa44slug.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/076_mldsa44slug.go b/migrations/076_mldsa44slug.go index 8523ef58..7028a0e0 100644 --- a/migrations/076_mldsa44slug.go +++ b/migrations/076_mldsa44slug.go @@ -191,7 +191,7 @@ func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { return err } - rows, err := tx.QueryContext(ctx, `SELECT id, JSON(actor) FROM npersons WHERE ed25519seed IS NOT NULL`, domain) + rows, err := tx.QueryContext(ctx, `SELECT id, JSON(actor) FROM npersons WHERE ed25519seed IS NOT NULL`) if err != nil { return err } From 3619109ef950fb4984d10ce14d47ceb01c48eec3 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 18:46:26 +0300 Subject: [PATCH 14/41] x --- front/id.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/id.go b/front/id.go index 7e316ac5..cf87cad8 100644 --- a/front/id.go +++ b/front/id.go @@ -23,7 +23,7 @@ import ( ) func idLink(id string) string { - if !ap.IsPortable(id) { + if len(id) < 64 { return strings.TrimPrefix(id, "https://") } From b24a7a3b85f0c02f89dc84ab3ebfebeae0e3bf61 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 19:12:20 +0300 Subject: [PATCH 15/41] x --- FEDERATION.md | 10 ++++++---- README.md | 2 +- cfg/cfg.go | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index d052c979..1d2f30bb 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -40,20 +40,20 @@ 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://github.com/C2SP/C2SP/blob/3bc97b2329fee167f7ff39efbbbc316c84876105/httpsig-pq.md) 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`, plus an Ed25519 key and a post-quantum ML-DSA-44 key under `assertionMethod`, as described in [FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md). 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: * 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 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`, `Ed25519Threshold` and `MLDSA44Threshold`) tries RFC9421, Ed25519 and ML-DSA-44 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too ## Collections @@ -171,7 +171,9 @@ 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. + +tootik allows the user to supply a base58-encoded Ed25519 or base64url-encoded ML-DSA-44 private key during registration, instead of using a randomly generated `did:key:z6Mk...` DID. The key, like the user's `preferredUsername`, must be unique per tootik instance. Users created by providing a ML-DSA-44 key use [`mldsa44-jcs-2024`](https://www.w3.org/TR/vc-di-quantum-resistant-1.0/#cryptosuite-mldsa44-jcs-2024) integrity proofs, while others use `eddsa-jcs-2022`. No matter if the key was generated by tootik or provided by the user, the user can recover it through the settings page. diff --git a/README.md b/README.md index 40f580f4..5fd22444 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cfg/cfg.go b/cfg/cfg.go index 312572d6..1dbdbacc 100644 --- a/cfg/cfg.go +++ b/cfg/cfg.go @@ -456,7 +456,7 @@ func (c *Config) FillDefaults() { } if c.MLDSA44Threshold <= 0 || c.MLDSA44Threshold > 1 { - c.MLDSA44Threshold = 0.01 + c.MLDSA44Threshold = 0.998 } if c.MaxGateways <= 0 { From 1061bb60720ffd684f493880bb521d6ccdd92cf5 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 19:23:59 +0300 Subject: [PATCH 16/41] x --- front/id.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/id.go b/front/id.go index cf87cad8..033ef362 100644 --- a/front/id.go +++ b/front/id.go @@ -23,7 +23,7 @@ import ( ) func idLink(id string) string { - if len(id) < 64 { + if len(id) < 80 { return strings.TrimPrefix(id, "https://") } From 3b116d8a829d1aafabb88066ea7ace8bd48ef469 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:00:05 +0300 Subject: [PATCH 17/41] x --- front/user/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/user/create.go b/front/user/create.go index 4c29a879..069420c4 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -88,7 +88,7 @@ func insertActor( if _, err := tx.ExecContext( ctx, - `INSERT INTO persons (slug, id, actor, rsaprivkey, ed25519seed, mldsa44seed) VALUES (?, ?, JSONB(?), ?, ?, ?) ON CONFLICT(id) DO NOTHING`, + `INSERT OR IGNORE INTO persons (slug, id, actor, rsaprivkey, ed25519seed, mldsa44seed) VALUES (?, ?, JSONB(?), ?, ?, ?)`, ap.Slug(actor.ID), actor.ID, actor, From 6b792978859082ab9e0cfde99d7179c4837840b6 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:01:38 +0300 Subject: [PATCH 18/41] x --- front/user/create.go | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/front/user/create.go b/front/user/create.go index 069420c4..e42998f4 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -124,27 +124,11 @@ func insertActor( if _, err := tx.ExecContext( ctx, - `INSERT OR IGNORE INTO keys (id, actor) VALUES (?, ?)`, - actor.PublicKey.ID, + `INSERT OR IGNORE INTO keys (id, actor) VALUES ($1, $2), ($1, $3), ($1, $4)`, actor.ID, - ); err != nil { - return err - } - - if _, err := tx.ExecContext( - ctx, - `INSERT OR IGNORE INTO keys (id, actor) VALUES (?, ?)`, + actor.PublicKey.ID, actor.AssertionMethod[0].ID, - actor.ID, - ); err != nil { - return err - } - - if _, err := tx.ExecContext( - ctx, - `INSERT OR IGNORE INTO keys (id, actor) VALUES (?, ?)`, actor.AssertionMethod[1].ID, - actor.ID, ); err != nil { return err } From 4b32614d12374ac2e0362e37955bd4d748df0bed Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:04:09 +0300 Subject: [PATCH 19/41] x --- front/user/create.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/front/user/create.go b/front/user/create.go index e42998f4..f88c88f9 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -186,7 +186,7 @@ func CreatePortableWithKey( mldsa44Priv *mldsa44.PrivateKey mldsa44Pub *mldsa44.PublicKey - ed25519PubMultibase, mldsa44PubMultibase, id string + ed25519PubMultibase, mldsa44PubMultibase, didKeyMultibase string ) switch v := priv.(type) { @@ -200,10 +200,8 @@ func CreatePortableWithKey( ed25519Pub = v.Public().(ed25519.PublicKey) ed25519PubMultibase = data.EncodeEd25519PublicKey(ed25519Pub) - - id = fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, ed25519PubMultibase) - mldsa44PubMultibase = data.EncodeMLDSA44Publickey(mldsa44Pub) + didKeyMultibase = ed25519PubMultibase case *mldsa44.PrivateKey: ed25519Pub, ed25519Priv, err = ed25519.GenerateKey(nil) @@ -215,12 +213,11 @@ func CreatePortableWithKey( mldsa44Pub = v.Public().(*mldsa44.PublicKey) mldsa44PubMultibase = data.EncodeMLDSA44Publickey(mldsa44Pub) - - id = fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, mldsa44PubMultibase) - ed25519PubMultibase = data.EncodeEd25519PublicKey(ed25519Pub) + didKeyMultibase = mldsa44PubMultibase } + id := fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, didKeyMultibase) actor := ap.Actor{ Context: []string{ "https://www.w3.org/ns/activitystreams", From 11e7e2fe3a3770a3509ba7b89c46029322704ad0 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:23:59 +0300 Subject: [PATCH 20/41] x --- FEDERATION.md | 2 +- ap/id.go | 3 +++ ap/slug.go | 8 ++++++-- proof/key.go | 33 +++++++++------------------------ 4 files changed, 19 insertions(+), 27 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 1d2f30bb..411dd8ae 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -151,7 +151,7 @@ By default, tootik omits user and post counters unless `FillNodeInfoUsage` is ch # Data Portability -tootik partially supports [FEP-ef61](https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md) portable actors, activities and objects. +tootik partially supports [FEP-ef61](https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md) portable actors, activities and objects, and extends it by supporting DIDs constructed using base64url-encoded ML-DSA-44 keys. If * `alice@a.localdomain` is `https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor` diff --git a/ap/id.go b/ap/id.go index a08d69f2..d22dda92 100644 --- a/ap/id.go +++ b/ap/id.go @@ -22,6 +22,9 @@ import ( "regexp" ) +// MLDSA44Prefix is the prefix of base64url-encoded ML-DSA-44 public keys. +const MLDSA44Prefix = "ukC" + var ( // KeyRegex matches a Multibase-encoded Ed25519 or ML-DSA-44 public key. KeyRegex = regexp.MustCompile(`\b(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|u(?:7Q|kC)[A-Za-z0-9_-]+)`) diff --git a/ap/slug.go b/ap/slug.go index 02192261..cd608472 100644 --- a/ap/slug.go +++ b/ap/slug.go @@ -19,9 +19,13 @@ 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([]byte(id)) - return base64.RawURLEncoding.EncodeToString(sum[:9]) + sum := sha256.Sum256(danger.Bytes(strings.TrimPrefix(id, "https://"))) + return base64.RawURLEncoding.EncodeToString(sum[:12]) } diff --git a/proof/key.go b/proof/key.go index 5befe582..3b023ef9 100644 --- a/proof/key.go +++ b/proof/key.go @@ -14,48 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package proof creates and verifies integrity proofs. -// -// See https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md for more details. package proof import ( "crypto/ed25519" + "strings" "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" - "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/httpsig" ) +// SigningKey the key that should be used to create proofs on behalf of actor. func SigningKey(id string, keys [3]httpsig.Key) httpsig.Key { - m := ap.GatewayURLRegex.FindStringSubmatch(id) - if m == nil { - return keys[1] - } - - pub, err := data.DecodePublicKey(m[1]) - if err != nil { - return keys[1] - } - - if _, ok := pub.(*mldsa44.PublicKey); ok { + if m := ap.KeyRegex.FindStringSubmatch(id); m != nil && strings.HasPrefix(m[1], ap.MLDSA44Prefix) { return keys[2] } return keys[1] } +// SigningSeed the key that should be used to create proofs on behalf of actor. func SigningSeed(actor *ap.Actor, ed25519Seed, mldsa44Seed []byte) httpsig.Key { - if m := ap.GatewayURLRegex.FindStringSubmatch(actor.ID); m != nil { - if pub, err := data.DecodePublicKey(m[1]); err == nil { - if _, ok := pub.(*mldsa44.PublicKey); ok { - _, priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) - return httpsig.Key{ - ID: actor.AssertionMethod[1].ID, - PrivateKey: priv, - } - } + if m := ap.KeyRegex.FindStringSubmatch(actor.ID); m != nil && strings.HasPrefix(m[1], ap.MLDSA44Prefix) { + _, priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + return httpsig.Key{ + ID: actor.AssertionMethod[1].ID, + PrivateKey: priv, } } From b673df7da6e4449ebb93569004f3013b36129652 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:27:10 +0300 Subject: [PATCH 21/41] x --- data/key.go | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/data/key.go b/data/key.go index 197f33c4..695ee63a 100644 --- a/data/key.go +++ b/data/key.go @@ -56,7 +56,7 @@ func EncodeMLDSA44Publickey(key *mldsa44.PublicKey) string { // DecodePrivateKey decodes a public key encoded by [EncodeEd25519PrivateKey] or [EncodeMLDSA44PrivateKey]. func DecodePrivateKey(key string) (PrivateKey, error) { if len(key) == 0 { - return nil, errors.New("key is empty") + return nil, errors.New("empty key") } var rawKey []byte @@ -109,23 +109,14 @@ func DecodePublicKey(key string) (crypto.PublicKey, error) { return nil, fmt.Errorf("invalid prefix: %c", key[0]) } - switch len(rawKey) { - case 2 + ed25519.PublicKeySize: - if rawKey[0] != 0xed || rawKey[1] != 0x01 { - return nil, fmt.Errorf("invalid prefix: %02x%02x", rawKey[0], rawKey[1]) - } - + if len(rawKey) == 2+ed25519.PublicKeySize && rawKey[0] == 0xed && rawKey[1] == 0x01 { return ed25519.PublicKey(rawKey[2:]), nil - - case 2 + mldsa44.PublicKeySize: - if rawKey[0] != 0x90 || rawKey[1] != 0x24 { - return nil, fmt.Errorf("invalid prefix: %02x%02x", rawKey[0], rawKey[1]) - } - + } else if len(rawKey) == 2+mldsa44.PublicKeySize && rawKey[0] == 0x90 && rawKey[1] == 0x24 { pub := &mldsa44.PublicKey{} return pub, pub.UnmarshalBinary(rawKey[2:]) - - default: + } else if len(rawKey) >= 2 { + return nil, fmt.Errorf("invalid prefix: %02x%02x", rawKey[0], rawKey[1]) + } else { return nil, fmt.Errorf("invalid key length: %d", len(rawKey)) } } From fc6d0587998f4c6d3473f2dc7a905f3d8a52d212 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:30:34 +0300 Subject: [PATCH 22/41] x --- fed/deliver.go | 12 ++++++------ outbox/deleter.go | 20 ++++++++++---------- outbox/mover.go | 6 +++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/fed/deliver.go b/fed/deliver.go index fc4e844a..729f9dfd 100644 --- a/fed/deliver.go +++ b/fed/deliver.go @@ -92,11 +92,11 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { slog.Debug("Polling delivery queue") rows, err := dbx.QueryCollectCountIgnore[struct { - DeliveryAttempts int - Activity ap.Activity - RawActivity string - Actor ap.Actor - RsaPrivKeyDer, Ed25519PrivKey, MLDSA44Seed []byte + DeliveryAttempts int + Activity ap.Activity + RawActivity string + Actor ap.Actor + RsaPrivKeyDer, Ed25519Seed, MLDSA44Seed []byte }]( ctx, q.DB, @@ -180,7 +180,7 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { keys := [3]httpsig.Key{ {ID: row.Actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: row.Actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey)}, + {ID: row.Actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519Seed)}, {ID: row.Actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, } diff --git a/outbox/deleter.go b/outbox/deleter.go index df5cca56..696429a8 100644 --- a/outbox/deleter.go +++ b/outbox/deleter.go @@ -35,10 +35,10 @@ type Deleter struct { func (d *Deleter) undoShares(ctx context.Context) (bool, error) { rows, err := dbx.QueryCollect[struct { - Sharer ap.Actor - Ed25519PrivKey []byte - MLDSA44Seed []byte - Share ap.Activity + Sharer ap.Actor + Ed25519Seed []byte + MLDSA44Seed []byte + Share ap.Activity }]( ctx, d.DB, @@ -64,7 +64,7 @@ func (d *Deleter) undoShares(ctx context.Context) (bool, error) { if err := d.Inbox.Undo( ctx, &row.Sharer, - proof.SigningSeed(&row.Sharer, row.Ed25519PrivKey, row.MLDSA44Seed), + proof.SigningSeed(&row.Sharer, row.Ed25519Seed, row.MLDSA44Seed), &row.Share, ); err != nil { return false, err @@ -83,10 +83,10 @@ func (d *Deleter) undoShares(ctx context.Context) (bool, error) { func (d *Deleter) deletePosts(ctx context.Context) (bool, error) { rows, err := dbx.QueryCollect[struct { - Author ap.Actor - Ed25519PrivKey []byte - MLDSA44Seed []byte - Note ap.Object + Author ap.Actor + Ed25519Seed []byte + MLDSA44Seed []byte + Note ap.Object }]( ctx, d.DB, @@ -112,7 +112,7 @@ func (d *Deleter) deletePosts(ctx context.Context) (bool, error) { if err := d.Inbox.Delete( ctx, &row.Author, - proof.SigningSeed(&row.Author, row.Ed25519PrivKey, row.MLDSA44Seed), + proof.SigningSeed(&row.Author, row.Ed25519Seed, row.MLDSA44Seed), &row.Note, ); err != nil { return false, err diff --git a/outbox/mover.go b/outbox/mover.go index affe86c9..ce4f405f 100644 --- a/outbox/mover.go +++ b/outbox/mover.go @@ -80,7 +80,7 @@ func (m *Mover) Run(ctx context.Context) error { rows, err := dbx.QueryCollectIgnore[struct { Actor ap.Actor - Ed25519PrivKey []byte + Ed25519Seed []byte MLDSA44Seed []byte OldID, NewID, OldFollowID string OnlyRemove bool @@ -121,12 +121,12 @@ func (m *Mover) Run(ctx context.Context) error { slog.Info("Removing follow of moved actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID) } else { slog.Info("Moving follow", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID) - if err := m.Inbox.Follow(ctx, &row.Actor, proof.SigningSeed(&row.Actor, row.Ed25519PrivKey, row.MLDSA44Seed), row.NewID); err != nil { + if err := m.Inbox.Follow(ctx, &row.Actor, proof.SigningSeed(&row.Actor, row.Ed25519Seed, row.MLDSA44Seed), row.NewID); err != nil { slog.Warn("Failed to follow new actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID, "error", err) continue } } - if err := m.Inbox.Unfollow(ctx, &row.Actor, proof.SigningSeed(&row.Actor, row.Ed25519PrivKey, row.MLDSA44Seed), row.OldID, row.OldFollowID); err != nil { + if err := m.Inbox.Unfollow(ctx, &row.Actor, proof.SigningSeed(&row.Actor, row.Ed25519Seed, row.MLDSA44Seed), row.OldID, row.OldFollowID); err != nil { slog.Warn("Failed to unfollow old actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID, "error", err) } } From ccd474fea23e93908c5c65d1501c178f3c107941 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:33:04 +0300 Subject: [PATCH 23/41] x --- cluster/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cluster/server.go b/cluster/server.go index 904ca9e2..34e513b2 100644 --- a/cluster/server.go +++ b/cluster/server.go @@ -149,6 +149,7 @@ func NewServer(t T, domain string, client fed.Client) *Server { cfg.ResolverCacheTTL = 0 cfg.ResolverRetryInterval = 0 cfg.FollowersSyncInterval = 0 + cfg.MLDSA44Threshold = 0.25 cfg.Ed25519Threshold = 0.25 cfg.RFC9421Threshold = 0.5 cfg.EnableNonPortableActorRegistration = true From bb0bb78ef9ea029d9ecbf1c20e7c6aaf7213fb02 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:36:53 +0300 Subject: [PATCH 24/41] x --- FEDERATION.md | 2 +- httpsig/rfc9421_test.go | 2 +- proof/proof.go | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 411dd8ae..335ad36b 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -40,7 +40,7 @@ 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`, `ed25519` and [`ml-dsa-44`](https://github.com/C2SP/C2SP/blob/3bc97b2329fee167f7ff39efbbbc316c84876105/httpsig-pq.md) 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")` diff --git a/httpsig/rfc9421_test.go b/httpsig/rfc9421_test.go index c44bf699..0c67156a 100644 --- a/httpsig/rfc9421_test.go +++ b/httpsig/rfc9421_test.go @@ -714,7 +714,7 @@ func TestRFC9421_VerifySignatureAge(t *testing.T) { } } -// https://github.com/C2SP/C2SP/blob/3bc97b2329fee167f7ff39efbbbc316c84876105/httpsig-pq.md?plain=1#L253 +// https://c2sp.org/httpsig-pq@v1.0.0#machine-readable-test-vectors func TestRFC9421_MLDSA44(t *testing.T) { t.Parallel() diff --git a/proof/proof.go b/proof/proof.go index cbe48768..546b2129 100644 --- a/proof/proof.go +++ b/proof/proof.go @@ -17,6 +17,8 @@ limitations under the License. // Package proof creates and verifies integrity proofs. // // See https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md for more details. +// +// In addition to eddsa-jcs-2022, this package supports mldsa44-jcs-2024; see https://www.w3.org/TR/vc-di-quantum-resistant-1.0/#cryptosuite-mldsa44-jcs-2024. package proof import ( From 8dd87b7eac1cc5a922ef1432f2b5f4ba26fdae55 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 21:40:23 +0300 Subject: [PATCH 25/41] x --- FEDERATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 335ad36b..ad582913 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -49,9 +49,9 @@ In addition, tootik partially implements [RFC9421](https://datatracker.ietf.org/ tootik's actors have a traditional RSA key under `publicKey`, plus an Ed25519 key and a post-quantum ML-DSA-44 key under `assertionMethod`, as described in [FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md). -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 +* 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) 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`, `Ed25519Threshold` and `MLDSA44Threshold`) tries RFC9421, Ed25519 and ML-DSA-44 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too From d371ac8f1d92911b4f2f62d70fe301698da12de1 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Thu, 13 Aug 2026 23:56:36 +0300 Subject: [PATCH 26/41] x --- FEDERATION.md | 2 +- cfg/cfg.go | 6 ++++++ cluster/server.go | 1 + fed/verify.go | 5 +++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/FEDERATION.md b/FEDERATION.md index ad582913..11d98f26 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -53,7 +53,7 @@ By default, tootik uses `draft-cavage-http-signatures` when it signs outgoing re * 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, Ed25519 or ML-DSA-44 * 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`, `Ed25519Threshold` and `MLDSA44Threshold`) tries RFC9421, Ed25519 and ML-DSA-44 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too +-* 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`, `Ed25519Threshold` and `MLDSA44Threshold`) tries RFC9421, Ed25519 and ML-DSA-44 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too, and randomly refuses `draft-cavage-http-signatures` signatures (see `CavageDraftFailureThreshold`) in `POST` requests to encourage other servers to retry with RFC9421 ## Collections diff --git a/cfg/cfg.go b/cfg/cfg.go index 1dbdbacc..97ce0203 100644 --- a/cfg/cfg.go +++ b/cfg/cfg.go @@ -132,6 +132,8 @@ type Config struct { FillNodeInfoUsage bool + CavageDraftFailureThreshold float32 + RFC9421Threshold float32 Ed25519Threshold float32 MLDSA44Threshold float32 @@ -447,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 } diff --git a/cluster/server.go b/cluster/server.go index 34e513b2..e3f834ea 100644 --- a/cluster/server.go +++ b/cluster/server.go @@ -149,6 +149,7 @@ 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 diff --git a/fed/verify.go b/fed/verify.go index b09eaa3d..b96caa4e 100644 --- a/fed/verify.go +++ b/fed/verify.go @@ -23,6 +23,7 @@ import ( "encoding/pem" "errors" "fmt" + "math/rand/v2" "net/http" "strings" "time" @@ -67,6 +68,10 @@ func (l *Listener) extractRequestSignature(r *http.Request, body []byte) (*https return nil, fmt.Errorf("failed to extract signature: %w", err) } + if r.Method == http.MethodPost && (sig.Alg == "rsa-sha256" || sig.Alg == "hs2019") && rand.Float32() > l.Config.CavageDraftFailureThreshold { + return nil, errors.New("randomly refusing draft-cavage-http-signatures to encourage use of RFC9421") + } + return sig, err } From 060ca05a42f8b4924b90f96e1cde95500d8ff7fb Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 00:02:00 +0300 Subject: [PATCH 27/41] x --- outbox/poller.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/outbox/poller.go b/outbox/poller.go index cd598d1b..d3c8379e 100644 --- a/outbox/poller.go +++ b/outbox/poller.go @@ -19,10 +19,11 @@ package outbox import ( "context" "database/sql" - "github.com/dimkr/tootik/proof" "log/slog" "time" + "github.com/dimkr/tootik/proof" + "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/dbx" "github.com/dimkr/tootik/httpsig" @@ -36,14 +37,14 @@ type Poller struct { func (p *Poller) Run(ctx context.Context) error { rows, err := dbx.QueryCollectIgnore[struct { - PollID string - Option sql.NullString - OptionCount int64 - VotersCount int64 - Object ap.Object - Actor ap.Actor - ED25519PrivKey []byte - MLDSA44Seed []byte + PollID string + Option sql.NullString + OptionCount int64 + VotersCount int64 + Object ap.Object + Actor ap.Actor + ED25519Seed []byte + MLDSA44Seed []byte }]( ctx, p.DB, @@ -113,7 +114,7 @@ func (p *Poller) Run(ctx context.Context) error { info = &poll{ Object: row.Object, Author: row.Actor, - Key: proof.SigningSeed(&row.Actor, row.ED25519PrivKey, row.MLDSA44Seed), + Key: proof.SigningSeed(&row.Actor, row.ED25519Seed, row.MLDSA44Seed), CurrentVotersCount: row.VotersCount, CurrentVotes: make(map[string]int64, len(row.Object.AnyOf)), } From 87821e346e036d068e1d4a0e266465add158fff8 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 00:05:31 +0300 Subject: [PATCH 28/41] x --- FEDERATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FEDERATION.md b/FEDERATION.md index 11d98f26..19abeb09 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -53,7 +53,7 @@ By default, tootik uses `draft-cavage-http-signatures` when it signs outgoing re * 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, Ed25519 or ML-DSA-44 * 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`, `Ed25519Threshold` and `MLDSA44Threshold`) tries RFC9421, Ed25519 and ML-DSA-44 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too, and randomly refuses `draft-cavage-http-signatures` signatures (see `CavageDraftFailureThreshold`) in `POST` requests to encourage other servers to retry with RFC9421 +* 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`, `Ed25519Threshold` and `MLDSA44Threshold`) tries RFC9421, Ed25519 and ML-DSA-44 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too, and randomly refuses `draft-cavage-http-signatures` signatures (see `CavageDraftFailureThreshold`) in `POST` requests to encourage other servers to retry with RFC9421 ## Collections From 8bc44728c5323e5ae43401516c083e40fba32998 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 10:01:59 +0300 Subject: [PATCH 29/41] x --- cmd/tootik/main.go | 5 +++-- front/user/create.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/tootik/main.go b/cmd/tootik/main.go index a3ad9517..6c86b65c 100644 --- a/cmd/tootik/main.go +++ b/cmd/tootik/main.go @@ -24,11 +24,12 @@ import ( "errors" "flag" "fmt" - "github.com/dimkr/tootik/proof" "log/slog" "net/http" "os" + "github.com/dimkr/tootik/proof" + "github.com/google/uuid" "os/signal" @@ -290,7 +291,7 @@ func main() { var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, - `select select json(actor), ed25519seed from persons where ed25519seed is not null and actor->>'$.preferredUsername' = ?`, + `select select json(actor), ed25519seed, mldsa44seed from persons where ed25519seed is not null and actor->>'$.preferredUsername' = ?`, userName, ).Scan(&actor, &ed25519Seed, &mldsa44Seed); err != nil { panic(err) diff --git a/front/user/create.go b/front/user/create.go index f88c88f9..0e8279b5 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -124,7 +124,7 @@ func insertActor( if _, err := tx.ExecContext( ctx, - `INSERT OR IGNORE INTO keys (id, actor) VALUES ($1, $2), ($1, $3), ($1, $4)`, + `INSERT OR IGNORE INTO keys (actor, id) VALUES ($1, $2), ($1, $3), ($1, $4)`, actor.ID, actor.PublicKey.ID, actor.AssertionMethod[0].ID, From 07270da454fa54d0108ad9d09293179591c7f3c1 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 10:33:19 +0300 Subject: [PATCH 30/41] x --- cmd/tootik/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/tootik/main.go b/cmd/tootik/main.go index 6c86b65c..abf800d2 100644 --- a/cmd/tootik/main.go +++ b/cmd/tootik/main.go @@ -291,7 +291,7 @@ func main() { var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, - `select select json(actor), ed25519seed, mldsa44seed from persons where ed25519seed is not null and actor->>'$.preferredUsername' = ?`, + `select json(actor), ed25519seed, mldsa44seed from persons where ed25519seed is not null and actor->>'$.preferredUsername' = ?`, userName, ).Scan(&actor, &ed25519Seed, &mldsa44Seed); err != nil { panic(err) From c5ef9f50f0cd0f82b66c9118eb5f35bd14dd79a8 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 10:53:44 +0300 Subject: [PATCH 31/41] x --- data/key.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/key.go b/data/key.go index 695ee63a..49dfa115 100644 --- a/data/key.go +++ b/data/key.go @@ -45,7 +45,7 @@ func EncodeEd25519PublicKey(key ed25519.PublicKey) string { // EncodeMLDSA44PrivateKey encodes a ML-DSA-44 private key. func EncodeMLDSA44PrivateKey(key *mldsa44.PrivateKey) string { - return "u" + base64.RawURLEncoding.EncodeToString(append([]byte{0x13, 0x1a}, key.Seed()...)) + return "u" + base64.RawURLEncoding.EncodeToString(append([]byte{0x9a, 0x26}, key.Seed()...)) } // EncodeMLDSA44Publickey encodes a ML-DSA-44 public key. @@ -77,7 +77,7 @@ func DecodePrivateKey(key string) (PrivateKey, error) { if len(rawKey) == 2+ed25519.SeedSize && rawKey[0] == 0x80 && rawKey[1] == 0x26 { return ed25519.NewKeyFromSeed(rawKey[2:]), nil - } else if len(rawKey) == 2+mldsa44.SeedSize && rawKey[0] == 0x13 && rawKey[1] == 0x1a { + } else if len(rawKey) == 2+mldsa44.SeedSize && rawKey[0] == 0x9a && rawKey[1] == 0x26 { _, priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(rawKey[2:])) return priv, nil } else if len(rawKey) >= 2 { From 6d07d036ee39ec1032c8789b2355ea785459ea23 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 10:55:34 +0300 Subject: [PATCH 32/41] x --- front/user/create.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/front/user/create.go b/front/user/create.go index 0e8279b5..0227192b 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -215,6 +215,9 @@ func CreatePortableWithKey( mldsa44PubMultibase = data.EncodeMLDSA44Publickey(mldsa44Pub) ed25519PubMultibase = data.EncodeEd25519PublicKey(ed25519Pub) didKeyMultibase = mldsa44PubMultibase + + default: + return nil, [3]httpsig.Key{}, fmt.Errorf("unsupported key type: %T", priv) } id := fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, didKeyMultibase) From 2181ea789f0e39e4cdbfac89f5a8b7b3e2c1a230 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 10:56:28 +0300 Subject: [PATCH 33/41] x --- fed/followers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fed/followers.go b/fed/followers.go index e74f1218..77c50254 100644 --- a/fed/followers.go +++ b/fed/followers.go @@ -23,7 +23,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/dimkr/tootik/proof" "io" "log/slog" "net/http" @@ -37,6 +36,7 @@ import ( "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/dbx" "github.com/dimkr/tootik/httpsig" + "github.com/dimkr/tootik/proof" ) type partialFollowers map[string]map[string]string From 230a8c6e5189a0b996b94459b149639e3fef7089 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 10:59:54 +0300 Subject: [PATCH 34/41] x --- front/register.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/register.go b/front/register.go index 9b5ebf5a..bb8d625d 100644 --- a/front/register.go +++ b/front/register.go @@ -120,14 +120,14 @@ func (h *Handler) register(w text.Writer, r *Request, args ...string) { default: key, err := data.DecodePrivateKey(r.URL.RawQuery) if err != nil { - r.Log.Warn("Failed to decode Ed25519 private key", "name", userName, "error", err) + r.Log.Warn("Failed to decode private key", "name", userName, "error", err) w.Statusf(40, "Invalid key: %s", err.Error()) return } - switch v := key.(type) { + switch key.(type) { case ed25519.PrivateKey, *mldsa44.PrivateKey: - if _, _, err := user.CreatePortableWithKey(r.Context, h.Domain, h.DB, h.Config, userName, ap.Person, clientCert, v); err != nil { + if _, _, err := user.CreatePortableWithKey(r.Context, h.Domain, h.DB, h.Config, userName, ap.Person, clientCert, key); err != nil { r.Log.Warn("Failed to create new portable user", "name", userName, "error", err) w.Status(40, "Failed to create new user") return From 776163a3b800a0c49623ef76e1d570357dac8678 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 11:22:42 +0300 Subject: [PATCH 35/41] x --- ap/id.go | 20 +++++++++++++++----- fed/apgateway.go | 2 +- proof/key.go | 8 +++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/ap/id.go b/ap/id.go index d22dda92..1bc9cda8 100644 --- a/ap/id.go +++ b/ap/id.go @@ -22,18 +22,28 @@ import ( "regexp" ) -// MLDSA44Prefix is the prefix of base64url-encoded ML-DSA-44 public keys. -const MLDSA44Prefix = "ukC" +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}` + + // DIDKeyPattern matches a base58-encoded Ed25519 or base64url-encoded ML-DSA-44 public key. + DIDKeyPattern = ed25519PubBase58 + `|` + MLDSA44PubBase64 +) var ( // KeyRegex matches a Multibase-encoded Ed25519 or ML-DSA-44 public key. - KeyRegex = regexp.MustCompile(`\b(z(?:6Mk|4sd)[a-km-zA-HJ-NP-Z1-9]+|u(?:7Q|kC)[A-Za-z0-9_-]+)`) + KeyRegex = regexp.MustCompile(`\b(` + DIDKeyPattern + `|` + ed25519PubBase64 + `|` + mldsa44PubBase58 + `)(?:[\/#?]|$)`) // apURLRegex matches an ap:// URL. - apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) + apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(` + DIDKeyPattern + `)([\/#?].*|$)`) // 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]+|ukC[A-Za-z0-9_-]+)([\/#?].*)?`) + GatewayURLRegex = regexp.MustCompile(`^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\/\.well-known\/apgateway\/did:key:(` + ed25519PubBase58 + `|` + MLDSA44PubBase64 + `)([\/#?].*|$)`) ) // IsPortable determines whether or not an ActivityPub ID is portable. diff --git a/fed/apgateway.go b/fed/apgateway.go index dba8f7d5..c8ba0a71 100644 --- a/fed/apgateway.go +++ b/fed/apgateway.go @@ -40,7 +40,7 @@ import ( "github.com/dimkr/tootik/proof" ) -var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key:(?:z6Mk[a-km-zA-HJ-NP-Z1-9]+|ukC[A-Za-z0-9_-]+))(\/actor(?:\/[^\/]+)?)(\/.+)?`) +var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key:(?:` + ap.DIDKeyPattern + `))(\/actor(?:\/[^\/]+)?)(\/.+)?`) func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Request, did string) { var actor ap.Actor diff --git a/proof/key.go b/proof/key.go index 3b023ef9..f778e16b 100644 --- a/proof/key.go +++ b/proof/key.go @@ -18,16 +18,18 @@ package proof import ( "crypto/ed25519" - "strings" + "regexp" "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/httpsig" ) +var mldsa44DIDRegex = regexp.MustCompile(`\bdid:key:` + ap.MLDSA44PubBase64 + `(?:[\/#?]|$)`) + // SigningKey the key that should be used to create proofs on behalf of actor. func SigningKey(id string, keys [3]httpsig.Key) httpsig.Key { - if m := ap.KeyRegex.FindStringSubmatch(id); m != nil && strings.HasPrefix(m[1], ap.MLDSA44Prefix) { + if mldsa44DIDRegex.MatchString(id) { return keys[2] } @@ -36,7 +38,7 @@ func SigningKey(id string, keys [3]httpsig.Key) httpsig.Key { // SigningSeed the key that should be used to create proofs on behalf of actor. func SigningSeed(actor *ap.Actor, ed25519Seed, mldsa44Seed []byte) httpsig.Key { - if m := ap.KeyRegex.FindStringSubmatch(actor.ID); m != nil && strings.HasPrefix(m[1], ap.MLDSA44Prefix) { + if mldsa44DIDRegex.MatchString(actor.ID) { _, priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) return httpsig.Key{ ID: actor.AssertionMethod[1].ID, From 2524e006ea617367c8fad4804b88ffecc73f6d80 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 11:23:36 +0300 Subject: [PATCH 36/41] x --- cmd/tootik/main.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/tootik/main.go b/cmd/tootik/main.go index abf800d2..c6ae6fb6 100644 --- a/cmd/tootik/main.go +++ b/cmd/tootik/main.go @@ -28,8 +28,6 @@ import ( "net/http" "os" - "github.com/dimkr/tootik/proof" - "github.com/google/uuid" "os/signal" @@ -51,6 +49,7 @@ import ( "github.com/dimkr/tootik/inbox" "github.com/dimkr/tootik/migrations" "github.com/dimkr/tootik/outbox" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/sqlite" ) From aab270bb947350ddaeb161bccc4a8de566563a9e Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 11:45:35 +0300 Subject: [PATCH 37/41] x --- data/garbage.go | 10 +- fed/resolve.go | 2 +- front/fts.go | 12 +- inbox/inbox.go | 6 +- inbox/note/insert.go | 15 +- migrations/076_mldsa44slug.go | 258 +++++++++++++++++++++++----------- 6 files changed, 203 insertions(+), 100 deletions(-) diff --git a/data/garbage.go b/data/garbage.go index ef88fc3d..d5ba5f4b 100644 --- a/data/garbage.go +++ b/data/garbage.go @@ -35,7 +35,7 @@ type GarbageCollector struct { func (gc *GarbageCollector) Run(ctx context.Context) error { now := time.Now() - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where slug in (select notes.slug from notes left join follows on follows.followed in (notes.author, notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'$.to') where value = follows.followed)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = follows.followed)) where follows.accepted = 1 and notes.inserted < $1 and notes.host != $2 and follows.id is null and not exists (select 1 from bookmarks where bookmarks.note = notes.id) and not exists (select 1 from shares where shares.note = notes.id and exists (select 1 from persons where persons.id = shares.by and persons.host = $2)))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select notes.pk from notes left join follows on follows.followed in (notes.author, notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'$.to') where value = follows.followed)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = follows.followed)) where follows.accepted = 1 and notes.inserted < $1 and notes.host != $2 and follows.id is null and not exists (select 1 from bookmarks where bookmarks.note = notes.id) and not exists (select 1 from shares where shares.note = notes.id and exists (select 1 from persons where persons.id = shares.by and persons.host = $2)))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove invisible posts: %w", err) } @@ -43,7 +43,7 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove invisible posts: %w", err) } - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where slug in (select slug from notes where inserted < $1 and author not in (select followed from follows where accepted = 1) and host != $2 and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select pk from notes where inserted < $1 and author not in (select followed from follows where accepted = 1) and host != $2 and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove posts by authors without followers: %w", err) } @@ -51,7 +51,7 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove posts by authors without followers: %w", err) } - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where slug in (select slug from notes where inserted < ? and host != ? and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.NotesTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select pk from notes where inserted < ? and host != ? and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.NotesTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove old posts: %w", err) } @@ -110,6 +110,10 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove expired certificates: %w", err) } + if _, err := gc.DB.ExecContext(ctx, `insert into notesfts(notesfts, rank) values('merge', -16)`); err != nil { + return fmt.Errorf("failed to merge FTS: %w", err) + } + if _, err := gc.DB.ExecContext(ctx, `pragma optimize`); err != nil { return fmt.Errorf("failed to optimize: %w", err) } diff --git a/fed/resolve.go b/fed/resolve.go index 65f6fdf2..bca2b3b3 100644 --- a/fed/resolve.go +++ b/fed/resolve.go @@ -138,7 +138,7 @@ func (r *Resolver) validate(try func() (*ap.Actor, *ap.Actor, error)) (*ap.Actor } func deleteActor(ctx context.Context, db *sql.DB, id string) { - if _, err := db.ExecContext(ctx, `delete from notesfts where exists (select 1 from notes where notes.author = ? and notes.slug = notesfts.slug)`, id); err != nil { + if _, err := db.ExecContext(ctx, `delete from notesfts where exists (select 1 from notes where notes.author = ? and notes.pk = notesfts.rowid)`, id); err != nil { slog.Warn("Failed to delete notes by actor", "id", id, "error", err) } diff --git a/front/fts.go b/front/fts.go index b2b05aa3..4117139a 100644 --- a/front/fts.go +++ b/front/fts.go @@ -60,9 +60,9 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { r.Context, ` select json(notes.object), json(authors.actor), json(groups.actor), notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from - (select slug, rank from notesfts where content match $1 order by rank limit $2) top + (select rowid, rank from notesfts where content match $1 order by rank limit $2) top join notes on - notes.slug = top.slug + notes.pk = top.rowid join persons authors on authors.id = notes.author and coalesce(authors.actor->>'$.discoverable', 1) left join notes parent_notes on @@ -87,14 +87,14 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { r.Context, ` with top as ( - select slug, rank from notesfts where content match $1 order by rank limit $2 + select rowid, rank from notesfts where content match $1 order by rank limit $2 ) select json(u.object), json(authors.actor), json(groups.actor), u.inserted, u.nreplies, u.nquotes, u.nshares, json(parent_authors.actor) from ( select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 2 as aud from top join notes on - notes.slug = top.slug + notes.pk = top.rowid where notes.public = 1 union all @@ -114,7 +114,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { ) join top on - top.slug = notes.slug + top.rowid = notes.pk where follows.follower = $3 and follows.accepted = 1 @@ -122,7 +122,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 0 as aud from top join notes on - notes.slug = top.slug + notes.pk = top.rowid where ( $3 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or diff --git a/inbox/inbox.go b/inbox/inbox.go index 20f33e25..322a4aca 100644 --- a/inbox/inbox.go +++ b/inbox/inbox.go @@ -24,7 +24,6 @@ import ( "database/sql" "errors" "fmt" - "github.com/dimkr/tootik/proof" "log/slog" "net/url" "time" @@ -33,6 +32,7 @@ import ( "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/inbox/note" + "github.com/dimkr/tootik/proof" ) type Inbox struct { @@ -162,7 +162,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return fmt.Errorf("failed to delete %s: %w", deleted, err) } - if _, err := tx.ExecContext(ctx, `delete from notesfts where slug = (select slug from notes where id = ?)`, deleted); err != nil { + if _, err := tx.ExecContext(ctx, `delete from notesfts where rowid = (select pk from notes where id = ?)`, deleted); err != nil { return fmt.Errorf("cannot delete %s: %w", deleted, err) } if _, err := tx.ExecContext(ctx, `update notes set object = jsonb_set(jsonb_remove(object, '$.name', '$.summary', '$.tag', '$.attachment', '$.votersCount', '$.oneOf', '$.anyOf'), '$.content', '[deleted]'), deleted = 1 where id = ?`, deleted); err != nil { @@ -421,7 +421,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu if post.Content != oldPost.Content { if _, err := tx.ExecContext( ctx, - `update notesfts set content = ? where slug = (select slug from notes where id = ?)`, + `update notesfts set content = ? where rowid = (select pk from notes where id = ?)`, note.Flatten(post), post.ID, ); err != nil { diff --git a/inbox/note/insert.go b/inbox/note/insert.go index 7fadfb39..bc7a51a4 100644 --- a/inbox/note/insert.go +++ b/inbox/note/insert.go @@ -63,24 +63,23 @@ func Insert(ctx context.Context, tx *sql.Tx, note *ap.Object) error { public = 1 } - slug := ap.Slug(note.ID) - - if _, err := tx.ExecContext( + var pk int64 + if err := tx.QueryRowContext( ctx, - `INSERT INTO notes (slug, id, author, object, public) VALUES (?, ?, ?, JSONB(?), ?)`, - slug, + `INSERT INTO notes (slug, id, author, object, public) VALUES (?, ?, ?, JSONB(?), ?) RETURNING pk`, + ap.Slug(note.ID), note.ID, note.AttributedTo, ¬e, public, - ); err != nil { + ).Scan(&pk); err != nil { return fmt.Errorf("failed to insert note %s: %w", note.ID, err) } if _, err := tx.ExecContext( ctx, - `INSERT INTO notesfts (slug, content) VALUES(?,?)`, - slug, + `INSERT INTO notesfts (rowid, content) VALUES(?,?)`, + pk, Flatten(note), ); err != nil { return fmt.Errorf("failed to insert note %s: %w", note.ID, err) diff --git a/migrations/076_mldsa44slug.go b/migrations/076_mldsa44slug.go index 7028a0e0..ca6a3119 100644 --- a/migrations/076_mldsa44slug.go +++ b/migrations/076_mldsa44slug.go @@ -3,54 +3,185 @@ package migrations import ( "context" "database/sql" - "strings" + "fmt" "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/data" + "github.com/dimkr/tootik/inbox/note" ) -func insertSlugs(ctx context.Context, tx *sql.Tx, query string) error { - if _, err := tx.ExecContext(ctx, `DELETE FROM slugs`); err != nil { +func insertSlugs(ctx context.Context, tx *sql.Tx, table string) error { + if _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS slugs`); err != nil { return err } - rows, err := tx.QueryContext(ctx, query) - if err != nil { + if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE slugs(src INTEGER PRIMARY KEY, slug TEXT NOT NULL)`); err != nil { return err } - var ids []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() + type row struct { + src int64 + id string + } + batch := make([]row, 0, 10000) + query := fmt.Sprintf(`SELECT rowid, id FROM %s WHERE rowid > ? ORDER BY rowid LIMIT %d`, table, cap(batch)) + + last := int64(0) + for { + rows, err := tx.QueryContext(ctx, query, last) + if err != nil { + return err + } + + batch = batch[:0] + for rows.Next() { + var r row + if err := rows.Scan(&r.src, &r.id); err != nil { + rows.Close() + return err + } + + batch = append(batch, r) + } + + rows.Close() + + if err := rows.Err(); err != nil { return err } - ids = append(ids, id) + if len(batch) == 0 { + return nil + } + + for _, r := range batch { + if _, err := tx.ExecContext(ctx, `INSERT INTO slugs(src, slug) VALUES(?,?)`, r.src, ap.Slug(r.id)); err != nil { + return err + } + } + + last = batch[len(batch)-1].src } - rows.Close() +} - if err := rows.Err(); err != nil { - return err +func rebuildNotesFts(ctx context.Context, tx *sql.Tx) error { + type row struct { + pk int64 + content string } + batch := make([]row, 0, 1000) + query := fmt.Sprintf(`SELECT pk, JSON(object) FROM notes WHERE deleted = 0 AND pk > ? ORDER BY pk LIMIT %d`, cap(batch)) + + last := int64(0) + for { + rows, err := tx.QueryContext(ctx, query, last) + if err != nil { + return err + } + + batch = batch[:0] + for rows.Next() { + var pk int64 + var post ap.Object + if err := rows.Scan(&pk, &post); err != nil { + rows.Close() + return err + } + + batch = append(batch, row{pk, note.Flatten(&post)}) + } + + rows.Close() - for _, id := range ids { - if _, err := tx.ExecContext(ctx, `INSERT INTO slugs(id, slug) VALUES(?,?)`, id, ap.Slug(id)); err != nil { + if err := rows.Err(); err != nil { return err } + + if len(batch) == 0 { + break + } + + for _, r := range batch { + if _, err := tx.ExecContext(ctx, `INSERT INTO notesfts(rowid, content) VALUES(?,?)`, r.pk, r.content); err != nil { + return err + } + } + + last = batch[len(batch)-1].pk } - return nil + _, err := tx.ExecContext(ctx, `INSERT INTO notesfts(notesfts) VALUES('optimize')`) + return err } -func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { - if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE slugs(id TEXT NOT NULL PRIMARY KEY, slug TEXT NOT NULL)`); err != nil { - return err +func addMLDSA44Keys(ctx context.Context, tx *sql.Tx) error { + type local struct { + pk int64 + actor ap.Actor } + batch := make([]local, 0, 1000) + query := fmt.Sprintf(`SELECT pk, JSON(actor) FROM persons WHERE ed25519seed IS NOT NULL AND mldsa44seed IS NULL LIMIT %d`, cap(batch)) + + for { + rows, err := tx.QueryContext(ctx, query) + if err != nil { + return err + } + + batch = batch[:0] + for rows.Next() { + var l local + if err := rows.Scan(&l.pk, &l.actor); err != nil { + rows.Close() + return err + } + + batch = append(batch, l) + } + + rows.Close() + + if err := rows.Err(); err != nil { + return err + } + + if len(batch) == 0 { + return nil + } + + for _, l := range batch { + if len(l.actor.AssertionMethod) == 0 { + return fmt.Errorf("local actor %s has no assertion method", l.actor.ID) + } + + mldsa44Pub, mldsa44Priv, err := mldsa44.GenerateKey(nil) + if err != nil { + return err + } + + keyID := l.actor.ID + "#ml-dsa-44-key" - if err := insertSlugs(ctx, tx, `select id from notes`); err != nil { + l.actor.AssertionMethod = append(l.actor.AssertionMethod, ap.AssertionMethod{ + ID: keyID, + Type: "Multikey", + Controller: l.actor.ID, + PublicKeyMultibase: data.EncodeMLDSA44Publickey(mldsa44Pub), + }) + + if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = JSONB(?), mldsa44seed = ? WHERE pk = ?`, &l.actor, mldsa44Priv.Seed(), l.pk); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO keys(id, actor) VALUES(?,?)`, keyID, l.actor.ID); err != nil { + return err + } + } + } +} + +func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { + if err := insertSlugs(ctx, tx, `notes`); err != nil { return err } @@ -58,15 +189,15 @@ func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { `DROP TRIGGER nshares_insert`, `DROP TRIGGER nshares_delete`, - `CREATE TABLE nnotes(slug TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL UNIQUE, author TEXT NOT NULL, object JSONB NOT NULL, public INTEGER NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT 0, host TEXT AS (substr(substr(author, 9), 0, instr(substr(author, 9), '/'))), to0 TEXT AS (object->>'$.to[0]'), to1 TEXT AS (object->>'$.to[1]'), to2 TEXT AS (object->>'$.to[2]'), cc0 TEXT AS (object->>'$.cc[0]'), cc1 TEXT AS (object->>'$.cc[1]'), cc2 TEXT AS (object->>'$.cc[2]'), deleted INTEGER NOT NULL DEFAULT 0, nreplies INTEGER DEFAULT 0, nquotes INTEGER DEFAULT 0, nshares INTEGER DEFAULT 0, pulse INTEGER DEFAULT 0, cid TEXT NOT NULL UNIQUE AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`, - `INSERT INTO nnotes(slug, id, author, object, public, inserted, updated, deleted, nreplies, nquotes, nshares, pulse) SELECT slugs.slug, notes.id, author, object, public, inserted, updated, deleted, nreplies, nquotes, nshares, pulse FROM notes JOIN slugs ON slugs.id = notes.id`, - `CREATE VIRTUAL TABLE nnotesfts USING fts5(slug UNINDEXED, content, tokenize = "unicode61 tokenchars '#@'")`, - `INSERT INTO nnotesfts(slug, content) SELECT slugs.slug, notesfts.content FROM notesfts JOIN notes ON notes.rowid = notesfts.rowid JOIN slugs ON slugs.id = notes.id`, - `DROP TABLE notesfts`, - `ALTER TABLE nnotesfts RENAME TO notesfts`, + `CREATE TABLE nnotes(pk INTEGER PRIMARY KEY, slug TEXT NOT NULL, id TEXT NOT NULL, author TEXT NOT NULL, object JSONB NOT NULL, public INTEGER NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT 0, host TEXT AS (substr(substr(author, 9), 0, instr(substr(author, 9), '/'))), to0 TEXT AS (object->>'$.to[0]'), to1 TEXT AS (object->>'$.to[1]'), to2 TEXT AS (object->>'$.to[2]'), cc0 TEXT AS (object->>'$.cc[0]'), cc1 TEXT AS (object->>'$.cc[1]'), cc2 TEXT AS (object->>'$.cc[2]'), deleted INTEGER NOT NULL DEFAULT 0, nreplies INTEGER DEFAULT 0, nquotes INTEGER DEFAULT 0, nshares INTEGER DEFAULT 0, pulse INTEGER DEFAULT 0, cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`, + `INSERT INTO nnotes(slug, id, author, object, public, inserted, updated, deleted, nreplies, nquotes, nshares, pulse) SELECT slugs.slug, notes.id, author, object, public, inserted, updated, deleted, nreplies, nquotes, nshares, pulse FROM notes JOIN slugs ON slugs.src = notes.rowid`, `DROP TABLE notes`, `ALTER TABLE nnotes RENAME TO notes`, + `CREATE UNIQUE INDEX notesid ON notes(id)`, + `CREATE UNIQUE INDEX notescid ON notes(cid)`, + `CREATE UNIQUE INDEX notesslug ON notes(slug)`, + `CREATE INDEX notesinserted ON notes(inserted)`, `CREATE INDEX notespublicauthor ON notes(public, author)`, `CREATE INDEX noteshostinserted on notes(host, inserted)`, @@ -173,78 +304,47 @@ func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { BEGIN DELETE FROM hashtags WHERE note = old.id; END`, + + `DROP TABLE notesfts`, + `CREATE VIRTUAL TABLE notesfts USING fts5(content, tokenize = "unicode61 tokenchars '#@'", content='', contentless_delete=1)`, } { if _, err := tx.ExecContext(ctx, stmt); err != nil { return err } } - if err := insertSlugs(ctx, tx, `select id from persons`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `CREATE TABLE npersons(slug TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL UNIQUE, actor JSONB NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT (UNIXEPOCH()), host TEXT AS (substr(substr(id, 9), 0, instr(substr(id, 9), '/'))), fetched INTEGER, ttl INTEGER, rsaprivkey BLOB, ed25519seed BLOB, mldsa44seed BLOB, cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, `INSERT INTO npersons(slug, id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519seed) SELECT slugs.slug, persons.id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey FROM persons JOIN slugs ON slugs.id = persons.id`); err != nil { + if err := rebuildNotesFts(ctx, tx); err != nil { return err } - rows, err := tx.QueryContext(ctx, `SELECT id, JSON(actor) FROM npersons WHERE ed25519seed IS NOT NULL`) - if err != nil { + if err := insertSlugs(ctx, tx, `persons`); err != nil { return err } - defer rows.Close() - - for rows.Next() { - var id string - var actor ap.Actor - if err := rows.Scan(&id, &actor); err != nil { - return err - } - - if len(actor.AssertionMethod) == 0 { - continue - } - - last := actor.AssertionMethod[len(actor.AssertionMethod)-1] - - prefix, ok := strings.CutSuffix(last.ID, "#ed25519-key") - if !ok { - continue - } - - mldsa44Pub, mldsa44Priv, err := mldsa44.GenerateKey(nil) - if err != nil { - return err - } - - actor.AssertionMethod = append(actor.AssertionMethod, ap.AssertionMethod{ - ID: prefix + "#ml-dsa-44-key", - Type: "Multikey", - Controller: last.Controller, - PublicKeyMultibase: data.EncodeMLDSA44Publickey(mldsa44Pub), - }) + for _, stmt := range []string{ + `CREATE TABLE npersons(pk INTEGER PRIMARY KEY, slug TEXT NOT NULL, id TEXT NOT NULL, actor JSONB NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT (UNIXEPOCH()), host TEXT AS (substr(substr(id, 9), 0, instr(substr(id, 9), '/'))), fetched INTEGER, ttl INTEGER, rsaprivkey BLOB, ed25519seed BLOB, mldsa44seed BLOB, cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`, + `INSERT INTO npersons(slug, id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519seed) SELECT slugs.slug, persons.id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey FROM persons JOIN slugs ON slugs.src = persons.rowid`, + `DROP TABLE persons`, + `ALTER TABLE npersons RENAME TO persons`, - if _, err := tx.ExecContext(ctx, `UPDATE npersons SET actor = JSONB(?), mldsa44seed = ? WHERE id = ?`, &actor, mldsa44Priv.Seed(), id); err != nil { + `CREATE UNIQUE INDEX personsslug ON persons(slug)`, + `CREATE UNIQUE INDEX personsid ON persons(id)`, + `CREATE INDEX personstypeid ON persons(actor->>'$.type', id)`, + `CREATE INDEX personsmovedto ON persons(actor->>'$.movedTo') WHERE actor->>'$.movedTo' IS NOT NULL`, + `CREATE UNIQUE INDEX personspreferredusernamehosttype ON persons(actor->>'$.preferredUsername', host, actor->>'$.type')`, + `CREATE INDEX personscid ON persons(cid)`, + `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519seed IS NOT NULL`, + } { + if _, err := tx.ExecContext(ctx, stmt); err != nil { return err } } - if err := rows.Err(); err != nil { + if err := addMLDSA44Keys(ctx, tx); err != nil { return err } for _, stmt := range []string{ - `DROP TABLE persons`, - `ALTER TABLE npersons RENAME TO persons`, - `CREATE INDEX personstypeid ON persons(actor->>'$.type', id)`, - `CREATE INDEX personsmovedto ON persons(actor->>'$.movedTo') WHERE actor->>'$.movedTo' IS NOT NULL`, - `CREATE UNIQUE INDEX personspreferredusernamehosttype ON persons(actor->>'$.preferredUsername', host, actor->>'$.type')`, - `CREATE INDEX personscid ON persons(cid)`, - `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519seed IS NOT NULL`, `DROP INDEX outboxcidsender`, `ALTER TABLE outbox DROP COLUMN cid`, `ALTER TABLE outbox ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN activity->>'$.id' LIKE 'https://%' AND (activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22, CASE WHEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') - 1 ELSE LENGTH(activity->>'$.id') END) WHEN activity->>'$.id' LIKE 'https://%' THEN activity->>'$.id' ELSE NULL END)`, @@ -255,6 +355,6 @@ func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { } } - _, err = tx.ExecContext(ctx, `DROP TABLE slugs`) + _, err := tx.ExecContext(ctx, `DROP TABLE slugs`) return err } From 9266e8f0fe874753017c0b896902d05b2062b860 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 11:51:54 +0300 Subject: [PATCH 38/41] x --- ap/id.go | 10 +++++----- fed/apgateway.go | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ap/id.go b/ap/id.go index 1bc9cda8..dacb4391 100644 --- a/ap/id.go +++ b/ap/id.go @@ -31,16 +31,16 @@ const ( // MLDSA44PubBase64 matches a base64url-encoded ML-DSA-44 public key. MLDSA44PubBase64 = `ukC[A-Za-z0-9_-]{1000}[A-Za-z0-9_-]{750}` - // DIDKeyPattern matches a base58-encoded Ed25519 or base64url-encoded ML-DSA-44 public key. - DIDKeyPattern = ed25519PubBase58 + `|` + MLDSA44PubBase64 + // PortableActorPubPattern matches public keys in portable actor did:key DIDs. + PortableActorPubPattern = ed25519PubBase58 + `|` + MLDSA44PubBase64 ) var ( - // KeyRegex matches a Multibase-encoded Ed25519 or ML-DSA-44 public key. - KeyRegex = regexp.MustCompile(`\b(` + DIDKeyPattern + `|` + ed25519PubBase64 + `|` + mldsa44PubBase58 + `)(?:[\/#?]|$)`) + // 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:(` + DIDKeyPattern + `)([\/#?].*|$)`) + 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:(` + ed25519PubBase58 + `|` + MLDSA44PubBase64 + `)([\/#?].*|$)`) diff --git a/fed/apgateway.go b/fed/apgateway.go index c8ba0a71..7fe2ddec 100644 --- a/fed/apgateway.go +++ b/fed/apgateway.go @@ -40,7 +40,7 @@ import ( "github.com/dimkr/tootik/proof" ) -var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key:(?:` + ap.DIDKeyPattern + `))(\/actor(?:\/[^\/]+)?)(\/.+)?`) +var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key:(?:` + ap.PortableActorPubPattern + `))(\/actor(?:\/[^\/]+)?)(\/.+)?`) func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Request, did string) { var actor ap.Actor From de642befa436820baddb72d541d9299df2bf3ee0 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 11:53:58 +0300 Subject: [PATCH 39/41] x --- ap/id.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ap/id.go b/ap/id.go index dacb4391..246cf28e 100644 --- a/ap/id.go +++ b/ap/id.go @@ -43,7 +43,7 @@ var ( 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:(` + ed25519PubBase58 + `|` + MLDSA44PubBase64 + `)([\/#?].*|$)`) + 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. From 7f9a3b992217a0f11ff806536613602d98e1121d Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 12:23:01 +0300 Subject: [PATCH 40/41] x --- FEDERATION.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 19abeb09..ad8ae38e 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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"` @@ -47,13 +47,16 @@ In addition, tootik partially implements [RFC9421](https://datatracker.ietf.org/ * 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`, plus an Ed25519 key and a post-quantum ML-DSA-44 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 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, Ed25519 or ML-DSA-44 -* 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`, `Ed25519Threshold` and `MLDSA44Threshold`) tries RFC9421, Ed25519 and ML-DSA-44 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too, and randomly refuses `draft-cavage-http-signatures` signatures (see `CavageDraftFailureThreshold`) in `POST` requests to encourage other servers to retry with RFC9421 +* 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 @@ -151,7 +154,7 @@ By default, tootik omits user and post counters unless `FillNodeInfoUsage` is ch # Data Portability -tootik partially supports [FEP-ef61](https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md) portable actors, activities and objects, and extends it by supporting DIDs constructed using base64url-encoded ML-DSA-44 keys. +tootik partially supports [FEP-ef61](https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md) portable actors, activities and objects. If * `alice@a.localdomain` is `https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor` @@ -171,11 +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. -All portable actors have both Ed25519 and ML-DSA-44 keys. +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. -tootik allows the user to supply a base58-encoded Ed25519 or base64url-encoded ML-DSA-44 private key during registration, instead of using a randomly generated `did:key:z6Mk...` DID. The key, like the user's `preferredUsername`, must be unique per tootik instance. Users created by providing a ML-DSA-44 key use [`mldsa44-jcs-2024`](https://www.w3.org/TR/vc-di-quantum-resistant-1.0/#cryptosuite-mldsa44-jcs-2024) integrity proofs, while others use `eddsa-jcs-2022`. +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. From c18400c8df83f22c74ac9c7165fe6f2c296c6fd4 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Fri, 14 Aug 2026 19:39:03 +0300 Subject: [PATCH 41/41] x --- FEDERATION.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 19abeb09..a98070ee 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -245,7 +245,7 @@ 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`. @@ -253,11 +253,11 @@ Moreover, all objects and activities owned by a portable actor contain an integr ## 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. @@ -278,4 +278,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.