From 80cf2c300397beec62c41d9e824e6a0212404c02 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Sat, 26 Sep 2026 00:25:55 +0900 Subject: [PATCH] Resolve relative petnames using the selected signer --- .github/workflows/ci.yml | 2 +- blossom.go | 3 +- git.go | 4 +- key.go | 2 +- main.go | 2 + outbox.go | 3 +- petname_cli.go | 114 +++++++++++++++++++++++++++++++++++++ petname_cli_test.go | 119 +++++++++++++++++++++++++++++++++++++++ profile.go | 3 +- spell.go | 2 +- 10 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 petname_cli.go create mode 100644 petname_cli_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dde86dd..36570c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,4 +20,4 @@ jobs: # Existing CLI tests share flag state and include public-relay requests. # Keep this regression suite independent of those tests and Go vet's # pre-existing non-constant-format diagnostics. - - run: go test -vet=off . -run 'TestResolvePetname|TestParsePubKey' -count=1 + - run: go test -vet=off . -run 'TestResolvePetname|TestParsePubKey|TestPetnameCLI' -count=1 diff --git a/blossom.go b/blossom.go index 2634aee..94922f9 100644 --- a/blossom.go +++ b/blossom.go @@ -9,7 +9,6 @@ import ( "os" "unsafe" - "fiatjaf.com/nostr" "fiatjaf.com/nostr/keyer" "fiatjaf.com/nostr/nipb7/blossom" "github.com/urfave/cli/v3" @@ -39,7 +38,7 @@ var blossomCmd = &cli.Command{ var client *blossom.Client pubkey := c.Args().First() if pubkey != "" { - pk, err := parsePubKey(pubkey, nostr.ZeroPK) + pk, err := parsePubKeyForCommand(ctx, c, pubkey) if err != nil { return fmt.Errorf("invalid public key '%s': %w", pubkey, err) } diff --git a/git.go b/git.go index 6319010..3edccc6 100644 --- a/git.go +++ b/git.go @@ -156,7 +156,7 @@ aside from those, there is also: var owner nostr.PubKey var ownerStr string if c.String("owner") != "" { - owner, err = parsePubKey(c.String("owner"), nostr.ZeroPK) + owner, err = parsePubKeyForCommand(ctx, c, c.String("owner")) if err != nil { return fmt.Errorf("invalid owner pubkey: %w", err) } @@ -169,7 +169,7 @@ aside from those, there is also: }, &ownerStr); err != nil { return err } - owner, err = parsePubKey(ownerStr, nostr.ZeroPK) + owner, err = parsePubKeyForCommand(ctx, c, ownerStr) if err == nil { ownerStr = nip19.EncodeNpub(owner) break diff --git a/key.go b/key.go index a81cb6f..9ac42a0 100644 --- a/key.go +++ b/key.go @@ -299,7 +299,7 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys)) for i, keyhex := range result.Keys { - pk32, err := parsePubKey(keyhex, nostr.ZeroPK) + pk32, err := parsePubKeyForCommand(ctx, c, keyhex) if err == nil { /* we'll try both the 02 and the 03 prefix versions */ result.Keys[i] = pk32.Hex() diff --git a/main.go b/main.go index 455fd81..8bc321e 100644 --- a/main.go +++ b/main.go @@ -188,6 +188,8 @@ var app = &cli.Command{ } func init() { + deferPetnameFlags(app) + cli.VersionFlag = &cli.BoolFlag{ Name: "version", Usage: "prints the version", diff --git a/outbox.go b/outbox.go index 03db8ea..1c2520f 100644 --- a/outbox.go +++ b/outbox.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "fiatjaf.com/nostr" "github.com/urfave/cli/v3" ) @@ -23,7 +22,7 @@ var outboxCmd = &cli.Command{ return fmt.Errorf("expected exactly one argument (pubkey)") } - pk, err := parsePubKey(c.Args().First(), nostr.ZeroPK) + pk, err := parsePubKeyForCommand(ctx, c, c.Args().First()) if err != nil { return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err) } diff --git a/petname_cli.go b/petname_cli.go new file mode 100644 index 0000000..b695a91 --- /dev/null +++ b/petname_cli.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "flag" + "fmt" + "strings" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip02" + "fiatjaf.com/nostr/nip05" + "fiatjaf.com/nostr/nip19" + "github.com/urfave/cli/v3" +) + +func parsePubKeyForCommand(ctx context.Context, c *cli.Command, value string) (nostr.PubKey, error) { + value = strings.TrimPrefix(value, "nostr:") + from := nostr.ZeroPK + if strings.HasPrefix(value, "~") { + parts, err := nip02.ParsePetnamePath(value) + if err != nil { + return nostr.ZeroPK, err + } + // Absolute roots must also work without access to a signing key. + _, hexErr := nostr.PubKeyFromHex(parts[0]) + prefix, _, nip19Err := nip19.Decode(parts[0]) + absolute := hexErr == nil || nip05.IsValidIdentifier(parts[0]) || + (nip19Err == nil && (prefix == "npub" || prefix == "nprofile")) + if !absolute { + kr, _, err := gatherKeyerFromArguments(ctx, c) + if err != nil { + return nostr.ZeroPK, fmt.Errorf("failed to get petname identity: %w", err) + } + from, err = kr.GetPublicKey(ctx) + if err != nil { + return nostr.ZeroPK, fmt.Errorf("failed to get petname identity: %w", err) + } + } + } + return parsePubKey(value, from) +} + +// Public-key flags need the fully parsed signer options, including --sec after +// the petname flag. Keep their input until flag actions run, then let the +// original value parser handle the resolved keys (and any address inputs). +type deferredPubkeyFlag[T any, C any, V cli.ValueCreator[T, C]] struct { + *cli.FlagBase[T, C, V] + pending []string + value cli.Value +} + +func (f *deferredPubkeyFlag[T, C, V]) Apply(set *flag.FlagSet) error { + if err := f.FlagBase.Apply(set); err != nil { + return err + } + f.pending = nil + f.value = set.Lookup(f.Name).Value.(cli.Value) + for _, name := range f.Names() { + set.Lookup(name).Value = &deferredPubkeyValue{Value: f.value, pending: &f.pending} + } + return nil +} + +func (f *deferredPubkeyFlag[T, C, V]) IsSet() bool { + return len(f.pending) > 0 || f.FlagBase.IsSet() +} + +func (f *deferredPubkeyFlag[T, C, V]) RunAction(ctx context.Context, c *cli.Command) error { + for _, input := range f.pending { + value := strings.TrimSpace(input) + if strings.HasPrefix(strings.TrimPrefix(value, "nostr:"), "~") { + pk, err := parsePubKeyForCommand(ctx, c, value) + if err != nil { + return fmt.Errorf("invalid value %q for --%s: %w", input, f.Name, err) + } + input = pk.Hex() + } + if err := f.value.Set(input); err != nil { + return fmt.Errorf("invalid value %q for --%s: %w", input, f.Name, err) + } + } + f.pending = nil + return f.FlagBase.RunAction(ctx, c) +} + +type deferredPubkeyValue struct { + cli.Value + pending *[]string +} + +func (v *deferredPubkeyValue) Set(input string) error { + *v.pending = append(*v.pending, input) + return nil +} + +func deferPetnameFlag[T any, C any, V cli.ValueCreator[T, C]](f *cli.FlagBase[T, C, V]) cli.Flag { + return &deferredPubkeyFlag[T, C, V]{FlagBase: f} +} + +func deferPetnameFlags(c *cli.Command) { + for i, flag := range c.Flags { + switch flag := flag.(type) { + case *PubKeyFlag: + c.Flags[i] = deferPetnameFlag(flag) + case *PubKeySliceFlag: + c.Flags[i] = deferPetnameFlag(flag) + case *PubKeyOrAddressFlag: + c.Flags[i] = deferPetnameFlag(flag) + } + } + for _, sub := range c.Commands { + deferPetnameFlags(sub) + } +} diff --git a/petname_cli_test.go b/petname_cli_test.go new file mode 100644 index 0000000..a273df6 --- /dev/null +++ b/petname_cli_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "io" + "testing" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip19" + "fiatjaf.com/nostr/sdk" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestPetnameCLI(t *testing.T) { + me := keyFromSeed(t, 1) + erin := keyFromSeed(t, 2) + david := keyFromSeed(t, 3) + abs := "~" + nip19.EncodeNpub(me.Public()) + "/erin" + for _, tc := range []struct { + name string + args []string + }{ + {"secret before flag", []string{"--sec", me.Hex(), "check", "--pubkey", "~/erin"}}, + {"secret after flag", []string{"check", "--pubkey", "~/erin", "--sec", me.Hex()}}, + {"relative chain", []string{"check", "--pubkey", "~erin/david", "--sec", me.Hex()}}, + {"nostr prefix", []string{"check", "--pubkey", "nostr:~/erin", "--sec", me.Hex()}}, + {"environment key", []string{"check", "--pubkey", "~/erin"}}, + {"absolute without signer", []string{"check", "--pubkey", abs, "--sec", "invalid"}}, + {"npub without signer", []string{"check", "--pubkey", nip19.EncodeNpub(erin.Public()), "--sec", "invalid"}}, + {"positional", []string{"check", "~/erin", "--sec", me.Hex()}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("NOSTR_SECRET_KEY", me.Hex()) + setupPetnameSystem(t, map[nostr.SecretKey][]sdk.ProfileRef{ + me: {{Pubkey: erin.Public(), Petname: "erin"}}, + erin: {{Pubkey: david.Public(), Petname: "david"}}, + }) + called := false + cmd := &cli.Command{ + Name: "nak", Writer: io.Discard, ErrWriter: io.Discard, + Flags: []cli.Flag{&cli.StringFlag{Name: "sec", Sources: cli.EnvVars("NOSTR_SECRET_KEY")}}, + Commands: []*cli.Command{{ + Name: "check", + Flags: []cli.Flag{&PubKeyFlag{Name: "pubkey"}}, + Action: func(ctx context.Context, c *cli.Command) error { + called = true + var got nostr.PubKey + if c.Args().Present() { + var err error + got, err = parsePubKeyForCommand(ctx, c, c.Args().First()) + require.NoError(t, err) + } else { + got = getPubKey(c, "pubkey") + } + want := erin.Public() + if tc.name == "relative chain" { + want = david.Public() + } + require.Equal(t, want, got) + return nil + }, + }}, + } + deferPetnameFlags(cmd) + require.NoError(t, cmd.Run(t.Context(), append([]string{"nak"}, tc.args...))) + require.True(t, called) + }) + } +} + +func TestPetnameCLIRepeatedFlags(t *testing.T) { + me := keyFromSeed(t, 1) + erin := keyFromSeed(t, 2) + david := keyFromSeed(t, 3) + setupPetnameSystem(t, map[nostr.SecretKey][]sdk.ProfileRef{ + me: {{Pubkey: erin.Public(), Petname: "erin"}}, + }) + addr := "30023:" + david.Public().Hex() + ":post" + cmd := &cli.Command{ + Name: "nak", Writer: io.Discard, ErrWriter: io.Discard, + DisableSliceFlagSeparator: true, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "sec"}, + &PubKeySliceFlag{Name: "keys"}, + &PubKeyOrAddressFlag{Name: "author", Aliases: []string{"a"}}, + }, + Action: func(ctx context.Context, c *cli.Command) error { + require.Equal(t, []nostr.PubKey{erin.Public(), david.Public(), erin.Public()}, getPubKeySlice(c, "keys")) + authors := getPubKeyOrAddressSlice(c, "author") + require.Len(t, authors, 3) + require.Equal(t, erin.Public(), authors[0].PubKey) + require.Equal(t, david.Public(), authors[1].PubKey) + require.NotNil(t, authors[2].Addr) + require.Equal(t, addr, authors[2].Addr.AsTagReference()) + return nil + }, + } + deferPetnameFlags(cmd) + require.NoError(t, cmd.Run(t.Context(), []string{"nak", + "--keys", "~/erin", "--keys", david.Public().Hex(), "--keys", "~/erin", + "-a", "~/erin", "--author", david.Public().Hex(), "-a", addr, "--sec", me.Hex(), + })) +} + +func TestPetnameCLIInvalidIdentity(t *testing.T) { + setupPetnameSystem(t, nil) + cmd := &cli.Command{ + Name: "nak", Writer: io.Discard, ErrWriter: io.Discard, + Flags: []cli.Flag{&cli.StringFlag{Name: "sec"}, &PubKeyFlag{Name: "pubkey"}}, + Action: func(context.Context, *cli.Command) error { + t.Fatal("action must not run after a failed petname lookup") + return nil + }, + } + deferPetnameFlags(cmd) + err := cmd.Run(t.Context(), []string{"nak", "--pubkey", "~/erin", "--sec", "invalid"}) + require.ErrorContains(t, err, "failed to get petname identity") +} diff --git a/profile.go b/profile.go index 47a04f8..7aed2da 100644 --- a/profile.go +++ b/profile.go @@ -5,7 +5,6 @@ import ( stdjson "encoding/json" - "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip19" "github.com/fatih/color" "github.com/urfave/cli/v3" @@ -40,7 +39,7 @@ example usage: ArgsUsage: "[pubkey]", Action: func(ctx context.Context, c *cli.Command) error { for pubkeyInput := range getStdinLinesOrArguments(c.Args()) { - pk, err := parsePubKey(pubkeyInput, nostr.ZeroPK) + pk, err := parsePubKeyForCommand(ctx, c, pubkeyInput) if err != nil { ctx = lineProcessingError(ctx, "invalid pubkey '%s': %s", pubkeyInput, err) continue diff --git a/spell.go b/spell.go index e2631f6..7c94ce0 100644 --- a/spell.go +++ b/spell.go @@ -260,7 +260,7 @@ func buildSpellReq(ctx context.Context, c *cli.Command, tags nostr.Tags) (nostr. getMe := func() (nostr.PubKey, error) { if !c.IsSet("sec") && !c.IsSet("prompt-sec") && c.IsSet("pub") { - return parsePubKey(c.String("pub"), nostr.ZeroPK) + return parsePubKeyForCommand(ctx, c, c.String("pub")) } kr, _, err := gatherKeyerFromArguments(ctx, c)