Author SHA1 Message Date
ic-builder cf4094b9e4 smcli: self-healing sessions — refresh-token grant + auto-relogin
ci / vet (pull_request) Successful in 1m9s
Vault access tokens expire (~10min); consumers hit 401s after container
recreates. Now: refresh_token persisted at login and rotated on every
refresh (Vaultwarden semantics); 401 on an authed call refreshes once
and retries; if refresh is unavailable, full relogin (password+TOTP
injected by the sm shims from the TSGCOO vault-account env) is attempted
before failing. cmdLogin passes the TOTP seed again (regression).
QA: corrupted access_token -> TSGCOO sm read self-healed end-to-end.
Ticket: https://projects.knownelement.com/issues/832
2026-09-07 09:43:50 -05:00
vpengops e067eee330 smcli: refresh-token support - sessions no longer die after ~1h
The login grant requested offline_access but the issued refresh_token
was parsed and discarded: every access token died with the ~1h
Vaultwarden TTL, and consumers (all lanes) hit HTTP 401 on sync until
a human re-logged in.

- persist refresh_token in state (0600, same file)
- add refresh grant (grant_type=refresh_token, rotated token saved)
- on 401 for authed calls: refresh once, retry the request
- persistTokens() keeps the rest of the state intact

Build verified in golang:1.23-alpine (vet + gofmt clean). After
deploy, one `sm login` issues a refresh token (~30d, rotated on use)
and sessions self-heal from then on.
2026-09-06 22:44:33 -05:00
ic-builder 9e0cf6aa33 docs: production architecture (mermaid), KNELBMS integration, README
ci / vet (push) Successful in 1m15s
ci / vet (pull_request) Successful in 1m7s
Component + sequence + rotation-flow diagrams; consumer pattern; KNELBMS
secrets-flow table + provisioning sequence; README to prod standard.
Ticket: https://projects.knownelement.com/issues/832
2026-09-06 19:03:32 -05:00
ic-builder 68ba03c1af smcli setfield: surgical single-field rotation updates; env exposes login triple
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
2026-09-06 18:52:34 -05:00
ic-builder 907ddb6000 smcli env: emit URI/USERNAME/PASSWORD for consumers; pin v5
ci / vet (push) Successful in 55s
convert of login items keeps original env names in fields; env now also
exposes the login triple so sourced consumers can map to legacy names.
Ticket: https://projects.knownelement.com/issues/832
2026-09-06 17:23:22 -05:00
ic-builder 0dcf41a839 smcli: login-item support + convert command (notes -> username/password/apikey)
ci / vet (push) Successful in 36s
Founder follow-up: lifted items restructured to proper login items
(first-class username/password/URIs, remainder as named custom fields).
Classified from the env-key semantics; zero data loss.
Ticket: https://projects.knownelement.com/issues/829
2026-09-06 16:53:22 -05:00
9 changed files with 590 additions and 42 deletions
+41 -3
View File
@@ -1,5 +1,43 @@
# KNELSecretsManager (ARCHIVED — moved to KNEL/secrets) # KNELSecretsManager
This body of work moved to **[KNEL/secrets](https://git.knownelement.com/KNEL/secrets)** per the 2026-09-03 repo split ([#769](https://projects.knownelement.com/issues/769)); content was ported as `legacy-knelsecretsmanager/` (secret-scanned clean — placeholders only). Fleet secrets management: a **pure-Go Bitwarden/Vaultwarden CLI** (`smcli`)
in a house container, backed by the self-hosted TSGCOO vault. No upstream
Rust `bw` binary, no Node runtime, no `.creds` text files — those patterns
are retired (ADR-003; founder rulings #829/#832).
This repo is historical. New secrets-management work happens in KNEL/secrets (#770). - Docs: [docs/architecture.md](docs/architecture.md) (diagrams, crypto, rotation program)
- KNELBMS integration: [docs/integration-knelbms.md](docs/integration-knelbms.md)
- Redmine: https://projects.knownelement.com/issues/832 (build) / #829 (migration+rotation)
## Quick start (lane)
```bash
# TSGCOO account (COO-area chats; docker group, no sudo)
/data2/TSGCOO/.local/bin/sm status
# reachableceo crossover
~/projects/KNEL/OAM/.tools/sm env creds/cloudron # export URI/USERNAME/PASSWORD + keys
~/projects/KNEL/OAM/.tools/sm get creds/librenms --field password
~/projects/KNEL/OAM/.tools/sm setfield creds/<item> <KEY> <newvalue> # rotation updates
```
## Layout
| Path | What |
|---|---|
| `cli/cmd/smcli/` | the Go CLI (crypto, API, commands) |
| `docker/Dockerfile.cli` | golang build → alpine runtime (CA certs, non-root) |
| `docker/compose.yaml` | always-hot service `ukrrs-secretsmgr-cli` (digest-pinned) |
| `archive/rust-bw-era/` | retired upstream-binary wrapper scripts |
| `docs/ADR-003-GoCLI.md` | decision record |
## Rules (binding)
- Secrets live ONLY in the TSGCOO Bitwarden vault, accessed ONLY via this
CLI (container `ukrrs-secretsmgr-cli`, shims above). No textfile creds,
no upstream bw CLI — anywhere.
- All work product is authored by Cloudron account identities
(ic-builder / ic-reviewer / manager-tsg / vptechops); the founder
account (ReachableCEO) reviews and approves.
- Production-affecting rotations follow the CR gating + cross-linking
house rules (GLPI CR deep link in the PR/ticket; evidence on solve).
+71 -3
View File
@@ -21,8 +21,11 @@ type Client struct {
HTTP *http.Client HTTP *http.Client
Email string Email string
Password string Password string
TOTPSecret string
reloginDone bool
AccessToken string AccessToken string
RefreshToken string
KDFType int KDFType int
KDFIter uint32 KDFIter uint32
KDFMemory uint32 KDFMemory uint32
@@ -72,11 +75,49 @@ func (c *Client) api(method, path string, body any, auth bool) ([]byte, error) {
return nil, err return nil, err
} }
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
// access token expired: refresh once and retry (never for the
// identity endpoints themselves, which manage their own tokens)
if resp.StatusCode == 401 && auth && c.RefreshToken != "" && !strings.HasPrefix(path, "/identity/") {
if rerr := c.refresh(); rerr == nil {
return c.api(method, path, body, auth)
}
}
return out, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, truncate(string(out), 200)) return out, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, truncate(string(out), 200))
} }
return out, nil return out, nil
} }
// refresh exchanges the persisted refresh_token for a fresh access token
// (Vaultwarden rotates the refresh token on every use). Scope must match
// the original grant (api offline_access).
func (c *Client) refresh() error {
if c.RefreshToken == "" {
return errors.New("no refresh token in state; re-login required")
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", c.RefreshToken)
form.Set("client_id", "cli")
form.Set("scope", "api offline_access")
out, err := c.apiRaw("POST", "/identity/connect/token", form, false)
if err != nil {
return fmt.Errorf("refresh: %w", err)
}
var t tokenResp
if err := json.Unmarshal(out, &t); err != nil {
return err
}
if t.AccessToken == "" {
return fmt.Errorf("refresh failed: %s", truncate(string(out), 200))
}
c.AccessToken = t.AccessToken
if t.RefreshTok != "" {
c.RefreshToken = t.RefreshTok
}
persistTokens(c)
return nil
}
func truncate(s string, n int) string { func truncate(s string, n int) string {
if len(s) <= n { if len(s) <= n {
return s return s
@@ -141,7 +182,7 @@ func (c *Client) Login() error {
if t.AccessToken == "" { if t.AccessToken == "" {
// 2FA retry path (provider 0 = authenticator TOTP) // 2FA retry path (provider 0 = authenticator TOTP)
if strings.Contains(string(out), "Two factor required") { if strings.Contains(string(out), "Two factor required") {
secret := os.Getenv("SM_TOTP_SECRET") secret := c.TOTPSecret
if secret != "" { if secret != "" {
code, terr := totpNow(secret, time.Now()) code, terr := totpNow(secret, time.Now())
if terr != nil { if terr != nil {
@@ -170,6 +211,7 @@ func (c *Client) Login() error {
return fmt.Errorf("login failed: %s", truncate(payload, 300)) return fmt.Errorf("login failed: %s", truncate(payload, 300))
} }
c.AccessToken = t.AccessToken c.AccessToken = t.AccessToken
c.RefreshToken = t.RefreshTok
return nil return nil
} }
@@ -225,8 +267,6 @@ func (c *Client) EditCipher(id string, cipherJSON any) ([]byte, error) {
return c.api("PUT", "/api/ciphers/"+id, cipherJSON, true) return c.api("PUT", "/api/ciphers/"+id, cipherJSON, true)
} }
func deviceID() string { func deviceID() string {
// stable per machine: hash of hostname (no secrets involved) // stable per machine: hash of hostname (no secrets involved)
hn := hostnameSafe() hn := hostnameSafe()
@@ -289,3 +329,31 @@ func (c *Client) DeleteCipher(id string) error {
_, _ = c.api("PUT", "/api/ciphers/"+id+"/purge", map[string]any{}, true) _, _ = c.api("PUT", "/api/ciphers/"+id+"/purge", map[string]any{}, true)
return nil return nil
} }
// selfRelogin performs the full login+unlock using SM_* env credentials
// (injected by the sm shims from the TSGCOO vault-account env). Saves state.
func (c *Client) selfRelogin() error {
if c.Password == "" {
c.Password = os.Getenv("SM_PASSWORD")
}
if c.TOTPSecret == "" {
c.TOTPSecret = os.Getenv("SM_TOTP_SECRET")
}
if c.Password == "" {
return errors.New("relogin unavailable: SM_PASSWORD not set")
}
if err := c.Login(); err != nil {
return err
}
if err := c.Unlock(); err != nil {
return err
}
if s, err := loadState(); err == nil {
s.AccessToken = c.AccessToken
s.UserSymKey = toHex(c.UserSymKey)
s.StretchedKey = toHex(c.StretchedKey)
s.MasterKey = toHex(c.MasterKey)
_ = saveState(s)
}
return nil
}
+217 -2
View File
@@ -34,6 +34,7 @@ type State struct {
Server string `json:"server"` Server string `json:"server"`
Email string `json:"email"` Email string `json:"email"`
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
KDFType int `json:"kdf_type"` KDFType int `json:"kdf_type"`
KDFIter uint32 `json:"kdf_iter"` KDFIter uint32 `json:"kdf_iter"`
KDFMemory uint32 `json:"kdf_memory"` KDFMemory uint32 `json:"kdf_memory"`
@@ -54,6 +55,18 @@ func stateDir() string {
func statePath() string { return filepath.Join(stateDir(), "state.json") } func statePath() string { return filepath.Join(stateDir(), "state.json") }
// persistTokens updates just the token pair in the existing state file
// after a successful refresh (called from api.go refresh()).
func persistTokens(c *Client) {
s, err := loadState()
if err != nil {
return // no readable state; tokens stay in-memory for this run
}
s.AccessToken = c.AccessToken
s.RefreshToken = c.RefreshToken
_ = saveState(s)
}
func saveState(s *State) error { func saveState(s *State) error {
if err := os.MkdirAll(stateDir(), 0o700); err != nil { if err := os.MkdirAll(stateDir(), 0o700); err != nil {
return err return err
@@ -83,7 +96,8 @@ func loadState() (*State, error) {
func newClientFromState(s *State) (*Client, error) { func newClientFromState(s *State) (*Client, error) {
c := &Client{ c := &Client{
Server: s.Server, Email: s.Email, Server: s.Server, Email: s.Email,
AccessToken: s.AccessToken, Password: os.Getenv("SM_PASSWORD"), TOTPSecret: os.Getenv("SM_TOTP_SECRET"),
AccessToken: s.AccessToken, RefreshToken: s.RefreshToken,
KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel, KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel,
HTTP: &http.Client{Timeout: 30 * time.Second}, HTTP: &http.Client{Timeout: 30 * time.Second},
} }
@@ -101,7 +115,7 @@ func newClientFromState(s *State) (*Client, error) {
} }
func cmdLogin(server, email, password string) error { func cmdLogin(server, email, password string) error {
c := &Client{Server: server, Email: email, Password: password, c := &Client{Server: server, Email: email, Password: password, TOTPSecret: os.Getenv("SM_TOTP_SECRET"),
HTTP: &http.Client{Timeout: 30 * time.Second}} HTTP: &http.Client{Timeout: 30 * time.Second}}
if err := c.Login(); err != nil { if err := c.Login(); err != nil {
return err return err
@@ -109,6 +123,7 @@ func cmdLogin(server, email, password string) error {
s := &State{ s := &State{
Version: stateVersion, Server: server, Email: email, Version: stateVersion, Server: server, Email: email,
AccessToken: c.AccessToken, AccessToken: c.AccessToken,
RefreshToken: c.RefreshToken,
KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel, KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel,
MasterKey: toHex(c.MasterKey), MasterKey: toHex(c.MasterKey),
StretchedKey: toHex(c.StretchedKey), StretchedKey: toHex(c.StretchedKey),
@@ -251,6 +266,15 @@ func cmdEnv(name string) error {
v := strings.ReplaceAll(f.Value, "'", "'\\''") v := strings.ReplaceAll(f.Value, "'", "'\\''")
fmt.Printf("export %s='%s'\n", f.Name, v) 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 return nil
} }
@@ -415,6 +439,16 @@ func main() {
for _, f := range sync.Folders { for _, f := range sync.Folders {
fmt.Println(f.ID, f.Name) 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: default:
usage() usage()
os.Exit(1) os.Exit(1)
@@ -448,3 +482,184 @@ func usage() {
folders list folders 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
}
+75
View File
@@ -91,6 +91,7 @@ type PlainCipher struct {
Password string `json:"password"` Password string `json:"password"`
} `json:"login"` } `json:"login"`
FolderID string `json:"folderId"` FolderID string `json:"folderId"`
URIs []string `json:"uris"`
} }
// DecryptCipher converts a raw sync cipher into PlainCipher. // DecryptCipher converts a raw sync cipher into PlainCipher.
@@ -132,6 +133,18 @@ func DecryptCipher(userKey []byte, raw []byte) (*PlainCipher, error) {
if cr.Login.Password != nil { if cr.Login.Password != nil {
pc.Login.Password, _ = decStr(userKey, ck, cr.Login.Password) pc.Login.Password, _ = decStr(userKey, ck, cr.Login.Password)
} }
for _, u := range cr.Login.URIs {
var um map[string]any
um, _ = u.(map[string]any)
if um != nil {
if us, ok := um["uri"].(string); ok {
v, err := decStr(userKey, ck, &us)
if err == nil {
pc.URIs = append(pc.URIs, v)
}
}
}
}
} }
return pc, nil return pc, nil
} }
@@ -201,3 +214,65 @@ func parseEnvFields(text string) []PlainField {
} }
return out return out
} }
// BuildLoginJSON produces an encrypted login-item payload: username/password
// as first-class login fields, URIs, and the remaining keys as custom fields
// (hidden for secrets, text for URLs/IDs).
func BuildLoginJSON(userKey []byte, name, folderID, username, password string, uris []string, fields []PlainField, existing *cipherRaw) (map[string]any, error) {
nameEnc, err := encStr(userKey, name)
if err != nil {
return nil, err
}
login := map[string]any{}
if username != "" {
u, err := encStr(userKey, username)
if err != nil {
return nil, err
}
login["username"] = u
}
if password != "" {
p, err := encStr(userKey, password)
if err != nil {
return nil, err
}
login["password"] = p
}
if len(uris) > 0 {
var list []map[string]any
for _, u := range uris {
ue, err := encStr(userKey, u)
if err != nil {
return nil, err
}
list = append(list, map[string]any{"uri": ue})
}
login["uris"] = list
}
var encFields []map[string]any
for _, f := range fields {
fn, err := encStr(userKey, f.Name)
if err != nil {
return nil, err
}
fv, err := encStr(userKey, f.Value)
if err != nil {
return nil, err
}
encFields = append(encFields, map[string]any{"type": f.Type, "name": fn, "value": fv})
}
payload := map[string]any{"type": 1, "name": nameEnc, "login": login}
if len(encFields) > 0 {
payload["fields"] = encFields
}
if folderID != "" {
payload["folderId"] = folderID
}
if existing != nil {
payload["id"] = existing.ID
if existing.OrganizationID != nil {
payload["organizationId"] = existing.OrganizationID
}
}
return payload, nil
}
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -10,13 +10,13 @@
name: knel-secretsmanager name: knel-secretsmanager
services: services:
smcli: smcli:
image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:41fe9bf298ba6d338ee658cf84f9914f7d607bfb27232c8deb2479c22ed0545d image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:8abfc55dfa7ca9e70b286a249b7ca823531bd55da29bed70d3354a58ec4d8fec
container_name: ukrrs-secretsmgr-cli container_name: ukrrs-secretsmgr-cli
restart: unless-stopped restart: unless-stopped
entrypoint: ["sleep", "infinity"] entrypoint: ["sleep", "infinity"]
init: true init: true
env_file: env_file:
- path: /home/reachableceo/.creds/smcli.env - path: ./smcli.env
required: false required: false
environment: environment:
SM_STATE_DIR: /data/state SM_STATE_DIR: /data/state
+3
View File
@@ -0,0 +1,3 @@
SM_SERVER=https://pwvault.turnsys.com
SM_EMAIL=coo@turnsys.com
SM_STATE_DIR=/data/state
+99
View File
@@ -0,0 +1,99 @@
# KNELSecretsManager — Architecture
Status: production. Ruling chain: ADR-002 (containerized CLI) → ADR-003
(pure-Go `smcli`, Rust `bw` retired). Founder directives: #829/#832.
## Components
```mermaid
flowchart LR
subgraph vault["TSGCOO Bitwarden vault (self-hosted)"]
V["pwvault.turnsys.com\n(Vaultwarden API)"]
end
subgraph workstation["Workstation (dev-only host)"]
C["ukrrs-secretsmgr-cli\n(compose, always-hot)\npure-Go smcli v9+"]
S1["/data2/TSGCOO/.local/bin/sm\n(TSGCOO account entry)"]
S2[".tools/sm\n(reachableceo crossover)"]
W1["mred-vp → Redmine identity"]
W2["ci-green.sh → Gitea admin"]
W3["redmine-sweep.sh"]
end
subgraph fleet["Fleet consumers (rotation waves)"]
B["KNELBMS: on-box secrets.yaml\n(deploy webhook, gitea_auth_header,\nkuma_push_url, pve_*_api_token)"]
K["pfv-k8s secrets\n(glpi-creds for kuma-glpi-bridge,\nflux PAT — wave 4)"]
R["Nightly timers\n(pve-config-backup, glpi-reconcile)"]
end
S1 --> C
S2 --> C
W1 --> C
W2 --> C
W3 --> C
C -->|HTTPS: prelogin/login(TOTP)/sync/CRUD| V
C -.->|reads after rotation| B
C -.-> K
C -.-> R
```
## Auth + unlock sequence
```mermaid
sequenceDiagram
participant U as Operator/Consumer
participant S as smcli (container)
participant V as pwvault.turnsys.com
U->>S: smcli login (SM_EMAIL, SM_PASSWORD, SM_TOTP_SECRET)
S->>V: POST /api/accounts/prelogin {email}
V-->>S: kdf type + iterations (PBKDF2 600k / Argon2id)
S->>S: masterKey = KDF(password, email); authHash = PBKDF2(masterKey, password, 1)
S->>V: POST /identity/connect/token (password grant, device fields)
V-->>S: 400 Two factor required (provider 0)
S->>S: TOTP code from SM_TOTP_SECRET (RFC 6238)
S->>V: token request + twoFactorToken
V-->>S: access_token (+Key, PrivateKey)
S->>V: GET /api/sync
V-->>S: profile.key (enc, type 2)
S->>S: stretchedKey = HKDF(masterKey,"enc"/"mac"); userSymKey = decrypt(profile.key)
S->>S: state.json 0600 (master/stretched/user keys + token)
```
Item payloads are type-2 encStrings (AES-256-CBC + HMAC-SHA256, 32B enc +
32B mac keys) encrypted/decrypted locally; the vault never sees plaintext.
## Consumer pattern
```bash
# env-style consumers (export lines; URI/USERNAME/PASSWORD + original keys)
eval "$(docker exec -i ukrrs-secretsmgr-cli smcli env creds/<name>)"
# surgical single-field rotation update
docker exec -i ukrrs-secretsmgr-cli smcli setfield creds/<name> <KEY> <newvalue>
```
Items are LOGIN type: username + password/API-key as first-class fields,
service URLs as URIs, remaining keys as named custom fields (hidden where
secret). Original env key names are preserved as the custom-field names.
## Rotation program (#829)
All pre-migration material is presumed BURNED (plaintext on disk + LLM
exposure). Waves, each item = rotate at source → `setfield` in vault →
rewire consumers to `sm env` → validate (guard rule: never write the vault
from a failed rotation):
1. Tooling tokens (librenms*, wazuh ✓, grafana*, gvm, technitium, phpipam,
rancher-sectest, beszel, pihole, PMG pair, PBS tokens)
2. Agent identities (gitea agent-stack, vptechops gitea/redmine)
3. Platform (cloudron API token, kuma, discourse)
4. Deep-wired (flux PAT + gitea runner + HA on-box secrets.yaml + k8s
secrets + PVE upsagent tokens)
\* item-specific notes: librenms — token-mint API route absent on this
install (UI/DB path); grafana — SSO-managed, local admin parked (instance
password policy blocks CLI reset).
## KNELBMS integration
See [integration-knelbms.md](integration-knelbms.md). Short form: the BMS
deploy pipeline (KNELBMS repo, packages/deploy_pipeline.yaml) consumes
`gitea_auth_header` + `kuma_push_url` + `pve_*_api_token` from on-box
`/config/secrets.yaml`; the vault is the source of record and rotations
push to the box through the CR-gated provisioning path.
+50
View File
@@ -0,0 +1,50 @@
# KNELBMS integration — secrets flow
Repo: https://git.knownelement.com/KNEL/KNELBMS (PhysicalPlant lane;
Home Assistant BMS on VM 100 @ pfv-tsys1, dev→release deploy by git).
## What the BMS consumes
| On-box secret (HA `secrets.yaml`) | Vault item + field | Provenance |
|---|---|---|
| `gitea_auth_header` | `creds/pfv-bms-deploy` → GITEA_DEPLOY_WATCH_TOKEN | release-branch sha-watch REST sensor |
| `deploy_webhook_id` / `pfv_relay_webhook_id` | `creds/pfv-bms-deploy` | fast-path deploy webhook |
| `kuma_push_url` | `creds/pfv-bms-beta` sibling — dead-man monitor `pfv-bms-ha-heartbeat-2026-09` (push token) | rotated 2026-09-06 under CR 21 |
| `pve_tsys{1,3,4,5,6,7}_api_token` | `creds/pve-upsagent` (per-node fields) | `upsagent@pam!ups`, PVEAdmin-on-/vms, privsep=0 |
| `doorman_*`, `pfvbms_smb_*`, beta HA creds | respective `creds/*` items | as rotated |
## Provisioning + rotation flow
```mermaid
sequenceDiagram
participant R as Rotation run (#829 wave)
participant V as TSGCOO vault
participant B as pfv-bms on-box secrets.yaml
participant H as Home Assistant
R->>V: new value (sm setfield creds/<item> <KEY>)
R->>B: CR-gated provisioning (ssh -p 22222, CR + ha core check)
R->>H: ha core restart (Kuma window; dead-man covers the gap)
H-->>R: post-deploy validation (entity/heartbeat checks)
R->>V: rotation evidence on #829
```
Rules that bind this flow (house rules + #811):
- pfv-bms prod changes need a GLPI CR **and** a Kuma maintenance window
when a restart is involved; the deploy path itself stays
dev → CI → release PR (founder merges).
- HA runtime template contexts cannot read secrets — the on-box
`shell_command` entries reference `!secret` names only (see KNELBMS
PR #6 / CR 21 for the dead-man fix that taught us this).
- The dead-man heartbeat (`pfv-bms-ha-heartbeat-2026-09`, Kuma id 291)
is the canary for provisioning mistakes: if the on-box secret and the
vault disagree, the push fails and the monitor pages.
## Current integration state (2026-09-06)
- On-box `secrets.yaml` provisioned manually under CR 21 (PVE tokens +
rotated Kuma push URL); git-side KNELBMS matches for the `!secret`
keys it owns (PR #6 on dev, awaiting founder release merge).
- Automated push-from-vault (rotation waves writing the box directly via
the AWX ssh path) is **planned, not built** — wave 4. Until then the
table above is the manual runbook, executed under CR.