diff --git a/CHANGELOG.md b/CHANGELOG.md index 2278af5..db8df76 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. (#60) ### 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 {