pure-Go smcli: Bitwarden/Vaultwarden client replacing upstream Rust bw
ci / vet (pull_request) Failing after 12s
ci / vet (pull_request) Failing after 12s
Full client-side crypto (PBKDF2/Argon2id master key, HKDF stretch, AES-256-CBC+HMAC encstrings), password grant with TOTP 2FA, sync, list/get/env/set/rm. Containerized (alpine, non-root), CI = gofmt/vet/ build/secret-scan, compose service ukrrs-secretsmgr-cli. Rust-era scripts archived. Live-validated against pwvault.turnsys.com. Ticket: https://projects.knownelement.com/issues/832
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
`)
|
||||
}
|
||||
Reference in New Issue
Block a user