diff --git a/cli/cmd/smcli/api.go b/cli/cmd/smcli/api.go index d3d04d0..226d3e2 100644 --- a/cli/cmd/smcli/api.go +++ b/cli/cmd/smcli/api.go @@ -85,10 +85,10 @@ func truncate(s string, n int) string { } type preloginResp struct { - KDF int `json:"kdf"` - KDFIterations uint32 `json:"kdfIterations"` - KDFMemory uint32 `json:"kdfMemory"` - KDFParallelism uint32 `json:"kdfParallelism"` + KDF int `json:"kdf"` + KDFIterations uint32 `json:"kdfIterations"` + KDFMemory uint32 `json:"kdfMemory"` + KDFParallelism uint32 `json:"kdfParallelism"` } func (c *Client) Prelogin() error { @@ -225,8 +225,6 @@ func (c *Client) EditCipher(id string, cipherJSON any) ([]byte, error) { return c.api("PUT", "/api/ciphers/"+id, cipherJSON, true) } - - func deviceID() string { // stable per machine: hash of hostname (no secrets involved) hn := hostnameSafe() diff --git a/cli/cmd/smcli/main.go b/cli/cmd/smcli/main.go index 75f208a..ed36dd5 100644 --- a/cli/cmd/smcli/main.go +++ b/cli/cmd/smcli/main.go @@ -30,13 +30,13 @@ import ( const stateVersion = 1 type State struct { - Version int `json:"version"` - Server string `json:"server"` - Email string `json:"email"` + 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"` + 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"` @@ -84,7 +84,7 @@ 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, + KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel, HTTP: &http.Client{Timeout: 30 * time.Second}, } var err error @@ -109,7 +109,7 @@ func cmdLogin(server, email, password string) error { s := &State{ Version: stateVersion, Server: server, Email: email, AccessToken: c.AccessToken, - 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), StretchedKey: toHex(c.StretchedKey), } @@ -415,6 +415,11 @@ func main() { for _, f := range sync.Folders { fmt.Println(f.ID, f.Name) } + case "convert": + if len(os.Args) < 3 { + fatal("convert ") + } + err = cmdConvert(os.Args[2]) default: usage() os.Exit(1) @@ -448,3 +453,87 @@ func usage() { 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 +} diff --git a/cli/cmd/smcli/model.go b/cli/cmd/smcli/model.go index 3d253a3..ea46012 100644 --- a/cli/cmd/smcli/model.go +++ b/cli/cmd/smcli/model.go @@ -25,17 +25,17 @@ type CipherLogin struct { } type cipherRaw struct { - ID string `json:"id"` - OrganizationID *string `json:"organizationId"` - Type int `json:"type"` // 1=login, 2=secureNote - Name string `json:"name"` - Notes *string `json:"notes"` - Fields []CipherField `json:"fields,omitempty"` - Key *string `json:"key,omitempty"` - Login *CipherLogin `json:"login,omitempty"` - SecureNote map[string]any `json:"secureNote,omitempty"` - DeletedDate *string `json:"deletedDate,omitempty"` - Extra map[string]interface{} `json:"-"` + ID string `json:"id"` + OrganizationID *string `json:"organizationId"` + Type int `json:"type"` // 1=login, 2=secureNote + Name string `json:"name"` + Notes *string `json:"notes"` + Fields []CipherField `json:"fields,omitempty"` + Key *string `json:"key,omitempty"` + Login *CipherLogin `json:"login,omitempty"` + SecureNote map[string]any `json:"secureNote,omitempty"` + DeletedDate *string `json:"deletedDate,omitempty"` + Extra map[string]interface{} `json:"-"` } // cipherKeyFor returns the 64B key to use for a cipher's data. @@ -158,8 +158,8 @@ func BuildSecureNoteJSON(userKey []byte, name, folderID, notes string, fields [] return nil, err } encFields = append(encFields, map[string]any{ - "type": f.Type, - "name": fn, + "type": f.Type, + "name": fn, "value": fv, }) } @@ -201,3 +201,65 @@ func parseEnvFields(text string) []PlainField { } 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 +} diff --git a/cli/smcli b/cli/smcli index 33eac24..cf1af7c 100755 Binary files a/cli/smcli and b/cli/smcli differ diff --git a/docker/compose.yaml b/docker/compose.yaml index 567093f..36b4499 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:41fe9bf298ba6d338ee658cf84f9914f7d607bfb27232c8deb2479c22ed0545d + image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:a0482ad7ebbfed58e0662fa99fc1ac9416d2542058db7df02907a9fe7f9e21e7 container_name: ukrrs-secretsmgr-cli restart: unless-stopped entrypoint: ["sleep", "infinity"]