diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82b5f36 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +**/target +**/node_modules +**/.venv +**/__pycache__ +e2e/test-results +e2e/playwright-report +e2e/screenshots +deploy/.env +**/.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cdf613..f943383 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,3 +125,30 @@ jobs: - name: License fields are Apache-2.0 run: python3 scripts/check-license-metadata.py + e2e: + name: e2e + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: API dependencies + working-directory: api + run: python -m pip install -r requirements.txt + + - name: Playwright + working-directory: e2e + run: npm install && npx playwright install --with-deps chromium + + - name: Console flows + working-directory: e2e + run: npx playwright test + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..27c31f6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,22 @@ +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + version: + name: tag matches VERSION + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + + - name: Tag matches VERSION + run: | + tag="${GITHUB_REF_NAME#v}" + current="$(tr -d '[:space:]' < VERSION)" + test "$tag" = "$current" diff --git a/.gitignore b/.gitignore index cbd61f5..74fe822 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ target/ bindings/ts/pkg/ bindings/ts/node_modules/ bindings/ts/dist/ +e2e/node_modules/ +e2e/test-results/ +e2e/playwright-report/ +e2e/screenshots/ diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..31bf2ae --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,55 @@ +# Adopt Encryptoglyph + +Encryptoglyph 0.2.0 exposes two surfaces that share one envelope: the in-process library, and the versioned HTTP sidecar (`/v1`, header `X-Encryptoglyph-Api: 1`). Keys created through the sidecar stay in process memory. Responses carry fingerprints, never key bytes. The assistant endpoint refuses plaintext and key fields. + +## In process + +```python +from eg_crypto import Keyring, RecordContext, encrypt, decrypt + +kr = Keyring({kid: key}, kid) +ctx = RecordContext("acme", "field", "customers", "42", "email") +blob = encrypt(kr, ctx, b"ada@example.com") +assert decrypt(kr, ctx, blob) == b"ada@example.com" +``` + +`api/eg_crypto` is the Python package. `crates/encryptoglyph` is the Rust source of truth. Both check `testvectors/envelope.json`. + +## Sidecar + +Run the API (it also serves the console): + +```bash +cd api +python -m pip install -r requirements.txt +BUILD_SHA="$(git rev-parse --short=12 HEAD)" python -m uvicorn main:app --host 127.0.0.1 --port 8000 +``` + +```python +import os +from encryptoglyph_client import Encryptoglyph + +eg = Encryptoglyph(os.environ["EG_URL"]) +eg.session() +sealed = eg.seal( + {"tenant": "acme", "purpose": "field", "table": "customers", "record_id": "42", "field": "email"}, + b"ada@example.com", +) +``` + +The client is `sdk/python/encryptoglyph_client.py`. The browser equivalent is `sdk/js/encryptoglyph.js`. Set `EG_URL` to the sidecar origin. No secret belongs in the repo or in that environment variable. + +A GoDesk app (GitGoat, RailYard, Portkyl, Komboe, Cerebro, or another service) vendors one client and calls `session` then `seal`. The same record context must be presented to open the blob. + +## Updates + +`VERSION` is the product version. `GET /v1/update` compares it with the public GitHub release for `GoDeskio/Encryptoglyph` and returns `update_available` without downloading anything. + +```bash +python scripts/encryptoglyph-update.py --check +python scripts/encryptoglyph-update.py --download /tmp/encryptoglyph-next +``` + +The downloader accepts only `https://api.github.com/repos/GoDeskio/Encryptoglyph/tarball/`, checks the gzip header, and extracts into a new directory. It does not run the archive. + +Tagged releases (`v*`) are checked by `.github/workflows/release.yml`, which fails if the tag disagrees with `VERSION`. Publishing the GitHub release is a separate maintainer step. This tree does not deploy. diff --git a/README.md b/README.md index ed9a8ec..c2eebbd 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Encryptoglyph is an open-source encryption-at-rest library and self-hosted servi ## Features - **XChaCha20-Poly1305 AEAD.** New ciphertext uses a 192-bit nonce and a 256-bit key. The header carries a version, an algorithm id, and a key id. Legacy AES-256-GCM envelopes still decrypt, and `rewrap` upgrades them. Rust (`crates/encryptoglyph`) and Python (`api/eg_crypto`) share the wire format and the vectors in `testvectors/envelope.json`. -- **Key custody.** The compose file runs OpenBao on an internal network for Transit keys and KV. The library itself has no escrow key and no master override. Root keys are 32 bytes, derived with HKDF-SHA256 per tenant, purpose, and algorithm, or from a password with Argon2id. The health-only API does not call OpenBao yet. The custody design is in [docs/plan.md](docs/plan.md) and [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md). +- **Key custody.** The compose file runs OpenBao on an internal network for Transit keys and KV. The library itself has no escrow key and no master override. Root keys are 32 bytes, derived with HKDF-SHA256 per tenant, purpose, and algorithm, or from a password with Argon2id. The console and `/v1` sidecar do not call OpenBao. The custody design is in [docs/plan.md](docs/plan.md) and [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md). - **Encrypted backup.** `restic/rest-server` sits on the same internal network as the backup target. Host ports are not published. Backup scheduling and restore evidence are described in the plan and are not implemented in this tree. -- **MSP console.** Operators are meant to reach `eg-console` through Traefik at `eg.home.arpa`. The compose service is present. Its image is `encryptoglyph-console:placeholder`, and this repository does not contain a console UI. +- **Console.** `console/` is the operator UI, served by the API at `/`. It seals fields, rotates and shreds keys, and runs a deterministic assistant. OpenBao and restic stay unwired until a deployment supplies them. The compose service builds the same image as the API. ## What this tree runs @@ -23,13 +23,13 @@ Encryptoglyph is an open-source encryption-at-rest library and self-hosted servi | Rust envelope, HKDF, Argon2id, key rotation | Implemented and tested | | Python module, wire-compatible with Rust | Implemented and tested | | TypeScript wrapper over the WASM build | Source in `bindings/ts`; `wasm-pack` output is not committed | -| `eg-api` container | `GET /healthz` only. Swagger, ReDoc, and `/openapi.json` are not served | -| OpenBao, Postgres 16, restic rest-server | Images and config in `deploy/docker-compose.yml` | -| MSP console | Service name and Traefik labels only | +| `eg-api` container | `/healthz`, the `/v1` sidecar, and the console. Swagger, ReDoc, and `/openapi.json` are not served | +| OpenBao, Postgres 16, restic rest-server | Images and config in `deploy/docker-compose.yml`. The console reports them as not connected | +| Console | `console/`, served by the API. See [INTEGRATION.md](INTEGRATION.md) | ## Architecture -Traefik is external. Only `eg-console` and `eg-api` join `proxy-net`. OpenBao, Postgres, and the restic rest-server stay on the `internal` network (`internal: true`), which has no route off the Docker host. The API container is the only app process that joins both networks. The crypto libraries are not containers. The API image copies `main.py` only, so the running container does not load `eg_crypto`. +Traefik is external. Only `eg-console` and `eg-api` join `proxy-net`. OpenBao, Postgres, and the restic rest-server stay on the `internal` network (`internal: true`), which has no route off the Docker host. The API container is the only app process that joins both networks. The crypto libraries are not containers. The API image copies the console, `eg_crypto`, and the `/v1` service. ```mermaid flowchart TB @@ -37,7 +37,7 @@ flowchart TB subgraph proxy [proxy-net] traefik[Traefik TLS, external] console["eg-console MSP console"] - api["eg-api GET /healthz"] + api["eg-api console and /v1"] end subgraph internal [internal network, no host ports] openbao[OpenBao key custody] @@ -78,7 +78,7 @@ docker compose -f deploy/docker-compose.yml config `proxy-net` must already exist because the compose file marks it external. `docker compose config` is the check CI runs. Do not commit `.env`. -`docker compose up` needs a local image named `encryptoglyph-console:placeholder`. That image is not published, and the console UI is not in this repository. OpenBao, Postgres, and rest-server also expect real values in `.env` before they will start. +`docker compose up` builds `eg-api` and `eg-console` from `api/Dockerfile`. OpenBao, Postgres, and rest-server still expect real values in `.env` before they will start. The console does not require them. The API image is buildable on its own. It listens on port 8000 inside the container and, in the compose file, publishes no host port. This loopback publish is only for a local health check: @@ -95,20 +95,22 @@ curl -sS http://127.0.0.1:8000/healthz A healthy process returns `{"status":"ok","build":"<12 hex chars>"}`. `/docs`, `/redoc`, `/openapi.json`, and `/swagger` are not served. That is checked by `scripts/assert-exposure.sh`. -Without Docker, from `api/` with Python 3.12: +Without Docker, from `api/` with Python 3.12. This serves `/healthz`, `/v1`, and the console at `/`: ```bash python -m pip install -r requirements.txt BUILD_SHA="$(git rev-parse --short=12 HEAD)" python -m uvicorn main:app --host 127.0.0.1 --port 8000 ``` +Open `http://127.0.0.1:8000`. Adoption snippets are in [INTEGRATION.md](INTEGRATION.md). The assistant uses deterministic commands with no model. Set `OLLAMA_BASE_URL` when a local Ollama should choose those commands. It never receives plaintext or keys. + ## API and SDKs The HTTP API in this repository is the health check above. Envelope encryption is the library surface, with the same record context in each language: tenant, purpose, table, record id, field, and schema version. Those fields are authenticated as additional data, so a ciphertext cannot be moved to another row, field, or tenant. ### Rust -`crates/encryptoglyph` is the source of truth. `unsafe` is forbidden. Default writes are version 2, XChaCha20-Poly1305. +`crates/encryptoglyph` is the source of truth. `unsafe` is forbidden. Default writes are version 3, XChaCha20-Poly1305 with a key commitment. ```rust use encryptoglyph::{decrypt, encrypt, Keyring, RecordContext, VERSION_V2}; @@ -192,7 +194,7 @@ The package is `private` and is not published to npm from this repository. `bind - **Key separation.** A root key never encrypts plaintext directly. HKDF-SHA256 derives a data key from the root key, the purpose, the tenant, and the algorithm. Password root keys use Argon2id with a 16-byte salt. - **Rotation.** A `Keyring` encrypts with the current key id and can decrypt older ids. `rewrap` writes a fresh version-2 blob. The current key cannot be retired. - **Uniform failures.** Decrypt errors do not distinguish a bad tag from a malformed header. -- **No backdoor in the library.** There is no escrow or override key in the Rust or Python code. Custody of root keys is an OpenBao operation, still outside the health-only API. +- **No backdoor in the library.** There is no escrow or override key in the Rust or Python code. Custody of root keys is an OpenBao operation. This console keeps session keys in process memory and says so. - **Network.** Compose does not publish host ports. OpenBao's own UI is disabled. Postgres passwords come from the gitignored env file. - **Dependency licenses.** `cargo deny` allows Apache-2.0, MIT, BSD, ISC, MPL-2.0, Unicode, Zlib, and CC0. The check runs in CI. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3eadc64..0c25d83 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -25,6 +25,20 @@ Encryptoglyph runs the following third-party software unmodified. Attribute them - License: BSD 2-Clause License - Source: https://github.com/restic/rest-server +## fpe + +- Project: fpe (FF1) +- Use: Rust dependency of the format-preserving encryption wrapper +- License: MIT OR Apache-2.0 +- Source: https://github.com/str4d/fpe + +## blahaj + +- Project: blahaj +- Use: Rust dependency for Shamir secret sharing (fixed fork of sharks) +- License: MIT +- Source: https://git.distrust.co/public/blahaj + ## FastAPI - Project: FastAPI diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/api/Dockerfile b/api/Dockerfile index 1de1fb0..83bca1e 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,10 +2,14 @@ FROM python:3.12-slim WORKDIR /app -COPY requirements.txt . +COPY api/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py . +COPY api/main.py /app/main.py +COPY api/eg_crypto /app/eg_crypto +COPY api/eg_api /app/eg_api +COPY console /app/console +COPY VERSION /app/VERSION ENV BUILD_SHA=unknown \ PYTHONDONTWRITEBYTECODE=1 diff --git a/api/eg_api/__init__.py b/api/eg_api/__init__.py new file mode 100644 index 0000000..6ac662f --- /dev/null +++ b/api/eg_api/__init__.py @@ -0,0 +1 @@ +"""Encryptoglyph HTTP service helpers. Crypto stays in eg_crypto.""" diff --git a/api/eg_api/agent.py b/api/eg_api/agent.py new file mode 100644 index 0000000..98e01f2 --- /dev/null +++ b/api/eg_api/agent.py @@ -0,0 +1,174 @@ +"""Deterministic assistant. An optional Ollama hop may only pick an action id. + +The model is never shown plaintext, keys, or the raw user text when that text +looks like a secret. Replies are canned strings from this module. +""" + +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.request + +HELP = ( + "I can check health, create a key, open Seal, Keys, Custody, or Adopt, " + "and check for updates. Plaintext and keys stay out of this chat. " + "Use the Seal form for values." +) +SHRED = ( + "Shred a retired key from the Keys panel and confirm with its fingerprint. " + "That fingerprint never belongs in chat." +) + +_SECRET = re.compile( + r"(?i)\b(password|secret|api[_ -]?key|token|seed|private key|plaintext)\b\s*[:=]" +) +_BLOB = re.compile(r"[A-Za-z0-9+/]{40,}={0,2}") +_NAV = { + "home": "home", + "overview": "home", + "seal": "seal", + "keys": "keys", + "custody": "custody", + "agent": "agent", + "adopt": "adopt", + "integrate": "adopt", + "integration": "adopt", +} +_ACTIONS = { + "help", + "health", + "new-key", + "update", + "navigate:home", + "navigate:seal", + "navigate:keys", + "navigate:custody", + "navigate:agent", + "navigate:adopt", +} + + +def _looks_secret(text: str) -> bool: + if _SECRET.search(text) or _BLOB.search(text): + return True + return any(len(word) > 32 for word in text.split()) + + +def sanitize(text: str) -> str | None: + compact = " ".join(text.split()) + if not compact or len(compact) > 180 or _looks_secret(compact): + return None + return compact + + +def _reply(action: str) -> dict[str, object]: + if action == "health": + return {"reply": "Checking service health.", "actions": [{"op": "health"}], "redacted": False} + if action == "new-key": + return { + "reply": "Creating a key. Only the fingerprint comes back.", + "actions": [{"op": "new-key"}], + "redacted": False, + } + if action == "update": + return { + "reply": "Checking the public release feed.", + "actions": [{"op": "update"}], + "redacted": False, + } + if action.startswith("navigate:"): + view = action.split(":", 1)[1] + return { + "reply": f"Opening {view}.", + "actions": [{"op": "navigate", "view": view}], + "redacted": False, + } + return {"reply": HELP, "actions": [], "redacted": False} + + +def decide(message: str) -> dict[str, object]: + """Map a chat line to a canned reply. `recognized` is false when Ollama may choose.""" + if _looks_secret(message or ""): + return {"reply": HELP, "actions": [], "redacted": True, "recognized": True} + text = sanitize(message or "") + if text is None: + return {"reply": HELP, "actions": [], "redacted": True, "recognized": True} + low = text.lower().strip(" ?.!") + if low in {"help", "what can you do", "what do you do"}: + result = _reply("help") + elif low in {"health", "status"}: + result = _reply("health") + elif low in {"new key", "create key", "create a key", "rotate", "rotate key"}: + result = _reply("new-key") + elif low in {"check updates", "check for updates", "updates", "update"}: + result = _reply("update") + elif low in {"shred", "shred key", "delete key"}: + result = {"reply": SHRED, "actions": [], "redacted": False} + elif low.startswith(("go to ", "open ", "show ")): + target = low.split(" ", 2)[-1].strip() + view = _NAV.get(target) + result = _reply(f"navigate:{view}") if view else _reply("help") + if view is None: + result["recognized"] = False + return result + else: + return {"reply": HELP, "actions": [], "redacted": False, "recognized": False, "safe": text} + result["recognized"] = True + return result + + +def ollama_action(base_url: str, safe_text: str, timeout: float = 2.0) -> str | None: + """Ask Ollama for one action id. The prompt contains only sanitized text.""" + payload = { + "model": os.environ.get("OLLAMA_MODEL", "llama3.2"), + "stream": False, + "messages": [ + { + "role": "system", + "content": ( + "You are the Encryptoglyph assistant. You never see or request keys, " + "passwords, or plaintext. Reply with exactly one of: help, health, " + "new-key, update, navigate:home, navigate:seal, navigate:keys, " + "navigate:custody, navigate:agent, navigate:adopt." + ), + }, + {"role": "user", "content": safe_text}, + ], + } + request = urllib.request.Request( + base_url.rstrip("/") + "/api/chat", + data=json.dumps(payload).encode(), + headers={"content-type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = json.loads(response.read().decode()) + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError, ValueError): + return None + content = str(body.get("message", {}).get("content", "")).strip().lower() + first = content.split()[0] if content else "" + if first in _ACTIONS: + return first + return None + + +def answer(message: str, ollama_base: str | None = None) -> dict[str, object]: + chosen = decide(message) + if chosen.get("recognized"): + chosen.pop("safe", None) + return chosen + safe = str(chosen.get("safe") or "") + base = ollama_base if ollama_base is not None else os.environ.get("OLLAMA_BASE_URL", "") + if base and safe: + action = ollama_action(base, safe) + if action: + resolved = _reply(action) + resolved["recognized"] = True + resolved["provider"] = "ollama" + return resolved + chosen.pop("safe", None) + return chosen diff --git a/api/eg_api/http.py b/api/eg_api/http.py new file mode 100644 index 0000000..f1d8547 --- /dev/null +++ b/api/eg_api/http.py @@ -0,0 +1,379 @@ +"""Versioned HTTP surface and the Encryptoglyph console.""" + +from __future__ import annotations + +import base64 +import hmac +import json +import os +import re +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware + +from eg_crypto import ( + DecryptError, + DowngradeError, + RecordContext, + decrypt, + encrypt, + needs_rewrap, + rewrap, +) +from eg_crypto.envelope import blind_index, fingerprint +from eg_crypto.reauth import ReauthGate, ReauthRequired + +from .agent import answer +from .store import Store +from .updater import check_release, github_fetch, read_version + +_ID = re.compile(r"^[A-Za-z0-9._:-]{1,64}$") +_FORBIDDEN = { + "plaintext", + "plaintext_b64", + "key", + "key_hex", + "password", + "secret", + "dek", + "seed", + "share", + "private_key", +} +_SECURITY = { + "content-security-policy": ( + "default-src 'self'; style-src 'self'; script-src 'self'; " + "img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'" + ), + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "x-frame-options": "DENY", +} + + +def _here() -> Path: + return Path(__file__).resolve() + + +def locate_version() -> Path: + here = _here() + for path in (here.parents[1] / "VERSION", here.parents[2] / "VERSION"): + if path.is_file(): + return path + return here.parents[2] / "VERSION" + + +def locate_console() -> Path: + here = _here() + for path in (here.parents[1] / "console", here.parents[2] / "console"): + if (path / "index.html").is_file(): + return path + return here.parents[2] / "console" + + +def _fail(status: int, error: str) -> JSONResponse: + return JSONResponse({"error": error}, status_code=status) + + +def _b64(value: object, limit: int) -> bytes | None: + if not isinstance(value, str) or not value or len(value) > limit: + return None + try: + raw = base64.b64decode(value, validate=True) + except (ValueError, TypeError): + return None + if len(raw) > 65536: + return None + return raw + + +def _context(body: dict) -> RecordContext | None: + values: dict[str, str] = {} + for name in ("tenant", "purpose", "table", "record_id", "field"): + value = body.get(name, "") + if not isinstance(value, str) or not _ID.fullmatch(value): + return None + values[name] = value + schema = body.get("schema_version", 1) + if isinstance(schema, bool) or not isinstance(schema, int) or not 1 <= schema <= 1000: + return None + return RecordContext( + values["tenant"], + values["purpose"], + values["table"], + values["record_id"], + values["field"], + schema, + ) + + +class _Headers(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers.update(_SECURITY) + response.headers["x-encryptoglyph-api"] = "1" + return response + + +def create_app() -> FastAPI: + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + app.add_middleware(_Headers) + app.state.store = Store() + app.state.gate = ReauthGate(os.urandom(32)) + app.state.version = read_version(locate_version()) + console = locate_console() + app.mount("/static", StaticFiles(directory=console), name="static") + + @app.get("/") + def index() -> FileResponse: + return FileResponse(console / "index.html") + + @app.get("/healthz") + def healthz() -> dict[str, str]: + build = os.environ.get("BUILD_SHA", "")[:12] + return {"status": "ok", "build": build} + + @app.get("/v1/status") + def status() -> dict[str, object]: + return { + "product": "Encryptoglyph", + "version": app.state.version, + "api": "v1", + "aead": "XChaCha20-Poly1305", + "kdf": "HKDF-SHA256", + "password_kdf": "Argon2id", + "custody": "process-memory", + "openbao": "not-connected", + "restic": "not-configured", + "agent": "deterministic", + "ollama": bool(os.environ.get("OLLAMA_BASE_URL")), + } + + @app.get("/v1/integration") + def integration() -> dict[str, str]: + return { + "version": app.state.version, + "api": "v1", + "python": ( + "from eg_crypto import Keyring, RecordContext, encrypt, decrypt\n" + "kr = Keyring({kid: key}, kid)\n" + "blob = encrypt(kr, RecordContext(tenant, purpose, table, record_id, field), plaintext)" + ), + "http": ( + "eg = Encryptoglyph(os.environ['EG_URL'])\n" + "eg.session()\n" + "sealed = eg.seal(ctx, plaintext)" + ), + } + + @app.get("/v1/update") + def update_check() -> dict[str, object]: + return check_release(app.state.version, github_fetch) + + @app.post("/v1/sessions") + def open_session() -> JSONResponse: + try: + sid, session = app.state.store.create() + except RuntimeError: + return _fail(503, "busy") + return JSONResponse({"session_id": sid, "expires_unix": int(session.expires)}) + + def _session(request: Request): + token = request.headers.get("x-eg-session", "") + session = app.state.store.get(token) + if session is None: + return None, None + return token, session + + @app.post("/v1/keys") + def create_key(request: Request) -> JSONResponse: + _token, session = _session(request) + if session is None: + return _fail(401, "session required") + try: + kid, fp = session.add_key() + except ValueError: + return _fail(400, "too many keys") + return JSONResponse({"kid": kid, "fingerprint": fp, "current": True}) + + @app.get("/v1/keys") + def list_keys(request: Request) -> JSONResponse: + _token, session = _session(request) + if session is None: + return _fail(401, "session required") + return JSONResponse({"keys": session.public_keys()}) + + @app.post("/v1/seal") + def seal(request: Request, body: dict) -> JSONResponse: + _token, session = _session(request) + if session is None: + return _fail(401, "session required") + ctx = _context(body) + raw = _b64(body.get("plaintext_b64"), 90_000) + if ctx is None or raw is None: + return _fail(400, "invalid payload") + if not session.current: + try: + session.add_key() + except ValueError: + return _fail(400, "too many keys") + try: + blob = encrypt(session.keyring(), ctx, raw) + except (LookupError, ValueError): + return _fail(400, "invalid payload") + kid = session.current or "" + material = session.keys.get(kid, bytearray()) + return JSONResponse( + { + "blob_b64": base64.b64encode(blob).decode(), + "kid": kid, + "fingerprint": fingerprint(bytes(material)) if material else "", + "version": 3, + "plaintext_bytes": len(raw), + } + ) + + @app.post("/v1/open") + def open_blob(request: Request, body: dict) -> JSONResponse: + _token, session = _session(request) + if session is None: + return _fail(401, "session required") + ctx = _context(body) + blob = _b64(body.get("blob_b64"), 180_000) + if ctx is None or blob is None: + return _fail(400, "invalid payload") + try: + plain = decrypt(session.keyring(), ctx, blob) + except DowngradeError: + return _fail(400, "envelope version below minimum") + except (DecryptError, LookupError, ValueError): + return _fail(400, "decryption failed") + return JSONResponse( + {"plaintext_b64": base64.b64encode(bytes(plain)).decode(), "bytes": len(plain)} + ) + + @app.post("/v1/rewrap") + def rewrap_blob(request: Request, body: dict) -> JSONResponse: + _token, session = _session(request) + if session is None: + return _fail(401, "session required") + ctx = _context(body) + blob = _b64(body.get("blob_b64"), 180_000) + if ctx is None or blob is None: + return _fail(400, "invalid payload") + try: + ring = session.keyring() + upgraded = needs_rewrap(ring, blob) + fresh = rewrap(ring, ctx, blob) if upgraded else blob + except (DecryptError, DowngradeError, LookupError, ValueError): + return _fail(400, "decryption failed") + return JSONResponse( + { + "blob_b64": base64.b64encode(fresh).decode(), + "rewrapped": bool(upgraded), + "kid": session.current, + } + ) + + @app.post("/v1/blind-index") + def blind(request: Request, body: dict) -> JSONResponse: + _token, session = _session(request) + if session is None: + return _fail(401, "session required") + tenant = body.get("tenant", "") + field = body.get("field", "") + value = body.get("value", "") + if ( + not isinstance(tenant, str) + or not isinstance(field, str) + or not isinstance(value, str) + or not _ID.fullmatch(tenant) + or not _ID.fullmatch(field) + or not value + or len(value) > 500 + ): + return _fail(400, "invalid payload") + if not session.current: + return _fail(400, "create a key first") + try: + key = session.keyring().derive(session.current, "blind-index", tenant, 2) + except (LookupError, ValueError, DecryptError): + return _fail(400, "invalid payload") + digest = blind_index(key, tenant, field, value) + return JSONResponse({"index_b64": base64.b64encode(digest).decode(), "bytes": len(value)}) + + @app.post("/v1/backup") + def backup(request: Request) -> JSONResponse: + _token, session = _session(request) + if session is None: + return _fail(401, "session required") + if not session.current: + return _fail(400, "create a key first") + manifest = json.dumps( + {"product": "Encryptoglyph", "version": app.state.version, "keys": session.public_keys()}, + separators=(",", ":"), + ).encode() + ctx = RecordContext("local", "backup", "inventory", "manifest", "json", 1) + try: + blob = encrypt(session.keyring(), ctx, manifest) + except (LookupError, ValueError): + return _fail(400, "invalid payload") + return JSONResponse( + { + "blob_b64": base64.b64encode(blob).decode(), + "filename": "encryptoglyph-inventory.eg", + "note": "Sealed inventory of fingerprints. Key material is not in this file.", + } + ) + + @app.post("/v1/reauth") + def reauth(request: Request, body: dict) -> JSONResponse: + token, session = _session(request) + if session is None or token is None: + return _fail(401, "session required") + kid = body.get("kid", "") + confirm = body.get("confirm_fingerprint", "") + if not isinstance(kid, str) or not isinstance(confirm, str) or kid not in session.keys: + return _fail(400, "invalid payload") + expected = fingerprint(bytes(session.keys[kid])) + if not hmac.compare_digest(confirm.encode(), expected.encode()): + return _fail(403, "confirmation did not match") + try: + grant = app.state.gate.grant(token, "key.shred", kid, verified=True) + except ReauthRequired: + return _fail(403, "confirmation did not match") + return JSONResponse({"grant": grant}) + + @app.post("/v1/shred") + def shred(request: Request, body: dict) -> JSONResponse: + token, session = _session(request) + if session is None or token is None: + return _fail(401, "session required") + kid = body.get("kid", "") + grant = body.get("grant", "") + if not isinstance(kid, str) or not isinstance(grant, str): + return _fail(400, "invalid payload") + try: + app.state.gate.consume(grant, token, "key.shred", kid) + fp = session.shred(kid) + except ReauthRequired: + return _fail(403, "re-authentication required") + except ValueError: + return _fail(400, "cannot shred the current key") + return JSONResponse({"shredded": kid, "fingerprint": fp}) + + @app.post("/v1/agent") + def agent(body: dict) -> JSONResponse: + if not isinstance(body, dict) or _FORBIDDEN.intersection(body): + return _fail(400, "redacted") + message = body.get("message", "") + if not isinstance(message, str) or len(message) > 2000: + return _fail(400, "invalid request") + result = answer(message) + result.pop("safe", None) + return JSONResponse(result) + + return app diff --git a/api/eg_api/store.py b/api/eg_api/store.py new file mode 100644 index 0000000..8e6ddd1 --- /dev/null +++ b/api/eg_api/store.py @@ -0,0 +1,125 @@ +"""Process-memory sessions. Key bytes never appear in repr or JSON.""" + +from __future__ import annotations + +import secrets +import threading +import time +from dataclasses import dataclass, field + +from eg_crypto import Keyring +from eg_crypto.envelope import fingerprint + +SESSION_TTL = 8 * 60 * 60 +MAX_SESSIONS = 200 +MAX_KEYS = 16 + + +def _wipe(buf: bytearray) -> None: + for index in range(len(buf)): + buf[index] = 0 + + +@dataclass +class Session: + created: float + expires: float + keys: dict[str, bytearray] = field(default_factory=dict) + order: list[str] = field(default_factory=list) + current: str | None = None + + def wipe(self) -> None: + for material in self.keys.values(): + _wipe(material) + self.keys.clear() + self.order.clear() + self.current = None + + def keyring(self) -> Keyring: + if not self.current or self.current not in self.keys: + raise LookupError("no key") + public = {kid: bytes(material) for kid, material in self.keys.items()} + return Keyring(public, self.current) + + def add_key(self) -> tuple[str, str]: + if len(self.keys) >= MAX_KEYS: + raise ValueError("too many keys") + number = 1 + while f"k{number}" in self.keys: + number += 1 + kid = f"k{number}" + material = bytearray(secrets.token_bytes(32)) + self.keys[kid] = material + self.order.append(kid) + self.current = kid + return kid, fingerprint(bytes(material)) + + def public_keys(self) -> list[dict[str, object]]: + rows = [] + for kid in self.order: + material = self.keys.get(kid) + if material is None: + continue + rows.append( + { + "kid": kid, + "fingerprint": fingerprint(bytes(material)), + "current": kid == self.current, + } + ) + return rows + + def shred(self, kid: str) -> str: + if kid == self.current or kid not in self.keys: + raise ValueError("cannot shred") + fp = fingerprint(bytes(self.keys[kid])) + _wipe(self.keys.pop(kid)) + self.order = [item for item in self.order if item != kid] + return fp + + +class Store: + def __init__(self) -> None: + self._lock = threading.Lock() + self._sessions: dict[str, Session] = {} + + def reset(self) -> None: + with self._lock: + for session in self._sessions.values(): + session.wipe() + self._sessions.clear() + + def _purge(self, now: float) -> None: + expired = [sid for sid, session in self._sessions.items() if session.expires <= now] + for sid in expired: + self._sessions.pop(sid).wipe() + + def create(self) -> tuple[str, Session]: + with self._lock: + now = time.time() + self._purge(now) + if len(self._sessions) >= MAX_SESSIONS: + raise RuntimeError("busy") + sid = secrets.token_urlsafe(32) + session = Session(created=now, expires=now + SESSION_TTL) + self._sessions[sid] = session + return sid, session + + def get(self, token: str) -> Session | None: + if not token: + return None + with self._lock: + session = self._sessions.get(token) + if session is None: + return None + if session.expires <= time.time(): + self._sessions.pop(token, None) + session.wipe() + return None + return session + + def drop(self, token: str) -> None: + with self._lock: + session = self._sessions.pop(token, None) + if session is not None: + session.wipe() diff --git a/api/eg_api/updater.py b/api/eg_api/updater.py new file mode 100644 index 0000000..d893ab2 --- /dev/null +++ b/api/eg_api/updater.py @@ -0,0 +1,103 @@ +"""Version check and tarball fetch for GoDeskio/Encryptoglyph releases. + +The updater talks only to the GitHub API for this repository. It refuses any +other host, does not run downloaded code, and rejects archive members that +leave the destination directory. +""" + +from __future__ import annotations + +import io +import json +import tarfile +from collections.abc import Callable +from pathlib import Path +from urllib.request import Request, urlopen + +REPO = "GoDeskio/Encryptoglyph" +RELEASES = f"https://api.github.com/repos/{REPO}/releases/latest" +_TARBALL_PREFIX = f"https://api.github.com/repos/{REPO}/tarball/" + + +def parse_version(value: str) -> tuple[int, int, int]: + text = value.strip().lstrip("v") + parts = text.split(".") + if len(parts) != 3 or any(not part.isdigit() for part in parts): + raise ValueError("bad version") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def read_version(path: Path) -> str: + return path.read_text(encoding="utf-8").strip() + + +def allowed_tarball(url: str) -> bool: + if not url.startswith(_TARBALL_PREFIX): + return False + rest = url[len(_TARBALL_PREFIX) :] + return bool(rest) and ".." not in rest and "/" not in rest and " " not in rest + + +def check_release(current: str, fetch: Callable[[str], tuple[int, dict]]) -> dict[str, object]: + unknown = { + "current": current, + "latest": None, + "update_available": False, + "state": "unknown", + "url": None, + } + try: + status, payload = fetch(RELEASES) + except (OSError, ValueError, TimeoutError): + return unknown + if status != 200 or not isinstance(payload, dict): + return unknown + latest = str(payload.get("tag_name") or "").lstrip("v") + try: + newer = parse_version(latest) > parse_version(current) + except ValueError: + return unknown + url = payload.get("tarball_url") if isinstance(payload.get("tarball_url"), str) else "" + safe = url if isinstance(url, str) and allowed_tarball(url) else None + return { + "current": current, + "latest": latest, + "update_available": newer, + "state": "available" if newer else "current", + "url": safe, + } + + +def github_fetch(url: str, timeout: float = 5.0) -> tuple[int, dict]: + if url != RELEASES and not allowed_tarball(url): + raise ValueError("refusing url") + request = Request(url, headers={"accept": "application/vnd.github+json", "user-agent": "encryptoglyph-updater"}) + with urlopen(request, timeout=timeout) as response: + return response.status, json.loads(response.read().decode()) + + +def download_tarball(url: str, timeout: float = 20.0) -> bytes: + if not allowed_tarball(url): + raise ValueError("refusing url") + request = Request(url, headers={"user-agent": "encryptoglyph-updater", "accept": "application/vnd.github+json"}) + with urlopen(request, timeout=timeout) as response: + blob = response.read() + if blob[:2] != b"\x1f\x8b": + raise ValueError("not a gzip tarball") + return blob + + +def extract_tarball(blob: bytes, dest: Path) -> None: + """Extract a release tree into a new directory. No links, no parent paths.""" + if blob[:2] != b"\x1f\x8b": + raise ValueError("not a gzip tarball") + if dest.exists(): + raise ValueError("destination exists") + dest.mkdir() + with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as archive: + members = archive.getmembers() + for member in members: + parts = Path(member.name).parts + if member.islnk() or member.issym() or member.name.startswith("/") or ".." in parts: + raise ValueError("unsafe path") + archive.extractall(dest, members=members, filter="data") diff --git a/api/eg_crypto/__init__.py b/api/eg_crypto/__init__.py index 8fb6765..2833089 100644 --- a/api/eg_crypto/__init__.py +++ b/api/eg_crypto/__init__.py @@ -27,6 +27,7 @@ rewrap, verify_blind_index, ) +from .reauth import ReauthGate, ReauthRequired __all__ = [ "ALG_AES256GCM", @@ -38,6 +39,8 @@ "DecryptError", "DowngradeError", "Keyring", + "ReauthGate", + "ReauthRequired", "RecordContext", "blind_index", "ct_equal", diff --git a/api/eg_crypto/envelope.py b/api/eg_crypto/envelope.py index 473eeaf..60aa72a 100644 --- a/api/eg_crypto/envelope.py +++ b/api/eg_crypto/envelope.py @@ -169,6 +169,12 @@ def _commitment(commit_key: bytes, aad: bytes) -> bytes: return hmac.new(commit_key, b"EGCMT1" + aad, hashlib.sha256).digest() +def fingerprint(key: bytes) -> str: + """First 16 bytes of SHA-256(label || key), hex. Safe to log. Not the key.""" + digest = hashlib.sha256(b"encryptoglyph/key-fingerprint/v1" + key).hexdigest() + return digest[:32] + + def encrypt(keyring: Keyring, ctx: RecordContext, plaintext: bytes) -> bytes: """Encrypt with the default v3 envelope: XChaCha20-Poly1305 plus a 32-byte key commitment. @@ -176,9 +182,19 @@ def encrypt(keyring: Keyring, ctx: RecordContext, plaintext: bytes) -> bytes: one ciphertext cannot decrypt validly under two different keys (no partitioning-oracle or "invisible salamander" attacks). """ + return encrypt_with_nonce(keyring, ctx, plaintext, os.urandom(24)) + + +def encrypt_with_nonce(keyring: Keyring, ctx: RecordContext, plaintext: bytes, nonce: bytes) -> bytes: + """v3 writer with a caller-supplied nonce. + + Nonce reuse under one key destroys confidentiality. Known-answer tests use this. + Production callers use ``encrypt``. + """ + if len(nonce) != 24: + raise ValueError("nonce must be 24 bytes") alg = ALG_XCHACHA20POLY1305_COMMIT dek = keyring.derive(keyring.current, ctx.purpose, ctx.tenant, alg) - nonce = os.urandom(24) hdr = _header(VERSION_V3, alg, keyring.current, nonce) aad = hdr + ctx.aad() enc_key, commit_key = _commit_keys(dek, nonce) @@ -214,13 +230,19 @@ def decrypt(keyring: Keyring, ctx: RecordContext, blob: bytes, *, min_version: i try: if alg == ALG_XCHACHA20POLY1305_COMMIT: enc_key, commit_key = _commit_keys(key, nonce) - if not hmac.compare_digest(body[:_COMMIT_LEN], _commitment(commit_key, aad)): + expect = _commitment(commit_key, aad) + # Open the AEAD even when the commitment is wrong so the two + # failure classes take the same path. + opened = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( + body[_COMMIT_LEN:], aad, nonce, enc_key + ) + if not hmac.compare_digest(body[:_COMMIT_LEN], expect): raise DecryptError("decryption failed") - return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(body[_COMMIT_LEN:], aad, nonce, enc_key) + return opened if alg == ALG_XCHACHA20POLY1305: return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(body, aad, nonce, key) return AESGCM(key).decrypt(nonce, body, aad) - except (CryptoError, InvalidTag, ValueError): + except (CryptoError, InvalidTag, ValueError, DecryptError): raise DecryptError("decryption failed") from None diff --git a/api/main.py b/api/main.py index 640b9cc..ce440be 100644 --- a/api/main.py +++ b/api/main.py @@ -1,11 +1,3 @@ -import os +from eg_api.http import create_app -from fastapi import FastAPI - -app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) - - -@app.get("/healthz") -def healthz() -> dict[str, str]: - build = os.environ.get("BUILD_SHA", "")[:12] - return {"status": "ok", "build": build} +app = create_app() diff --git a/api/tests/test_console.py b/api/tests/test_console.py new file mode 100644 index 0000000..dfc0a73 --- /dev/null +++ b/api/tests/test_console.py @@ -0,0 +1,163 @@ +import base64 +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest +from fastapi.testclient import TestClient + +from main import app + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setenv("BUILD_SHA", "0123456789abcdef") + monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) + app.state.store.reset() + return TestClient(app) + + +def _session(client: TestClient) -> dict[str, str]: + opened = client.post("/v1/sessions") + assert opened.status_code == 200 + return {"x-eg-session": opened.json()["session_id"]} + + +def test_home_is_branded_and_health_stays_narrow(client: TestClient) -> None: + page = client.get("/") + assert page.status_code == 200 + assert "Seal the field" in page.text + assert 'data-testid="hero"' in page.text + health = client.get("/healthz") + assert health.json() == {"status": "ok", "build": "0123456789ab"} + for path in ("/docs", "/redoc", "/openapi.json", "/swagger"): + assert client.get(path).status_code == 404 + + +def test_seal_open_rewrap_and_shred_roundtrip(client: TestClient) -> None: + headers = _session(client) + created = client.post("/v1/keys", headers=headers) + assert created.status_code == 200 + body = created.json() + assert set(body) == {"kid", "fingerprint", "current"} + assert len(body["fingerprint"]) == 32 + + ctx = { + "tenant": "acme", + "purpose": "field", + "table": "customers", + "record_id": "42", + "field": "email", + "schema_version": 1, + } + secret = "ada@example.com" + sealed = client.post( + "/v1/seal", + headers=headers, + json={**ctx, "plaintext_b64": base64.b64encode(secret.encode()).decode()}, + ) + assert sealed.status_code == 200 + payload = sealed.json() + assert secret not in json.dumps(payload) + assert "key" not in payload + + opened = client.post("/v1/open", headers=headers, json={**ctx, "blob_b64": payload["blob_b64"]}) + assert base64.b64decode(opened.json()["plaintext_b64"]) == secret.encode() + + rotated = client.post("/v1/keys", headers=headers) + assert rotated.json()["kid"] != body["kid"] + rewrapped = client.post( + "/v1/rewrap", headers=headers, json={**ctx, "blob_b64": payload["blob_b64"]} + ) + assert rewrapped.status_code == 200 + assert rewrapped.json()["rewrapped"] is True + assert rewrapped.json()["blob_b64"] != payload["blob_b64"] + + denied = client.post( + "/v1/reauth", + headers=headers, + json={"kid": body["kid"], "confirm_fingerprint": "0" * 32}, + ) + assert denied.status_code == 403 + listed = client.get("/v1/keys", headers=headers).json()["keys"] + old = next(row for row in listed if row["kid"] == body["kid"]) + grant = client.post( + "/v1/reauth", + headers=headers, + json={"kid": body["kid"], "confirm_fingerprint": old["fingerprint"]}, + ) + assert grant.status_code == 200 + shredded = client.post( + "/v1/shred", headers=headers, json={"kid": body["kid"], "grant": grant.json()["grant"]} + ) + assert shredded.status_code == 200 + assert client.post( + "/v1/open", headers=headers, json={**ctx, "blob_b64": payload["blob_b64"]} + ).status_code == 400 + + +def test_agent_never_echoes_secrets_and_runs_without_a_model(client: TestClient) -> None: + planted = "hunter2-plaintext" + rejected = client.post("/v1/agent", json={"message": "health", "password": planted}) + assert rejected.status_code == 400 + assert planted not in rejected.text + + leaked = client.post("/v1/agent", json={"message": f"password={planted}"}) + assert leaked.status_code == 200 + assert planted not in leaked.text + assert leaked.json()["redacted"] is True + + health = client.post("/v1/agent", json={"message": "health"}) + assert health.json()["actions"] == [{"op": "health"}] + moved = client.post("/v1/agent", json={"message": "go to keys"}) + assert moved.json()["actions"] == [{"op": "navigate", "view": "keys"}] + + +def test_ollama_receives_only_sanitized_text(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, str] = {} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + raw = self.rfile.read(length).decode() + seen["body"] = raw + reply = json.dumps({"message": {"content": "health"}}).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(reply))) + self.end_headers() + self.wfile.write(reply) + + def log_message(self, fmt: str, *args: object) -> None: + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setenv("OLLAMA_BASE_URL", f"http://127.0.0.1:{server.server_port}") + try: + response = client.post("/v1/agent", json={"message": "how does rotation work"}) + finally: + server.shutdown() + assert response.status_code == 200 + assert response.json()["actions"] == [{"op": "health"}] + assert "hunter2" not in seen["body"] + assert "how does rotation work" in seen["body"] + + +def test_backup_has_fingerprints_not_key_bytes(client: TestClient) -> None: + headers = _session(client) + client.post("/v1/keys", headers=headers) + saved = client.post("/v1/backup", headers=headers) + assert saved.status_code == 200 + assert "Key material is not" in saved.json()["note"] + blob = base64.b64decode(saved.json()["blob_b64"]) + assert b"k1" not in blob or blob[:2] == b"EG" + + +def test_status_is_honest(client: TestClient) -> None: + status = client.get("/v1/status").json() + assert status["openbao"] == "not-connected" + assert status["restic"] == "not-configured" + assert status["aead"] == "XChaCha20-Poly1305" + assert status["version"] == "0.2.0" diff --git a/api/tests/test_crypto.py b/api/tests/test_crypto.py index ea885f4..70bf1fb 100644 --- a/api/tests/test_crypto.py +++ b/api/tests/test_crypto.py @@ -158,6 +158,11 @@ def test_shared_vectors(): for f in ("v3_hex", "v2_hex", "v1_hex"): assert decrypt(kr, ctx, bytes.fromhex(v[f])) == v["plaintext"].encode() assert blind_index(bytes.fromhex(v["key_hex"]), "tenant-a", "email", "Foo@Example.com").hex() == v["blind_index_hex"] + blob = bytes.fromhex(v["v3_hex"]) + _ver, _alg, _kid, nonce, _hdr, _body = parse_header(blob) + from eg_crypto.envelope import encrypt_with_nonce + + assert encrypt_with_nonce(kr, ctx, v["plaintext"].encode(), nonce) == blob # --- v3 key commitment ------------------------------------------------------- diff --git a/api/tests/test_updater.py b/api/tests/test_updater.py new file mode 100644 index 0000000..4a3ab47 --- /dev/null +++ b/api/tests/test_updater.py @@ -0,0 +1,51 @@ +import io +import tarfile +from pathlib import Path + +import pytest + +from eg_api.updater import allowed_tarball, check_release, extract_tarball, parse_version + + +def test_release_check_ignores_foreign_urls() -> None: + def fetch(_url: str) -> tuple[int, dict]: + return 200, { + "tag_name": "v0.3.0", + "tarball_url": "https://evil.example/tarball/v0.3.0", + } + + report = check_release("0.2.0", fetch) + assert report["update_available"] is True + assert report["url"] is None + assert allowed_tarball("https://api.github.com/repos/GoDeskio/Encryptoglyph/tarball/v0.3.0") + assert not allowed_tarball("https://api.github.com/repos/GoDeskio/Encryptoglyph/tarball/v0.3.0/../../etc") + + +def test_extract_rejects_parent_paths(tmp_path: Path) -> None: + blob = io.BytesIO() + with tarfile.open(fileobj=blob, mode="w:gz") as archive: + payload = b"nope" + info = tarfile.TarInfo("../evil.txt") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + with pytest.raises(ValueError): + extract_tarball(blob.getvalue(), tmp_path / "out") + assert not (tmp_path / "evil.txt").exists() + + +def test_extract_writes_a_safe_member(tmp_path: Path) -> None: + blob = io.BytesIO() + with tarfile.open(fileobj=blob, mode="w:gz") as archive: + payload = b"encryptoglyph" + info = tarfile.TarInfo("encryptoglyph-0.2.0/VERSION") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + dest = tmp_path / "out" + extract_tarball(blob.getvalue(), dest) + assert (dest / "encryptoglyph-0.2.0" / "VERSION").read_bytes() == b"encryptoglyph" + + +def test_parse_version() -> None: + assert parse_version("v0.2.0") == (0, 2, 0) + with pytest.raises(ValueError): + parse_version("latest") diff --git a/console/app.js b/console/app.js new file mode 100644 index 0000000..918724f --- /dev/null +++ b/console/app.js @@ -0,0 +1,326 @@ +const sessionKey = "eg.session"; +let session = sessionStorage.getItem(sessionKey) || ""; + +const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; +document.documentElement.dataset.motion = reduce ? "reduce" : "full"; +if (!reduce) { + window.addEventListener("pointermove", (event) => { + document.documentElement.style.setProperty("--px", `${event.clientX}px`); + document.documentElement.style.setProperty("--py", `${event.clientY}px`); + }); +} + +const $ = (id) => document.getElementById(id); + +function b64encode(text) { + const bytes = new TextEncoder().encode(text); + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +} + +function b64decode(value) { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +function looksSecret(text) { + if (/(password|secret|api[_ -]?key|token|seed|private key|plaintext)\s*[:=]/i.test(text)) return true; + return /[A-Za-z0-9+/]{40,}={0,2}/.test(text); +} + +async function api(path, body, retry = true) { + const headers = {}; + if (session) headers["x-eg-session"] = session; + const options = { headers }; + if (body !== undefined) { + headers["content-type"] = "application/json"; + options.method = "POST"; + options.body = JSON.stringify(body); + } + const response = await fetch(path, options); + const data = await response.json().catch(() => ({})); + if (response.status === 401 && retry && path !== "/v1/sessions") { + session = ""; + sessionStorage.removeItem(sessionKey); + await ensureSession(); + return api(path, body, false); + } + if (!response.ok) { + const error = new Error(data.error || "request failed"); + error.status = response.status; + throw error; + } + return data; +} + +async function ensureSession() { + if (session) return; + const data = await api("/v1/sessions", {}); + session = data.session_id; + sessionStorage.setItem(sessionKey, session); +} + +function show(view) { + document.querySelectorAll("[data-view-panel]").forEach((panel) => { + panel.hidden = panel.dataset.viewPanel !== view; + }); + document.querySelectorAll("[data-nav]").forEach((button) => { + if (button.dataset.nav === view) button.setAttribute("aria-current", "page"); + else button.removeAttribute("aria-current"); + }); + $("nav-links").classList.remove("open"); + $("menu").setAttribute("aria-expanded", "false"); + if (view === "keys") refreshKeys(); + if (view === "custody") refreshCustody(); +} + +function contextBody(extra) { + return { + tenant: $("tenant").value.trim(), + purpose: "field", + table: $("table").value.trim(), + record_id: $("record").value.trim(), + field: $("field").value.trim(), + schema_version: 1, + ...extra, + }; +} + +async function refreshKeys() { + await ensureSession(); + const data = await api("/v1/keys"); + const list = $("key-list"); + list.replaceChildren(); + if (!data.keys.length) { + const empty = document.createElement("p"); + empty.className = "glass"; + empty.textContent = "No keys yet."; + list.append(empty); + return; + } + data.keys.forEach((row) => { + const card = document.createElement("article"); + card.className = "glass key"; + const text = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = row.current ? `${row.kid} ยท current` : row.kid; + const code = document.createElement("code"); + code.textContent = row.fingerprint; + text.append(title, document.createElement("br"), code); + card.append(text); + if (!row.current) { + const button = document.createElement("button"); + button.className = "btn"; + button.type = "button"; + button.textContent = "Shred"; + button.dataset.kid = row.kid; + button.addEventListener("click", () => shred(row.kid)); + card.append(button); + } + list.append(card); + }); +} + +async function shred(kid) { + const confirm = $("shred-confirm").value.trim(); + try { + const grant = await api("/v1/reauth", { kid, confirm_fingerprint: confirm }); + const result = await api("/v1/shred", { kid, grant: grant.grant }); + $("key-status").textContent = `Shredded ${result.shredded}. Fingerprint ${result.fingerprint}.`; + $("shred-confirm").value = ""; + await refreshKeys(); + } catch (error) { + $("key-status").textContent = error.message; + } +} + +async function refreshCustody() { + const data = await api("/v1/status"); + const list = $("custody-status"); + list.replaceChildren(); + [ + ["Custody", data.custody], + ["OpenBao", data.openbao], + ["Backup target", data.restic], + ["AEAD", data.aead], + ["Agent", data.ollama ? "deterministic, Ollama available" : "deterministic"], + ].forEach(([name, value]) => { + const term = document.createElement("dt"); + term.textContent = name; + const detail = document.createElement("dd"); + detail.textContent = String(value); + list.append(term, detail); + }); +} + +function addBubble(text, role) { + const line = document.createElement("p"); + line.className = `bubble ${role}`; + line.textContent = text; + $("transcript").append(line); +} + +async function runAgent(text) { + const trimmed = text.trim(); + if (!trimmed) return; + if (looksSecret(trimmed)) { + addBubble("That stays out of the assistant. Use the Seal form for values.", "agent"); + $("agent-input").value = ""; + return; + } + addBubble(trimmed, "user"); + $("agent-input").value = ""; + const data = await api("/v1/agent", { message: trimmed }); + addBubble(data.reply, "agent"); + for (const action of data.actions || []) { + if (action.op === "navigate") show(action.view); + if (action.op === "health") { + const health = await fetch("/healthz").then((response) => response.json()); + addBubble(`Health is ${health.status}. Build ${health.build || "local"}.`, "agent"); + } + if (action.op === "new-key") { + await ensureSession(); + const key = await api("/v1/keys", {}); + addBubble(`Key ${key.kid}. Fingerprint ${key.fingerprint}.`, "agent"); + await refreshKeys(); + } + if (action.op === "update") await checkUpdate(); + } +} + +async function checkUpdate() { + const data = await api("/v1/update"); + const target = $("update-status"); + if (data.state === "unknown") { + target.textContent = `This build is ${data.current}. The release feed did not answer.`; + } else if (data.update_available) { + target.textContent = `Update ${data.latest} is published. This build is ${data.current}.`; + } else { + target.textContent = `This build is ${data.current} and matches the latest release.`; + } +} + +document.querySelectorAll("[data-nav]").forEach((button) => { + button.addEventListener("click", () => show(button.dataset.nav)); +}); + +$("menu").addEventListener("click", () => { + const open = $("nav-links").classList.toggle("open"); + $("menu").setAttribute("aria-expanded", String(open)); +}); + +$("hero-update").addEventListener("click", () => { + checkUpdate().catch((error) => { + $("update-status").textContent = error.message; + }); +}); + +$("seal-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await ensureSession(); + const value = $("plaintext").value; + if (!value) { + $("seal-status").textContent = "Enter a value to seal."; + return; + } + const sealed = await api("/v1/seal", contextBody({ plaintext_b64: b64encode(value) })); + $("ciphertext").value = sealed.blob_b64; + $("seal-status").textContent = `Sealed ${sealed.plaintext_bytes} bytes with ${sealed.kid}. Fingerprint ${sealed.fingerprint}.`; + $("opened").textContent = ""; + } catch (error) { + $("seal-status").textContent = error.message; + } +}); + +$("open-blob").addEventListener("click", async () => { + try { + await ensureSession(); + const opened = await api("/v1/open", contextBody({ blob_b64: $("ciphertext").value.trim() })); + $("opened").textContent = b64decode(opened.plaintext_b64); + $("seal-status").textContent = `Opened ${opened.bytes} bytes in this panel.`; + } catch (error) { + $("seal-status").textContent = error.message; + } +}); + +$("rewrap-blob").addEventListener("click", async () => { + try { + await ensureSession(); + const fresh = await api("/v1/rewrap", contextBody({ blob_b64: $("ciphertext").value.trim() })); + $("ciphertext").value = fresh.blob_b64; + $("seal-status").textContent = fresh.rewrapped ? `Rewrapped under ${fresh.kid}.` : "Already on the current key."; + } catch (error) { + $("seal-status").textContent = error.message; + } +}); + +$("index-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await ensureSession(); + const value = $("index-value").value; + const indexed = await api("/v1/blind-index", { + tenant: $("tenant").value.trim(), + field: $("field").value.trim(), + value, + }); + $("index-status").textContent = `Index ready (${indexed.bytes} bytes hashed).`; + $("index-status").dataset.index = indexed.index_b64; + } catch (error) { + $("index-status").textContent = error.message; + } +}); + +$("new-key").addEventListener("click", async () => { + try { + await ensureSession(); + const key = await api("/v1/keys", {}); + $("key-status").textContent = `Created ${key.kid}. Fingerprint ${key.fingerprint}.`; + await refreshKeys(); + } catch (error) { + $("key-status").textContent = error.message; + } +}); + +$("backup").addEventListener("click", async () => { + try { + await ensureSession(); + const file = await api("/v1/backup", {}); + const link = document.createElement("a"); + link.href = `data:application/octet-stream;base64,${file.blob_b64}`; + link.download = file.filename; + link.click(); + $("backup-status").textContent = `Sealed inventory downloaded. ${file.note}`; + } catch (error) { + $("backup-status").textContent = error.message; + } +}); + +document.querySelectorAll("[data-agent]").forEach((button) => { + button.addEventListener("click", () => { + runAgent(button.dataset.agent).catch((error) => addBubble(error.message, "agent")); + }); +}); + +$("agent-form").addEventListener("submit", (event) => { + event.preventDefault(); + runAgent($("agent-input").value).catch((error) => addBubble(error.message, "agent")); +}); + +async function boot() { + const status = await api("/v1/status"); + $("foot-version").textContent = status.version; + const integration = await api("/v1/integration"); + $("snippet-python").textContent = integration.python; + $("snippet-http").textContent = integration.http; + show("home"); +} + +boot().catch((error) => { + $("update-status").textContent = error.message; +}); diff --git a/console/index.html b/console/index.html new file mode 100644 index 0000000..9c76c2f --- /dev/null +++ b/console/index.html @@ -0,0 +1,180 @@ + + + + + + + Encryptoglyph + + + + +
+ + + Encryptoglyph + + + +
+ +
+
+
+

Encryption at rest

+

Seal the field. Keep the key.

+

Encryptoglyph wraps each value in an XChaCha20-Poly1305 envelope, derives the data key with HKDF, and leaves plaintext out of the assistant.

+
+ + + +
+

+
+
+
+

Field sealing

+

Record context is authenticated with the ciphertext, so a blob cannot move to another row.

+
+
+

Key fingerprints

+

The console shows a fingerprint. The key bytes stay in process memory and are wiped on shred.

+
+
+

Vaults that already seal passwords

+

Komboe adopts this envelope for vault items.

+
+
+

Encrypted file backup

+

Inventory here is a sealed manifest. For whole-file backup, restic is the usual tool, and age seals files.

+
+ +
+
+ + + + + + + + + + +
+ + + + diff --git a/console/styles.css b/console/styles.css new file mode 100644 index 0000000..25b5265 --- /dev/null +++ b/console/styles.css @@ -0,0 +1,225 @@ +:root { + color-scheme: dark; + --bg: #10151b; + --bg-2: #171e27; + --ink: #f4efe6; + --muted: #c4bdb1; + --gold: #e4c48a; + --line: rgba(244, 239, 230, 0.14); + --glass: rgba(244, 239, 230, 0.045); + --shadow: #07090c; + --lift: #314052; + --px: 70%; + --py: 0px; + font-family: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; min-height: 100%; } + +body { + background: + radial-gradient(900px 420px at 10% -10%, rgba(228, 196, 138, 0.16), transparent 55%), + var(--bg); + color: var(--ink); +} + +.glow { + pointer-events: none; + position: fixed; + inset: 0; + background: radial-gradient(540px circle at var(--px) var(--py), rgba(228, 196, 138, 0.16), transparent 42%); + z-index: 0; +} + +html[data-motion="reduce"] .glow { + background: radial-gradient(540px circle at 70% 0, rgba(228, 196, 138, 0.12), transparent 46%); +} + +body > * { position: relative; z-index: 1; } + +a { color: var(--gold); } + +.top { + display: flex; + align-items: center; + gap: 1rem; + width: min(1120px, calc(100% - 2rem)); + margin: 0 auto; + padding: 1rem 0; + position: sticky; + top: 0; + z-index: 2; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.7rem; + color: var(--ink); + text-decoration: none; + font-family: Palatino, "Palatino Linotype", Georgia, serif; + font-size: 1.25rem; + letter-spacing: 0.01em; +} + +.mark { width: 36px; height: 36px; } +.mark rect { fill: #1a222c; stroke: var(--gold); } +.mark path { fill: none; stroke: var(--gold); stroke-width: 2.2; stroke-linecap: round; } + +.nav-links { display: flex; gap: 0.4rem; margin-left: auto; flex-wrap: wrap; } +.nav-links button { + background: transparent; + color: var(--muted); + border: 0; + border-radius: 999px; + padding: 0.45rem 0.8rem; + font: inherit; + cursor: pointer; +} +.nav-links button[aria-current="page"] { color: var(--ink); background: rgba(244, 239, 230, 0.06); } + +.menu { display: none; margin-left: auto; } + +main { width: min(1120px, calc(100% - 2rem)); margin: 0 auto; padding-bottom: 4rem; } + +.hero { padding: 3.5rem 0 2rem; max-width: 46rem; } +.eyebrow { + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--gold); + font-size: 0.78rem; + margin: 0 0 0.8rem; +} +h1 { + font-family: Palatino, "Palatino Linotype", Georgia, serif; + font-weight: 500; + font-size: clamp(2.6rem, 6vw, 5rem); + line-height: 0.95; + margin: 0 0 1rem; +} +.lede { color: var(--muted); font-size: 1.15rem; line-height: 1.55; margin: 0 0 1.5rem; } +.hero-actions, .row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: end; } +.update, #seal-status, #key-status, #backup-status, #index-status { color: var(--muted); min-height: 1.4em; } + +.btn { + appearance: none; + border: 0; + border-radius: 14px; + padding: 0.8rem 1.15rem; + background: #1a222c; + color: var(--ink); + box-shadow: 7px 7px 16px var(--shadow), -5px -5px 14px var(--lift); + font: inherit; + cursor: pointer; +} +.btn.primary { color: #f8ecd4; } +.btn:hover { transform: translateY(-1px); } +.btn:active { transform: none; box-shadow: inset 5px 5px 12px var(--shadow), inset -3px -3px 8px var(--lift); } +.btn:focus-visible, input:focus-visible, textarea:focus-visible, .nav-links button:focus-visible, a:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 3px; +} + +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; } +.stack { display: grid; gap: 0.8rem; margin: 1rem 0; } + +.glass { + background: var(--glass); + border: 1px solid var(--line); + border-radius: 22px; + box-shadow: 0 24px 50px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(16px); + padding: 1.15rem 1.2rem 1.25rem; +} +.glass h2 { font-family: Palatino, Georgia, serif; font-weight: 500; margin: 0 0 0.4rem; font-size: 1.35rem; } +.glass p { color: var(--muted); line-height: 1.5; } + +.section-head { padding: 1.5rem 0 0.5rem; max-width: 40rem; } +.section-head h1 { font-size: clamp(2.2rem, 5vw, 3.4rem); } +.section-head p { color: var(--muted); } + +.form { display: grid; gap: 0.8rem; margin-top: 1rem; } +.split { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.7rem; } +label { display: grid; gap: 0.35rem; color: var(--muted); font-size: 0.92rem; } +input, textarea, output { + width: 100%; + border: 0; + border-radius: 12px; + padding: 0.75rem 0.8rem; + background: #121820; + color: var(--ink); + box-shadow: inset 4px 4px 10px var(--shadow), inset -3px -3px 8px #263242; + font: inherit; +} +output { min-height: 2.8rem; display: block; white-space: pre-wrap; word-break: break-word; } +textarea { resize: vertical; } + +.key { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; +} +.key code { color: var(--gold); word-break: break-all; } +.status { display: grid; grid-template-columns: auto 1fr; gap: 0.45rem 1rem; } +.status dt { color: var(--muted); } +.status dd { margin: 0; } + +.transcript { min-height: 12rem; display: grid; gap: 0.6rem; align-content: start; margin: 1rem 0; } +.bubble { margin: 0; padding: 0.7rem 0.85rem; border-radius: 14px; background: rgba(0, 0, 0, 0.2); } +.bubble.user { justify-self: end; background: rgba(228, 196, 138, 0.12); } +.grow { flex: 1; } + +pre { + white-space: pre-wrap; + word-break: break-word; + margin: 0; + color: var(--ink); +} + +.foot { + width: min(1120px, calc(100% - 2rem)); + margin: 0 auto 2rem; + display: flex; + gap: 1rem; + color: var(--muted); + font-size: 0.9rem; +} + +@media (max-width: 800px) { + .split { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 720px) { + .menu { display: inline-flex; } + .nav-links { + display: none; + position: absolute; + top: 4.2rem; + right: 0; + left: 0; + margin: 0; + padding: 0.6rem; + flex-direction: column; + background: rgba(16, 21, 27, 0.92); + border: 1px solid var(--line); + border-radius: 18px; + backdrop-filter: blur(16px); + } + .nav-links.open { display: flex; } + .top { width: min(1120px, calc(100% - 1.25rem)); } + main, .foot { width: min(1120px, calc(100% - 1.25rem)); } + .hero { padding-top: 1.6rem; } + .split { grid-template-columns: 1fr; } + .key { flex-direction: column; align-items: stretch; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation: none !important; + transition: none !important; + } + .btn:hover { transform: none; } +} diff --git a/crates/encryptoglyph/Cargo.lock b/crates/encryptoglyph/Cargo.lock index ff1972e..fbb07c4 100644 --- a/crates/encryptoglyph/Cargo.lock +++ b/crates/encryptoglyph/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -37,6 +37,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "argon2" version = "0.5.3" @@ -45,16 +51,33 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "blahaj" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487933050478214ae28623a5095231bce28713322e85e32d30fce4e760c96f3a" +dependencies = [ + "hashbrown", + "rand", + "zeroize", +] + [[package]] name = "blake2" version = "0.10.6" @@ -79,6 +102,15 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -93,7 +125,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -103,7 +146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -135,6 +178,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -142,7 +194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -162,7 +214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -221,7 +273,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -235,13 +287,15 @@ dependencies = [ "aes", "aes-gcm", "argon2", + "blahaj", "chacha20poly1305", "ed25519-dalek", - "getrandom", + "fpe", + "getrandom 0.2.17", "hkdf", "hmac", "ml-kem", - "rand_core", + "rand_core 0.6.4", "sha2", "subtle", "wasm-bindgen", @@ -249,12 +303,38 @@ dependencies = [ "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fpe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c4b37de5ae15812a764c958297cfc50f5c010438f60c6ce75d11b802abd404" +dependencies = [ + "cbc", + "cipher", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -302,6 +382,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + [[package]] name = "ghash" version = "0.5.1" @@ -312,6 +404,17 @@ dependencies = [ "polyval", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -365,7 +468,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -374,7 +477,7 @@ version = "0.3.0-pre.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" dependencies = [ - "rand_core", + "rand_core 0.6.4", "zeroize", ] @@ -384,6 +487,12 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "ml-kem" version = "0.2.3" @@ -392,10 +501,38 @@ checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" dependencies = [ "hybrid-array", "kem", - "rand_core", + "rand_core 0.6.4", "sha3", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "opaque-debug" version = "0.3.1" @@ -409,7 +546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -435,7 +572,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -447,7 +584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -470,15 +607,38 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rustc_version" version = "0.4.1" @@ -537,7 +697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -557,7 +717,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -689,7 +849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/crates/encryptoglyph/Cargo.toml b/crates/encryptoglyph/Cargo.toml index 95024d2..d268b40 100644 --- a/crates/encryptoglyph/Cargo.toml +++ b/crates/encryptoglyph/Cargo.toml @@ -29,3 +29,5 @@ subtle = "2.6" zeroize = { version = "1.8", features = ["derive"] } getrandom = "0.2" wasm-bindgen = { version = "0.2", optional = true } +fpe = "0.6.1" +blahaj = "0.7" diff --git a/crates/encryptoglyph/src/ff1.rs b/crates/encryptoglyph/src/ff1.rs index 463e091..efce9fd 100644 --- a/crates/encryptoglyph/src/ff1.rs +++ b/crates/encryptoglyph/src/ff1.rs @@ -1,186 +1,90 @@ -//! FF1 format-preserving encryption (NIST SP 800-38G), written from the spec (backlog #9). -//! Encrypts a digit string into another string of the same length and radix, for example a -//! card number or SSN in legacy columns. +//! FF1 format-preserving encryption (NIST SP 800-38G) via the `fpe` crate +//! (Jack Grigg's reviewed implementation). AES is the block cipher. This module +//! only maps alphabets onto numeral strings. //! -//! Limits: radix 2..=65536, length 2..=max, where radix^ceil(len/2) < 2^120 (all arithmetic is -//! u128), and radix^len >= 1_000_000 (the SP 800-38G Rev.1 minimum domain size). -//! FPE leaks length and is deterministic per tweak. Use a distinct tweak per column or tenant. +//! Limits follow SP 800-38G Rev. 1: radix 2..=65536 and a domain of at least +//! one million strings. FPE leaks length and is deterministic for a tweak. +//! Use a distinct tweak per column or tenant. use crate::Error; -use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; +use aes::{Aes128, Aes192, Aes256}; +use fpe::ff1::{FlexibleNumeralString, FF1}; -enum Aes { - A128(aes::Aes128), - A192(aes::Aes192), - A256(aes::Aes256), -} - -impl Aes { - fn enc(&self, b: &mut [u8; 16]) { - let g = GenericArray::from_mut_slice(b); - match self { - Aes::A128(c) => c.encrypt_block(g), - Aes::A192(c) => c.encrypt_block(g), - Aes::A256(c) => c.encrypt_block(g), - } - } +enum Inner { + A128(FF1), + A192(FF1), + A256(FF1), } pub struct Ff1 { - aes: Aes, + inner: Inner, radix: u32, } -const LIMIT: u128 = 1u128 << 120; - -fn pow(r: u32, e: usize) -> Option { - let mut x: u128 = 1; - for _ in 0..e { - x = x.checked_mul(r as u128)?; - } - Some(x) -} - -fn num(x: &[u16], radix: u32) -> u128 { - x.iter().fold(0u128, |a, &d| a * radix as u128 + d as u128) -} - -fn str_m(mut v: u128, radix: u32, m: usize) -> Vec { - let mut out = vec![0u16; m]; - for o in out.iter_mut().rev() { - *o = (v % radix as u128) as u16; - v /= radix as u128; - } - out -} - impl Ff1 { pub fn new(key: &[u8], radix: u32) -> Result { if !(2..=65536).contains(&radix) { return Err(Error::InvalidArgument); } - let aes = match key.len() { - 16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(|_| Error::InvalidKey)?), - 24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(|_| Error::InvalidKey)?), - 32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(|_| Error::InvalidKey)?), + let inner = match key.len() { + 16 => Inner::A128(FF1::::new(key, radix).map_err(|_| Error::InvalidArgument)?), + 24 => Inner::A192(FF1::::new(key, radix).map_err(|_| Error::InvalidArgument)?), + 32 => Inner::A256(FF1::::new(key, radix).map_err(|_| Error::InvalidArgument)?), _ => return Err(Error::InvalidKey), }; - Ok(Ff1 { aes, radix }) + Ok(Ff1 { inner, radix }) } - fn prf(&self, data: &[u8]) -> [u8; 16] { - let mut y = [0u8; 16]; - for blk in data.chunks(16) { - for (a, b) in y.iter_mut().zip(blk) { - *a ^= b; - } - self.aes.enc(&mut y); - } - y - } - - fn check(&self, x: &[u16]) -> Result<(usize, usize, usize, u128, u128), Error> { - let n = x.len(); - if n < 2 || x.iter().any(|&d| d as u32 >= self.radix) { - return Err(Error::InvalidArgument); - } - let (u, v) = (n / 2, n - n / 2); - let mu = pow(self.radix, u).ok_or(Error::InvalidArgument)?; - let mv = pow(self.radix, v).filter(|&m| m < LIMIT).ok_or(Error::InvalidArgument)?; - // Domain size check: radix^n >= 10^6. - if mu.saturating_mul(mv) < 1_000_000 { + fn apply(&self, tweak: &[u8], x: &[u16], encrypt: bool) -> Result, Error> { + if x.iter().any(|&d| u32::from(d) >= self.radix) { return Err(Error::InvalidArgument); } - // b = ceil(ceil(v * log2 radix) / 8), and ceil(log2(mv)) = bit length of (mv - 1). - let bits = 128 - (mv - 1).leading_zeros() as usize; - Ok((u, v, bits.div_ceil(8), mu, mv)) - } - - fn round(&self, p: &[u8], tweak: &[u8], b: usize, i: u8, bval: u128, m_mod: u128) -> u128 { - let t = tweak.len(); - let pad = (16 - ((t + b + 1) % 16)) % 16; - let mut q = Vec::with_capacity(p.len() + t + pad + 1 + b); - q.extend_from_slice(p); - q.extend_from_slice(tweak); - q.extend(std::iter::repeat_n(0u8, pad)); - q.push(i); - let be = bval.to_be_bytes(); - if b > 16 { - q.extend(std::iter::repeat_n(0u8, b - 16)); - q.extend_from_slice(&be); - } else { - q.extend_from_slice(&be[16 - b..]); - } - let r = self.prf(&q); - let d = 4 * b.div_ceil(4) + 4; - let mut s = r.to_vec(); - let mut j = 1u128; - while s.len() < d { - let mut blk = r; - for (a, c) in blk.iter_mut().zip(j.to_be_bytes()) { - *a ^= c; - } - self.aes.enc(&mut blk); - s.extend_from_slice(&blk); - j += 1; - } - // y mod radix^m, folded bytewise so it never overflows (m_mod < 2^120). - s[..d].iter().fold(0u128, |a, &c| (a * 256 + c as u128) % m_mod) - } - - fn p_block(&self, u: usize, n: usize, t: usize) -> Vec { - let mut p = vec![1, 2, 1]; - p.extend_from_slice(&self.radix.to_be_bytes()[1..]); - p.push(10); - p.push((u % 256) as u8); - p.extend_from_slice(&(n as u32).to_be_bytes()); - p.extend_from_slice(&(t as u32).to_be_bytes()); - p + let ns = FlexibleNumeralString::from(x.to_vec()); + let out = match &self.inner { + Inner::A128(ff) if encrypt => ff.encrypt(tweak, &ns).map_err(|_| Error::InvalidArgument)?, + Inner::A128(ff) => ff.decrypt(tweak, &ns).map_err(|_| Error::InvalidArgument)?, + Inner::A192(ff) if encrypt => ff.encrypt(tweak, &ns).map_err(|_| Error::InvalidArgument)?, + Inner::A192(ff) => ff.decrypt(tweak, &ns).map_err(|_| Error::InvalidArgument)?, + Inner::A256(ff) if encrypt => ff.encrypt(tweak, &ns).map_err(|_| Error::InvalidArgument)?, + Inner::A256(ff) => ff.decrypt(tweak, &ns).map_err(|_| Error::InvalidArgument)?, + }; + Ok(Vec::::from(out)) } pub fn encrypt(&self, tweak: &[u8], x: &[u16]) -> Result, Error> { - let (u, v, b, mu, mv) = self.check(x)?; - let p = self.p_block(u, x.len(), tweak.len()); - let (mut a, mut bb) = (x[..u].to_vec(), x[u..].to_vec()); - for i in 0..10u8 { - let (m, md) = if i % 2 == 0 { (u, mu) } else { (v, mv) }; - let y = self.round(&p, tweak, b, i, num(&bb, self.radix), md); - let c = (num(&a, self.radix) % md + y) % md; - a = std::mem::replace(&mut bb, str_m(c, self.radix, m)); - } - a.extend(bb); - Ok(a) + self.apply(tweak, x, true) } pub fn decrypt(&self, tweak: &[u8], x: &[u16]) -> Result, Error> { - let (u, v, b, mu, mv) = self.check(x)?; - let p = self.p_block(u, x.len(), tweak.len()); - let (mut a, mut bb) = (x[..u].to_vec(), x[u..].to_vec()); - for i in (0..10u8).rev() { - let (m, md) = if i % 2 == 0 { (u, mu) } else { (v, mv) }; - let y = self.round(&p, tweak, b, i, num(&a, self.radix), md); - let c = (num(&bb, self.radix) % md + md - y) % md; - bb = std::mem::replace(&mut a, str_m(c, self.radix, m)); - } - a.extend(bb); - Ok(a) + self.apply(tweak, x, false) } /// Convenience wrapper for alphabet strings such as "0123456789" or base-36. pub fn encrypt_str(&self, tweak: &[u8], alphabet: &str, s: &str) -> Result { - self.map(alphabet, s, |x| self.encrypt(tweak, x)) + self.map(tweak, alphabet, s, true) } + pub fn decrypt_str(&self, tweak: &[u8], alphabet: &str, s: &str) -> Result { - self.map(alphabet, s, |x| self.decrypt(tweak, x)) + self.map(tweak, alphabet, s, false) } - fn map(&self, alphabet: &str, s: &str, f: impl Fn(&[u16]) -> Result, Error>) -> Result { + + fn map(&self, tweak: &[u8], alphabet: &str, s: &str, encrypt: bool) -> Result { let al: Vec = alphabet.chars().collect(); if al.len() != self.radix as usize { return Err(Error::InvalidArgument); } let x = s .chars() - .map(|c| al.iter().position(|&a| a == c).map(|p| p as u16).ok_or(Error::InvalidArgument)) + .map(|c| { + al.iter() + .position(|&a| a == c) + .map(|p| p as u16) + .ok_or(Error::InvalidArgument) + }) .collect::, _>>()?; - Ok(f(&x)?.into_iter().map(|d| al[d as usize]).collect()) + Ok(self + .apply(tweak, &x, encrypt)? + .into_iter() + .map(|d| al[d as usize]) + .collect()) } } diff --git a/crates/encryptoglyph/src/lib.rs b/crates/encryptoglyph/src/lib.rs index af9ad2d..e9b8fe0 100644 --- a/crates/encryptoglyph/src/lib.rs +++ b/crates/encryptoglyph/src/lib.rs @@ -361,25 +361,35 @@ fn commitment(commit_key: &[u8], aad: &[u8]) -> [u8; COMMIT_LEN] { /// Default writer: v3 key-committing envelope. The commitment binds key, nonce, header and /// record context, so a ciphertext cannot be crafted to decrypt validly under two keys. pub fn encrypt(kr: &Keyring, ctx: &RecordContext, pt: &[u8]) -> Result, Error> { + let mut nonce = [0u8; 24]; + getrandom::getrandom(&mut nonce).map_err(|_| Error::InvalidArgument)?; + encrypt_with_nonce(kr, ctx, pt, &nonce) +} + +/// v3 writer with a caller-supplied nonce. Nonce reuse under one key destroys +/// confidentiality. Known-answer tests and cross-language vectors use this. +/// Production callers use [`encrypt`], which draws a fresh nonce. +pub fn encrypt_with_nonce( + kr: &Keyring, + ctx: &RecordContext, + pt: &[u8], + nonce: &[u8], +) -> Result, Error> { + if nonce.len() != 24 { + return Err(Error::InvalidArgument); + } let dek = kr.derive( kr.current(), &ctx.purpose, &ctx.tenant, ALG_XCHACHA20POLY1305_COMMIT, )?; - let mut nonce = [0u8; 24]; - getrandom::getrandom(&mut nonce).map_err(|_| Error::InvalidArgument)?; - let mut out = header( - VERSION_V3, - ALG_XCHACHA20POLY1305_COMMIT, - kr.current(), - &nonce, - ); + let mut out = header(VERSION_V3, ALG_XCHACHA20POLY1305_COMMIT, kr.current(), nonce); let mut aad = out.clone(); aad.extend_from_slice(&ctx.aad()); - let (enc, com) = commit_keys(dek.as_ref(), &nonce)?; + let (enc, com) = commit_keys(dek.as_ref(), nonce)?; let ct = XChaCha20Poly1305::new((&*enc).into()) - .encrypt(XNonce::from_slice(&nonce), Payload { msg: pt, aad: &aad }) + .encrypt(XNonce::from_slice(nonce), Payload { msg: pt, aad: &aad }) .map_err(|_| Error::InvalidArgument)?; out.extend_from_slice(&commitment(com.as_ref(), &aad)); out.extend_from_slice(&ct); @@ -387,7 +397,6 @@ pub fn encrypt(kr: &Keyring, ctx: &RecordContext, pt: &[u8]) -> Result, } /// v2 writer (no key commitment), for tests and interop only. - pub fn encrypt_v2(kr: &Keyring, ctx: &RecordContext, pt: &[u8]) -> Result, Error> { let key = kr.derive( kr.current(), @@ -444,27 +453,37 @@ pub fn decrypt( msg: h.body, aad: &aad, }; - let r = match h.alg { + let opened = match h.alg { ALG_XCHACHA20POLY1305_COMMIT => { let (enc, com) = commit_keys(key.as_ref(), h.nonce)?; let (tag, body) = h.body.split_at(COMMIT_LEN); - if !ct_equal(tag, &commitment(com.as_ref(), &aad)) { - return Err(Error::Decrypt); - } - XChaCha20Poly1305::new((&*enc).into()).decrypt( + // Always run the AEAD open and the commitment check. Returning + // before the AEAD would make a bad commitment faster than a bad tag. + let expect = commitment(com.as_ref(), &aad); + let plain = XChaCha20Poly1305::new((&*enc).into()).decrypt( XNonce::from_slice(h.nonce), Payload { msg: body, aad: &aad, }, - ) - } - ALG_XCHACHA20POLY1305 => { - XChaCha20Poly1305::new((&*key).into()).decrypt(XNonce::from_slice(h.nonce), p) + ); + let commit_ok = ct_equal(tag, &expect); + match plain { + Ok(pt) if commit_ok => Ok(pt), + other => { + drop(other); + Err(Error::Decrypt) + } + } } - _ => Aes256Gcm::new((&*key).into()).decrypt(aes_gcm::Nonce::from_slice(h.nonce), p), - }; - r.map(Zeroizing::new).map_err(|_| Error::Decrypt) + ALG_XCHACHA20POLY1305 => XChaCha20Poly1305::new((&*key).into()) + .decrypt(XNonce::from_slice(h.nonce), p) + .map_err(|_| Error::Decrypt), + _ => Aes256Gcm::new((&*key).into()) + .decrypt(aes_gcm::Nonce::from_slice(h.nonce), p) + .map_err(|_| Error::Decrypt), + }?; + Ok(Zeroizing::new(opened)) } pub fn needs_rewrap(kr: &Keyring, blob: &[u8]) -> Result { diff --git a/crates/encryptoglyph/src/shamir.rs b/crates/encryptoglyph/src/shamir.rs index 1740989..fbbe575 100644 --- a/crates/encryptoglyph/src/shamir.rs +++ b/crates/encryptoglyph/src/shamir.rs @@ -1,111 +1,64 @@ -//! Shamir M-of-N secret sharing over GF(2^8) (backlog #15). Use it to split a root or KEK across -//! custodians for break-glass recovery, with no single holder able to decrypt on their own. -//! This is not an escrow or backdoor: shares exist only where the key owner hands them out. +//! Shamir threshold sharing via the `blahaj` crate (GF(256), zeroizing shares). +//! `blahaj` is the maintained fix for the coefficient-bias bug in `sharks` 0.5 +//! (RUSTSEC-2024-0398). +//! Use it to split a root or KEK across custodians. Shares exist only where the +//! key owner hands them out. This is not an escrow key and not a backdoor. //! -//! Share format: `b"EGSS" | ver=1 | threshold u8 | x u8 | y[len]`. -//! Field arithmetic uses the AES polynomial 0x11b and is branch-free (no lookup tables). +//! Share format: `b"EGSS" | ver=1 | threshold u8 | sharks share bytes`. +//! Wrong-but-well-formed shares are rejected by the library when they are +//! duplicate or short. A substituted share can still interpolate to other +//! bytes, so verify a key-check value before trusting the result. use crate::Error; +use blahaj::{Share, Sharks}; use zeroize::Zeroizing; const MAGIC: &[u8; 4] = b"EGSS"; const VER: u8 = 1; -const HDR: usize = 7; - -fn mul(mut a: u8, mut b: u8) -> u8 { - let mut p = 0u8; - for _ in 0..8 { - p ^= a & 0u8.wrapping_sub(b & 1); - let hi = 0u8.wrapping_sub(a >> 7); - a = (a << 1) ^ (0x1b & hi); - b >>= 1; - } - p -} - -fn inv(a: u8) -> u8 { - // a^254 = a^-1 in GF(2^8). inv(0) = 0, which is never used. - let mut r = 1u8; - let mut x = a; - for bit in 0..8 { - if (254u8 >> bit) & 1 == 1 { - r = mul(r, x); - } - x = mul(x, x); - } - r -} +const HDR: usize = 6; /// Split `secret` into `n` shares, any `threshold` of which reconstruct it. pub fn split(secret: &[u8], threshold: u8, n: u8) -> Result>, Error> { if secret.is_empty() || threshold < 2 || n < threshold { return Err(Error::InvalidArgument); } - let t = threshold as usize; - let mut coeffs = Zeroizing::new(vec![0u8; secret.len() * (t - 1)]); - getrandom::getrandom(&mut coeffs).map_err(|_| Error::InvalidArgument)?; - let mut shares = Vec::with_capacity(n as usize); - for x in 1..=n { - let mut s = Vec::with_capacity(HDR + secret.len()); - s.extend_from_slice(MAGIC); - s.extend_from_slice(&[VER, threshold, x]); - for (i, &b) in secret.iter().enumerate() { - let c = &coeffs[i * (t - 1)..(i + 1) * (t - 1)]; - // Horner's method, highest coefficient first. - let mut y = 0u8; - for &k in c.iter().rev() { - y = mul(y, x) ^ k; - } - s.push(mul(y, x) ^ b); - } - shares.push(s); + let dealer = Sharks(threshold); + let shares: Vec = dealer.dealer(secret).take(usize::from(n)).collect(); + if shares.len() != usize::from(n) { + return Err(Error::InvalidArgument); + } + let mut out = Vec::with_capacity(shares.len()); + for share in &shares { + let body = Vec::from(share); + let mut framed = Vec::with_capacity(HDR + body.len()); + framed.extend_from_slice(MAGIC); + framed.extend_from_slice(&[VER, threshold]); + framed.extend(body); + out.push(framed); } - Ok(shares) + Ok(out) } -/// Recombine shares. Fails if there are fewer than the threshold, mixed lengths or thresholds, -/// duplicate x values, or bad headers. Wrong-but-well-formed shares yield wrong bytes, so verify -/// the result (for example, unwrap a key-check value) before trusting it. +/// Recombine shares. Fails closed on a short set, mixed lengths, mixed +/// thresholds, duplicate x values, or a bad header. pub fn combine(shares: &[&[u8]]) -> Result>, Error> { - let first = shares.first().ok_or(Error::InvalidArgument)?; - if first.len() <= HDR { + let first = shares.first().copied().ok_or(Error::InvalidArgument)?; + if first.len() < HDR + 2 || first[..4] != *MAGIC || first[4] != VER { return Err(Error::InvalidArgument); } - let t = first[5] as usize; - if shares.len() < t { + let threshold = first[5]; + if threshold < 2 || shares.len() < usize::from(threshold) { return Err(Error::InvalidArgument); } - let use_ = &shares[..t]; - let mut xs = Vec::with_capacity(t); - for s in use_ { - if s.len() != first.len() || &s[..4] != MAGIC || s[4] != VER || s[5] as usize != t || s[6] == 0 || xs.contains(&s[6]) { + let used = &shares[..usize::from(threshold)]; + let mut parsed = Vec::with_capacity(used.len()); + for share in used { + if share.len() != first.len() || share[..HDR] != first[..HDR] { return Err(Error::InvalidArgument); } - xs.push(s[6]); - } - let mut out = Zeroizing::new(vec![0u8; first.len() - HDR]); - for (j, s) in use_.iter().enumerate() { - // Lagrange basis at x = 0: prod x_m / (x_m - x_j), and subtraction is XOR. - let mut l = 1u8; - for (m, &xm) in xs.iter().enumerate() { - if m != j { - l = mul(l, mul(xm, inv(xm ^ xs[j]))); - } - } - for (o, &y) in out.iter_mut().zip(&s[HDR..]) { - *o ^= mul(l, y); - } - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn field() { - assert_eq!(mul(0x57, 0x83), 0xc1); // FIPS-197 worked example - for a in 1..=255u8 { - assert_eq!(mul(a, inv(a)), 1); - } + parsed.push(Share::try_from(&share[HDR..]).map_err(|_| Error::InvalidArgument)?); } + let secret = Sharks(threshold) + .recover(&parsed) + .map_err(|_| Error::InvalidArgument)?; + Ok(Zeroizing::new(secret)) } diff --git a/crates/encryptoglyph/tests/primitives.rs b/crates/encryptoglyph/tests/primitives.rs new file mode 100644 index 0000000..b5a5a84 --- /dev/null +++ b/crates/encryptoglyph/tests/primitives.rs @@ -0,0 +1,75 @@ +//! Published known-answer tests for the primitives the envelope is built on. +//! Expected bytes come from the RFCs, not from a previous run of this crate. +use argon2::{Algorithm, Argon2, AssociatedData, ParamsBuilder, Version}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +fn hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +#[test] +fn hkdf_sha256_rfc5869_case_1() { + let ikm = [0x0bu8; 22]; + let salt = hex("000102030405060708090a0b0c"); + let info = hex("f0f1f2f3f4f5f6f7f8f9"); + let mut okm = [0u8; 42]; + Hkdf::::new(Some(&salt), &ikm) + .expand(&info, &mut okm) + .unwrap(); + assert_eq!( + okm.as_slice(), + hex("3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865").as_slice() + ); +} + +#[test] +fn hmac_sha256_rfc4231_case_1() { + let mut mac = as Mac>::new_from_slice(&[0x0bu8; 20]).unwrap(); + mac.update(b"Hi There"); + let got = mac.finalize().into_bytes(); + assert_eq!( + got.as_slice(), + hex("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7").as_slice() + ); +} + +#[test] +fn argon2id_rfc9106_section_5_3() { + let mut params = ParamsBuilder::new(); + params + .m_cost(32) + .t_cost(3) + .p_cost(4) + .data(AssociatedData::new(&[0x04u8; 12]).unwrap()) + .output_len(32); + let argon = Argon2::new_with_secret( + &[0x03u8; 8], + Algorithm::Argon2id, + Version::V0x13, + params.build().unwrap(), + ) + .unwrap(); + let mut out = [0u8; 32]; + argon + .hash_password_into(&[0x01u8; 32], &[0x02u8; 16], &mut out) + .unwrap(); + assert_eq!( + out.as_slice(), + hex("0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659").as_slice() + ); +} + +#[test] +fn constant_time_compare_rejects_a_flipped_tag() { + let a = [1u8; 32]; + let mut b = a; + b[31] ^= 1; + assert!(encryptoglyph::ct_equal(&a, &a)); + assert!(!encryptoglyph::ct_equal(&a, &b)); + assert!(!encryptoglyph::ct_equal(&a, &a[..31])); +} diff --git a/crates/encryptoglyph/tests/vectors.rs b/crates/encryptoglyph/tests/vectors.rs index 349d65c..a30fdbe 100644 --- a/crates/encryptoglyph/tests/vectors.rs +++ b/crates/encryptoglyph/tests/vectors.rs @@ -41,3 +41,21 @@ fn python_vectors_decrypt_in_rust() { hex(field(j, "blind_index_hex")) ); } + +#[test] +fn v3_known_answer_reseals_with_the_vector_nonce() { + let j = include_str!("../../../testvectors/envelope.json"); + let blob = hex(field(j, "v3_hex")); + let kr = Keyring::new(field(j, "kid"), &hex(field(j, "key_hex"))).unwrap(); + let ctx = RecordContext { + tenant: "tenant-a".into(), + purpose: "field-enc".into(), + table: "users".into(), + record_id: "42".into(), + field: "ssn".into(), + schema_version: 1, + }; + let header = parse_header(&blob).unwrap(); + let again = encrypt_with_nonce(&kr, &ctx, field(j, "plaintext").as_bytes(), header.nonce).unwrap(); + assert_eq!(again, blob); +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5e02e6a..ed6cd55 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -32,8 +32,8 @@ services: eg-api: build: - context: ../api - dockerfile: Dockerfile + context: .. + dockerfile: api/Dockerfile image: encryptoglyph-api:local env_file: - .env @@ -49,7 +49,10 @@ services: traefik.http.services.eg-api.loadbalancer.server.port: "8000" eg-console: - image: encryptoglyph-console:placeholder + build: + context: .. + dockerfile: api/Dockerfile + image: encryptoglyph-console:local env_file: - .env networks: @@ -60,7 +63,7 @@ services: traefik.http.routers.eg-console.rule: Host(`eg.home.arpa`) traefik.http.routers.eg-console.entrypoints: websecure traefik.http.routers.eg-console.tls: "true" - traefik.http.services.eg-console.loadbalancer.server.port: "80" + traefik.http.services.eg-console.loadbalancer.server.port: "8000" rest-server: image: restic/rest-server:0.14.0 diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..bfca8ba --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "encryptoglyph-e2e", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "encryptoglyph-e2e", + "version": "0.2.0", + "license": "Apache-2.0", + "devDependencies": { + "@playwright/test": "1.56.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..f521ae0 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "encryptoglyph-e2e", + "private": true, + "version": "0.2.0", + "license": "Apache-2.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.56.1" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..786b3fb --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,21 @@ +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests", + timeout: 30000, + fullyParallel: false, + workers: 1, + use: { + baseURL: "http://127.0.0.1:8765", + trace: "off", + }, + webServer: { + command: "python3 -m uvicorn main:app --host 127.0.0.1 --port 8765", + cwd: "../api", + url: "http://127.0.0.1:8765/healthz", + reuseExistingServer: true, + env: { + BUILD_SHA: "0123456789abcdef", + }, + }, +}); diff --git a/e2e/tests/console.spec.js b/e2e/tests/console.spec.js new file mode 100644 index 0000000..649138d --- /dev/null +++ b/e2e/tests/console.spec.js @@ -0,0 +1,90 @@ +const { test, expect } = require("@playwright/test"); +const fs = require("fs"); +const path = require("path"); + +const shots = path.join(__dirname, "..", "screenshots"); + +test.beforeAll(() => { + fs.mkdirSync(shots, { recursive: true }); +}); + +test("console flows seal, navigate, and refuse secrets", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await expect(page.getByTestId("hero")).toContainText("Seal the field"); + const hero = await page.getByTestId("hero").innerText(); + for (const brand of ["Komboe", "GitGoat", "RailYard", "Portkyl", "Cerebro", "restic", "age"]) { + expect(hero).not.toMatch(new RegExp(`\\b${brand}\\b`, "i")); + } + await expect(page.getByTestId("feature-cards")).toContainText("Komboe"); + await expect(page.getByRole("link", { name: "Komboe" })).toHaveAttribute("href", "https://komboe.com"); + + await page.screenshot({ path: path.join(shots, "desktop-1440.png"), fullPage: true }); + + await page.getByRole("button", { name: "Seal a field" }).click(); + await expect(page.locator("#view-seal")).toBeVisible(); + await page.locator("#plaintext").fill("ada@example.com"); + await page.locator("#seal-form").getByRole("button", { name: "Seal", exact: true }).click(); + await expect(page.getByTestId("seal-status")).toContainText("Sealed"); + const ciphertext = await page.getByTestId("ciphertext").inputValue(); + expect(ciphertext.length).toBeGreaterThan(20); + await expect(page.getByTestId("transcript")).toHaveText(""); + + await page.getByRole("button", { name: "Open", exact: true }).click(); + await expect(page.getByTestId("opened")).toHaveText("ada@example.com"); + + await page.getByRole("button", { name: "Keys", exact: true }).click(); + await page.getByRole("button", { name: "New key" }).click(); + await expect(page.getByTestId("key-status")).toContainText("Fingerprint"); + await page.locator("#nav-links").getByRole("button", { name: "Seal", exact: true }).click(); + await page.getByRole("button", { name: "Rewrap" }).click(); + await expect(page.getByTestId("seal-status")).toContainText("Rewrap"); + await page.getByRole("button", { name: "Keys", exact: true }).click(); + const retired = page.locator(".key", { has: page.getByRole("button", { name: "Shred" }) }); + const fingerprint = await retired.locator("code").innerText(); + await page.getByTestId("shred-confirm").fill(fingerprint); + await retired.getByRole("button", { name: "Shred" }).click(); + await expect(page.getByTestId("key-status")).toContainText("Shredded"); + + await page.getByRole("button", { name: "Custody", exact: true }).click(); + await expect(page.getByTestId("custody-status")).toContainText("not-connected"); + await page.getByRole("button", { name: "Download sealed inventory" }).click(); + await expect(page.getByTestId("backup-status")).toContainText("Sealed inventory"); + + await page.getByRole("button", { name: "Agent", exact: true }).click(); + await page.getByRole("button", { name: "Health" }).click(); + await expect(page.getByTestId("transcript")).toContainText("Health is ok"); + await page.getByRole("button", { name: "What can you do" }).click(); + await expect(page.getByTestId("transcript")).toContainText("Seal form"); + + await page.getByTestId("agent-input").fill("password=hunter2-secret"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByTestId("transcript")).toContainText("stays out of the assistant"); + await expect(page.getByTestId("transcript")).not.toContainText("hunter2-secret"); + await expect(page.getByTestId("agent-input")).toHaveValue(""); + + await page.getByRole("button", { name: "Adopt", exact: true }).click(); + await expect(page.getByTestId("snippet-python")).toContainText("eg_crypto"); + + await page.getByRole("button", { name: "Overview", exact: true }).click(); + await page.getByRole("button", { name: "Check for updates" }).click(); + await expect(page.getByTestId("update-status")).toContainText("0.2.0"); + await page.screenshot({ path: path.join(shots, "desktop-1440.png"), fullPage: true }); +}); + +test("mobile nav and reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await expect(page.locator("html")).toHaveAttribute("data-motion", "reduce"); + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + await page.getByRole("button", { name: "Menu" }).click(); + await page.locator("#nav-links").getByRole("button", { name: "Seal", exact: true }).click(); + await expect(page.locator("#view-seal")).toBeVisible(); + await page.locator("#plaintext").fill("mobile"); + await page.locator("#seal-form").getByRole("button", { name: "Seal", exact: true }).click(); + await expect(page.getByTestId("seal-status")).toContainText("Sealed"); + await page.getByRole("button", { name: "Menu" }).click(); + await page.locator("#nav-links").getByRole("button", { name: "Overview", exact: true }).click(); + await page.screenshot({ path: path.join(shots, "mobile-390.png"), fullPage: true }); +}); diff --git a/scripts/assert-exposure.sh b/scripts/assert-exposure.sh index ee14cd0..0f136f6 100755 --- a/scripts/assert-exposure.sh +++ b/scripts/assert-exposure.sh @@ -16,7 +16,7 @@ cleanup() { } trap cleanup EXIT -docker build -t "$image" -f api/Dockerfile api +docker build -t "$image" -f api/Dockerfile . docker rm -f "$name" >/dev/null 2>&1 || true docker run -d --name "$name" -e "BUILD_SHA=${sha}" "$image" >/dev/null diff --git a/scripts/encryptoglyph-update.py b/scripts/encryptoglyph-update.py new file mode 100755 index 0000000..dfd12eb --- /dev/null +++ b/scripts/encryptoglyph-update.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Check or download an Encryptoglyph release. Does not execute it.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api")) + +from eg_api.http import locate_version +from eg_api.updater import check_release, download_tarball, extract_tarball, github_fetch, read_version + + +def main() -> int: + parser = argparse.ArgumentParser(description="Encryptoglyph release updater") + parser.add_argument("--check", action="store_true") + parser.add_argument("--download", type=Path) + args = parser.parse_args() + current = read_version(locate_version()) + report = check_release(current, github_fetch) + print( + f"current={report['current']} latest={report['latest']} " + f"state={report['state']} update_available={report['update_available']}" + ) + if args.download: + url = report.get("url") + if not isinstance(url, str): + print("no release tarball to download", file=sys.stderr) + return 1 + extract_tarball(download_tarball(url), args.download) + print(f"extracted={args.download}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/js/encryptoglyph.js b/sdk/js/encryptoglyph.js new file mode 100644 index 0000000..0c30195 --- /dev/null +++ b/sdk/js/encryptoglyph.js @@ -0,0 +1,35 @@ +// Encryptoglyph sidecar client. Drop this file into a GoDesk app. +export class Encryptoglyph { + constructor(baseUrl) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.sessionId = ""; + } + + async session() { + const opened = await this.#json("POST", "/v1/sessions", {}); + this.sessionId = opened.session_id; + return this.sessionId; + } + + async seal(ctx, plaintext) { + const bytes = plaintext instanceof Uint8Array ? plaintext : new TextEncoder().encode(plaintext); + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return this.#json("POST", "/v1/seal", { ...ctx, plaintext_b64: btoa(binary) }); + } + + async #json(method, path, body) { + const headers = { "content-type": "application/json" }; + if (this.sessionId) headers["x-eg-session"] = this.sessionId; + const response = await fetch(this.baseUrl + path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "encryptoglyph request failed"); + return data; + } +} diff --git a/sdk/python/encryptoglyph_client.py b/sdk/python/encryptoglyph_client.py new file mode 100644 index 0000000..a66544e --- /dev/null +++ b/sdk/python/encryptoglyph_client.py @@ -0,0 +1,40 @@ +"""Small client for the Encryptoglyph sidecar. No extra dependencies.""" + +from __future__ import annotations + +import base64 +import json +import urllib.request + + +class Encryptoglyph: + def __init__(self, base_url: str) -> None: + self.base_url = base_url.rstrip("/") + self.session_id = "" + + def session(self) -> str: + opened = self._json("POST", "/v1/sessions", {}) + self.session_id = str(opened["session_id"]) + return self.session_id + + def seal(self, ctx: dict, plaintext: bytes) -> dict: + body = dict(ctx) + body["plaintext_b64"] = base64.b64encode(plaintext).decode() + return self._json("POST", "/v1/seal", body) + + def open(self, ctx: dict, blob_b64: str) -> bytes: + body = dict(ctx) + body["blob_b64"] = blob_b64 + opened = self._json("POST", "/v1/open", body) + return base64.b64decode(opened["plaintext_b64"]) + + def _json(self, method: str, path: str, body: dict | None = None) -> dict: + data = None if body is None else json.dumps(body).encode() + headers = {"content-type": "application/json"} + if self.session_id: + headers["x-eg-session"] = self.session_id + request = urllib.request.Request( + self.base_url + path, data=data, headers=headers, method=method + ) + with urllib.request.urlopen(request, timeout=10) as response: + return json.loads(response.read().decode())