"""Encrypted credential store for node integrations. Nodes never hold credentials: a parameter written as ``{"$secret": "name"}`` is replaced with the stored value when the node is built. Secrets are kept encrypted outside the flows repository, so what gets committed and shared never contains a password. """ from __future__ import annotations import base64 import hashlib import json from pathlib import Path from typing import Any from cryptography.fernet import Fernet, InvalidToken SECRET_REF = "$secret" class SecretsUnreadable(RuntimeError): """The store exists but does not decrypt with the current SECRET_KEY.""" class SecretNotFound(KeyError): """A node asked for a secret that is not in the store.""" def __init__(self, name: str) -> None: super().__init__(name) self.name = name def __str__(self) -> str: return f"No secret named '{self.name}' — add it under Secrets." class SecretsStore: """Named credentials, encrypted at rest with a key derived from the app key.""" def __init__(self, path: Path, key: str) -> None: self._path = path self._fernet = Fernet( base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()) ) def _read(self) -> dict[str, str]: if not self._path.exists(): return {} try: data: dict[str, str] = json.loads( self._fernet.decrypt(self._path.read_bytes()) ) return data except (InvalidToken, ValueError) as exc: # A changed app key makes existing secrets unreadable. Fail loudly: # flows coming up with silently missing credentials are far harder # to diagnose than a node that reports why it did not load. raise SecretsUnreadable( f"Cannot decrypt {self._path} — it was written with a " "different SECRET_KEY. Re-encrypt it with " "scripts/rotate_secret_key.py." ) from exc def _write(self, data: dict[str, str]) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) self._path.write_bytes(self._fernet.encrypt(json.dumps(data).encode())) def list(self) -> list[str]: return sorted(self._read()) def get(self, name: str) -> str: data = self._read() if name not in data: raise SecretNotFound(name) return data[name] def set(self, name: str, value: str) -> None: data = self._read() data[name] = value self._write(data) def delete(self, name: str) -> None: data = self._read() if data.pop(name, None) is None: raise SecretNotFound(name) self._write(data) _store: SecretsStore | None = None def init_secrets(path: Path, key: str) -> SecretsStore: """Create the process-wide store (called once at startup).""" global _store _store = SecretsStore(path, key) return _store def get_secrets() -> SecretsStore: if _store is None: raise RuntimeError("Secrets store not initialised") return _store def get(name: str) -> str: """Look up a secret by name — the entry point for custom node code.""" return get_secrets().get(name) def resolve_params(params: Any) -> Any: """Replace every ``{"$secret": "name"}`` reference with its value.""" if isinstance(params, dict): if set(params) == {SECRET_REF} and isinstance(params[SECRET_REF], str): return get(params[SECRET_REF]) return {k: resolve_params(v) for k, v in params.items()} if isinstance(params, list): return [resolve_params(v) for v in params] return params