diff --git a/blossom.go b/blossom.go index 53d8374..f227fec 100644 --- a/blossom.go +++ b/blossom.go @@ -9,6 +9,7 @@ import ( "os" "unsafe" + "fiatjaf.com/nostr" "fiatjaf.com/nostr/keyer" "fiatjaf.com/nostr/nipb7/blossom" "github.com/urfave/cli/v3" @@ -39,7 +40,7 @@ var blossomCmd = &cli.Command{ var client *blossom.Client pubkey := c.Args().First() if pubkey != "" { - pk, err := parsePubKey(pubkey) + pk, err := parsePubKey(pubkey, nostr.ZeroPK) if err != nil { return fmt.Errorf("invalid public key '%s': %w", pubkey, err) } diff --git a/flags.go b/flags.go index 0b36da3..45db2df 100644 --- a/flags.go +++ b/flags.go @@ -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) Set(value string) error { - pubkey, err := parsePubKey(value) + pubkey, err := parsePubKey(value, nostr.ZeroPK) t.pubkey = pubkey t.hasBeenSet = true return err @@ -255,7 +255,7 @@ func (t pubKeyOrAddressValue) ToString(b PubKeyOrAddress) string { func (t *pubKeyOrAddressValue) Set(value string) error { value = strings.TrimPrefix(value, "nostr:") - pubkey, err1 := parsePubKey(value) + pubkey, err1 := parsePubKey(value, nostr.ZeroPK) if err1 == nil { t.value = PubKeyOrAddress{PubKey: pubkey} t.hasBeenSet = true diff --git a/git.go b/git.go index 1c643bf..6319010 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")) + owner, err = parsePubKey(c.String("owner"), nostr.ZeroPK) 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) + owner, err = parsePubKey(ownerStr, nostr.ZeroPK) if err == nil { ownerStr = nip19.EncodeNpub(owner) break @@ -2204,7 +2204,7 @@ please fix } // parse owner - owner, err := parsePubKey(localConfig.Owner) + owner, err := parsePubKey(localConfig.Owner, nostr.ZeroPK) if err != nil { 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 - owner, err := parsePubKey(localConfig.Owner) + owner, err := parsePubKey(localConfig.Owner, nostr.ZeroPK) if err != nil { return nip34.Repository{}, nil, fmt.Errorf("invalid owner public key: %w", err) } @@ -3573,7 +3573,7 @@ func parseRepositoryAddress( parts := strings.Split(address, "/") if len(parts) == 5 { // nostr://// - owner, err = parsePubKey(parts[2]) + owner, err = parsePubKey(parts[2], nostr.ZeroPK) if err != nil { return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err) } @@ -3596,7 +3596,7 @@ func parseRepositoryAddress( return owner, identifier, relayHints, nil } else if len(parts) == 4 { // nostr:/// - owner, err = parsePubKey(parts[2]) + owner, err = parsePubKey(parts[2], nostr.ZeroPK) if err != nil { return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err) } @@ -3625,7 +3625,7 @@ func parseRepositoryAddress( identifier = parts[1] // try to parse as pubkey (npub, nprofile, or hex) - owner, err = parsePubKey(ownerPart) + owner, err = parsePubKey(ownerPart, nostr.ZeroPK) if err != nil { 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 { - _, err := parsePubKey(localConfig.Owner) + _, err := parsePubKey(localConfig.Owner, nostr.ZeroPK) if err != nil { 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 { - owner, err := parsePubKey(localConfig.Owner) + owner, err := parsePubKey(localConfig.Owner, nostr.ZeroPK) if err != nil { panic(err) } diff --git a/go.mod b/go.mod index 5ce9688..7639b6e 100644 --- a/go.mod +++ b/go.mod @@ -113,3 +113,5 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/qr v0.2.0 // indirect ) + +replace fiatjaf.com/nostr => ../nostrlib diff --git a/helpers.go b/helpers.go index 657d729..19ef562 100644 --- a/helpers.go +++ b/helpers.go @@ -21,6 +21,7 @@ import ( "unicode/utf8" "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip02" "fiatjaf.com/nostr/nip05" "fiatjaf.com/nostr/nip19" "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:") + + if strings.HasPrefix(value, "~") { + return resolvePetnamePath(value, from) + } + if nip05.IsValidIdentifier(value) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) pp, err := nip05.QueryIdentifier(ctx, value) @@ -736,3 +742,29 @@ func combineFlags(flagSlices [][]cli.Flag, extraFlags ...cli.Flag) []cli.Flag { result = append(result, extraFlags...) 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 +} diff --git a/key.go b/key.go index 2f193c1..a81cb6f 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) + pk32, err := parsePubKey(keyhex, nostr.ZeroPK) if err == nil { /* we'll try both the 02 and the 03 prefix versions */ result.Keys[i] = pk32.Hex() diff --git a/outbox.go b/outbox.go index 9d95329..03db8ea 100644 --- a/outbox.go +++ b/outbox.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "fiatjaf.com/nostr" "github.com/urfave/cli/v3" ) @@ -22,7 +23,7 @@ var outboxCmd = &cli.Command{ 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 { return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err) } diff --git a/petname_test.go b/petname_test.go new file mode 100644 index 0000000..7aff3eb --- /dev/null +++ b/petname_test.go @@ -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")) +} diff --git a/profile.go b/profile.go index 77ce4d5..47a04f8 100644 --- a/profile.go +++ b/profile.go @@ -5,6 +5,7 @@ import ( stdjson "encoding/json" + "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip19" "github.com/fatih/color" "github.com/urfave/cli/v3" @@ -39,7 +40,7 @@ example usage: ArgsUsage: "[pubkey]", Action: func(ctx context.Context, c *cli.Command) error { for pubkeyInput := range getStdinLinesOrArguments(c.Args()) { - pk, err := parsePubKey(pubkeyInput) + pk, err := parsePubKey(pubkeyInput, nostr.ZeroPK) if err != nil { ctx = lineProcessingError(ctx, "invalid pubkey '%s': %s", pubkeyInput, err) continue diff --git a/req.go b/req.go index e19d048..c52a029 100644 --- a/req.go +++ b/req.go @@ -295,7 +295,7 @@ func performReq( pTags := append([]string{}, filter.Tags["p"]...) pTags = append(pTags, filter.Tags["P"]...) for _, value := range pTags { - pubkey, err := parsePubKey(value) + pubkey, err := parsePubKey(value, nostr.ZeroPK) if err != nil { continue } diff --git a/spell.go b/spell.go index 6e44a7b..e2631f6 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")) + return parsePubKey(c.String("pub"), nostr.ZeroPK) } kr, _, err := gatherKeyerFromArguments(ctx, c)