diff --git a/README.md b/README.md index c2d5a00..fb6d93b 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,9 @@ DO UPDATE SET max_sub_accounts = EXCLUDED.max_sub_accounts; Operators can bootstrap or rotate keys without EdDSA by using the built-in CLI command. It uses the standard `PG*` connection environment variables and prints -the plaintext API key once. +the plaintext API key once. Creation verifies that the owner and delegated +address are present and accepting new messages in fmsgid; derived sub-accounts +are registered in fmsgid automatically. Derived sub-account: diff --git a/cmd/fmsg-webapi/apikey_cli.go b/cmd/fmsg-webapi/apikey_cli.go index f675592..ed6812a 100644 --- a/cmd/fmsg-webapi/apikey_cli.go +++ b/cmd/fmsg-webapi/apikey_cli.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "net/http" "os" "strings" "time" @@ -49,6 +50,18 @@ func runAPIKeyCreate(ctx context.Context, args []string) error { if len(allowed) == 0 { return fmt.Errorf("cidr is required for create") } + idURL := envOrDefault("FMSG_ID_URL", "http://127.0.0.1:8080") + if err := requireAcceptingCLIAddress(idURL, *owner, "owner"); err != nil { + return err + } + // Match the self-service sub-account flow: derived addresses are created in + // fmsgid before their API key is persisted. + if err := middleware.RegisterFmsgID(idURL, subAddr); err != nil { + return fmt.Errorf("registering derived address with fmsgid: %w", err) + } + if err := requireAcceptingCLIAddress(idURL, subAddr, "derived address"); err != nil { + return err + } database, err := db.New(ctx, "") if err != nil { return err @@ -124,6 +137,13 @@ func runAPIKeyCreateDelegation(ctx context.Context, args []string) error { if !middleware.IsValidAddr(*addr) { return fmt.Errorf("addr must be an fmsg address") } + idURL := envOrDefault("FMSG_ID_URL", "http://127.0.0.1:8080") + if err := requireAcceptingCLIAddress(idURL, *owner, "owner"); err != nil { + return err + } + if err := requireAcceptingCLIAddress(idURL, *addr, "delegated address"); err != nil { + return err + } database, err := db.New(ctx, "") if err != nil { return err @@ -214,6 +234,23 @@ func prepareCLIGrantInputs(owner, agent, cidrsRaw, expiresRaw string) ([]string, return allowed, expires, key, apiauth.HashAPIKey(key.Value), nil } +func requireAcceptingCLIAddress(idURL, addr, role string) error { + code, accepting, err := middleware.CheckFmsgID(idURL, addr) + if err != nil { + return fmt.Errorf("checking %s in fmsgid: %w", role, err) + } + if code == http.StatusNotFound { + return fmt.Errorf("%s %s not found in fmsgid", role, addr) + } + if code != http.StatusOK { + return fmt.Errorf("checking %s in fmsgid: unexpected status %d", role, code) + } + if !accepting { + return fmt.Errorf("%s %s is not accepting new messages", role, addr) + } + return nil +} + func printCLIKey(owner, agent, subAddr string, key apiauth.APIKey) { fmt.Printf("owner=%s\n", owner) fmt.Printf("agent=%s\n", agent) diff --git a/cmd/fmsg-webapi/apikey_cli_test.go b/cmd/fmsg-webapi/apikey_cli_test.go index a702846..2b9468a 100644 --- a/cmd/fmsg-webapi/apikey_cli_test.go +++ b/cmd/fmsg-webapi/apikey_cli_test.go @@ -1,6 +1,8 @@ package main import ( + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -23,6 +25,32 @@ func TestPrepareCLIGrantInputsAllowsArbitraryDelegatedAddressFlow(t *testing.T) } } +func TestRequireAcceptingCLIAddress(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/fmsgid/@alice@exists.test": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"acceptingNew":true}`)) + case "/fmsgid/@alice@disabled.test": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"acceptingNew":false}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + if err := requireAcceptingCLIAddress(server.URL, "@alice@exists.test", "owner"); err != nil { + t.Fatalf("existing accepting address: %v", err) + } + if err := requireAcceptingCLIAddress(server.URL, "@alice@missing.test", "owner"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("missing address error = %v", err) + } + if err := requireAcceptingCLIAddress(server.URL, "@alice@disabled.test", "owner"); err == nil || !strings.Contains(err.Error(), "not accepting") { + t.Fatalf("disabled address error = %v", err) + } +} + func TestPrepareCLIKeyInputsStillDerivesSubAccountAddress(t *testing.T) { expires := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) subAddr, _, _, _, _, err := prepareCLIKeyInputs("@mark@example.com", "bot", "203.0.113.0/24", expires)