package main // smcli — KNELSecretsManager Go CLI (pure Go; replaces the upstream Rust bw binary). // Speaks the Bitwarden/Vaultwarden API against the self-hosted vault. // // Commands: // login authenticate (SM_EMAIL/SM_PASSWORD/SM_SERVER env or flags) // status show auth/key state // list [pattern] list item names // get [--field KEY] print decrypted item (or one field / KEY=VALUE block) // env print KEY=VALUE lines for `eval`/sourcing // set [k=v ...] create/update a secure-note item from --file or inline k=v // rm delete item // folders list folders // // State: SM_STATE_DIR (default ~/.config/smcli), files 0600. import ( "encoding/json" "errors" "flag" "fmt" "net/http" "os" "path/filepath" "strings" "time" ) const stateVersion = 1 type State struct { Version int `json:"version"` Server string `json:"server"` Email string `json:"email"` AccessToken string `json:"access_token"` KDFType int `json:"kdf_type"` KDFIter uint32 `json:"kdf_iter"` KDFMemory uint32 `json:"kdf_memory"` KDFParallel uint32 `json:"kdf_parallel"` // MasterKey/StretchedKey/UserSymKey stored raw (hex) — file must be 0600. MasterKey string `json:"master_key"` StretchedKey string `json:"stretched_key"` UserSymKey string `json:"user_sym_key"` } func stateDir() string { if v := os.Getenv("SM_STATE_DIR"); v != "" { return v } home, _ := os.UserHomeDir() return filepath.Join(home, ".config", "smcli") } func statePath() string { return filepath.Join(stateDir(), "state.json") } func saveState(s *State) error { if err := os.MkdirAll(stateDir(), 0o700); err != nil { return err } b, err := json.Marshal(s) if err != nil { return err } return os.WriteFile(statePath(), b, 0o600) } func loadState() (*State, error) { b, err := os.ReadFile(statePath()) if err != nil { return nil, err } var s State if err := json.Unmarshal(b, &s); err != nil { return nil, err } if s.Version != stateVersion { return nil, errors.New("state version mismatch; re-login") } return &s, nil } func newClientFromState(s *State) (*Client, error) { c := &Client{ Server: s.Server, Email: s.Email, AccessToken: s.AccessToken, KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel, HTTP: &http.Client{Timeout: 30 * time.Second}, } var err error if c.MasterKey, err = fromHex(s.MasterKey); err != nil { return nil, err } if c.StretchedKey, err = fromHex(s.StretchedKey); err != nil { return nil, err } if c.UserSymKey, err = fromHex(s.UserSymKey); err != nil { return nil, err } return c, nil } func cmdLogin(server, email, password string) error { c := &Client{Server: server, Email: email, Password: password, HTTP: &http.Client{Timeout: 30 * time.Second}} if err := c.Login(); err != nil { return err } s := &State{ Version: stateVersion, Server: server, Email: email, AccessToken: c.AccessToken, KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel, MasterKey: toHex(c.MasterKey), StretchedKey: toHex(c.StretchedKey), } if err := saveState(s); err != nil { return err } // immediately decrypt the user sym key if err := c.Unlock(); err != nil { return err } s.UserSymKey = toHex(c.UserSymKey) return saveState(s) } func cmdList(pattern string) error { s, err := loadState() if err != nil { return err } c, err := newClientFromState(s) if err != nil { return err } raw, err := c.Sync() if err != nil { return err } var sync struct { Ciphers []json.RawMessage `json:"ciphers"` } if err := json.Unmarshal(raw, &sync); err != nil { return err } for _, r := range sync.Ciphers { pc, err := DecryptCipher(c.UserSymKey, r) if err != nil { continue } if pattern == "" || strings.Contains(strings.ToLower(pc.Name), strings.ToLower(pattern)) { fmt.Println(pc.Name) } } return nil } func findCipher(c *Client, userKey []byte, name string) (*PlainCipher, json.RawMessage, error) { raw, err := c.Sync() if err != nil { return nil, nil, err } var sync struct { Ciphers []json.RawMessage `json:"ciphers"` } if err := json.Unmarshal(raw, &sync); err != nil { return nil, nil, err } for _, r := range sync.Ciphers { pc, err := DecryptCipher(userKey, r) if err != nil || pc.Name != name { continue } return pc, r, nil } return nil, nil, errors.New("item not found: " + name) } func rawCipherByID(raw []byte, id string) (*cipherRaw, error) { var sync struct { Ciphers []json.RawMessage `json:"ciphers"` } if err := json.Unmarshal(raw, &sync); err != nil { return nil, err } for _, r := range sync.Ciphers { var cr cipherRaw if err := json.Unmarshal(r, &cr); err != nil { continue } if cr.ID == id { return &cr, nil } } return nil, errors.New("cipher id not found: " + id) } func cmdGet(name, field string) error { s, err := loadState() if err != nil { return err } c, err := newClientFromState(s) if err != nil { return err } pc, _, err := findCipher(c, c.UserSymKey, name) if err != nil { return err } if field != "" { for _, f := range pc.Fields { if f.Name == field { fmt.Println(f.Value) return nil } } if field == "password" { fmt.Println(pc.Login.Password) return nil } if field == "username" { fmt.Println(pc.Login.Username) return nil } if field == "notes" { fmt.Print(pc.Notes) return nil } return fmt.Errorf("field not found: %s", field) } out, _ := json.MarshalIndent(pc, "", " ") fmt.Println(string(out)) return nil } func cmdEnv(name string) error { s, err := loadState() if err != nil { return err } c, err := newClientFromState(s) if err != nil { return err } pc, _, err := findCipher(c, c.UserSymKey, name) if err != nil { return err } for _, f := range pc.Fields { v := strings.ReplaceAll(f.Value, "'", "'\\''") fmt.Printf("export %s='%s'\n", f.Name, v) } return nil } func cmdSet(name, file string, kv []string, folderID string) error { s, err := loadState() if err != nil { return err } c, err := newClientFromState(s) if err != nil { return err } var fields []PlainField if file != "" { b, err := os.ReadFile(file) if err != nil { return err } fields = parseEnvFields(string(b)) } for _, kv := range kv { eq := strings.Index(kv, "=") if eq <= 0 { return fmt.Errorf("bad k=v: %s", kv) } fields = append(fields, PlainField{Type: 1, Name: kv[:eq], Value: kv[eq+1:]}) } raw, err := c.Sync() if err != nil { return err } pc, _, err := findCipher(c, c.UserSymKey, name) exists := err == nil var payload map[string]any if exists { var cr *cipherRaw cr, err = rawCipherByID(raw, pc.ID) if err != nil { return err } payload, err = BuildSecureNoteJSON(c.UserSymKey, name, folderID, pc.Notes, fields, cr) if err != nil { return err } _, err = c.EditCipher(pc.ID, payload) } else { payload, err = BuildSecureNoteJSON(c.UserSymKey, name, folderID, "", fields, nil) if err != nil { return err } _, err = c.CreateCipher(payload) } if err != nil { return err } fmt.Println("ok:", name) return nil } func cmdRm(name string) error { s, err := loadState() if err != nil { return err } c, err := newClientFromState(s) if err != nil { return err } pc, raw, err := findCipher(c, c.UserSymKey, name) if err != nil { return err } var parsed map[string]any _ = json.Unmarshal(raw, &parsed) id, _ := parsed["id"].(string) _ = pc fmt.Println("deleted:", name) return c.DeleteCipher(id) } func main() { if len(os.Args) < 2 { usage() os.Exit(1) } var err error switch os.Args[1] { case "login": fs := flag.NewFlagSet("login", flag.ExitOnError) server := fs.String("server", envOr("SM_SERVER", "https://pwvault.turnsys.com"), "vault server") email := fs.String("email", os.Getenv("SM_EMAIL"), "account email") password := fs.String("password", os.Getenv("SM_PASSWORD"), "master password (prefer env)") _ = fs.Parse(os.Args[2:]) if *email == "" || *password == "" { fatal("login needs SM_EMAIL and SM_PASSWORD (or -email/-password)") } err = cmdLogin(*server, *email, *password) case "status": s, e := loadState() if e != nil { fmt.Println("locked/absent:", e) return } fmt.Printf("server=%s email=%s unlocked=%v\n", s.Server, s.Email, s.UserSymKey != "") case "list": pat := "" if len(os.Args) > 2 { pat = os.Args[2] } err = cmdList(pat) case "get": if len(os.Args) < 3 { fatal("get [--field KEY]") } field := "" for i, a := range os.Args { if a == "--field" && i+1 < len(os.Args) { field = os.Args[i+1] } } err = cmdGet(os.Args[2], field) case "env": if len(os.Args) < 3 { fatal("env ") } err = cmdEnv(os.Args[2]) case "set": fs := flag.NewFlagSet("set", flag.ExitOnError) file := fs.String("file", "", "env file with KEY=VALUE lines") folder := fs.String("folder", "", "folder id") _ = fs.Parse(os.Args[2:]) rest := fs.Args() if len(rest) < 1 { fatal("set [--file F] [k=v ...]") } err = cmdSet(rest[0], *file, rest[1:], *folder) case "rm": if len(os.Args) < 3 { fatal("rm ") } err = cmdRm(os.Args[2]) case "folders": s, e := loadState() if e != nil { fatal(e) } c, e := newClientFromState(s) if e != nil { fatal(e) } raw, e := c.Sync() if e != nil { fatal(e) } var sync struct { Folders []struct { ID string `json:"id"` Name string `json:"name"` } `json:"folders"` } _ = json.Unmarshal(raw, &sync) for _, f := range sync.Folders { fmt.Println(f.ID, f.Name) } case "convert": if len(os.Args) < 3 { fatal("convert ") } err = cmdConvert(os.Args[2]) default: usage() os.Exit(1) } if err != nil { fatal(err) } } func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func fatal(v any) { fmt.Fprintln(os.Stderr, "smcli:", v) os.Exit(1) } func usage() { fmt.Fprint(os.Stderr, `smcli — KNELSecretsManager Go CLI (Bitwarden/Vaultwarden) login authenticate (SM_EMAIL/SM_PASSWORD/SM_SERVER) status auth/key state list [pattern] list item names get [--field KEY] decrypt item / field env print export KEY='VALUE' lines (sourcable) set [--file F] k=v create/update secure note with hidden fields rm delete item folders list folders `) } // classifyCreds maps lifted env fields to login-item structure. func classifyCreds(fields []PlainField) (username, password string, uris []string, rest []PlainField) { used := map[int]bool{} pick := func(res []string) (PlainField, bool) { for i, f := range fields { if used[i] { continue } up := strings.ToUpper(f.Name) for _, re := range res { if strings.Contains(up, re) && f.Value != "" { used[i] = true return f, true } } } return PlainField{}, false } if f, ok := pick([]string{"USERNAME", "USER", "EMAIL", "LOGIN", "AUTH_ID"}); ok { username = f.Value } if f, ok := pick([]string{"PASSWORD", "PASS", "TOKEN", "SECRET", "APIKEY", "API_KEY", "KEY", "HASH", "REFRESH"}); ok { password = f.Value } for i, f := range fields { up := strings.ToUpper(f.Name) if !used[i] && (strings.HasSuffix(up, "URL") || strings.HasSuffix(up, "URI") || strings.HasSuffix(up, "DASH") || strings.Contains(up, "ENDPOINT")) && f.Value != "" { used[i] = true uris = append(uris, f.Value) } } for i, f := range fields { if used[i] || f.Value == "" { continue } up := strings.ToUpper(f.Name) hidden := strings.Contains(up, "PASS") || strings.Contains(up, "TOKEN") || strings.Contains(up, "SECRET") || strings.Contains(up, "KEY") || strings.Contains(up, "HASH") t := 0 if hidden { t = 1 } rest = append(rest, PlainField{Type: t, Name: f.Name, Value: f.Value}) } return } func cmdConvert(name string) error { s, err := loadState() if err != nil { return err } c, err := newClientFromState(s) if err != nil { return err } pc, raw, err := findCipher(c, c.UserSymKey, name) if err != nil { return err } all := append([]PlainField{}, pc.Fields...) var folderID string var parsed map[string]any _ = json.Unmarshal(raw, &parsed) if v, ok := parsed["folderId"].(string); ok { folderID = v } username, password, uris, rest := classifyCreds(all) payload, err := BuildLoginJSON(c.UserSymKey, name, folderID, username, password, uris, rest, nil) if err != nil { return err } if _, err := c.CreateCipher(payload); err != nil { return err } // remove the old note var parsedOld map[string]any _ = json.Unmarshal(raw, &parsedOld) if id, ok := parsedOld["id"].(string); ok { _ = c.DeleteCipher(id) } fmt.Printf("converted: %s (user=%v pass=%v uris=%d fields=%d)\n", name, username != "", password != "", len(uris), len(rest)) return nil }