Resolve relative petnames using the selected signer

This commit is contained in:
Yasuhiro Matsumoto
2026-09-26 00:25:55 +09:00
parent 588b243351
commit 80cf2c3003
10 changed files with 243 additions and 11 deletions
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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()
+2
View File
@@ -188,6 +188,8 @@ var app = &cli.Command{
}
func init() {
deferPetnameFlags(app)
cli.VersionFlag = &cli.BoolFlag{
Name: "version",
Usage: "prints the version",
+1 -2
View File
@@ -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)
}
+114
View File
@@ -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)
}
}
+119
View File
@@ -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")
}
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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)