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
This commit is contained in:
2026-09-06 16:53:22 -05:00
parent ee52682499
commit 0dcf41a839
5 changed files with 177 additions and 28 deletions
+97 -8
View File
@@ -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 <name>")
}
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
}