feat(cli): add init-master-key / export-credentials / import-credentials commands
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,11 @@ Machine commands output single-line JSON where appropriate.
|
|||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from service.account_crypto_commands import (
|
||||||
|
cmd_account_export_credentials,
|
||||||
|
cmd_account_import_credentials,
|
||||||
|
cmd_account_init_master_key,
|
||||||
|
)
|
||||||
from service.account_service import (
|
from service.account_service import (
|
||||||
cmd_account_add_secret,
|
cmd_account_add_secret,
|
||||||
cmd_account_add_web,
|
cmd_account_add_web,
|
||||||
@@ -211,6 +216,12 @@ def main() -> None:
|
|||||||
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
|
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cmd_open(av[3])
|
cmd_open(av[3])
|
||||||
|
elif sub == "init-master-key":
|
||||||
|
cmd_account_init_master_key(av)
|
||||||
|
elif sub == "export-credentials":
|
||||||
|
cmd_account_export_credentials(av)
|
||||||
|
elif sub == "import-credentials":
|
||||||
|
cmd_account_import_credentials(av)
|
||||||
else:
|
else:
|
||||||
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
|
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
|
||||||
print(_USAGE)
|
print(_USAGE)
|
||||||
|
|||||||
304
scripts/service/account_crypto_commands.py
Normal file
304
scripts/service/account_crypto_commands.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""CLI handlers for master key init and credential export/import (stage B)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from db.accounts_repo import _unix_to_iso_local
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
from db.credentials_repo import insert_credential_encrypted
|
||||||
|
from service.crypto import CredentialDecryptError, decrypt_secret
|
||||||
|
from service.master_key import (
|
||||||
|
ENV_MASTER_KEY,
|
||||||
|
MasterKeyExistsError,
|
||||||
|
MasterKeyInvalidError,
|
||||||
|
generate_master_key,
|
||||||
|
normalize_master_key_input,
|
||||||
|
write_master_key_file,
|
||||||
|
)
|
||||||
|
from service.secret_store import mask_secret, read_secret
|
||||||
|
|
||||||
|
|
||||||
|
def _has_flag(argv: list[str], flag: str) -> bool:
|
||||||
|
return flag in argv
|
||||||
|
|
||||||
|
|
||||||
|
def _get_opt(argv: list[str], flag: str, default=None):
|
||||||
|
if flag in argv:
|
||||||
|
idx = argv.index(flag)
|
||||||
|
if idx + 1 < len(argv):
|
||||||
|
return argv[idx + 1]
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _key_preview(key_bytes: bytes) -> str:
|
||||||
|
s = key_bytes.strip().decode("ascii")
|
||||||
|
if len(s) <= 8:
|
||||||
|
return "****"
|
||||||
|
return s[:4] + "****" + s[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_init_master_key(argv: list[str]) -> None:
|
||||||
|
"""account init-master-key [--from-env] [--print] [--rotate]"""
|
||||||
|
from_env = _has_flag(argv, "--from-env")
|
||||||
|
do_print = _has_flag(argv, "--print")
|
||||||
|
rotate = _has_flag(argv, "--rotate")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if from_env:
|
||||||
|
raw = (os.environ.get(ENV_MASTER_KEY) or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise MasterKeyInvalidError("ACCOUNT_MANAGER_MASTER_KEY is not set")
|
||||||
|
key = normalize_master_key_input(raw)
|
||||||
|
else:
|
||||||
|
key = generate_master_key()
|
||||||
|
|
||||||
|
if rotate:
|
||||||
|
print(
|
||||||
|
"[init-master-key] WARNING: --rotate overwrites master.key; "
|
||||||
|
"ensure you have an export backup.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
path_written: Optional[str] = None
|
||||||
|
if not do_print:
|
||||||
|
path_written = write_master_key_file(key, allow_overwrite=rotate)
|
||||||
|
|
||||||
|
if do_print:
|
||||||
|
line = json.dumps(
|
||||||
|
{"success": True, "key": key.decode("ascii")},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
print(line)
|
||||||
|
return
|
||||||
|
|
||||||
|
assert path_written is not None
|
||||||
|
preview = _key_preview(key)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"success": True, "path": path_written, "key_preview": preview},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except MasterKeyExistsError as e:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": {"code": e.code, "message": str(e)},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except MasterKeyInvalidError as e:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": {"code": e.code, "message": str(e)},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_export_credentials(argv: list[str]) -> None:
|
||||||
|
"""account export-credentials --plaintext"""
|
||||||
|
if not _has_flag(argv, "--plaintext"):
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": "ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG",
|
||||||
|
"message": "export requires explicit --plaintext acknowledgement",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
"[export] WARNING: plaintext credentials written to stdout. Handle with extreme care.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, account_id, platform_key, credential_label, credential_type,
|
||||||
|
secret_storage, secret_ref, secret_mask, secret_ciphertext,
|
||||||
|
expires_at, extra_json, created_at, updated_at
|
||||||
|
FROM credentials
|
||||||
|
ORDER BY id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for r in rows:
|
||||||
|
cid = r[0]
|
||||||
|
storage = (r[5] or "").strip().lower()
|
||||||
|
secret_ref = r[6] or ""
|
||||||
|
secret_mask = r[7] or ""
|
||||||
|
ct = r[8]
|
||||||
|
extra_raw = r[10]
|
||||||
|
try:
|
||||||
|
extra_obj = json.loads(extra_raw) if isinstance(extra_raw, str) else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
extra_obj = {}
|
||||||
|
|
||||||
|
plaintext: Optional[str] = None
|
||||||
|
extra_out = dict(extra_obj)
|
||||||
|
|
||||||
|
if storage == "none":
|
||||||
|
plaintext = None
|
||||||
|
elif storage == "local_encrypted":
|
||||||
|
try:
|
||||||
|
if not ct:
|
||||||
|
raise CredentialDecryptError("missing ciphertext")
|
||||||
|
plaintext = decrypt_secret(str(ct))
|
||||||
|
except CredentialDecryptError as e:
|
||||||
|
print(
|
||||||
|
f"[export] decrypt_failed id={cid} code={e.code} msg={e}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
plaintext = None
|
||||||
|
elif storage == "env":
|
||||||
|
val = os.environ.get(secret_ref)
|
||||||
|
if val is None:
|
||||||
|
plaintext = None
|
||||||
|
extra_out["export_note"] = "env var missing at export time"
|
||||||
|
else:
|
||||||
|
plaintext = val
|
||||||
|
elif storage == "windows_credential":
|
||||||
|
try:
|
||||||
|
plaintext = read_secret("windows_credential", secret_ref)
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
plaintext = None
|
||||||
|
extra_out["export_note"] = f"windows_credential read failed: {e}"
|
||||||
|
else:
|
||||||
|
extra_out["export_note"] = f"unsupported storage at export: {storage}"
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"id": cid,
|
||||||
|
"account_id": r[1],
|
||||||
|
"platform_key": r[2],
|
||||||
|
"credential_label": r[3],
|
||||||
|
"credential_type": r[4],
|
||||||
|
"secret_storage": r[5],
|
||||||
|
"plaintext": plaintext,
|
||||||
|
"secret_mask": secret_mask,
|
||||||
|
"expires_at": _unix_to_iso_local(r[9]),
|
||||||
|
"extra_json": extra_out,
|
||||||
|
"created_at": _unix_to_iso_local(r[11]),
|
||||||
|
"updated_at": _unix_to_iso_local(r[12]),
|
||||||
|
}
|
||||||
|
out.append(item)
|
||||||
|
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_maybe_to_unix(val: Any) -> Optional[int]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
return int(val)
|
||||||
|
s = str(val).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s)
|
||||||
|
return int(dt.timestamp())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_import_credentials(argv: list[str]) -> None:
|
||||||
|
"""account import-credentials [--replace-all] < stdin JSON array"""
|
||||||
|
replace_all = _has_flag(argv, "--replace-all")
|
||||||
|
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"[import] fatal: invalid JSON stdin: {e}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
print("[import] fatal: stdin JSON must be an array", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
inserted = 0
|
||||||
|
skipped = 0
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
if replace_all:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM credentials WHERE secret_storage = ?",
|
||||||
|
("local_encrypted",),
|
||||||
|
)
|
||||||
|
now = int(time.time())
|
||||||
|
for idx, row in enumerate(data):
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
print(f"[import] skip line {idx}: not an object", file=sys.stderr)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
pt = row.get("plaintext")
|
||||||
|
if pt is None or str(pt).strip() == "":
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
plat = str(row.get("platform_key") or "").strip()
|
||||||
|
label = str(row.get("credential_label") or "").strip()
|
||||||
|
ctype = str(row.get("credential_type") or "").strip()
|
||||||
|
if not plat or not label or not ctype:
|
||||||
|
raise ValueError("missing platform_key/credential_label/credential_type")
|
||||||
|
|
||||||
|
account_id = row.get("account_id")
|
||||||
|
aid = int(account_id) if account_id is not None else None
|
||||||
|
|
||||||
|
expires_at = _iso_maybe_to_unix(row.get("expires_at"))
|
||||||
|
ex = row.get("extra_json")
|
||||||
|
if isinstance(ex, dict):
|
||||||
|
extra_json = json.dumps(ex, ensure_ascii=False)
|
||||||
|
elif isinstance(ex, str):
|
||||||
|
extra_json = ex
|
||||||
|
else:
|
||||||
|
extra_json = "{}"
|
||||||
|
|
||||||
|
insert_credential_encrypted(
|
||||||
|
conn,
|
||||||
|
account_id=aid,
|
||||||
|
platform_key=plat,
|
||||||
|
credential_label=label,
|
||||||
|
credential_type=ctype,
|
||||||
|
plaintext=str(pt),
|
||||||
|
expires_at=expires_at,
|
||||||
|
extra_json=extra_json,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[import] skip line {idx}: {e}", file=sys.stderr)
|
||||||
|
skipped += 1
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[import] inserted {inserted} credentials, skipped {skipped} lines (no plaintext).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user