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
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ var app = &cli.Command{
curl,
fsCmd,
publish,
nwc,
},
Version: version,
Flags: []cli.Flag{
Expand Down
265 changes: 265 additions & 0 deletions nip47.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
package main

import (
"context"
"fmt"
"net/url"
"strings"
"time"

"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip44"
"github.com/urfave/cli/v3"
)

type nip47Client struct {
Relay string
WalletPubkey nostr.PubKey
Secret nostr.SecretKey
}

type nip47Request struct {
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
}
type nip47Response struct {
ResultType string `json:"result_type"`
Result map[string]interface{} `json:"result"`
Error *nip47Error `json:"error"`
}

func (r nip47Response) String() string {
b, err := json.Marshal(r)
if err != nil {
return fmt.Sprintf("error marshaling response: %v", err)
}
return string(b)
}

type nip47Error struct {
Code int `json:"error"`
Message string `json:"message"`
}

// encryption tag to negotiate upgrading to NIP44
// only supports nip44_v2 because nip04 is deprecated
// https://github.com/nostr-protocol/nips/pull/1780
type EncryptionTag string

const (
EncryptionTagNip44V2 EncryptionTag = "nip44_v2"
)

var (
// timeout per request or response
timeout = 10 * time.Second
)

var nwc = &cli.Command{
Name: "nwc",
Usage: "nip47 stuff",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "url",
Usage: "url that starts with nostr+walletconnect://",
Required: true,
},
},
Commands: []*cli.Command{
{
Name: "info",
Usage: "get info event (kind 13194)",
Action: func(ctx context.Context, c *cli.Command) error {
url := c.String("url")

client, err := newNip47ClientFromUrl(url)
if err != nil {
return fmt.Errorf("failed to parse url: %w", err)
}

info, err := client.info(ctx)
if err != nil {
return fmt.Errorf("failed to get info: %w", err)
}

stdout(info)
return nil
},
},
{
Name: "get_info",
Usage: "get info for this connection",
Action: func(ctx context.Context, c *cli.Command) error {
url := c.String("url")

client, err := newNip47ClientFromUrl(url)
if err != nil {
return fmt.Errorf("failed to parse url: %w", err)
}

name := "get_info"
result, err := client.method(ctx, &nip47Request{
Method: name,
Params: map[string]interface{}{},
})
if err != nil {
return fmt.Errorf("%s failed: %w", name, err)
}

stdout(result)
return nil
},
},
},
}

func newNip47ClientFromUrl(nwcUrl string) (*nip47Client, error) {
if !strings.HasPrefix(nwcUrl, "nostr+walletconnect://") {
return nil, fmt.Errorf("must start with nostr+walletconnect://")
}

parts := strings.SplitN(nwcUrl, "?", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("query not found")
}

walletPubkey, err := nostr.PubKeyFromHex(strings.TrimPrefix(parts[0], "nostr+walletconnect://"))
if err != nil {
return nil, fmt.Errorf("invalid wallet pubkey: %w", err)
}

params, err := url.ParseQuery(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid query: %w", err)
}

relay := params.Get("relay")
if relay == "" {
return nil, fmt.Errorf("relay missing")
}

secret := params.Get("secret")
if secret == "" {
return nil, fmt.Errorf("secret missing")
}

sk, err := nostr.SecretKeyFromHex(secret)
if err != nil {
return nil, fmt.Errorf("invalid secret: %w", err)
}

return &nip47Client{
Relay: relay,
WalletPubkey: walletPubkey,
Secret: sk,
}, nil
}

// fetch info event (kind 13194)
func (c *nip47Client) info(ctx context.Context) (*nostr.Event, error) {
r, err := nostr.RelayConnect(ctx, c.Relay, nostr.RelayOptions{})
if err != nil {
return nil, err
}
defer r.Close()

sub, err := r.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{13194},
Authors: []nostr.PubKey{c.WalletPubkey},
}, nostr.SubscriptionOptions{})
if err != nil {
return nil, err
}
defer sub.Close()

for {
select {
case ev := <-sub.Events:
return &ev, nil
case <-time.After(timeout):
return nil, timeoutError()
}
}
}

func (c *nip47Client) method(ctx context.Context, req *nip47Request) (*nip47Response, error) {
command, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal json: %w", err)
}

sharedSecret, err := nip44.GenerateConversationKey(c.WalletPubkey, c.Secret)
if err != nil {
return nil, fmt.Errorf("failed to compute shared secret: %w", err)
}

encrypted, err := nip44.Encrypt(string(command), sharedSecret)
if err != nil {
return nil, fmt.Errorf("failed to encrypt content: %w", err)
}

reqEvent := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 23194,
Content: encrypted,
Tags: nostr.Tags{
{"p", c.WalletPubkey.Hex()},
// the wallet service might not support nip44_v2 or not know
// about this tag at all so it might respond with nip04 instead.
{"encryption", string(EncryptionTagNip44V2)},
},
}

if err := keyer.NewPlainKeySigner(c.Secret).SignEvent(ctx, &reqEvent); err != nil {
return nil, fmt.Errorf("failed to sign event: %w", err)
}

r, err := nostr.RelayConnect(ctx, c.Relay, nostr.RelayOptions{})
if err != nil {
return nil, err
}
defer r.Close()

if err := r.Publish(ctx, reqEvent); err != nil {
return nil, err
}

sub, err := r.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{23195},
Authors: []nostr.PubKey{c.WalletPubkey},
Tags: nostr.TagMap{
"e": []string{reqEvent.ID.Hex()},
},
}, nostr.SubscriptionOptions{})
if err != nil {
return nil, err
}
defer sub.Close()

var resEvent nostr.Event
select {
case resEvent = <-sub.Events:
break
case <-time.After(timeout):
return nil, timeoutError()
}

// content might be nip04 for old wallet services
decrypted, err := nip44.Decrypt(resEvent.Content, sharedSecret)
if err != nil {
return nil, fmt.Errorf("failed to decrypt response: %w", err)
}

var res nip47Response
if err := json.Unmarshal([]byte(decrypted), &res); err != nil {
return nil, fmt.Errorf("failed to unmarshal result: %w", err)
}

return &res, nil
}

func timeoutError() error {
return fmt.Errorf("timeout after %s", timeout)
}