diff --git a/cli/cmd/smcli/api.go b/cli/cmd/smcli/api.go index 226d3e2..0c8fc43 100644 --- a/cli/cmd/smcli/api.go +++ b/cli/cmd/smcli/api.go @@ -17,12 +17,15 @@ import ( ) type Client struct { - Server string // e.g. https://pwvault.turnsys.com - HTTP *http.Client - Email string - Password string + Server string // e.g. https://pwvault.turnsys.com + HTTP *http.Client + Email string + Password string + TOTPSecret string + reloginDone bool AccessToken string + RefreshToken string KDFType int KDFIter uint32 KDFMemory uint32 @@ -72,11 +75,49 @@ func (c *Client) api(method, path string, body any, auth bool) ([]byte, error) { return nil, err } 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, 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 { if len(s) <= n { return s @@ -141,7 +182,7 @@ func (c *Client) Login() error { if t.AccessToken == "" { // 2FA retry path (provider 0 = authenticator TOTP) if strings.Contains(string(out), "Two factor required") { - secret := os.Getenv("SM_TOTP_SECRET") + secret := c.TOTPSecret if secret != "" { code, terr := totpNow(secret, time.Now()) if terr != nil { @@ -170,6 +211,7 @@ func (c *Client) Login() error { return fmt.Errorf("login failed: %s", truncate(payload, 300)) } c.AccessToken = t.AccessToken + c.RefreshToken = t.RefreshTok return nil } @@ -287,3 +329,31 @@ func (c *Client) DeleteCipher(id string) error { _, _ = c.api("PUT", "/api/ciphers/"+id+"/purge", map[string]any{}, true) 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 +} diff --git a/cli/cmd/smcli/main.go b/cli/cmd/smcli/main.go index c31e87e..246254a 100644 --- a/cli/cmd/smcli/main.go +++ b/cli/cmd/smcli/main.go @@ -30,14 +30,15 @@ import ( 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"` + Version int `json:"version"` + Server string `json:"server"` + Email string `json:"email"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + 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"` @@ -54,6 +55,18 @@ func stateDir() string { 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 { if err := os.MkdirAll(stateDir(), 0o700); err != nil { return err @@ -83,8 +96,9 @@ func loadState() (*State, error) { 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, + 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, HTTP: &http.Client{Timeout: 30 * time.Second}, } var err error @@ -101,15 +115,16 @@ func newClientFromState(s *State) (*Client, 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}} 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, + AccessToken: c.AccessToken, + RefreshToken: c.RefreshToken, + KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel, MasterKey: toHex(c.MasterKey), StretchedKey: toHex(c.StretchedKey), } diff --git a/cli/smcli b/cli/smcli index f42fb64..777587b 100755 Binary files a/cli/smcli and b/cli/smcli differ diff --git a/docker/compose.yaml b/docker/compose.yaml index 9272a78..a4318d9 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -10,7 +10,7 @@ name: knel-secretsmanager services: smcli: - image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:46d80c0a0ef53a9303dd54b3799a64a61cd4e4dc892282bc4d8a220378117ce9 + image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:8abfc55dfa7ca9e70b286a249b7ca823531bd55da29bed70d3354a58ec4d8fec container_name: ukrrs-secretsmgr-cli restart: unless-stopped entrypoint: ["sleep", "infinity"]