Files
nap-cli/resources/scripts/napseer_credentials.py
T

434 lines
21 KiB
Python

"""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