From d2122801cdecfb4d924670e0f5e75969433fd6dc Mon Sep 17 00:00:00 2001 From: mleku Date: Thu, 25 Dec 2025 14:45:21 +0100 Subject: [PATCH] Add nurl and vainstr CLI tools (v0.39.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add nurl: NIP-98 authenticated HTTP client for testing owner APIs - Add vainstr: vanity npub generator using fast secp256k1 library - Update CLAUDE.md with documentation for both tools - Properly handle secp256k1 library loading via p8k.New() Files modified: - cmd/nurl/main.go: New NIP-98 HTTP client tool - cmd/vainstr/main.go: New vanity npub generator - CLAUDE.md: Added usage documentation for nurl and vainstr - go.mod/go.sum: Added go-arg dependency for vainstr - pkg/version/version: Bump to v0.39.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 11 +++ cmd/nurl/main.go | 201 +++++++++++++++++++++++++++++++++++++ cmd/vainstr/main.go | 235 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 5 + pkg/version/version | 2 +- 6 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 cmd/nurl/main.go create mode 100644 cmd/vainstr/main.go diff --git a/CLAUDE.md b/CLAUDE.md index 2177630..5669698 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,17 @@ go test -v -run TestName ./pkg/package # Web UI dev (hot reload) ORLY_WEB_DISABLE=true ORLY_WEB_DEV_PROXY_URL=http://localhost:5173 ./orly & cd app/web && bun run dev + +# NIP-98 HTTP debugging (build: go build -o nurl ./cmd/nurl) +NOSTR_SECRET_KEY=nsec1... ./nurl https://relay.example.com/api/logs +NOSTR_SECRET_KEY=nsec1... ./nurl https://relay.example.com/api/logs/clear +./nurl help # Show usage + +# Vanity npub generator (build: go build -o vainstr ./cmd/vainstr) +./vainstr mleku end # Find npub ending with "mleku" +./vainstr orly begin # Find npub starting with "orly" (after npub1) +./vainstr foo contain # Find npub containing "foo" +./vainstr --threads 4 xyz end # Use 4 threads ``` ## Key Environment Variables diff --git a/cmd/nurl/main.go b/cmd/nurl/main.go new file mode 100644 index 0000000..8f34cbc --- /dev/null +++ b/cmd/nurl/main.go @@ -0,0 +1,201 @@ +// Package main is a simple implementation of a cURL like tool that can do +// simple GET/POST operations on a HTTP server that understands NIP-98 +// authentication, with the signing key found in an environment variable. +package main + +import ( + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + + "git.mleku.dev/mleku/nostr/encoders/bech32encoding" + "git.mleku.dev/mleku/nostr/encoders/hex" + "git.mleku.dev/mleku/nostr/httpauth" + "git.mleku.dev/mleku/nostr/interfaces/signer" + "git.mleku.dev/mleku/nostr/interfaces/signer/p8k" + + "next.orly.dev/pkg/version" +) + +const secEnv = "NOSTR_SECRET_KEY" + +var userAgent = fmt.Sprintf("nurl/%s", strings.TrimSpace(version.V)) + +func fail(format string, a ...any) { + _, _ = fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} + +func main() { + if len(os.Args) > 1 && os.Args[1] == "help" { + fmt.Printf( + `nurl help: + +for nostr http using NIP-98 HTTP authentication: + + nurl [file] + + if no file is given, the request will be processed as a HTTP GET. + + * NIP-98 secret will be expected in the environment variable "%s" + - if absent, will not be added to the header. + - endpoint is assumed to not require it if absent. + - an error will be returned if it was needed. + + output will be rendered to stdout + +`, secEnv, + ) + os.Exit(0) + } + if len(os.Args) < 2 { + fail( + `error: nurl requires minimum 1 arg: + + signing nsec (in bech32 format) is expected to be found in %s environment variable. + + use "help" to get usage information +`, secEnv, + ) + } + var err error + var sign signer.I + if sign, err = GetNIP98Signer(); err != nil { + log.W.Ln("no signer available:", err) + } + var ur *url.URL + if ur, err = url.Parse(os.Args[1]); chk.E(err) { + fail("invalid URL: `%s` error: `%s`", os.Args[1], err.Error()) + } + log.T.S(ur) + if len(os.Args) == 2 { + if err = Get(ur, sign); chk.E(err) { + fail(err.Error()) + } + return + } + if err = Post(os.Args[2], ur, sign); chk.E(err) { + fail(err.Error()) + } +} + +func GetNIP98Signer() (sign signer.I, err error) { + nsec := os.Getenv(secEnv) + var sk []byte + if len(nsec) == 0 { + err = fmt.Errorf("no bech32 secret key found in environment variable %s", secEnv) + return + } else if sk, err = bech32encoding.NsecToBytes([]byte(nsec)); chk.E(err) { + err = fmt.Errorf("failed to decode nsec: '%s'", err.Error()) + return + } + var s *p8k.Signer + if s, err = p8k.New(); chk.E(err) { + err = fmt.Errorf("failed to create signer: '%s'", err.Error()) + return + } + if err = s.InitSec(sk); chk.E(err) { + err = fmt.Errorf("failed to init signer: '%s'", err.Error()) + return + } + sign = s + return +} + +func Get(ur *url.URL, sign signer.I) (err error) { + log.T.F("GET %s", ur.String()) + var r *http.Request + if r, err = http.NewRequest("GET", ur.String(), nil); chk.E(err) { + return + } + r.Header.Add("User-Agent", userAgent) + if sign != nil { + if err = httpauth.AddNIP98Header( + r, ur, "GET", "", sign, 0, + ); chk.E(err) { + fail(err.Error()) + } + } + client := &http.Client{ + CheckRedirect: func( + req *http.Request, + via []*http.Request, + ) error { + return http.ErrUseLastResponse + }, + } + var res *http.Response + if res, err = client.Do(r); chk.E(err) { + err = fmt.Errorf("request failed: %w", err) + return + } + if _, err = io.Copy(os.Stdout, res.Body); chk.E(err) { + res.Body.Close() + return + } + res.Body.Close() + return +} + +func Post(f string, ur *url.URL, sign signer.I) (err error) { + log.T.F("POST %s", ur.String()) + var contentLength int64 + var payload io.ReadCloser + // get the file path parameters and optional hash + var fi os.FileInfo + if fi, err = os.Stat(f); chk.E(err) { + return + } + var b []byte + if b, err = os.ReadFile(f); chk.E(err) { + return + } + hb := sha256.Sum256(b) + h := hex.Enc(hb[:]) + contentLength = fi.Size() + if payload, err = os.Open(f); chk.E(err) { + return + } + log.T.F("opened file %s hash %s", f, h) + var r *http.Request + r = &http.Request{ + Method: "POST", + URL: ur, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Body: payload, + ContentLength: contentLength, + Host: ur.Host, + } + r.Header.Add("User-Agent", userAgent) + if sign != nil { + if err = httpauth.AddNIP98Header( + r, ur, "POST", string(h), sign, 0, + ); chk.E(err) { + fail(err.Error()) + } + } + r.GetBody = func() (rc io.ReadCloser, err error) { + rc = payload + return + } + client := &http.Client{} + var res *http.Response + if res, err = client.Do(r); chk.E(err) { + return + } + defer res.Body.Close() + if _, err = io.Copy(os.Stdout, res.Body); chk.E(err) { + return + } + return +} diff --git a/cmd/vainstr/main.go b/cmd/vainstr/main.go new file mode 100644 index 0000000..5d90c24 --- /dev/null +++ b/cmd/vainstr/main.go @@ -0,0 +1,235 @@ +// Package main is a simple nostr key miner that uses the fast bitcoin secp256k1 +// C library to derive npubs with specified prefix/infix/suffix strings present. +package main + +import ( + "bytes" + "encoding/hex" + "fmt" + "os" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + + "git.mleku.dev/mleku/nostr/crypto/ec/bech32" + "git.mleku.dev/mleku/nostr/encoders/bech32encoding" + "git.mleku.dev/mleku/nostr/interfaces/signer/p8k" + + "github.com/alexflint/go-arg" +) + +var prefix = append(bech32encoding.PubHRP, '1') + +const ( + PositionBeginning = iota + PositionContains + PositionEnding +) + +type Result struct { + sec []byte + npub []byte + pub []byte +} + +var args struct { + String string `arg:"positional" help:"the string you want to appear in the npub"` + Position string `arg:"positional" default:"end" help:"[begin|contain|end] default: end"` + Threads int `help:"number of threads to mine with - defaults to using all CPU threads available"` +} + +func main() { + arg.MustParse(&args) + if args.String == "" { + _, _ = fmt.Fprintln( + os.Stderr, + `Usage: vainstr [--threads THREADS] [STRING [POSITION]] + +Positional arguments: + STRING the string you want to appear in the npub + POSITION [begin|contain|end] default: end + +Options: + --threads THREADS number of threads to mine with - defaults to using all CPU threads available + --help, -h display this help and exit`, + ) + os.Exit(0) + } + var where int + canonical := strings.ToLower(args.Position) + switch { + case strings.HasPrefix(canonical, "begin"): + where = PositionBeginning + case strings.Contains(canonical, "contain"): + where = PositionContains + case strings.HasSuffix(canonical, "end"): + where = PositionEnding + } + if args.Threads == 0 { + args.Threads = runtime.NumCPU() + } + if err := Vanity(args.String, where, args.Threads); chk.E(err) { + log.F.F("error: %s", err) + } +} + +func Vanity(str string, where int, threads int) (err error) { + // check the string has valid bech32 ciphers + for i := range str { + wrong := true + for j := range bech32.Charset { + if str[i] == bech32.Charset[j] { + wrong = false + break + } + } + if wrong { + return fmt.Errorf( + "found invalid character '%c' only ones from '%s' allowed\n", + str[i], bech32.Charset, + ) + } + } + started := time.Now() + quit := make(chan struct{}) + resC := make(chan Result) + + // Handle interrupt + go func() { + c := make(chan os.Signal, 1) + <-c + close(quit) + log.I.Ln("\rinterrupt signal received") + os.Exit(0) + }() + + var wg sync.WaitGroup + var counter int64 + for i := 0; i < threads; i++ { + log.D.F("starting up worker %d", i) + go mine(str, where, quit, resC, &wg, &counter) + } + tick := time.NewTicker(time.Second * 5) + var res Result +out: + for { + select { + case <-tick.C: + workingFor := time.Now().Sub(started) + wm := workingFor % time.Second + workingFor -= wm + fmt.Printf( + " working for %v, attempts %d", + workingFor, atomic.LoadInt64(&counter), + ) + case r := <-resC: + // one of the workers found the solution + res = r + // tell the others to stop + close(quit) + break out + } + } + + // wait for all workers to stop + wg.Wait() + + fmt.Printf( + "\r# generated in %d attempts using %d threads, taking %v ", + atomic.LoadInt64(&counter), args.Threads, time.Now().Sub(started), + ) + fmt.Printf( + "\nHSEC = %s\nHPUB = %s\n", + hex.EncodeToString(res.sec), + hex.EncodeToString(res.pub), + ) + nsec, _ := bech32encoding.BinToNsec(res.sec) + fmt.Printf("NSEC = %s\nNPUB = %s\n", nsec, res.npub) + return +} + +func mine( + str string, where int, quit <-chan struct{}, resC chan Result, wg *sync.WaitGroup, + counter *int64, +) { + wg.Add(1) + defer wg.Done() + + var r Result + var e error + found := false +out: + for { + select { + case <-quit: + if found { + // send back the result + log.D.Ln("sending back result") + resC <- r + log.D.Ln("sent") + } else { + log.D.Ln("other thread found it") + } + break out + default: + } + atomic.AddInt64(counter, 1) + r.sec, r.pub, e = Gen() + if e != nil { + log.E.Ln("error generating key: '%v' worker stopping", e) + break out + } + if r.npub, e = bech32encoding.BinToNpub(r.pub); e != nil { + log.E.Ln("fatal error generating npub: %s", e) + break out + } + fmt.Printf("\rgenerating key: %s", r.npub) + switch where { + case PositionBeginning: + if bytes.HasPrefix(r.npub, append(prefix, []byte(str)...)) { + found = true + // Signal quit by sending result + select { + case resC <- r: + default: + } + return + } + case PositionEnding: + if bytes.HasSuffix(r.npub, []byte(str)) { + found = true + select { + case resC <- r: + default: + } + return + } + case PositionContains: + if bytes.Contains(r.npub, []byte(str)) { + found = true + select { + case resC <- r: + default: + } + return + } + } + } +} + +func Gen() (skb, pkb []byte, err error) { + sign, err := p8k.New() + if err != nil { + return nil, nil, err + } + if err = sign.Generate(); chk.E(err) { + return + } + skb, pkb = sign.Sec(), sign.Pub() + return +} diff --git a/go.mod b/go.mod index 5574032..e3cbdcb 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,8 @@ require ( require ( github.com/BurntSushi/toml v1.5.0 // indirect github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect + github.com/alexflint/go-arg v1.6.1 // indirect + github.com/alexflint/go-scalar v1.2.0 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/bytedance/sonic v1.13.1 // indirect diff --git a/go.sum b/go.sum index b7e71e1..4b6ff16 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,10 @@ github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNN github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA= +github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= github.com/aperturerobotics/go-indexeddb v0.2.3 h1:DfquIk9YEZjWD/lJyBWZWGCtRga43/a96bx0Ulv9VhQ= github.com/aperturerobotics/go-indexeddb v0.2.3/go.mod h1:JV1XngOCCui7zrMSyRz+Wvz00nUSfotRKZqJzWpl5fQ= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= @@ -126,6 +130,7 @@ github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERA github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/pkg/version/version b/pkg/version/version index b4a466a..2302e30 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.38.1 +v0.39.0