From ab6602815141119a4a11915e60554ef89e1a8fc4 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sat, 2 May 2026 22:13:53 -0500 Subject: [PATCH] more labeler improvements. standardize did work between labeler and hold. improve sql race conditions on local-only db --- .air.labeler.toml | 25 + cmd/hold/plc.go | 271 ++++++----- cmd/hold/repo.go | 13 +- cmd/labeler/main.go | 1 + cmd/labeler/plc.go | 225 +++++++++ config-labeler.example.yaml | 55 +++ deploy/upcloud/configs/labeler.yaml.tmpl | 7 +- docker-compose.yml | 50 ++ ...te_labels.yaml => 0023_create_labels.yaml} | 0 pkg/appview/db/queries.go | 43 +- pkg/appview/db/readonly.go | 17 +- pkg/appview/db/schema.go | 86 +++- pkg/appview/handlers/diff.go | 2 +- pkg/appview/handlers/digest.go | 2 +- pkg/appview/handlers/repository.go | 2 +- pkg/appview/handlers/user.go | 2 +- pkg/appview/labeler/subscriber.go | 121 +++-- pkg/atproto/did/cmd.go | 208 +++++++++ pkg/atproto/did/cmd_test.go | 304 ++++++++++++ pkg/atproto/did/did.go | 243 ++++++++++ pkg/atproto/did/did_test.go | 356 ++++++++++++++ pkg/atproto/did/plc.go | 172 +++++++ pkg/atproto/did/plc_test.go | 243 ++++++++++ pkg/atproto/did/web.go | 24 + pkg/atproto/did/web_test.go | 38 ++ pkg/billing/billing.go | 8 +- pkg/hold/config.go | 19 + pkg/hold/pds/did.go | 435 ------------------ pkg/hold/pds/did_test.go | 274 ----------- pkg/hold/pds/export.go | 11 - pkg/hold/pds/{server.go => hold_pds.go} | 12 + .../pds/{server_test.go => hold_pds_test.go} | 0 pkg/hold/pds/xrpc.go | 5 +- pkg/hold/server.go | 11 +- pkg/labeler/auth.go | 209 ++++++++- pkg/labeler/config.go | 96 +++- pkg/labeler/config_test.go | 24 - pkg/labeler/db.go | 326 +++++++++---- pkg/labeler/db_test.go | 321 ++++++------- pkg/labeler/handlers.go | 7 +- pkg/labeler/hub.go | 75 +++ pkg/labeler/identity.go | 59 ++- pkg/labeler/server.go | 66 ++- pkg/labeler/server_test.go | 30 ++ pkg/labeler/subscribe.go | 348 ++++++++++---- pkg/labeler/subscribe_test.go | 117 +++-- pkg/labeler/takedown.go | 222 ++++++--- pkg/labeler/takedown_test.go | 103 +++-- 48 files changed, 3735 insertions(+), 1553 deletions(-) create mode 100644 .air.labeler.toml create mode 100644 cmd/labeler/plc.go create mode 100644 config-labeler.example.yaml rename pkg/appview/db/migrations/{0017_create_labels.yaml => 0023_create_labels.yaml} (100%) create mode 100644 pkg/atproto/did/cmd.go create mode 100644 pkg/atproto/did/cmd_test.go create mode 100644 pkg/atproto/did/did.go create mode 100644 pkg/atproto/did/did_test.go create mode 100644 pkg/atproto/did/plc.go create mode 100644 pkg/atproto/did/plc_test.go create mode 100644 pkg/atproto/did/web.go create mode 100644 pkg/atproto/did/web_test.go delete mode 100644 pkg/hold/pds/did.go delete mode 100644 pkg/hold/pds/did_test.go delete mode 100644 pkg/hold/pds/export.go rename pkg/hold/pds/{server.go => hold_pds.go} (97%) rename pkg/hold/pds/{server_test.go => hold_pds_test.go} (100%) create mode 100644 pkg/labeler/hub.go create mode 100644 pkg/labeler/server_test.go diff --git a/.air.labeler.toml b/.air.labeler.toml new file mode 100644 index 0000000..dec1af6 --- /dev/null +++ b/.air.labeler.toml @@ -0,0 +1,25 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -buildvcs=false -o ./tmp/atcr-labeler ./cmd/labeler" +entrypoint = ["./tmp/atcr-labeler", "serve", "--config", "config-labeler.example.yaml"] +include_ext = ["go", "html", "css", "js"] +exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview", "pkg/hold", "node_modules"] +exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$", "public/css/style\\.css$", "public/icons\\.svg$"] +delay = 3000 +stop_on_error = true +send_interrupt = true +kill_delay = 500 + +[log] +time = false + +[color] +main = "cyan" +watcher = "magenta" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/cmd/hold/plc.go b/cmd/hold/plc.go index 753cad4..54de050 100644 --- a/cmd/hold/plc.go +++ b/cmd/hold/plc.go @@ -5,12 +5,11 @@ import ( "fmt" "log/slog" + "atcr.io/pkg/atproto/did" "atcr.io/pkg/auth/oauth" "atcr.io/pkg/hold" - "atcr.io/pkg/hold/pds" "github.com/bluesky-social/indigo/atproto/atcrypto" - didplc "github.com/did-method-plc/go-didplc" "github.com/spf13/cobra" ) @@ -21,144 +20,210 @@ var plcCmd = &cobra.Command{ var plcConfigFile string +var ( + plcAddRotationKeyFirst bool + plcAddRotationKeyLast bool +) + var plcAddRotationKeyCmd = &cobra.Command{ - Use: "add-rotation-key ", + Use: "add-rotation-key [multibase-key]", Short: "Add a rotation key to this hold's PLC identity", Long: `Add an additional rotation key to the hold's did:plc document. -The key must be a multibase-encoded private key (K-256 or P-256, starting with 'z'). + +If a multibase-encoded private key (K-256 or P-256, starting with 'z') is supplied as +the positional argument, that key is added. If no argument is given, a fresh K-256 +keypair is generated and the private half is printed to stdout. Save it offline as +your recovery key, since it will not be shown again. + +By default the new key is inserted at the highest priority position (--first), which +allows it to override ops signed by lower-priority keys within PLC's 72-hour recovery +window. Pass --last to append at the lowest priority instead. + The hold's configured rotation key is used to sign the PLC update. - atcr-hold plc add-rotation-key --config config.yaml z...`, - Args: cobra.ExactArgs(1), + atcr-hold plc add-rotation-key --config config.yaml # generate + print + atcr-hold plc add-rotation-key --config config.yaml --last # append, low priority + atcr-hold plc add-rotation-key --config config.yaml z... # use supplied key`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + firstSet := cmd.Flags().Changed("first") + lastSet := cmd.Flags().Changed("last") + if firstSet && lastSet { + return fmt.Errorf("--first and --last are mutually exclusive") + } + prepend := !plcAddRotationKeyLast + cfg, err := hold.LoadConfig(plcConfigFile) if err != nil { return fmt.Errorf("failed to load config: %w", err) } - if cfg.Database.DIDMethod != "plc" { return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod) } ctx := context.Background() - - // Resolve the hold's DID - holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{ - DID: cfg.Database.DID, - DIDMethod: cfg.Database.DIDMethod, - PublicURL: cfg.Server.PublicURL, - DBPath: cfg.Database.Path, - SigningKeyPath: cfg.Database.KeyPath, - RotationKey: cfg.Database.RotationKey, - PLCDirectoryURL: cfg.Database.PLCDirectoryURL, - }) + holdDID, rotationKey, signingKey, err := loadHoldPLCIdentity(ctx, cfg) if err != nil { - return fmt.Errorf("failed to resolve hold DID: %w", err) + return err } - // Parse the rotation key from config (required for signing PLC updates) - if cfg.Database.RotationKey == "" { - return fmt.Errorf("database.rotation_key must be set to sign PLC updates") - } - rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey) - if err != nil { - return fmt.Errorf("failed to parse rotation_key from config: %w", err) - } - - // Parse the new key to add (K-256 or P-256) - newKey, err := atcrypto.ParsePrivateMultibase(args[0]) - if err != nil { - return fmt.Errorf("failed to parse key argument: %w", err) - } - newKeyPub, err := newKey.PublicKey() - if err != nil { - return fmt.Errorf("failed to get public key from argument: %w", err) - } - newKeyDIDKey := newKeyPub.DIDKey() - - // Load signing key for verification methods - keyPath := cfg.Database.KeyPath - if keyPath == "" { - keyPath = cfg.Database.Path + "/signing.key" - } - signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath) - if err != nil { - return fmt.Errorf("failed to load signing key: %w", err) - } - - // Fetch current PLC state - plcDirectoryURL := cfg.Database.PLCDirectoryURL - if plcDirectoryURL == "" { - plcDirectoryURL = "https://plc.directory" - } - client := &didplc.Client{DirectoryURL: plcDirectoryURL} - - opLog, err := client.OpLog(ctx, holdDID) - if err != nil { - return fmt.Errorf("failed to fetch PLC op log: %w", err) - } - if len(opLog) == 0 { - return fmt.Errorf("empty op log for %s", holdDID) - } - - lastEntry := opLog[len(opLog)-1] - lastOp := lastEntry.Regular - if lastOp == nil { - return fmt.Errorf("last PLC operation is not a regular op") - } - - // Check if key already present - for _, k := range lastOp.RotationKeys { - if k == newKeyDIDKey { - fmt.Printf("Key %s is already a rotation key for %s\n", newKeyDIDKey, holdDID) - return nil + var newKey atcrypto.PrivateKeyExportable + if len(args) == 1 { + newKey, err = atcrypto.ParsePrivateMultibase(args[0]) + if err != nil { + return fmt.Errorf("failed to parse key argument: %w", err) } } - // Build updated rotation keys: keep existing, append new - rotationKeys := make([]string, len(lastOp.RotationKeys)) - copy(rotationKeys, lastOp.RotationKeys) - rotationKeys = append(rotationKeys, newKeyDIDKey) - - // Build update: preserve everything else from current state - sigPub, err := signingKey.PublicKey() + res, err := did.AddRotationKey(ctx, did.AddRotationKeyOptions{ + DID: holdDID, + PLCDirectoryURL: cfg.Database.PLCDirectoryURL, + RotationKey: rotationKey, + SigningKey: signingKey, + VerificationKeyName: "atproto", + NewKey: newKey, + Prepend: prepend, + }) if err != nil { - return fmt.Errorf("failed to get signing public key: %w", err) + return err } - prevCID := lastEntry.AsOperation().CID().String() - - op := &didplc.RegularOp{ - Type: "plc_operation", - RotationKeys: rotationKeys, - VerificationMethods: map[string]string{ - "atproto": sigPub.DIDKey(), - }, - AlsoKnownAs: lastOp.AlsoKnownAs, - Services: lastOp.Services, - Prev: &prevCID, + if res.AlreadyPresent { + fmt.Printf("Key %s is already a rotation key for %s (priority %d of %d)\n", + res.NewKeyDIDKey, holdDID, res.ExistingAt, res.TotalKeys) + return nil } - if err := op.Sign(rotationKey); err != nil { - return fmt.Errorf("failed to sign PLC update: %w", err) - } - - if err := client.Submit(ctx, holdDID, op); err != nil { - return fmt.Errorf("failed to submit PLC update: %w", err) + if res.Generated { + fmt.Println("=========================================================================") + fmt.Println("GENERATED NEW ROTATION KEY. SAVE THIS NOW. IT WILL NOT BE SHOWN AGAIN.") + fmt.Println("Store it offline (password manager, paper, hardware token).") + fmt.Println() + fmt.Printf("Private key (multibase): %s\n", res.NewKey.Multibase()) + fmt.Printf("Public key (did:key): %s\n", res.NewKeyDIDKey) + fmt.Println("=========================================================================") } slog.Info("Added rotation key to PLC identity", "did", holdDID, - "new_key", newKeyDIDKey, - "total_rotation_keys", len(rotationKeys), + "new_key", res.NewKeyDIDKey, + "priority", res.InsertedAt, + "total_rotation_keys", res.TotalKeys, + "generated", res.Generated, ) - fmt.Printf("Added rotation key %s to %s\n", newKeyDIDKey, holdDID) + fmt.Printf("Added rotation key %s to %s (priority %d of %d)\n", + res.NewKeyDIDKey, holdDID, res.InsertedAt, res.TotalKeys) return nil }, } +var plcListRotationKeysCmd = &cobra.Command{ + Use: "list-rotation-keys", + Short: "List rotation keys in this hold's PLC document", + Long: `Fetch the hold's did:plc document from the PLC directory and print its +rotation keys in priority order (index 0 is highest priority and can override +ops signed by lower-priority keys within PLC's 72-hour recovery window). + +The key matching the local database.rotation_key is marked as LOCAL.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := hold.LoadConfig(plcConfigFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if cfg.Database.DIDMethod != "plc" { + return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod) + } + + ctx := context.Background() + holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig()) + if err != nil { + return fmt.Errorf("failed to resolve hold DID: %w", err) + } + + var localRotationKey atcrypto.PrivateKey + if cfg.Database.RotationKey != "" { + localRotationKey, err = atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey) + if err != nil { + return fmt.Errorf("failed to parse rotation_key from config: %w", err) + } + } + + res, err := did.ListRotationKeys(ctx, did.ListRotationKeysOptions{ + DID: holdDID, + PLCDirectoryURL: cfg.Database.PLCDirectoryURL, + LocalRotationKey: localRotationKey, + }) + if err != nil { + return err + } + printRotationKeys(res) + return nil + }, +} + +// loadHoldPLCIdentity is the shared "load DID + rotation key + signing key" helper used +// by every PLC command. It enforces that database.rotation_key is set since every PLC +// command needs a rotation key to either sign updates or verify the LOCAL marker. +func loadHoldPLCIdentity(ctx context.Context, cfg *hold.Config) (string, atcrypto.PrivateKey, *atcrypto.PrivateKeyK256, error) { + holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig()) + if err != nil { + return "", nil, nil, fmt.Errorf("failed to resolve hold DID: %w", err) + } + + if cfg.Database.RotationKey == "" { + return "", nil, nil, fmt.Errorf("database.rotation_key must be set to sign PLC updates") + } + rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey) + if err != nil { + return "", nil, nil, fmt.Errorf("failed to parse rotation_key from config: %w", err) + } + + keyPath := cfg.Database.KeyPath + if keyPath == "" { + keyPath = cfg.Database.Path + "/signing.key" + } + signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath) + if err != nil { + return "", nil, nil, fmt.Errorf("failed to load signing key: %w", err) + } + return holdDID, rotationKey, signingKey, nil +} + +// printRotationKeys is the shared CLI output for `list-rotation-keys`. +func printRotationKeys(res *did.ListRotationKeysResult) { + fmt.Printf("DID: %s\n", res.DID) + fmt.Printf("PLC directory: %s\n", res.Directory) + fmt.Printf("Rotation keys (%d):\n", len(res.Keys)) + for i, k := range res.Keys { + marker := "" + switch { + case len(res.Keys) == 1: + marker = "(only key)" + case i == 0: + marker = "(highest priority)" + case i == len(res.Keys)-1: + marker = "(lowest priority)" + } + localTag := "" + if res.LocalDIDKey != "" && k == res.LocalDIDKey { + localTag = " [LOCAL — database.rotation_key]" + } + fmt.Printf(" [%d] %s %s%s\n", i, k, marker, localTag) + } + + if res.LocalDIDKey != "" && !res.LocalPresent { + fmt.Printf("\nWARNING: local rotation_key (%s) is NOT present in the PLC document.\n", res.LocalDIDKey) + fmt.Println("This service cannot sign PLC updates. Possible compromise or out-of-band rotation.") + } +} + func init() { plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file") + plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyFirst, "first", true, "insert at highest priority (default)") + plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyLast, "last", false, "insert at lowest priority") + plcCmd.AddCommand(plcAddRotationKeyCmd) + plcCmd.AddCommand(plcListRotationKeysCmd) } diff --git a/cmd/hold/repo.go b/cmd/hold/repo.go index 45b7acd..4bb66c4 100644 --- a/cmd/hold/repo.go +++ b/cmd/hold/repo.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" + "atcr.io/pkg/atproto/did" "atcr.io/pkg/hold" holddb "atcr.io/pkg/hold/db" "atcr.io/pkg/hold/pds" @@ -39,7 +40,7 @@ The CAR is written to stdout, so redirect to a file: } defer cleanup() - if err := holdPDS.ExportToCAR(ctx, os.Stdout); err != nil { + if err := holdPDS.RepomgrRef().ReadRepo(ctx, holdPDS.UID(), "", os.Stdout); err != nil { return fmt.Errorf("failed to export: %w", err) } @@ -105,15 +106,7 @@ func init() { // openHoldPDS creates a HoldPDS from config for offline CLI operations. // Returns the PDS and a cleanup function that must be deferred. func openHoldPDS(ctx context.Context, cfg *hold.Config) (*pds.HoldPDS, func(), error) { - holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{ - DID: cfg.Database.DID, - DIDMethod: cfg.Database.DIDMethod, - PublicURL: cfg.Server.PublicURL, - DBPath: cfg.Database.Path, - SigningKeyPath: cfg.Database.KeyPath, - RotationKey: cfg.Database.RotationKey, - PLCDirectoryURL: cfg.Database.PLCDirectoryURL, - }) + holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig()) if err != nil { return nil, nil, fmt.Errorf("failed to resolve hold DID: %w", err) } diff --git a/cmd/labeler/main.go b/cmd/labeler/main.go index d4e095d..3b22f81 100644 --- a/cmd/labeler/main.go +++ b/cmd/labeler/main.go @@ -73,6 +73,7 @@ func init() { rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(plcCmd) } func main() { diff --git a/cmd/labeler/plc.go b/cmd/labeler/plc.go new file mode 100644 index 0000000..b2215f8 --- /dev/null +++ b/cmd/labeler/plc.go @@ -0,0 +1,225 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + + "atcr.io/pkg/atproto/did" + "atcr.io/pkg/auth/oauth" + "atcr.io/pkg/labeler" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/spf13/cobra" +) + +var plcCmd = &cobra.Command{ + Use: "plc", + Short: "PLC directory management commands", +} + +var plcConfigFile string + +var ( + plcAddRotationKeyFirst bool + plcAddRotationKeyLast bool +) + +var plcAddRotationKeyCmd = &cobra.Command{ + Use: "add-rotation-key [multibase-key]", + Short: "Add a rotation key to this labeler's PLC identity", + Long: `Add an additional rotation key to the labeler's did:plc document. + +If a multibase-encoded private key (K-256 or P-256, starting with 'z') is supplied as +the positional argument, that key is added. If no argument is given, a fresh K-256 +keypair is generated and the private half is printed to stdout. Save it offline as +your recovery key, since it will not be shown again. + +By default the new key is inserted at the highest priority position (--first), which +allows it to override ops signed by lower-priority keys within PLC's 72-hour recovery +window. Pass --last to append at the lowest priority instead. + +The labeler's configured rotation key is used to sign the PLC update. + + atcr-labeler plc add-rotation-key --config config.yaml # generate + print + atcr-labeler plc add-rotation-key --config config.yaml --last # append, low priority + atcr-labeler plc add-rotation-key --config config.yaml z... # use supplied key`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + firstSet := cmd.Flags().Changed("first") + lastSet := cmd.Flags().Changed("last") + if firstSet && lastSet { + return fmt.Errorf("--first and --last are mutually exclusive") + } + prepend := !plcAddRotationKeyLast + + cfg, err := labeler.LoadConfig(plcConfigFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if cfg.Labeler.DIDMethod != "plc" { + return fmt.Errorf("this command only works with did:plc (labeler.did_method is %q)", cfg.Labeler.DIDMethod) + } + + ctx := context.Background() + labelerDID, rotationKey, signingKey, err := loadLabelerPLCIdentity(ctx, cfg) + if err != nil { + return err + } + + var newKey atcrypto.PrivateKeyExportable + if len(args) == 1 { + newKey, err = atcrypto.ParsePrivateMultibase(args[0]) + if err != nil { + return fmt.Errorf("failed to parse key argument: %w", err) + } + } + + res, err := did.AddRotationKey(ctx, did.AddRotationKeyOptions{ + DID: labelerDID, + PLCDirectoryURL: cfg.PLCDirectoryURL(), + RotationKey: rotationKey, + SigningKey: signingKey, + VerificationKeyName: "atproto_label", + NewKey: newKey, + Prepend: prepend, + }) + if err != nil { + return err + } + + if res.AlreadyPresent { + fmt.Printf("Key %s is already a rotation key for %s (priority %d of %d)\n", + res.NewKeyDIDKey, labelerDID, res.ExistingAt, res.TotalKeys) + return nil + } + + if res.Generated { + fmt.Println("=========================================================================") + fmt.Println("GENERATED NEW ROTATION KEY. SAVE THIS NOW. IT WILL NOT BE SHOWN AGAIN.") + fmt.Println("Store it offline (password manager, paper, hardware token).") + fmt.Println() + fmt.Printf("Private key (multibase): %s\n", res.NewKey.Multibase()) + fmt.Printf("Public key (did:key): %s\n", res.NewKeyDIDKey) + fmt.Println("=========================================================================") + } + + slog.Info("Added rotation key to PLC identity", + "did", labelerDID, + "new_key", res.NewKeyDIDKey, + "priority", res.InsertedAt, + "total_rotation_keys", res.TotalKeys, + "generated", res.Generated, + ) + fmt.Printf("Added rotation key %s to %s (priority %d of %d)\n", + res.NewKeyDIDKey, labelerDID, res.InsertedAt, res.TotalKeys) + return nil + }, +} + +var plcListRotationKeysCmd = &cobra.Command{ + Use: "list-rotation-keys", + Short: "List rotation keys in this labeler's PLC document", + Long: `Fetch the labeler's did:plc document from the PLC directory and print its +rotation keys in priority order (index 0 is highest priority and can override +ops signed by lower-priority keys within PLC's 72-hour recovery window). + +The key matching the local labeler.rotation_key is marked as LOCAL.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := labeler.LoadConfig(plcConfigFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if cfg.Labeler.DIDMethod != "plc" { + return fmt.Errorf("this command only works with did:plc (labeler.did_method is %q)", cfg.Labeler.DIDMethod) + } + + ctx := context.Background() + labelerDID, _, _, err := loadLabelerPLCIdentity(ctx, cfg) + if err != nil { + return err + } + + var localRotationKey atcrypto.PrivateKey + if cfg.Labeler.RotationKey != "" { + localRotationKey, err = atcrypto.ParsePrivateMultibase(cfg.Labeler.RotationKey) + if err != nil { + return fmt.Errorf("failed to parse rotation_key from config: %w", err) + } + } + + res, err := did.ListRotationKeys(ctx, did.ListRotationKeysOptions{ + DID: labelerDID, + PLCDirectoryURL: cfg.PLCDirectoryURL(), + LocalRotationKey: localRotationKey, + }) + if err != nil { + return err + } + printRotationKeys(res) + return nil + }, +} + +// loadLabelerPLCIdentity is the shared "load DID + rotation key + signing key" helper +// used by every PLC command. Mirrors loadHoldPLCIdentity over in cmd/hold/plc.go. +func loadLabelerPLCIdentity(ctx context.Context, cfg *labeler.Config) (string, atcrypto.PrivateKey, *atcrypto.PrivateKeyK256, error) { + labelerDID, _, err := labeler.LoadIdentity(ctx, cfg) + if err != nil { + return "", nil, nil, err + } + + if cfg.Labeler.RotationKey == "" { + return "", nil, nil, fmt.Errorf("labeler.rotation_key must be set to sign PLC updates") + } + rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Labeler.RotationKey) + if err != nil { + return "", nil, nil, fmt.Errorf("failed to parse rotation_key from config: %w", err) + } + + signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath()) + if err != nil { + return "", nil, nil, fmt.Errorf("failed to load signing key: %w", err) + } + return labelerDID, rotationKey, signingKey, nil +} + +// printRotationKeys is the shared CLI output for list-rotation-keys, kept identical to +// the hold version since the formatting is service-agnostic. +func printRotationKeys(res *did.ListRotationKeysResult) { + fmt.Printf("DID: %s\n", res.DID) + fmt.Printf("PLC directory: %s\n", res.Directory) + fmt.Printf("Rotation keys (%d):\n", len(res.Keys)) + for i, k := range res.Keys { + marker := "" + switch { + case len(res.Keys) == 1: + marker = "(only key)" + case i == 0: + marker = "(highest priority)" + case i == len(res.Keys)-1: + marker = "(lowest priority)" + } + localTag := "" + if res.LocalDIDKey != "" && k == res.LocalDIDKey { + localTag = " [LOCAL — labeler.rotation_key]" + } + fmt.Printf(" [%d] %s %s%s\n", i, k, marker, localTag) + } + + if res.LocalDIDKey != "" && !res.LocalPresent { + fmt.Printf("\nWARNING: local rotation_key (%s) is NOT present in the PLC document.\n", res.LocalDIDKey) + fmt.Println("This service cannot sign PLC updates. Possible compromise or out-of-band rotation.") + } +} + +func init() { + plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file") + + plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyFirst, "first", true, "insert at highest priority (default)") + plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyLast, "last", false, "insert at lowest priority") + + plcCmd.AddCommand(plcAddRotationKeyCmd) + plcCmd.AddCommand(plcListRotationKeysCmd) +} diff --git a/config-labeler.example.yaml b/config-labeler.example.yaml new file mode 100644 index 0000000..f3eec66 --- /dev/null +++ b/config-labeler.example.yaml @@ -0,0 +1,55 @@ +# ATCR Labeler Configuration +# Generated with defaults — edit as needed. + +# Configuration format version. +version: "0.1" +# Log level: debug, info, warn, error. +log_level: info +# Labeler service settings. +labeler: + # Enable the labeler service. + enabled: true + # Listen address for labeler (e.g., :5002). + addr: :5002 + # Externally reachable labeler URL. Empty = derive from server.base_url. + public_url: "" + # DID of the labeler admin. Only this DID can log into the admin panel. + owner_did: did:plc:your-did-here + # Directory for labeler state (database, signing key, did.txt). + data_dir: /var/lib/atcr-labeler + # DID method: "plc" (recommended) or "web". + did_method: plc + # Explicit did:plc identifier for adoption/recovery (optional). + did: "" + # Path to K-256 signing key (defaults to /signing.key). + key_path: "" + # Multibase-encoded rotation key (K-256 or P-256). Required to update the PLC document. + rotation_key: "" + # PLC directory URL (default https://plc.directory). + plc_directory_url: https://plc.directory + # Optional libSQL/Bunny remote sync URL. Empty = local-only. + libsql_sync_url: "" + # Auth token for libsql_sync_url. + libsql_auth_token: "" + # Embedded-replica pull interval (e.g. 30s). 0 = manual sync only. + libsql_sync_interval: 0s +# AppView server settings (shared config). +server: + base_url: https://atcr.io + client_name: AT Container Registry + client_short_name: ATCR + test_mode: false +# Remote log shipping settings. +log_shipper: + # Log shipping backend: "victoria", "opensearch", or "loki". Empty disables shipping. + backend: "" + # Remote log service endpoint, e.g. "http://victorialogs:9428". + url: "" + # Number of log entries to buffer before flushing to the remote service. + batch_size: 0 + # Maximum time between flushes, even if batch is not full. + flush_interval: 0s + # Basic auth username for the log service (optional). + username: "" + # Basic auth password for the log service (optional). + password: "" diff --git a/deploy/upcloud/configs/labeler.yaml.tmpl b/deploy/upcloud/configs/labeler.yaml.tmpl index ed26e50..e4adc88 100644 --- a/deploy/upcloud/configs/labeler.yaml.tmpl +++ b/deploy/upcloud/configs/labeler.yaml.tmpl @@ -11,7 +11,12 @@ labeler: enabled: true addr: :5002 owner_did: "" - db_path: "{{.BasePath}}/labeler/labeler.db" + data_dir: "{{.BasePath}}/labeler" + did_method: plc + did: "" + key_path: "" + rotation_key: "" + plc_directory_url: https://plc.directory server: base_url: "https://seamark.dev" client_name: Seamark diff --git a/docker-compose.yml b/docker-compose.yml index b6d2c3b..75f5bfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,9 @@ services: # ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark" ATCR_SERVER_MANAGED_HOLDS: did:web:172.28.0.3%3A8080 ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080 + # Labeler URL (HTTP for dev — ParseLabelerURL accepts it directly so we don't + # have to round-trip through did:web → https:// resolution). + ATCR_LABELER_DID: http://172.28.0.4:5002 ATCR_SERVER_TEST_MODE: true ATCR_LOG_LEVEL: debug LOG_SHIPPER_BACKEND: victoria @@ -97,6 +100,52 @@ services: atcr-network: ipv4_address: 172.28.0.3 + atcr-labeler: + # Base config: config-labeler.example.yaml (passed via Air entrypoint). + # Env vars below override config file values for local dev. + # + # Why did:web for dev: did:plc would submit a real PLC operation to plc.directory + # for every fresh dev environment, polluting production with throwaway DIDs that + # point at 172.28.0.x. did:web is purely self-served via /.well-known/did.json so + # nothing leaks. Switch to plc + a real public_url for production. + environment: + LABELER_LABELER_DID_METHOD: web + LABELER_LABELER_PUBLIC_URL: http://172.28.0.4:5002 + LABELER_LABELER_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg + LABELER_LABELER_DATA_DIR: /var/lib/atcr-labeler + LABELER_SERVER_TEST_MODE: true + LABELER_LOG_LEVEL: debug + LOG_SHIPPER_BACKEND: victoria + LOG_SHIPPER_URL: http://172.28.0.10:9428 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "1" + build: + context: . + dockerfile: Dockerfile.dev + args: + AIR_CONFIG: .air.labeler.toml + image: atcr-labeler-dev:latest + container_name: atcr-labeler + ports: + - "5002:5002" + volumes: + # Mount source code for Air hot reload + - .:/app:z + - go-mod-cache:/go/pkg/mod + # Persist signing key + did.txt + label database across container restarts so + # dev signatures stay verifiable. Wipe with `docker compose down -v` to reset. + - atcr-labeler:/var/lib/atcr-labeler + restart: unless-stopped + dns: + - 8.8.8.8 + - 1.1.1.1 + networks: + atcr-network: + ipv4_address: 172.28.0.4 + # Victoria Logs for centralized log storage # Uncomment to enable, then set LOG_SHIPPER_* env vars above victorialogs: @@ -123,6 +172,7 @@ networks: volumes: atcr-hold: + atcr-labeler: atcr-auth: atcr-ui: go-mod-cache: diff --git a/pkg/appview/db/migrations/0017_create_labels.yaml b/pkg/appview/db/migrations/0023_create_labels.yaml similarity index 100% rename from pkg/appview/db/migrations/0017_create_labels.yaml rename to pkg/appview/db/migrations/0023_create_labels.yaml diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index f421956..c10c12b 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -16,6 +16,31 @@ func BlobCDNURL(did, cid string) string { return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid) } +// activeTakedownClause returns a SQL fragment ready to drop into a `WHERE NOT +// EXISTS (...)` filter for excluding rows whose `(did, repository)` pair is currently +// taken down. The `alias` argument is the outer table alias (e.g. "m" for manifests, +// "lm" for latest_manifests) and must already be in scope at the use site. +// +// Mirrors the semantics of `IsTakenDown` (defined in labels.go) so listings stay +// consistent with the per-repo page check: a label only counts as active when it has +// neg=0, no newer neg=1 row with the same (src, uri, val), and a non-expired `exp`. +// Without these clauses listings hide a repo forever once you've ever taken it down, +// even after a reversal. +func activeTakedownClause(alias string) string { + return `NOT EXISTS ( + SELECT 1 FROM labels l1 + WHERE l1.subject_did = ` + alias + `.did + AND (l1.subject_repo = ` + alias + `.repository OR l1.subject_repo = '') + AND l1.val = '!takedown' AND l1.neg = 0 + AND NOT EXISTS ( + SELECT 1 FROM labels l2 + WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val + AND l2.neg = 1 AND l2.id > l1.id + ) + AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP) + )` +} + // accessibleHoldsSubquery returns SQL that evaluates to the set of hold DIDs // the viewer is allowed to see in listings. Requires the viewerDID to be // passed twice as query arguments (once for the owner_did check and once @@ -107,11 +132,7 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID WHERE ra.did = lm.did AND ra.repository = lm.repository AND ra.value LIKE ? ESCAPE '\' )) - AND NOT EXISTS ( - SELECT 1 FROM labels - WHERE (subject_did = lm.did AND (subject_repo = lm.repository OR subject_repo = '')) - AND val = '!takedown' AND neg = 0 - ) + AND ` + activeTakedownClause("lm") + ` ), repo_stats AS ( SELECT @@ -2122,11 +2143,7 @@ func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardS JOIN users u ON m.did = u.did LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository - WHERE NOT EXISTS ( - SELECT 1 FROM labels - WHERE (subject_did = m.did AND (subject_repo = m.repository OR subject_repo = '')) - AND val = '!takedown' AND neg = 0 - ) + WHERE ` + activeTakedownClause("m") + ` ORDER BY ` + orderBy + ` LIMIT ? ` @@ -2205,11 +2222,7 @@ func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCar JOIN users u ON m.did = u.did LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository - WHERE NOT EXISTS ( - SELECT 1 FROM labels - WHERE (subject_did = m.did AND (subject_repo = m.repository OR subject_repo = '')) - AND val = '!takedown' AND neg = 0 - ) + WHERE ` + activeTakedownClause("m") + ` ORDER BY MAX(rs.last_push, m.created_at) DESC ` diff --git a/pkg/appview/db/readonly.go b/pkg/appview/db/readonly.go index 863b3b5..4343957 100644 --- a/pkg/appview/db/readonly.go +++ b/pkg/appview/db/readonly.go @@ -39,18 +39,17 @@ func InitializeDatabase(dbPath string, cfg LibsqlConfig) (*sql.DB, *sql.DB, *Ses } else { roDSN += "?mode=ro" } - readOnlyDB, err := sql.Open("libsql", roDSN) + // Wrap with busyTimeoutConnector so every pooled read-only connection + // gets PRAGMA busy_timeout. Without this, reads return SQLITE_BUSY + // immediately when a write is in progress on the read-write connection + // (busy_timeout is per-connection, so a one-shot PRAGMA only configures + // whichever conn served it). + roBase, err := openLibsqlLocalConnector(roDSN) if err != nil { - slog.Warn("Failed to open read-only database connection", "error", err) + slog.Warn("Failed to open read-only database connector", "error", err) return nil, nil, nil } - - // busy_timeout is per-connection — without this, reads return SQLITE_BUSY - // immediately when a write is in progress on the read-write connection. - var busyTimeout int - if err := readOnlyDB.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil { - slog.Warn("Failed to set busy_timeout on read-only connection", "error", err) - } + readOnlyDB := sql.OpenDB(&busyTimeoutConnector{base: roBase, timeoutMs: 5000}) slog.Info("UI database initialized", "mode", "readonly", "path", dbPath) diff --git a/pkg/appview/db/schema.go b/pkg/appview/db/schema.go index 95a5481..51edf7e 100644 --- a/pkg/appview/db/schema.go +++ b/pkg/appview/db/schema.go @@ -5,7 +5,9 @@ package db import ( + "context" "database/sql" + "database/sql/driver" "embed" "fmt" "io/fs" @@ -55,36 +57,34 @@ func InitDB(path string, cfg LibsqlConfig) (*sql.DB, error) { db = sql.OpenDB(connector) slog.Info("Database opened in embedded replica mode", "path", path, "sync_url", cfg.SyncURL) } else { - // Local-only mode: plain file via libsql driver - // Paths starting with "file:" or ":memory:" are already valid libsql URIs + // Local-only mode: plain file via libsql driver, wrapped so every new + // connection gets PRAGMA busy_timeout. SQLite's busy_timeout is + // per-connection, so a one-shot db.Exec only configures whichever + // pooled conn served the call — leaving the rest to fail SQLITE_BUSY + // instantly on any write contention with the jetstream/backfill workers. + // Paths starting with "file:" or ":memory:" are already valid libsql URIs. dsn := path if !strings.HasPrefix(path, "file:") && !strings.HasPrefix(path, ":memory:") { dsn = "file:" + path } - var err error - db, err = sql.Open("libsql", dsn) + baseConnector, err := openLibsqlLocalConnector(dsn) if err != nil { return nil, err } + db = sql.OpenDB(&busyTimeoutConnector{base: baseConnector, timeoutMs: 5000}) slog.Info("Database opened in local-only mode", "path", path) } - // In local-only mode, configure WAL and busy_timeout locally. - // In embedded replica mode, the remote server manages these settings - // and PRAGMA assignments are rejected as "unsupported statement" - // (observed with Bunny Database; Turso may behave similarly). + // In local-only mode, set WAL mode (database-wide setting, persists + // across connections — single call is sufficient unlike busy_timeout). + // In embedded replica mode, the remote server manages this and the + // PRAGMA is rejected as "unsupported statement" (observed with Bunny; + // Turso may behave similarly). if cfg.SyncURL == "" { - // Enable WAL mode for concurrent read/write access var journalMode string if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil { return nil, err } - - // Retry on lock instead of failing immediately (5s timeout) - var busyTimeout int - if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil { - return nil, err - } } // Enable foreign keys @@ -377,3 +377,59 @@ func parseMigrationFilename(filename string) (int, string, error) { return version, name, nil } + +// openLibsqlLocalConnector returns a driver.Connector for a local libsql DSN. +// go-libsql exports NewEmbeddedReplicaConnector for replica mode but no public +// constructor for local files, so we obtain the driver via a probe sql.Open +// (which is lazy and opens no connection) and ask it for a Connector. +func openLibsqlLocalConnector(dsn string) (driver.Connector, error) { + probe, err := sql.Open("libsql", dsn) + if err != nil { + return nil, fmt.Errorf("probe libsql driver: %w", err) + } + drv := probe.Driver() + _ = probe.Close() + + dctx, ok := drv.(driver.DriverContext) + if !ok { + return nil, fmt.Errorf("libsql driver does not implement driver.DriverContext") + } + return dctx.OpenConnector(dsn) +} + +// busyTimeoutConnector wraps a driver.Connector and runs PRAGMA busy_timeout +// on every newly opened connection. SQLite's busy_timeout is per-connection, +// so this is the only way to ensure every conn in the pool waits on lock +// contention instead of returning SQLITE_BUSY immediately. +type busyTimeoutConnector struct { + base driver.Connector + timeoutMs int +} + +func (c *busyTimeoutConnector) Connect(ctx context.Context) (driver.Conn, error) { + conn, err := c.base.Connect(ctx) + if err != nil { + return nil, err + } + + // libsql treats PRAGMA assignments as queries that return a row, so we + // must use QueryerContext rather than ExecerContext. + queryer, ok := conn.(driver.QueryerContext) + if !ok { + _ = conn.Close() + return nil, fmt.Errorf("libsql conn does not support QueryerContext") + } + + rows, err := queryer.QueryContext(ctx, fmt.Sprintf("PRAGMA busy_timeout = %d", c.timeoutMs), nil) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("set busy_timeout on new conn: %w", err) + } + _ = rows.Close() + + return conn, nil +} + +func (c *busyTimeoutConnector) Driver() driver.Driver { + return c.base.Driver() +} diff --git a/pkg/appview/handlers/diff.go b/pkg/appview/handlers/diff.go index 2721872..98de5cf 100644 --- a/pkg/appview/handlers/diff.go +++ b/pkg/appview/handlers/diff.go @@ -234,7 +234,7 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } if owner.Handle != resolvedHandle { - _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle) + _ = db.UpdateUserHandle(h.DB, did, resolvedHandle) owner.Handle = resolvedHandle } diff --git a/pkg/appview/handlers/digest.go b/pkg/appview/handlers/digest.go index c523c27..4f6e43c 100644 --- a/pkg/appview/handlers/digest.go +++ b/pkg/appview/handlers/digest.go @@ -105,7 +105,7 @@ func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } if owner.Handle != resolvedHandle { - _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle) + _ = db.UpdateUserHandle(h.DB, did, resolvedHandle) owner.Handle = resolvedHandle } diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index bff6876..7184979 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -63,7 +63,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request // Opportunistically update cached handle if it changed if owner.Handle != resolvedHandle { - _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle) + _ = db.UpdateUserHandle(h.DB, did, resolvedHandle) owner.Handle = resolvedHandle } diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go index 3d61cac..8f610af 100644 --- a/pkg/appview/handlers/user.go +++ b/pkg/appview/handlers/user.go @@ -44,7 +44,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } else if viewedUser.Handle != resolvedHandle { // Opportunistically update cached handle if it changed - _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle) + _ = db.UpdateUserHandle(h.DB, did, resolvedHandle) viewedUser.Handle = resolvedHandle } diff --git a/pkg/appview/labeler/subscriber.go b/pkg/appview/labeler/subscriber.go index eb05c3a..ed383b3 100644 --- a/pkg/appview/labeler/subscriber.go +++ b/pkg/appview/labeler/subscriber.go @@ -3,8 +3,9 @@ package labeler import ( + "bytes" "database/sql" - "encoding/json" + "errors" "fmt" "log/slog" "net/url" @@ -13,26 +14,11 @@ import ( "atcr.io/pkg/appview/db" + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/events" "github.com/gorilla/websocket" ) -// LabelsMessage is the wire format for subscribeLabels events. -type LabelsMessage struct { - Seq int64 `json:"seq"` - Labels []LabelEvent `json:"labels"` -} - -// LabelEvent is a single label from the labeler. -type LabelEvent struct { - Src string `json:"src"` - URI string `json:"uri"` - CID string `json:"cid,omitempty"` - Val string `json:"val"` - Neg bool `json:"neg"` - Cts string `json:"cts"` - Exp string `json:"exp,omitempty"` -} - // Subscriber connects to a labeler's subscribeLabels endpoint // and mirrors labels into the appview database. type Subscriber struct { @@ -121,35 +107,55 @@ func (s *Subscriber) connect() error { default: } - var msg LabelsMessage - if err := conn.ReadJSON(&msg); err != nil { + mt, payload, err := conn.ReadMessage() + if err != nil { return fmt.Errorf("read error: %w", err) } + // Per the ATProto event-stream spec each frame is a binary message; reject text. + if mt != websocket.BinaryMessage { + slog.Warn("Ignoring non-binary frame from labeler", "type", mt) + continue + } - for _, le := range msg.Labels { + seq, labels, err := decodeFrame(payload) + if err != nil { + if errors.Is(err, errInfoFrame) { + continue // already logged inside decodeFrame + } + return fmt.Errorf("decode frame: %w", err) + } + + for _, le := range labels { cts, _ := time.Parse(time.RFC3339, le.Cts) - did, repo := extractSubjectFromURI(le.URI) + did, repo := extractSubjectFromURI(le.Uri) label := &db.Label{ Src: le.Src, - URI: le.URI, + URI: le.Uri, Val: le.Val, - Neg: le.Neg, + Neg: le.Neg != nil && *le.Neg, Cts: cts, SubjectDID: did, SubjectRepo: repo, - Seq: msg.Seq, + Seq: seq, } if err := db.UpsertLabel(s.database, label); err != nil { - slog.Warn("Failed to upsert label", "uri", le.URI, "error", err) + slog.Warn("Failed to upsert label", "uri", le.Uri, "error", err) continue } - slog.Info("Mirrored label", - "uri", le.URI, + // "Mirrored label X" reads as an apply; reversals are a different action + // from the operator's POV (and a different SQL effect — the NOT EXISTS + // negation clause kicks in), so log them distinctly. + msg := "Mirrored label" + if label.Neg { + msg = "Mirrored label reversal" + } + slog.Info(msg, + "uri", le.Uri, "val", le.Val, - "neg", le.Neg, + "neg", label.Neg, "subject_did", did, "subject_repo", repo, ) @@ -157,6 +163,54 @@ func (s *Subscriber) connect() error { } } +// errInfoFrame is returned by decodeFrame when the frame is informational and the +// caller should just continue to the next message. +var errInfoFrame = errors.New("labeler: info frame") + +// decodeFrame parses a single subscribeLabels binary frame. ATProto event-stream framing +// is two concatenated CBOR objects: a {op,t} header and a body. We dispatch on the +// header op/t pair and return the labels body for op=1, t="#labels". For #info frames +// we log and signal errInfoFrame so the caller skips. Error frames (op=-1) become Go +// errors so the run loop reconnects with backoff. +func decodeFrame(payload []byte) (int64, []*comatproto.LabelDefs_Label, error) { + r := bytes.NewReader(payload) + var header events.EventHeader + if err := header.UnmarshalCBOR(r); err != nil { + return 0, nil, fmt.Errorf("unmarshal header: %w", err) + } + + switch { + case header.Op == events.EvtKindErrorFrame: + var ef events.ErrorFrame + if err := ef.UnmarshalCBOR(r); err != nil { + return 0, nil, fmt.Errorf("unmarshal error frame: %w", err) + } + return 0, nil, fmt.Errorf("labeler error frame: %s — %s", ef.Error, ef.Message) + + case header.Op == events.EvtKindMessage && header.MsgType == "#labels": + var body comatproto.LabelSubscribeLabels_Labels + if err := body.UnmarshalCBOR(r); err != nil { + return 0, nil, fmt.Errorf("unmarshal labels body: %w", err) + } + return body.Seq, body.Labels, nil + + case header.Op == events.EvtKindMessage && header.MsgType == "#info": + var info comatproto.LabelSubscribeLabels_Info + if err := info.UnmarshalCBOR(r); err != nil { + return 0, nil, fmt.Errorf("unmarshal info body: %w", err) + } + message := "" + if info.Message != nil { + message = *info.Message + } + slog.Info("Labeler info frame", "name", info.Name, "message", message) + return 0, nil, errInfoFrame + + default: + return 0, nil, fmt.Errorf("unexpected frame op=%d t=%q", header.Op, header.MsgType) + } +} + // extractSubjectFromURI extracts the DID and repository from an AT URI. // Examples: // @@ -228,12 +282,3 @@ func SubscriberFromConfig(labelerDIDOrURL string, database *sql.DB) *Subscriber labelerURL := ParseLabelerURL(labelerDIDOrURL) return NewSubscriber(labelerURL, database) } - -// DecodeLabelsFromJSON decodes a JSON-encoded labels message. -func DecodeLabelsFromJSON(data []byte) (*LabelsMessage, error) { - var msg LabelsMessage - if err := json.Unmarshal(data, &msg); err != nil { - return nil, err - } - return &msg, nil -} diff --git a/pkg/atproto/did/cmd.go b/pkg/atproto/did/cmd.go new file mode 100644 index 0000000..f7a6b80 --- /dev/null +++ b/pkg/atproto/did/cmd.go @@ -0,0 +1,208 @@ +package did + +import ( + "context" + "fmt" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + didplc "github.com/did-method-plc/go-didplc" +) + +// AddRotationKeyOptions configures a rotation-key insert operation. +type AddRotationKeyOptions struct { + // DID is the resolved did:plc identifier of the service. + DID string + + // PLCDirectoryURL is the PLC directory endpoint (defaults to https://plc.directory if empty). + PLCDirectoryURL string + + // RotationKey is the currently-authorized rotation key used to sign the update op. + RotationKey atcrypto.PrivateKey + + // SigningKey is the local k256 verification key — its public part goes into the + // new op's VerificationMethods so we don't accidentally drop it during the update. + SigningKey *atcrypto.PrivateKeyK256 + + // VerificationKeyName is the fragment under which SigningKey is registered + // (e.g. "atproto" for a PDS, "atproto_label" for a labeler). + VerificationKeyName string + + // NewKey is the rotation key to add. If nil, a fresh K-256 key is generated and + // returned in the result so the caller can print/persist it. + NewKey atcrypto.PrivateKeyExportable + + // Prepend places the new key at the highest priority position. When false the key + // is appended at the lowest priority — only set false when the operator explicitly + // asks for it. + Prepend bool +} + +// AddRotationKeyResult describes the outcome of an AddRotationKey call. +type AddRotationKeyResult struct { + NewKey atcrypto.PrivateKeyExportable + NewKeyDIDKey string + Generated bool + AlreadyPresent bool + ExistingAt int + InsertedAt int + TotalKeys int +} + +// AddRotationKey fetches the current PLC op log, inserts NewKey (generating one if nil), +// signs the update with RotationKey, and submits it. Caller is responsible for printing +// the generated key material — this function returns it on the result so prints can +// happen in the binary's own format. +func AddRotationKey(ctx context.Context, opt AddRotationKeyOptions) (*AddRotationKeyResult, error) { + if opt.DID == "" { + return nil, fmt.Errorf("plc: DID is required") + } + if opt.RotationKey == nil { + return nil, fmt.Errorf("plc: rotation key is required to sign updates") + } + if opt.SigningKey == nil { + return nil, fmt.Errorf("plc: signing key is required (becomes verificationMethods.%s)", opt.VerificationKeyName) + } + if opt.VerificationKeyName == "" { + return nil, fmt.Errorf("plc: VerificationKeyName is required") + } + + directory := opt.PLCDirectoryURL + if directory == "" { + directory = "https://plc.directory" + } + client := &didplc.Client{DirectoryURL: directory} + + res := &AddRotationKeyResult{NewKey: opt.NewKey} + if res.NewKey == nil { + raw, err := atcrypto.GeneratePrivateKeyK256() + if err != nil { + return nil, fmt.Errorf("plc: failed to generate rotation key: %w", err) + } + res.NewKey = raw + res.Generated = true + } + newPub, err := res.NewKey.PublicKey() + if err != nil { + return nil, fmt.Errorf("plc: failed to derive new public key: %w", err) + } + res.NewKeyDIDKey = newPub.DIDKey() + + opLog, err := client.OpLog(ctx, opt.DID) + if err != nil { + return nil, fmt.Errorf("plc: failed to fetch op log for %s: %w", opt.DID, err) + } + if len(opLog) == 0 { + return nil, fmt.Errorf("plc: empty op log for %s", opt.DID) + } + lastEntry := opLog[len(opLog)-1] + lastOp := lastEntry.Regular + if lastOp == nil { + return nil, fmt.Errorf("plc: last operation is not a regular op") + } + + for i, k := range lastOp.RotationKeys { + if k == res.NewKeyDIDKey { + res.AlreadyPresent = true + res.ExistingAt = i + res.TotalKeys = len(lastOp.RotationKeys) + return res, nil + } + } + + rotationKeys := make([]string, 0, len(lastOp.RotationKeys)+1) + if opt.Prepend { + rotationKeys = append(rotationKeys, res.NewKeyDIDKey) + rotationKeys = append(rotationKeys, lastOp.RotationKeys...) + res.InsertedAt = 0 + } else { + rotationKeys = append(rotationKeys, lastOp.RotationKeys...) + rotationKeys = append(rotationKeys, res.NewKeyDIDKey) + res.InsertedAt = len(rotationKeys) - 1 + } + res.TotalKeys = len(rotationKeys) + + sigPub, err := opt.SigningKey.PublicKey() + if err != nil { + return nil, fmt.Errorf("plc: failed to derive signing public key: %w", err) + } + prevCID := lastEntry.AsOperation().CID().String() + + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: rotationKeys, + VerificationMethods: map[string]string{ + opt.VerificationKeyName: sigPub.DIDKey(), + }, + AlsoKnownAs: lastOp.AlsoKnownAs, + Services: lastOp.Services, + Prev: &prevCID, + } + if err := op.Sign(opt.RotationKey); err != nil { + return nil, fmt.Errorf("plc: failed to sign update: %w", err) + } + if err := client.Submit(ctx, opt.DID, op); err != nil { + return nil, fmt.Errorf("plc: failed to submit update: %w", err) + } + return res, nil +} + +// ListRotationKeysOptions configures a list-rotation-keys read. +type ListRotationKeysOptions struct { + DID string + PLCDirectoryURL string + LocalRotationKey atcrypto.PrivateKey // optional — used to compute the LOCAL marker +} + +// ListRotationKeysResult holds the priority-ordered rotation keys plus the local +// rotation key's did:key form (if provided), so callers can mark and warn appropriately. +type ListRotationKeysResult struct { + DID string + Directory string + Keys []string + LocalDIDKey string + LocalPresent bool +} + +// ListRotationKeys fetches the current PLC op and returns its rotation keys in priority order. +func ListRotationKeys(ctx context.Context, opt ListRotationKeysOptions) (*ListRotationKeysResult, error) { + if opt.DID == "" { + return nil, fmt.Errorf("plc: DID is required") + } + directory := opt.PLCDirectoryURL + if directory == "" { + directory = "https://plc.directory" + } + client := &didplc.Client{DirectoryURL: directory} + + opLog, err := client.OpLog(ctx, opt.DID) + if err != nil { + return nil, fmt.Errorf("plc: failed to fetch op log for %s: %w", opt.DID, err) + } + if len(opLog) == 0 { + return nil, fmt.Errorf("plc: empty op log for %s", opt.DID) + } + lastOp := opLog[len(opLog)-1].Regular + if lastOp == nil { + return nil, fmt.Errorf("plc: last operation is not a regular op") + } + + res := &ListRotationKeysResult{ + DID: opt.DID, + Directory: directory, + Keys: append([]string(nil), lastOp.RotationKeys...), + } + if opt.LocalRotationKey != nil { + pub, err := opt.LocalRotationKey.PublicKey() + if err != nil { + return nil, fmt.Errorf("plc: failed to derive local rotation public key: %w", err) + } + res.LocalDIDKey = pub.DIDKey() + for _, k := range res.Keys { + if k == res.LocalDIDKey { + res.LocalPresent = true + break + } + } + } + return res, nil +} diff --git a/pkg/atproto/did/cmd_test.go b/pkg/atproto/did/cmd_test.go new file mode 100644 index 0000000..ef8299e --- /dev/null +++ b/pkg/atproto/did/cmd_test.go @@ -0,0 +1,304 @@ +package did + +import ( + "context" + "strings" + "testing" + + "github.com/bluesky-social/indigo/atproto/atcrypto" +) + +// TestAddRotationKey_AppendNew confirms a fresh key is appended at the lowest priority +// when Prepend is false. +func TestAddRotationKey_AppendNew(t *testing.T) { + ctx := context.Background() + + serverRot := generateK256(t) + signing := generateK256(t) + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing) + defer fake.Close() + + newKey := generateK256(t) + res, err := AddRotationKey(ctx, AddRotationKeyOptions{ + DID: fake.did, + PLCDirectoryURL: fake.URL(), + RotationKey: serverRot, + SigningKey: signing, + VerificationKeyName: "atproto", + NewKey: newKey, + Prepend: false, + }) + if err != nil { + t.Fatalf("AddRotationKey: %v", err) + } + if res.AlreadyPresent { + t.Fatal("AlreadyPresent should be false") + } + if res.Generated { + t.Error("Generated should be false when NewKey provided") + } + if res.TotalKeys != 2 { + t.Errorf("TotalKeys: got %d want 2", res.TotalKeys) + } + if res.InsertedAt != 1 { + t.Errorf("InsertedAt: got %d want 1 (appended)", res.InsertedAt) + } + + if len(fake.submitted) != 1 { + t.Fatalf("expected one update submission, got %d", len(fake.submitted)) + } + got := fake.submitted[0] + newPub, _ := newKey.PublicKey() + if got.RotationKeys[len(got.RotationKeys)-1] != newPub.DIDKey() { + t.Errorf("appended key not at last position: %v", got.RotationKeys) + } +} + +// TestAddRotationKey_Prepend confirms Prepend=true puts the new key at index 0 +// (highest priority position). +func TestAddRotationKey_Prepend(t *testing.T) { + ctx := context.Background() + + serverRot := generateK256(t) + signing := generateK256(t) + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing) + defer fake.Close() + + newKey := generateK256(t) + res, err := AddRotationKey(ctx, AddRotationKeyOptions{ + DID: fake.did, + PLCDirectoryURL: fake.URL(), + RotationKey: serverRot, + SigningKey: signing, + VerificationKeyName: "atproto", + NewKey: newKey, + Prepend: true, + }) + if err != nil { + t.Fatalf("AddRotationKey: %v", err) + } + if res.InsertedAt != 0 { + t.Errorf("InsertedAt: got %d want 0", res.InsertedAt) + } + if len(fake.submitted) != 1 { + t.Fatalf("expected one update, got %d", len(fake.submitted)) + } + newPub, _ := newKey.PublicKey() + if fake.submitted[0].RotationKeys[0] != newPub.DIDKey() { + t.Errorf("prepended key not at first position: %v", fake.submitted[0].RotationKeys) + } +} + +// TestAddRotationKey_GeneratesWhenNil confirms the helper generates a fresh key +// and reports it via Result. +func TestAddRotationKey_GeneratesWhenNil(t *testing.T) { + ctx := context.Background() + + serverRot := generateK256(t) + signing := generateK256(t) + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing) + defer fake.Close() + + res, err := AddRotationKey(ctx, AddRotationKeyOptions{ + DID: fake.did, + PLCDirectoryURL: fake.URL(), + RotationKey: serverRot, + SigningKey: signing, + VerificationKeyName: "atproto", + NewKey: nil, + }) + if err != nil { + t.Fatalf("AddRotationKey: %v", err) + } + if !res.Generated { + t.Error("Generated should be true when NewKey is nil") + } + if res.NewKey == nil { + t.Fatal("NewKey on result should not be nil") + } + if res.NewKeyDIDKey == "" { + t.Error("NewKeyDIDKey should be populated") + } +} + +// TestAddRotationKey_AlreadyPresent confirms a no-op when the key is already in the list. +func TestAddRotationKey_AlreadyPresent(t *testing.T) { + ctx := context.Background() + + serverRot := generateK256(t) + signing := generateK256(t) + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing) + defer fake.Close() + + res, err := AddRotationKey(ctx, AddRotationKeyOptions{ + DID: fake.did, + PLCDirectoryURL: fake.URL(), + RotationKey: serverRot, + SigningKey: signing, + VerificationKeyName: "atproto", + NewKey: serverRot, + }) + if err != nil { + t.Fatalf("AddRotationKey: %v", err) + } + if !res.AlreadyPresent { + t.Error("AlreadyPresent should be true") + } + if res.ExistingAt != 0 { + t.Errorf("ExistingAt: got %d want 0", res.ExistingAt) + } + if len(fake.submitted) != 0 { + t.Errorf("no submission expected when key already present, got %d", len(fake.submitted)) + } +} + +// TestAddRotationKey_ValidationErrors covers the early-return guard clauses in AddRotationKey. +func TestAddRotationKey_ValidationErrors(t *testing.T) { + ctx := context.Background() + signing := generateK256(t) + rot := generateK256(t) + + cases := []struct { + name string + opt AddRotationKeyOptions + wantSub string + }{ + { + name: "missing DID", + opt: AddRotationKeyOptions{RotationKey: rot, SigningKey: signing, VerificationKeyName: "atproto"}, + wantSub: "DID is required", + }, + { + name: "missing rotation key", + opt: AddRotationKeyOptions{DID: "did:plc:abc", SigningKey: signing, VerificationKeyName: "atproto"}, + wantSub: "rotation key is required", + }, + { + name: "missing signing key", + opt: AddRotationKeyOptions{DID: "did:plc:abc", RotationKey: rot, VerificationKeyName: "atproto"}, + wantSub: "signing key is required", + }, + { + name: "missing verification key name", + opt: AddRotationKeyOptions{DID: "did:plc:abc", RotationKey: rot, SigningKey: signing}, + wantSub: "VerificationKeyName is required", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := AddRotationKey(ctx, tc.opt) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("error: got %q want substring %q", err.Error(), tc.wantSub) + } + }) + } +} + +// TestListRotationKeys returns the priority-ordered keys from the latest op. +func TestListRotationKeys(t *testing.T) { + ctx := context.Background() + + rot1 := generateK256(t) + rot2 := generateK256(t) + signing := generateK256(t) + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{rot1, rot2}, rot1, signing) + defer fake.Close() + + res, err := ListRotationKeys(ctx, ListRotationKeysOptions{ + DID: fake.did, + PLCDirectoryURL: fake.URL(), + }) + if err != nil { + t.Fatalf("ListRotationKeys: %v", err) + } + if res.DID != fake.did { + t.Errorf("DID: got %s want %s", res.DID, fake.did) + } + if res.Directory != fake.URL() { + t.Errorf("Directory: got %s want %s", res.Directory, fake.URL()) + } + if len(res.Keys) != 2 { + t.Fatalf("Keys length: got %d want 2", len(res.Keys)) + } + pub1, _ := rot1.PublicKey() + pub2, _ := rot2.PublicKey() + if res.Keys[0] != pub1.DIDKey() { + t.Errorf("Keys[0]: got %s want %s", res.Keys[0], pub1.DIDKey()) + } + if res.Keys[1] != pub2.DIDKey() { + t.Errorf("Keys[1]: got %s want %s", res.Keys[1], pub2.DIDKey()) + } + if res.LocalDIDKey != "" { + t.Errorf("LocalDIDKey should be empty when no LocalRotationKey provided, got %s", res.LocalDIDKey) + } + if res.LocalPresent { + t.Error("LocalPresent should be false when no LocalRotationKey provided") + } +} + +// TestListRotationKeys_LocalPresent confirms LocalPresent flips to true when the local +// key matches one in the published list. +func TestListRotationKeys_LocalPresent(t *testing.T) { + ctx := context.Background() + + serverRot := generateK256(t) + signing := generateK256(t) + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing) + defer fake.Close() + + res, err := ListRotationKeys(ctx, ListRotationKeysOptions{ + DID: fake.did, + PLCDirectoryURL: fake.URL(), + LocalRotationKey: serverRot, + }) + if err != nil { + t.Fatalf("ListRotationKeys: %v", err) + } + if !res.LocalPresent { + t.Error("LocalPresent should be true") + } + pub, _ := serverRot.PublicKey() + if res.LocalDIDKey != pub.DIDKey() { + t.Errorf("LocalDIDKey: got %s want %s", res.LocalDIDKey, pub.DIDKey()) + } +} + +// TestListRotationKeys_LocalNotPresent flags a rotated-out local key. +func TestListRotationKeys_LocalNotPresent(t *testing.T) { + ctx := context.Background() + + serverRot := generateK256(t) + signing := generateK256(t) + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing) + defer fake.Close() + + stranger := generateK256(t) + res, err := ListRotationKeys(ctx, ListRotationKeysOptions{ + DID: fake.did, + PLCDirectoryURL: fake.URL(), + LocalRotationKey: stranger, + }) + if err != nil { + t.Fatalf("ListRotationKeys: %v", err) + } + if res.LocalPresent { + t.Error("LocalPresent should be false for a stranger key") + } + if res.LocalDIDKey == "" { + t.Error("LocalDIDKey should still be populated even when not present") + } +} + +// TestListRotationKeys_MissingDID surfaces the early-return validation. +func TestListRotationKeys_MissingDID(t *testing.T) { + _, err := ListRotationKeys(context.Background(), ListRotationKeysOptions{}) + if err == nil { + t.Fatal("expected error for missing DID") + } + if !strings.Contains(err.Error(), "DID is required") { + t.Errorf("error: got %q", err.Error()) + } +} diff --git a/pkg/atproto/did/did.go b/pkg/atproto/did/did.go new file mode 100644 index 0000000..2969b04 --- /dev/null +++ b/pkg/atproto/did/did.go @@ -0,0 +1,243 @@ +// Package did provides shared did:web and did:plc identity management for ATCR services. +// +// Both the hold and labeler services declare an ATProto identity with a signing key +// and one or more service endpoints. This package generalizes the genesis/update/load +// flow so callers only have to specify their verification key fragment name and the +// service entries they want to register. +package did + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "os" + "path/filepath" + "strings" + + "atcr.io/pkg/auth/oauth" + "github.com/bluesky-social/indigo/atproto/atcrypto" +) + +// Service is a service entry in a DID document or PLC operation. +type Service struct { + Type string + Endpoint string +} + +// Config configures DID identity loading or creation. +type Config struct { + // Method is "web" or "plc". + Method string + + // PublicURL is the externally reachable URL of the service. + PublicURL string + + // DBPath is a directory used to persist did.txt for did:plc identities. + DBPath string + + // SigningKeyPath is the on-disk path for the K-256 signing key (will be generated if missing). + SigningKeyPath string + + // RotationKey is a multibase-encoded private key used to sign PLC operations (optional). + // If empty for did:plc, a new rotation key is generated and logged once for the operator. + RotationKey string + + // PLCDirectoryURL is the PLC directory endpoint. + PLCDirectoryURL string + + // DID overrides the persisted DID (used for adoption/recovery of an existing did:plc). + DID string + + // VerificationKeyName is the fragment used in the DID document and PLC operation + // for the signing key (e.g. "atproto" for a PDS, "atproto_label" for a labeler). + VerificationKeyName string + + // Services lists service entries keyed by service id (e.g. "atproto_pds", "atproto_labeler"). + Services map[string]Service +} + +// LoadOrCreate returns the service's DID. did:web is derived deterministically from +// PublicURL; did:plc is loaded from disk or created and registered with the PLC directory. +func LoadOrCreate(ctx context.Context, cfg Config) (string, error) { + if cfg.Method != "plc" { + return GenerateDIDFromURL(cfg.PublicURL), nil + } + + if cfg.VerificationKeyName == "" { + return "", fmt.Errorf("did: VerificationKeyName is required for did:plc") + } + if len(cfg.Services) == 0 { + return "", fmt.Errorf("did: at least one service entry is required for did:plc") + } + + didPath := filepath.Join(cfg.DBPath, "did.txt") + + var d string + if cfg.DID != "" { + if !strings.HasPrefix(cfg.DID, "did:plc:") { + return "", fmt.Errorf("did: DID must be a did:plc identifier, got %q", cfg.DID) + } + d = cfg.DID + slog.Info("Using DID from config (adoption/recovery)", "did", d) + } else if data, err := os.ReadFile(didPath); err == nil { + val := strings.TrimSpace(string(data)) + if strings.HasPrefix(val, "did:plc:") { + d = val + slog.Info("Loaded existing did:plc identity", "did", d) + } + } + + if d != "" { + if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil { + return "", fmt.Errorf("did: failed to create did.txt directory: %w", err) + } + if err := os.WriteFile(didPath, []byte(d+"\n"), 0600); err != nil { + return "", fmt.Errorf("did: failed to write did.txt: %w", err) + } + + signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath) + if err != nil { + return "", fmt.Errorf("did: failed to load signing key: %w", err) + } + rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey) + + if err := EnsureCurrent(ctx, d, rotationKey, signingKey, cfg); err != nil { + slog.Warn("Failed to verify PLC identity is current (will retry on next restart)", + "did", d, "error", err) + } + return d, nil + } + + slog.Info("Creating new did:plc identity") + + signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath) + if err != nil { + return "", fmt.Errorf("did: failed to load signing key: %w", err) + } + + var rotationKey atcrypto.PrivateKeyExportable + if cfg.RotationKey != "" { + rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey) + if err != nil { + return "", fmt.Errorf("did: failed to parse rotation_key: %w", err) + } + } else { + rawKey, genErr := atcrypto.GeneratePrivateKeyK256() + if genErr != nil { + return "", fmt.Errorf("did: failed to generate rotation key: %w", genErr) + } + rotationKey = rawKey + slog.Warn("Generated new rotation key — save this in your config as rotation_key", + "rotation_key", rawKey.Multibase()) + } + + d, err = CreateIdentity(ctx, rotationKey, signingKey, cfg) + if err != nil { + return "", fmt.Errorf("did: failed to create PLC identity: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil { + return "", fmt.Errorf("did: failed to create did.txt directory: %w", err) + } + if err := os.WriteFile(didPath, []byte(d+"\n"), 0600); err != nil { + return "", fmt.Errorf("did: failed to write did.txt: %w", err) + } + + slog.Info("Created did:plc identity", "did", d, "plc_directory", cfg.PLCDirectoryURL) + slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).") + return d, nil +} + +// DIDDocument is the JSON shape we serve for did:web identities. +type DIDDocument struct { + Context []string `json:"@context"` + ID string `json:"id"` + AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` + VerificationMethod []VerificationMethod `json:"verificationMethod"` + Authentication []string `json:"authentication,omitempty"` + AssertionMethod []string `json:"assertionMethod,omitempty"` + Service []DIDService `json:"service,omitempty"` +} + +// VerificationMethod is a public key entry in a DID document. +type VerificationMethod struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyMultibase string `json:"publicKeyMultibase"` +} + +// DIDService is a service entry in a DID document. +type DIDService struct { + ID string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` +} + +// BuildDIDDocument constructs a DID document for a did:web identity. The verification +// method fragment matches verificationKeyName (e.g. "#atproto" or "#atproto_label"); +// pass "" to default to "atproto". Authentication is only added for the standard +// "atproto" key per the bsky/PDS pattern. +func BuildDIDDocument(did, publicURL string, signingKey *atcrypto.PrivateKeyK256, verificationKeyName string, services map[string]Service) (*DIDDocument, error) { + host, err := hostWithPort(publicURL) + if err != nil { + return nil, err + } + pub, err := signingKey.PublicKey() + if err != nil { + return nil, fmt.Errorf("did: failed to get public key: %w", err) + } + + keyName := verificationKeyName + if keyName == "" { + keyName = "atproto" + } + + doc := &DIDDocument{ + Context: []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1", + }, + ID: did, + AlsoKnownAs: []string{"at://" + host}, + VerificationMethod: []VerificationMethod{ + { + ID: fmt.Sprintf("%s#%s", did, keyName), + Type: "Multikey", + Controller: did, + PublicKeyMultibase: pub.Multibase(), + }, + }, + } + if keyName == "atproto" { + doc.Authentication = []string{fmt.Sprintf("%s#atproto", did)} + } + for id, svc := range services { + doc.Service = append(doc.Service, DIDService{ + ID: "#" + id, + Type: svc.Type, + ServiceEndpoint: svc.Endpoint, + }) + } + return doc, nil +} + +// MarshalDIDDocument is a convenience for serving a DID doc as indented JSON. +func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) { + return json.MarshalIndent(doc, "", " ") +} + +func hostWithPort(publicURL string) (string, error) { + u, err := url.Parse(publicURL) + if err != nil { + return "", fmt.Errorf("did: failed to parse public URL: %w", err) + } + host := u.Hostname() + if port := u.Port(); port != "" && port != "80" && port != "443" { + host = host + ":" + port + } + return host, nil +} diff --git a/pkg/atproto/did/did_test.go b/pkg/atproto/did/did_test.go new file mode 100644 index 0000000..0b3c993 --- /dev/null +++ b/pkg/atproto/did/did_test.go @@ -0,0 +1,356 @@ +package did + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func testServices(publicURL string) map[string]Service { + return map[string]Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL}, + "atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL}, + } +} + +// TestBuildDIDDocument verifies the standard atproto DID document layout for a did:web service. +func TestBuildDIDDocument(t *testing.T) { + publicURL := "https://hold.example.com" + signingKey := generateK256(t) + + doc, err := BuildDIDDocument("did:web:hold.example.com", publicURL, signingKey, "atproto", testServices(publicURL)) + if err != nil { + t.Fatalf("BuildDIDDocument: %v", err) + } + + if doc.ID != "did:web:hold.example.com" { + t.Errorf("ID: got %s want did:web:hold.example.com", doc.ID) + } + + expectedContexts := []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1", + } + if len(doc.Context) != len(expectedContexts) { + t.Errorf("Context length: got %d want %d", len(doc.Context), len(expectedContexts)) + } + for i, expected := range expectedContexts { + if doc.Context[i] != expected { + t.Errorf("Context[%d]: got %s want %s", i, doc.Context[i], expected) + } + } + + if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" { + t.Errorf("AlsoKnownAs: got %v want [at://hold.example.com]", doc.AlsoKnownAs) + } + + if len(doc.VerificationMethod) != 1 { + t.Fatalf("VerificationMethod length: got %d want 1", len(doc.VerificationMethod)) + } + vm := doc.VerificationMethod[0] + if vm.ID != "did:web:hold.example.com#atproto" { + t.Errorf("VerificationMethod.ID: got %s", vm.ID) + } + if vm.Type != "Multikey" { + t.Errorf("VerificationMethod.Type: got %s want Multikey", vm.Type) + } + if vm.Controller != "did:web:hold.example.com" { + t.Errorf("VerificationMethod.Controller: got %s", vm.Controller) + } + if vm.PublicKeyMultibase == "" { + t.Error("VerificationMethod.PublicKeyMultibase is empty") + } + + pub, _ := signingKey.PublicKey() + if vm.PublicKeyMultibase != pub.Multibase() { + t.Errorf("VerificationMethod.PublicKeyMultibase: got %s want %s", vm.PublicKeyMultibase, pub.Multibase()) + } + + if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" { + t.Errorf("Authentication: got %v", doc.Authentication) + } + + if len(doc.Service) != 2 { + t.Fatalf("Service length: got %d want 2", len(doc.Service)) + } + + svcByID := map[string]DIDService{} + for _, s := range doc.Service { + svcByID[s.ID] = s + } + pdsService, ok := svcByID["#atproto_pds"] + if !ok { + t.Fatalf("missing #atproto_pds service in %v", svcByID) + } + if pdsService.Type != "AtprotoPersonalDataServer" { + t.Errorf("#atproto_pds Type: got %s", pdsService.Type) + } + if pdsService.ServiceEndpoint != publicURL { + t.Errorf("#atproto_pds Endpoint: got %s want %s", pdsService.ServiceEndpoint, publicURL) + } + holdService, ok := svcByID["#atcr_hold"] + if !ok { + t.Fatalf("missing #atcr_hold service in %v", svcByID) + } + if holdService.Type != "AtcrHoldService" { + t.Errorf("#atcr_hold Type: got %s", holdService.Type) + } + if holdService.ServiceEndpoint != publicURL { + t.Errorf("#atcr_hold Endpoint: got %s want %s", holdService.ServiceEndpoint, publicURL) + } +} + +// TestBuildDIDDocument_WithPort confirms non-standard ports flow into AlsoKnownAs. +func TestBuildDIDDocument_WithPort(t *testing.T) { + publicURL := "https://hold.example.com:8443" + signingKey := generateK256(t) + + doc, err := BuildDIDDocument("did:web:hold.example.com%3A8443", publicURL, signingKey, "atproto", testServices(publicURL)) + if err != nil { + t.Fatalf("BuildDIDDocument: %v", err) + } + + if doc.ID != "did:web:hold.example.com%3A8443" { + t.Errorf("ID: got %s", doc.ID) + } + if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" { + t.Errorf("AlsoKnownAs: got %s want at://hold.example.com:8443", doc.AlsoKnownAs[0]) + } +} + +// TestBuildDIDDocument_StandardPortsStripped verifies port 80/443 are not appended to alsoKnownAs. +func TestBuildDIDDocument_StandardPortsStripped(t *testing.T) { + signingKey := generateK256(t) + + cases := []struct { + name string + publicURL string + wantAKA string + }{ + {"http port 80", "http://hold.example.com:80", "at://hold.example.com"}, + {"https port 443", "https://hold.example.com:443", "at://hold.example.com"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + doc, err := BuildDIDDocument("did:web:hold.example.com", tc.publicURL, signingKey, "atproto", nil) + if err != nil { + t.Fatalf("BuildDIDDocument: %v", err) + } + if doc.AlsoKnownAs[0] != tc.wantAKA { + t.Errorf("AlsoKnownAs: got %s want %s", doc.AlsoKnownAs[0], tc.wantAKA) + } + }) + } +} + +// TestBuildDIDDocument_InvalidURL confirms malformed URLs surface as errors. +func TestBuildDIDDocument_InvalidURL(t *testing.T) { + signingKey := generateK256(t) + _, err := BuildDIDDocument("did:web:bogus", "ht!tp://invalid url", signingKey, "atproto", nil) + if err == nil { + t.Fatal("expected error for invalid URL, got nil") + } +} + +// TestBuildDIDDocument_DefaultVerificationKeyName confirms the empty fragment defaults to "atproto" +// and adds Authentication. +func TestBuildDIDDocument_DefaultVerificationKeyName(t *testing.T) { + signingKey := generateK256(t) + doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "", nil) + if err != nil { + t.Fatalf("BuildDIDDocument: %v", err) + } + if doc.VerificationMethod[0].ID != "did:web:example.com#atproto" { + t.Errorf("VerificationMethod.ID: got %s want did:web:example.com#atproto", doc.VerificationMethod[0].ID) + } + if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:example.com#atproto" { + t.Errorf("Authentication: got %v", doc.Authentication) + } +} + +// TestBuildDIDDocument_LabelerKey confirms a non-"atproto" verification key (e.g. labeler) +// does not add Authentication, mirroring the bsky labeler pattern. +func TestBuildDIDDocument_LabelerKey(t *testing.T) { + signingKey := generateK256(t) + services := map[string]Service{ + "atproto_labeler": {Type: "AtprotoLabeler", Endpoint: "https://labeler.example.com"}, + } + doc, err := BuildDIDDocument("did:web:labeler.example.com", "https://labeler.example.com", signingKey, "atproto_label", services) + if err != nil { + t.Fatalf("BuildDIDDocument: %v", err) + } + if doc.VerificationMethod[0].ID != "did:web:labeler.example.com#atproto_label" { + t.Errorf("VerificationMethod.ID: got %s", doc.VerificationMethod[0].ID) + } + if len(doc.Authentication) != 0 { + t.Errorf("Authentication should be empty for non-atproto key, got %v", doc.Authentication) + } + if len(doc.Service) != 1 || doc.Service[0].ID != "#atproto_labeler" { + t.Errorf("Service: got %v", doc.Service) + } +} + +// TestBuildDIDDocument_NoServices confirms a DID document can be built without any service entries. +func TestBuildDIDDocument_NoServices(t *testing.T) { + signingKey := generateK256(t) + doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "atproto", nil) + if err != nil { + t.Fatalf("BuildDIDDocument: %v", err) + } + if len(doc.Service) != 0 { + t.Errorf("Service should be empty, got %v", doc.Service) + } +} + +// TestMarshalDIDDocument confirms marshaling produces parseable, indented JSON. +func TestMarshalDIDDocument(t *testing.T) { + signingKey := generateK256(t) + doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "atproto", testServices("https://example.com")) + if err != nil { + t.Fatalf("BuildDIDDocument: %v", err) + } + + data, err := MarshalDIDDocument(doc) + if err != nil { + t.Fatalf("MarshalDIDDocument: %v", err) + } + + if !strings.Contains(string(data), " ") { + t.Error("expected indented JSON output") + } + + var parsed DIDDocument + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if parsed.ID != doc.ID { + t.Errorf("ID round-trip: got %s want %s", parsed.ID, doc.ID) + } + if len(parsed.Service) != len(doc.Service) { + t.Errorf("Service length round-trip: got %d want %d", len(parsed.Service), len(doc.Service)) + } +} + +// TestLoadOrCreate_DIDWeb confirms did:web mode returns a deterministic identifier +// without touching disk or any external service. +func TestLoadOrCreate_DIDWeb(t *testing.T) { + cfg := Config{ + Method: "web", + PublicURL: "https://hold.example.com", + } + d, err := LoadOrCreate(context.Background(), cfg) + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + if d != "did:web:hold.example.com" { + t.Errorf("DID: got %s want did:web:hold.example.com", d) + } +} + +// TestLoadOrCreate_DIDWebDefaultsToWebWhenMethodEmpty confirms an empty method behaves like did:web. +func TestLoadOrCreate_DIDWebDefaultsToWebWhenMethodEmpty(t *testing.T) { + cfg := Config{ + PublicURL: "https://example.com:8443", + } + d, err := LoadOrCreate(context.Background(), cfg) + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + if d != "did:web:example.com%3A8443" { + t.Errorf("DID: got %s want did:web:example.com%%3A8443", d) + } +} + +// TestLoadOrCreate_PLCRequiresVerificationKeyName confirms missing required PLC fields error early. +func TestLoadOrCreate_PLCRequiresVerificationKeyName(t *testing.T) { + cfg := Config{ + Method: "plc", + PublicURL: "https://example.com", + Services: map[string]Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"}, + }, + } + _, err := LoadOrCreate(context.Background(), cfg) + if err == nil { + t.Fatal("expected error when VerificationKeyName is empty") + } + if !strings.Contains(err.Error(), "VerificationKeyName") { + t.Errorf("expected error about VerificationKeyName, got: %v", err) + } +} + +// TestLoadOrCreate_PLCRequiresServices confirms PLC mode demands at least one service entry. +func TestLoadOrCreate_PLCRequiresServices(t *testing.T) { + cfg := Config{ + Method: "plc", + PublicURL: "https://example.com", + VerificationKeyName: "atproto", + } + _, err := LoadOrCreate(context.Background(), cfg) + if err == nil { + t.Fatal("expected error when Services is empty") + } + if !strings.Contains(err.Error(), "service") { + t.Errorf("expected error about services, got: %v", err) + } +} + +// TestLoadOrCreate_PLCRejectsNonPLCAdoption confirms a configured DID must be a did:plc identifier. +func TestLoadOrCreate_PLCRejectsNonPLCAdoption(t *testing.T) { + tmp := t.TempDir() + cfg := Config{ + Method: "plc", + PublicURL: "https://example.com", + DBPath: tmp, + SigningKeyPath: filepath.Join(tmp, "signing.key"), + VerificationKeyName: "atproto", + DID: "did:web:example.com", + Services: map[string]Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"}, + }, + } + _, err := LoadOrCreate(context.Background(), cfg) + if err == nil { + t.Fatal("expected error for non-did:plc adoption") + } + if !strings.Contains(err.Error(), "did:plc") { + t.Errorf("expected error about did:plc, got: %v", err) + } +} + +// TestLoadOrCreate_PLCAdoptionPersistsDID confirms a configured did:plc is written to did.txt +// even when the PLC directory call fails (the failure is logged, not returned). +func TestLoadOrCreate_PLCAdoptionPersistsDID(t *testing.T) { + tmp := t.TempDir() + cfg := Config{ + Method: "plc", + PublicURL: "https://example.com", + DBPath: tmp, + SigningKeyPath: filepath.Join(tmp, "signing.key"), + VerificationKeyName: "atproto", + DID: "did:plc:abcdefghijklmnopqrstuvwx", + PLCDirectoryURL: "http://127.0.0.1:1", // unreachable; EnsureCurrent failure is non-fatal + Services: map[string]Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"}, + }, + } + d, err := LoadOrCreate(context.Background(), cfg) + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + if d != "did:plc:abcdefghijklmnopqrstuvwx" { + t.Errorf("DID: got %s", d) + } + + got, err := os.ReadFile(filepath.Join(tmp, "did.txt")) + if err != nil { + t.Fatalf("read did.txt: %v", err) + } + if strings.TrimSpace(string(got)) != "did:plc:abcdefghijklmnopqrstuvwx" { + t.Errorf("did.txt contents: got %q", string(got)) + } +} diff --git a/pkg/atproto/did/plc.go b/pkg/atproto/did/plc.go new file mode 100644 index 0000000..5569622 --- /dev/null +++ b/pkg/atproto/did/plc.go @@ -0,0 +1,172 @@ +package did + +import ( + "context" + "fmt" + "log/slog" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + didplc "github.com/did-method-plc/go-didplc" +) + +// CreateIdentity builds a genesis PLC operation with the configured verification key and +// services, signs it with the rotation key, and submits it to the PLC directory. +func CreateIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, cfg Config) (string, error) { + rotPub, err := rotationKey.PublicKey() + if err != nil { + return "", fmt.Errorf("did: failed to get rotation public key: %w", err) + } + sigPub, err := signingKey.PublicKey() + if err != nil { + return "", fmt.Errorf("did: failed to get signing public key: %w", err) + } + + host, err := hostWithPort(cfg.PublicURL) + if err != nil { + return "", err + } + + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: []string{rotPub.DIDKey()}, + VerificationMethods: map[string]string{ + cfg.VerificationKeyName: sigPub.DIDKey(), + }, + AlsoKnownAs: []string{"at://" + host}, + Services: toOpServices(cfg.Services), + Prev: nil, + } + if err := op.Sign(rotationKey); err != nil { + return "", fmt.Errorf("did: failed to sign genesis operation: %w", err) + } + + d, err := op.DID() + if err != nil { + return "", fmt.Errorf("did: failed to compute DID from genesis: %w", err) + } + + client := &didplc.Client{DirectoryURL: cfg.PLCDirectoryURL} + if err := client.Submit(ctx, d, op); err != nil { + return "", fmt.Errorf("did: failed to submit genesis operation: %w", err) + } + return d, nil +} + +// EnsureCurrent reconciles the published DID document with the local config; if the local +// signing key, public URL, or service set differs, an update operation is signed and submitted. +// Without a rotation key, mismatches log a warning but are not fatal. +func EnsureCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, cfg Config) error { + client := &didplc.Client{DirectoryURL: cfg.PLCDirectoryURL} + + opLog, err := client.OpLog(ctx, did) + if err != nil { + return fmt.Errorf("did: failed to fetch op log for %s: %w", did, err) + } + if len(opLog) == 0 { + return fmt.Errorf("did: empty op log for %s", did) + } + lastEntry := opLog[len(opLog)-1] + lastOp := lastEntry.Regular + if lastOp == nil { + slog.Warn("Last PLC operation is not a regular op, skipping auto-update", "did", did) + return nil + } + + sigPub, err := signingKey.PublicKey() + if err != nil { + return fmt.Errorf("did: failed to get signing public key: %w", err) + } + localKey := sigPub.DIDKey() + plcKey := lastOp.VerificationMethods[cfg.VerificationKeyName] + keyMatch := localKey == plcKey + + servicesMatch := true + for name, svc := range cfg.Services { + plcSvc, ok := lastOp.Services[name] + if !ok || plcSvc.Type != svc.Type || plcSvc.Endpoint != svc.Endpoint { + servicesMatch = false + break + } + } + + if keyMatch && servicesMatch { + slog.Info("PLC identity is current", "did", did) + return nil + } + + slog.Info("PLC identity needs update", + "did", did, "signing_key_changed", !keyMatch, "services_changed", !servicesMatch) + + if rotationKey == nil { + slog.Warn("PLC document doesn't match local state but no rotation key available. Provide rotation key to auto-update.", + "did", did, "signing_key_changed", !keyMatch, "services_changed", !servicesMatch) + return nil + } + + rotPub, err := rotationKey.PublicKey() + if err != nil { + return fmt.Errorf("did: failed to get rotation public key: %w", err) + } + localRotKey := rotPub.DIDKey() + + // Verify the local rotation key still has authority on the PLC document. + // If it's been rotated out (possibly maliciously), refuse to submit — PLC would + // reject anyway, and silent failure here would be confusing. + localRotKeyPresent := false + for _, k := range lastOp.RotationKeys { + if k == localRotKey { + localRotKeyPresent = true + break + } + } + if !localRotKeyPresent { + slog.Warn("Local rotation key not present in PLC document — refusing to update. Possible compromise or out-of-band rotation. Recover with offline key if available.", + "did", did, "local_rotation_key", localRotKey, "plc_rotation_keys", lastOp.RotationKeys) + return nil + } + + host, err := hostWithPort(cfg.PublicURL) + if err != nil { + return err + } + prevCID := lastEntry.AsOperation().CID().String() + + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: lastOp.RotationKeys, + VerificationMethods: map[string]string{ + cfg.VerificationKeyName: localKey, + }, + AlsoKnownAs: []string{"at://" + host}, + Services: toOpServices(cfg.Services), + Prev: &prevCID, + } + if err := op.Sign(rotationKey); err != nil { + return fmt.Errorf("did: failed to sign update operation: %w", err) + } + if err := client.Submit(ctx, did, op); err != nil { + return fmt.Errorf("did: failed to submit update: %w", err) + } + slog.Info("Updated PLC identity", + "did", did, "signing_key_rotated", !keyMatch, "services_changed", !servicesMatch) + return nil +} + +func toOpServices(in map[string]Service) map[string]didplc.OpService { + out := make(map[string]didplc.OpService, len(in)) + for name, svc := range in { + out[name] = didplc.OpService{Type: svc.Type, Endpoint: svc.Endpoint} + } + return out +} + +func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) { + if encoded == "" { + return nil, nil + } + key, err := atcrypto.ParsePrivateMultibase(encoded) + if err != nil { + return nil, fmt.Errorf("did: failed to parse multibase key: %w", err) + } + return key, nil +} diff --git a/pkg/atproto/did/plc_test.go b/pkg/atproto/did/plc_test.go new file mode 100644 index 0000000..0d52eba --- /dev/null +++ b/pkg/atproto/did/plc_test.go @@ -0,0 +1,243 @@ +package did + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + didplc "github.com/did-method-plc/go-didplc" +) + +// fakePLC stands up an httptest server that serves a single op log entry and +// captures any submitted update op for inspection. +type fakePLC struct { + server *httptest.Server + did string + logEntries []didplc.OpEnum + submitted []didplc.RegularOp +} + +func (f *fakePLC) URL() string { return f.server.URL } + +func (f *fakePLC) Close() { f.server.Close() } + +// newFakePLC creates a fake PLC directory pre-loaded with a single signed +// genesis op containing the given rotation keys (in priority order). Returns +// the fake server and the resulting did:plc DID derived from the genesis op. +func newFakePLC(t *testing.T, rotationKeys []*atcrypto.PrivateKeyK256, signer atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256) *fakePLC { + t.Helper() + + rotationDIDKeys := make([]string, 0, len(rotationKeys)) + for _, k := range rotationKeys { + pub, err := k.PublicKey() + if err != nil { + t.Fatalf("rotation key public: %v", err) + } + rotationDIDKeys = append(rotationDIDKeys, pub.DIDKey()) + } + + sigPub, err := signingKey.PublicKey() + if err != nil { + t.Fatalf("signing key public: %v", err) + } + + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: rotationDIDKeys, + VerificationMethods: map[string]string{ + "atproto": sigPub.DIDKey(), + }, + AlsoKnownAs: []string{"at://example.test"}, + Services: map[string]didplc.OpService{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"}, + }, + Prev: nil, + } + if err := op.Sign(signer); err != nil { + t.Fatalf("sign genesis: %v", err) + } + did, err := op.DID() + if err != nil { + t.Fatalf("compute DID: %v", err) + } + + f := &fakePLC{ + did: did, + logEntries: []didplc.OpEnum{{Regular: op}}, + } + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/log") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(f.logEntries) + return + } + if r.Method == http.MethodPost && r.URL.Path == "/"+did { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var op didplc.RegularOp + if err := json.Unmarshal(body, &op); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f.submitted = append(f.submitted, op) + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + f.server = httptest.NewServer(mux) + return f +} + +// generateK256 returns a fresh K-256 keypair, failing the test on error. +func generateK256(t *testing.T) *atcrypto.PrivateKeyK256 { + t.Helper() + k, err := atcrypto.GeneratePrivateKeyK256() + if err != nil { + t.Fatalf("generate K-256: %v", err) + } + return k +} + +// writeSigningKey persists a signing key to a temp file and returns its path. +// Used so EnsureCurrent can load it via oauth.GenerateOrLoadPDSKey. +func writeSigningKey(t *testing.T, dir string, key *atcrypto.PrivateKeyK256) string { + t.Helper() + path := filepath.Join(dir, "signing.key") + if err := os.WriteFile(path, key.Bytes(), 0600); err != nil { + t.Fatalf("write signing key: %v", err) + } + return path +} + +func TestEnsureCurrent_PreservesRotationKeys(t *testing.T) { + ctx := context.Background() + tmp := t.TempDir() + + // Server-side rotation key (the one stored in database.rotation_key) and an + // "offline" recovery key that lives only in PLC. The genesis op lists offline + // FIRST (highest priority). + serverRot := generateK256(t) + offlineRot := generateK256(t) + + // Original signing key used to build genesis; the local signing key on disk + // will be different to force EnsureCurrent into the update path. + originalSigning := generateK256(t) + localSigning := generateK256(t) + writeSigningKey(t, tmp, localSigning) + + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{offlineRot, serverRot}, serverRot, originalSigning) + defer fake.Close() + + cfg := Config{ + PublicURL: "https://example.test", + PLCDirectoryURL: fake.URL(), + VerificationKeyName: "atproto", + Services: map[string]Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"}, + }, + } + + if err := EnsureCurrent(ctx, fake.did, serverRot, localSigning, cfg); err != nil { + t.Fatalf("EnsureCurrent: %v", err) + } + + if len(fake.submitted) != 1 { + t.Fatalf("expected exactly one update op submitted, got %d", len(fake.submitted)) + } + got := fake.submitted[0] + + offlinePub, _ := offlineRot.PublicKey() + serverPub, _ := serverRot.PublicKey() + want := []string{offlinePub.DIDKey(), serverPub.DIDKey()} + + if len(got.RotationKeys) != len(want) { + t.Fatalf("rotation keys length: got %d want %d", len(got.RotationKeys), len(want)) + } + for i := range want { + if got.RotationKeys[i] != want[i] { + t.Errorf("rotation key [%d]: got %s want %s", i, got.RotationKeys[i], want[i]) + } + } + + // Verify signing key was actually rotated (sanity check we hit the update path). + localPub, _ := localSigning.PublicKey() + if got.VerificationMethods["atproto"] != localPub.DIDKey() { + t.Errorf("expected signing key to update to local key %s, got %s", + localPub.DIDKey(), got.VerificationMethods["atproto"]) + } +} + +func TestEnsureCurrent_RefusesUpdateWhenLocalKeyMissing(t *testing.T) { + ctx := context.Background() + tmp := t.TempDir() + + // Genesis lists only the offline key. The local server has been rotated out. + offlineRot := generateK256(t) + localRot := generateK256(t) // not in PLC + + originalSigning := generateK256(t) + localSigning := generateK256(t) + writeSigningKey(t, tmp, localSigning) + + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{offlineRot}, offlineRot, originalSigning) + defer fake.Close() + + cfg := Config{ + PublicURL: "https://example.test", + PLCDirectoryURL: fake.URL(), + VerificationKeyName: "atproto", + Services: map[string]Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"}, + }, + } + + // Local signing key has drifted, which would normally trigger an update. + if err := EnsureCurrent(ctx, fake.did, localRot, localSigning, cfg); err != nil { + t.Fatalf("EnsureCurrent returned error: %v", err) + } + + if len(fake.submitted) != 0 { + t.Fatalf("expected no update submission when local rotation key isn't in PLC list, got %d", len(fake.submitted)) + } +} + +func TestEnsureCurrent_NoOpWhenCurrent(t *testing.T) { + ctx := context.Background() + tmp := t.TempDir() + + serverRot := generateK256(t) + signing := generateK256(t) + writeSigningKey(t, tmp, signing) + + fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing) + defer fake.Close() + + cfg := Config{ + PublicURL: "https://example.test", + PLCDirectoryURL: fake.URL(), + VerificationKeyName: "atproto", + Services: map[string]Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"}, + }, + } + + if err := EnsureCurrent(ctx, fake.did, serverRot, signing, cfg); err != nil { + t.Fatalf("EnsureCurrent: %v", err) + } + if len(fake.submitted) != 0 { + t.Fatalf("expected no update when state is current, got %d submitted", len(fake.submitted)) + } +} diff --git a/pkg/atproto/did/web.go b/pkg/atproto/did/web.go new file mode 100644 index 0000000..63fd8b1 --- /dev/null +++ b/pkg/atproto/did/web.go @@ -0,0 +1,24 @@ +package did + +import ( + "fmt" + "net/url" +) + +// GenerateDIDFromURL computes a did:web identifier from a public URL. +// Per the did:web spec, ports are percent-encoded (`:` → `%3A`). +func GenerateDIDFromURL(publicURL string) string { + u, err := url.Parse(publicURL) + if err != nil { + return fmt.Sprintf("did:web:%s", publicURL) + } + hostname := u.Hostname() + if hostname == "" { + hostname = "localhost" + } + port := u.Port() + if port != "" && port != "80" && port != "443" { + return fmt.Sprintf("did:web:%s%%3A%s", hostname, port) + } + return fmt.Sprintf("did:web:%s", hostname) +} diff --git a/pkg/atproto/did/web_test.go b/pkg/atproto/did/web_test.go new file mode 100644 index 0000000..a62b44f --- /dev/null +++ b/pkg/atproto/did/web_test.go @@ -0,0 +1,38 @@ +package did + +import "testing" + +// TestGenerateDIDFromURL covers host extraction and port encoding for did:web. +func TestGenerateDIDFromURL(t *testing.T) { + cases := []struct { + name string + publicURL string + want string + }{ + {"https no port", "https://hold.example.com", "did:web:hold.example.com"}, + {"http no port", "http://hold.example.com", "did:web:hold.example.com"}, + {"https port 443 stripped", "https://hold.example.com:443", "did:web:hold.example.com"}, + {"http port 80 stripped", "http://hold.example.com:80", "did:web:hold.example.com"}, + {"non-standard port encoded", "https://hold.example.com:8443", "did:web:hold.example.com%3A8443"}, + {"localhost with port", "http://localhost:3000", "did:web:localhost%3A3000"}, + {"trailing path ignored", "https://hold.example.com/foo/bar", "did:web:hold.example.com"}, + {"subdomain preserved", "https://api.hold.example.com", "did:web:api.hold.example.com"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := GenerateDIDFromURL(tc.publicURL) + if got != tc.want { + t.Errorf("GenerateDIDFromURL(%q): got %s want %s", tc.publicURL, got, tc.want) + } + }) + } +} + +// TestGenerateDIDFromURL_EmptyHost confirms a URL without a hostname falls back to localhost. +// (url.Parse on a bare path does not error, so the fallback is the only signal we have.) +func TestGenerateDIDFromURL_EmptyHost(t *testing.T) { + got := GenerateDIDFromURL("") + if got != "did:web:localhost" { + t.Errorf("empty URL: got %s want did:web:localhost", got) + } +} diff --git a/pkg/billing/billing.go b/pkg/billing/billing.go index f772569..0f573f7 100644 --- a/pkg/billing/billing.go +++ b/pkg/billing/billing.go @@ -644,10 +644,10 @@ func (m *Manager) aggregateHoldFeatures(rank int) []string { } var ( - minQuota int64 = -1 - maxQuota int64 - scanCount int - totalHolds int + minQuota int64 = -1 + maxQuota int64 + scanCount int + totalHolds int ) for _, cached := range m.holdTierCache { diff --git a/pkg/hold/config.go b/pkg/hold/config.go index 16118f3..e38b416 100644 --- a/pkg/hold/config.go +++ b/pkg/hold/config.go @@ -15,8 +15,10 @@ import ( "github.com/spf13/viper" + "atcr.io/pkg/atproto/did" "atcr.io/pkg/config" "atcr.io/pkg/hold/gc" + "atcr.io/pkg/hold/pds" "atcr.io/pkg/hold/quota" ) @@ -57,6 +59,23 @@ type Config struct { // Subsystems (e.g. billing) use this to re-read the same file for extended fields. func (c *Config) ConfigPath() string { return c.configPath } +// DIDConfig builds the did.Config used to load or create the hold's identity. +// The verification key fragment and service set are hold-specific (atproto PDS + +// AtcrHoldService), so they're filled in here rather than at every callsite. +func (c *Config) DIDConfig() did.Config { + return did.Config{ + DID: c.Database.DID, + Method: c.Database.DIDMethod, + PublicURL: c.Server.PublicURL, + DBPath: c.Database.Path, + SigningKeyPath: c.Database.KeyPath, + RotationKey: c.Database.RotationKey, + PLCDirectoryURL: c.Database.PLCDirectoryURL, + VerificationKeyName: "atproto", + Services: pds.HoldServices(c.Server.PublicURL), + } +} + // AdminConfig defines admin panel settings type AdminConfig struct { // Enable the web-based admin panel. diff --git a/pkg/hold/pds/did.go b/pkg/hold/pds/did.go deleted file mode 100644 index dcce6ec..0000000 --- a/pkg/hold/pds/did.go +++ /dev/null @@ -1,435 +0,0 @@ -package pds - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net/url" - "os" - "path/filepath" - "strings" - - "atcr.io/pkg/auth/oauth" - "github.com/bluesky-social/indigo/atproto/atcrypto" - didplc "github.com/did-method-plc/go-didplc" -) - -// DIDDocument represents a did:web document -type DIDDocument struct { - Context []string `json:"@context"` - ID string `json:"id"` - AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` - VerificationMethod []VerificationMethod `json:"verificationMethod"` - Authentication []string `json:"authentication,omitempty"` - AssertionMethod []string `json:"assertionMethod,omitempty"` - Service []Service `json:"service,omitempty"` -} - -// VerificationMethod represents a public key in a DID document -type VerificationMethod struct { - ID string `json:"id"` - Type string `json:"type"` - Controller string `json:"controller"` - PublicKeyMultibase string `json:"publicKeyMultibase"` -} - -// Service represents a service endpoint in a DID document -type Service struct { - ID string `json:"id"` - Type string `json:"type"` - ServiceEndpoint string `json:"serviceEndpoint"` -} - -// GenerateDIDDocument creates a DID document for the hold's identity. -// It uses the hold's stored DID (which may be did:web or did:plc). -func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) { - did := p.did - - // Parse URL for alsoKnownAs - u, err := url.Parse(publicURL) - if err != nil { - return nil, fmt.Errorf("failed to parse public URL: %w", err) - } - host := u.Hostname() - if port := u.Port(); port != "" && port != "80" && port != "443" { - host = fmt.Sprintf("%s:%s", host, port) - } - - // Get public key in multibase format using indigo's crypto - pubKey, err := p.signingKey.PublicKey() - if err != nil { - return nil, fmt.Errorf("failed to get public key: %w", err) - } - publicKeyMultibase := pubKey.Multibase() - - doc := &DIDDocument{ - Context: []string{ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/multikey/v1", - "https://w3id.org/security/suites/secp256k1-2019/v1", - }, - ID: did, - AlsoKnownAs: []string{ - fmt.Sprintf("at://%s", host), - }, - VerificationMethod: []VerificationMethod{ - { - ID: fmt.Sprintf("%s#atproto", did), - Type: "Multikey", - Controller: did, - PublicKeyMultibase: publicKeyMultibase, - }, - }, - Authentication: []string{ - fmt.Sprintf("%s#atproto", did), - }, - Service: []Service{ - { - ID: "#atproto_pds", - Type: "AtprotoPersonalDataServer", - ServiceEndpoint: publicURL, - }, - { - ID: "#atcr_hold", - Type: "AtcrHoldService", - ServiceEndpoint: publicURL, - }, - }, - } - - return doc, nil -} - -// MarshalDIDDocument converts a DID document to JSON using the stored public URL -func (p *HoldPDS) MarshalDIDDocument() ([]byte, error) { - doc, err := p.GenerateDIDDocument(p.PublicURL) - if err != nil { - return nil, err - } - - return json.MarshalIndent(doc, "", " ") -} - -// DIDConfig holds parameters for DID creation/loading. -type DIDConfig struct { - DID string // Explicit DID for adoption/recovery (optional) - DIDMethod string // "web" or "plc" - PublicURL string - DBPath string - SigningKeyPath string - RotationKey string // Multibase-encoded private key, K-256 or P-256 (optional) - PLCDirectoryURL string -} - -// LoadOrCreateDID returns the hold's DID, either by deriving it from the URL (did:web) -// or by loading/creating a did:plc identity registered with the PLC directory. -// -// For did:plc, the priority is: config DID > did.txt > create new. -// When an existing DID is found (config or did.txt), EnsurePLCCurrent is called -// to auto-update the PLC directory if the signing key or URL has changed. -func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) { - if cfg.DIDMethod != "plc" { - return GenerateDIDFromURL(cfg.PublicURL), nil - } - - didPath := filepath.Join(cfg.DBPath, "did.txt") - - // Priority: config DID > did.txt > create new - var did string - if cfg.DID != "" { - if !strings.HasPrefix(cfg.DID, "did:plc:") { - return "", fmt.Errorf("database.did must be a did:plc identifier, got %q", cfg.DID) - } - did = cfg.DID - slog.Info("Using DID from config (adoption/recovery)", "did", did) - } else if data, err := os.ReadFile(didPath); err == nil { - d := strings.TrimSpace(string(data)) - if strings.HasPrefix(d, "did:plc:") { - did = d - slog.Info("Loaded existing did:plc identity", "did", did) - } - } - - if did != "" { - // Persist to did.txt (may be from config on first adoption) - if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil { - return "", fmt.Errorf("failed to create directory for did.txt: %w", err) - } - if err := os.WriteFile(didPath, []byte(did+"\n"), 0600); err != nil { - return "", fmt.Errorf("failed to write did.txt: %w", err) - } - - // Load signing key (generate if missing — recovery case) - signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath) - if err != nil { - return "", fmt.Errorf("failed to load signing key: %w", err) - } - - // Try to parse rotation key (optional — may not be configured) - rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey) - - if err := EnsurePLCCurrent(ctx, did, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL); err != nil { - slog.Warn("Failed to verify PLC identity is current (will retry on next restart)", - "did", did, - "error", err, - ) - } - - return did, nil - } - - // No existing DID — create new genesis operation - slog.Info("Creating new did:plc identity") - - // Load or generate signing key - signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath) - if err != nil { - return "", fmt.Errorf("failed to load signing key: %w", err) - } - - // Parse or generate rotation key - var rotationKey atcrypto.PrivateKeyExportable - if cfg.RotationKey != "" { - rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey) - if err != nil { - return "", fmt.Errorf("failed to parse rotation_key: %w", err) - } - } else { - // Generate a new rotation key — user must save the multibase output - rawKey, genErr := atcrypto.GeneratePrivateKeyK256() - if genErr != nil { - return "", fmt.Errorf("failed to generate rotation key: %w", genErr) - } - rotationKey = rawKey - slog.Warn("Generated new rotation key — save this in your config as database.rotation_key", - "rotation_key", rawKey.Multibase(), - ) - } - - did, err = CreatePLCIdentity(ctx, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL) - if err != nil { - return "", fmt.Errorf("failed to create PLC identity: %w", err) - } - - // Persist DID - if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil { - return "", fmt.Errorf("failed to create directory for did.txt: %w", err) - } - if err := os.WriteFile(didPath, []byte(did+"\n"), 0600); err != nil { - return "", fmt.Errorf("failed to write did.txt: %w", err) - } - - slog.Info("Created did:plc identity", - "did", did, - "plc_directory", cfg.PLCDirectoryURL, - ) - slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).") - - return did, nil -} - -// parseOptionalMultibaseKey parses a multibase-encoded private key string (K-256 or P-256). -// Returns nil, nil if the input is empty (key not configured). -func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) { - if encoded == "" { - return nil, nil - } - key, err := atcrypto.ParsePrivateMultibase(encoded) - if err != nil { - return nil, fmt.Errorf("failed to parse rotation key multibase string: %w", err) - } - return key, nil -} - -// EnsurePLCCurrent checks the PLC directory for the given DID and updates it -// if the local signing key or public URL doesn't match what's registered. -// If rotationKey is nil, mismatches are logged as warnings but not fatal. -func EnsurePLCCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error { - client := &didplc.Client{DirectoryURL: plcDirectoryURL} - - // Fetch current op log - opLog, err := client.OpLog(ctx, did) - if err != nil { - return fmt.Errorf("failed to fetch PLC op log for %s: %w", did, err) - } - if len(opLog) == 0 { - return fmt.Errorf("empty op log for %s", did) - } - - lastEntry := opLog[len(opLog)-1] - lastOp := lastEntry.Regular - if lastOp == nil { - // Last op is not a regular op (could be legacy or tombstone) — skip update - slog.Warn("Last PLC operation is not a regular op, skipping auto-update", "did", did) - return nil - } - - // Compare local state vs PLC state - sigPub, err := signingKey.PublicKey() - if err != nil { - return fmt.Errorf("failed to get signing public key: %w", err) - } - localVerificationKey := sigPub.DIDKey() - plcVerificationKey := lastOp.VerificationMethods["atproto"] - - localEndpoint := publicURL - var plcEndpoint string - if svc, ok := lastOp.Services["atproto_pds"]; ok { - plcEndpoint = svc.Endpoint - } - - keyMatch := localVerificationKey == plcVerificationKey - endpointMatch := localEndpoint == plcEndpoint - - if keyMatch && endpointMatch { - slog.Info("PLC identity is current", "did", did) - return nil - } - - slog.Info("PLC identity needs update", - "did", did, - "signing_key_changed", !keyMatch, - "endpoint_changed", !endpointMatch, - ) - - if rotationKey == nil { - slog.Warn("PLC document doesn't match local state but no rotation key available. Provide rotation key to auto-update PLC directory.", - "did", did, - "signing_key_changed", !keyMatch, - "endpoint_changed", !endpointMatch, - ) - return nil - } - - // Build update operation - rotPub, err := rotationKey.PublicKey() - if err != nil { - return fmt.Errorf("failed to get rotation public key: %w", err) - } - - // Extract hostname for alsoKnownAs - u, err := url.Parse(publicURL) - if err != nil { - return fmt.Errorf("failed to parse public URL: %w", err) - } - host := u.Hostname() - if port := u.Port(); port != "" && port != "80" && port != "443" { - host = host + ":" + port - } - - prevCID := lastEntry.AsOperation().CID().String() - - op := &didplc.RegularOp{ - Type: "plc_operation", - RotationKeys: []string{rotPub.DIDKey()}, - VerificationMethods: map[string]string{ - "atproto": localVerificationKey, - }, - AlsoKnownAs: []string{"at://" + host}, - Services: map[string]didplc.OpService{ - "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL}, - "atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL}, - }, - Prev: &prevCID, - } - - if err := op.Sign(rotationKey); err != nil { - return fmt.Errorf("failed to sign PLC update operation: %w", err) - } - - if err := client.Submit(ctx, did, op); err != nil { - return fmt.Errorf("failed to submit PLC update: %w", err) - } - - slog.Info("Updated PLC identity", - "did", did, - "signing_key_rotated", !keyMatch, - "endpoint_changed", !endpointMatch, - ) - - return nil -} - -// CreatePLCIdentity creates a new did:plc identity by building a genesis operation, -// signing it with the rotation key, and submitting it to the PLC directory. -func CreatePLCIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) { - rotPub, err := rotationKey.PublicKey() - if err != nil { - return "", fmt.Errorf("failed to get rotation public key: %w", err) - } - - sigPub, err := signingKey.PublicKey() - if err != nil { - return "", fmt.Errorf("failed to get signing public key: %w", err) - } - - // Extract hostname for alsoKnownAs - u, err := url.Parse(publicURL) - if err != nil { - return "", fmt.Errorf("failed to parse public URL: %w", err) - } - host := u.Hostname() - if port := u.Port(); port != "" && port != "80" && port != "443" { - host = host + ":" + port - } - - op := &didplc.RegularOp{ - Type: "plc_operation", - RotationKeys: []string{rotPub.DIDKey()}, - VerificationMethods: map[string]string{ - "atproto": sigPub.DIDKey(), - }, - AlsoKnownAs: []string{"at://" + host}, - Services: map[string]didplc.OpService{ - "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL}, - "atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL}, - }, - Prev: nil, - } - - if err := op.Sign(rotationKey); err != nil { - return "", fmt.Errorf("failed to sign PLC genesis operation: %w", err) - } - - did, err := op.DID() - if err != nil { - return "", fmt.Errorf("failed to compute DID from genesis operation: %w", err) - } - - client := &didplc.Client{DirectoryURL: plcDirectoryURL} - if err := client.Submit(ctx, did, op); err != nil { - return "", fmt.Errorf("failed to submit genesis operation to PLC directory: %w", err) - } - - return did, nil -} - -// GenerateDIDFromURL creates a did:web identifier from a public URL. -// Per the did:web spec, ports are percent-encoded: the colon becomes %3A. -// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com%3A8080" -func GenerateDIDFromURL(publicURL string) string { - // Parse URL - u, err := url.Parse(publicURL) - if err != nil { - // Fallback: assume it's just a hostname - return fmt.Sprintf("did:web:%s", publicURL) - } - - // Get hostname - hostname := u.Hostname() - if hostname == "" { - hostname = "localhost" - } - - // Get port - port := u.Port() - - // Include port in DID if it's non-standard (not 80 for http, not 443 for https) - // Per did:web spec, the colon is percent-encoded as %3A - if port != "" && port != "80" && port != "443" { - return fmt.Sprintf("did:web:%s%%3A%s", hostname, port) - } - - return fmt.Sprintf("did:web:%s", hostname) -} diff --git a/pkg/hold/pds/did_test.go b/pkg/hold/pds/did_test.go deleted file mode 100644 index 3c3dbc8..0000000 --- a/pkg/hold/pds/did_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package pds - -import ( - "context" - "encoding/json" - "path/filepath" - "testing" -) - -// TestGenerateDIDFromURL tests DID generation from various URL formats -func TestGenerateDIDFromURL(t *testing.T) { - tests := []struct { - name string - publicURL string - expectedDID string - }{ - { - name: "standard HTTP with standard port", - publicURL: "http://hold.example.com", - expectedDID: "did:web:hold.example.com", - }, - { - name: "standard HTTPS with standard port", - publicURL: "https://hold.example.com", - expectedDID: "did:web:hold.example.com", - }, - { - name: "HTTP with non-standard port", - publicURL: "http://hold.example.com:8080", - expectedDID: "did:web:hold.example.com%3A8080", - }, - { - name: "HTTPS with non-standard port", - publicURL: "https://hold.example.com:8443", - expectedDID: "did:web:hold.example.com%3A8443", - }, - { - name: "localhost with port", - publicURL: "http://localhost:8080", - expectedDID: "did:web:localhost%3A8080", - }, - { - name: "HTTP with explicit port 80", - publicURL: "http://hold.example.com:80", - expectedDID: "did:web:hold.example.com", - }, - { - name: "HTTPS with explicit port 443", - publicURL: "https://hold.example.com:443", - expectedDID: "did:web:hold.example.com", - }, - { - name: "subdomain", - publicURL: "https://hold1.atcr.io", - expectedDID: "did:web:hold1.atcr.io", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - did := GenerateDIDFromURL(tt.publicURL) - if did != tt.expectedDID { - t.Errorf("Expected DID %s, got %s", tt.expectedDID, did) - } - }) - } -} - -// TestGenerateDIDFromURL_InvalidURL tests handling of invalid URLs -func TestGenerateDIDFromURL_InvalidURL(t *testing.T) { - // Invalid URLs get parsed with empty hostname, which defaults to localhost - did := GenerateDIDFromURL("not a url") - if did != "did:web:localhost" { - t.Errorf("Expected did:web:localhost for invalid URL, got %s", did) - } -} - -// TestGenerateDIDDocument tests DID document generation -func TestGenerateDIDDocument(t *testing.T) { - ctx := context.Background() - tmpDir := t.TempDir() - - dbPath := filepath.Join(tmpDir, "pds.db") - keyPath := filepath.Join(tmpDir, "signing-key") - publicURL := "https://hold.example.com" - - pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false) - if err != nil { - t.Fatalf("Failed to create PDS: %v", err) - } - - doc, err := pds.GenerateDIDDocument(publicURL) - if err != nil { - t.Fatalf("Failed to generate DID document: %v", err) - } - - // Verify required fields - if doc.ID != "did:web:hold.example.com" { - t.Errorf("Expected DID did:web:hold.example.com, got %s", doc.ID) - } - - // Verify context - if len(doc.Context) != 3 { - t.Errorf("Expected 3 context entries, got %d", len(doc.Context)) - } - - expectedContexts := []string{ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/multikey/v1", - "https://w3id.org/security/suites/secp256k1-2019/v1", - } - for i, expected := range expectedContexts { - if doc.Context[i] != expected { - t.Errorf("Expected context[%d] = %s, got %s", i, expected, doc.Context[i]) - } - } - - // Verify alsoKnownAs - if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" { - t.Errorf("Expected alsoKnownAs=['at://hold.example.com'], got %v", doc.AlsoKnownAs) - } - - // Verify verification method - if len(doc.VerificationMethod) != 1 { - t.Fatalf("Expected 1 verification method, got %d", len(doc.VerificationMethod)) - } - - vm := doc.VerificationMethod[0] - if vm.ID != "did:web:hold.example.com#atproto" { - t.Errorf("Expected verification method ID did:web:hold.example.com#atproto, got %s", vm.ID) - } - if vm.Type != "Multikey" { - t.Errorf("Expected type Multikey, got %s", vm.Type) - } - if vm.Controller != "did:web:hold.example.com" { - t.Errorf("Expected controller did:web:hold.example.com, got %s", vm.Controller) - } - if vm.PublicKeyMultibase == "" { - t.Error("Expected non-empty publicKeyMultibase") - } - - // Verify authentication - if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" { - t.Errorf("Expected authentication=['did:web:hold.example.com#atproto'], got %v", doc.Authentication) - } - - // Verify services - if len(doc.Service) != 2 { - t.Fatalf("Expected 2 services, got %d", len(doc.Service)) - } - - // Check PDS service - pdsService := doc.Service[0] - if pdsService.ID != "#atproto_pds" { - t.Errorf("Expected service ID #atproto_pds, got %s", pdsService.ID) - } - if pdsService.Type != "AtprotoPersonalDataServer" { - t.Errorf("Expected service type AtprotoPersonalDataServer, got %s", pdsService.Type) - } - if pdsService.ServiceEndpoint != publicURL { - t.Errorf("Expected service endpoint %s, got %s", publicURL, pdsService.ServiceEndpoint) - } - - // Check hold service - holdService := doc.Service[1] - if holdService.ID != "#atcr_hold" { - t.Errorf("Expected service ID #atcr_hold, got %s", holdService.ID) - } - if holdService.Type != "AtcrHoldService" { - t.Errorf("Expected service type AtcrHoldService, got %s", holdService.Type) - } - if holdService.ServiceEndpoint != publicURL { - t.Errorf("Expected service endpoint %s, got %s", publicURL, holdService.ServiceEndpoint) - } -} - -// TestGenerateDIDDocument_WithPort tests DID document with non-standard port -func TestGenerateDIDDocument_WithPort(t *testing.T) { - ctx := context.Background() - tmpDir := t.TempDir() - - dbPath := filepath.Join(tmpDir, "pds.db") - keyPath := filepath.Join(tmpDir, "signing-key") - publicURL := "https://hold.example.com:8443" - - pds, err := NewHoldPDS(ctx, "did:web:hold.example.com%3A8443", publicURL, "https://atcr.io", dbPath, keyPath, false) - if err != nil { - t.Fatalf("Failed to create PDS: %v", err) - } - - doc, err := pds.GenerateDIDDocument(publicURL) - if err != nil { - t.Fatalf("Failed to generate DID document: %v", err) - } - - // Verify DID includes percent-encoded port - if doc.ID != "did:web:hold.example.com%3A8443" { - t.Errorf("Expected DID did:web:hold.example.com%%3A8443, got %s", doc.ID) - } - - // Verify alsoKnownAs includes port - if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" { - t.Errorf("Expected alsoKnownAs with port, got %s", doc.AlsoKnownAs[0]) - } -} - -// TestMarshalDIDDocument tests DID document JSON marshaling -func TestMarshalDIDDocument(t *testing.T) { - ctx := context.Background() - tmpDir := t.TempDir() - - dbPath := filepath.Join(tmpDir, "pds.db") - keyPath := filepath.Join(tmpDir, "signing-key") - publicURL := "https://hold.example.com" - - pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false) - if err != nil { - t.Fatalf("Failed to create PDS: %v", err) - } - - jsonBytes, err := pds.MarshalDIDDocument() - if err != nil { - t.Fatalf("Failed to marshal DID document: %v", err) - } - - // Verify it's valid JSON - var doc map[string]any - if err := json.Unmarshal(jsonBytes, &doc); err != nil { - t.Fatalf("Failed to unmarshal DID document JSON: %v", err) - } - - // Verify required fields - if id, ok := doc["id"].(string); !ok || id != "did:web:hold.example.com" { - t.Errorf("Expected id='did:web:hold.example.com', got %v", doc["id"]) - } - - if _, ok := doc["@context"]; !ok { - t.Error("Expected @context field in JSON") - } - - if _, ok := doc["verificationMethod"]; !ok { - t.Error("Expected verificationMethod field in JSON") - } - - if _, ok := doc["service"]; !ok { - t.Error("Expected service field in JSON") - } - - // Verify pretty-printed (has indentation) - if len(jsonBytes) < 100 { - t.Error("Expected pretty-printed JSON to be reasonably sized") - } -} - -// TestGenerateDIDDocument_InvalidURL tests error handling -func TestGenerateDIDDocument_InvalidURL(t *testing.T) { - ctx := context.Background() - tmpDir := t.TempDir() - - dbPath := filepath.Join(tmpDir, "pds.db") - keyPath := filepath.Join(tmpDir, "signing-key") - publicURL := "https://hold.example.com" - - pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false) - if err != nil { - t.Fatalf("Failed to create PDS: %v", err) - } - - // Try to generate DID document with invalid URL - _, err = pds.GenerateDIDDocument("ht!tp://invalid url") - if err == nil { - t.Error("Expected error for invalid URL, got nil") - } -} diff --git a/pkg/hold/pds/export.go b/pkg/hold/pds/export.go deleted file mode 100644 index 12e9f72..0000000 --- a/pkg/hold/pds/export.go +++ /dev/null @@ -1,11 +0,0 @@ -package pds - -import ( - "context" - "io" -) - -// ExportToCAR streams the hold's repo as a CAR file to the writer. -func (p *HoldPDS) ExportToCAR(ctx context.Context, w io.Writer) error { - return p.repomgr.ReadRepo(ctx, p.uid, "", w) -} diff --git a/pkg/hold/pds/server.go b/pkg/hold/pds/hold_pds.go similarity index 97% rename from pkg/hold/pds/server.go rename to pkg/hold/pds/hold_pds.go index 20b516c..9c4d72c 100644 --- a/pkg/hold/pds/server.go +++ b/pkg/hold/pds/hold_pds.go @@ -11,6 +11,7 @@ import ( "strings" "atcr.io/pkg/atproto" + "atcr.io/pkg/atproto/did" "atcr.io/pkg/auth/oauth" holddb "atcr.io/pkg/hold/db" "atcr.io/pkg/s3" @@ -36,6 +37,17 @@ func init() { lexutil.RegisterType(atproto.ImageConfigCollection, &atproto.ImageConfigRecord{}) } +// HoldServices returns the service entries the hold publishes in its DID document +// and PLC operations: an atproto PDS endpoint plus the ATCR hold service endpoint. +// Single source of truth, used by both boot-time identity loading and DID-document +// serving. +func HoldServices(publicURL string) map[string]did.Service { + return map[string]did.Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL}, + "atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL}, + } +} + // HoldPDS is a minimal ATProto PDS implementation for a hold service type HoldPDS struct { did string diff --git a/pkg/hold/pds/server_test.go b/pkg/hold/pds/hold_pds_test.go similarity index 100% rename from pkg/hold/pds/server_test.go rename to pkg/hold/pds/hold_pds_test.go diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go index 9e53438..233b57b 100644 --- a/pkg/hold/pds/xrpc.go +++ b/pkg/hold/pds/xrpc.go @@ -7,6 +7,7 @@ import ( "fmt" "atcr.io/pkg/atproto" + "atcr.io/pkg/atproto/did" "atcr.io/pkg/hold/quota" "atcr.io/pkg/s3" "github.com/bluesky-social/indigo/api/bsky" @@ -425,7 +426,7 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) } // Generate DID document - didDoc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL) + didDoc, err := did.BuildDIDDocument(h.pds.DID(), h.pds.PublicURL, h.pds.SigningKey(), "atproto", HoldServices(h.pds.PublicURL)) if err != nil { http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError) return @@ -1387,7 +1388,7 @@ func (h *XRPCHandler) HandleGetLatestCommit(w http.ResponseWriter, r *http.Reque // HandleDIDDocument returns the DID document func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) { - doc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL) + doc, err := did.BuildDIDDocument(h.pds.DID(), h.pds.PublicURL, h.pds.SigningKey(), "atproto", HoldServices(h.pds.PublicURL)) if err != nil { http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError) return diff --git a/pkg/hold/server.go b/pkg/hold/server.go index f69344e..f47183f 100644 --- a/pkg/hold/server.go +++ b/pkg/hold/server.go @@ -11,6 +11,7 @@ import ( "time" "atcr.io/pkg/atproto" + "atcr.io/pkg/atproto/did" "atcr.io/pkg/hold/admin" holddb "atcr.io/pkg/hold/db" "atcr.io/pkg/hold/gc" @@ -76,15 +77,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) { if cfg.Database.Path != "" { ctx := context.Background() - holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{ - DID: cfg.Database.DID, - DIDMethod: cfg.Database.DIDMethod, - PublicURL: cfg.Server.PublicURL, - DBPath: cfg.Database.Path, - SigningKeyPath: cfg.Database.KeyPath, - RotationKey: cfg.Database.RotationKey, - PLCDirectoryURL: cfg.Database.PLCDirectoryURL, - }) + holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig()) if err != nil { return nil, fmt.Errorf("failed to resolve hold DID: %w", err) } diff --git a/pkg/labeler/auth.go b/pkg/labeler/auth.go index 0871f32..cdb3522 100644 --- a/pkg/labeler/auth.go +++ b/pkg/labeler/auth.go @@ -1,26 +1,53 @@ package labeler import ( + "context" "crypto/rand" + "crypto/subtle" "encoding/base64" + "fmt" + "html/template" + "log/slog" + "net" "net/http" + "strings" "sync" + "time" ) -// Session represents an authenticated admin session. +const ( + sessionCookieName = "labeler_session" + sessionTTL = 24 * time.Hour + csrfHeaderName = "X-CSRF-Token" + csrfFormField = "csrf_token" +) + +// Session represents an authenticated admin session. Restart wipes the in-memory +// map so any stolen cookie token becomes useless after a restart, by design. +// +// UserAgent and IPPrefix are captured at login and rechecked on every request — +// a stolen token replayed from a different browser or network prefix is rejected +// and the session is torn down. Empty bound values (Unix sockets, tests, unusual +// proxies) opt out rather than locking users out. Binding at /24 (IPv4) / /64 +// (IPv6) tolerates DHCP renewals within a prefix without inviting cross-network +// replay. type Session struct { - DID string - Handle string + DID string + Handle string + CSRFToken string + CreatedAt time.Time + UserAgent string + IPPrefix string } -// Auth manages admin authentication. +// Auth manages in-memory admin sessions for the labeler. type Auth struct { ownerDID string sessions map[string]*Session sessionsMu sync.RWMutex } -// NewAuth creates a new Auth manager. +// NewAuth wires a fresh in-memory session store keyed to the configured owner DID. func NewAuth(ownerDID string) *Auth { return &Auth{ ownerDID: ownerDID, @@ -28,41 +55,70 @@ func NewAuth(ownerDID string) *Auth { } } -func (a *Auth) createSession(did, handle string) (string, error) { +func randToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { - return "", err + return "", fmt.Errorf("generate token: %w", err) } - token := base64.URLEncoding.EncodeToString(b) + return base64.URLEncoding.EncodeToString(b), nil +} +// CreateSession installs a new in-memory session and returns its cookie token +// alongside the embedded CSRF token (for echoing into forms). +func (a *Auth) CreateSession(did, handle, userAgent, ipPrefix string) (string, *Session, error) { + token, err := randToken() + if err != nil { + return "", nil, err + } + csrfToken, err := randToken() + if err != nil { + return "", nil, err + } + s := &Session{ + DID: did, + Handle: handle, + CSRFToken: csrfToken, + CreatedAt: time.Now(), + UserAgent: userAgent, + IPPrefix: ipPrefix, + } a.sessionsMu.Lock() - a.sessions[token] = &Session{DID: did, Handle: handle} + a.sessions[token] = s a.sessionsMu.Unlock() - - return token, nil + return token, s, nil } -func (a *Auth) getSession(token string) *Session { +// GetSession returns the session for the cookie token, evicting expired entries on access. +func (a *Auth) GetSession(token string) *Session { a.sessionsMu.RLock() - defer a.sessionsMu.RUnlock() - return a.sessions[token] + s := a.sessions[token] + a.sessionsMu.RUnlock() + if s == nil { + return nil + } + if !s.CreatedAt.IsZero() && time.Since(s.CreatedAt) > sessionTTL { + a.sessionsMu.Lock() + delete(a.sessions, token) + a.sessionsMu.Unlock() + return nil + } + return s } -func (a *Auth) deleteSession(token string) { +// DeleteSession removes a session by cookie token (logout). +func (a *Auth) DeleteSession(token string) { a.sessionsMu.Lock() delete(a.sessions, token) a.sessionsMu.Unlock() } -const sessionCookieName = "labeler_session" - func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) { secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", - MaxAge: 86400, // 24 hours + MaxAge: int(sessionTTL.Seconds()), HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, @@ -88,7 +144,18 @@ func getSessionCookie(r *http.Request) (string, bool) { return cookie.Value, true } -// RequireOwner is middleware that checks the session belongs to the owner DID. +type sessionContextKeyT struct{} + +var sessionContextKey = sessionContextKeyT{} + +// SessionFromContext returns the session attached to the request context, if any. +func SessionFromContext(ctx context.Context) *Session { + s, _ := ctx.Value(sessionContextKey).(*Session) + return s +} + +// RequireOwner enforces a valid session bound to the owner DID, with UA / IP-prefix +// replay defense. State-mutating methods then go through the CSRF check below. func (a *Auth) RequireOwner(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := getSessionCookie(r) @@ -96,11 +163,111 @@ func (a *Auth) RequireOwner(next http.Handler) http.Handler { http.Redirect(w, r, "/auth/login", http.StatusFound) return } - session := a.getSession(token) - if session == nil || session.DID != a.ownerDID { + session := a.GetSession(token) + if session == nil { + clearSessionCookie(w) http.Redirect(w, r, "/auth/login", http.StatusFound) return } + if session.DID != a.ownerDID { + a.DeleteSession(token) + clearSessionCookie(w) + http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound) + return + } + if session.UserAgent != "" && session.UserAgent != r.UserAgent() { + slog.Warn("Admin session UA mismatch — suspected token replay", "did", session.DID) + a.DeleteSession(token) + clearSessionCookie(w) + http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound) + return + } + if session.IPPrefix != "" { + if now := clientIPPrefix(r); now != "" && now != session.IPPrefix { + slog.Warn("Admin session IP-prefix mismatch — suspected token replay", + "did", session.DID, "session", session.IPPrefix, "request", now) + a.DeleteSession(token) + clearSessionCookie(w) + http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound) + return + } + } + + ctx := context.WithValue(r.Context(), sessionContextKey, session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireCSRF validates a per-session CSRF token on state-mutating requests. Safe +// methods pass through. Token comes from X-CSRF-Token header or the csrf_token form +// field for application/x-www-form-urlencoded bodies. Must run after RequireOwner. +func (a *Auth) RequireCSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + session := SessionFromContext(r.Context()) + if session == nil || session.CSRFToken == "" { + http.Error(w, "Forbidden: missing session", http.StatusForbidden) + return + } + got := r.Header.Get(csrfHeaderName) + if got == "" { + contentType := r.Header.Get("Content-Type") + if idx := strings.IndexByte(contentType, ';'); idx >= 0 { + contentType = contentType[:idx] + } + contentType = strings.TrimSpace(strings.ToLower(contentType)) + if contentType == "application/x-www-form-urlencoded" { + if err := r.ParseForm(); err == nil { + got = r.PostFormValue(csrfFormField) + } + } + } + if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 { + slog.Warn("Labeler CSRF mismatch", "path", r.URL.Path, "did", session.DID) + http.Error(w, "Forbidden: CSRF token mismatch — reload the page and try again.", http.StatusForbidden) + return + } next.ServeHTTP(w, r) }) } + +// csrfInputHTML emits a hidden form input carrying the per-session CSRF token. +func csrfInputHTML(token string) template.HTML { + escaped := template.HTMLEscapeString(token) + return template.HTML(``) +} + +// clientIPPrefix returns a stable prefix key for the request's client IP — /24 for +// IPv4, /64 for IPv6. Empty string means "don't bind" (avoids locking users behind +// unusual proxies / Unix sockets / tests). +func clientIPPrefix(r *http.Request) string { + var host string + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + if comma := strings.IndexByte(fwd, ','); comma >= 0 { + host = strings.TrimSpace(fwd[:comma]) + } else { + host = strings.TrimSpace(fwd) + } + } else { + h, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + host = h + } else { + host = r.RemoteAddr + } + } + ip := net.ParseIP(host) + if ip == nil { + return "" + } + if v4 := ip.To4(); v4 != nil { + return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2]) + } + v6 := ip.To16() + return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x", + v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7]) +} diff --git a/pkg/labeler/config.go b/pkg/labeler/config.go index 761294a..1335296 100644 --- a/pkg/labeler/config.go +++ b/pkg/labeler/config.go @@ -5,7 +5,9 @@ package labeler import ( "fmt" "net/url" + "path/filepath" "strings" + "time" "github.com/spf13/viper" @@ -30,11 +32,41 @@ type LabelerConfig struct { // Listen address for the labeler HTTP server. Addr string `yaml:"addr" comment:"Listen address for labeler (e.g., :5002)."` + // PublicURL is the externally reachable URL of the labeler. When empty the URL is + // derived from server.base_url by prefixing "labeler." (so https://atcr.io → + // https://labeler.atcr.io). Set explicitly for IP-based dev environments. + PublicURL string `yaml:"public_url" comment:"Externally reachable labeler URL. Empty = derive from server.base_url."` + // DID of the labeler admin. Only this DID can log into the admin panel. OwnerDID string `yaml:"owner_did" comment:"DID of the labeler admin. Only this DID can log into the admin panel."` - // Path to labeler SQLite database. - DBPath string `yaml:"db_path" comment:"Path to labeler SQLite database."` + // Directory for labeler state: SQLite database, signing key, did.txt. + DataDir string `yaml:"data_dir" comment:"Directory for labeler state (database, signing key, did.txt)."` + + // DID method: "plc" (recommended, portable) or "web" (hostname-bound). + DIDMethod string `yaml:"did_method" comment:"DID method: \"plc\" (recommended) or \"web\"."` + + // Explicit DID for did:plc adoption/recovery (optional). + DID string `yaml:"did" comment:"Explicit did:plc identifier for adoption/recovery (optional)."` + + // Signing key path (defaults to /signing.key). + KeyPath string `yaml:"key_path" comment:"Path to K-256 signing key (defaults to /signing.key)."` + + // Rotation key multibase (K-256 or P-256). Required to update the PLC document. + RotationKey string `yaml:"rotation_key" comment:"Multibase-encoded rotation key (K-256 or P-256). Required to update the PLC document."` + + // PLC directory URL (default https://plc.directory). + PLCDirectoryURL string `yaml:"plc_directory_url" comment:"PLC directory URL (default https://plc.directory)."` + + // LibsqlSyncURL enables embedded-replica sync to a remote libSQL/Bunny database when set. + // Empty = local-only mode (default). + LibsqlSyncURL string `yaml:"libsql_sync_url" comment:"Optional libSQL/Bunny remote sync URL. Empty = local-only."` + + // LibsqlAuthToken is the auth token for the remote libSQL database. + LibsqlAuthToken string `yaml:"libsql_auth_token" comment:"Auth token for libsql_sync_url."` + + // LibsqlSyncInterval is how often the embedded replica pulls from the remote. + LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"Embedded-replica pull interval (e.g. 30s). 0 = manual sync only."` } // AppviewServerConfig is a subset of the appview ServerConfig that the labeler needs. @@ -45,9 +77,13 @@ type AppviewServerConfig struct { TestMode bool `yaml:"test_mode"` } -// PublicURL returns the labeler's public URL derived from the appview base URL. -// If appview is https://atcr.io, labeler is https://labeler.atcr.io. +// PublicURL returns the labeler's externally reachable URL. When labeler.public_url +// is set explicitly it wins; otherwise it's derived from server.base_url by prefixing +// "labeler." (so https://atcr.io → https://labeler.atcr.io). func (c *Config) PublicURL() string { + if c.Labeler.PublicURL != "" { + return c.Labeler.PublicURL + } u, err := url.Parse(c.Server.BaseURL) if err != nil { return "" @@ -56,17 +92,25 @@ func (c *Config) PublicURL() string { return u.String() } -// DID returns the labeler's did:web identity derived from its public URL. -func (c *Config) DID() string { - u, err := url.Parse(c.PublicURL()) - if err != nil { - return "" +// DBPath returns the path to the SQLite database file inside the data dir. +func (c *Config) DBPath() string { + return filepath.Join(c.Labeler.DataDir, "labeler.db") +} + +// SigningKeyPath returns the configured signing key path or the default inside DataDir. +func (c *Config) SigningKeyPath() string { + if c.Labeler.KeyPath != "" { + return c.Labeler.KeyPath } - host := u.Hostname() - if port := u.Port(); port != "" { - host += "%3A" + port + return filepath.Join(c.Labeler.DataDir, "signing.key") +} + +// PLCDirectoryURL returns the configured PLC directory URL or the canonical default. +func (c *Config) PLCDirectoryURL() string { + if c.Labeler.PLCDirectoryURL != "" { + return c.Labeler.PLCDirectoryURL } - return "did:web:" + host + return "https://plc.directory" } func setDefaults(v *viper.Viper) { @@ -76,8 +120,17 @@ func setDefaults(v *viper.Viper) { // Labeler defaults v.SetDefault("labeler.enabled", false) v.SetDefault("labeler.addr", ":5002") + v.SetDefault("labeler.public_url", "") v.SetDefault("labeler.owner_did", "") - v.SetDefault("labeler.db_path", "/var/lib/atcr-labeler/labeler.db") + v.SetDefault("labeler.data_dir", "/var/lib/atcr-labeler") + v.SetDefault("labeler.did_method", "plc") + v.SetDefault("labeler.did", "") + v.SetDefault("labeler.key_path", "") + v.SetDefault("labeler.rotation_key", "") + v.SetDefault("labeler.plc_directory_url", "https://plc.directory") + v.SetDefault("labeler.libsql_sync_url", "") + v.SetDefault("labeler.libsql_auth_token", "") + v.SetDefault("labeler.libsql_sync_interval", 0) // Server defaults (read from shared appview config) v.SetDefault("server.base_url", "") @@ -121,6 +174,11 @@ func LoadConfig(yamlPath string) (*Config, error) { if !strings.HasPrefix(cfg.Labeler.OwnerDID, "did:") { return nil, fmt.Errorf("labeler.owner_did must be a DID (got %q)", cfg.Labeler.OwnerDID) } + switch cfg.Labeler.DIDMethod { + case "plc", "web": + default: + return nil, fmt.Errorf("labeler.did_method must be \"plc\" or \"web\" (got %q)", cfg.Labeler.DIDMethod) + } return cfg, nil } @@ -136,10 +194,12 @@ func ExampleYAML() ([]byte, error) { ClientShortName: "ATCR", }, Labeler: LabelerConfig{ - Enabled: true, - Addr: ":5002", - OwnerDID: "did:plc:your-did-here", - DBPath: "/var/lib/atcr-labeler/labeler.db", + Enabled: true, + Addr: ":5002", + OwnerDID: "did:plc:your-did-here", + DataDir: "/var/lib/atcr-labeler", + DIDMethod: "plc", + PLCDirectoryURL: "https://plc.directory", }, } return config.MarshalCommentedYAML("ATCR Labeler Configuration", cfg) diff --git a/pkg/labeler/config_test.go b/pkg/labeler/config_test.go index b8775cf..4c1fe15 100644 --- a/pkg/labeler/config_test.go +++ b/pkg/labeler/config_test.go @@ -25,27 +25,3 @@ func TestConfig_PublicURL(t *testing.T) { }) } } - -func TestConfig_DID(t *testing.T) { - tests := []struct { - name string - baseURL string - want string - }{ - {"standard", "https://atcr.io", "did:web:labeler.atcr.io"}, - {"with port", "https://atcr.io:8080", "did:web:labeler.atcr.io%3A8080"}, - {"localhost", "http://localhost:5000", "did:web:labeler.localhost%3A5000"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Server: AppviewServerConfig{BaseURL: tt.baseURL}, - } - got := cfg.DID() - if got != tt.want { - t.Errorf("DID() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/pkg/labeler/db.go b/pkg/labeler/db.go index a5ac89a..f169320 100644 --- a/pkg/labeler/db.go +++ b/pkg/labeler/db.go @@ -3,13 +3,21 @@ package labeler import ( "database/sql" "fmt" + "io" + "log/slog" "os" "path/filepath" + "strings" "time" - _ "github.com/tursodatabase/go-libsql" + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/labeling" + "github.com/tursodatabase/go-libsql" ) +// LabelVersion is the ATProto label format version (currently 1). +const LabelVersion int64 = labeling.ATPROTO_LABEL_VERSION + const schema = ` CREATE TABLE IF NOT EXISTS labels ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -20,15 +28,19 @@ CREATE TABLE IF NOT EXISTS labels ( neg BOOLEAN NOT NULL DEFAULT 0, cts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, exp TIMESTAMP, + ver INTEGER NOT NULL DEFAULT 1, + sig BLOB NOT NULL, subject_did TEXT NOT NULL, - subject_repo TEXT NOT NULL DEFAULT '', - UNIQUE(src, uri, val, neg) + subject_repo TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_labels_subject ON labels(subject_did, subject_repo); CREATE INDEX IF NOT EXISTS idx_labels_cts ON labels(cts DESC); +CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri); ` -// Label represents an ATProto label (com.atproto.label.defs#label). +// Label represents an ATProto label record stored locally. Its on-the-wire representation +// is produced by ToLabeling() which round-trips through indigo's labeling package so the +// signature stays valid byte-for-byte. type Label struct { ID int64 Src string @@ -38,100 +50,218 @@ type Label struct { Neg bool Cts time.Time Exp *time.Time + Ver int64 + Sig []byte SubjectDID string SubjectRepo string } -// OpenDB opens or creates the labeler database. -func OpenDB(dbPath string) (*sql.DB, error) { +// LibsqlSync configures optional embedded-replica sync to a remote libSQL database. +// SyncURL empty means local-only mode. +type LibsqlSync struct { + SyncURL string + AuthToken string + SyncInterval time.Duration +} + +// LabelerDB wraps the *sql.DB plus its libsql connector (when in embedded-replica mode) +// so the caller can release file locks on shutdown. +type LabelerDB struct { + DB *sql.DB + connector io.Closer +} + +// Close closes the database and the libsql connector (if any). The connector close is +// what releases file locks; without it a subsequent local-only open errors with +// "database is locked" — the same gotcha the hold ran into. +func (l *LabelerDB) Close() error { + var dbErr, connErr error + if l.DB != nil { + dbErr = l.DB.Close() + } + if l.connector != nil { + connErr = l.connector.Close() + } + if dbErr != nil { + return dbErr + } + return connErr +} + +// OpenDB opens or creates the labeler database. When sync.SyncURL is set, the DB runs +// in embedded-replica mode (writes go to the remote, frames replicate to the local +// file); otherwise it's a plain local libSQL file. Schema is applied either way. +func OpenDB(dbPath string, sync LibsqlSync) (*LabelerDB, error) { if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { return nil, fmt.Errorf("failed to create db directory: %w", err) } - db, err := sql.Open("libsql", "file:"+dbPath) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) + var ( + db *sql.DB + connector io.Closer + ) + + if sync.SyncURL != "" { + opts := []libsql.Option{libsql.WithAuthToken(sync.AuthToken)} + if sync.SyncInterval > 0 { + opts = append(opts, libsql.WithSyncInterval(sync.SyncInterval)) + } + conn, err := libsql.NewEmbeddedReplicaConnector(dbPath, sync.SyncURL, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create libsql embedded replica connector: %w", err) + } + db = sql.OpenDB(conn) + connector = conn + slog.Info("Labeler database opened in embedded replica mode", "path", dbPath, "sync_url", sync.SyncURL) + } else { + dsn := dbPath + if !strings.HasPrefix(dsn, "file:") && !strings.HasPrefix(dsn, ":memory:") { + dsn = "file:" + dsn + } + var err error + db, err = sql.Open("libsql", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + slog.Info("Labeler database opened in local-only mode", "path", dbPath) + } + + // Local PRAGMAs only — Bunny rejects PRAGMA assignments forwarded over the + // replication protocol (same caveat as pkg/hold/db). + if sync.SyncURL == "" { + var journalMode string + if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil { + _ = closeIfNonNil(db, connector) + return nil, fmt.Errorf("failed to set journal mode: %w", err) + } + var busyTimeout int + if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil { + _ = closeIfNonNil(db, connector) + return nil, fmt.Errorf("failed to set busy_timeout: %w", err) + } } - // Apply schema for _, stmt := range splitStatements(schema) { if _, err := db.Exec(stmt); err != nil { + _ = closeIfNonNil(db, connector) return nil, fmt.Errorf("failed to apply schema: %w", err) } } + return &LabelerDB{DB: db, connector: connector}, nil +} - return db, nil +// closeIfNonNil is the defensive cleanup for the failure path on OpenDB so we don't +// leave file locks dangling if schema application fails. +func closeIfNonNil(db *sql.DB, connector io.Closer) error { + if db != nil { + _ = db.Close() + } + if connector != nil { + return connector.Close() + } + return nil } -// splitStatements splits SQL by semicolons (go-libsql doesn't support multi-statement exec). func splitStatements(sql string) []string { - var stmts []string - for _, s := range splitOnSemicolon(sql) { - s = trimSpace(s) + parts := strings.Split(sql, ";") + out := make([]string, 0, len(parts)) + for _, s := range parts { + s = strings.TrimSpace(s) if s != "" { - stmts = append(stmts, s) + out = append(out, s) } } - return stmts + return out } -func splitOnSemicolon(s string) []string { - var parts []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == ';' { - parts = append(parts, s[start:i]) - start = i + 1 - } +// ToLabeling converts the row into indigo's label struct (deterministic CBOR shape). +func (l *Label) ToLabeling() labeling.Label { + out := labeling.Label{ + CreatedAt: l.Cts.UTC().Format(time.RFC3339), + SourceDID: l.Src, + URI: l.URI, + Val: l.Val, + Version: l.Ver, } - if start < len(s) { - parts = append(parts, s[start:]) + if l.CID != "" { + s := l.CID + out.CID = &s } - return parts + if l.Exp != nil { + s := l.Exp.UTC().Format(time.RFC3339) + out.ExpiresAt = &s + } + if l.Neg { + t := true + out.Negated = &t + } + if len(l.Sig) > 0 { + out.Sig = l.Sig + } + return out } -func trimSpace(s string) string { - // Simple trim that handles newlines and spaces - i := 0 - for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') { - i++ +// Sign computes a k256 signature over the deterministic CBOR encoding of the label +// (without the sig field) and stores it on the row. +func (l *Label) Sign(key *atcrypto.PrivateKeyK256) error { + if l.Ver == 0 { + l.Ver = LabelVersion } - j := len(s) - for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') { - j-- + if l.Cts.IsZero() { + l.Cts = time.Now().UTC() } - return s[i:j] + pre := l.ToLabeling() + pre.Sig = nil + if err := pre.Sign(key); err != nil { + return fmt.Errorf("failed to sign label: %w", err) + } + l.Sig = pre.Sig + return nil } -// CreateLabel inserts a new label into the database. +// CreateLabel inserts a freshly signed label and returns its sequence id. +// Caller must Sign() first — CreateLabel rejects rows missing a signature. func CreateLabel(db *sql.DB, l *Label) (int64, error) { + if len(l.Sig) == 0 { + return 0, fmt.Errorf("refusing to insert unsigned label") + } + if l.Ver == 0 { + l.Ver = LabelVersion + } + var expStr *string + if l.Exp != nil { + s := l.Exp.UTC().Format(time.RFC3339) + expStr = &s + } result, err := db.Exec( - `INSERT INTO labels (src, uri, cid, val, neg, cts, exp, subject_did, subject_repo) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(src, uri, val, neg) DO UPDATE SET cts = excluded.cts`, - l.Src, l.URI, l.CID, l.Val, l.Neg, l.Cts.UTC().Format(time.RFC3339), l.Exp, + `INSERT INTO labels (src, uri, cid, val, neg, cts, exp, ver, sig, subject_did, subject_repo) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + l.Src, l.URI, nullableString(l.CID), l.Val, l.Neg, + l.Cts.UTC().Format(time.RFC3339), expStr, l.Ver, l.Sig, l.SubjectDID, l.SubjectRepo, ) if err != nil { - return 0, fmt.Errorf("failed to create label: %w", err) + return 0, fmt.Errorf("failed to insert label: %w", err) } - return result.LastInsertId() + id, err := result.LastInsertId() + if err != nil { + return 0, err + } + l.ID = id + return id, nil } -// NegateLabel creates a negation label to reverse a previous label. -func NegateLabel(db *sql.DB, src, uri, val string, subjectDID, subjectRepo string) error { - _, err := db.Exec( - `INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo) - VALUES (?, ?, ?, 1, ?, ?, ?)`, - src, uri, val, time.Now().UTC().Format(time.RFC3339), subjectDID, subjectRepo, - ) - return err +func nullableString(s string) any { + if s == "" { + return nil + } + return s } // GetLabelsSince returns labels with id > cursor, ordered by id ascending. func GetLabelsSince(db *sql.DB, cursor int64, limit int) ([]Label, error) { rows, err := db.Query( - `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, subject_did, subject_repo + `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo FROM labels WHERE id > ? ORDER BY id ASC LIMIT ?`, cursor, limit, ) @@ -139,10 +269,21 @@ func GetLabelsSince(db *sql.DB, cursor int64, limit int) ([]Label, error) { return nil, err } defer rows.Close() - return scanLabels(rows) } +// LatestSeq returns the highest sequence id in the database, or 0 if empty. +func LatestSeq(db *sql.DB) (int64, error) { + var seq sql.NullInt64 + if err := db.QueryRow(`SELECT MAX(id) FROM labels`).Scan(&seq); err != nil { + return 0, err + } + if !seq.Valid { + return 0, nil + } + return seq.Int64, nil +} + // ListActiveTakedowns returns active (non-negated) takedown labels. func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) { var total int @@ -161,7 +302,7 @@ func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) { } rows, err := db.Query( - `SELECT l1.id, l1.src, l1.uri, COALESCE(l1.cid, ''), l1.val, l1.neg, l1.cts, l1.exp, l1.subject_did, l1.subject_repo + `SELECT l1.id, l1.src, l1.uri, COALESCE(l1.cid, ''), l1.val, l1.neg, l1.cts, l1.exp, l1.ver, l1.sig, l1.subject_did, l1.subject_repo FROM labels l1 WHERE l1.val = '!takedown' AND l1.neg = 0 AND NOT EXISTS ( @@ -182,10 +323,10 @@ func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) { return labels, total, err } -// GetLabelsForRepo returns all active labels for a specific DID + repository. +// GetLabelsForRepo returns all labels for a specific DID + repository. func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) { rows, err := db.Query( - `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, subject_did, subject_repo + `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo FROM labels WHERE subject_did = ? AND subject_repo = ? ORDER BY cts DESC`, @@ -198,55 +339,67 @@ func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) { return scanLabels(rows) } -// NegateRepoLabels creates negation labels for all active takedown labels on a (DID, repo) pair. -func NegateRepoLabels(db *sql.DB, src, did, repo string) error { +// newNegationLabel constructs an unsigned negation label awaiting Sign(). +func newNegationLabel(src, uri, val, did, repo string) *Label { + return &Label{ + Src: src, + URI: uri, + Val: val, + Neg: true, + Cts: time.Now().UTC(), + SubjectDID: did, + SubjectRepo: repo, + } +} + +// NegateRepoLabels signs+inserts negation labels for all active takedown labels on (DID, repo). +func NegateRepoLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did, repo string) ([]Label, error) { rows, err := db.Query( `SELECT uri FROM labels WHERE subject_did = ? AND subject_repo = ? AND val = '!takedown' AND neg = 0`, did, repo, ) if err != nil { - return err + return nil, err } - var uris []string for rows.Next() { var uri string if err := rows.Scan(&uri); err != nil { rows.Close() - return err + return nil, err } uris = append(uris, uri) } rows.Close() if err := rows.Err(); err != nil { - return err + return nil, err } - now := time.Now().UTC().Format(time.RFC3339) + out := make([]Label, 0, len(uris)) for _, uri := range uris { - if _, err := db.Exec( - `INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo) - VALUES (?, ?, '!takedown', 1, ?, ?, ?)`, - src, uri, now, did, repo, - ); err != nil { - return err + neg := newNegationLabel(src, uri, "!takedown", did, repo) + if err := neg.Sign(key); err != nil { + return out, err } + if _, err := CreateLabel(db, neg); err != nil { + return out, err + } + out = append(out, *neg) } - return nil + return out, nil } -// NegateUserLabels creates negation labels for all active takedown labels on a DID (user-level). -func NegateUserLabels(db *sql.DB, src, did string) error { +// NegateUserLabels signs+inserts negation labels for all active takedown labels on a DID. +func NegateUserLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did string) ([]Label, error) { rows, err := db.Query( `SELECT uri, subject_repo FROM labels WHERE subject_did = ? AND val = '!takedown' AND neg = 0`, did, ) if err != nil { - return err + return nil, err } - type uriRepo struct { uri string repo string @@ -256,26 +409,27 @@ func NegateUserLabels(db *sql.DB, src, did string) error { var e uriRepo if err := rows.Scan(&e.uri, &e.repo); err != nil { rows.Close() - return err + return nil, err } entries = append(entries, e) } rows.Close() if err := rows.Err(); err != nil { - return err + return nil, err } - now := time.Now().UTC().Format(time.RFC3339) + out := make([]Label, 0, len(entries)) for _, e := range entries { - if _, err := db.Exec( - `INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo) - VALUES (?, ?, '!takedown', 1, ?, ?, ?)`, - src, e.uri, now, did, e.repo, - ); err != nil { - return err + neg := newNegationLabel(src, e.uri, "!takedown", did, e.repo) + if err := neg.Sign(key); err != nil { + return out, err } + if _, err := CreateLabel(db, neg); err != nil { + return out, err + } + out = append(out, *neg) } - return nil + return out, nil } func scanLabels(rows *sql.Rows) ([]Label, error) { @@ -284,7 +438,7 @@ func scanLabels(rows *sql.Rows) ([]Label, error) { var l Label var cts string var exp *string - if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.SubjectDID, &l.SubjectRepo); err != nil { + if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.Ver, &l.Sig, &l.SubjectDID, &l.SubjectRepo); err != nil { return nil, err } if t, err := time.Parse(time.RFC3339, cts); err == nil { diff --git a/pkg/labeler/db_test.go b/pkg/labeler/db_test.go index 789683c..8b2c727 100644 --- a/pkg/labeler/db_test.go +++ b/pkg/labeler/db_test.go @@ -1,31 +1,66 @@ package labeler import ( + "database/sql" "os" "path/filepath" "testing" "time" + + "github.com/bluesky-social/indigo/atproto/atcrypto" ) +func newTestKey(t *testing.T) *atcrypto.PrivateKeyK256 { + t.Helper() + k, err := atcrypto.GeneratePrivateKeyK256() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return k +} + +// openTestDB opens a fresh local-only labeler DB and registers cleanup. Returns the +// raw *sql.DB so existing tests can keep using it; the wrapper lifecycle is handled +// here so tests don't have to know about the embedded-replica machinery. +func openTestDB(t *testing.T, path string) *sql.DB { + t.Helper() + storage, err := OpenDB(path, LibsqlSync{}) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = storage.Close() }) + return storage.DB +} + +// signAndCreate is a helper that signs the label and inserts it; it returns the row id. +func signAndCreate(t *testing.T, db *sql.DB, key *atcrypto.PrivateKeyK256, l *Label) int64 { + t.Helper() + if err := l.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + id, err := CreateLabel(db, l) + if err != nil { + t.Fatalf("create: %v", err) + } + return id +} + func TestOpenDB(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "subdir", "test.db") - db, err := OpenDB(dbPath) + storage, err := OpenDB(dbPath, LibsqlSync{}) if err != nil { t.Fatalf("OpenDB failed: %v", err) } - defer db.Close() + defer storage.Close() - // Verify directory was created if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) { t.Error("expected directory to be created") } - // Verify tables exist var count int - err = db.QueryRow("SELECT COUNT(*) FROM labels").Scan(&count) - if err != nil { + if err := storage.DB.QueryRow("SELECT COUNT(*) FROM labels").Scan(&count); err != nil { t.Fatalf("failed to query labels table: %v", err) } if count != 0 { @@ -35,31 +70,26 @@ func TestOpenDB(t *testing.T) { func TestCreateLabel(t *testing.T) { dir := t.TempDir() - db, err := OpenDB(filepath.Join(dir, "test.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() + db := openTestDB(t, filepath.Join(dir, "test.db")) + key := newTestKey(t) now := time.Now().UTC().Truncate(time.Second) label := &Label{ - Src: "did:web:labeler.atcr.io", + Src: "did:plc:labeler-1", URI: "at://did:plc:abc/io.atcr.manifest/sha256-123", Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "myimage", } - - id, err := CreateLabel(db, label) - if err != nil { - t.Fatalf("CreateLabel failed: %v", err) - } + id := signAndCreate(t, db, key, label) if id <= 0 { t.Errorf("expected positive id, got %d", id) } + if len(label.Sig) == 0 { + t.Error("expected signature populated by Sign()") + } - // Verify it was stored labels, err := GetLabelsSince(db, 0, 10) if err != nil { t.Fatal(err) @@ -67,146 +97,88 @@ func TestCreateLabel(t *testing.T) { if len(labels) != 1 { t.Fatalf("expected 1 label, got %d", len(labels)) } - if labels[0].Src != "did:web:labeler.atcr.io" { - t.Errorf("expected src did:web:labeler.atcr.io, got %s", labels[0].Src) - } - if labels[0].Val != "!takedown" { - t.Errorf("expected val !takedown, got %s", labels[0].Val) + if labels[0].Src != label.Src { + t.Errorf("Src = %s, want %s", labels[0].Src, label.Src) } if labels[0].SubjectDID != "did:plc:abc" { - t.Errorf("expected subject_did did:plc:abc, got %s", labels[0].SubjectDID) + t.Errorf("SubjectDID = %s", labels[0].SubjectDID) } if labels[0].SubjectRepo != "myimage" { - t.Errorf("expected subject_repo myimage, got %s", labels[0].SubjectRepo) + t.Errorf("SubjectRepo = %s", labels[0].SubjectRepo) + } + if labels[0].Ver != LabelVersion { + t.Errorf("Ver = %d, want %d", labels[0].Ver, LabelVersion) + } + if len(labels[0].Sig) == 0 { + t.Error("expected stored sig to be populated") } } -func TestCreateLabel_Upsert(t *testing.T) { +func TestCreateLabel_RejectsUnsigned(t *testing.T) { dir := t.TempDir() - db, err := OpenDB(filepath.Join(dir, "test.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() + db := openTestDB(t, filepath.Join(dir, "test.db")) - now := time.Now().UTC() label := &Label{ - Src: "did:web:labeler.atcr.io", - URI: "at://did:plc:abc/io.atcr.manifest/sha256-123", - Val: "!takedown", - Cts: now, - SubjectDID: "did:plc:abc", - SubjectRepo: "myimage", + Src: "did:plc:labeler-1", URI: "at://did:plc:abc", + Val: "!takedown", Cts: time.Now().UTC(), + SubjectDID: "did:plc:abc", } - - // First insert - _, err = CreateLabel(db, label) - if err != nil { - t.Fatal(err) - } - - // Same (src, uri, val) - should upsert, not error - label.Cts = now.Add(time.Hour) - _, err = CreateLabel(db, label) - if err != nil { - t.Fatalf("upsert should not fail: %v", err) - } - - // Should still be 1 label - labels, err := GetLabelsSince(db, 0, 10) - if err != nil { - t.Fatal(err) - } - if len(labels) != 1 { - t.Errorf("expected 1 label after upsert, got %d", len(labels)) + if _, err := CreateLabel(db, label); err == nil { + t.Fatal("expected CreateLabel to reject an unsigned label") } } -func TestNegateLabel(t *testing.T) { - dir := t.TempDir() - db, err := OpenDB(filepath.Join(dir, "test.db")) - if err != nil { - t.Fatal(err) +func TestSignAndVerify(t *testing.T) { + key := newTestKey(t) + label := &Label{ + Src: "did:plc:labeler-1", + URI: "at://did:plc:abc", + Val: "!takedown", + Cts: time.Now().UTC(), + Ver: LabelVersion, } - defer db.Close() - - src := "did:web:labeler.atcr.io" - now := time.Now().UTC() - - // Create a label - _, err = CreateLabel(db, &Label{ - Src: src, URI: "at://did:plc:abc/io.atcr.manifest/sha256-123", - Val: "!takedown", Cts: now, - SubjectDID: "did:plc:abc", SubjectRepo: "myimage", - }) - if err != nil { + if err := label.Sign(key); err != nil { t.Fatal(err) } - // Negate it - err = NegateLabel(db, src, "at://did:plc:abc/io.atcr.manifest/sha256-123", "!takedown", "did:plc:abc", "myimage") - if err != nil { - t.Fatalf("NegateLabel failed: %v", err) - } - - // Should have 2 labels now (original + negation) - labels, err := GetLabelsSince(db, 0, 10) + pub, err := key.PublicKey() if err != nil { t.Fatal(err) } - if len(labels) != 2 { - t.Fatalf("expected 2 labels, got %d", len(labels)) - } - - // The negation label should have neg=true - negLabel := labels[1] - if !negLabel.Neg { - t.Error("expected negation label to have neg=true") + wire := label.ToLabeling() + if err := wire.VerifySignature(pub); err != nil { + t.Fatalf("signature did not verify: %v", err) } } func TestListActiveTakedowns(t *testing.T) { dir := t.TempDir() - db, err := OpenDB(filepath.Join(dir, "test.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() + db := openTestDB(t, filepath.Join(dir, "test.db")) + key := newTestKey(t) - src := "did:web:labeler.atcr.io" + src := "did:plc:labeler-1" now := time.Now().UTC() - // Create 3 labels for i, repo := range []string{"repo1", "repo2", "repo3"} { - _, err = CreateLabel(db, &Label{ + signAndCreate(t, db, key, &Label{ Src: src, URI: "at://did:plc:abc/io.atcr.repo/" + repo, Val: "!takedown", Cts: now.Add(time.Duration(i) * time.Minute), SubjectDID: "did:plc:abc", SubjectRepo: repo, }) - if err != nil { - t.Fatal(err) - } } - // All 3 should be active labels, total, err := ListActiveTakedowns(db, 10, 0) if err != nil { t.Fatal(err) } - if total != 3 { - t.Errorf("expected 3 active takedowns, got %d", total) - } - if len(labels) != 3 { - t.Errorf("expected 3 labels returned, got %d", len(labels)) + if total != 3 || len(labels) != 3 { + t.Errorf("expected 3 active takedowns, got total=%d returned=%d", total, len(labels)) } - // Negate one - err = NegateLabel(db, src, "at://did:plc:abc/io.atcr.repo/repo2", "!takedown", "did:plc:abc", "repo2") - if err != nil { + if _, err := NegateRepoLabels(db, key, src, "did:plc:abc", "repo2"); err != nil { t.Fatal(err) } - // Should be 2 active _, total, err = ListActiveTakedowns(db, 10, 0) if err != nil { t.Fatal(err) @@ -218,39 +190,33 @@ func TestListActiveTakedowns(t *testing.T) { func TestNegateRepoLabels(t *testing.T) { dir := t.TempDir() - db, err := OpenDB(filepath.Join(dir, "test.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() + db := openTestDB(t, filepath.Join(dir, "test.db")) + key := newTestKey(t) - src := "did:web:labeler.atcr.io" + src := "did:plc:labeler-1" now := time.Now().UTC() did := "did:plc:abc" - // Create multiple labels for same repo uris := []string{ "at://did:plc:abc/io.atcr.manifest/sha256-111", "at://did:plc:abc/io.atcr.manifest/sha256-222", "at://did:plc:abc/io.atcr.tag/myimage-latest", } for _, uri := range uris { - _, err = CreateLabel(db, &Label{ + signAndCreate(t, db, key, &Label{ Src: src, URI: uri, Val: "!takedown", Cts: now, SubjectDID: did, SubjectRepo: "myimage", }) - if err != nil { - t.Fatal(err) - } } - // Negate all labels for the repo - err = NegateRepoLabels(db, src, did, "myimage") + negs, err := NegateRepoLabels(db, key, src, did, "myimage") if err != nil { t.Fatal(err) } + if len(negs) != len(uris) { + t.Errorf("expected %d negation labels, got %d", len(uris), len(negs)) + } - // Should have 0 active takedowns _, total, err := ListActiveTakedowns(db, 10, 0) if err != nil { t.Fatal(err) @@ -262,39 +228,30 @@ func TestNegateRepoLabels(t *testing.T) { func TestNegateUserLabels(t *testing.T) { dir := t.TempDir() - db, err := OpenDB(filepath.Join(dir, "test.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() + db := openTestDB(t, filepath.Join(dir, "test.db")) + key := newTestKey(t) - src := "did:web:labeler.atcr.io" + src := "did:plc:labeler-1" now := time.Now().UTC() did := "did:plc:abc" - // Create labels for different repos + a user-level label - _, err = CreateLabel(db, &Label{ + signAndCreate(t, db, key, &Label{ Src: src, URI: "at://did:plc:abc", Val: "!takedown", Cts: now, - SubjectDID: did, SubjectRepo: "", + SubjectDID: did, }) - if err != nil { - t.Fatal(err) - } - _, err = CreateLabel(db, &Label{ + signAndCreate(t, db, key, &Label{ Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo1", Val: "!takedown", Cts: now, SubjectDID: did, SubjectRepo: "repo1", }) + + negs, err := NegateUserLabels(db, key, src, did) if err != nil { t.Fatal(err) } - - // Negate all labels for the user - err = NegateUserLabels(db, src, did) - if err != nil { - t.Fatal(err) + if len(negs) != 2 { + t.Errorf("expected 2 negation labels, got %d", len(negs)) } - // Should have 0 active _, total, err := ListActiveTakedowns(db, 10, 0) if err != nil { t.Fatal(err) @@ -306,28 +263,20 @@ func TestNegateUserLabels(t *testing.T) { func TestGetLabelsSince(t *testing.T) { dir := t.TempDir() - db, err := OpenDB(filepath.Join(dir, "test.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() + db := openTestDB(t, filepath.Join(dir, "test.db")) + key := newTestKey(t) - src := "did:web:labeler.atcr.io" + src := "did:plc:labeler-1" now := time.Now().UTC() - // Create 5 labels for i := 0; i < 5; i++ { - _, err = CreateLabel(db, &Label{ + signAndCreate(t, db, key, &Label{ Src: src, URI: "at://did:plc:abc/io.atcr.manifest/" + string(rune('a'+i)), Val: "!takedown", Cts: now.Add(time.Duration(i) * time.Minute), SubjectDID: "did:plc:abc", SubjectRepo: "repo", }) - if err != nil { - t.Fatal(err) - } } - // Get all since 0 labels, err := GetLabelsSince(db, 0, 10) if err != nil { t.Fatal(err) @@ -336,19 +285,15 @@ func TestGetLabelsSince(t *testing.T) { t.Errorf("expected 5 labels, got %d", len(labels)) } - // Get since cursor (skip first 3) - if len(labels) >= 3 { - cursor := labels[2].ID - after, err := GetLabelsSince(db, cursor, 10) - if err != nil { - t.Fatal(err) - } - if len(after) != 2 { - t.Errorf("expected 2 labels after cursor %d, got %d", cursor, len(after)) - } + cursor := labels[2].ID + after, err := GetLabelsSince(db, cursor, 10) + if err != nil { + t.Fatal(err) + } + if len(after) != 2 { + t.Errorf("expected 2 labels after cursor %d, got %d", cursor, len(after)) } - // Get with limit limited, err := GetLabelsSince(db, 0, 2) if err != nil { t.Fatal(err) @@ -358,32 +303,50 @@ func TestGetLabelsSince(t *testing.T) { } } -func TestGetLabelsForRepo(t *testing.T) { +func TestLatestSeq(t *testing.T) { dir := t.TempDir() - db, err := OpenDB(filepath.Join(dir, "test.db")) + db := openTestDB(t, filepath.Join(dir, "test.db")) + key := newTestKey(t) + + if seq, err := LatestSeq(db); err != nil || seq != 0 { + t.Fatalf("expected empty seq=0, got %d (err=%v)", seq, err) + } + + id := signAndCreate(t, db, key, &Label{ + Src: "did:plc:labeler-1", URI: "at://did:plc:abc", + Val: "!takedown", Cts: time.Now().UTC(), + SubjectDID: "did:plc:abc", + }) + seq, err := LatestSeq(db) if err != nil { t.Fatal(err) } - defer db.Close() + if seq != id { + t.Errorf("LatestSeq = %d, want %d", seq, id) + } +} - src := "did:web:labeler.atcr.io" +func TestGetLabelsForRepo(t *testing.T) { + dir := t.TempDir() + db := openTestDB(t, filepath.Join(dir, "test.db")) + key := newTestKey(t) + + src := "did:plc:labeler-1" now := time.Now().UTC() - // Labels for different repos - _, _ = CreateLabel(db, &Label{ + signAndCreate(t, db, key, &Label{ Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo1", Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo1", }) - _, _ = CreateLabel(db, &Label{ + signAndCreate(t, db, key, &Label{ Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo2", Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo2", }) - _, _ = CreateLabel(db, &Label{ + signAndCreate(t, db, key, &Label{ Src: src, URI: "at://did:plc:def/io.atcr.repo/repo1", Val: "!takedown", Cts: now, SubjectDID: "did:plc:def", SubjectRepo: "repo1", }) - // Get labels for specific did+repo labels, err := GetLabelsForRepo(db, "did:plc:abc", "repo1") if err != nil { t.Fatal(err) @@ -392,7 +355,6 @@ func TestGetLabelsForRepo(t *testing.T) { t.Errorf("expected 1 label for did:plc:abc/repo1, got %d", len(labels)) } - // Different user same repo labels, err = GetLabelsForRepo(db, "did:plc:def", "repo1") if err != nil { t.Fatal(err) @@ -401,7 +363,6 @@ func TestGetLabelsForRepo(t *testing.T) { t.Errorf("expected 1 label for did:plc:def/repo1, got %d", len(labels)) } - // No labels labels, err = GetLabelsForRepo(db, "did:plc:xyz", "repo1") if err != nil { t.Fatal(err) diff --git a/pkg/labeler/handlers.go b/pkg/labeler/handlers.go index 4921bd8..2b30d52 100644 --- a/pkg/labeler/handlers.go +++ b/pkg/labeler/handlers.go @@ -13,9 +13,8 @@ import ( // Auth handlers func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { - // If already logged in, redirect to dashboard if token, ok := getSessionCookie(r); ok { - if session := s.auth.getSession(token); session != nil && session.DID == s.config.Labeler.OwnerDID { + if session := s.auth.GetSession(token); session != nil && session.DID == s.config.Labeler.OwnerDID { http.Redirect(w, r, "/", http.StatusFound) return } @@ -99,7 +98,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { return } - token, err := s.auth.createSession(did, handle) + token, _, err := s.auth.CreateSession(did, handle, r.UserAgent(), clientIPPrefix(r)) if err != nil { http.Error(w, "Failed to create session", http.StatusInternalServerError) return @@ -111,7 +110,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if token, ok := getSessionCookie(r); ok { - s.auth.deleteSession(token) + s.auth.DeleteSession(token) } clearSessionCookie(w) http.Redirect(w, r, "/auth/login", http.StatusFound) diff --git a/pkg/labeler/hub.go b/pkg/labeler/hub.go new file mode 100644 index 0000000..09cbc3c --- /dev/null +++ b/pkg/labeler/hub.go @@ -0,0 +1,75 @@ +package labeler + +import ( + "sync" +) + +// hubSubscriber is one connected subscribeLabels client. The hub fans out new labels +// to each subscriber's bounded channel; if a slow client fills the buffer, the hub +// drops them rather than blocking the writer. +type hubSubscriber struct { + ch chan *Label + closed bool +} + +// Hub broadcasts newly-inserted labels to all live subscribeLabels clients. +type Hub struct { + mu sync.Mutex + subs map[*hubSubscriber]struct{} +} + +// NewHub returns an empty hub ready to accept subscribers. +func NewHub() *Hub { + return &Hub{subs: make(map[*hubSubscriber]struct{})} +} + +// subscribe registers a new subscriber and returns its event channel + a cancel func. +// The buffer size bounds backpressure tolerance per client. +func (h *Hub) subscribe(buffer int) (*hubSubscriber, func()) { + s := &hubSubscriber{ch: make(chan *Label, buffer)} + h.mu.Lock() + h.subs[s] = struct{}{} + h.mu.Unlock() + return s, func() { h.unsubscribe(s) } +} + +func (h *Hub) unsubscribe(s *hubSubscriber) { + h.mu.Lock() + defer h.mu.Unlock() + if _, ok := h.subs[s]; !ok { + return + } + delete(h.subs, s) + if !s.closed { + s.closed = true + close(s.ch) + } +} + +// Broadcast sends a copy of the label to every live subscriber. Subscribers whose +// buffer is full are evicted on the spot rather than slowing down the writer. +func (h *Hub) Broadcast(l *Label) { + if l == nil { + return + } + h.mu.Lock() + dead := make([]*hubSubscriber, 0) + for s := range h.subs { + select { + case s.ch <- l: + default: + dead = append(dead, s) + } + } + h.mu.Unlock() + for _, s := range dead { + h.unsubscribe(s) + } +} + +// Len returns the number of live subscribers (mostly for tests / metrics). +func (h *Hub) Len() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.subs) +} diff --git a/pkg/labeler/identity.go b/pkg/labeler/identity.go index 8fcae96..729785f 100644 --- a/pkg/labeler/identity.go +++ b/pkg/labeler/identity.go @@ -1,38 +1,55 @@ package labeler import ( + "context" "encoding/json" "fmt" "net/http" + + "atcr.io/pkg/atproto/did" + "atcr.io/pkg/auth/oauth" + "github.com/bluesky-social/indigo/atproto/atcrypto" ) -// DIDDocument represents a did:web DID document. -type DIDDocument struct { - Context []string `json:"@context"` - ID string `json:"id"` - Service []DIDService `json:"service,omitempty"` +// labelerServices returns the service entries the labeler publishes in its DID document +// and PLC operations: a single AtprotoLabeler endpoint at #atproto_labeler. +func labelerServices(publicURL string) map[string]did.Service { + return map[string]did.Service{ + "atproto_labeler": {Type: "AtprotoLabeler", Endpoint: publicURL}, + } } -// DIDService represents a service entry in a DID document. -type DIDService struct { - ID string `json:"id"` - Type string `json:"type"` - ServiceEndpoint string `json:"serviceEndpoint"` +// LoadIdentity resolves the labeler's DID and loads its k256 signing key. +// For did:plc this calls into the shared PLC package (loading or creating); for did:web +// the DID is derived from PublicURL and the signing key is generated on disk if missing. +func LoadIdentity(ctx context.Context, cfg *Config) (string, *atcrypto.PrivateKeyK256, error) { + labelerDID, err := did.LoadOrCreate(ctx, did.Config{ + Method: cfg.Labeler.DIDMethod, + PublicURL: cfg.PublicURL(), + DBPath: cfg.Labeler.DataDir, + SigningKeyPath: cfg.SigningKeyPath(), + RotationKey: cfg.Labeler.RotationKey, + PLCDirectoryURL: cfg.PLCDirectoryURL(), + DID: cfg.Labeler.DID, + VerificationKeyName: "atproto_label", + Services: labelerServices(cfg.PublicURL()), + }) + if err != nil { + return "", nil, fmt.Errorf("labeler: failed to resolve DID: %w", err) + } + signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath()) + if err != nil { + return "", nil, fmt.Errorf("labeler: failed to load signing key: %w", err) + } + return labelerDID, signingKey, nil } func (s *Server) handleDIDDocument(w http.ResponseWriter, r *http.Request) { - doc := DIDDocument{ - Context: []string{"https://www.w3.org/ns/did/v1"}, - ID: s.config.DID(), - Service: []DIDService{ - { - ID: "#atproto_labeler", - Type: "AtprotoLabeler", - ServiceEndpoint: s.config.PublicURL(), - }, - }, + doc, err := did.BuildDIDDocument(s.did, s.config.PublicURL(), s.signingKey, "atproto_label", labelerServices(s.config.PublicURL())) + if err != nil { + http.Error(w, "failed to build DID document", http.StatusInternalServerError) + return } - w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(doc) } diff --git a/pkg/labeler/server.go b/pkg/labeler/server.go index 8d32b4f..28a7e1b 100644 --- a/pkg/labeler/server.go +++ b/pkg/labeler/server.go @@ -5,34 +5,51 @@ import ( "database/sql" "fmt" "log/slog" + "net" "net/http" "net/url" "os" "os/signal" - "strings" "syscall" + "time" "atcr.io/pkg/atproto" + "github.com/bluesky-social/indigo/atproto/atcrypto" indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/go-chi/chi/v5" ) // Server is the labeler HTTP server. type Server struct { - config *Config - db *sql.DB - router chi.Router - clientApp *indigooauth.ClientApp - auth *Auth + config *Config + storage *LabelerDB + db *sql.DB + router chi.Router + clientApp *indigooauth.ClientApp + auth *Auth + did string + signingKey *atcrypto.PrivateKeyK256 + hub *Hub } // NewServer creates a new labeler server. func NewServer(cfg *Config) (*Server, error) { - db, err := OpenDB(cfg.Labeler.DBPath) + storage, err := OpenDB(cfg.DBPath(), LibsqlSync{ + SyncURL: cfg.Labeler.LibsqlSyncURL, + AuthToken: cfg.Labeler.LibsqlAuthToken, + SyncInterval: cfg.Labeler.LibsqlSyncInterval, + }) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } + ctx := context.Background() + did, signingKey, err := LoadIdentity(ctx, cfg) + if err != nil { + _ = storage.Close() + return nil, err + } + publicURL := cfg.PublicURL() // Set up OAuth client for admin login @@ -68,10 +85,14 @@ func NewServer(cfg *Config) (*Server, error) { auth := NewAuth(cfg.Labeler.OwnerDID) s := &Server{ - config: cfg, - db: db, - clientApp: clientApp, - auth: auth, + config: cfg, + storage: storage, + db: storage.DB, + clientApp: clientApp, + auth: auth, + did: did, + signingKey: signingKey, + hub: NewHub(), } s.setupRoutes() @@ -97,9 +118,11 @@ func (s *Server) setupRoutes() { r.Get("/xrpc/com.atproto.label.subscribeLabels", s.handleSubscribeLabels) r.Get("/xrpc/com.atproto.label.queryLabels", s.handleQueryLabels) - // Protected routes (require owner) + // Protected routes (require owner). CSRF is enforced for state-mutating + // methods inside the same group, so it sees the session on the context. r.Group(func(r chi.Router) { r.Use(s.auth.RequireOwner) + r.Use(s.auth.RequireCSRF) r.Get("/", s.handleDashboard) r.Get("/takedown", s.handleTakedownForm) @@ -115,7 +138,7 @@ func (s *Server) Serve() error { slog.Info("Starting labeler service", "addr", s.config.Labeler.Addr, "public_url", s.config.PublicURL(), - "did", s.config.DID(), + "did", s.did, "owner", s.config.Labeler.OwnerDID, ) @@ -140,17 +163,28 @@ func (s *Server) Serve() error { } case <-ctx.Done(): slog.Info("Shutting down labeler service") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5000000000) // 5s + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("shutdown error: %w", err) } } - s.db.Close() + if err := s.storage.Close(); err != nil { + slog.Warn("Error closing labeler database", "error", err) + } return nil } +// isLocalhost returns true when the host is reachable only from the local machine / +// docker host — anything that an external PDS can't reach. Matches the hold's policy: +// any IP literal counts (covers 127.0.0.1, 192.168.*, 172.16-31.*, 10.*, ::1, etc.) plus +// the literal "localhost". When this is true, OAuth uses indigo's `NewLocalhostConfig` +// which sets a `http://localhost`-form client_id that PDSes accept under the loopback +// exception — so the PDS never has to fetch the client metadata URL we publish. func isLocalhost(host string) bool { - return host == "localhost" || host == "127.0.0.1" || strings.HasPrefix(host, "192.168.") + if host == "localhost" { + return true + } + return net.ParseIP(host) != nil } diff --git a/pkg/labeler/server_test.go b/pkg/labeler/server_test.go new file mode 100644 index 0000000..00bf48f --- /dev/null +++ b/pkg/labeler/server_test.go @@ -0,0 +1,30 @@ +package labeler + +import "testing" + +// TestIsLocalhost covers the OAuth-mode decision: any IP-literal host (including +// docker-compose private addresses like 172.28.0.x and the standard 127.0.0.1) plus +// the literal "localhost" routes through the loopback OAuth path so PDSes don't have +// to fetch our published client metadata. Domain names go through the public-client +// path and require the metadata endpoint to be reachable from the PDS. +func TestIsLocalhost(t *testing.T) { + tests := []struct { + host string + want bool + }{ + {"localhost", true}, + {"127.0.0.1", true}, + {"::1", true}, + {"192.168.1.10", true}, + {"172.28.0.4", true}, // docker-compose private network + {"10.0.0.5", true}, // RFC 1918 + {"labeler.atcr.io", false}, + {"labeler.example.com", false}, + {"", false}, + } + for _, tt := range tests { + if got := isLocalhost(tt.host); got != tt.want { + t.Errorf("isLocalhost(%q) = %v, want %v", tt.host, got, tt.want) + } + } +} diff --git a/pkg/labeler/subscribe.go b/pkg/labeler/subscribe.go index 8e6fd47..3d2cf73 100644 --- a/pkg/labeler/subscribe.go +++ b/pkg/labeler/subscribe.go @@ -1,62 +1,107 @@ package labeler import ( + "bytes" + "database/sql" "encoding/json" + "errors" + "fmt" "log/slog" "net/http" "strconv" - "time" + "strings" + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/events" "github.com/gorilla/websocket" + cbg "github.com/whyrusleeping/cbor-gen" +) + +const ( + subscriberBuffer = 64 + backfillPageLimit = 200 ) var upgrader = websocket.Upgrader{ + // CheckOrigin is permissive: the firehose is a public stream by design and ATProto + // consumers are not browsers, so the same-origin policy doesn't apply to them anyway. CheckOrigin: func(r *http.Request) bool { return true }, } -// LabelsMessage is the ATProto subscribeLabels wire format. -type LabelsMessage struct { - Seq int64 `json:"seq"` - Labels []LabelOutput `json:"labels"` -} +// frameLabels builds the binary frame for a labels event: CBOR-encoded +// {op:1, t:"#labels"} header concatenated with CBOR-encoded {seq, labels:[...]} body. +func frameLabels(seq int64, labels []*comatproto.LabelDefs_Label) ([]byte, error) { + var buf bytes.Buffer + w := cbg.NewCborWriter(&buf) -// LabelOutput is the ATProto label format for subscribeLabels/queryLabels output. -type LabelOutput struct { - Src string `json:"src"` - URI string `json:"uri"` - CID string `json:"cid,omitempty"` - Val string `json:"val"` - Neg bool `json:"neg"` - Cts string `json:"cts"` - Exp string `json:"exp,omitempty"` -} - -func labelToOutput(l Label) LabelOutput { - out := LabelOutput{ - Src: l.Src, - URI: l.URI, - CID: l.CID, - Val: l.Val, - Neg: l.Neg, - Cts: l.Cts.UTC().Format(time.RFC3339), + header := events.EventHeader{Op: events.EvtKindMessage, MsgType: "#labels"} + if err := header.MarshalCBOR(w); err != nil { + return nil, fmt.Errorf("marshal header: %w", err) } - if l.Exp != nil { - out.Exp = l.Exp.UTC().Format(time.RFC3339) + body := comatproto.LabelSubscribeLabels_Labels{Seq: seq, Labels: labels} + if err := body.MarshalCBOR(w); err != nil { + return nil, fmt.Errorf("marshal body: %w", err) } - return out + return buf.Bytes(), nil } -// handleSubscribeLabels implements com.atproto.label.subscribeLabels (WebSocket). +// frameInfo builds the binary frame for an info event: header {op:1, t:"#info"} plus body. +func frameInfo(name, message string) ([]byte, error) { + var buf bytes.Buffer + w := cbg.NewCborWriter(&buf) + + header := events.EventHeader{Op: events.EvtKindMessage, MsgType: "#info"} + if err := header.MarshalCBOR(w); err != nil { + return nil, err + } + body := comatproto.LabelSubscribeLabels_Info{Name: name} + if message != "" { + body.Message = &message + } + if err := body.MarshalCBOR(w); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// frameError builds an error frame: header {op:-1} plus {error, message}. +func frameError(name, message string) ([]byte, error) { + var buf bytes.Buffer + w := cbg.NewCborWriter(&buf) + + header := events.EventHeader{Op: events.EvtKindErrorFrame} + if err := header.MarshalCBOR(w); err != nil { + return nil, err + } + body := events.ErrorFrame{Error: name, Message: message} + if err := body.MarshalCBOR(w); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// labelToLexicon converts a stored row into the indigo lexicon type used in the wire format. +func labelToLexicon(l *Label) *comatproto.LabelDefs_Label { + tmp := l.ToLabeling() + lex := tmp.ToLexicon() + return &lex +} + +// handleSubscribeLabels implements com.atproto.label.subscribeLabels. +// +// Wire format: each WebSocket binary message is two concatenated CBOR objects (header +// + body) matching the firehose convention. Backfill pages historical labels since the +// cursor, then the connection joins the broadcast hub for live deliveries. func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) { cursorStr := r.URL.Query().Get("cursor") var cursor int64 if cursorStr != "" { - var err error - cursor, err = strconv.ParseInt(cursorStr, 10, 64) + v, err := strconv.ParseInt(cursorStr, 10, 64) if err != nil { http.Error(w, "invalid cursor", http.StatusBadRequest) return } + cursor = v } conn, err := upgrader.Upgrade(w, r, nil) @@ -68,34 +113,64 @@ func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) { slog.Info("subscribeLabels client connected", "cursor", cursor) - // Send historical labels since cursor - labels, err := GetLabelsSince(s.db, cursor, 1000) + latest, err := LatestSeq(s.db) if err != nil { - slog.Error("Failed to get labels", "error", err) + slog.Error("Failed to read latest seq", "error", err) + return + } + if cursor > latest { + if frame, ferr := frameError("FutureCursor", "cursor is in the future"); ferr == nil { + _ = conn.WriteMessage(websocket.BinaryMessage, frame) + } return } - for _, l := range labels { - msg := LabelsMessage{ - Seq: l.ID, - Labels: []LabelOutput{labelToOutput(l)}, + // Subscribe to the broadcast hub BEFORE backfilling so we don't lose events + // that arrive while we're streaming the historical tail. + sub, cancel := s.hub.subscribe(subscriberBuffer) + defer cancel() + + if cursor > 0 { + if frame, ferr := frameInfo("OutdatedCursor", "starting backfill from cursor"); ferr == nil { + if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + return + } } - if err := conn.WriteJSON(msg); err != nil { - return - } - cursor = l.ID } - // Poll for new labels - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() + // Backfill historical labels in pages until we catch up. + for { + labels, err := GetLabelsSince(s.db, cursor, backfillPageLimit) + if err != nil { + slog.Error("Failed to read labels for backfill", "error", err) + return + } + if len(labels) == 0 { + break + } + for i := range labels { + frame, ferr := frameLabels(labels[i].ID, []*comatproto.LabelDefs_Label{labelToLexicon(&labels[i])}) + if ferr != nil { + slog.Error("Failed to encode label frame", "error", ferr) + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + return + } + cursor = labels[i].ID + } + if len(labels) < backfillPageLimit { + break + } + } - // Read pump (detect client disconnect) + // Live delivery: a goroutine monitors the read side so we notice client disconnects; + // the main loop pulls from the hub and writes frames until either side closes. done := make(chan struct{}) go func() { defer close(done) for { - if _, _, err := conn.ReadMessage(); err != nil { + if _, _, rerr := conn.ReadMessage(); rerr != nil { return } } @@ -105,83 +180,158 @@ func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) { select { case <-done: return - case <-ticker.C: - labels, err := GetLabelsSince(s.db, cursor, 100) - if err != nil { - slog.Error("Failed to poll labels", "error", err) - continue + case lbl, ok := <-sub.ch: + if !ok { + return } - for _, l := range labels { - msg := LabelsMessage{ - Seq: l.ID, - Labels: []LabelOutput{labelToOutput(l)}, - } - if err := conn.WriteJSON(msg); err != nil { - return - } - cursor = l.ID + if lbl.ID <= cursor { + continue // already delivered during backfill } + frame, ferr := frameLabels(lbl.ID, []*comatproto.LabelDefs_Label{labelToLexicon(lbl)}) + if ferr != nil { + slog.Error("Failed to encode label frame", "error", ferr) + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + return + } + cursor = lbl.ID } } } -// handleQueryLabels implements com.atproto.label.queryLabels (HTTP GET). +// queryLabelsResponse mirrors the lexicon JSON shape for queryLabels. +type queryLabelsResponse struct { + Cursor string `json:"cursor,omitempty"` + Labels []*comatproto.LabelDefs_Label `json:"labels"` +} + +// handleQueryLabels implements com.atproto.label.queryLabels. +// +// Filters (uriPatterns, sources) are applied in SQL so the LIMIT cap operates on the +// filtered result, not the raw scan. URI patterns support a single trailing `*` glob +// (LIKE), with `%` and `_` escaped to remain literal. func (s *Server) handleQueryLabels(w http.ResponseWriter, r *http.Request) { - uriPatterns := r.URL.Query()["uriPatterns"] - cursorStr := r.URL.Query().Get("cursor") - limitStr := r.URL.Query().Get("limit") + q := r.URL.Query() + patterns := q["uriPatterns"] + sources := q["sources"] var cursor int64 - if cursorStr != "" { - cursor, _ = strconv.ParseInt(cursorStr, 10, 64) + if cs := q.Get("cursor"); cs != "" { + v, err := strconv.ParseInt(cs, 10, 64) + if err != nil { + http.Error(w, "invalid cursor", http.StatusBadRequest) + return + } + cursor = v } + limit := 50 - if limitStr != "" { - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 250 { + if ls := q.Get("limit"); ls != "" { + if l, err := strconv.Atoi(ls); err == nil && l > 0 && l <= 250 { limit = l } } - labels, err := GetLabelsSince(s.db, cursor, limit) + rows, err := queryLabelsSQL(s.db, patterns, sources, cursor, limit) if err != nil { - http.Error(w, "failed to query labels", http.StatusInternalServerError) + if errors.Is(err, errInvalidPattern) { + http.Error(w, "invalid uriPattern: wildcard '*' must be at the end", http.StatusBadRequest) + return + } + slog.Error("queryLabels failed", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) return } - // Filter by URI patterns if provided - var filtered []LabelOutput - for _, l := range labels { - if len(uriPatterns) == 0 || matchesAnyPattern(l.URI, uriPatterns) { - filtered = append(filtered, labelToOutput(l)) - } + out := &queryLabelsResponse{Labels: make([]*comatproto.LabelDefs_Label, 0, len(rows))} + for i := range rows { + out.Labels = append(out.Labels, labelToLexicon(&rows[i])) } - - var nextCursor string - if len(labels) > 0 { - nextCursor = strconv.FormatInt(labels[len(labels)-1].ID, 10) - } - - resp := struct { - Cursor string `json:"cursor,omitempty"` - Labels []LabelOutput `json:"labels"` - }{ - Cursor: nextCursor, - Labels: filtered, - } - if resp.Labels == nil { - resp.Labels = []LabelOutput{} + if len(rows) > 0 { + out.Cursor = strconv.FormatInt(rows[len(rows)-1].ID, 10) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(out) } -func matchesAnyPattern(uri string, patterns []string) bool { - for _, p := range patterns { - // Simple prefix matching (ATProto spec allows glob-like patterns) - if p == uri || (len(p) > 0 && p[len(p)-1] == '*' && len(uri) >= len(p)-1 && uri[:len(p)-1] == p[:len(p)-1]) { - return true +var errInvalidPattern = errors.New("invalid uriPattern") + +// queryLabelsSQL builds the WHERE clause from filter args and runs the query. All +// filtering happens in SQL so LIMIT operates on already-filtered rows. +func queryLabelsSQL(db *sql.DB, patterns, sources []string, cursor int64, limit int) ([]Label, error) { + var ( + where []string + args []any + ) + where = append(where, "id > ?") + args = append(args, cursor) + + if len(patterns) > 0 { + var ors []string + var matchAll bool + for _, p := range patterns { + if p == "" { + continue + } + if p == "*" { + matchAll = true + break + } + like, err := patternToLike(p) + if err != nil { + return nil, err + } + if strings.ContainsAny(like, `%_\`) { + ors = append(ors, "uri LIKE ? ESCAPE '\\'") + } else { + ors = append(ors, "uri = ?") + } + args = append(args, like) + } + if !matchAll && len(ors) > 0 { + where = append(where, "("+strings.Join(ors, " OR ")+")") } } - return false + + if len(sources) > 0 { + placeholders := strings.Repeat("?,", len(sources)) + placeholders = placeholders[:len(placeholders)-1] + where = append(where, "src IN ("+placeholders+")") + for _, s := range sources { + args = append(args, s) + } + } + + args = append(args, limit) + q := `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo + FROM labels WHERE ` + strings.Join(where, " AND ") + + ` ORDER BY id ASC LIMIT ?` + + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLabels(rows) +} + +// patternToLike converts a uriPattern into a SQLite LIKE expression. The only +// wildcard supported is a trailing `*`, which becomes `%`. Literal `%`, `_`, and `\` +// in the input are escaped via the LIKE ESCAPE clause used at query time. +func patternToLike(p string) (string, error) { + if idx := strings.Index(p, "*"); idx >= 0 && idx != len(p)-1 { + return "", errInvalidPattern + } + literal := p + suffix := "" + if strings.HasSuffix(p, "*") { + literal = p[:len(p)-1] + suffix = "%" + } + literal = strings.ReplaceAll(literal, `\`, `\\`) + literal = strings.ReplaceAll(literal, `%`, `\%`) + literal = strings.ReplaceAll(literal, `_`, `\_`) + return literal + suffix, nil } diff --git a/pkg/labeler/subscribe_test.go b/pkg/labeler/subscribe_test.go index e5256aa..a5ca3e0 100644 --- a/pkg/labeler/subscribe_test.go +++ b/pkg/labeler/subscribe_test.go @@ -1,86 +1,109 @@ package labeler import ( + "strings" "testing" "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" ) -func TestLabelToOutput(t *testing.T) { +// TestLabelToLexicon checks the wire-format conversion populates the indigo lexicon +// fields (which is what's serialized into both the WS frame and the queryLabels JSON). +func TestLabelToLexicon(t *testing.T) { now := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) exp := time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC) label := Label{ ID: 1, - Src: "did:web:labeler.atcr.io", + Src: "did:plc:abc", URI: "at://did:plc:abc/io.atcr.manifest/sha256-123", CID: "bafyabc", Val: "!takedown", - Neg: false, Cts: now, Exp: &exp, + Ver: LabelVersion, + Sig: []byte{0x01, 0x02, 0x03}, SubjectDID: "did:plc:abc", SubjectRepo: "myimage", } + lex := labelToLexicon(&label) - out := labelToOutput(label) - if out.Src != "did:web:labeler.atcr.io" { - t.Errorf("Src = %q, want did:web:labeler.atcr.io", out.Src) + if lex.Src != label.Src { + t.Errorf("Src = %q", lex.Src) } - if out.URI != "at://did:plc:abc/io.atcr.manifest/sha256-123" { - t.Errorf("URI = %q", out.URI) + if lex.Uri != label.URI { + t.Errorf("Uri = %q", lex.Uri) } - if out.CID != "bafyabc" { - t.Errorf("CID = %q, want bafyabc", out.CID) + if lex.Cid == nil || *lex.Cid != "bafyabc" { + t.Errorf("Cid = %v", lex.Cid) } - if out.Val != "!takedown" { - t.Errorf("Val = %q", out.Val) + if lex.Cts != "2026-03-22T10:00:00Z" { + t.Errorf("Cts = %q", lex.Cts) } - if out.Neg { - t.Error("expected Neg=false") + if lex.Exp == nil || *lex.Exp != "2026-04-22T10:00:00Z" { + t.Errorf("Exp = %v", lex.Exp) } - if out.Cts != "2026-03-22T10:00:00Z" { - t.Errorf("Cts = %q", out.Cts) - } - if out.Exp != "2026-04-22T10:00:00Z" { - t.Errorf("Exp = %q", out.Exp) + if len(lex.Sig) != 3 { + t.Errorf("Sig length = %d, want 3", len(lex.Sig)) } } -func TestLabelToOutput_NoExpiration(t *testing.T) { - label := Label{ - Src: "did:web:labeler.atcr.io", - URI: "at://did:plc:abc", - Val: "!takedown", - Cts: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), - } - - out := labelToOutput(label) - if out.Exp != "" { - t.Errorf("expected empty Exp, got %q", out.Exp) - } -} - -func TestMatchesAnyPattern(t *testing.T) { +func TestPatternToLike(t *testing.T) { tests := []struct { - name string - uri string - patterns []string - want bool + in string + want string + wantErr bool }{ - {"exact match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:abc/io.atcr.manifest/sha256-123"}, true}, - {"no match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/io.atcr.manifest/sha256-123"}, false}, - {"wildcard match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:abc/*"}, true}, - {"wildcard no match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/*"}, false}, - {"empty patterns", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{}, false}, - {"multiple patterns", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/*", "at://did:plc:abc/*"}, true}, + {"at://did:plc:abc/foo", "at://did:plc:abc/foo", false}, + {"at://did:plc:abc/*", "at://did:plc:abc/%", false}, + {"at://did:plc:abc%/foo", `at://did:plc:abc\%/foo`, false}, + {"at://did:plc:abc_/foo", `at://did:plc:abc\_/foo`, false}, + {"at://*/foo", "", true}, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := matchesAnyPattern(tt.uri, tt.patterns) + t.Run(tt.in, func(t *testing.T) { + got, err := patternToLike(tt.in) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } if got != tt.want { - t.Errorf("matchesAnyPattern(%q, %v) = %v, want %v", tt.uri, tt.patterns, got, tt.want) + t.Errorf("patternToLike(%q) = %q, want %q", tt.in, got, tt.want) } }) } } + +// TestFrameLabels exercises the CBOR framing — produces non-empty bytes whose first +// byte is a CBOR map header and whose body contains the labels payload keys. +func TestFrameLabels(t *testing.T) { + now := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) + l := &Label{ + Src: "did:plc:abc", + URI: "at://did:plc:abc", + Val: "!takedown", + Cts: now, + Ver: LabelVersion, + Sig: []byte("sig-bytes"), + } + frame, err := frameLabels(42, []*comatproto.LabelDefs_Label{labelToLexicon(l)}) + if err != nil { + t.Fatalf("frame: %v", err) + } + if len(frame) < 2 { + t.Fatalf("frame too short: %d bytes", len(frame)) + } + if frame[0]&0xe0 != 0xa0 { + t.Errorf("first byte %#x is not a CBOR map header", frame[0]) + } + if !strings.Contains(string(frame), "labels") || !strings.Contains(string(frame), "seq") { + t.Errorf("frame missing expected keys") + } +} diff --git a/pkg/labeler/takedown.go b/pkg/labeler/takedown.go index 546c86b..c9718c4 100644 --- a/pkg/labeler/takedown.go +++ b/pkg/labeler/takedown.go @@ -7,6 +7,7 @@ import ( "html/template" "log/slog" "net/http" + "net/url" "strings" "time" @@ -21,88 +22,137 @@ type TakedownInput struct { } // ParseTakedownInput parses various input formats into a TakedownInput. -// Supported formats: -// - atcr.io/r/handle/repo -// - handle/repo -// - at://did:plc:xyz/io.atcr.repo.page/repo -// - at://did:plc:xyz (user-level) -// - handle (user-level) -// - did:plc:xyz (user-level) +// +// Supported shapes (dispatched in order): +// +// - at://[/collection/rkey] — ATProto AT URI +// - did:plc:..., did:web:... — bare DID, user-level takedown +// - URL with /u/ or /r// — appview routes (with or without scheme) +// - — bare handle, user-level takedown +// +// Anything else is rejected. The appview's /r/ route uses the repo name as a single +// path segment so any trailing path (digest pages, tag tabs) is discarded; URL +// fragments and query strings are dropped in all cases. func ParseTakedownInput(ctx context.Context, input string) (*TakedownInput, error) { input = strings.TrimSpace(input) + if input == "" { + return nil, fmt.Errorf("empty takedown input") + } - // AT URI format if strings.HasPrefix(input, "at://") { return parseATURI(ctx, input) } - // Strip URL prefix if present - input = strings.TrimPrefix(input, "https://") - input = strings.TrimPrefix(input, "http://") - - // Remove atcr.io/r/ or similar prefix - for _, prefix := range []string{"atcr.io/r/", "localhost/r/"} { - if strings.HasPrefix(input, prefix) { - input = strings.TrimPrefix(input, prefix) - break - } - } - // Also handle custom domains: anything ending in /r/ - if idx := strings.Index(input, "/r/"); idx >= 0 { - input = input[idx+3:] + // Bare DID — no slashes, no scheme. did:plc:..., did:web:..., did:web:host%3Aport. + if strings.HasPrefix(input, "did:") && !strings.Contains(input, "/") { + return resolveBareIdentifier(ctx, input) } - // Now input should be "handle/repo" or "handle" or "did:xxx" - parts := strings.SplitN(input, "/", 2) - identifier := parts[0] - var repo string - if len(parts) > 1 { - repo = parts[1] - repo = strings.TrimSuffix(repo, "/") + // URL-shaped: contains a scheme or a slash. Parse and dispatch on the path. + if hasURLShape(input) { + return parseTakedownURL(ctx, input) } - did, handle, err := resolveIdentifier(ctx, identifier) - if err != nil { - return nil, err - } - - return &TakedownInput{ - DID: did, - Handle: handle, - Repository: repo, - }, nil + // Otherwise: bare handle. + return resolveBareIdentifier(ctx, input) } -func parseATURI(ctx context.Context, uri string) (*TakedownInput, error) { - // at://did:plc:xyz/collection/rkey - trimmed := strings.TrimPrefix(uri, "at://") - parts := strings.SplitN(trimmed, "/", 3) +// hasURLShape reports whether the input looks like a URL or a path. A bare handle like +// "alice.bsky.social" is not URL-shaped (no slashes, no scheme). +func hasURLShape(s string) bool { + return strings.Contains(s, "://") || strings.Contains(s, "/") +} - did := parts[0] - if !strings.HasPrefix(did, "did:") { - // It's a handle - resolvedDID, handle, err := resolveIdentifier(ctx, did) +// parseTakedownURL parses a URL whose path is one of the appview's takedown-relevant +// routes: /u/ for user-level, /r// for repo-level. The host part +// is irrelevant — we only use the path — so this also accepts schemeless input like +// "atcr.io/r/handle/repo" by prepending https:// before parsing. +func parseTakedownURL(ctx context.Context, input string) (*TakedownInput, error) { + if !strings.Contains(input, "://") { + input = "https://" + input + } + u, err := url.Parse(input) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) == 0 || parts[0] == "" { + // No path — treat the host as the identifier (e.g. "alice.bsky.social/"). + return resolveBareIdentifier(ctx, u.Host) + } + + switch parts[0] { + case "u": + if len(parts) < 2 || parts[1] == "" { + return nil, fmt.Errorf("missing handle in /u/") + } + return resolveBareIdentifier(ctx, parts[1]) + case "r": + if len(parts) < 3 || parts[1] == "" || parts[2] == "" { + return nil, fmt.Errorf("missing handle or repo in /r//") + } + base, err := resolveBareIdentifier(ctx, parts[1]) if err != nil { return nil, err } - did = resolvedDID - if len(parts) >= 3 { - return &TakedownInput{DID: did, Handle: handle, Repository: parts[2]}, nil + // parts[2] only — discard any /digest/..., /tags/..., etc. trailing path. + base.Repository = parts[2] + return base, nil + default: + return nil, fmt.Errorf("unsupported URL path %q (expected /u/ or /r//)", u.Path) + } +} + +// parseATURI parses an at:// URI. The authority is a DID or handle; the path's third +// segment (rkey) becomes the repo for repo-level takedowns. Fragment and query are +// stripped first since paste-of-browser-AT-URI may include them. +func parseATURI(ctx context.Context, uri string) (*TakedownInput, error) { + trimmed := strings.TrimPrefix(uri, "at://") + if idx := strings.IndexAny(trimmed, "#?"); idx >= 0 { + trimmed = trimmed[:idx] + } + parts := strings.SplitN(trimmed, "/", 3) + authority := parts[0] + if authority == "" { + return nil, fmt.Errorf("at:// URI missing authority") + } + + var ( + did, handle string + err error + ) + if strings.HasPrefix(authority, "did:") { + did = authority + _, handle, _, _ = atproto.ResolveIdentity(ctx, did) + } else { + did, handle, err = resolveIdentifier(ctx, authority) + if err != nil { + return nil, err } - return &TakedownInput{DID: did, Handle: handle}, nil } - // Resolve handle from DID - _, handle, _, _ := atproto.ResolveIdentity(ctx, did) - - if len(parts) < 3 { - // User-level takedown - return &TakedownInput{DID: did, Handle: handle}, nil + out := &TakedownInput{DID: did, Handle: handle} + if len(parts) >= 3 { + out.Repository = parts[2] } + return out, nil +} - // Extract repository from rkey (third part) - repo := parts[2] - return &TakedownInput{DID: did, Handle: handle, Repository: repo}, nil +// resolveBareIdentifier resolves a handle or DID to a user-level TakedownInput. When +// the input is already a DID, the resolve is best-effort (DID is the source of truth; +// handle is just for display) and we don't fail if PLC/web resolution is unreachable. +// For a handle, resolution is required since we need a DID to label. +func resolveBareIdentifier(ctx context.Context, id string) (*TakedownInput, error) { + if strings.HasPrefix(id, "did:") { + _, handle, _, _ := atproto.ResolveIdentity(ctx, id) + return &TakedownInput{DID: id, Handle: handle}, nil + } + did, handle, err := resolveIdentifier(ctx, id) + if err != nil { + return nil, err + } + return &TakedownInput{DID: did, Handle: handle}, nil } func resolveIdentifier(ctx context.Context, identifier string) (did, handle string, err error) { @@ -124,7 +174,7 @@ type TakedownResult struct { // ExecuteTakedown creates takedown labels for a repo or user. func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*TakedownResult, error) { - src := s.config.DID() + src := s.did now := time.Now().UTC() result := &TakedownResult{ DID: input.DID, @@ -143,9 +193,13 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta SubjectDID: input.DID, SubjectRepo: "", } + if err := label.Sign(s.signingKey); err != nil { + return nil, fmt.Errorf("failed to sign user-level label: %w", err) + } if _, err := CreateLabel(s.db, label); err != nil { return nil, fmt.Errorf("failed to create user-level label: %w", err) } + s.hub.Broadcast(label) result.Labels = append(result.Labels, *label) slog.Info("Created user-level takedown", "did", input.DID, "handle", input.Handle) return result, nil @@ -168,9 +222,13 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta SubjectDID: input.DID, SubjectRepo: input.Repository, } + if err := summaryLabel.Sign(s.signingKey); err != nil { + return nil, fmt.Errorf("failed to sign summary label: %w", err) + } if _, err := CreateLabel(s.db, summaryLabel); err != nil { return nil, fmt.Errorf("failed to create summary label: %w", err) } + s.hub.Broadcast(summaryLabel) result.Labels = append(result.Labels, *summaryLabel) slog.Info("Created repo-level takedown", @@ -225,10 +283,15 @@ func (s *Server) discoverAndLabelRecords(ctx context.Context, did, repo, src str SubjectDID: did, SubjectRepo: repo, } + if err := label.Sign(s.signingKey); err != nil { + slog.Warn("Failed to sign label", "uri", uri, "error", err) + continue + } if _, err := CreateLabel(s.db, label); err != nil { slog.Warn("Failed to create label", "uri", uri, "error", err) continue } + s.hub.Broadcast(label) labels = append(labels, *label) } } @@ -256,6 +319,10 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to list takedowns", http.StatusInternalServerError) return } + csrf := "" + if session := SessionFromContext(r.Context()); session != nil { + csrf = session.CSRFToken + } w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, ` @@ -301,12 +368,13 @@ form{display:inline} %s %s %s -
+
%s
`, template.HTMLEscapeString(l.SubjectDID), repoDisplay, template.HTMLEscapeString(l.URI), l.Cts.Format("2006-01-02 15:04"), + csrfInputHTML(csrf), template.HTMLEscapeString(l.SubjectDID), template.HTMLEscapeString(l.SubjectRepo), ) @@ -320,6 +388,10 @@ form{display:inline} func (s *Server) handleTakedownForm(w http.ResponseWriter, r *http.Request) { msg := r.URL.Query().Get("msg") errorMsg := r.URL.Query().Get("error") + csrf := "" + if session := SessionFromContext(r.Context()); session != nil { + csrf = session.CSRFToken + } w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, ` @@ -353,15 +425,16 @@ nav{display:flex;gap:16px;margin-bottom:24px} fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(errorMsg)) } - fmt.Fprint(w, ` + fmt.Fprintf(w, `
+%s - -

Accepts repo URLs, AT URIs, handles, or DIDs. Omit the repo for a user-level takedown.

+ +

Repo: /r/handle/repo (or full atcr.io URL). User-level: /u/handle, a bare handle, or a DID. AT URIs (at://...) also work.


-`) +`, csrfInputHTML(csrf)) } func (s *Server) handleTakedownSubmit(w http.ResponseWriter, r *http.Request) { @@ -399,12 +472,15 @@ func (s *Server) handleReverse(w http.ResponseWriter, r *http.Request) { return } - src := s.config.DID() - var err error + src := s.did + var ( + negs []Label + err error + ) if repo == "" { - err = NegateUserLabels(s.db, src, did) + negs, err = NegateUserLabels(s.db, s.signingKey, src, did) } else { - err = NegateRepoLabels(s.db, src, did, repo) + negs, err = NegateRepoLabels(s.db, s.signingKey, src, did, repo) } if err != nil { @@ -413,6 +489,10 @@ func (s *Server) handleReverse(w http.ResponseWriter, r *http.Request) { return } - slog.Info("Reversed takedown", "did", did, "repo", repo) + for i := range negs { + s.hub.Broadcast(&negs[i]) + } + + slog.Info("Reversed takedown", "did", did, "repo", repo, "negations", len(negs)) http.Redirect(w, r, "/", http.StatusFound) } diff --git a/pkg/labeler/takedown_test.go b/pkg/labeler/takedown_test.go index af89f40..8a895da 100644 --- a/pkg/labeler/takedown_test.go +++ b/pkg/labeler/takedown_test.go @@ -5,37 +5,73 @@ import ( "testing" ) -func TestParseTakedownInput_RepoURL(t *testing.T) { - // These tests only exercise parsing logic, not PDS resolution. - // ResolveIdentity calls are tested with mock server below. +// TestParseTakedownURL exercises the URL-shaped parsing path offline by feeding only +// inputs whose identifier is a DID — DIDs short-circuit ResolveIdentity entirely, so +// this whole table runs without network. Every case here came up while debugging +// browser-paste bugs (URL fragments, query strings, trailing /digest/... segments). +func TestParseTakedownURL(t *testing.T) { tests := []struct { name string input string + wantDID string wantRepo string + wantErr bool }{ - {"full URL", "https://atcr.io/r/handle/myimage", "myimage"}, - {"no scheme", "atcr.io/r/handle/myimage", "myimage"}, - {"handle/repo", "handle/myimage", "myimage"}, - {"trailing slash", "atcr.io/r/handle/myimage/", "myimage"}, - {"custom domain", "https://registry.example.com/r/handle/myimage", "myimage"}, - } + // /r// + {"r path with scheme", "https://atcr.io/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false}, + {"r path no scheme", "atcr.io/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false}, + {"r path trailing slash", "atcr.io/r/did:plc:abc/myimage/", "did:plc:abc", "myimage", false}, + {"r path custom domain", "https://seamark.dev/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false}, + {"r path subroute (digest)", "https://atcr.io/r/did:plc:abc/myimage/digest/sha256-deadbeef", "did:plc:abc", "myimage", false}, + {"r path with hash fragment", "https://atcr.io/r/did:plc:abc/myimage#overview", "did:plc:abc", "myimage", false}, + {"r path with query string", "https://atcr.io/r/did:plc:abc/myimage?tag=latest", "did:plc:abc", "myimage", false}, + {"r path missing repo", "https://atcr.io/r/did:plc:abc", "", "", true}, + // /u/ + {"u path with scheme", "https://atcr.io/u/did:plc:abc", "did:plc:abc", "", false}, + {"u path no scheme", "atcr.io/u/did:plc:abc", "did:plc:abc", "", false}, + {"u path with hash", "https://atcr.io/u/did:plc:abc#tab", "did:plc:abc", "", false}, + {"u path missing handle", "https://atcr.io/u", "", "", true}, + + // Unknown route + {"unsupported path", "https://atcr.io/foo/did:plc:abc", "", "", true}, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // These will fail on ResolveIdentity since there's no real PDS, - // but we can at least verify the parsing doesn't panic - _, err := ParseTakedownInput(context.Background(), tt.input) - if err == nil { - t.Skip("ResolveIdentity succeeded unexpectedly (network available)") + got, err := parseTakedownURL(context.Background(), tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got %+v", got) + } + return } - // The error should be from resolution, not parsing if err != nil { - t.Logf("Expected resolution error: %v", err) + t.Fatalf("unexpected error: %v", err) + } + if got.DID != tt.wantDID { + t.Errorf("DID = %q, want %q", got.DID, tt.wantDID) + } + if got.Repository != tt.wantRepo { + t.Errorf("Repository = %q, want %q", got.Repository, tt.wantRepo) } }) } } +func TestParseTakedownInput_BareDID(t *testing.T) { + // Bare DIDs short-circuit network resolution. + got, err := ParseTakedownInput(context.Background(), "did:plc:abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.DID != "did:plc:abc123" { + t.Errorf("DID = %q, want did:plc:abc123", got.DID) + } + if got.Repository != "" { + t.Errorf("Repository = %q, want empty (user-level)", got.Repository) + } +} + func TestParseTakedownInput_ATURI(t *testing.T) { tests := []struct { name string @@ -61,37 +97,32 @@ func TestParseTakedownInput_ATURI(t *testing.T) { "did:plc:xyz", "sha256-deadbeef", }, + { + "AT URI with hash fragment", + "at://did:plc:abc#frag", + "did:plc:abc", + "", + }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - input, err := ParseTakedownInput(context.Background(), tt.input) + got, err := ParseTakedownInput(context.Background(), tt.input) if err != nil { - // Resolution may fail for handle-based AT URIs - t.Logf("Parse error (may be expected): %v", err) - return + t.Fatalf("unexpected error: %v", err) } - if input.DID != tt.wantDID { - t.Errorf("DID = %q, want %q", input.DID, tt.wantDID) + if got.DID != tt.wantDID { + t.Errorf("DID = %q, want %q", got.DID, tt.wantDID) } - if input.Repository != tt.wantRepo { - t.Errorf("Repository = %q, want %q", input.Repository, tt.wantRepo) + if got.Repository != tt.wantRepo { + t.Errorf("Repository = %q, want %q", got.Repository, tt.wantRepo) } }) } } -func TestParseTakedownInput_DID(t *testing.T) { - // Direct DID input (user-level takedown) - input, err := ParseTakedownInput(context.Background(), "at://did:plc:abc123") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if input.DID != "did:plc:abc123" { - t.Errorf("DID = %q, want did:plc:abc123", input.DID) - } - if input.Repository != "" { - t.Errorf("Repository = %q, want empty (user-level)", input.Repository) +func TestParseTakedownInput_Empty(t *testing.T) { + if _, err := ParseTakedownInput(context.Background(), ""); err == nil { + t.Error("expected error for empty input") } }