diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index 50a8688..a551418 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -597,16 +597,16 @@ def handle_gateway(args): print(" nap gateway configure [--command CMD] Configure the local gateway command.") print(" nap gateway setup Create or migrate local gateway storage.") 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 process Complete pending setup requests with the vault/content passphrase.") print(" nap gateway vault rotate-secret --kind memory") - print(" nap chat secret setup Configure chat secret state through the gateway.") + print(" nap chat secret setup Configure chat secret state with the vault/content passphrase.") return 0 if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}: - print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") + print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]") print(" status Show local gateway state and pending setup requests.") print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") print(" rotate-secret Rotate the memory project secret from the gateway worker path.") - print(" Chat secrets use: nap chat secret setup|status|rotate") + print(" Chat secrets use: nap chat secret setup|status|rotate; setup uses the same vault/content passphrase.") return 0 if subcommand == "process": return run_wrapper("gateway", ["vault", "process", *rest]) @@ -687,7 +687,7 @@ def main(argv): print(" Examples: nap project create, nap project status, nap project encryption status") print(" Encrypted project setup is completed by the local gateway: nap gateway vault process") print(" chat Chat secret commands: secret status, secret setup, secret rotate.") - print(" Examples: nap chat secret setup, nap chat secret status") + print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status") print(" plan Plan commands: list, to-kanban.") print(" Example: nap plan to-kanban /plans/example --column todo") print(" lineage Generic source-record -> plan -> kanban lineage checks.") diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index e962df9..5935fa0 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -531,7 +531,7 @@ MEMORY_NODE_HKDF_INFO_PREFIX = "napseer-memory-node-v1" MEMORY_NODE_LEGACY_HKDF_INFO_PREFIX = "napseer-memory-local-mcp-v1" MEMORY_SECRET_CACHE = {} ACTIVE_MEMORY_SECRET_VERSIONS = {} -GATEWAY_PROJECT_BUNDLE_PASSPHRASE = None +PROJECT_VAULT_PASSPHRASE = None VAULT_SECRETS = {} VAULT_KEY = None @@ -720,12 +720,12 @@ def _derive_vault_key(passphrase, salt, iterations): return hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32) -def read_master_passphrase(create=False): - value = os.environ.get("NAPSEER_MASTER_PASSPHRASE") +def read_gateway_relay_passphrase(create=False): + value = os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") if value: return value if not sys.stdin.isatty(): - raise RuntimeError("NAPSEER_MASTER_PASSPHRASE is required in non-interactive mode") + raise RuntimeError("NAPSEER_GATEWAY_PASSPHRASE is required in non-interactive mode") first = getpass.getpass("Create gateway relay passphrase: " if create else "Gateway relay passphrase: ") if create: second = getpass.getpass("Confirm gateway relay passphrase: ") @@ -734,6 +734,20 @@ def read_master_passphrase(create=False): return first +def read_project_vault_passphrase(create=False): + value = os.environ.get("NAPSEER_VAULT_PASSPHRASE") or os.environ.get("NAPSEER_MASTER_PASSPHRASE") + if value: + return value + if not sys.stdin.isatty(): + raise RuntimeError("NAPSEER_VAULT_PASSPHRASE is required in non-interactive mode") + first = getpass.getpass("Create project vault passphrase: " if create else "Project vault passphrase: ") + if create: + second = getpass.getpass("Confirm project vault passphrase: ") + if first != second: + raise RuntimeError("project vault passphrases did not match") + return first + + def read_local_vault_key(create=False): value = os.environ.get("NAPSEER_LOCAL_VAULT_KEY") if value: @@ -1098,8 +1112,8 @@ def touch_gateway(): def clear_gateway_runtime_caches(): - global GATEWAY_PROJECT_BUNDLE_PASSPHRASE - GATEWAY_PROJECT_BUNDLE_PASSPHRASE = None + global PROJECT_VAULT_PASSPHRASE + PROJECT_VAULT_PASSPHRASE = None MEMORY_SECRET_CACHE.clear() ACTIVE_MEMORY_SECRET_VERSIONS.clear() @@ -1222,7 +1236,7 @@ def apply_vault_secrets(secrets_payload): def gateway_open_local(passphrase=None): - global VAULT_SECRETS, VAULT_KEY, GATEWAY_UNLOCKED, AUTH, BASE_URL, TOKEN, DEFAULT_PROJECT_ID, TOKEN_EXPIRES_AT, GATEWAY_PROJECT_BUNDLE_PASSPHRASE + global VAULT_SECRETS, VAULT_KEY, GATEWAY_UNLOCKED, AUTH, BASE_URL, TOKEN, DEFAULT_PROJECT_ID, TOKEN_EXPIRES_AT if not vault_exists(): raise RuntimeError("gateway vault is not configured") local_key = read_local_vault_key(create=False) @@ -1286,7 +1300,6 @@ def gateway_open_local(passphrase=None): DEFAULT_PROJECT_ID = AUTH["project_id"] TOKEN_EXPIRES_AT = AUTH["token_expires_at"] if passphrase: - GATEWAY_PROJECT_BUNDLE_PASSPHRASE = str(passphrase) write_gateway_relay_secret(passphrase) persist_vault_secrets() touch_gateway() @@ -1333,7 +1346,7 @@ def normalize_gateway_command(value=None): def gateway_setup(args): - passphrase = args.get("passphrase") or os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") or read_master_passphrase(create=True) + passphrase = args.get("passphrase") or os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") or read_gateway_relay_passphrase(create=True) if vault_exists() and not args.get("overwrite"): result = gateway_open_local(passphrase) capability_result = ensure_gateway_worker_capability(refresh=True) @@ -1388,7 +1401,6 @@ def gateway_rotate_passphrase(args): apply_vault_secrets(vault_payload) globals()["VAULT_KEY"] = vault_key globals()["VAULT_SECRETS"] = gateway_vault_secret_subset(secrets_map) - globals()["GATEWAY_PROJECT_BUNDLE_PASSPHRASE"] = new_passphrase write_gateway_relay_secret(new_passphrase) touch_gateway() return { @@ -3156,13 +3168,23 @@ def current_account_id(): return account_id +def set_project_vault_passphrase(passphrase): + global PROJECT_VAULT_PASSPHRASE + if not isinstance(passphrase, str) or not passphrase.strip(): + raise RuntimeError("project vault passphrase is required") + PROJECT_VAULT_PASSPHRASE = passphrase.strip() + return PROJECT_VAULT_PASSPHRASE + + def project_bundle_passphrase(): - if GATEWAY_PROJECT_BUNDLE_PASSPHRASE: - return GATEWAY_PROJECT_BUNDLE_PASSPHRASE - value = os.environ.get("NAPSEER_MASTER_PASSPHRASE") + if PROJECT_VAULT_PASSPHRASE: + return PROJECT_VAULT_PASSPHRASE + value = os.environ.get("NAPSEER_VAULT_PASSPHRASE") or os.environ.get("NAPSEER_MASTER_PASSPHRASE") if value: return value - raise RuntimeError("gateway relay passphrase is required before handling project key bundles") + if sys.stdin.isatty(): + return set_project_vault_passphrase(read_project_vault_passphrase(create=False)) + raise RuntimeError("project vault passphrase is required before handling project key bundles; set NAPSEER_VAULT_PASSPHRASE") def project_bundle_hkdf_info(project_id, secret_kind, wrapping_epoch, bundle_version, data_key_epoch): @@ -3752,6 +3774,7 @@ def gateway_generate_project_vault_setup_payload(project_id, setup_request_id): project_id = str(project_id or current_project_id()).strip() if not project_id: raise RuntimeError("project_id is required") + project_bundle_passphrase() account_id = current_account_id() plaintext_bundle = generate_project_data_key_bundle(project_id, account_id, data_key_epoch=1) project_secrets = [] @@ -3826,6 +3849,8 @@ def gateway_complete_vault_setup_request(setup_request, project_id=None): def gateway_process_vault_setup_requests(args=None): args = args or {} + if args.get("passphrase") or args.get("vault_passphrase"): + set_project_vault_passphrase(args.get("passphrase") or args.get("vault_passphrase")) 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"] @@ -3951,6 +3976,7 @@ def chat_secret_status(args=None): response = { "status": "ok", "project_id": project_id, + "passphrase_authority": "vault", "locked": vault.get("locked"), "vault_configured": vault.get("vault_configured"), "pending_setup_requests": vault.get("pending_setup_requests", 0), @@ -4008,6 +4034,8 @@ def chat_secret_status(args=None): def chat_secret_setup(args=None): args = args or {} + if args.get("passphrase") or args.get("vault_passphrase"): + set_project_vault_passphrase(args.get("passphrase") or args.get("vault_passphrase")) project_id = str(args.get("project_id") or current_project_id()).strip() status = chat_secret_status({"project_id": project_id}) if status.get("chat_secret_configured") is True: @@ -4033,10 +4061,11 @@ def chat_secret_rotate(args=None): return { "status": "operator_auth_required", "project_id": project_id, + "passphrase_authority": "vault", "message": ( "Chat secret rotation is not available through the gateway worker token. " "This preserves the boundary that worker tokens cannot read active chat secrets. " - "Use an operator-authorized rotation flow." + "Use an operator-authorized rotation flow with the project vault passphrase." ), } @@ -7026,6 +7055,7 @@ def cli_positionals(args): "--image-ref", "--secret-kind", "--kind", + "--vault-passphrase", "--root-path", "--name", "--target", @@ -10328,12 +10358,12 @@ def raw_tools(): }, { "name": "nap_gateway_setup", - "description": "Create or migrate local gateway storage and configure the remote relay passphrase.", + "description": "Create or migrate local gateway storage and configure the remote relay passphrase. This does not configure the project vault/content passphrase used for chat or memory.", "inputSchema": { "type": "object", "required": ["passphrase"], "properties": { - "passphrase": {"type": "string", "description": "Remote relay passphrase. Local access is protected by the machine/user session, not an interactive unlock gate."}, + "passphrase": {"type": "string", "description": "Gateway relay passphrase for remote terminal/relay access only. It is not the chat/vault passphrase."}, "default_command": {"type": "string", "description": "Command Napseer should launch in its managed tmux window, for example bash -l."}, "overwrite": {"type": "boolean"}, }, @@ -10405,10 +10435,13 @@ def raw_tools(): }, { "name": "nap_chat_secret_setup", - "description": "Complete pending gateway vault setup needed for chat secrets, or report the next required action.", + "description": "Complete pending gateway vault setup needed for chat secrets using the project vault/content passphrase, or report the next required action.", "inputSchema": { "type": "object", - "properties": {"project_id": {"type": "string"}}, + "properties": { + "project_id": {"type": "string"}, + "vault_passphrase": {"type": "string"}, + }, "additionalProperties": False, }, }, @@ -12249,6 +12282,7 @@ def cli_main(argv): "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"), + "vault_passphrase": cli_option(vault_args, "--vault-passphrase", "--passphrase", default=None), } if action in {"list", "ls", "status"}: print(json.dumps(gateway_vault_status(payload), indent=2)) @@ -12286,6 +12320,7 @@ def cli_main(argv): secret_args = args payload = { "project_id": cli_option(secret_args, "--project-id", default=None), + "vault_passphrase": cli_option(secret_args, "--vault-passphrase", "--passphrase", default=None), } if action in {"status", "show", "ls", "list"}: print(json.dumps(chat_secret_status(payload), indent=2)) @@ -12296,7 +12331,7 @@ def cli_main(argv): if action in {"rotate", "rotate-secret"}: print(json.dumps(chat_secret_rotate(payload), indent=2)) return - print("Usage: napseer_mcp_server.py chat secret [status|setup|rotate] [--project-id ID]") + print("Usage: napseer_mcp_server.py chat secret [status|setup|rotate] [--project-id ID] [--vault-passphrase TEXT]") return if command == "feedback": subcommand = argv[2] if len(argv) > 2 else "list" diff --git a/resources/scripts/napseer_setup.py b/resources/scripts/napseer_setup.py index d35a933..93e57da 100644 --- a/resources/scripts/napseer_setup.py +++ b/resources/scripts/napseer_setup.py @@ -117,16 +117,16 @@ def ensure_local_files(): chmod_best_effort(pathlib.Path(str(KEY_PATH) + ".pub"), 0o644) -def read_master_passphrase(): - value = os.environ.get("NAPSEER_MASTER_PASSPHRASE") +def read_gateway_passphrase(): + value = os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") or os.environ.get("NAPSEER_MASTER_PASSPHRASE") if value: return value if not sys.stdin.isatty(): - raise RuntimeError("NAPSEER_MASTER_PASSPHRASE is required in non-interactive mode") - first = getpass.getpass("Create gateway master passphrase: ") - second = getpass.getpass("Confirm gateway master passphrase: ") + raise RuntimeError("NAPSEER_GATEWAY_PASSPHRASE is required in non-interactive mode") + first = getpass.getpass("Create gateway relay/storage passphrase: ") + second = getpass.getpass("Confirm gateway relay/storage passphrase: ") if first != second: - raise RuntimeError("gateway master passphrases did not match") + raise RuntimeError("gateway relay/storage passphrases did not match") return first @@ -278,7 +278,7 @@ def wait_for_claim_result(receiver, timeout_seconds): def main(): ensure_local_files() - master_passphrase = read_master_passphrase() + gateway_passphrase = read_gateway_passphrase() public_key = pathlib.Path(str(KEY_PATH) + ".pub").read_text(encoding="utf-8").strip() private_key = KEY_PATH.read_text(encoding="utf-8") project_slug = os.environ.get("NAPSEER_PROJECT_SLUG", default_slug()) @@ -357,7 +357,7 @@ def main(): if account_claim: secret_auth["account_claim_url"] = account_claim["claim_url"] secret_auth["account_claim_expires_at"] = account_claim["expires_at"] - write_gateway_vault(master_passphrase, public_auth, secret_auth) + write_gateway_vault(gateway_passphrase, public_auth, secret_auth) remove_plain_private_key() output = { diff --git a/scripts/nap_install.py b/scripts/nap_install.py index 50a8688..a551418 100644 --- a/scripts/nap_install.py +++ b/scripts/nap_install.py @@ -597,16 +597,16 @@ def handle_gateway(args): print(" nap gateway configure [--command CMD] Configure the local gateway command.") print(" nap gateway setup Create or migrate local gateway storage.") 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 process Complete pending setup requests with the vault/content passphrase.") print(" nap gateway vault rotate-secret --kind memory") - print(" nap chat secret setup Configure chat secret state through the gateway.") + print(" nap chat secret setup Configure chat secret state with the vault/content passphrase.") return 0 if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}: - print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") + print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]") print(" status Show local gateway state and pending setup requests.") print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") print(" rotate-secret Rotate the memory project secret from the gateway worker path.") - print(" Chat secrets use: nap chat secret setup|status|rotate") + print(" Chat secrets use: nap chat secret setup|status|rotate; setup uses the same vault/content passphrase.") return 0 if subcommand == "process": return run_wrapper("gateway", ["vault", "process", *rest]) @@ -687,7 +687,7 @@ def main(argv): print(" Examples: nap project create, nap project status, nap project encryption status") print(" Encrypted project setup is completed by the local gateway: nap gateway vault process") print(" chat Chat secret commands: secret status, secret setup, secret rotate.") - print(" Examples: nap chat secret setup, nap chat secret status") + print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status") print(" plan Plan commands: list, to-kanban.") print(" Example: nap plan to-kanban /plans/example --column todo") print(" lineage Generic source-record -> plan -> kanban lineage checks.") diff --git a/tests/test_chat_secret_cli_flow.py b/tests/test_chat_secret_cli_flow.py index 01b7960..b5a23a3 100644 --- a/tests/test_chat_secret_cli_flow.py +++ b/tests/test_chat_secret_cli_flow.py @@ -40,7 +40,8 @@ def run(): return {"items": []} mod.request_json = request_json - setup = mod.chat_secret_setup({"project_id": PROJECT_ID}) + setup = mod.chat_secret_setup({"project_id": PROJECT_ID, "vault_passphrase": "vault-content"}) + assert mod.PROJECT_VAULT_PASSPHRASE == "vault-content" assert processed and processed[-1]["complete_all"] is True assert setup["chat_secret_configured"] is False @@ -56,6 +57,7 @@ def run(): }] } status = mod.chat_secret_status({"project_id": PROJECT_ID}) + assert status["passphrase_authority"] == "vault" assert status["chat_secret_configured"] is True assert status["active_chat_secret"]["version"] == 3 diff --git a/tests/test_gateway_vault_setup_payload.py b/tests/test_gateway_vault_setup_payload.py index a011d28..67a8f47 100644 --- a/tests/test_gateway_vault_setup_payload.py +++ b/tests/test_gateway_vault_setup_payload.py @@ -49,7 +49,7 @@ def run(): writes = [] mod.AUTH = {"agent_id": "gateway-agent-1", "account_id": ACCOUNT_ID} - mod.GATEWAY_PROJECT_BUNDLE_PASSPHRASE = PASSPHRASE + mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE mod.VAULT_KEY = bytes(range(32)) mod.VAULT_SECRETS = {} mod.persist_vault_secrets = lambda: writes.append(dict(mod.VAULT_SECRETS)) diff --git a/tests/test_mcp_memory_node_encryption.py b/tests/test_mcp_memory_node_encryption.py index 62778f1..185efbb 100644 --- a/tests/test_mcp_memory_node_encryption.py +++ b/tests/test_mcp_memory_node_encryption.py @@ -115,7 +115,7 @@ def encrypted_node(mod, full_path="/notes/alpha", content="secret needle", metad def run(): mod = load_module() mod.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"} - mod.GATEWAY_PROJECT_BUNDLE_PASSPHRASE = PASSPHRASE + mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE mod.DEFAULT_PROJECT_ID = "project-1" mod.MEMORY_SECRET_CACHE.clear() mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear() diff --git a/tests/test_two_passphrase_boundary.py b/tests/test_two_passphrase_boundary.py new file mode 100644 index 0000000..c0e5629 --- /dev/null +++ b/tests/test_two_passphrase_boundary.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Smoke-test vault/content and gateway relay passphrases stay separate.""" + +import importlib.util +import os +import pathlib +import sys + + +PROJECT_ID = "11111111-1111-1111-1111-111111111111" +ACCOUNT_ID = "22222222-2222-2222-2222-222222222222" + + +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_two_passphrase_test", 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() + original_env = dict(os.environ) + try: + os.environ.pop("NAPSEER_VAULT_PASSPHRASE", None) + os.environ.pop("NAPSEER_MASTER_PASSPHRASE", None) + os.environ["NAPSEER_GATEWAY_PASSPHRASE"] = "gateway-only" + mod.PROJECT_VAULT_PASSPHRASE = None + + try: + mod.gateway_generate_project_vault_setup_payload(PROJECT_ID, "setup-1") + except RuntimeError as exc: + assert "NAPSEER_VAULT_PASSPHRASE" in str(exc) + else: + raise AssertionError("gateway relay passphrase must not wrap project bundles") + + os.environ["NAPSEER_VAULT_PASSPHRASE"] = "vault-content" + mod.AUTH = {"agent_id": "gateway-agent-1", "account_id": ACCOUNT_ID} + payload = mod.gateway_generate_project_vault_setup_payload(PROJECT_ID, "setup-2") + assert {item["secret_kind"] for item in payload["project_secrets"]} == {"chat", "tabs", "gateway", "memory"} + first = payload["project_secrets"][0]["wrapped_key_bundle"] + assert mod.unwrap_project_key_bundle(first)["project_id"] == PROJECT_ID + + os.environ["NAPSEER_VAULT_PASSPHRASE"] = "wrong-vault-content" + mod.PROJECT_VAULT_PASSPHRASE = None + try: + mod.unwrap_project_key_bundle(first) + except RuntimeError: + pass + else: + raise AssertionError("project bundle unwrap must require the vault/content passphrase") + + mod.PROJECT_VAULT_PASSPHRASE = "manual-vault" + assert mod.project_bundle_passphrase() == "manual-vault" + finally: + os.environ.clear() + os.environ.update(original_env) + + print("ok: two passphrase boundary smoke passed") + + +if __name__ == "__main__": + run()