305 lines
9.6 KiB
Python
305 lines
9.6 KiB
Python
# -*- 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,
|
|
)
|