#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The per-user vault: envelope encryption that makes a user's secrets opaque to the operator at rest.Both the X25519 private key (`~/.euler/id`) and the project env (`~/.euler/env`) are stored**encrypted** rather than plain. The scheme is standard envelope encryption (see`docs/web-server-guide.md` § The vault):- a random 32-byte **vault key** `VK` encrypts the secrets (AES-256-GCM, random nonce per blob);- `VK` is stored **wrapped** under `PK = KDF(password, salt)` in `~/.euler/vault` -- so at rest the operator holds only `{salt, wrap(PK, VK)}` and the `VK`-ciphertext, and can decrypt none of it without the password. Changing the password **re-keys** the vault: a fresh `VK`, the few secret files re-encrypted under it, and the new `VK` wrapped under the new `PK` -- so a `VK` that leaked before the change opens nothing written after it (:func:`rekey_vault`).`PK` is derived with **Argon2id** (:data:`~solver.crypto.wire.VAULT_KDF`: 64 MiB, 3 passes), overthe password and the vault's salt prefixed with `euler-vault-pk:` -- the browser derives the samekey from the password it already holds and the salt it gets in the SRP challenge, with the vendoredhash-wasm it also uses for the SRP key, whose label differs so the two never coincide. The vaultrecords its parameters, so the cost can rise later without stranding anybody.**Key delivery.** The code that needs `VK` -- :func:`~solver.crypto.ciphers.load_private_key` andthe git clean/smudge filter -- runs as *subprocesses*, so `VK` cannot live only in the interactiveshell. It lives in a **uid-private tmpfs file** whose path is exported as `EULER_VAULT_KEY_FILE`(:func:`session_vault_key` reads it); the key itself is never in any process's environment. The webpath writes that file from the `PK` the browser supplies at shell-attach.**Where the terminal's password comes from**, in order: `$EULER_VAULT_PASSWORD` if set (a script,CI, an automated run -- no prompt, no tty needed), otherwise the operator is **asked**, once, by theshell (:func:`solver.crypto.keys.unlock_session`), which then materialises the key file for theprocess tree. There is no password file: one stored next to the ciphertext it unlocks would hand thepassword to exactly the reader the encryption is meant to stop, leaving the 0600 secrets dir as theonly real protection -- which is what the vault exists to improve on.**The boundary, honestly:** this is at-rest opacity against a passive/honest operator only.A malicious active root can read a live session's memory or alter the shared solver code -- no designprotects a secret from the entity that controls the CPU. Do not over-claim "zero-knowledge".This module is imported on the git-filter path (via :mod:`solver.crypto.ciphers`), so its hardcontract is the same: **importing it, and everything it imports, emits nothing on stdout**, and itdepends only on the standard library, `cryptography`, :mod:`solver.config` and:mod:`solver.crypto.wire`. Allinteractive parts (password prompts, the `vault` command) live in :mod:`solver.crypto.keys`."""from__future__importannotations__all__=['clear_session_key','decrypt_secret','derive_password_key','encrypt_secret','encrypt_secret_files','ensure_session_key','init_vault','init_vault_from_pk','is_vault_encrypted','new_vault_key','read_vault','rekey_vault','rekey_vault_with_pk','replace_session_key','reset_vault','session_vault_key','unlock_vault_with_pk','unwrap_vault_key','vault_exists','wrap_vault_key','write_session_key','write_vault',]importosfromcollections.abcimportCallablefromfunctoolsimportlru_cachefromjsonimportdumps,loadsfrompathlibimportPathfromsecretsimporttoken_bytesfromtempfileimportmkstempfromtypingimportTypedDict,castfromcryptography.exceptionsimportInvalidTagfromcryptography.hazmat.primitives.ciphers.aeadimportAESGCMfromsolver.configimportconfigfromsolver.cryptoimportwire_KEY_LEN:int=32# AES-256 / VK length_SALT_LEN:int=16# matches the SRP salt width (euler_site.auth.srp)_NONCE_LEN:int=12# AES-GCM nonce_STAGED:str='.rekey'# suffix of a secret file re-encrypted under a new VK, not yet in placeclassVaultData(TypedDict):"""The on-disk `~/.euler/vault` body: the wrapped vault key and how its wrapping key is derived."""kdf:str# the Argon2id parameters, `argon2id$m=…,t=…,p=…` (wire.VAULT_KDF when made)salt:str# hex; the KDF salt (the SRP salt, so the browser derives the same PK)wrapped_vk:str# hex (nonce | AES-GCM ciphertext of VK under PK)# ==================================================================================================================== ## password key + vault-key wrap/unwrap# ==================================================================================================================== #defderive_password_key(password:str,salt:bytes,kdf:str|None=None)->bytes:"""Derive the 32-byte password key `PK`: Argon2id over the password and the labelled salt. *kdf* names the parameters (`argon2id$m=…,t=…,p=…`), defaulting to the current :data:`~solver.crypto.wire.VAULT_KDF`. The browser derives the identical key (`vault.js`), so the web path can supply `PK` without shipping the password to the server. Raises: ValueError: If *kdf* is not an Argon2id parameter string. """kdf=wire.VAULT_KDFifkdfisNoneelsekdfsecret:bytes=password.encode('utf-8')name,_,params=kdf.partition('$')try:values=dict(pair.split('=',1)forpairinparams.split(','))memory,passes,lanes=int(values['m']),int(values['t']),int(values['p'])except(KeyError,ValueError)asexc:raiseValueError(f'unsupported vault kdf: {kdf!r}')fromexcifname!='argon2id':raiseValueError(f'unsupported vault kdf: {kdf!r}')fromargon2.low_levelimportType,hash_secret_raw# not on the git-filter pathreturnhash_secret_raw(secret,wire.VAULT_ARGON2_LABEL+salt,passes,memory,lanes,_KEY_LEN,Type.ID)def_vault_password_key(password:str,vault:VaultData)->bytes:"""`PK` for *vault*, derived the way that vault was made."""returnderive_password_key(password,bytes.fromhex(vault['salt']),vault['kdf'])defnew_vault_key()->bytes:"""Return a fresh random 32-byte vault key (`VK`)."""returntoken_bytes(_KEY_LEN)defwrap_vault_key(password_key:bytes,vault_key:bytes)->str:"""Wrap `vault_key` under `password_key` (AES-256-GCM, random nonce). Returns hex(nonce | ct)."""nonce:bytes=token_bytes(_NONCE_LEN)return(nonce+AESGCM(password_key).encrypt(nonce,vault_key,None)).hex()defunwrap_vault_key(password_key:bytes,wrapped:str)->bytes:"""Unwrap a vault key produced by :func:`wrap_vault_key`. Raises: cryptography.exceptions.InvalidTag: If the password key is wrong or the blob is corrupt. """raw:bytes=bytes.fromhex(wrapped)returnAESGCM(password_key).decrypt(raw[:_NONCE_LEN],raw[_NONCE_LEN:],None)# ==================================================================================================================== ## secret blob encrypt/decrypt (id, env)# ==================================================================================================================== #defis_vault_encrypted(blob:bytes)->bool:"""Return True if `blob` carries the vault MAGIC header (i.e. is vault ciphertext, not plaintext)."""magic:bytes=wire.VAULT_MAGICreturnblob[:len(magic)]==magicdefencrypt_secret(vault_key:bytes,plaintext:bytes)->bytes:"""Encrypt a secret under `VK` (AES-256-GCM, random nonce). Idempotent on already-encrypted input. Layout: `MAGIC | nonce(12) | ciphertext`. Unlike the git filter's convergent scheme this uses a random nonce -- these files are not tracked in git, so there is no reproducible-ciphertext need. """ifis_vault_encrypted(plaintext):returnplaintextmagic:bytes=wire.VAULT_MAGICnonce:bytes=token_bytes(_NONCE_LEN)returnmagic+nonce+AESGCM(vault_key).encrypt(nonce,plaintext,None)defdecrypt_secret(vault_key:bytes,blob:bytes)->bytes:"""Decrypt a blob produced by :func:`encrypt_secret`; pass-through for plaintext (no MAGIC). Raises: cryptography.exceptions.InvalidTag: If the blob is corrupt or the wrong key is used. """ifnotis_vault_encrypted(blob):returnblobmagic:int=len(wire.VAULT_MAGIC)nonce:bytes=blob[magic:magic+_NONCE_LEN]returnAESGCM(vault_key).decrypt(nonce,blob[magic+_NONCE_LEN:],None)# ==================================================================================================================== ## the vault file# ==================================================================================================================== #defvault_exists()->bool:"""Return True if this user's vault file is present (i.e. their secrets are vault-encrypted)."""returnconfig.vault_file.exists()defread_vault()->VaultData|None:"""Read and parse `~/.euler/vault`; return None if the user has no vault (plaintext at rest)."""vault_file:Path=config.vault_fileifnotvault_file.exists():returnNonereturncast(VaultData,loads(vault_file.read_text()))defwrite_vault(salt:bytes,wrapped_vk:str)->None:"""Serialise `~/.euler/vault` (0600) with the wrapped vault key, under the current KDF."""vault_file:Path=config.vault_filevault_file.parent.mkdir(parents=True,exist_ok=True)vault_file.parent.chmod(0o700)body:VaultData={'kdf':wire.VAULT_KDF,'salt':salt.hex(),'wrapped_vk':wrapped_vk}_write_atomic(vault_file,dumps(body,indent=2).encode('utf-8'))def_write_atomic(path:Path,data:bytes)->None:"""Write *path* (0600) through a sibling temp file and a rename: readers see old or new, never half."""fd,name=mkstemp(prefix=f'.{path.name}-',dir=path.parent)try:os.fchmod(fd,0o600)os.write(fd,data)finally:os.close(fd)os.replace(name,path)def_secret_files()->list[Path]:"""Every file the vault key may encrypt: `id`, its rolling backups, and `env`."""key_file:Path=config.private_key_filereturn[key_file,config.env_file]+[key_file.with_suffix(f'.{i}')foriinrange(1,config.private_key_backups+1)]definit_vault(password:str,salt:bytes|None=None)->bytes:"""Create a fresh vault: mint `VK`, wrap it under `PK = KDF(password, salt)`, persist it. Returns the plaintext `VK` so the caller can immediately re-encrypt the existing secrets. When `salt` is omitted a random one is generated; the web path passes the account's SRP salt so the browser derives the same `PK`. """salt=token_bytes(_SALT_LEN)ifsaltisNoneelsesaltvault_key:bytes=new_vault_key()write_vault(salt,wrap_vault_key(derive_password_key(password,salt),vault_key))returnvault_keydefunlock_vault(password:str)->bytes:"""Derive `PK` from `password` and the stored salt, then unwrap and return `VK`. Raises: FileNotFoundError: If no vault has been initialised. cryptography.exceptions.InvalidTag: If the password is wrong. """vault:VaultData|None=read_vault()ifvaultisNone:raiseFileNotFoundError(f'no vault at {config.vault_file}; run `vault --init`')vault_key:bytes=unwrap_vault_key(_vault_password_key(password,vault),vault['wrapped_vk'])_settle_rekey(vault_key)returnvault_keydefencrypt_secret_files(vault_key:bytes)->list[str]:"""Encrypt any still-plaintext secret files (`id`, `env`) in place under `vault_key`. Idempotent (already-encrypted files are left alone). Returns the names of the files rewritten. Shared by the interactive `vault init` and the web first-login initialisation, so a vault is never created with its secrets left plain beside it. """encrypted:list[str]=[]forpathin(config.private_key_file,config.env_file):ifnotpath.exists():continueraw:bytes=path.read_bytes()ifis_vault_encrypted(raw):continuepath.write_bytes(encrypt_secret(vault_key,raw))path.chmod(0o600)encrypted.append(path.name)returnencrypteddefrekey_vault(old_password:str,new_password:str)->bytes:"""Change the vault password **and** its key: a fresh `VK`, wrapped under the new password. Every vault-encrypted secret file is re-encrypted under the new `VK` (:func:`_rekey`). Returns the new `VK`; the caller replaces the session key with it (:func:`replace_session_key`). Raises: cryptography.exceptions.InvalidTag: If `old_password` is wrong. """old_key:bytes=unlock_vault(old_password)salt:bytes=token_bytes(_SALT_LEN)return_rekey(old_key,salt,lambdanew_key:wrap_vault_key(derive_password_key(new_password,salt),new_key))def_rekey(old_key:bytes,salt:bytes,wrap:Callable[[bytes],str])->bytes:"""Replace `VK`: re-encrypt the secret files under a fresh key, then commit the new wrapping. Crash-safe by one commit point. Each re-encrypted file is first **staged** beside its original (`<name>.rekey`); the vault file is then replaced atomically -- the commit -- and only after it are the staged files renamed over the originals. A crash before the commit leaves the old vault and files intact (the strays are dropped later); a crash after it leaves staged files that the new `VK` opens, and :func:`_settle_rekey` (run at every unlock) finishes the renames. """new_key:bytes=new_vault_key()staged:list[tuple[Path,Path]]=[]forpathin_secret_files():ifnotpath.exists():continueblob:bytes=path.read_bytes()ifnotis_vault_encrypted(blob):continuetarget:Path=path.with_name(path.name+_STAGED)_write_atomic(target,encrypt_secret(new_key,decrypt_secret(old_key,blob)))staged.append((target,path))write_vault(salt,wrap(new_key))# the commit pointfortarget,pathinstaged:os.replace(target,path)returnnew_keydef_settle_rekey(vault_key:bytes)->None:"""Finish or discard a re-key a crash interrupted: the vault's own `VK` decides. A staged file this `VK` opens belongs to the committed vault -- move it into place. One it does not open is from a re-key that never committed -- drop it. Cheap when there is nothing to do. """forpathin_secret_files():target:Path=path.with_name(path.name+_STAGED)ifnottarget.exists():continuetry:decrypt_secret(vault_key,target.read_bytes())exceptInvalidTag:target.unlink(missing_ok=True)else:os.replace(target,path)# ==================================================================================================================== ## web path: PK-based vault operations# ==================================================================================================================== ## The browser derives `PK` itself (Argon2id over the password it already holds and the SRP# salt) and sends only `PK` to the user's own service -- the password# never reaches any server, and the auth service never sees either. These are the `PK`-taking# twins of the password functions above.defunlock_vault_with_pk(password_key:bytes)->bytes:"""Unwrap and return `VK` using a browser-derived `PK` — derived the way this vault was made. Raises: FileNotFoundError: If no vault has been initialised. cryptography.exceptions.InvalidTag: If `PK` does not match the vault's wrapping (a stale vault after a password reset, or a wrong password). """vault:VaultData|None=read_vault()ifvaultisNone:raiseFileNotFoundError(f'no vault at {config.vault_file}')vault_key:bytes=unwrap_vault_key(password_key,vault['wrapped_vk'])_settle_rekey(vault_key)returnvault_keydefinit_vault_from_pk(password_key:bytes,salt:bytes)->bytes:"""Create a fresh vault wrapped under a browser-derived `PK` (web first-login). `salt` is the account's SRP salt, recorded so the terminal path (and any later browser session) derives the identical `PK`. Any plaintext `id`/`env` already on disk is encrypted in place -- a vault must never coexist with plain secrets. Returns the plaintext `VK`. """vault_key:bytes=new_vault_key()write_vault(salt,wrap_vault_key(password_key,vault_key))encrypt_secret_files(vault_key)returnvault_keydefrekey_vault_with_pk(old_password_key:bytes,new_password_key:bytes,new_salt:bytes)->bytes:"""Re-key the vault under a new browser-derived `PK` (a password change: the secrets survive). The `PK` twin of :func:`rekey_vault`: a fresh `VK`, the secret files re-encrypted under it, and the new `VK` wrapped under `new_password_key`. `new_salt` is the account's new SRP salt (the browser derived `new_password_key` from it, under the current KDF); `old_password_key` is derived with the vault's own recorded parameters. Returns the new `VK`. Raises: FileNotFoundError: If no vault has been initialised. cryptography.exceptions.InvalidTag: If `old_password_key` is wrong. """old_key:bytes=unlock_vault_with_pk(old_password_key)return_rekey(old_key,new_salt,lambdanew_key:wrap_vault_key(new_password_key,new_key))defreset_vault()->list[str]:"""Destroy the vault after a password reset: the secrets are unrecoverable *by design*. An SRP reset re-mints the login verifier but shares nothing with the vault, so the old `VK` can never be unwrapped again -- leaving the blobs around would only misrepresent the state. Removes the vault file and every **vault-encrypted** secret file (`id` + its rolling backups, `env`); plaintext files are never touched (they were never under this vault). Also drops the session key. Returns the names of the files removed; the user re-provisions (`user --regen` + re-upload) afterwards. """removed:list[str]=[]vault_file:Path=config.vault_fileifvault_file.exists():vault_file.unlink()removed.append(vault_file.name)forpathin_secret_files():forcandidatein(path,path.with_name(path.name+_STAGED)):ifcandidate.exists()andis_vault_encrypted(candidate.read_bytes()):candidate.unlink()removed.append(candidate.name)clear_session_key()returnremoved# ==================================================================================================================== ## session vault key (VK delivery)# ==================================================================================================================== #defwrite_session_key(vault_key:bytes)->Path:"""Write `VK` to a fresh uid-private tmpfs file (0600) and export its path as the session key. Prefers `$XDG_RUNTIME_DIR` (a per-user tmpfs, cleared at logout); falls back to the system temp dir. Sets `EULER_VAULT_KEY_FILE` in this process's environment so child processes -- notably the git clean/smudge filter -- inherit it. Returns the file path. """runtime:str=os.environ.get('XDG_RUNTIME_DIR','').strip()directory:str|None=Noneifruntime:euler_run:Path=Path(runtime)/'euler'euler_run.mkdir(parents=True,exist_ok=True)euler_run.chmod(0o700)directory=str(euler_run)fd,name=mkstemp(prefix='vk-',dir=directory)# dir=None → the system temp dir (also uid-private)try:os.fchmod(fd,0o600)os.write(fd,vault_key)finally:os.close(fd)os.environ[wire.VAULT_KEY_ENV]=name_resolve_session_key.cache_clear()returnPath(name)@lru_cache(maxsize=1)def_resolve_session_key()->bytes|None:"""Resolve the session `VK` once per process: tmpfs key file first, else `$EULER_VAULT_PASSWORD`. Both sources are non-interactive by construction. This function is on the git-filter path, in a subprocess with no terminal and a stdout that belongs to git — it must never prompt. When neither source answers, the session is simply **locked**, and the interactive recovery is the shell's job (:func:`solver.crypto.keys.unlock_session`), which materialises the key file the fast path above then finds. There is deliberately no password *file*: a password at rest beside the ciphertext it unlocks protects nothing that the secrets dir's own 0600 does not already protect. """key_path:str=os.environ.get(wire.VAULT_KEY_ENV,'').strip()ifkey_pathand(path:=Path(key_path)).exists():data:bytes=path.read_bytes()iflen(data)==_KEY_LEN:returndata# The env password: a non-interactive unlock for a script or an automated run. Expensive (Argon2id)# per process, so a shell calls ensure_session_key() once at startup to materialise the tmpfs file# and let its children take the fast path above.password:str=os.environ.get(wire.VAULT_PASSWORD_ENV,'')ifnot(passwordandvault_exists()):returnNonetry:returnunlock_vault(password)except(InvalidTag,ValueError,OSError):returnNonedefsession_vault_key()->bytes|None:"""Return the current session's vault key, or None if the vault is locked / not initialised. Read by :func:`~solver.crypto.ciphers.load_private_key`, the git filter, and :func:`~solver.ai.models.get_api_key` to decrypt vault secrets. Never emits to stdout. """return_resolve_session_key()defreplace_session_key(vault_key:bytes)->Path:"""Put a re-keyed `VK` into this session's key file **in place**, or write a fresh one. In place, at the same path, because other processes of this uid were handed that path (a web shell inherits it from its service), and a new file would strand them on a deleted one. They still hold the old `VK` in memory, which is why a re-key ends the session it ran in. """key_path:str=os.environ.get(wire.VAULT_KEY_ENV,'').strip()ifnotkey_pathornotPath(key_path).exists():returnwrite_session_key(vault_key)_write_atomic(Path(key_path),vault_key)_resolve_session_key.cache_clear()returnPath(key_path)defclear_session_key()->None:"""Lock the session: delete the tmpfs key file, drop the env var, and clear the cached `VK`. Called on logout and vault reset so a torn-down session leaves no reachable key material. Idempotent. """key_path:str=os.environ.pop(wire.VAULT_KEY_ENV,'').strip()ifkey_path:Path(key_path).unlink(missing_ok=True)_resolve_session_key.cache_clear()defensure_session_key()->bytes|None:"""Establish the session key file for this process tree; return the `VK` (or None if locked). Idempotent: if `EULER_VAULT_KEY_FILE` already points at a valid key file, reuse it; otherwise derive `VK` from `$EULER_VAULT_PASSWORD` and materialise the tmpfs file so subprocesses (the git filter) skip the Argon2id cost. Non-interactive: a locked vault stays locked here and returns None -- asking the operator is :func:`solver.crypto.keys.unlock_session`, which is what a shell calls at startup. """key_path:str=os.environ.get(wire.VAULT_KEY_ENV,'').strip()ifkey_pathandPath(key_path).exists():returnsession_vault_key()vault_key:bytes|None=session_vault_key()ifvault_keyisnotNone:write_session_key(vault_key)returnvault_key