Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,19 @@ NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"

# ─── Authorship Passport (Sprint 2) ───
# Run `make keys` (scripts/generate_keys.py) to create keys/ — git-ignored.
#
# LOCAL: point at the PEM files.
# DEPLOYED: `keys/**` is git-ignored, so Railway has no files. Paste the PEM
# *content* into PASSPORT_PRIVATE_KEY_PEM / PASSPORT_PUBLIC_KEY_PEM instead —
# they take precedence over the *_PATH pair. Whichever you use, the public key
# must match the private one: both carry the same PASSPORT_KID, so a mismatched
# pair makes every Passport fail verification with `invalid_signature` and
# nothing warns you. See docs/DEPLOYMENT.md.
PASSPORT_PRIVATE_KEY_PATH="keys/passport.priv.pem"
PASSPORT_PUBLIC_KEY_PATH="keys/passport.pub.pem"
# PASSPORT_PRIVATE_KEY_PEM=<paste the whole of keys/passport.priv.pem, BEGIN/END lines included>
# PASSPORT_PUBLIC_KEY_PEM=<paste the whole of keys/passport.pub.pem, BEGIN/END lines included>
# (Left as placeholders on purpose: a real PEM header here would trip the
# detect-private-key pre-commit hook, which is doing its job.)
PASSPORT_KID="autoria-2026-07"
PASSPORT_VERIFIER_URL="https://autoria.app/verify"
82 changes: 82 additions & 0 deletions ai_pipeline/autoria_ai/passport/keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Resolve Passport signing/verification keys from the environment or disk.

Why this exists
---------------
The signing keys live in `keys/` as PEM files, and `.gitignore` excludes
`keys/**` and `*.pem` — correctly, since the private key must never be
committed. But Railway and Vercel deploy from the repository: the PEM files
simply are not there, so a path-only lookup resolves to nothing in production.
The visible symptom is `/.well-known/jwks.json` answering 500 and
`POST /api/generate` failing to sign, which is the demo's centrepiece.

Platform dashboards inject *values*, not files, so this module accepts the PEM
content directly through `PASSPORT_PRIVATE_KEY_PEM` / `PASSPORT_PUBLIC_KEY_PEM`
and keeps the existing `*_PATH` variables working unchanged for local
development.

Precedence is explicit-argument → `*_PEM` → `*_PATH`. The PEM variable wins
over the path because it is the production signal: an image that happens to
ship a stale key file must not silently outrank the key the operator set in the
dashboard.

Never log the return value of anything here.
"""

from __future__ import annotations

import os
from pathlib import Path

# A PEM pasted into a dashboard field often arrives with literal backslash-n
# instead of real newlines (shells, .env files and some CI UIs escape them).
# `load_pem_*` rejects that with a parse error that says nothing useful, so we
# normalise before handing the bytes over.
_ESCAPED_NEWLINE = "\\n"


def _normalise_pem(raw: str) -> bytes:
text = raw.strip()
if _ESCAPED_NEWLINE in text and "\n" not in text:
text = text.replace(_ESCAPED_NEWLINE, "\n")
return text.encode("utf-8")


def resolve_pem(
*,
pem_env: str,
path_env: str,
explicit_path: str | Path | None = None,
) -> bytes:
"""Return PEM bytes for a key, or raise RuntimeError naming what is missing.

Args:
pem_env: Name of the env var holding the PEM content (production).
path_env: Name of the env var holding a filesystem path (local dev).
explicit_path: Caller-supplied path; wins over both env vars.

Raises:
RuntimeError: Neither source is configured, or the file is absent.
The message names the variables to set and never includes key
material.
"""
if explicit_path:
path = Path(explicit_path)
if not path.is_file():
raise RuntimeError(f"Key file not found: {path}")
return path.read_bytes()

inline = os.getenv(pem_env)
if inline and inline.strip():
return _normalise_pem(inline)

env_path = os.getenv(path_env)
if env_path:
path = Path(env_path)
if not path.is_file():
raise RuntimeError(f"Key file not found at {path_env}: {path}")
return path.read_bytes()

raise RuntimeError(
f"No key configured: set {pem_env} (PEM content, for deploys) "
f"or {path_env} (file path, for local development)"
)
16 changes: 11 additions & 5 deletions ai_pipeline/autoria_ai/passport/signer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,27 @@
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from jose import jws as jose_jws

from autoria_ai.passport.keys import resolve_pem

_TYP = "passport+jws"
_ALG = "ES256"


def _load_private_key(path: str | Path | None = None):
key_path = path or os.getenv("PASSPORT_PRIVATE_KEY_PATH")
if not key_path:
raise RuntimeError("PASSPORT_PRIVATE_KEY_PATH is not configured")
pem = Path(key_path).read_bytes()
# PASSPORT_PRIVATE_KEY_PEM (content) takes precedence over
# PASSPORT_PRIVATE_KEY_PATH (file) — `keys/**` is gitignored, so on Railway
# there is no file to point at. See autoria_ai.passport.keys.
pem = resolve_pem(
pem_env="PASSPORT_PRIVATE_KEY_PEM",
path_env="PASSPORT_PRIVATE_KEY_PATH",
explicit_path=path,
)
# Fix #3: wrap cryptography errors with `from None` so the raw PEM bytes
# never appear in __cause__/__context__ or in log traces.
try:
return load_pem_private_key(pem, password=None)
except Exception:
raise RuntimeError("Failed to load private key (check key file format)") from None
raise RuntimeError("Failed to load private key (check key format)") from None


def sign_passport(
Expand Down
17 changes: 11 additions & 6 deletions ai_pipeline/autoria_ai/passport/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from jose import jws as jose_jws
from jose.exceptions import JOSEError

from autoria_ai.passport.keys import resolve_pem

_ALLOWED_ALGS = frozenset({"ES256"})
_SUPPORTED_SCHEMA_VERSIONS = frozenset({"1.0"})
# Hard cap against oversized-token DoS (compact JWS string length).
Expand Down Expand Up @@ -58,12 +60,15 @@ def _load_public_key(
) -> EllipticCurvePublicKey:
if public_key is not None:
return public_key
path = public_key_path or os.getenv("PASSPORT_PUBLIC_KEY_PATH")
if not path:
raise RuntimeError("PASSPORT_PUBLIC_KEY_PATH is not configured")
if not Path(path).is_file():
raise RuntimeError("Public key file not found")
key = load_pem_public_key(Path(path).read_bytes())
# PASSPORT_PUBLIC_KEY_PEM (content) takes precedence over
# PASSPORT_PUBLIC_KEY_PATH (file): `keys/**` is gitignored, so a deployed
# image has no key file. See autoria_ai.passport.keys.
pem = resolve_pem(
pem_env="PASSPORT_PUBLIC_KEY_PEM",
path_env="PASSPORT_PUBLIC_KEY_PATH",
explicit_path=public_key_path,
)
key = load_pem_public_key(pem)
if not isinstance(key, EllipticCurvePublicKey):
raise RuntimeError("Configured public key is not an EC key")
return key
Expand Down
6 changes: 6 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@

# Passport key env var names — exposed only as presence booleans in env_report().
PASSPORT_ENV_VARS: tuple[str, ...] = (
# Deploys inject PEM *content* (dashboards hold values, not files) and
# `keys/**` is gitignored, so the image has no key file to point at.
# Local development keeps using the *_PATH form. Either pair works; the
# PEM one wins when both are set (autoria_ai.passport.keys.resolve_pem).
"PASSPORT_PRIVATE_KEY_PEM",
"PASSPORT_PUBLIC_KEY_PEM",
"PASSPORT_PRIVATE_KEY_PATH",
"PASSPORT_PUBLIC_KEY_PATH",
"PASSPORT_KID",
Expand Down
36 changes: 20 additions & 16 deletions backend/app/routes/jwks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
back to our servers.

Security invariants enforced here:
- Only the public key is ever read (PASSPORT_PUBLIC_KEY_PATH).
- Only the public key is ever read (PASSPORT_PUBLIC_KEY_PEM, else
PASSPORT_PUBLIC_KEY_PATH).
- The private key is never touched by this module.
- If the key file is absent or PASSPORT_PUBLIC_KEY_PATH is unset → 500; we
never silently serve an empty key set (that would make all signatures
- If neither variable is configured, or the file is absent → 500; we never
silently serve an empty key set (that would make all signatures
unverifiable without error).
- The `d` (private scalar) field is never present in the response; ensured by
using `cryptography`'s `public_key().public_bytes()` path rather than
Expand All @@ -19,8 +20,6 @@

import base64
import logging
import os
from pathlib import Path

from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key
Expand All @@ -29,6 +28,7 @@

import app.config as _cfg
from app.schemas import JsonWebKey, JwksDocument
from autoria_ai.passport.keys import resolve_pem

logger = logging.getLogger(__name__)

Expand All @@ -49,22 +49,26 @@ def _load_public_jwk() -> JsonWebKey:
Raises RuntimeError (→ 500) if the env var is unset or the file is missing.
Never logs key material.
"""
path = _cfg.settings.passport_public_key_path
kid = _cfg.settings.passport_kid or "autoria"

if not path:
raise RuntimeError("PASSPORT_PUBLIC_KEY_PATH is not configured")

if not os.path.isfile(path):
# Log the path (not the content) to assist ops debugging.
logger.error("Public key file not found: %s", path)
raise RuntimeError("Public key file not found")
# PASSPORT_PUBLIC_KEY_PEM (content) wins over PASSPORT_PUBLIC_KEY_PATH
# (file). `keys/**` is gitignored, so a Railway image ships no key file and
# a path-only lookup made this endpoint answer 500 in every deploy.
try:
pem = resolve_pem(
pem_env="PASSPORT_PUBLIC_KEY_PEM",
path_env="PASSPORT_PUBLIC_KEY_PATH",
)
except RuntimeError as exc:
# Message names the missing variable, never key material.
logger.error("Public key unavailable: %s", exc)
raise

try:
key = load_pem_public_key(Path(path).read_bytes())
key = load_pem_public_key(pem)
except Exception as exc:
# exc message may include path details but never key material.
logger.error("Failed to load public key from %s: %s", path, type(exc).__name__)
# Log the error *type* only — the message could echo PEM bytes.
logger.error("Failed to parse the configured public key: %s", type(exc).__name__)
raise RuntimeError("Failed to parse public key") from exc

if not isinstance(key, EllipticCurvePublicKey):
Expand Down
13 changes: 13 additions & 0 deletions backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,17 @@

pytest inserts the directory containing the rootdir conftest.py onto sys.path,
so tests can `from app.main import app` without an editable install.

The monorepo's ``ai_pipeline`` is added the same way, and for the same reason
the production code does it (``app.routes.generate._ensure_ai_pipeline_on_path``):
CI installs only ``backend/``, so ``import autoria_ai`` fails there while
passing locally, where an editable install papers over the difference. Tests
that assert on pipeline constants must not be green locally and red in CI.
"""

import sys
from pathlib import Path

_AI_PIPELINE = Path(__file__).resolve().parent.parent / "ai_pipeline"
if _AI_PIPELINE.is_dir() and str(_AI_PIPELINE) not in sys.path:
sys.path.insert(0, str(_AI_PIPELINE))
Loading