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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
37 changes: 37 additions & 0 deletions cmd/fmsg-webapi/apikey_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"flag"
"fmt"
"net/http"
"os"
"strings"
"time"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions cmd/fmsg-webapi/apikey_cli_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
Expand All @@ -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)
Expand Down
Loading