From 0f981a4b0778a423e440d03f245fdc4b11a35d73 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Tue, 5 May 2026 05:49:49 -0300 Subject: [PATCH] Fix project encryption and gateway vault CLI --- resources/scripts/nap_install.py | 31 +++- resources/scripts/napseer_mcp_server.py | 134 ++++++++++++++++-- scripts/nap_install.py | 31 +++- tests/test_nap_install_gateway_routes.py | 46 ++++++ .../test_project_encryption_enable_payload.py | 59 ++++++++ 5 files changed, 276 insertions(+), 25 deletions(-) create mode 100644 tests/test_nap_install_gateway_routes.py create mode 100644 tests/test_project_encryption_enable_payload.py diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index d6e851e..90ec204 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -607,6 +607,23 @@ def service_logs(kind, args): def handle_gateway(args): subcommand = args[0] if args else "ls" rest = args[1:] if args else [] + if subcommand in {"help", "-h", "--help"}: + print("Usage: nap gateway [start|stop|status|logs|configure|setup|unlock|lock|restart|kill|vault|terminal|schedule|service]") + print(" nap gateway start [--port PORT] Start the local gateway service.") + print(" nap gateway status Show the managed gateway process.") + print(" nap gateway configure [--command CMD] Configure the local gateway command.") + print(" nap gateway setup Create or unlock the encrypted gateway vault.") + print(" nap gateway vault Show gateway vault status and pending setup requests.") + print(" nap gateway vault process Complete pending setup requests.") + print(" nap gateway vault rotate-secret --kind chat|tabs|gateway") + return 0 + if subcommand in {"vault", "vault-setup", "vault_setup", "project-vault"} and rest and rest[0] in {"help", "-h", "--help"}: + print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway] [--all] [--project-id ID]") + print(" status Show configured/locked state and pending setup requests.") + print(" process Generate local secret material and send encrypted envelopes only.") + print(" rotate-secret Rotate one encrypted project secret.") + print(" vault-setup Deprecated alias for vault.") + return 0 if subcommand == "start": return print_json(start_service("gateway", ["--no-browser", *rest])) or 0 if subcommand == "stop": @@ -617,9 +634,9 @@ def handle_gateway(args): return print_json(service_logs("gateway", rest)) or 0 if subcommand in {"configure", "config"}: return run_wrapper("gateway", ["configure", *rest]) - if subcommand in {"setup", "unlock", "lock", "restart", "kill"}: + if subcommand in {"setup", "unlock", "lock", "restart", "kill", "vault", "vault-setup", "vault_setup", "project-vault", "terminal", "schedule", "service"}: return run_wrapper("gateway", [subcommand, *rest]) - raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|unlock|lock|restart|kill|logs|status") + raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|unlock|lock|restart|kill|logs|status|vault|terminal|schedule|service") def print_json(value): @@ -680,11 +697,13 @@ def main(argv): print("Usage: nap [auth|project|gateway|agent|feedback|status|update|where|install]") print(" auth Auth commands: login-local, status.") print(" Example: nap auth login-local") - print(" project Project commands: create, status, encryption, plaintext, encrypted.") - print(" Examples: nap project create, nap project status, nap project encryption set plaintext") - print(" gateway Gateway commands: start, stop, configure, setup, unlock, lock, restart, kill, logs, status.") + print(" project Project commands: create, status, encryption, encrypted, plaintext.") + print(" Examples: nap project create --encrypted, nap project encrypted, nap project plaintext") + print(" Project encryption uses a project passphrase; use --passphrase or NAPSEER_PROJECT_PASSPHRASE for automation.") + print(" Equivalent explicit form: nap project encryption set encrypted|plaintext") + print(" gateway Gateway commands: start, stop, configure, setup, unlock, lock, restart, kill, logs, status, vault.") print(" Start auto-selects a local port; use --port to pin one.") - print(" Examples: nap gateway start, nap gateway start --port 8767, nap gateway logs") + print(" Examples: nap gateway start, nap gateway vault, nap gateway vault rotate-secret --kind chat") print(" agent Agent commands: list, workspaces, create, show, edit.") print(" list shows registered project agents; workspaces shows /agents folders.") print(" feedback Feedback commands: list, global, status, resolve.") diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 90d69d3..73a886d 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -477,6 +477,7 @@ GATEWAY_SCHEDULES_SCHEMA = "napseer.gateway-schedules.v1" CONTAINER_IDENTITY_SCHEMA = "napseer.container-identity.v1" GATEWAY_RELAY_SECRET_ITERATIONS = int(os.environ.get("NAPSEER_GATEWAY_RELAY_SECRET_ITERATIONS", "210000")) VAULT_KDF_ITERATIONS = int(os.environ.get("NAPSEER_VAULT_KDF_ITERATIONS", "600000")) +PROJECT_KEY_KDF_ITERATIONS = int(os.environ.get("NAPSEER_PROJECT_KEY_KDF_ITERATIONS", "600000")) GATEWAY_IDLE_TIMEOUT_SECONDS = int(os.environ.get("NAPSEER_GATEWAY_IDLE_TIMEOUT_SECONDS", "900")) GATEWAY_MAX_LOG_BYTES = int(os.environ.get("NAPSEER_GATEWAY_MAX_LOG_BYTES", str(5 * 1024 * 1024))) GATEWAY_PROJECT_SECRET_KINDS = ("chat", "tabs", "gateway") @@ -682,6 +683,23 @@ def read_master_passphrase(create=False): return first +def read_project_passphrase(create=False): + value = os.environ.get("NAPSEER_PROJECT_PASSPHRASE") or os.environ.get("NAPSEER_MASTER_PASSPHRASE") + if value: + return value + if not sys.stdin.isatty(): + raise RuntimeError( + "Project encryption needs a local wrapping passphrase. " + "Pass --passphrase TEXT or set NAPSEER_PROJECT_PASSPHRASE." + ) + first = getpass.getpass("Create project encryption passphrase: " if create else "Project encryption passphrase: ") + if create: + second = getpass.getpass("Confirm project encryption passphrase: ") + if first != second: + raise RuntimeError("project encryption passphrases did not match") + return first + + def vault_exists(): return VAULT_PATH.exists() @@ -3042,6 +3060,46 @@ def gateway_vault_master_wrap_aad(project_id, credential_id): return f"napseer.vault-master-wrap.v1:{project_id}:{credential_id}".encode("utf-8") +def project_key_wrap_aad(project_id, credential_id): + return f"napseer.project-key-wrap.v1:{project_id}:{credential_id}".encode("utf-8") + + +def local_wrapped_project_key(project_id, passphrase=None, project_key=None): + project_id = str(project_id or current_project_id()).strip() + if not project_id: + raise RuntimeError("project_id is required") + passphrase = passphrase or read_project_passphrase(create=True) + if not isinstance(passphrase, str) or not passphrase: + raise RuntimeError("project encryption passphrase is required") + project_key = project_key or secrets.token_bytes(32) + credential_id = f"local-passphrase:{AUTH.get('agent_id') or 'local'}" + salt = secrets.token_bytes(16) + nonce = secrets.token_bytes(12) + wrapping_key = hashlib.pbkdf2_hmac( + "sha256", + passphrase.encode("utf-8"), + salt, + PROJECT_KEY_KDF_ITERATIONS, + dklen=32, + ) + aad = project_key_wrap_aad(project_id, credential_id) + packed = aes_gcm_encrypt(wrapping_key, nonce, aad, project_key) + return { + "credential_id": credential_id, + "wrapped_project_key_b64": base64.b64encode(packed).decode("ascii"), + "wrap_alg": "AES-GCM-256", + "kdf_alg": "PBKDF2-SHA256", + "salt_b64": base64.b64encode(salt).decode("ascii"), + "kdf_params": { + "source": "local_passphrase", + "iterations": PROJECT_KEY_KDF_ITERATIONS, + "aad_hash": hashlib.sha256(aad).hexdigest(), + "nonce_b64": base64.b64encode(nonce).decode("ascii"), + "runtime": "nap-cli", + }, + } + + def gateway_project_secret_envelope(project_id, secret_kind, version, vault_master_secret, secret_material): nonce = secrets.token_bytes(12) aad = gateway_project_secret_aad(project_id, secret_kind, version) @@ -3138,6 +3196,27 @@ def gateway_vault_setup_requests(args=None): return request_json("GET", f"/v1/projects/{project_id}/vault/setup-requests") +def gateway_vault_status(args=None): + args = args or {} + project_id = args.get("project_id") or current_project_id() + requests = gateway_vault_setup_requests({"project_id": project_id}) + items = requests.get("items", []) + pending = [item for item in items if item.get("status") == "pending"] + return { + "status": "ok", + "project_id": project_id, + "locked": not gateway_is_unlocked(), + "vault_configured": vault_exists(), + "pending_setup_requests": len(pending), + "items": items, + "message": ( + "No pending gateway vault setup requests." + if not pending + else f"{len(pending)} pending gateway vault setup request(s). Run `nap gateway vault process` to complete them." + ), + } + + def gateway_complete_vault_setup_request(setup_request, project_id=None): require_unlocked("completing project vault setup") project_id = str(project_id or setup_request.get("project_id") or current_project_id()) @@ -3164,12 +3243,22 @@ def gateway_complete_vault_setup_request(setup_request, project_id=None): def gateway_process_vault_setup_requests(args=None): args = args or {} - require_unlocked("processing project vault setup requests") project_id = args.get("project_id") or current_project_id() items = gateway_vault_setup_requests({"project_id": project_id}).get("items", []) + pending = [item for item in items if item.get("status") == "pending"] + if not pending: + return { + "status": "noop", + "project_id": project_id, + "completed_count": 0, + "completed": [], + "skipped": [{"id": item.get("id"), "status": item.get("status")} for item in items if item.get("status") != "pending"], + "message": "No pending gateway vault setup requests.", + } + require_unlocked("processing project vault setup requests") completed = [] skipped = [] - for item in items: + for item in pending: if item.get("status") != "pending": skipped.append({"id": item.get("id"), "status": item.get("status")}) continue @@ -5863,10 +5952,11 @@ def bootstrap_project(args): project, project_status, message = create_project_with_state(args) encryption_state = "plaintext" if encryption == "encrypted": + wrapped_project_key = local_wrapped_project_key(project["id"], passphrase=args.get("passphrase")) status = request_project_write( "POST", f"/v1/projects/{project['id']}/encryption/enable", - {"state": "encrypted"}, + {"state": "encrypted", "wrapped_project_key": wrapped_project_key}, project["id"], "enable project encryption", ) @@ -5915,6 +6005,8 @@ def project_encryption_transition(args=None): project_id = resolve_project_id(args) operation = "enable" if desired == "encrypted" else "disable" payload = {"state": desired} + if desired == "encrypted": + payload["wrapped_project_key"] = local_wrapped_project_key(project_id, passphrase=args.get("passphrase")) result = request_project_write( "POST", f"/v1/projects/{project_id}/encryption/{operation}", @@ -9666,17 +9758,26 @@ def cli_main(argv): if action == "set": if len(argv) <= 4: raise RuntimeError("nap project encryption set requires plaintext or encrypted") - print(json.dumps(project_encryption_transition({"state": argv[4]}), indent=2)) + print(json.dumps(project_encryption_transition({ + "state": argv[4], + "passphrase": cli_option(argv[5:], "--passphrase", default=None), + }), indent=2)) return - print(json.dumps(project_encryption_transition({"state": action}), indent=2)) + print(json.dumps(project_encryption_transition({ + "state": action, + "passphrase": cli_option(argv[4:], "--passphrase", default=None), + }), indent=2)) return if subcommand in {"plaintext", "plain", "unencrypt", "decrypt", "disable-encryption"}: print(json.dumps(project_encryption_transition({"state": "plaintext"}), indent=2)) return if subcommand in {"encrypted", "encrypt", "enable-encryption"}: - print(json.dumps(project_encryption_transition({"state": "encrypted"}), indent=2)) + print(json.dumps(project_encryption_transition({ + "state": "encrypted", + "passphrase": cli_option(argv[3:], "--passphrase", default=None), + }), indent=2)) return - print("Usage: napseer_mcp_server.py project [create [slug] [--name NAME] [--description TEXT] [--encrypted|--encryption plaintext|encrypted] [--passphrase TEXT]|claim [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set plaintext|set encrypted|plaintext|encrypted]|plaintext|encrypted]") + print("Usage: napseer_mcp_server.py project [create [slug] [--name NAME] [--description TEXT] [--encrypted|--encryption plaintext|encrypted] [--passphrase TEXT]|claim [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set plaintext|set encrypted|plaintext|encrypted] [--passphrase TEXT]|plaintext|encrypted [--passphrase TEXT]]") return if command == "gateway": subcommand = argv[2] if len(argv) > 2 else "status" @@ -9809,16 +9910,19 @@ def cli_main(argv): gateway_service_run(payload) return raise RuntimeError("unknown gateway service action; use preregister|activate|run") - if subcommand in {"vault-setup", "vault_setup", "project-vault"}: - action = args[0] if args else "process" + if subcommand in {"vault", "vault-setup", "vault_setup", "project-vault"}: + action = args[0] if args else "status" vault_args = args[1:] if args else [] + if action in {"help", "-h", "--help"}: + print("Usage: napseer_mcp_server.py gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway] [--all] [--project-id ID]") + return payload = { "project_id": cli_option(vault_args, "--project-id", default=None), "secret_kind": cli_option(vault_args, "--secret-kind", "--kind", default=None), "complete_all": cli_flag(vault_args, "--all", "--complete-all"), } if action in {"list", "ls", "status"}: - print(json.dumps(gateway_vault_setup_requests(payload), indent=2)) + print(json.dumps(gateway_vault_status(payload), indent=2)) return if action in {"process", "complete", "run"}: print(json.dumps(gateway_process_vault_setup_requests(payload), indent=2)) @@ -9828,7 +9932,7 @@ def cli_main(argv): payload["secret_kind"] = vault_args[0] if vault_args and not vault_args[0].startswith("-") else "chat" print(json.dumps(gateway_rotate_project_vault_secret(payload), indent=2)) return - raise RuntimeError("unknown gateway vault-setup action; use list|process") + raise RuntimeError("unknown gateway vault action; use status|list|process|rotate-secret") if subcommand == "setup": print(json.dumps(gateway_setup({ "passphrase": cli_option(args, "--passphrase", default=None), @@ -9846,7 +9950,7 @@ def cli_main(argv): if subcommand == "lock": print(json.dumps(gateway_lock(), indent=2)) return - print("Usage: napseer_mcp_server.py gateway [status|configure [--command CMD] [--target session:window.pane]|vault-setup [list|process] [--all]|terminal [list|create|close|capture|input]|schedule [list|create|update|delete|run]|restart [--passphrase TEXT]|kill [--passphrase TEXT]|setup|rotate-passphrase --new-passphrase TEXT|unlock|lock]") + print("Usage: napseer_mcp_server.py gateway [status|configure [--command CMD] [--target session:window.pane]|vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway] [--all]|terminal [list|create|close|capture|input]|schedule [list|create|update|delete|run]|restart [--passphrase TEXT]|kill [--passphrase TEXT]|setup|rotate-passphrase --new-passphrase TEXT|unlock|lock]") return if command == "feedback": subcommand = argv[2] if len(argv) > 2 else "list" @@ -9943,4 +10047,8 @@ def main(): if __name__ == "__main__": - cli_main(sys.argv) + try: + cli_main(sys.argv) + except Exception as exc: + print(json.dumps({"status": "error", "error": str(exc)}, indent=2), file=sys.stderr) + sys.exit(1) diff --git a/scripts/nap_install.py b/scripts/nap_install.py index d6e851e..90ec204 100644 --- a/scripts/nap_install.py +++ b/scripts/nap_install.py @@ -607,6 +607,23 @@ def service_logs(kind, args): def handle_gateway(args): subcommand = args[0] if args else "ls" rest = args[1:] if args else [] + if subcommand in {"help", "-h", "--help"}: + print("Usage: nap gateway [start|stop|status|logs|configure|setup|unlock|lock|restart|kill|vault|terminal|schedule|service]") + print(" nap gateway start [--port PORT] Start the local gateway service.") + print(" nap gateway status Show the managed gateway process.") + print(" nap gateway configure [--command CMD] Configure the local gateway command.") + print(" nap gateway setup Create or unlock the encrypted gateway vault.") + print(" nap gateway vault Show gateway vault status and pending setup requests.") + print(" nap gateway vault process Complete pending setup requests.") + print(" nap gateway vault rotate-secret --kind chat|tabs|gateway") + return 0 + if subcommand in {"vault", "vault-setup", "vault_setup", "project-vault"} and rest and rest[0] in {"help", "-h", "--help"}: + print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway] [--all] [--project-id ID]") + print(" status Show configured/locked state and pending setup requests.") + print(" process Generate local secret material and send encrypted envelopes only.") + print(" rotate-secret Rotate one encrypted project secret.") + print(" vault-setup Deprecated alias for vault.") + return 0 if subcommand == "start": return print_json(start_service("gateway", ["--no-browser", *rest])) or 0 if subcommand == "stop": @@ -617,9 +634,9 @@ def handle_gateway(args): return print_json(service_logs("gateway", rest)) or 0 if subcommand in {"configure", "config"}: return run_wrapper("gateway", ["configure", *rest]) - if subcommand in {"setup", "unlock", "lock", "restart", "kill"}: + if subcommand in {"setup", "unlock", "lock", "restart", "kill", "vault", "vault-setup", "vault_setup", "project-vault", "terminal", "schedule", "service"}: return run_wrapper("gateway", [subcommand, *rest]) - raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|unlock|lock|restart|kill|logs|status") + raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|unlock|lock|restart|kill|logs|status|vault|terminal|schedule|service") def print_json(value): @@ -680,11 +697,13 @@ def main(argv): print("Usage: nap [auth|project|gateway|agent|feedback|status|update|where|install]") print(" auth Auth commands: login-local, status.") print(" Example: nap auth login-local") - print(" project Project commands: create, status, encryption, plaintext, encrypted.") - print(" Examples: nap project create, nap project status, nap project encryption set plaintext") - print(" gateway Gateway commands: start, stop, configure, setup, unlock, lock, restart, kill, logs, status.") + print(" project Project commands: create, status, encryption, encrypted, plaintext.") + print(" Examples: nap project create --encrypted, nap project encrypted, nap project plaintext") + print(" Project encryption uses a project passphrase; use --passphrase or NAPSEER_PROJECT_PASSPHRASE for automation.") + print(" Equivalent explicit form: nap project encryption set encrypted|plaintext") + print(" gateway Gateway commands: start, stop, configure, setup, unlock, lock, restart, kill, logs, status, vault.") print(" Start auto-selects a local port; use --port to pin one.") - print(" Examples: nap gateway start, nap gateway start --port 8767, nap gateway logs") + print(" Examples: nap gateway start, nap gateway vault, nap gateway vault rotate-secret --kind chat") print(" agent Agent commands: list, workspaces, create, show, edit.") print(" list shows registered project agents; workspaces shows /agents folders.") print(" feedback Feedback commands: list, global, status, resolve.") diff --git a/tests/test_nap_install_gateway_routes.py b/tests/test_nap_install_gateway_routes.py new file mode 100644 index 0000000..d5cbee7 --- /dev/null +++ b/tests/test_nap_install_gateway_routes.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Smoke-test top-level nap gateway command routing.""" + +import importlib.util +import pathlib +import sys + + +def load_module(): + script_path = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "nap_install.py" + spec = importlib.util.spec_from_file_location("nap_install", script_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def run(): + mod = load_module() + calls = [] + + mod.run_wrapper = lambda command, args: calls.append((command, args)) or 0 + + assert mod.handle_gateway(["--help"]) == 0 + assert calls == [] + + assert mod.handle_gateway(["vault", "--help"]) == 0 + assert calls == [] + + assert mod.handle_gateway(["vault", "list"]) == 0 + assert calls[-1] == ("gateway", ["vault", "list"]) + + assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "chat"]) == 0 + assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "chat"]) + + assert mod.handle_gateway(["vault-setup", "list"]) == 0 + assert calls[-1] == ("gateway", ["vault-setup", "list"]) + + assert mod.handle_gateway(["terminal", "list"]) == 0 + assert calls[-1] == ("gateway", ["terminal", "list"]) + + print("ok: nap installer gateway route smoke passed") + + +if __name__ == "__main__": + run() diff --git a/tests/test_project_encryption_enable_payload.py b/tests/test_project_encryption_enable_payload.py new file mode 100644 index 0000000..5b9bad8 --- /dev/null +++ b/tests/test_project_encryption_enable_payload.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Smoke-test project encryption enable payload generation.""" + +import base64 +import importlib.util +import pathlib +import sys + + +def load_module(): + script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py" + sys.path.insert(0, str(script_path.parent)) + spec = importlib.util.spec_from_file_location("napseer_mcp_server", script_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def run(): + mod = load_module() + calls = [] + + mod.DEFAULT_PROJECT_ID = "project-1" + mod.AUTH = {"agent_id": "agent-1"} + mod.request_project_write = lambda method, path, body, project_id, purpose, scope_type="project": calls.append( + (method, path, body, project_id, purpose, scope_type) + ) or {"state": "encrypted", "active_project_key_version": {"version": 1}} + mod.save_auth = lambda payload: None + + result = mod.project_encryption_transition({"state": "encrypted", "passphrase": "pw"}) + assert result["status"] == "updated" + assert calls, "encryption enable should call backend" + method, path, body, project_id, purpose, scope_type = calls[0] + assert method == "POST" + assert path == "/v1/projects/project-1/encryption/enable" + assert project_id == "project-1" + assert scope_type == "encryption" + assert body["state"] == "encrypted" + wrapped = body["wrapped_project_key"] + assert wrapped["credential_id"] == "local-passphrase:agent-1" + assert wrapped["wrap_alg"] == "AES-GCM-256" + assert wrapped["kdf_alg"] == "PBKDF2-SHA256" + assert wrapped["kdf_params"]["source"] == "local_passphrase" + assert wrapped["kdf_params"]["iterations"] == mod.PROJECT_KEY_KDF_ITERATIONS + assert len(base64.b64decode(wrapped["salt_b64"])) == 16 + assert len(base64.b64decode(wrapped["kdf_params"]["nonce_b64"])) == 12 + assert len(base64.b64decode(wrapped["wrapped_project_key_b64"])) == 48 + + calls.clear() + result = mod.project_encryption_transition({"state": "plaintext"}) + assert result["status"] == "updated" + assert "wrapped_project_key" not in calls[0][2] + + print("ok: project encryption enable payload smoke passed") + + +if __name__ == "__main__": + run()