"""User-owned credentials, separate from repository identity and gateway state. No network, enrollment, or credential output belongs in this module. """ from contextlib import contextmanager import hashlib import json import os from pathlib import Path import stat import subprocess import sys import tempfile import threading import time class CredentialError(RuntimeError): pass CREDENTIAL_KEYS = { "base_url", "account_id", "token", "access_token", "refresh_token", "token_expires_at", "refresh_expires_at", "account_mode", "oauth_client_id", "oauth_scope", "logged_out", "credential_kind", } SELECTION_KEYS = { "project_id", "project_slug", "project_name", "project_encryption_state", "project_signing_key_fingerprint", "credential_profile", } _registry_lock = threading.Lock() _thread_locks = {} _held = threading.local() def user_data_dir(): if os.environ.get("NAPSEER_USER_DATA_DIR"): path = Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser() if not path.is_absolute(): raise CredentialError("NAPSEER_USER_DATA_DIR must be an absolute path outside the checkout.") return path.resolve() if sys.platform == "win32": return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer" if sys.platform == "darwin": return Path.home() / "Library" / "Application Support" / "napseer" xdg = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) return (xdg if xdg.is_absolute() else Path.home() / ".local" / "share") / "napseer" class WorkspacePaths: """Pure path resolution: Git identity survives worktree moves, not copies. The public locator stays in Git. Repository credentials/signing keys are shared by related worktrees; daemon/vault/cache state belongs to one worktree. No credentials are read and no directory is created during path discovery. """ def __init__(self, root=None): start = Path(root or os.environ.get("NAPSEER_PROJECT_ROOT") or Path.cwd()).expanduser().resolve() self.root = start git_dir = common = None environment = {k:v for k,v in os.environ.items() if k not in {"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"}} try: result = subprocess.run(["git", "-C", str(start), "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"], capture_output=True, text=True, timeout=3, env=environment) if result.returncode == 0: top, git_dir, common = result.stdout.strip().splitlines() self.root = Path(top).resolve() except (OSError, ValueError, subprocess.TimeoutExpired): pass # A nested project's explicit locator is a separate namespace within Git. for candidate in (start, *start.parents): if (candidate / ".napseer/project.json").is_file(): self.root = candidate break if git_dir and candidate == self.root: break suffix = str(self.root.relative_to(Path(top).resolve())) if git_dir else "" repo_identity = str(Path(common).resolve()) + ":" + suffix if common else str(self.root) worktree_identity = str(Path(git_dir).resolve()) + ":" + suffix if git_dir else str(self.root) self.repository_id = hashlib.sha256(repo_identity.encode()).hexdigest() self.workspace_id = hashlib.sha256(worktree_identity.encode()).hexdigest() self.data = user_data_dir().expanduser().resolve() if self.data.is_relative_to(self.root) and self.root != Path.home().resolve(): raise CredentialError("Napseer user data must be outside the project checkout.") self.legacy = self.root / ".napseer" self.locator = self.legacy / "project.json" self.repository = self.data / "repositories" / self.repository_id self.state = self.data / "workspaces" / self.workspace_id self.signing_key = self.repository / "id_ed25519" self.project_credentials = self.data / "credentials/projects" / (self.repository_id + ".json") old_id = hashlib.sha256(str(self.root).encode()).hexdigest() self.old_credentials = self.data / "credentials/projects" / (old_id + ".json") def migration_sources(self): if self.legacy.is_symlink(): raise CredentialError("Legacy Napseer state must not be a symbolic link.") sources = [] if self.legacy.exists(): sources = [p for p in self.legacy.iterdir() if p.name not in {"project.json", ".gitignore"}] if self.old_credentials != self.project_credentials and self.old_credentials.exists(): sources.append(self.old_credentials) return sources def require_migrated(self): if self.migration_sources(): raise CredentialError("Repository-local Napseer state needs migration. Stop older MCP clients and local gateways, then run `nap auth migrate`.") def migrate(self, *, gateway=False): """Copy/verify/publish before removing originals; never overwrite conflicts. Older processes must be stopped before this explicit operation. Existing daemon PIDs are checked. A partial copy can be retried safely; all source bytes remain until every destination has been verified. """ with credential_lock(self.data / "migration"): sources = self.migration_sources() if not sources: return {"status":"already_migrated", "state_dir":str(self.state)} for name in ("gateway.json", "ui.json"): state = read_json(self.legacy / name) pid = state.get("pid") if isinstance(pid, int) and pid > 0: try: os.kill(pid, 0) except ProcessLookupError: continue except OSError: pass raise CredentialError("Stop the running local gateway/UI before `nap auth migrate`.") entries = [] for source in sources: if source.is_symlink() or not source.is_file(): raise CredentialError("Migration requires regular files; original state was preserved.") destination = self.state / source.name if source.name in {"id_ed25519", "id_ed25519.pub"}: destination = self.repository / source.name if source.name == "auth.json" or source == self.old_credentials: destination = (self.state / "auth.json") if gateway and source.name == "auth.json" else self.project_credentials original = file_fingerprint(source) copied = None if source.name in {"auth.json", "gateway-auth.json", "state.json"} or source == self.old_credentials: data = read_json(source) for field, old, new in ( ("ssh_key_path", self.legacy / "id_ed25519", self.signing_key), ("vault_path", self.legacy / "vault.json", self.state / "vault.json"), ): value = data.get(field) if isinstance(value, str): candidate = Path(value).expanduser() candidate = candidate if candidate.is_absolute() else self.root / candidate if candidate.resolve() == old.resolve(): data[field] = str(new) copied = (json.dumps(data, indent=2) + "\n").encode() if destination.is_symlink(): raise CredentialError("Migration destination must not be a symbolic link.") expected = (len(copied), hashlib.sha256(copied).hexdigest()) if copied is not None else original if destination.exists() and file_fingerprint(destination) != expected: raise CredentialError("Migration conflicts with existing private state; nothing was overwritten. Reconcile the two states before retrying.") if any(dst == destination and digest != expected for _,dst,_,_,digest in entries): raise CredentialError("Legacy credentials conflict; original state was preserved.") entries.append((source, destination, original, copied, expected)) # Publish credentials last, after their keys and other state exist. entries.sort(key=lambda e: e[1] in {self.project_credentials, self.state / "auth.json"}) for source, destination, original, copied, expected in entries: private_directory(destination.parent, self.data) if not destination.exists(): fd, temporary = tempfile.mkstemp(prefix=".migrate-", dir=destination.parent) try: with os.fdopen(fd, "wb") as stream: os.fchmod(stream.fileno(), 0o600) if copied is not None: stream.write(copied) else: with source.open("rb") as original_stream: while chunk := original_stream.read(1024 * 1024): stream.write(chunk) stream.flush(); os.fsync(stream.fileno()) # Publish without replacing another login or migration's file. try: os.link(temporary, destination) except FileExistsError: if file_fingerprint(destination) != expected: raise CredentialError("Migration destination changed; original state was preserved.") from None finally: if os.path.exists(temporary): os.unlink(temporary) if file_fingerprint(destination) != expected or file_fingerprint(source) != original: raise CredentialError("State changed during migration; original files were preserved. Stop older clients and retry.") for source, destination, original, copied, expected in entries: if file_fingerprint(source) != original or file_fingerprint(destination) != expected: raise CredentialError("Migration verification failed; remaining originals were preserved.") # Make published directory entries durable before removing originals. # Windows does not support opening directories for fsync this way. if os.name != "nt": parents = set() for _, destination, _, _, _ in entries: for parent in (destination.parent, *destination.parent.parents): parents.add(parent) if parent == self.data: break for parent in sorted(parents, key=lambda p: len(p.parts), reverse=True): fd = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: os.fsync(fd) finally: os.close(fd) for source, _, _, _, _ in entries: source.unlink() return {"status":"migrated", "state_dir":str(self.state), "files":len(entries)} def private_directory(path, boundary): path, boundary = Path(path), Path(boundary) if not path.is_relative_to(boundary): raise CredentialError("Private state directory is outside its user data boundary.") for parent in (path, *path.parents): if parent.is_symlink(): raise CredentialError("Private Napseer state must not use symlink directories.") if parent == boundary: break path.mkdir(mode=0o700, parents=True, exist_ok=True) for parent in (path, *path.parents): parent.chmod(0o700) if parent == boundary: break def file_fingerprint(path): digest = hashlib.sha256() fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(fd, "rb") as stream: if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): raise CredentialError("Migration requires regular files.") size = 0 while chunk := stream.read(1024 * 1024): digest.update(chunk); size += len(chunk) return size, digest.hexdigest() def read_json(path): try: fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) except FileNotFoundError: return {} except OSError: raise CredentialError("Credential state cannot be opened safely.") from None try: if not stat.S_ISREG(os.fstat(fd).st_mode): raise CredentialError("Credential state must be a regular file.") with os.fdopen(fd, "r", encoding="utf-8") as stream: fd = None raw = stream.read(65537) if len(raw) > 65536: raise CredentialError("Credential state exceeds the size limit.") result = json.loads(raw) if not isinstance(result, dict): raise ValueError() return result except (ValueError, UnicodeError, OSError): raise CredentialError("Credential state is unreadable; run nap auth login to repair it.") from None finally: if fd is not None: os.close(fd) def atomic_json(path, data): path = Path(path) path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) if path.is_symlink(): raise CredentialError("Credential state must not be a symbolic link.") fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8") as stream: os.fchmod(stream.fileno(), 0o600) json.dump(data, stream, indent=2) stream.write("\n") stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) finally: if os.path.exists(temporary): os.unlink(temporary) @contextmanager def credential_lock(path, timeout=30): """Bounded process lock; nested calls in the same thread reuse the lock.""" path = Path(path).absolute() with _registry_lock: thread_lock = _thread_locks.setdefault(str(path), threading.RLock()) if not thread_lock.acquire(timeout=timeout): raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.") try: held = getattr(_held, "paths", {}) if str(path) in held: yield return path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) try: fd = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) except OSError: raise CredentialError("Credential lock cannot be opened safely.") from None try: if not stat.S_ISREG(os.fstat(fd).st_mode): raise CredentialError("Credential lock must be a regular file.") deadline = time.monotonic() + timeout while True: try: if os.name == "nt": import msvcrt os.lseek(fd, 0, os.SEEK_SET) msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) else: import fcntl fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) break except (BlockingIOError, OSError): if time.monotonic() >= deadline: raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.") from None time.sleep(0.025) held[str(path)] = fd _held.paths = held try: yield finally: held.pop(str(path), None) finally: os.close(fd) finally: thread_lock.release() class CredentialStore: def __init__(self, state_dir, legacy_path=None): self.workspace = WorkspacePaths(Path(state_dir).parent) self.state_dir = self.workspace.state self.legacy = Path(legacy_path or self.workspace.legacy / "auth.json").expanduser().absolute() self.user = user_data_dir() / "credentials" / "default.json" self.project = self.workspace.project_credentials self.selection = self.state_dir / "state.json" def selected(self): bound = getattr(_held,"bindings",{}).get(str(self.state_dir.absolute())) if bound: return bound explicit = os.environ.get("NAPSEER_AUTH_FILE") if explicit: path = Path(explicit).expanduser().absolute() if path.is_symlink(): raise CredentialError("Explicit credentials must not be a symbolic link.") if path.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve(): raise CredentialError("NAPSEER_AUTH_FILE must be outside the checkout; migrate private state first.") return path, "explicit" if self.legacy != self.workspace.legacy / "auth.json": if self.legacy.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve(): raise CredentialError("Explicit credentials must be outside the checkout; migrate private state first.") return self.legacy, "explicit" self.workspace.require_migrated() if read_json(self.selection).get("credential_profile") == "user": return self.user, "user" if self.project.exists() or self.project.is_symlink(): return self.project, "project" return self.user, "user" @contextmanager def transaction(self): """Pin one credential source through read/refresh/write despite new overrides.""" identity = str(self.state_dir.absolute()) chosen = self.selected() with credential_lock(chosen[0]): bindings = getattr(_held,"bindings",{}) previous = bindings.get(identity) bindings[identity] = chosen; _held.bindings = bindings try: yield finally: if previous: bindings[identity] = previous else: bindings.pop(identity,None) def use_user(self): current = read_json(self.user) if not current.get("token") or current.get("logged_out"): raise CredentialError("Run nap auth login before selecting the general user login.") with credential_lock(self.selection): selection = read_json(self.selection) selection["credential_profile"] = "user" atomic_json(self.selection, selection) return self.user def migrate(self): self.workspace.migrate() return self.project def read(self): path, source = self.selected() credentials = read_json(path) selection = {k: v for k, v in read_json(self.selection).items() if k in SELECTION_KEYS} # Project-bound grants retain their issued project. General grants only # consume repository selection; selection cannot confer authority. return {**selection, **credentials} def write(self, data): path, source = self.selected() with credential_lock(path): if source == "explicit": atomic_json(path, data) return credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS} if source == "project": # Preserve project-bound worker/recovery/key metadata during # refresh after legacy migration. Profile selection stays local # to the worktree and must not change sibling worktrees. credentials = {k: v for k, v in data.items() if k != "credential_profile"} current = read_json(path) # Reading project metadata must not churn or overwrite shared # credentials, including after another process logged out. if credentials != current: atomic_json(path, credentials) selection = {**read_json(self.selection), **{k: v for k, v in data.items() if k in SELECTION_KEYS}} if selection != read_json(self.selection): atomic_json(self.selection, selection) def login(self, credentials, project=False): target = self.project if project else self.user with credential_lock(target): atomic_json(target, credentials) if project: with credential_lock(self.selection): selection = read_json(self.selection); selection["credential_profile"] = "project" atomic_json(self.selection, selection) return target def logout(self, project=False): target = self.selected()[0] if project else self.user with credential_lock(target): previous = read_json(target) # Keep a tombstone: removing an override would silently restore the # more privileged user session on the next request. atomic_json(target, {"base_url": previous.get("base_url"), "logged_out": True}) return target