434 lines
21 KiB
Python
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
|