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
451 lines
10 KiB
Go
451 lines
10 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)
|
|
}
|
|
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
|
|
`)
|
|
}
|