smcli: login-item support + convert command (notes -> username/password/apikey)
ci / vet (push) Successful in 36s
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
This commit is contained in:
@@ -85,10 +85,10 @@ func truncate(s string, n int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type preloginResp struct {
|
type preloginResp struct {
|
||||||
KDF int `json:"kdf"`
|
KDF int `json:"kdf"`
|
||||||
KDFIterations uint32 `json:"kdfIterations"`
|
KDFIterations uint32 `json:"kdfIterations"`
|
||||||
KDFMemory uint32 `json:"kdfMemory"`
|
KDFMemory uint32 `json:"kdfMemory"`
|
||||||
KDFParallelism uint32 `json:"kdfParallelism"`
|
KDFParallelism uint32 `json:"kdfParallelism"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Prelogin() error {
|
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)
|
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()
|
||||||
|
|||||||
+97
-8
@@ -30,13 +30,13 @@ import (
|
|||||||
const stateVersion = 1
|
const stateVersion = 1
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
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"`
|
||||||
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"`
|
||||||
KDFParallel uint32 `json:"kdf_parallel"`
|
KDFParallel uint32 `json:"kdf_parallel"`
|
||||||
// MasterKey/StretchedKey/UserSymKey stored raw (hex) — file must be 0600.
|
// MasterKey/StretchedKey/UserSymKey stored raw (hex) — file must be 0600.
|
||||||
MasterKey string `json:"master_key"`
|
MasterKey string `json:"master_key"`
|
||||||
@@ -84,7 +84,7 @@ 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,
|
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},
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
@@ -109,7 +109,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,
|
||||||
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),
|
||||||
}
|
}
|
||||||
@@ -415,6 +415,11 @@ 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 "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 +453,87 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
+75
-13
@@ -25,17 +25,17 @@ type CipherLogin struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cipherRaw struct {
|
type cipherRaw struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OrganizationID *string `json:"organizationId"`
|
OrganizationID *string `json:"organizationId"`
|
||||||
Type int `json:"type"` // 1=login, 2=secureNote
|
Type int `json:"type"` // 1=login, 2=secureNote
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
Fields []CipherField `json:"fields,omitempty"`
|
Fields []CipherField `json:"fields,omitempty"`
|
||||||
Key *string `json:"key,omitempty"`
|
Key *string `json:"key,omitempty"`
|
||||||
Login *CipherLogin `json:"login,omitempty"`
|
Login *CipherLogin `json:"login,omitempty"`
|
||||||
SecureNote map[string]any `json:"secureNote,omitempty"`
|
SecureNote map[string]any `json:"secureNote,omitempty"`
|
||||||
DeletedDate *string `json:"deletedDate,omitempty"`
|
DeletedDate *string `json:"deletedDate,omitempty"`
|
||||||
Extra map[string]interface{} `json:"-"`
|
Extra map[string]interface{} `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// cipherKeyFor returns the 64B key to use for a cipher's data.
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
encFields = append(encFields, map[string]any{
|
encFields = append(encFields, map[string]any{
|
||||||
"type": f.Type,
|
"type": f.Type,
|
||||||
"name": fn,
|
"name": fn,
|
||||||
"value": fv,
|
"value": fv,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -201,3 +201,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
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
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:a0482ad7ebbfed58e0662fa99fc1ac9416d2542058db7df02907a9fe7f9e21e7
|
||||||
container_name: ukrrs-secretsmgr-cli
|
container_name: ukrrs-secretsmgr-cli
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
entrypoint: ["sleep", "infinity"]
|
entrypoint: ["sleep", "infinity"]
|
||||||
|
|||||||
Reference in New Issue
Block a user