diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index 835ea39..5d881b0 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -28,6 +28,7 @@ from db.accounts_repo import ( find_accounts, find_credentials, get_account_by_id, + get_active_lease_by_token, get_credential_by_id, get_platform_from_db, insert_account, @@ -42,6 +43,21 @@ from db.accounts_repo import ( update_account_session_status, update_credential_last_used, ) +from db.auth_strategy import ( + ALL_AUTH_STRATEGIES, + AUTH_STRATEGY_CLIENT_CERTIFICATE, + AUTH_STRATEGY_DEVICE_BOUND, + AUTH_STRATEGY_PASSWORD_PLUS_2FA, + AUTH_STRATEGY_PER_SESSION_MANUAL, + AUTH_STRATEGY_QR_CODE_MANUAL, + AUTH_STRATEGY_SSO_REDIRECT, +) +from db.credentials_repo import ( + CredentialNotEncryptedError, + insert_credential_encrypted, + list_credentials_by_account, + read_credential_plaintext, +) from db.connection import get_conn, init_db from util.constants import SKILL_SLUG from util.logging_config import get_skill_logger @@ -56,6 +72,8 @@ from util.runtime_paths import ( get_skill_data_dir, _runtime_paths_debug_text, ) +from service.crypto import CredentialDecryptError +from service.master_key import MasterKeyMissingError, is_master_key_available from service.secret_store import ( mask_secret, make_windows_credential_target, @@ -83,6 +101,20 @@ def _credential_ok_line(payload: dict) -> str: return json.dumps({"success": True, **payload}, ensure_ascii=False) +def _derive_session_persistent(auth_strategy: str) -> int: + """Derive default session_persistent from auth_strategy when CLI omits it.""" + if auth_strategy in ( + AUTH_STRATEGY_QR_CODE_MANUAL, + AUTH_STRATEGY_PASSWORD_PLUS_2FA, + AUTH_STRATEGY_DEVICE_BOUND, + AUTH_STRATEGY_SSO_REDIRECT, + ): + return 1 + if auth_strategy in (AUTH_STRATEGY_PER_SESSION_MANUAL, AUTH_STRATEGY_CLIENT_CERTIFICATE): + return 0 + return 1 + + def _insert_secret_holder_account( conn, *, @@ -222,6 +254,10 @@ def cmd_account_add_web( url: Optional[str], extra_json_str: Optional[str], profile_dir_override: Optional[str], + *, + auth_strategy: Optional[str] = None, + session_persistent: Optional[int] = None, + device_fingerprint: Optional[str] = None, ) -> None: """account add-web — create a web/RPA account record.""" log = get_skill_logger() @@ -263,7 +299,35 @@ def cmd_account_add_web( conn = get_conn() try: seed_platforms(conn) - resolved_auth = resolve_auth_strategy_for_platform(conn, key) + if auth_strategy is None: + resolved_auth = resolve_auth_strategy_for_platform(conn, key) + else: + if auth_strategy not in ALL_AUTH_STRATEGIES: + _say(_err_json( + "ERR_AUTH_STRATEGY_NOT_SUPPORTED", + f"不支持的 auth_strategy:{auth_strategy}", + )) + return + resolved_auth = auth_strategy + + if session_persistent is None: + sp = _derive_session_persistent(resolved_auth) + else: + if session_persistent not in (0, 1): + _say(_err_json( + "ERR_INVALID_SESSION_PERSISTENT", + "session_persistent 必须是 0 或 1。", + )) + return + sp = session_persistent + + if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND: + _say(_err_json( + "ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE", + "device_fingerprint 仅在 auth_strategy=device_bound 时可设置。", + )) + return + new_id = insert_account( conn=conn, platform_key=key, @@ -279,6 +343,8 @@ def cmd_account_add_web( extra_json=resolved_extra, now=now, auth_strategy=resolved_auth, + session_persistent=sp, + device_fingerprint=device_fingerprint, ) conn.commit() except Exception: @@ -302,6 +368,9 @@ def cmd_account_add_web( "provider_code": resolved_provider, "status": "active", "session_status": "unknown", + "auth_strategy": resolved_auth, + "session_persistent": sp, + "device_fingerprint": device_fingerprint, })) @@ -331,9 +400,9 @@ def cmd_account_add_secret( return storage_n = secret_storage.strip().lower() - if storage_n not in ("none", "env", "windows_credential"): + if storage_n not in ("none", "env", "windows_credential", "local_encrypted"): _say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED", - f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential")) + f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential / local_encrypted")) return # Validate storage/type combinations @@ -351,6 +420,123 @@ def cmd_account_add_secret( now = int(time.time()) safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}" + if storage_n == "local_encrypted": + if not is_master_key_available(): + _say(_err_json( + "ERR_MASTER_KEY_MISSING", + "master key 未初始化。先跑 `account init-master-key`。", + )) + return + if not secret_stdin: + _say(_err_json( + "ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN", + "local_encrypted 后端必须 --secret-stdin 输入密文,避免命令历史泄漏。", + )) + return + if credential_type not in ( + "password", + "api_key", + "api_token", + "bearer_token", + "refresh_token", + "oauth_client", + ): + _say(_err_json( + "ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED", + f"local_encrypted 不接受 credential_type={credential_type}(仅 secret 类)。", + )) + return + if secret_ref: + _say_err("[add-secret] WARNING: secret_ref ignored for local_encrypted (secret comes from stdin).") + + extra: dict = {} + if extra_json_str: + try: + extra = json.loads(extra_json_str) + except json.JSONDecodeError: + extra = {} + resolved_env = environment or "production" + resolved_role = role or "api" + extra["environment"] = resolved_env + extra["role"] = resolved_role + resolved_extra = json.dumps(extra, ensure_ascii=False) + resolved_url = (platform_spec.get("default_url") or "").strip() or None + resolved_provider = platform_spec.get("provider_code") or key + + plaintext = sys.stdin.readline().rstrip("\r\n") + if not plaintext: + _say(_err_json("ERR_LOCAL_ENCRYPTED_EMPTY_STDIN", "stdin 没读到密码内容。")) + return + secret_mask_out = mask_secret(plaintext) + + conn = get_conn() + cred_id: Optional[int] = None + holder_id: Optional[int] = None + try: + seed_platforms(conn) + holder_id = account_id + if holder_id is not None: + acc = get_account_by_id(holder_id) + if not acc: + _say(_err_json( + "ERROR:ACCOUNT_NOT_FOUND", + f"account_id={holder_id} 不存在。", + )) + return + if holder_id is None: + holder_id = _insert_secret_holder_account( + conn, + platform_key=key, + account_label=safe_label, + environment=resolved_env, + role=resolved_role, + provider_code=resolved_provider, + url=resolved_url, + now=now, + ) + cred_id = insert_credential_encrypted( + conn, + account_id=holder_id, + platform_key=key, + credential_label=safe_label, + credential_type=credential_type, + plaintext=plaintext, + expires_at=None, + extra_json=resolved_extra, + now=now, + ) + conn.commit() + except MasterKeyMissingError as e: + conn.rollback() + _say(_err_json("ERR_MASTER_KEY_MISSING", str(e))) + return + except CredentialDecryptError as e: + conn.rollback() + _say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e))) + return + except Exception: + conn.rollback() + log.exception("account_add_secret_failure") + _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) + return + finally: + conn.close() + + log.info( + "account_add_secret_encrypted_success cred_id=%s account_id=%s", + cred_id, + holder_id, + ) + _say(_ok_json({ + "credential_id": cred_id, + "account_id": holder_id, + "platform_key": key, + "credential_type": credential_type, + "secret_storage": "local_encrypted", + "secret_mask": secret_mask_out, + })) + return + # For env storage: just use the env var name as ref, mask is optional if storage_n == "env": secret_ref_value = secret_ref.strip() @@ -593,6 +779,121 @@ def cmd_account_get(account_id, include_credentials: bool = False) -> None: _say(json.dumps(acc, ensure_ascii=False)) +def cmd_account_get_credential( + account_id: int, + *, + reveal: bool = False, + lease_token: Optional[str] = None, +) -> None: + """account get-credential — metadata or plaintext when lease proves access.""" + log = get_skill_logger() + log.info("cli_start subcommand=account_get_credential id=%s reveal=%s", account_id, reveal) + + init_db() + acc = get_account_by_id(account_id) + if not acc: + log.warning("account_get_credential_not_found id=%s", account_id) + _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) + return + + conn = get_conn() + try: + creds_all = list_credentials_by_account(conn, account_id) + creds = [c for c in creds_all if (c.get("status") or "").strip().lower() == "active"] + if not creds: + _say(_err_json( + "ERR_CREDENTIAL_MISSING", + f"账号 id={account_id} 没有活跃凭据。", + )) + return + cred = creds[0] + + base_out = { + "success": True, + "account_id": account_id, + "credential_id": cred["id"], + "credential_type": cred["credential_type"], + "secret_storage": cred["secret_storage"], + "secret_mask": cred["secret_mask"], + } + + if not reveal: + _say(json.dumps(base_out, ensure_ascii=False)) + return + + tok = (lease_token or "").strip() + if not tok: + log.warning("account_get_credential_reveal_missing_token id=%s", account_id) + _say(_err_json( + "ERR_LEASE_INVALID", + "reveal 必须带 --lease-token。", + )) + return + + lease = get_active_lease_by_token(conn, tok) + lease_prefix = tok[:8] + if lease is None: + log.warning( + "account_get_credential_reveal_bad_lease id=%s lease_prefix=%s", + account_id, + lease_prefix, + ) + _say(_err_json( + "ERR_LEASE_INVALID", + "lease token 无效或已过期", + )) + return + + if int(lease["account_id"]) != int(account_id): + log.warning( + "account_get_credential_reveal_lease_mismatch id=%s lease_account=%s lease_prefix=%s", + account_id, + lease["account_id"], + lease_prefix, + ) + _say(_err_json( + "ERR_LEASE_INVALID", + "lease token 不属于该 account", + )) + return + + try: + pt = read_credential_plaintext(conn, int(cred["id"])) + except MasterKeyMissingError as e: + log.warning("account_get_credential_master_missing id=%s", account_id) + _say(_err_json("ERR_MASTER_KEY_MISSING", str(e))) + return + except CredentialDecryptError as e: + log.warning( + "account_get_credential_decrypt_failed id=%s cred_id=%s lease_prefix=%s", + account_id, + cred["id"], + lease_prefix, + ) + _say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e))) + return + except CredentialNotEncryptedError: + out = {**base_out, "plaintext": None, "note": "credential storage=none, no plaintext to reveal"} + log.info( + "account_get_credential_reveal_note_plain id=%s cred_id=%s lease_prefix=%s", + account_id, + cred["id"], + lease_prefix, + ) + _say(json.dumps(out, ensure_ascii=False)) + return + + log.info( + "account_get_credential_reveal_success account_id=%s cred_id=%s lease_prefix=%s", + account_id, + cred["id"], + lease_prefix, + ) + _say(json.dumps({**base_out, "plaintext": pt}, ensure_ascii=False)) + finally: + conn.close() + + # Legacy alias def cmd_get(account_id) -> None: """Legacy: get — same as account get .""" @@ -711,6 +1012,9 @@ def cmd_account_pick_web( "role": acc["role"], "provider_code": acc["provider_code"], "session_status": acc["session_status"], + "auth_strategy": acc["auth_strategy"], + "session_persistent": acc["session_persistent"], + "device_fingerprint": acc.get("device_fingerprint"), } # Lease handling