ci / vet (push) Successful in 1m2s
Rotation waves need per-field updates on login items without touching the rest (password/username/custom fields), and consumers need URI/ USERNAME/PASSWORD surfaced by env. Ticket: https://projects.knownelement.com/issues/829
651 lines
15 KiB
Go
651 lines
15 KiB
Go
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 <name> [--field KEY] print decrypted item (or one field / KEY=VALUE block)
|
|
// env <name> print KEY=VALUE lines for `eval`/sourcing
|
|
// set <name> [k=v ...] create/update a secure-note item from --file or inline k=v
|
|
// rm <name> 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)
|
|
}
|
|
if len(pc.URIs) > 0 {
|
|
fmt.Printf("export URI='%s'\n", pc.URIs[0])
|
|
}
|
|
if pc.Login.Username != "" {
|
|
fmt.Printf("export USERNAME='%s'\n", pc.Login.Username)
|
|
}
|
|
if pc.Login.Password != "" {
|
|
fmt.Printf("export PASSWORD='%s'\n", pc.Login.Password)
|
|
}
|
|
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 <name> [--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 <name>")
|
|
}
|
|
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 <name> [--file F] [k=v ...]")
|
|
}
|
|
err = cmdSet(rest[0], *file, rest[1:], *folder)
|
|
case "rm":
|
|
if len(os.Args) < 3 {
|
|
fatal("rm <name>")
|
|
}
|
|
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 "setfield":
|
|
if len(os.Args) < 5 {
|
|
fatal("setfield <name> <key> <value>")
|
|
}
|
|
err = cmdSetField(os.Args[2], os.Args[3], os.Args[4])
|
|
case "convert":
|
|
if len(os.Args) < 3 {
|
|
fatal("convert <name>")
|
|
}
|
|
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 <name> [--field KEY] decrypt item / field
|
|
env <name> print export KEY='VALUE' lines (sourcable)
|
|
set <name> [--file F] k=v create/update secure note with hidden fields
|
|
rm <name> 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
|
|
}
|
|
|
|
func urisOf(pc *PlainCipher) []string { return pc.URIs }
|
|
|
|
// cmdSetField surgically updates one key on an existing item: login.password,
|
|
// login.username, an existing custom field, or appends a new hidden field.
|
|
func cmdSetField(name, key, value 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
|
|
}
|
|
var cr *cipherRaw
|
|
for _, r := range sync.Ciphers {
|
|
var x cipherRaw
|
|
if json.Unmarshal(r, &x) == nil && x.ID != "" {
|
|
pc2, derr := DecryptCipher(c.UserSymKey, r)
|
|
if derr == nil && pc2.Name == name {
|
|
cr = &x
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if cr == nil {
|
|
return fmt.Errorf("item not found: %s", name)
|
|
}
|
|
ck, err := cipherKeyFor(c.UserSymKey, cr.Key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch strings.ToLower(key) {
|
|
case "password":
|
|
p, err := encStr(c.UserSymKey, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cr.Login == nil {
|
|
cr.Login = &CipherLogin{}
|
|
}
|
|
cr.Login.Password = &p
|
|
case "username":
|
|
p, err := encStr(c.UserSymKey, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cr.Login == nil {
|
|
cr.Login = &CipherLogin{}
|
|
}
|
|
cr.Login.Username = &p
|
|
default:
|
|
found := false
|
|
for i := range cr.Fields {
|
|
fn, derr := decStr(ck, ck, cr.Fields[i].Name)
|
|
if derr == nil && fn == key {
|
|
fv, err := encStr(c.UserSymKey, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cr.Fields[i].Value = &fv
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
fn, _ := encStr(c.UserSymKey, key)
|
|
fv, _ := encStr(c.UserSymKey, value)
|
|
cr.Fields = append(cr.Fields, CipherField{Type: 1, Name: &fn, Value: &fv})
|
|
}
|
|
}
|
|
payload := map[string]any{"type": cr.Type, "name": cr.Name}
|
|
if cr.Login != nil {
|
|
payload["login"] = cr.Login
|
|
}
|
|
if cr.Fields != nil {
|
|
payload["fields"] = cr.Fields
|
|
}
|
|
if cr.Notes != nil {
|
|
payload["notes"] = *cr.Notes
|
|
}
|
|
if _, err := c.EditCipher(cr.ID, payload); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("updated:", name, key)
|
|
return nil
|
|
}
|