petname support in parsePubKey().

This commit is contained in:
fiatjaf
2026-09-20 22:35:11 -03:00
parent 8b1c3c9403
commit 4011cba5c5
11 changed files with 294 additions and 18 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"unsafe" "unsafe"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer" "fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb7/blossom" "fiatjaf.com/nostr/nipb7/blossom"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -39,7 +40,7 @@ var blossomCmd = &cli.Command{
var client *blossom.Client var client *blossom.Client
pubkey := c.Args().First() pubkey := c.Args().First()
if pubkey != "" { if pubkey != "" {
pk, err := parsePubKey(pubkey) pk, err := parsePubKey(pubkey, nostr.ZeroPK)
if err != nil { if err != nil {
return fmt.Errorf("invalid public key '%s': %w", pubkey, err) return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
} }
+2 -2
View File
@@ -192,7 +192,7 @@ func (t pubkeyValue) Create(val nostr.PubKey, p *nostr.PubKey, c struct{}) cli.V
func (t pubkeyValue) ToString(b nostr.PubKey) string { return t.pubkey.String() } func (t pubkeyValue) ToString(b nostr.PubKey) string { return t.pubkey.String() }
func (t *pubkeyValue) Set(value string) error { func (t *pubkeyValue) Set(value string) error {
pubkey, err := parsePubKey(value) pubkey, err := parsePubKey(value, nostr.ZeroPK)
t.pubkey = pubkey t.pubkey = pubkey
t.hasBeenSet = true t.hasBeenSet = true
return err return err
@@ -255,7 +255,7 @@ func (t pubKeyOrAddressValue) ToString(b PubKeyOrAddress) string {
func (t *pubKeyOrAddressValue) Set(value string) error { func (t *pubKeyOrAddressValue) Set(value string) error {
value = strings.TrimPrefix(value, "nostr:") value = strings.TrimPrefix(value, "nostr:")
pubkey, err1 := parsePubKey(value) pubkey, err1 := parsePubKey(value, nostr.ZeroPK)
if err1 == nil { if err1 == nil {
t.value = PubKeyOrAddress{PubKey: pubkey} t.value = PubKeyOrAddress{PubKey: pubkey}
t.hasBeenSet = true t.hasBeenSet = true
+9 -9
View File
@@ -156,7 +156,7 @@ aside from those, there is also:
var owner nostr.PubKey var owner nostr.PubKey
var ownerStr string var ownerStr string
if c.String("owner") != "" { if c.String("owner") != "" {
owner, err = parsePubKey(c.String("owner")) owner, err = parsePubKey(c.String("owner"), nostr.ZeroPK)
if err != nil { if err != nil {
return fmt.Errorf("invalid owner pubkey: %w", err) return fmt.Errorf("invalid owner pubkey: %w", err)
} }
@@ -169,7 +169,7 @@ aside from those, there is also:
}, &ownerStr); err != nil { }, &ownerStr); err != nil {
return err return err
} }
owner, err = parsePubKey(ownerStr) owner, err = parsePubKey(ownerStr, nostr.ZeroPK)
if err == nil { if err == nil {
ownerStr = nip19.EncodeNpub(owner) ownerStr = nip19.EncodeNpub(owner)
break break
@@ -2204,7 +2204,7 @@ please fix
} }
// parse owner // parse owner
owner, err := parsePubKey(localConfig.Owner) owner, err := parsePubKey(localConfig.Owner, nostr.ZeroPK)
if err != nil { if err != nil {
return fmt.Errorf("invalid owner public key: %w", err) return fmt.Errorf("invalid owner public key: %w", err)
} }
@@ -3046,7 +3046,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer, skipAnnouncement bool) (ni
} }
// parse owner // parse owner
owner, err := parsePubKey(localConfig.Owner) owner, err := parsePubKey(localConfig.Owner, nostr.ZeroPK)
if err != nil { if err != nil {
return nip34.Repository{}, nil, fmt.Errorf("invalid owner public key: %w", err) return nip34.Repository{}, nil, fmt.Errorf("invalid owner public key: %w", err)
} }
@@ -3573,7 +3573,7 @@ func parseRepositoryAddress(
parts := strings.Split(address, "/") parts := strings.Split(address, "/")
if len(parts) == 5 { if len(parts) == 5 {
// nostr://<owner>/<relay>/<identifier> // nostr://<owner>/<relay>/<identifier>
owner, err = parsePubKey(parts[2]) owner, err = parsePubKey(parts[2], nostr.ZeroPK)
if err != nil { if err != nil {
return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err) return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err)
} }
@@ -3596,7 +3596,7 @@ func parseRepositoryAddress(
return owner, identifier, relayHints, nil return owner, identifier, relayHints, nil
} else if len(parts) == 4 { } else if len(parts) == 4 {
// nostr://<owner>/<identifier> // nostr://<owner>/<identifier>
owner, err = parsePubKey(parts[2]) owner, err = parsePubKey(parts[2], nostr.ZeroPK)
if err != nil { if err != nil {
return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err) return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err)
} }
@@ -3625,7 +3625,7 @@ func parseRepositoryAddress(
identifier = parts[1] identifier = parts[1]
// try to parse as pubkey (npub, nprofile, or hex) // try to parse as pubkey (npub, nprofile, or hex)
owner, err = parsePubKey(ownerPart) owner, err = parsePubKey(ownerPart, nostr.ZeroPK)
if err != nil { if err != nil {
return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner identifier '%s': %w", ownerPart, err) return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner identifier '%s': %w", ownerPart, err)
} }
@@ -3737,7 +3737,7 @@ func RepositoryToConfig(repo nip34.Repository) Nip34Config {
} }
func (localConfig Nip34Config) Validate() error { func (localConfig Nip34Config) Validate() error {
_, err := parsePubKey(localConfig.Owner) _, err := parsePubKey(localConfig.Owner, nostr.ZeroPK)
if err != nil { if err != nil {
return fmt.Errorf("owner pubkey '%s' is not valid: %w", localConfig.Owner, err) return fmt.Errorf("owner pubkey '%s' is not valid: %w", localConfig.Owner, err)
} }
@@ -3745,7 +3745,7 @@ func (localConfig Nip34Config) Validate() error {
} }
func (localConfig Nip34Config) ToRepository() nip34.Repository { func (localConfig Nip34Config) ToRepository() nip34.Repository {
owner, err := parsePubKey(localConfig.Owner) owner, err := parsePubKey(localConfig.Owner, nostr.ZeroPK)
if err != nil { if err != nil {
panic(err) panic(err)
} }
+2
View File
@@ -113,3 +113,5 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/qr v0.2.0 // indirect rsc.io/qr v0.2.0 // indirect
) )
replace fiatjaf.com/nostr => ../nostrlib
+33 -1
View File
@@ -21,6 +21,7 @@ import (
"unicode/utf8" "unicode/utf8"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip02"
"fiatjaf.com/nostr/nip05" "fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19" "fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema" "fiatjaf.com/nostr/schema"
@@ -475,8 +476,13 @@ func askConfirmation(msg string) bool {
} }
} }
func parsePubKey(value string) (nostr.PubKey, error) { func parsePubKey(value string, from nostr.PubKey) (nostr.PubKey, error) {
value = strings.TrimPrefix(value, "nostr:") value = strings.TrimPrefix(value, "nostr:")
if strings.HasPrefix(value, "~") {
return resolvePetnamePath(value, from)
}
if nip05.IsValidIdentifier(value) { if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value) pp, err := nip05.QueryIdentifier(ctx, value)
@@ -736,3 +742,29 @@ func combineFlags(flagSlices [][]cli.Flag, extraFlags ...cli.Flag) []cli.Flag {
result = append(result, extraFlags...) result = append(result, extraFlags...)
return result return result
} }
// resolvePetnamePath resolves NIP-02 petname references like "~erin/david/frank" to a pubkey,
// by walking the follow lists of each name in the chain. The first name is looked up in the
// signer's own follow list (or its NIP-05 identifier, which can be mixed in, like
// "~erin@names.com/david/frank"). See https://github.com/nostr-protocol/nips/blob/master/02.md.
func resolvePetnamePath(value string, from nostr.PubKey) (nostr.PubKey, error) {
if sys == nil {
return nostr.ZeroPK, fmt.Errorf("can't resolve petnames before system initialization")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
pk, err := sys.ResolvePetnamePath(ctx, from, value)
if err != nil {
// InputToProfile intentionally hides NIP-05 lookup errors. Preserve
// useful network errors instead of treating a failed lookup as petname.
if parts, parseErr := nip02.ParsePetnamePath(value); parseErr == nil && len(parts) > 0 && nip05.IsValidIdentifier(parts[0]) {
if _, nip05Err := nip05.QueryIdentifier(ctx, parts[0]); nip05Err != nil {
return nostr.ZeroPK, nip05Err
}
}
return nostr.ZeroPK, fmt.Errorf("failed to resolve \"%s\": %w", value, err)
}
return pk, nil
}
+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)) keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
for i, keyhex := range result.Keys { for i, keyhex := range result.Keys {
pk32, err := parsePubKey(keyhex) pk32, err := parsePubKey(keyhex, nostr.ZeroPK)
if err == nil { /* we'll try both the 02 and the 03 prefix versions */ if err == nil { /* we'll try both the 02 and the 03 prefix versions */
result.Keys[i] = pk32.Hex() result.Keys[i] = pk32.Hex()
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"fiatjaf.com/nostr"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -22,7 +23,7 @@ var outboxCmd = &cli.Command{
return fmt.Errorf("expected exactly one argument (pubkey)") return fmt.Errorf("expected exactly one argument (pubkey)")
} }
pk, err := parsePubKey(c.Args().First()) pk, err := parsePubKey(c.Args().First(), nostr.ZeroPK)
if err != nil { if err != nil {
return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err) return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err)
} }
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"strings"
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
cache_memory "fiatjaf.com/nostr/sdk/cache/memory"
"github.com/stretchr/testify/require"
)
// makeFollowListEvent builds a signed kind-3 event for user with a set of ["p", pubkey, relay, petname] tags.
func makeFollowListEvent(t *testing.T, sec nostr.SecretKey, ts nostr.Timestamp, refs ...sdk.ProfileRef) nostr.Event {
t.Helper()
tags := make(nostr.Tags, 0, len(refs))
for _, ref := range refs {
tags = append(tags, nostr.Tag{"p", ref.Pubkey.Hex(), ref.Relay, ref.Petname})
}
evt := nostr.Event{
PubKey: sec.Public(),
CreatedAt: ts,
Kind: nostr.KindFollowList,
Tags: tags,
}
require.NoError(t, evt.Sign(sec))
return evt
}
// setupPetnameSystem initializes a hermetic global sys by pre-seeding the follow list cache with
// kind-3 events for the given identities, so no network or store access ever happens.
func setupPetnameSystem(t *testing.T, lists map[nostr.SecretKey][]sdk.ProfileRef) {
t.Helper()
sys = sdk.NewSystem()
sys.FollowListCache = newSettledFollowListCache(t, lists)
t.Cleanup(func() {
sys = nil
rootSec = ""
})
}
// newSettledFollowListCache builds a follow list cache with the given entries already visible.
// ristretto Set/SetWithTTL are asynchronous, so entries are read back until they stick.
func newSettledFollowListCache(t *testing.T, lists map[nostr.SecretKey][]sdk.ProfileRef) *cache_memory.RistrettoCache[sdk.GenericList[nostr.PubKey, sdk.ProfileRef]] {
t.Helper()
cache := cache_memory.New[sdk.GenericList[nostr.PubKey, sdk.ProfileRef]](1000)
for sk, refs := range lists {
pubkey := sk.Public()
v := sdk.GenericList[nostr.PubKey, sdk.ProfileRef]{PubKey: pubkey}
if refs != nil {
// nil refs means "no kind-3 event found at all"; empty refs means a tagless event
evt := makeFollowListEvent(t, sk, 1000, refs...)
v.Event = &evt
v.Items = refs
}
deadline := time.Now().Add(5 * time.Second)
for {
if cache.SetWithTTL(pubkey, v, time.Hour) {
if _, ok := cache.Get(pubkey); ok {
break
}
}
require.Less(t, time.Now(), deadline, "cache entry did not settle")
time.Sleep(time.Millisecond)
}
}
return cache
}
func keyFromSeed(t *testing.T, seed byte) nostr.SecretKey {
t.Helper()
sk := nostr.SecretKey{}
for i := range sk {
sk[i] = seed + byte(i)
}
return sk
}
func TestResolvePetnamePathChain(t *testing.T) {
me := keyFromSeed(t, 1)
erin := keyFromSeed(t, 2)
david := keyFromSeed(t, 3)
frank := keyFromSeed(t, 4)
setupPetnameSystem(t, map[nostr.SecretKey][]sdk.ProfileRef{
me: {{Pubkey: erin.Public(), Petname: "erin"}},
erin: {{Pubkey: david.Public(), Petname: "david"}},
david: {{Pubkey: frank.Public(), Petname: "frank"}},
})
rootSec = me.Hex()
pk, err := resolvePetnamePath("~erin/david/frank")
require.NoError(t, err)
require.Equal(t, frank.Public(), pk)
}
func TestResolvePetnamePathSingleHop(t *testing.T) {
me := keyFromSeed(t, 1)
erin := keyFromSeed(t, 2)
setupPetnameSystem(t, map[nostr.SecretKey][]sdk.ProfileRef{
me: {{Pubkey: erin.Public(), Petname: "erin"}},
})
rootSec = me.Hex()
pk, err := resolvePetnamePath("~erin")
require.NoError(t, err)
require.Equal(t, erin.Public(), pk)
}
func TestResolvePetnamePathCurrentUser(t *testing.T) {
me := keyFromSeed(t, 1)
erin := keyFromSeed(t, 2)
setupPetnameSystem(t, map[nostr.SecretKey][]sdk.ProfileRef{
me: {{Pubkey: erin.Public(), Petname: "erin"}},
})
rootSec = me.Hex()
pk, err := resolvePetnamePath("~/erin")
require.NoError(t, err)
require.Equal(t, erin.Public(), pk)
}
func TestResolvePetnamePathDirectRootWithoutIdentity(t *testing.T) {
me := keyFromSeed(t, 1)
erin := keyFromSeed(t, 2)
setupPetnameSystem(t, map[nostr.SecretKey][]sdk.ProfileRef{
me: {{Pubkey: erin.Public(), Petname: "erin"}},
})
pk, err := resolvePetnamePath("~" + nip19.EncodeNpub(me.Public()) + "/erin")
require.NoError(t, err)
require.Equal(t, erin.Public(), pk)
}
func TestResolvePetnamePathNip05Root(t *testing.T) {
erin := keyFromSeed(t, 2)
david := keyFromSeed(t, 3)
setupPetnameSystem(t, map[nostr.SecretKey][]sdk.ProfileRef{
erin: {{Pubkey: david.Public(), Petname: "david"}},
})
// the first segment must be routed to nip05 (which will fail to connect here, as nothing
// listens on 127.0.0.1:443) instead of being treated as a petname
_, err := resolvePetnamePath("~erin@127.0.0.1/david")
require.Error(t, err)
require.Contains(t, err.Error(), "request failed")
require.NotContains(t, err.Error(), "follow list")
}
func TestResolvePetnamePathErrors(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"}},
erin: {{Pubkey: david.Public(), Petname: "david"}},
})
rootSec = me.Hex()
// unknown petname at root
_, err := resolvePetnamePath("~carol")
require.ErrorContains(t, err, "no one in your follow list")
// unknown petname mid-chain
_, err = resolvePetnamePath("~erin/carol")
require.ErrorContains(t, err, "doesn't follow anyone named")
// empty reference
_, err = resolvePetnamePath("~")
require.Error(t, err)
// empty segment
_, err = resolvePetnamePath("~erin//frank")
require.Error(t, err)
// invalid petname characters
_, err = resolvePetnamePath("~er\tin")
require.Error(t, err)
// no identity
rootSec = ""
_, err = resolvePetnamePath("~erin")
require.ErrorContains(t, err, "no secret key")
}
func TestResolvePetnameMissingFollowList(t *testing.T) {
me := keyFromSeed(t, 1)
erin := keyFromSeed(t, 2)
// erin has a cached follow list record with no event, i.e. her kind-3 was never found
sys = sdk.NewSystem()
sys.FollowListCache = newSettledFollowListCache(t, map[nostr.SecretKey][]sdk.ProfileRef{
me: {{Pubkey: erin.Public(), Petname: "erin"}},
erin: nil,
})
t.Cleanup(func() { sys = nil; rootSec = "" })
rootSec = me.Hex()
_, err := resolvePetnamePath("~erin/frank")
require.ErrorContains(t, err, "couldn't get follow list")
}
func TestParsePubKeyUnaffectedInputs(t *testing.T) {
me := keyFromSeed(t, 1)
erin := keyFromSeed(t, 2)
setupPetnameSystem(t, map[nostr.SecretKey][]sdk.ProfileRef{
me: {{Pubkey: erin.Public(), Petname: "erin"}},
})
rootSec = me.Hex()
// plain npub keeps working through parsePubKey
pk, err := parsePubKey(nip19.EncodeNpub(erin.Public()), nostr.ZeroPK)
require.NoError(t, err)
require.Equal(t, erin.Public(), pk)
// hex keeps working
pk, err = parsePubKey(erin.Public().Hex(), nostr.ZeroPK)
require.NoError(t, err)
require.Equal(t, erin.Public(), pk)
// without a system initialized, petname resolution fails cleanly
sys = nil
_, err = parsePubKey("~erin", nostr.ZeroPK)
require.Error(t, err)
require.True(t, strings.Contains(err.Error(), "petname") || strings.Contains(err.Error(), "system"))
}
+2 -1
View File
@@ -5,6 +5,7 @@ import (
stdjson "encoding/json" stdjson "encoding/json"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19" "fiatjaf.com/nostr/nip19"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -39,7 +40,7 @@ example usage:
ArgsUsage: "[pubkey]", ArgsUsage: "[pubkey]",
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
for pubkeyInput := range getStdinLinesOrArguments(c.Args()) { for pubkeyInput := range getStdinLinesOrArguments(c.Args()) {
pk, err := parsePubKey(pubkeyInput) pk, err := parsePubKey(pubkeyInput, nostr.ZeroPK)
if err != nil { if err != nil {
ctx = lineProcessingError(ctx, "invalid pubkey '%s': %s", pubkeyInput, err) ctx = lineProcessingError(ctx, "invalid pubkey '%s': %s", pubkeyInput, err)
continue continue
+1 -1
View File
@@ -295,7 +295,7 @@ func performReq(
pTags := append([]string{}, filter.Tags["p"]...) pTags := append([]string{}, filter.Tags["p"]...)
pTags = append(pTags, filter.Tags["P"]...) pTags = append(pTags, filter.Tags["P"]...)
for _, value := range pTags { for _, value := range pTags {
pubkey, err := parsePubKey(value) pubkey, err := parsePubKey(value, nostr.ZeroPK)
if err != nil { if err != nil {
continue 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) { getMe := func() (nostr.PubKey, error) {
if !c.IsSet("sec") && !c.IsSet("prompt-sec") && c.IsSet("pub") { if !c.IsSet("sec") && !c.IsSet("prompt-sec") && c.IsSet("pub") {
return parsePubKey(c.String("pub")) return parsePubKey(c.String("pub"), nostr.ZeroPK)
} }
kr, _, err := gatherKeyerFromArguments(ctx, c) kr, _, err := gatherKeyerFromArguments(ctx, c)