From fec9b2544b3d1bb307346172ef047100029037bb Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:51:49 +0000 Subject: [PATCH 1/2] refactor(client): split the vault and prefs into leaf packages Importing client for programmatic vault access meant inheriting its whole closure -- tview/tcell and the terminfo database, viper and zerolog via cli/common, and bbolt -- to read a YAML file. The cost isn't binary size, which the linker handles, but the module graph a consumer carries. The vault now lives in client/vault and the prefs store in client/prefs, both leaves. A consumer importing them pulls 10 modules instead of 38, dropping tview, tcell, viper, bbolt, cobra, afero and 22 others. Three things had to move that the issue didn't account for: - AppConfigDir, into a new leaf appdir. It was already stdlib-only, but Go compiles a package wholesale, so importing cli/common for it dragged in viper and zerolog anyway. cli/common re-exports it. - LoadPrefs/SavePrefs/VaultIdentityRef, which the vault needs to read and write its own keypair reference. That pulled the rest of the Prefs type with it, since methods live with their type. TargetsFromPrefs and TargetsFromRelayList stay behind -- they return a TargetsSpec, which is what bbolt hangs off. - ResolveRelayURL, which prefs needs to validate relay entries. It now sits with the relay list it validates, and its tests moved with it. client re-exports every previous name, permanently: types as aliases, so a *client.VaultEntry and a *vault.Entry are the same type. No call site changed -- the existing client/vault_test.go now doubles as a test that the aliases really are transparent. zerolog is still in the leaf closure, via nmilat/utils. That one is upstream's to fix. Closes #59 --- CHANGELOG.md | 5 + appdir/appdir.go | 36 +++++ cli/common/appdir.go | 33 ++--- client/compat.go | 100 +++++++++++++ client/identity.go | 36 ----- client/prefs.go | 258 +--------------------------------- client/prefs/prefs.go | 256 +++++++++++++++++++++++++++++++++ client/prefs/relayurl.go | 67 +++++++++ client/prefs/relayurl_test.go | 114 +++++++++++++++ client/spec.go | 53 ------- client/spec_test.go | 111 --------------- client/vault/identity.go | 48 +++++++ client/{ => vault}/vault.go | 77 +++++----- 13 files changed, 679 insertions(+), 515 deletions(-) create mode 100644 appdir/appdir.go create mode 100644 client/compat.go create mode 100644 client/prefs/prefs.go create mode 100644 client/prefs/relayurl.go create mode 100644 client/prefs/relayurl_test.go create mode 100644 client/vault/identity.go rename client/{ => vault}/vault.go (78%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2278af5..680dd3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ - A spinner on stderr while any command waits on the network. Off under `--json`, `-q/--quiet`, `NO_COLOR`, and whenever stderr isn't a terminal. (#55) +- `client/vault` and `client/prefs` are importable on their own, for code + that wants the vault or the relay list without the rest of `client`. + Importing them pulls 10 modules instead of 38 -- no TUI, no viper, no + bbolt. `client` re-exports every previous name unchanged, so existing + code needs no edits. ### Changed diff --git a/appdir/appdir.go b/appdir/appdir.go new file mode 100644 index 0000000..45132ea --- /dev/null +++ b/appdir/appdir.go @@ -0,0 +1,36 @@ +// Package appdir resolves ncli's per-user application directory. +// +// It is deliberately a leaf: stdlib only, no logging and no config stack, so +// the packages that merely need to know where ncli keeps its files -- the +// vault and prefs stores, which a library consumer may import on their own -- +// don't inherit anything heavier. cli/common re-exports it for the CLI. +package appdir + +import ( + "os" + "path/filepath" +) + +// Name is ncli's own subdirectory within the OS's per-user application +// directory. +const Name = ".ncli" + +// Config returns the OS-appropriate per-user application directory for ncli -- +// %AppData% on Windows, ~/Library/Application Support on macOS, +// $XDG_CONFIG_HOME (or ~/.config) on Linux -- joined with ncli's own .ncli +// subdirectory. It's the one base directory everything ncli writes outside a +// project lives under: prefs.yaml, vault.yaml, the CLI's log file, and its +// crash log. Falls back to the home directory, then the working directory, if +// the platform's config directory can't be determined (e.g. neither +// $XDG_CONFIG_HOME nor $HOME set). +func Config() string { + dir, err := os.UserConfigDir() + if err != nil { + home, homeErr := os.UserHomeDir() + if homeErr != nil { + home, _ = os.Getwd() + } + dir = home + } + return filepath.Join(dir, Name) +} diff --git a/cli/common/appdir.go b/cli/common/appdir.go index 30662b6..8eaf0cb 100644 --- a/cli/common/appdir.go +++ b/cli/common/appdir.go @@ -1,30 +1,15 @@ package common -import ( - "os" - "path/filepath" -) +import "github.com/ohstr/ncli/appdir" + +// The implementation lives in the leaf package appdir, so the vault and prefs +// stores can resolve the same directory without importing this package (and +// with it viper and zerolog). These stay as the CLI's spelling of it. // AppDirName is ncli's own subdirectory within the OS's per-user // application directory. -const AppDirName = ".ncli" +const AppDirName = appdir.Name -// AppConfigDir returns the OS-appropriate per-user application directory -// for ncli -- %AppData% on Windows, ~/Library/Application Support on -// macOS, $XDG_CONFIG_HOME (or ~/.config) on Linux -- joined with ncli's -// own .ncli subdirectory. It's the one base directory everything ncli -// writes outside a project lives under: prefs.yaml, the CLI's log file, -// and its crash log. Falls back to the home directory, then the working -// directory, if the platform's config directory can't be determined -// (e.g. neither $XDG_CONFIG_HOME nor $HOME set). -func AppConfigDir() string { - dir, err := os.UserConfigDir() - if err != nil { - home, homeErr := os.UserHomeDir() - if homeErr != nil { - home, _ = os.Getwd() - } - dir = home - } - return filepath.Join(dir, AppDirName) -} +// AppConfigDir returns the OS-appropriate per-user application directory for +// ncli. See appdir.Config. +func AppConfigDir() string { return appdir.Config() } diff --git a/client/compat.go b/client/compat.go new file mode 100644 index 0000000..fb84e1a --- /dev/null +++ b/client/compat.go @@ -0,0 +1,100 @@ +package client + +import ( + "net/url" + + "github.com/ohstr/ncli/client/prefs" + "github.com/ohstr/ncli/client/vault" +) + +// The vault, prefs and keypair code moved into client/vault and client/prefs +// so a consumer that only wants programmatic vault access stops inheriting +// this package's TUI, bbolt and viper closure (issue #59). Everything they +// exported is re-exported here unchanged, permanently -- these are the +// spellings the CLI and any existing consumer already use, and there is no +// deprecation planned. +// +// Types are aliases (=), not definitions, so a *client.VaultEntry and a +// *vault.Entry are the same type and callers can mix the two freely. + +// --- client/vault --- + +type ( + // VaultEntry is vault.Entry. + VaultEntry = vault.Entry + // Identity is vault.Identity. + Identity = vault.Identity +) + +// ErrLabelExists reports that a vault label is already taken. Same value as +// vault.ErrLabelExists, so errors.Is works across both spellings. +var ErrLabelExists = vault.ErrLabelExists + +// GenerateIdentity mints a new keypair. See vault.GenerateIdentity. +func GenerateIdentity() (*Identity, error) { return vault.GenerateIdentity() } + +// VaultPath returns the path to vault.yaml. See vault.Path. +func VaultPath() string { return vault.Path() } + +// VaultExists reports whether the vault identity exists. See vault.Exists. +func VaultExists() (bool, error) { return vault.Exists() } + +// CreateVaultIdentity creates the vault's own keypair. See vault.CreateIdentity. +func CreateVaultIdentity(password string) (npub, privKeyHex string, err error) { + return vault.CreateIdentity(password) +} + +// UnlockVaultIdentity decrypts the vault key. See vault.Unlock. +func UnlockVaultIdentity(password string) (string, error) { return vault.Unlock(password) } + +// LoadVaultEntries reads vault.yaml. See vault.LoadEntries. +func LoadVaultEntries() ([]VaultEntry, error) { return vault.LoadEntries() } + +// SaveVaultEntries writes vault.yaml. See vault.SaveEntries. +func SaveVaultEntries(entries []VaultEntry) error { return vault.SaveEntries(entries) } + +// AddVaultEntry saves a new identity into the vault. See vault.AddEntry. +func AddVaultEntry(vaultPrivKeyHex, label, entryPrivKeyHex string) (*VaultEntry, error) { + return vault.AddEntry(vaultPrivKeyHex, label, entryPrivKeyHex) +} + +// DecryptVaultEntry reverses AddVaultEntry. See vault.DecryptEntry. +func DecryptVaultEntry(vaultPrivKeyHex string, entry VaultEntry) (string, error) { + return vault.DecryptEntry(vaultPrivKeyHex, entry) +} + +// FindVaultEntry looks a vault entry up by label or key. See vault.FindEntry. +func FindVaultEntry(labelOrNpub string) (*VaultEntry, bool, error) { + return vault.FindEntry(labelOrNpub) +} + +// --- client/prefs --- + +type ( + // Prefs is prefs.Prefs. + Prefs = prefs.Prefs + // VaultIdentityRef is prefs.VaultIdentityRef. + VaultIdentityRef = prefs.VaultIdentityRef +) + +// PrefsPath returns the path to prefs.yaml. See prefs.Path. +func PrefsPath() string { return prefs.Path() } + +// LoadPrefs reads prefs.yaml. See prefs.Load. +func LoadPrefs() (*Prefs, error) { return prefs.Load() } + +// SavePrefs writes prefs.yaml. See prefs.Save. +func SavePrefs(p *Prefs) error { return prefs.Save(p) } + +// BlossomServersFromPrefs returns the configured Blossom servers. See +// prefs.BlossomServers. +func BlossomServersFromPrefs() ([]string, error) { return prefs.BlossomServers() } + +// PrefsRelayURLs returns the configured relays as URLs. See prefs.RelayURLs. +func PrefsRelayURLs() ([]*url.URL, error) { return prefs.RelayURLs() } + +// ResolveRelayURL parses a relay input into its primary and fallback URLs. +// See prefs.ResolveRelayURL. +func ResolveRelayURL(raw string) (primary *url.URL, fallback *url.URL, err error) { + return prefs.ResolveRelayURL(raw) +} diff --git a/client/identity.go b/client/identity.go index b8d8af8..1463481 100644 --- a/client/identity.go +++ b/client/identity.go @@ -1,14 +1,12 @@ package client import ( - "encoding/hex" "encoding/json" "fmt" "net/http" "strings" "time" - btcec "github.com/flokiorg/go-flokicoin/crypto" "github.com/ohstr/nmilat/nip05" "github.com/ohstr/nmilat/nip19" "github.com/ohstr/nmilat/utils" @@ -16,40 +14,6 @@ import ( const nip05Timeout = 10 * time.Second -// Identity bundles every representation of a Nostr keypair. -type Identity struct { - PrivKeyHex string - PubKeyHex string - Nsec string - Npub string -} - -// GenerateIdentity mints a brand-new secp256k1 keypair and returns every -// display form of it. This is the only place a new keypair is ever created. -func GenerateIdentity() (*Identity, error) { - priv, err := btcec.NewPrivateKey() - if err != nil { - return nil, fmt.Errorf("failed to generate key: %w", err) - } - privHex := hex.EncodeToString(priv.Serialize()) - - pubHex, err := utils.GetPublicKey(privHex) - if err != nil { - return nil, fmt.Errorf("failed to derive public key: %w", err) - } - - nsec, err := nip19.EncodePrivateKey(privHex) - if err != nil { - return nil, err - } - npub, err := nip19.EncodePublicKey(pubHex) - if err != nil { - return nil, err - } - - return &Identity{PrivKeyHex: privHex, PubKeyHex: pubHex, Nsec: nsec, Npub: npub}, nil -} - // IdentityInspection is the read-only result of resolving an identifier // (vault label, npub, hex pubkey, nsec, nprofile, or nip-05 address). It is // deliberately not named Inspector/Inspect to avoid clashing with the diff --git a/client/prefs.go b/client/prefs.go index 8e0f5bc..bb1b020 100644 --- a/client/prefs.go +++ b/client/prefs.go @@ -3,258 +3,10 @@ package client import ( "errors" "fmt" - "net/url" - "os" - "path/filepath" - "slices" - "github.com/ohstr/ncli/cli/common" - "sigs.k8s.io/yaml" + "github.com/ohstr/ncli/client/prefs" ) -const prefsFileName = "prefs.yaml" - -// Prefs holds persistent ncli preferences that aren't tied to any single -// project's spec files -- the default relay list consulted by commands -// (dump, find) when they aren't given an explicit source, the local -// identity vault's own keypair reference (see client/vault.go), and the -// named `ncli relay context` config-file shortcuts below. -type Prefs struct { - Relays []string `json:"relays,omitempty" yaml:"relays,omitempty"` - VaultIdentity *VaultIdentityRef `json:"vault_identity,omitempty" yaml:"vault_identity,omitempty"` - - // BlossomServers is the default Blossom server list "ncli blossom" - // commands fall back to when not given an explicit --server. Managed - // via `ncli blossom servers add/remove/list`. - BlossomServers []string `json:"blossom_servers,omitempty" yaml:"blossom_servers,omitempty"` - - // RelayContexts maps a short name to an absolute ncli/relay config - // file path -- e.g. {"prod": "/etc/ncli/prod.yaml"} -- so relay admin - // commands (stats, members, invites, roles, ...) can target a - // specific relay by name instead of repeating --config on every - // invocation. Managed via `ncli relay context add/remove`. - RelayContexts map[string]string `json:"relay_contexts,omitempty" yaml:"relay_contexts,omitempty"` - - // CurrentRelayContext is the RelayContexts key currently in effect, - // set via `ncli relay context use `. ncli.InitConfig's - // resolveConfigFile uses this context's path whenever --config is - // omitted, taking priority over any ncli.yaml/relay.yaml in the - // working directory. - CurrentRelayContext string `json:"current_relay_context,omitempty" yaml:"current_relay_context,omitempty"` -} - -// VaultIdentityRef is the vault's own keypair, used only to derive each -// saved identity's per-entry NIP-44 encryption key (see client/vault.go) -- -// it is not itself a saved identity. Npub is plaintext (harmless to store -// openly); EncryptedNsec is the vault's private key wrapped with NIP-49 -// ("ncryptsec1...") under the password chosen at vault-creation time. -type VaultIdentityRef struct { - Npub string `json:"npub" yaml:"npub"` - EncryptedNsec string `json:"encrypted_nsec" yaml:"encrypted_nsec"` -} - -// PrefsPath returns the OS-appropriate path to prefs.yaml, under ncli's -// shared app config directory (see common.AppConfigDir) -- the same -// directory the CLI's log file and crash log live under. -func PrefsPath() string { - return filepath.Join(common.AppConfigDir(), prefsFileName) -} - -// LoadPrefs reads prefs.yaml, returning an empty Prefs (not an error) if -// it doesn't exist yet -- a fresh install has no preferences configured. -func LoadPrefs() (*Prefs, error) { - path := PrefsPath() - - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return &Prefs{}, nil - } else if err != nil { - return nil, err - } - - var p Prefs - if err := yaml.UnmarshalStrict(data, &p); err != nil { - return nil, fmt.Errorf("failed to parse %s: %w", path, err) - } - return &p, nil -} - -// SavePrefs writes p to prefs.yaml, creating its parent directory if -// needed. -func SavePrefs(p *Prefs) error { - path := PrefsPath() - - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - - data, err := yaml.Marshal(p) - if err != nil { - return err - } - - return os.WriteFile(path, data, 0600) -} - -// AddRelay validates and appends relay to p.Relays, reporting false -// (without modifying p) if it's already present. -func (p *Prefs) AddRelay(relay string) (bool, error) { - if _, _, err := ResolveRelayURL(relay); err != nil { - return false, err - } - if slices.Contains(p.Relays, relay) { - return false, nil - } - p.Relays = append(p.Relays, relay) - return true, nil -} - -// RemoveRelay removes relay from p.Relays, reporting false if it wasn't -// present. -func (p *Prefs) RemoveRelay(relay string) bool { - idx := slices.Index(p.Relays, relay) - if idx == -1 { - return false - } - p.Relays = slices.Delete(p.Relays, idx, idx+1) - return true -} - -// AddBlossomServer validates and appends server to p.BlossomServers, -// reporting false (without modifying p) if it's already present. -func (p *Prefs) AddBlossomServer(server string) (bool, error) { - u, err := url.ParseRequestURI(server) - if err != nil { - return false, fmt.Errorf("invalid blossom server url %q: %w", server, err) - } - if u.Scheme != "http" && u.Scheme != "https" { - return false, fmt.Errorf("blossom server url %q must use http or https scheme", server) - } - if slices.Contains(p.BlossomServers, server) { - return false, nil - } - p.BlossomServers = append(p.BlossomServers, server) - return true, nil -} - -// RemoveBlossomServer removes server from p.BlossomServers, reporting -// false if it wasn't present. -func (p *Prefs) RemoveBlossomServer(server string) bool { - idx := slices.Index(p.BlossomServers, server) - if idx == -1 { - return false - } - p.BlossomServers = slices.Delete(p.BlossomServers, idx, idx+1) - return true -} - -// BlossomServersFromPrefs loads prefs.yaml and returns the configured -// Blossom server list, erroring out (naming `ncli blossom servers add`) if -// none are configured -- callers use this as the fallback for an omitted -// explicit --server. -func BlossomServersFromPrefs() ([]string, error) { - prefs, err := LoadPrefs() - if err != nil { - return nil, err - } - if len(prefs.BlossomServers) == 0 { - return nil, errors.New("no blossom servers configured; pass --server explicitly, or run `ncli blossom servers add `") - } - return prefs.BlossomServers, nil -} - -// AddRelayContext saves name -> configPath's absolute form in -// p.RelayContexts, overwriting any existing entry for name. configPath -// must already exist as a regular file -- a typo'd path here would -// otherwise surface only later, as a confusing failure to load config the -// next time the context is used. -func (p *Prefs) AddRelayContext(name, configPath string) (string, error) { - if name == "" { - return "", errors.New("context name must not be empty") - } - - abs, err := filepath.Abs(configPath) - if err != nil { - return "", fmt.Errorf("invalid path %q: %w", configPath, err) - } - - info, err := os.Stat(abs) - if err != nil { - return "", fmt.Errorf("config file %q: %w", abs, err) - } - if info.IsDir() { - return "", fmt.Errorf("%q is a directory, not a config file", abs) - } - - if p.RelayContexts == nil { - p.RelayContexts = map[string]string{} - } - p.RelayContexts[name] = abs - return abs, nil -} - -// RemoveRelayContext deletes name from p.RelayContexts, reporting false if -// it wasn't present. Also clears CurrentRelayContext if it pointed at the -// removed name, rather than leaving it dangling on a name that no longer -// resolves to anything. -func (p *Prefs) RemoveRelayContext(name string) bool { - if _, ok := p.RelayContexts[name]; !ok { - return false - } - delete(p.RelayContexts, name) - if p.CurrentRelayContext == name { - p.CurrentRelayContext = "" - } - return true -} - -// UseRelayContext sets p.CurrentRelayContext to name, failing if name -// isn't a saved context -- switching to an unknown context would otherwise -// silently fall back to cwd/home discovery, exactly the ambiguity -// contexts exist to remove. -func (p *Prefs) UseRelayContext(name string) error { - if _, ok := p.RelayContexts[name]; !ok { - return fmt.Errorf("no such relay context %q (run `ncli relay context` to list configured contexts)", name) - } - p.CurrentRelayContext = name - return nil -} - -// CurrentRelayContextPath returns the config file path of the current -// relay context, and false if none is set (or it points at a name that's -// since been removed). -func (p *Prefs) CurrentRelayContextPath() (path string, ok bool) { - if p.CurrentRelayContext == "" { - return "", false - } - path, ok = p.RelayContexts[p.CurrentRelayContext] - return path, ok -} - -// PrefsRelayURLs loads prefs.yaml and validates every configured relay. -// It errors out (naming `ncli prefs relays add`) if none are configured, -// since callers use this specifically as a fallback for an omitted -// explicit source/target. -func PrefsRelayURLs() ([]*url.URL, error) { - prefs, err := LoadPrefs() - if err != nil { - return nil, err - } - if len(prefs.Relays) == 0 { - return nil, errors.New("no relays configured; pass the relay(s) explicitly, or run `ncli prefs relays add `") - } - - urls := make([]*url.URL, 0, len(prefs.Relays)) - for _, r := range prefs.Relays { - u, _, err := ResolveRelayURL(r) - if err != nil { - return nil, fmt.Errorf("invalid relay in prefs (%s): %w", r, err) - } - urls = append(urls, u) - } - return urls, nil -} - // TargetsFromPrefs builds a TargetsSpec purely from the prefs relay list, // treating every entry as a remote relay -- the fallback find/dump/miner // check use when they aren't given an explicit --targets file or --relays. @@ -262,17 +14,17 @@ func PrefsRelayURLs() ([]*url.URL, error) { // schemeless entry keeps its ws:// fallback candidate, not just its // wss:// primary. func TargetsFromPrefs() (*TargetsSpec, error) { - prefs, err := LoadPrefs() + p, err := prefs.Load() if err != nil { return nil, err } - if len(prefs.Relays) == 0 { + if len(p.Relays) == 0 { return nil, errors.New("no relays configured; pass the relay(s) explicitly, or run `ncli prefs relays add `") } spec := &TargetsSpec{} - for _, r := range prefs.Relays { - u, fallback, err := ResolveRelayURL(r) + for _, r := range p.Relays { + u, fallback, err := prefs.ResolveRelayURL(r) if err != nil { return nil, fmt.Errorf("invalid relay in prefs (%s): %w", r, err) } diff --git a/client/prefs/prefs.go b/client/prefs/prefs.go new file mode 100644 index 0000000..d401e47 --- /dev/null +++ b/client/prefs/prefs.go @@ -0,0 +1,256 @@ +package prefs + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "slices" + + "github.com/ohstr/ncli/appdir" + "sigs.k8s.io/yaml" +) + +const prefsFileName = "prefs.yaml" + +// Prefs holds persistent ncli preferences that aren't tied to any single +// project's spec files -- the default relay list consulted by commands +// (dump, find) when they aren't given an explicit source, the local +// identity vault's own keypair reference (see client/vault.go), and the +// named `ncli relay context` config-file shortcuts below. +type Prefs struct { + Relays []string `json:"relays,omitempty" yaml:"relays,omitempty"` + VaultIdentity *VaultIdentityRef `json:"vault_identity,omitempty" yaml:"vault_identity,omitempty"` + + // BlossomServers is the default Blossom server list "ncli blossom" + // commands fall back to when not given an explicit --server. Managed + // via `ncli blossom servers add/remove/list`. + BlossomServers []string `json:"blossom_servers,omitempty" yaml:"blossom_servers,omitempty"` + + // RelayContexts maps a short name to an absolute ncli/relay config + // file path -- e.g. {"prod": "/etc/ncli/prod.yaml"} -- so relay admin + // commands (stats, members, invites, roles, ...) can target a + // specific relay by name instead of repeating --config on every + // invocation. Managed via `ncli relay context add/remove`. + RelayContexts map[string]string `json:"relay_contexts,omitempty" yaml:"relay_contexts,omitempty"` + + // CurrentRelayContext is the RelayContexts key currently in effect, + // set via `ncli relay context use `. ncli.InitConfig's + // resolveConfigFile uses this context's path whenever --config is + // omitted, taking priority over any ncli.yaml/relay.yaml in the + // working directory. + CurrentRelayContext string `json:"current_relay_context,omitempty" yaml:"current_relay_context,omitempty"` +} + +// VaultIdentityRef is the vault's own keypair, used only to derive each +// saved identity's per-entry NIP-44 encryption key (see client/vault.go) -- +// it is not itself a saved identity. Npub is plaintext (harmless to store +// openly); EncryptedNsec is the vault's private key wrapped with NIP-49 +// ("ncryptsec1...") under the password chosen at vault-creation time. +type VaultIdentityRef struct { + Npub string `json:"npub" yaml:"npub"` + EncryptedNsec string `json:"encrypted_nsec" yaml:"encrypted_nsec"` +} + +// PrefsPath returns the OS-appropriate path to prefs.yaml, under ncli's +// shared app config directory (see common.AppConfigDir) -- the same +// directory the CLI's log file and crash log live under. +func Path() string { + return filepath.Join(appdir.Config(), prefsFileName) +} + +// LoadPrefs reads prefs.yaml, returning an empty Prefs (not an error) if +// it doesn't exist yet -- a fresh install has no preferences configured. +func Load() (*Prefs, error) { + path := Path() + + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return &Prefs{}, nil + } else if err != nil { + return nil, err + } + + var p Prefs + if err := yaml.UnmarshalStrict(data, &p); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + return &p, nil +} + +// SavePrefs writes p to prefs.yaml, creating its parent directory if +// needed. +func Save(p *Prefs) error { + path := Path() + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + data, err := yaml.Marshal(p) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} + +// AddRelay validates and appends relay to p.Relays, reporting false +// (without modifying p) if it's already present. +func (p *Prefs) AddRelay(relay string) (bool, error) { + if _, _, err := ResolveRelayURL(relay); err != nil { + return false, err + } + if slices.Contains(p.Relays, relay) { + return false, nil + } + p.Relays = append(p.Relays, relay) + return true, nil +} + +// RemoveRelay removes relay from p.Relays, reporting false if it wasn't +// present. +func (p *Prefs) RemoveRelay(relay string) bool { + idx := slices.Index(p.Relays, relay) + if idx == -1 { + return false + } + p.Relays = slices.Delete(p.Relays, idx, idx+1) + return true +} + +// AddBlossomServer validates and appends server to p.BlossomServers, +// reporting false (without modifying p) if it's already present. +func (p *Prefs) AddBlossomServer(server string) (bool, error) { + u, err := url.ParseRequestURI(server) + if err != nil { + return false, fmt.Errorf("invalid blossom server url %q: %w", server, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return false, fmt.Errorf("blossom server url %q must use http or https scheme", server) + } + if slices.Contains(p.BlossomServers, server) { + return false, nil + } + p.BlossomServers = append(p.BlossomServers, server) + return true, nil +} + +// RemoveBlossomServer removes server from p.BlossomServers, reporting +// false if it wasn't present. +func (p *Prefs) RemoveBlossomServer(server string) bool { + idx := slices.Index(p.BlossomServers, server) + if idx == -1 { + return false + } + p.BlossomServers = slices.Delete(p.BlossomServers, idx, idx+1) + return true +} + +// BlossomServersFromPrefs loads prefs.yaml and returns the configured +// Blossom server list, erroring out (naming `ncli blossom servers add`) if +// none are configured -- callers use this as the fallback for an omitted +// explicit --server. +func BlossomServers() ([]string, error) { + prefs, err := Load() + if err != nil { + return nil, err + } + if len(prefs.BlossomServers) == 0 { + return nil, errors.New("no blossom servers configured; pass --server explicitly, or run `ncli blossom servers add `") + } + return prefs.BlossomServers, nil +} + +// AddRelayContext saves name -> configPath's absolute form in +// p.RelayContexts, overwriting any existing entry for name. configPath +// must already exist as a regular file -- a typo'd path here would +// otherwise surface only later, as a confusing failure to load config the +// next time the context is used. +func (p *Prefs) AddRelayContext(name, configPath string) (string, error) { + if name == "" { + return "", errors.New("context name must not be empty") + } + + abs, err := filepath.Abs(configPath) + if err != nil { + return "", fmt.Errorf("invalid path %q: %w", configPath, err) + } + + info, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("config file %q: %w", abs, err) + } + if info.IsDir() { + return "", fmt.Errorf("%q is a directory, not a config file", abs) + } + + if p.RelayContexts == nil { + p.RelayContexts = map[string]string{} + } + p.RelayContexts[name] = abs + return abs, nil +} + +// RemoveRelayContext deletes name from p.RelayContexts, reporting false if +// it wasn't present. Also clears CurrentRelayContext if it pointed at the +// removed name, rather than leaving it dangling on a name that no longer +// resolves to anything. +func (p *Prefs) RemoveRelayContext(name string) bool { + if _, ok := p.RelayContexts[name]; !ok { + return false + } + delete(p.RelayContexts, name) + if p.CurrentRelayContext == name { + p.CurrentRelayContext = "" + } + return true +} + +// UseRelayContext sets p.CurrentRelayContext to name, failing if name +// isn't a saved context -- switching to an unknown context would otherwise +// silently fall back to cwd/home discovery, exactly the ambiguity +// contexts exist to remove. +func (p *Prefs) UseRelayContext(name string) error { + if _, ok := p.RelayContexts[name]; !ok { + return fmt.Errorf("no such relay context %q (run `ncli relay context` to list configured contexts)", name) + } + p.CurrentRelayContext = name + return nil +} + +// CurrentRelayContextPath returns the config file path of the current +// relay context, and false if none is set (or it points at a name that's +// since been removed). +func (p *Prefs) CurrentRelayContextPath() (path string, ok bool) { + if p.CurrentRelayContext == "" { + return "", false + } + path, ok = p.RelayContexts[p.CurrentRelayContext] + return path, ok +} + +// PrefsRelayURLs loads prefs.yaml and validates every configured relay. +// It errors out (naming `ncli prefs relays add`) if none are configured, +// since callers use this specifically as a fallback for an omitted +// explicit source/target. +func RelayURLs() ([]*url.URL, error) { + prefs, err := Load() + if err != nil { + return nil, err + } + if len(prefs.Relays) == 0 { + return nil, errors.New("no relays configured; pass the relay(s) explicitly, or run `ncli prefs relays add `") + } + + urls := make([]*url.URL, 0, len(prefs.Relays)) + for _, r := range prefs.Relays { + u, _, err := ResolveRelayURL(r) + if err != nil { + return nil, fmt.Errorf("invalid relay in prefs (%s): %w", r, err) + } + urls = append(urls, u) + } + return urls, nil +} diff --git a/client/prefs/relayurl.go b/client/prefs/relayurl.go new file mode 100644 index 0000000..d75de13 --- /dev/null +++ b/client/prefs/relayurl.go @@ -0,0 +1,67 @@ +package prefs + +import ( + "fmt" + "net" + "net/url" + "slices" + "strings" +) + +// This lives here rather than in client/spec.go because prefs owns the relay +// list and has to validate every entry going into it -- and because spec.go +// sits in a package that pulls in bbolt and the TUI, which a consumer that +// only wants to read the relay list shouldn't inherit. client re-exports +// ResolveRelayURL, so existing callers are unaffected. + +// ResolveRelayURL parses a relay input into its primary connection URL and, +// when raw has no explicit ws(s):// scheme, a ws:// fallback candidate -- +// wss:// is tried first (see connectRelayWithFallback/ +// readEventsWithFallback), falling back to ws:// only if that fails to +// connect. An explicit scheme is taken at face value with no fallback: +// writing "ws://" or "wss://" already says exactly what's wanted. +func ResolveRelayURL(raw string) (primary *url.URL, fallback *url.URL, err error) { + if !strings.Contains(raw, "://") { + if !looksLikeRelayHost(raw) { + return nil, nil, fmt.Errorf("invalid relay URL %s", raw) + } + u, err := url.Parse("wss://" + raw) + if err != nil || u.Host == "" { + return nil, nil, fmt.Errorf("invalid relay URL %s", raw) + } + f, err := url.Parse("ws://" + raw) + if err != nil { + return nil, nil, fmt.Errorf("invalid relay URL %s", raw) + } + return u, f, nil + } + + uri, err := url.Parse(raw) + if err != nil { + return nil, nil, fmt.Errorf("invalid relay URL %s: %w", raw, err) + } else if !slices.Contains([]string{"ws", "wss"}, uri.Scheme) { + return nil, nil, fmt.Errorf("invalid relay URL %s, unsupported scheme", raw) + } else if uri.Host == "" { + return nil, nil, fmt.Errorf("invalid relay URL %s, empty host", raw) + } + return uri, nil, nil +} + +// looksLikeRelayHost is ResolveRelayURL's gate on schemeless input: any +// bare string technically parses as a syntactically "valid" single-label +// URL host, which would otherwise swallow plain typos ("not-a-relay-url") +// and local store paths ("../notes.db") as relay candidates instead of +// letting them fail with a clear error / fall through to the file-path +// check in flowSpecFromString. A path separator rules out a host outright; +// otherwise this requires a dot (domain-like), "localhost", or a bare IP -- +// the same shape every schemeless relay input in practice actually has. +func looksLikeRelayHost(raw string) bool { + if raw == "" || strings.ContainsAny(raw, `/\`) { + return false + } + host := raw + if h, _, err := net.SplitHostPort(raw); err == nil { + host = h + } + return host == "localhost" || strings.Contains(host, ".") || net.ParseIP(host) != nil +} diff --git a/client/prefs/relayurl_test.go b/client/prefs/relayurl_test.go new file mode 100644 index 0000000..400a3b1 --- /dev/null +++ b/client/prefs/relayurl_test.go @@ -0,0 +1,114 @@ +package prefs + +import "testing" + +func TestResolveRelayURL_ExplicitWss(t *testing.T) { + primary, fallback, err := ResolveRelayURL("wss://relay.ohstr.com") + if err != nil { + t.Fatalf("ResolveRelayURL() error = %v", err) + } + if primary.String() != "wss://relay.ohstr.com" { + t.Fatalf("primary = %q, want wss://relay.ohstr.com", primary.String()) + } + if fallback != nil { + t.Fatalf("fallback = %v, want nil for an explicit scheme", fallback) + } +} + +func TestResolveRelayURL_ExplicitWs(t *testing.T) { + primary, fallback, err := ResolveRelayURL("ws://localhost:5500") + if err != nil { + t.Fatalf("ResolveRelayURL() error = %v", err) + } + if primary.String() != "ws://localhost:5500" { + t.Fatalf("primary = %q, want ws://localhost:5500", primary.String()) + } + if fallback != nil { + t.Fatalf("fallback = %v, want nil for an explicit scheme", fallback) + } +} + +func TestResolveRelayURL_UnsupportedScheme(t *testing.T) { + if _, _, err := ResolveRelayURL("https://example.com"); err == nil { + t.Fatal("ResolveRelayURL(https://...) error = nil, want an error") + } +} + +func TestResolveRelayURL_EmptyHost(t *testing.T) { + if _, _, err := ResolveRelayURL("wss://"); err == nil { + t.Fatal("ResolveRelayURL(wss://) error = nil, want an error (empty host)") + } +} + +func TestResolveRelayURL_SchemelessHost(t *testing.T) { + primary, fallback, err := ResolveRelayURL("relay.primal.net") + if err != nil { + t.Fatalf("ResolveRelayURL() error = %v", err) + } + if primary.String() != "wss://relay.primal.net" { + t.Fatalf("primary = %q, want wss://relay.primal.net", primary.String()) + } + if fallback == nil || fallback.String() != "ws://relay.primal.net" { + t.Fatalf("fallback = %v, want ws://relay.primal.net", fallback) + } +} + +func TestResolveRelayURL_SchemelessHostPort(t *testing.T) { + primary, fallback, err := ResolveRelayURL("localhost:4869") + if err != nil { + t.Fatalf("ResolveRelayURL() error = %v", err) + } + if primary.String() != "wss://localhost:4869" { + t.Fatalf("primary = %q, want wss://localhost:4869", primary.String()) + } + if fallback == nil || fallback.String() != "ws://localhost:4869" { + t.Fatalf("fallback = %v, want ws://localhost:4869", fallback) + } +} + +func TestResolveRelayURL_SchemelessIP(t *testing.T) { + primary, fallback, err := ResolveRelayURL("192.168.1.5:7000") + if err != nil { + t.Fatalf("ResolveRelayURL() error = %v", err) + } + if primary.String() != "wss://192.168.1.5:7000" { + t.Fatalf("primary = %q, want wss://192.168.1.5:7000", primary.String()) + } + if fallback == nil { + t.Fatal("fallback = nil, want a ws:// fallback for a schemeless IP") + } +} + +func TestResolveRelayURL_SchemelessGarbageRejected(t *testing.T) { + if _, _, err := ResolveRelayURL("not-a-relay-url"); err == nil { + t.Fatal("ResolveRelayURL(no dot, no scheme) error = nil, want an error") + } +} + +func TestResolveRelayURL_SchemelessPathRejected(t *testing.T) { + if _, _, err := ResolveRelayURL("../testdata/notes.db"); err == nil { + t.Fatal("ResolveRelayURL(path with slash) error = nil, want an error") + } +} + +func TestLooksLikeRelayHost(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"relay.primal.net", true}, + {"localhost", true}, + {"localhost:4869", true}, + {"192.168.1.5", true}, + {"192.168.1.5:7000", true}, + {"not-a-relay-url", false}, + {"", false}, + {"../notes.db", false}, + {`foo\bar`, false}, + } + for _, c := range cases { + if got := looksLikeRelayHost(c.in); got != c.want { + t.Errorf("looksLikeRelayHost(%q) = %v, want %v", c.in, got, c.want) + } + } +} diff --git a/client/spec.go b/client/spec.go index 05f0cbf..054d797 100644 --- a/client/spec.go +++ b/client/spec.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "net" "net/url" "os" "path/filepath" @@ -579,58 +578,6 @@ func (ss *SyncSpec) UnmarshalJSON(data []byte) error { ////// -// ResolveRelayURL parses a relay input into its primary connection URL and, -// when raw has no explicit ws(s):// scheme, a ws:// fallback candidate -- -// wss:// is tried first (see connectRelayWithFallback/ -// readEventsWithFallback), falling back to ws:// only if that fails to -// connect. An explicit scheme is taken at face value with no fallback: -// writing "ws://" or "wss://" already says exactly what's wanted. -func ResolveRelayURL(raw string) (primary *url.URL, fallback *url.URL, err error) { - if !strings.Contains(raw, "://") { - if !looksLikeRelayHost(raw) { - return nil, nil, fmt.Errorf("invalid relay URL %s", raw) - } - u, err := url.Parse("wss://" + raw) - if err != nil || u.Host == "" { - return nil, nil, fmt.Errorf("invalid relay URL %s", raw) - } - f, err := url.Parse("ws://" + raw) - if err != nil { - return nil, nil, fmt.Errorf("invalid relay URL %s", raw) - } - return u, f, nil - } - - uri, err := url.Parse(raw) - if err != nil { - return nil, nil, fmt.Errorf("invalid relay URL %s: %w", raw, err) - } else if !slices.Contains([]string{"ws", "wss"}, uri.Scheme) { - return nil, nil, fmt.Errorf("invalid relay URL %s, unsupported scheme", raw) - } else if uri.Host == "" { - return nil, nil, fmt.Errorf("invalid relay URL %s, empty host", raw) - } - return uri, nil, nil -} - -// looksLikeRelayHost is ResolveRelayURL's gate on schemeless input: any -// bare string technically parses as a syntactically "valid" single-label -// URL host, which would otherwise swallow plain typos ("not-a-relay-url") -// and local store paths ("../notes.db") as relay candidates instead of -// letting them fail with a clear error / fall through to the file-path -// check in flowSpecFromString. A path separator rules out a host outright; -// otherwise this requires a dot (domain-like), "localhost", or a bare IP -- -// the same shape every schemeless relay input in practice actually has. -func looksLikeRelayHost(raw string) bool { - if raw == "" || strings.ContainsAny(raw, `/\`) { - return false - } - host := raw - if h, _, err := net.SplitHostPort(raw); err == nil { - host = h - } - return host == "localhost" || strings.Contains(host, ".") || net.ParseIP(host) != nil -} - const ( HoursPerDay = 24 HoursPerWeek = 168 // 24 hours * 7 days diff --git a/client/spec_test.go b/client/spec_test.go index b419dd4..97ed538 100644 --- a/client/spec_test.go +++ b/client/spec_test.go @@ -7,117 +7,6 @@ import ( relayclient "github.com/ohstr/nmilat/relay/client" ) -func TestResolveRelayURL_ExplicitWss(t *testing.T) { - primary, fallback, err := ResolveRelayURL("wss://relay.ohstr.com") - if err != nil { - t.Fatalf("ResolveRelayURL() error = %v", err) - } - if primary.String() != "wss://relay.ohstr.com" { - t.Fatalf("primary = %q, want wss://relay.ohstr.com", primary.String()) - } - if fallback != nil { - t.Fatalf("fallback = %v, want nil for an explicit scheme", fallback) - } -} - -func TestResolveRelayURL_ExplicitWs(t *testing.T) { - primary, fallback, err := ResolveRelayURL("ws://localhost:5500") - if err != nil { - t.Fatalf("ResolveRelayURL() error = %v", err) - } - if primary.String() != "ws://localhost:5500" { - t.Fatalf("primary = %q, want ws://localhost:5500", primary.String()) - } - if fallback != nil { - t.Fatalf("fallback = %v, want nil for an explicit scheme", fallback) - } -} - -func TestResolveRelayURL_UnsupportedScheme(t *testing.T) { - if _, _, err := ResolveRelayURL("https://example.com"); err == nil { - t.Fatal("ResolveRelayURL(https://...) error = nil, want an error") - } -} - -func TestResolveRelayURL_EmptyHost(t *testing.T) { - if _, _, err := ResolveRelayURL("wss://"); err == nil { - t.Fatal("ResolveRelayURL(wss://) error = nil, want an error (empty host)") - } -} - -func TestResolveRelayURL_SchemelessHost(t *testing.T) { - primary, fallback, err := ResolveRelayURL("relay.primal.net") - if err != nil { - t.Fatalf("ResolveRelayURL() error = %v", err) - } - if primary.String() != "wss://relay.primal.net" { - t.Fatalf("primary = %q, want wss://relay.primal.net", primary.String()) - } - if fallback == nil || fallback.String() != "ws://relay.primal.net" { - t.Fatalf("fallback = %v, want ws://relay.primal.net", fallback) - } -} - -func TestResolveRelayURL_SchemelessHostPort(t *testing.T) { - primary, fallback, err := ResolveRelayURL("localhost:4869") - if err != nil { - t.Fatalf("ResolveRelayURL() error = %v", err) - } - if primary.String() != "wss://localhost:4869" { - t.Fatalf("primary = %q, want wss://localhost:4869", primary.String()) - } - if fallback == nil || fallback.String() != "ws://localhost:4869" { - t.Fatalf("fallback = %v, want ws://localhost:4869", fallback) - } -} - -func TestResolveRelayURL_SchemelessIP(t *testing.T) { - primary, fallback, err := ResolveRelayURL("192.168.1.5:7000") - if err != nil { - t.Fatalf("ResolveRelayURL() error = %v", err) - } - if primary.String() != "wss://192.168.1.5:7000" { - t.Fatalf("primary = %q, want wss://192.168.1.5:7000", primary.String()) - } - if fallback == nil { - t.Fatal("fallback = nil, want a ws:// fallback for a schemeless IP") - } -} - -func TestResolveRelayURL_SchemelessGarbageRejected(t *testing.T) { - if _, _, err := ResolveRelayURL("not-a-relay-url"); err == nil { - t.Fatal("ResolveRelayURL(no dot, no scheme) error = nil, want an error") - } -} - -func TestResolveRelayURL_SchemelessPathRejected(t *testing.T) { - if _, _, err := ResolveRelayURL("../testdata/notes.db"); err == nil { - t.Fatal("ResolveRelayURL(path with slash) error = nil, want an error") - } -} - -func TestLooksLikeRelayHost(t *testing.T) { - cases := []struct { - in string - want bool - }{ - {"relay.primal.net", true}, - {"localhost", true}, - {"localhost:4869", true}, - {"192.168.1.5", true}, - {"192.168.1.5:7000", true}, - {"not-a-relay-url", false}, - {"", false}, - {"../notes.db", false}, - {`foo\bar`, false}, - } - for _, c := range cases { - if got := looksLikeRelayHost(c.in); got != c.want { - t.Errorf("looksLikeRelayHost(%q) = %v, want %v", c.in, got, c.want) - } - } -} - // TestTimeoutSpecConnectionConfig is a regression guard: SyncModule.execute // used to build an all-zero ConnectionConfig whenever spec.Timeouts was // non-nil, silently discarding every configured value (relayclient diff --git a/client/vault/identity.go b/client/vault/identity.go new file mode 100644 index 0000000..c33bfe2 --- /dev/null +++ b/client/vault/identity.go @@ -0,0 +1,48 @@ +package vault + +import ( + "encoding/hex" + "fmt" + + btcec "github.com/flokiorg/go-flokicoin/crypto" + "github.com/ohstr/nmilat/nip19" + "github.com/ohstr/nmilat/utils" +) + +// Identity bundles every representation of a Nostr keypair. +type Identity struct { + PrivKeyHex string + PubKeyHex string + Nsec string + Npub string +} + +// GenerateIdentity mints a brand-new secp256k1 keypair and returns every +// display form of it. This is the only place a new keypair is ever created. +// +// It lives beside the vault rather than in client so that generating a key +// and saving it are reachable together without the streaming stack; client +// re-exports both names. +func GenerateIdentity() (*Identity, error) { + priv, err := btcec.NewPrivateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate key: %w", err) + } + privHex := hex.EncodeToString(priv.Serialize()) + + pubHex, err := utils.GetPublicKey(privHex) + if err != nil { + return nil, fmt.Errorf("failed to derive public key: %w", err) + } + + nsec, err := nip19.EncodePrivateKey(privHex) + if err != nil { + return nil, err + } + npub, err := nip19.EncodePublicKey(pubHex) + if err != nil { + return nil, err + } + + return &Identity{PrivKeyHex: privHex, PubKeyHex: pubHex, Nsec: nsec, Npub: npub}, nil +} diff --git a/client/vault.go b/client/vault/vault.go similarity index 78% rename from client/vault.go rename to client/vault/vault.go index c9f5693..b343b17 100644 --- a/client/vault.go +++ b/client/vault/vault.go @@ -1,4 +1,4 @@ -package client +package vault import ( "encoding/hex" @@ -11,7 +11,8 @@ import ( btcec "github.com/flokiorg/go-flokicoin/crypto" "github.com/flokiorg/go-flokicoin/crypto/schnorr" - "github.com/ohstr/ncli/cli/common" + "github.com/ohstr/ncli/appdir" + "github.com/ohstr/ncli/client/prefs" "github.com/ohstr/nmilat/nip19" "github.com/ohstr/nmilat/nip44" "github.com/ohstr/nmilat/nip49" @@ -21,15 +22,15 @@ import ( const vaultFileName = "vault.yaml" -// ErrLabelExists is wrapped into AddVaultEntry's error when label already +// ErrLabelExists is wrapped into AddEntry's error when label already // names a saved entry, so a caller can classify it (e.g. as a conflict) // with errors.Is instead of matching on message text. var ErrLabelExists = errors.New("vault label already exists") -// VaultEntry is one identity saved in the local vault: label and npub are +// Entry is one identity saved in the local vault: label and npub are // plaintext (so listing/inspecting needs no password), but EncryptedNsec is // a NIP-44 payload only the unlocked vault identity can decrypt. -type VaultEntry struct { +type Entry struct { Label string `json:"label" yaml:"label"` Npub string `json:"npub" yaml:"npub"` EncryptedNsec string `json:"encrypted_nsec" yaml:"encrypted_nsec"` @@ -38,24 +39,24 @@ type VaultEntry struct { // vaultFile is the on-disk envelope for vault.yaml. type vaultFile struct { - Entries []VaultEntry `json:"entries,omitempty" yaml:"entries,omitempty"` + Entries []Entry `json:"entries,omitempty" yaml:"entries,omitempty"` } // VaultPath returns the OS-appropriate path to vault.yaml, next to // prefs.yaml under ncli's shared app config directory. -func VaultPath() string { - return filepath.Join(common.AppConfigDir(), vaultFileName) +func Path() string { + return filepath.Join(appdir.Config(), vaultFileName) } // VaultExists reports whether the vault identity has been created yet. // This is the authoritative check (not vault.yaml's mere presence, which // mirrors prefs.yaml's "missing file means empty" convention). -func VaultExists() (bool, error) { - prefs, err := LoadPrefs() +func Exists() (bool, error) { + p, err := prefs.Load() if err != nil { return false, err } - return prefs.VaultIdentity != nil, nil + return p.VaultIdentity != nil, nil } // CreateVaultIdentity generates the vault's own keypair, encrypts its @@ -63,12 +64,12 @@ func VaultExists() (bool, error) { // prefs.yaml. It returns the plaintext private key hex too, so callers // that just created the vault don't need to immediately pay for a second, // redundant scrypt-based unlock (NIP-49's scrypt is deliberately slow). -func CreateVaultIdentity(password string) (npub, privKeyHex string, err error) { - prefs, err := LoadPrefs() +func CreateIdentity(password string) (npub, privKeyHex string, err error) { + p, err := prefs.Load() if err != nil { return "", "", err } - if prefs.VaultIdentity != nil { + if p.VaultIdentity != nil { return "", "", errors.New("vault identity already exists") } @@ -82,24 +83,24 @@ func CreateVaultIdentity(password string) (npub, privKeyHex string, err error) { return "", "", fmt.Errorf("failed to encrypt vault identity key: %w", err) } - prefs.VaultIdentity = &VaultIdentityRef{Npub: id.Npub, EncryptedNsec: encryptedNsec} - if err := SavePrefs(prefs); err != nil { + p.VaultIdentity = &prefs.VaultIdentityRef{Npub: id.Npub, EncryptedNsec: encryptedNsec} + if err := prefs.Save(p); err != nil { return "", "", err } return id.Npub, id.PrivKeyHex, nil } // UnlockVaultIdentity decrypts the vault's private key with password. -func UnlockVaultIdentity(password string) (string, error) { - prefs, err := LoadPrefs() +func Unlock(password string) (string, error) { + p, err := prefs.Load() if err != nil { return "", err } - if prefs.VaultIdentity == nil { + if p.VaultIdentity == nil { return "", errors.New("no vault identity yet; save an identity with `ncli id` to create one") } - privHex, err := nip49.Decrypt(prefs.VaultIdentity.EncryptedNsec, password) + privHex, err := nip49.Decrypt(p.VaultIdentity.EncryptedNsec, password) if err != nil { return "", err // "decryption failed (bad password?)" -- already AEAD-authenticated } @@ -116,7 +117,7 @@ func UnlockVaultIdentity(password string) (string, error) { if err != nil { return "", err } - if npub != prefs.VaultIdentity.Npub { + if npub != p.VaultIdentity.Npub { return "", errors.New("vault identity corrupted: decrypted key does not match stored npub") } @@ -125,8 +126,8 @@ func UnlockVaultIdentity(password string) (string, error) { // LoadVaultEntries reads vault.yaml, returning a nil slice (not an error) // if it doesn't exist yet. -func LoadVaultEntries() ([]VaultEntry, error) { - path := VaultPath() +func LoadEntries() ([]Entry, error) { + path := Path() data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -143,8 +144,8 @@ func LoadVaultEntries() ([]VaultEntry, error) { // SaveVaultEntries writes entries to vault.yaml, creating its parent // directory if needed. -func SaveVaultEntries(entries []VaultEntry) error { - path := VaultPath() +func SaveEntries(entries []Entry) error { + path := Path() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } @@ -156,12 +157,12 @@ func SaveVaultEntries(entries []VaultEntry) error { return os.WriteFile(path, data, 0600) } -// AddVaultEntry encrypts entryPrivKeyHex under a key derived from the +// AddEntry encrypts entryPrivKeyHex under a key derived from the // (already-unlocked) vault private key and that entry's own public key, // then appends and saves it as a new vault entry. A blank label defaults // to the entry's own npub (guaranteed unique), so leaving the label prompt // empty never blocks a save. -func AddVaultEntry(vaultPrivKeyHex, label, entryPrivKeyHex string) (*VaultEntry, error) { +func AddEntry(vaultPrivKeyHex, label, entryPrivKeyHex string) (*Entry, error) { entryPubHex, err := utils.GetPublicKey(entryPrivKeyHex) if err != nil { return nil, fmt.Errorf("invalid identity private key: %w", err) @@ -176,7 +177,7 @@ func AddVaultEntry(vaultPrivKeyHex, label, entryPrivKeyHex string) (*VaultEntry, label = npub } - entries, err := LoadVaultEntries() + entries, err := LoadEntries() if err != nil { return nil, err } @@ -195,22 +196,22 @@ func AddVaultEntry(vaultPrivKeyHex, label, entryPrivKeyHex string) (*VaultEntry, return nil, fmt.Errorf("failed to encrypt identity key: %w", err) } - entry := VaultEntry{ + entry := Entry{ Label: label, Npub: npub, EncryptedNsec: encryptedNsec, CreatedAt: time.Now().UTC().Format(time.RFC3339), } entries = append(entries, entry) - if err := SaveVaultEntries(entries); err != nil { + if err := SaveEntries(entries); err != nil { return nil, err } return &entry, nil } -// DecryptVaultEntry reverses AddVaultEntry, given the already-unlocked +// DecryptEntry reverses AddEntry, given the already-unlocked // vault private key. -func DecryptVaultEntry(vaultPrivKeyHex string, entry VaultEntry) (string, error) { +func DecryptEntry(vaultPrivKeyHex string, entry Entry) (string, error) { convKey, err := deriveEntryConversationKey(vaultPrivKeyHex, entry.Npub) if err != nil { return "", err @@ -218,15 +219,15 @@ func DecryptVaultEntry(vaultPrivKeyHex string, entry VaultEntry) (string, error) return nip44.Decrypt(entry.EncryptedNsec, convKey) } -// FindVaultEntry looks up a vault entry by exact label (case-insensitive) +// FindEntry looks up a vault entry by exact label (case-insensitive) // first, then by npub or hex pubkey. -func FindVaultEntry(labelOrNpub string) (*VaultEntry, bool, error) { +func FindEntry(labelOrNpub string) (*Entry, bool, error) { trimmed := strings.TrimSpace(labelOrNpub) if trimmed == "" { return nil, false, nil } - entries, err := LoadVaultEntries() + entries, err := LoadEntries() if err != nil { return nil, false, err } @@ -237,7 +238,7 @@ func FindVaultEntry(labelOrNpub string) (*VaultEntry, bool, error) { } } - targetHex := strings.ToLower(common.NormalizeKey(trimmed)) + targetHex := strings.ToLower(nip19.NormalizeToHex(trimmed)) for i := range entries { entryHex, err := nip19.DecodePublicKey(entries[i].Npub) if err != nil { @@ -253,8 +254,8 @@ func FindVaultEntry(labelOrNpub string) (*VaultEntry, bool, error) { // deriveEntryConversationKey computes the NIP-44 conversation key shared by // the vault's private key and an entry's own public key. ECDH is -// symmetric, so this is called identically at encrypt time (AddVaultEntry) -// and decrypt time (DecryptVaultEntry). +// symmetric, so this is called identically at encrypt time (AddEntry) +// and decrypt time (DecryptEntry). func deriveEntryConversationKey(vaultPrivKeyHex, entryNpub string) ([]byte, error) { privBytes, err := hex.DecodeString(vaultPrivKeyHex) if err != nil { From 1225b41a70b7e2482691fbc68eceabf1da30e1de Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:52:27 +0000 Subject: [PATCH 2/2] docs: add the PR backreference to the changelog (#60) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 680dd3b..db8df76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ that wants the vault or the relay list without the rest of `client`. Importing them pulls 10 modules instead of 38 -- no TUI, no viper, no bbolt. `client` re-exports every previous name unchanged, so existing - code needs no edits. + code needs no edits. (#60) ### Changed