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
93 changes: 79 additions & 14 deletions app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ def emr_recall_api_key() -> str | None:
return key or None


def emr_writer_api_key() -> str | None:
"""EMR_WRITER_API_KEY: a scoped credential that may call the seven read tools AND emr_remember / emr_upsert, and nothing else (docs/writer_credential.md)."""
key = (os.getenv("EMR_WRITER_API_KEY") or "").strip()
return key or None


MIN_WRITER_KEY_CHARS = 32


def writer_key_problems() -> list[str]:
"""What EMR_WRITER_API_KEY refuses to start with: too short, or the same string as another credential (a writer key that IS the ledger key would be the operator key)."""
writer = emr_writer_api_key()
if not writer:
return []
problems = []
if len(writer) < MIN_WRITER_KEY_CHARS:
problems.append(f"EMR_WRITER_API_KEY is shorter than {MIN_WRITER_KEY_CHARS} characters")
if is_public_deployment():
problems.append("EMR_WRITER_API_KEY is not supported on a public deployment (the recall key is the one credential there)")
for name, other in (("JARVIS_API_KEY", configured_api_key()), ("EMR_RECALL_API_KEY", emr_recall_api_key())):
if other and secrets.compare_digest(writer, other):
problems.append(f"EMR_WRITER_API_KEY must not equal {name}")
return problems


def assert_writer_key_config() -> None:
problems = writer_key_problems()
if problems:
raise RuntimeError("EMR_WRITER_API_KEY refuses to start: " + "; ".join(problems))


def memory_write_enabled() -> bool:
"""Gate for REST ledger CRUD / EMR reinforce / board mutations."""
default = "false" if is_public_deployment() else "true"
Expand Down Expand Up @@ -222,25 +253,32 @@ def require_emr_recall_api_key(
verify_operator_api_key(authorization, x_emr_recall_key)


def credential_kind(authorization: str | None, x_emr_recall_key: str | None, x_api_key: str | None) -> str | None:
"""Which credential does a tool or MCP request carry? ``operator`` (the ledger key), ``recall`` (EMR_RECALL_API_KEY: read tools only), or None.
def credential_kind(authorization: str | None, x_emr_recall_key: str | None, x_api_key: str | None, x_emr_writer_key: str | None = None) -> str | None:
"""Which credential does a tool or MCP request carry? ``operator`` (the ledger key), ``writer`` (EMR_WRITER_API_KEY: the read tools plus emr_remember / emr_upsert),
``recall`` (EMR_RECALL_API_KEY: read tools only), or None.

The recall key is a *scoped* credential: it never opens call log, drift, writes, administration or any REST ledger route (see docs/recall_credential.md).
The ledger key keeps working on the tool routes when a recall key is configured, so installing one breaks no existing client. On a PUBLIC deployment the
old rule stays: the recall key is the one operator credential there (the ledger key is not accepted on the tool routes)."""
The recall and writer keys are *scoped* credentials: neither opens call log, drift, administration or any REST ledger route (docs/recall_credential.md,
docs/writer_credential.md). The ledger key keeps working on the tool routes when they are configured, so installing one breaks no existing client, and it wins when
several are presented. The writer key is accepted as a Bearer or in X-EMR-Writer-Key, never in X-API-Key. On a PUBLIC deployment the old rule stays: the recall key is
the one operator credential there (the ledger key and the writer key are not accepted on the tool routes)."""
recall = emr_recall_api_key()
writer = emr_writer_api_key()
ledger = configured_api_key()
bearer = _extract_bearer(authorization)
recall_token = bearer or (x_emr_recall_key or "").strip()
recall_match = bool(recall and recall_token and secrets.compare_digest(recall_token, recall))
writer_token = bearer or (x_emr_writer_key or "").strip()
writer_match = bool(writer and writer_token and secrets.compare_digest(writer_token, writer))
if is_public_deployment():
return "operator" if recall_match else None # the old rule: on a public deployment the recall key is the operator credential
if ledger is None:
if recall:
return "recall" if recall_match else None # still read-only with no ledger key configured: operator-only operations fail closed (nobody is the operator)
if recall or writer:
return "writer" if writer_match else ("recall" if recall_match else None) # still scoped with no ledger key configured: operator-only operations fail closed (nobody is the operator)
return "operator" if allow_unauthenticated() else None
if any(t and secrets.compare_digest(t, ledger) for t in (bearer, (x_api_key or "").strip())): # (either one: see presented_ledger_candidates)
return "operator" # (the ledger key wins when both are presented)
if writer_match:
return "writer"
return "recall" if recall_match else None


Expand All @@ -249,42 +287,63 @@ def require_tools_credential(
authorization: str | None = Header(default=None),
x_emr_recall_key: str | None = Header(default=None, alias="X-EMR-Recall-Key"),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
x_emr_writer_key: str | None = Header(default=None, alias="X-EMR-Writer-Key"),
) -> None:
"""The read tool routes: the ledger key (operator) or, when configured, the recall key (read-only scope). Records which one on the request."""
"""The read tool routes: the ledger key (operator) or, when configured, the writer key (reads + the two draft writes) or the recall key (read-only scope). Records which one on the request."""
if tenant_identity_in_force():
return # the identity middleware authenticated the OAuth token / tenant key before this dependency
if is_public_deployment() and not emr_recall_api_key():
raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY")
kind = credential_kind(authorization, x_emr_recall_key, x_api_key)
kind = credential_kind(authorization, x_emr_recall_key, x_api_key, x_emr_writer_key)
if kind is None:
raise HTTPException(status_code=401, detail="Invalid or missing operator API key" if emr_recall_api_key() else "Invalid or missing API key")
request.scope["jarvis_credential"] = kind


def require_tools_write_credential(
request: Request,
authorization: str | None = Header(default=None),
x_emr_recall_key: str | None = Header(default=None, alias="X-EMR-Recall-Key"),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
x_emr_writer_key: str | None = Header(default=None, alias="X-EMR-Writer-Key"),
) -> None:
"""emr_remember / emr_upsert over REST: the LEDGER key or the WRITER key; the recall key is refused (401)."""
if tenant_identity_in_force():
return # (a tenant reaches the operator gates below this and is refused there, 403)
kind = credential_kind(authorization, x_emr_recall_key, x_api_key, x_emr_writer_key)
if kind in ("operator", "writer"):
request.scope["jarvis_credential"] = kind
return
if is_public_deployment() and not emr_recall_api_key():
raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY")
raise HTTPException(status_code=401, detail="Invalid or missing operator API key" if emr_recall_api_key() else "Invalid or missing API key")


def require_ledger_operator_key(
authorization: str | None = Header(default=None),
x_emr_recall_key: str | None = Header(default=None, alias="X-EMR-Recall-Key"),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
x_emr_writer_key: str | None = Header(default=None, alias="X-EMR-Writer-Key"),
) -> None:
"""Call log, drift, tool writes and the nx routes: the LEDGER key, never the recall key (a recall-only caller is refused 401).

With no recall key configured this is exactly what it was (the ledger key). On a public deployment the recall key stays the operator credential."""
if tenant_identity_in_force():
return # (a tenant reaches the operator gates below this and is refused there, 403)
if credential_kind(authorization, x_emr_recall_key, x_api_key) == "operator":
if credential_kind(authorization, x_emr_recall_key, x_api_key, x_emr_writer_key) == "operator":
return
if is_public_deployment() and not emr_recall_api_key():
raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY")
raise HTTPException(status_code=401, detail="Invalid or missing operator API key" if emr_recall_api_key() else "Invalid or missing API key")


def mcp_credential(authorization: str | None, x_emr_recall_key: str | None, x_api_key: str | None) -> str:
"""The MCP endpoint's check: raises like the tool routes do, and returns which credential the request carries (``operator`` or ``recall``)."""
def mcp_credential(authorization: str | None, x_emr_recall_key: str | None, x_api_key: str | None, x_emr_writer_key: str | None = None) -> str:
"""The MCP endpoint's check: raises like the tool routes do, and returns which credential the request carries (``operator``, ``writer`` or ``recall``)."""
if tenant_identity_in_force():
return "operator" # (as before: the identity middleware validated the token and bound the tenant; "operator" here only means "not recall-scoped")
if is_public_deployment() and not emr_recall_api_key():
raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY")
kind = credential_kind(authorization, x_emr_recall_key, x_api_key)
kind = credential_kind(authorization, x_emr_recall_key, x_api_key, x_emr_writer_key)
if kind is None:
raise HTTPException(status_code=401, detail="Invalid or missing operator API key" if emr_recall_api_key() else "Invalid or missing API key")
return kind
Expand Down Expand Up @@ -382,7 +441,8 @@ async def ledger_read_protection_middleware(request: Request, call_next):
# deployment the recall key stays the operator credential, as before.
if is_public_deployment() and not emr_recall_api_key():
raise HTTPException(status_code=503, detail="Public deployment requires EMR_RECALL_API_KEY")
if credential_kind(request.headers.get("authorization"), request.headers.get("x-emr-recall-key"), request.headers.get("x-api-key")) != "operator":
if credential_kind(request.headers.get("authorization"), request.headers.get("x-emr-recall-key"), request.headers.get("x-api-key"),
request.headers.get("x-emr-writer-key")) != "operator":
raise HTTPException(status_code=401, detail="Invalid or missing operator API key")
except HTTPException as exc:
return json_response(exc.status_code, exc.detail, headers=getattr(exc, "headers", None), path=request.url.path)
Expand Down Expand Up @@ -559,6 +619,11 @@ def credential_is_operator(headers, path: str) -> bool:
return False
elif is_public_deployment():
return False
writer = emr_writer_api_key()
if writer and not is_public_deployment():
wtoken = bearer or (headers.get("x-emr-writer-key") or "").strip()
if wtoken and secrets.compare_digest(wtoken, writer):
return True # likewise the writer key: it reaches the operator's ledger through the tool routes; credential_kind decides which tools
expected = configured_api_key()
if expected is None:
return allow_unauthenticated()
Expand Down
7 changes: 5 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@
ledger_read_protection_middleware,
mcp_write_enabled,
memory_write_enabled,
assert_writer_key_config,
require_ledger_operator_key,
require_tools_credential,
require_tools_write_credential,
require_mcp_write_scope,
require_memory_write,
assert_multi_tenant_config,
Expand Down Expand Up @@ -142,6 +144,7 @@ async def lifespan(app: FastAPI):
"""Application lifespan: track and cleanup background nx-search processes."""
# Store for background processes
assert_multi_tenant_config() # JARVIS_MULTI_TENANT: refuse to start with a configuration that could fail open
assert_writer_key_config() # EMR_WRITER_API_KEY: refuse a weak writer key, one equal to another credential, or one on a public deployment
assert_rate_limit_config() # per-process limiters: more than one worker multiplies every quota, so refuse unless a shared limiter is declared
if key_auth.key_tenants_enabled():
key_auth.assert_config() # JARVIS_KEY_TENANTS: needs the row store and a strong pepper
Expand Down Expand Up @@ -1118,7 +1121,7 @@ def tool_emr_recall(body: EmrRecallRequest):

@app.post(
"/api/jarvis/tools/emr_remember",
dependencies=[Depends(require_ledger_operator_key), Depends(require_mcp_write_scope)],
dependencies=[Depends(require_tools_write_credential), Depends(require_mcp_write_scope)],
)
def tool_emr_remember(body: EmrRememberRequest):
"""Governed create via EMR — draft-only; gated by JARVIS_MCP_WRITE_ENABLED."""
Expand All @@ -1128,7 +1131,7 @@ def tool_emr_remember(body: EmrRememberRequest):

@app.post(
"/api/jarvis/tools/emr_upsert",
dependencies=[Depends(require_ledger_operator_key), Depends(require_mcp_write_scope)],
dependencies=[Depends(require_tools_write_credential), Depends(require_mcp_write_scope)],
)
def tool_emr_upsert(body: EmrUpsertRequest):
"""Governed supersede via EMR — new draft + archive prior; gated by JARVIS_MCP_WRITE_ENABLED."""
Expand Down
7 changes: 6 additions & 1 deletion deploy/mint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,15 @@ does have one: run `app.pg_schema.V9_ROLLBACK` as the migration role in one tran

### Per-tenant API keys (`jarvisctl keys`)

`jarvisctl keys pepper-init|pepper-install-app|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status` issues and manages per-tenant API keys (schema v10; `docs/tenant_keys.md`). Tokens are written to a
`jarvisctl keys pepper-init|pepper-install-app|writer-key-init|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status` issues and manages per-tenant API keys (schema v10; `docs/tenant_keys.md`). Tokens are written to a
mode-600 file under `secrets/issued-keys/` and never printed; the pepper (`secrets/key-pepper`) is created once and never overwritten or shown. Key authentication stays off until `JARVIS_KEY_TENANTS=1` is set
for the app (see `docs/tenant_keys.md`); issuing a key does not enable it.

### The writer credential (`EMR_WRITER_API_KEY`)

`jarvisctl keys writer-key-init` creates a scoped key that can use the read tools plus `emr_remember` / `emr_upsert` and nothing else (`docs/writer_credential.md`): the key to give a second machine instead of the ledger
key. It is installed by recreating the app; it does not change `JARVIS_MCP_WRITE_ENABLED`, which stays the deployment's own switch.

### Signatures (schema v7)

Backup sets carry `<set>.signatures.json` (the attestations, trust statements and blocks, for the PC witness) and the anchors carry
Expand Down
2 changes: 1 addition & 1 deletion deploy/mint/bin/jarvisctl
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jarvisctl <command>
seal [--force|--status] seal new history into Continuity Blocks now (or show the unsealed tail)
attest status|sign|init-key|install-roots|verify signatures: the host-side signer, the trust roots, verification
tenants list|seal|sign|status|verify per-tenant versions of seal / attest sign / verify (new; the timers still run the operator-only ones)
keys pepper-init|pepper-install-app|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status per-tenant API keys (RFC phase 1): issue, revoke, list; tokens go to a mode-600 file, never the screen
keys pepper-init|pepper-install-app|writer-key-init|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status per-tenant API keys (RFC phase 1): issue, revoke, list; tokens go to a mode-600 file, never the screen
replay state|receipt|receipts|check|verify Replay Contracts: replay the ledger at a point, receipts at sealed points, re-derive them
backup take a backup set now
prune [--dry-run] apply the retention policy
Expand Down
18 changes: 17 additions & 1 deletion deploy/mint/bin/keys.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
# keys.sh tenants list tenants
# keys.sh keys [--tenant t_<hex>] list keys (never a hash)
# keys.sh pepper-install-app make the app's own copy of the pepper (secrets/key-pepper.app, owned by the app's uid 10001, mode 400); the operator's copy stays yours
# keys.sh writer-key-init generate the WRITER key (EMR_WRITER_API_KEY: read tools + emr_remember / emr_upsert only): secrets/writer.key (mode 600) and one line in
# secrets/app.env; never overwritten, never printed. The app picks it up at the next recreate; copy writer.key to the machine that needs it.
# keys.sh status pepper present? app copy present and identical? is JARVIS_KEY_TENANTS set in the app env? how many tenants and keys
#
# Issuing a key does NOT turn key authentication on: the app only honours keys with JARVIS_KEY_TENANTS=1 and a pepper mounted for it (docs/tenant_keys.md). Every argument is
Expand All @@ -20,7 +22,7 @@ export LOG_NAME=keys
# shellcheck source=lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

usage() { sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2; }
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2; }
bad() { echo "$*" >&2; exit 2; }
[ $# -ge 1 ] || usage
cmd="$1"; shift
Expand Down Expand Up @@ -78,6 +80,20 @@ case "$cmd" in
|| die "could not create the app's copy of the pepper"
log INFO "app copy written to $APP_PEPPER_FILE (owner 10001, mode 400; the value is not shown). Mount THIS file into the app as JARVIS_KEY_PEPPER_FILE; never the operator copy."
;;
writer-key-init)
[ $# -eq 0 ] || bad "writer-key-init takes no options"
WRITER_FILE="$SECRETS_DIR/writer.key"; APP_ENV="$SECRETS_DIR/app.env"
[ -f "$APP_ENV" ] || die "no $APP_ENV: run jarvisctl secrets first"
[ ! -e "$WRITER_FILE" ] || die "$WRITER_FILE already exists: the writer key is never overwritten (delete it AND its EMR_WRITER_API_KEY line to replace it)"
! grep -qE '^[[:space:]]*EMR_WRITER_API_KEY[[:space:]]*=' "$APP_ENV" || die "$APP_ENV already sets EMR_WRITER_API_KEY: not adding a second one"
( umask 077
key="$(head -c 32 /dev/urandom | base64 -w0 | tr '+/' '-_' | tr -d '=')"
[ "${#key}" -ge 32 ] || exit 1
printf '%s\n' "$key" > "$WRITER_FILE.tmp.$$" && mv -n "$WRITER_FILE.tmp.$$" "$WRITER_FILE"
printf '\n# scoped writer credential (read tools + emr_remember / emr_upsert): generated by jarvisctl keys writer-key-init\nEMR_WRITER_API_KEY=%s\n' "$key" >> "$APP_ENV" )
rm -f "$WRITER_FILE.tmp.$$"; chmod 600 "$WRITER_FILE" "$APP_ENV"
log INFO "writer key written to $WRITER_FILE and added to secrets/app.env (the value is not shown). Recreate the app to load it: docker compose up -d --force-recreate --no-deps app. Copy the file to the machine that needs it (scp), never paste it."
;;
tenant-create)
parse_opts "$@"; [ -z "$tenant$scopes$expires$out$status" ] || bad "tenant-create takes only --label"
[ "$have_label" -eq 1 ] && [ -n "$label" ] || bad "tenant-create needs --label NAME"
Expand Down
Loading
Loading