From 40e77690cfcc044e26308e69bf8210529ca17f69 Mon Sep 17 00:00:00 2001 From: warheart1984-ctrl Date: Sun, 18 Oct 2026 12:27:02 -0400 Subject: [PATCH 1/4] jarvisctl keys: issue, revoke and list per-tenant API keys from the box (tokens to a mode-600 file, never printed) Co-Authored-By: Claude Sonnet 5.5 --- deploy/mint/README.md | 6 ++ deploy/mint/bin/jarvisctl | 2 + deploy/mint/bin/keys.sh | 120 ++++++++++++++++++++++++++++ docs/tenant_keys.md | 20 ++++- tests/test_deploy_keys.py | 159 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 1 deletion(-) create mode 100755 deploy/mint/bin/keys.sh create mode 100644 tests/test_deploy_keys.py diff --git a/deploy/mint/README.md b/deploy/mint/README.md index 331d1fb..8967b98 100644 --- a/deploy/mint/README.md +++ b/deploy/mint/README.md @@ -230,6 +230,12 @@ newer `anchors/anchors-*.txt` files aside before the next backup, or it will qua 2026-10-08, v8 and v9 on 2026-10-10). V8 has no down-migration, so the way back across it is the named backup restored under the `pre-v8v9` images. V9 alone does have one: run `app.pg_schema.V9_ROLLBACK` as the migration role in one transaction and deploy the V8 code (`docs/ledger_search.md`), which keeps every write made since. +### Per-tenant API keys (`jarvisctl keys`) + +`jarvisctl keys pepper-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. + ### Signatures (schema v7) Backup sets carry `.signatures.json` (the attestations, trust statements and blocks, for the PC witness) and the anchors carry diff --git a/deploy/mint/bin/jarvisctl b/deploy/mint/bin/jarvisctl index 28d5f7b..141ead1 100755 --- a/deploy/mint/bin/jarvisctl +++ b/deploy/mint/bin/jarvisctl @@ -22,6 +22,7 @@ jarvisctl 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|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 @@ -62,6 +63,7 @@ case "$cmd" in seal) exec "$here/seal.sh" "$@" ;; attest) exec "$here/attest.sh" "$@" ;; tenants) exec "$here/tenants.sh" "$@" ;; + keys) exec "$here/keys.sh" "$@" ;; replay) exec "$here/replay.sh" "$@" ;; backup) exec "$here/backup.sh" "$@" ;; prune) exec "$here/prune.sh" "$@" ;; diff --git a/deploy/mint/bin/keys.sh b/deploy/mint/bin/keys.sh new file mode 100755 index 0000000..00e55d2 --- /dev/null +++ b/deploy/mint/bin/keys.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Per-tenant API keys (RFC phase 1) from the box: create tenants and keys, revoke, suspend, list. A thin, validating wrapper over `python -m app.tenant_keys`, which runs in the +# migrate container as the migrator role (the application role has no privilege on the control schema, and the public API cannot create or list keys or tenants). +# +# keys.sh pepper-init create secrets/key-pepper (random, mode 600); never overwrites; prints no value +# keys.sh tenant-create --label NAME a new tenant (prints its t_... id) +# keys.sh key-create --tenant t_<32 hex> --out NAME [--label L] [--scopes memory.read,memory.write] [--expires-days N] +# a new key; the TOKEN is written to secrets/issued-keys/NAME.token (mode 600, never overwritten) and is NEVER printed +# keys.sh key-revoke KEY_ID revoke one key (16 hex characters) +# keys.sh tenant-status --tenant t_ --status active|suspended|quiesced|restoring|deleting|deleted +# keys.sh tenants list tenants +# keys.sh keys [--tenant t_] list keys (never a hash) +# keys.sh status pepper present? 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 +# validated before Docker is touched; a rejected combination exits 2. The pepper and the tokens are never printed, logged or put on a command line. +set -Eeuo pipefail +export LOG_NAME=keys +# shellcheck source=lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +usage() { sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2; } +bad() { echo "$*" >&2; exit 2; } +[ $# -ge 1 ] || usage +cmd="$1"; shift + +PEPPER_FILE="$SECRETS_DIR/key-pepper" +KEYS_DIR="$SECRETS_DIR/issued-keys" +TENANT_RE='^t_[0-9a-f]{32}$' +KEYID_RE='^[0-9a-f]{16}$' +STATUSES=" active suspended quiesced restoring deleting deleted " + +tenant="" label="" scopes="" expires="" out="" status="" key_id="" have_label=0 +parse_opts() { + while [ $# -gt 0 ]; do + case "$1" in + --tenant) [ $# -ge 2 ] || bad "--tenant needs a value"; tenant="$2"; shift ;; + --label) [ $# -ge 2 ] || bad "--label needs a value"; label="$2"; have_label=1; shift ;; + --scopes) [ $# -ge 2 ] || bad "--scopes needs a value"; scopes="$2"; shift ;; + --expires-days) [ $# -ge 2 ] || bad "--expires-days needs a value"; expires="$2"; shift ;; + --out) [ $# -ge 2 ] || bad "--out needs a value"; out="$2"; shift ;; + --status) [ $# -ge 2 ] || bad "--status needs a value"; status="$2"; shift ;; + *) bad "unknown argument: $1" ;; + esac + shift + done +} +need_tenant() { [[ "$tenant" =~ $TENANT_RE ]] || bad "--tenant must be t_ followed by 32 lowercase hex characters"; } +no_label_newline() { case "$label" in *$'\n'*|*$'\r'*) bad "--label must be a single line" ;; esac; [ "${#label}" -le 80 ] || bad "--label is at most 80 characters"; } + +# the command inside the migrate container, as the host user so a token file belongs to the operator and not to uid 10001; the pepper is a read-only bind mount, never an env value +tk() { + "${COMPOSE[@]}" run --rm --no-deps -T --user "$(id -u):$(id -g)" \ + -v "$PEPPER_FILE:/run/key-pepper:ro" -v "$KEYS_DIR:/keys" -e JARVIS_KEY_PEPPER_FILE=/run/key-pepper \ + migrate python -m app.tenant_keys "$@" +} +need_pepper() { [ -f "$PEPPER_FILE" ] || die "no pepper yet: run jarvisctl keys pepper-init first"; [ "$(stat -c %a "$PEPPER_FILE")" = 600 ] || die "$PEPPER_FILE must be mode 600"; mkdir -p "$KEYS_DIR"; chmod 700 "$KEYS_DIR"; } + +case "$cmd" in + pepper-init) + parse_opts "$@"; [ -z "$tenant$label$scopes$expires$out$status" ] || bad "pepper-init takes no options" + [ ! -e "$PEPPER_FILE" ] || die "$PEPPER_FILE already exists: keys issued under it would stop verifying if it changed, so it is never overwritten" + mkdir -p "$SECRETS_DIR" + ( umask 077; head -c 48 /dev/urandom | base64 -w0 > "$PEPPER_FILE.tmp.$$" && echo >> "$PEPPER_FILE.tmp.$$" && mv -n "$PEPPER_FILE.tmp.$$" "$PEPPER_FILE" ) + rm -f "$PEPPER_FILE.tmp.$$" + chmod 600 "$PEPPER_FILE" + log INFO "pepper written to $PEPPER_FILE (mode 600, $(head -c -1 "$PEPPER_FILE" | wc -c) characters; the value is not shown). Back it up with the secrets: keys issued under it verify only with 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" + no_label_newline; need_pepper + tk create-tenant --label "$label" + ;; + key-create) + parse_opts "$@"; [ -z "$status" ] || bad "key-create has no --status" + need_tenant; no_label_newline + [[ "$out" =~ ^[A-Za-z0-9._-]{1,64}$ ]] && [ "$out" != . ] && [ "$out" != .. ] || bad "--out NAME is required (letters, digits, . _ - only; the token is never printed)" + if [ -n "$scopes" ]; then [[ "$scopes" =~ ^(memory\.read|memory\.write)(,(memory\.read|memory\.write))*$ ]] || bad "--scopes must be memory.read and/or memory.write, comma-separated"; fi + if [ -n "$expires" ]; then [[ "$expires" =~ ^[0-9]+(\.[0-9]+)?$ ]] || bad "--expires-days must be a non-negative number (0 expires the key now)"; fi + need_pepper + [ ! -e "$KEYS_DIR/$out.token" ] || die "$KEYS_DIR/$out.token already exists: a token file is never overwritten" + args=(create-key --tenant "$tenant" --token-file "/keys/$out.token") + [ "$have_label" -eq 0 ] || args+=(--label "$label") + [ -z "$scopes" ] || args+=(--scopes "$scopes") + [ -z "$expires" ] || args+=(--expires-days "$expires") + tk "${args[@]}" + ;; + key-revoke) + [ $# -eq 1 ] || bad "key-revoke takes exactly one KEY_ID" + [[ "$1" =~ $KEYID_RE ]] || bad "KEY_ID must be 16 lowercase hex characters (the part after jlk_, never the whole token)" + need_pepper; tk revoke-key "$1" + ;; + tenant-status) + parse_opts "$@"; [ -z "$label$scopes$expires$out" ] || bad "tenant-status takes only --tenant and --status" + need_tenant + case "$STATUSES" in *" $status "*) ;; *) bad "--status must be one of:${STATUSES}" ;; esac + need_pepper; tk set-status --tenant "$tenant" --status "$status" + ;; + tenants) + [ $# -eq 0 ] || bad "tenants takes no options" + need_pepper; tk list-tenants + ;; + keys) + parse_opts "$@"; [ -z "$label$scopes$expires$out$status" ] || bad "keys takes only --tenant" + [ -z "$tenant" ] || need_tenant + need_pepper + if [ -n "$tenant" ]; then tk list-keys --tenant "$tenant"; else tk list-keys; fi + ;; + status) + [ $# -eq 0 ] || bad "status takes no options" + if [ -f "$PEPPER_FILE" ]; then echo "pepper: present ($(stat -c %a "$PEPPER_FILE"))"; else echo "pepper: absent (jarvisctl keys pepper-init)"; fi + if [ -f "$SECRETS_DIR/app.env" ] && grep -qE '^JARVIS_KEY_TENANTS=(1|true|yes|on)$' "$SECRETS_DIR/app.env"; then echo "key authentication: ON in secrets/app.env"; else echo "key authentication: off (JARVIS_KEY_TENANTS not set in secrets/app.env)"; fi + if [ -f "$PEPPER_FILE" ]; then + echo "tenants: $(tk list-tenants 2>/dev/null | grep -c '^t_' || true)" + echo "keys: $(tk list-keys 2>/dev/null | grep -c '^[0-9a-f]\{16\}' || true)" + fi + ;; + *) usage ;; +esac diff --git a/docs/tenant_keys.md b/docs/tenant_keys.md index 2558e9f..98adc72 100644 --- a/docs/tenant_keys.md +++ b/docs/tenant_keys.md @@ -46,6 +46,24 @@ python -m app.tenant_keys set-status --tenant t_ --status suspended python -m app.tenant_keys list-tenants | list-keys [--tenant t_] ``` +### On the box: `jarvisctl keys` + +`jarvisctl keys ...` wraps the commands above for the box. It runs the same CLI in the migrate container as the migrator role, as the host user, with the pepper mounted read-only (never passed as a value), and +**never prints a token**: `key-create` requires `--out NAME` and writes `secrets/issued-keys/NAME.token` (mode 600, directory 700; an existing file is never overwritten). Every argument is validated before Docker is touched. + +``` +jarvisctl keys pepper-init # once: secrets/key-pepper, random, mode 600, never overwritten, value not shown. Back it up with the secrets: keys verify only with it. +jarvisctl keys tenant-create --label alice # prints the new t_... id +jarvisctl keys key-create --tenant t_... --out alice-agent [--label L] [--scopes memory.read,memory.write] [--expires-days N] +jarvisctl keys key-revoke KEY_ID # the 16 hex characters after jlk_ +jarvisctl keys tenant-status --tenant t_... --status suspended +jarvisctl keys tenants | keys [--tenant t_...] | status +``` + +Issuing a key does **not** turn key authentication on. To enable it: `jarvisctl keys pepper-init`, make the pepper readable by the app's uid (10001) and mount it into the app container, set +`JARVIS_KEY_TENANTS=1` and `JARVIS_KEY_PEPPER_FILE` in `secrets/app.env`, recreate the app only, and probe a key before announcing it (the 2026-10-17 drill is the template). Until then a `jlk_` token is an +ordinary wrong key (401). `tests/test_deploy_keys.py` pins the wrapper. + The token exists in exactly one place: the output of `create-key`. Prefer `--token-file` (mode 600, never overwrites an existing file) so it never appears on a terminal. Listing never shows a hash. Pinned by `tests/test_tenant_keys.py` (privileges, the constraints, round trip, uniform failure, expiry, revocation, the comparison on unknown ids, rate-limited `last_used_at`, the CLI, rollback and forward again). @@ -75,4 +93,4 @@ request parameter. The operator's ledger key keeps working exactly as before (th properties are proven for both kinds of tenant identity, and a route added without a matrix classification fails there too. The few routes that answer a request with no credential in a shared-key deployment (they carry no ledger data and behave the same on the box today) are named in that file, not hidden. -Not in this slice: `jarvisctl` wrappers for issuing keys, per-tenant call-log chains, and recording the credential in the history hash. +Not in this slice: per-tenant call-log chains, and recording the credential in the history hash. diff --git a/tests/test_deploy_keys.py b/tests/test_deploy_keys.py new file mode 100644 index 0000000..ddb84df --- /dev/null +++ b/tests/test_deploy_keys.py @@ -0,0 +1,159 @@ +"""jarvisctl keys: the box-side wrapper for per-tenant API keys (RFC phase 1). What it validates before Docker is touched, that the pepper and the tokens are never printed or put on a +command line, that a pepper or a token file is never overwritten, and that issuing a key does not switch key authentication on.""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MINT = ROOT / "deploy" / "mint" +TENANT = "t_" + "ab" * 16 +KEY_ID = "0123456789abcdef" + + +@pytest.fixture +def box(tmp_path): + d = tmp_path / "mint" + shutil.copytree(MINT, d, ignore=shutil.ignore_patterns(".env", "secrets")) + (d / "secrets").mkdir() + fake = tmp_path / "bin" + fake.mkdir() + log = tmp_path / "docker.log" + # a docker that records every argument (one line per call) and what the container would see in the environment, and answers like the CLI does + (fake / "docker").write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$FAKE_DOCKER_LOG"\ncase "$*" in *create-key*) echo "key 0123456789abcdef issued; token written to /keys/x.token" ;; *list-tenants*) echo "t_x active a" ;; esac\nexit 0\n') + (fake / "docker").chmod(0o755) + env = dict(os.environ, PATH=f"{fake}:{os.environ['PATH']}", JARVIS_HOME=str(tmp_path / "home"), FAKE_DOCKER_LOG=str(log), HOME=str(tmp_path)) + + class Box: + dir, secrets, env_, log_ = d, d / "secrets", env, log + + def run(self, *args): + return subprocess.run([str(d / "bin" / "keys.sh"), *args], env=env, capture_output=True, text=True, timeout=60) + + def docker_calls(self): + return log.read_text().splitlines() if log.exists() else [] + + return Box() + + +def with_pepper(box): + r = box.run("pepper-init") + assert r.returncode == 0, r.stderr + return (box.secrets / "key-pepper").read_text().strip() + + +BAD = [ + ("tenant-create",), ("tenant-create", "--label"), ("tenant-create", "--label", "x", "--tenant", TENANT), + ("key-create", "--tenant", TENANT), ("key-create", "--tenant", "operator", "--out", "a"), ("key-create", "--tenant", TENANT.upper(), "--out", "a"), + ("key-create", "--tenant", "t_" + "ab" * 15, "--out", "a"), ("key-create", "--tenant", TENANT, "--out", "../x"), ("key-create", "--tenant", TENANT, "--out", "a/b"), + ("key-create", "--tenant", TENANT, "--out", ".."), ("key-create", "--tenant", TENANT, "--out", "a b"), + ("key-create", "--tenant", TENANT, "--out", "a", "--scopes", "admin"), ("key-create", "--tenant", TENANT, "--out", "a", "--scopes", "memory.read,"), + ("key-create", "--tenant", TENANT, "--out", "a", "--expires-days", "-1"), ("key-create", "--tenant", TENANT, "--out", "a", "--expires-days", "soon"), + ("key-create", "--tenant", TENANT, "--out", "a", "--status", "active"), ("key-create", "--tenant", TENANT, "--out", "a", "--label", "x" * 81), + ("key-create", "--tenant", TENANT, "--out", "a", "--label", "two\nlines"), + ("key-revoke",), ("key-revoke", "jlk_" + KEY_ID + "_" + "A" * 43), ("key-revoke", KEY_ID.upper()), ("key-revoke", KEY_ID, "extra"), + ("tenant-status", "--tenant", TENANT), ("tenant-status", "--tenant", TENANT, "--status", "gone"), ("tenant-status", "--tenant", "x", "--status", "active"), + ("tenants", "--all"), ("keys", "--tenant", "nope"), ("status", "x"), ("pepper-init", "--out", "x"), ("nonsense",), ("keys", "--bogus"), +] + + +@pytest.mark.parametrize("args", BAD, ids=lambda a: " ".join(a)[:70].replace("\n", "\\n")) +def test_a_bad_argument_is_refused_before_docker_is_touched(box, args): + with_pepper(box) + r = box.run(*args) + assert r.returncode == 2, (r.returncode, r.stderr) + assert box.docker_calls() == [] + + +def test_pepper_init_writes_a_strong_mode_600_secret_and_prints_none_of_it(box): + r = box.run("pepper-init") + assert r.returncode == 0 + p = box.secrets / "key-pepper" + value = p.read_text().strip() + assert stat.S_IMODE(p.stat().st_mode) == 0o600 and len(value) >= 32 + assert value not in r.stdout + r.stderr + assert not list(box.secrets.glob("key-pepper.tmp*")) + + +def test_the_pepper_is_never_overwritten(box): + first = with_pepper(box) + r = box.run("pepper-init") + assert r.returncode == 1 and "already exists" in r.stderr + assert (box.secrets / "key-pepper").read_text().strip() == first + + +@pytest.mark.parametrize("args", [("tenant-create", "--label", "alice"), ("tenants",), ("keys",), ("key-revoke", KEY_ID), + ("key-create", "--tenant", TENANT, "--out", "alice")]) +def test_nothing_runs_without_a_pepper(box, args): + r = box.run(*args) + assert r.returncode == 1 and "pepper-init" in r.stderr + assert box.docker_calls() == [] + + +def test_the_pepper_must_be_mode_600(box): + with_pepper(box) + (box.secrets / "key-pepper").chmod(0o640) + r = box.run("tenants") + assert r.returncode == 1 and "600" in r.stderr and box.docker_calls() == [] + + +def test_key_create_hands_the_cli_a_token_file_and_a_read_only_pepper_mount_and_never_the_values(box): + pepper = with_pepper(box) + r = box.run("key-create", "--tenant", TENANT, "--out", "alice", "--label", "alice's agent", "--scopes", "memory.read", "--expires-days", "30") + assert r.returncode == 0, r.stderr + (call,) = box.docker_calls() + for needle in ("create-key", f"--tenant {TENANT}", "--token-file /keys/alice.token", "--scopes memory.read", "--expires-days 30", "migrate python -m app.tenant_keys", + "-e JARVIS_KEY_PEPPER_FILE=/run/key-pepper", f"{box.secrets}/key-pepper:/run/key-pepper:ro", f"--user {os.getuid()}:{os.getgid()}"): + assert needle in call, needle + assert pepper not in call and pepper not in r.stdout + r.stderr + assert "jlk_" not in call + r.stdout + r.stderr + assert (box.secrets / "issued-keys").is_dir() and stat.S_IMODE((box.secrets / "issued-keys").stat().st_mode) == 0o700 + + +def test_an_existing_token_file_is_never_overwritten_and_the_cli_is_not_even_called(box): + with_pepper(box) + keys = box.secrets / "issued-keys" + keys.mkdir() + (keys / "alice.token").write_text("precious\n") + r = box.run("key-create", "--tenant", TENANT, "--out", "alice") + assert r.returncode == 1 and "never overwritten" in r.stderr + assert (keys / "alice.token").read_text() == "precious\n" and box.docker_calls() == [] + + +def test_the_other_commands_pass_exactly_their_arguments(box): + with_pepper(box) + assert box.run("tenant-create", "--label", "alice").returncode == 0 + assert box.run("key-revoke", KEY_ID).returncode == 0 + assert box.run("tenant-status", "--tenant", TENANT, "--status", "suspended").returncode == 0 + assert box.run("tenants").returncode == 0 + assert box.run("keys", "--tenant", TENANT).returncode == 0 + calls = box.docker_calls() + assert [c.split("app.tenant_keys ", 1)[1] for c in calls] == ["create-tenant --label alice", f"revoke-key {KEY_ID}", f"set-status --tenant {TENANT} --status suspended", + "list-tenants", f"list-keys --tenant {TENANT}"] + + +def test_status_reports_whether_key_authentication_is_on_and_issuing_a_key_does_not_turn_it_on(box): + r = box.run("status") + assert r.returncode == 0 and "pepper: absent" in r.stdout and "key authentication: off" in r.stdout + with_pepper(box) + (box.secrets / "app.env").write_text("JARVIS_API_KEY=secret-value-that-must-not-be-echoed\n") + box.run("key-create", "--tenant", TENANT, "--out", "alice") + assert "JARVIS_KEY_TENANTS" not in (box.secrets / "app.env").read_text() + r = box.run("status") + assert "key authentication: off" in r.stdout and "secret-value" not in r.stdout + r.stderr + (box.secrets / "app.env").write_text("JARVIS_KEY_TENANTS=1\n") + assert "key authentication: ON" in box.run("status").stdout + + +def test_jarvisctl_dispatches_keys_and_lists_it_in_help(box): + ctl = str(box.dir / "bin" / "jarvisctl") + help_text = subprocess.run([ctl, "help"], env=box.env_, capture_output=True, text=True).stdout + assert "keys pepper-init|tenant-create|key-create" in help_text + r = subprocess.run([ctl, "keys", "nonsense"], env=box.env_, capture_output=True, text=True) + assert r.returncode == 2 and "keys.sh" in r.stderr From 7e7836aef6afcc0dd43abb3d91c0cd4e5cf4e7aa Mon Sep 17 00:00:00 2001 From: warheart1984-ctrl Date: Sun, 18 Oct 2026 15:58:41 -0400 Subject: [PATCH 2/4] jarvisctl keys status: read JARVIS_KEY_TENANTS the way the app does (Codex on #115) Co-Authored-By: Claude Sonnet 5.5 --- deploy/mint/bin/keys.sh | 4 +++- tests/test_deploy_keys.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/deploy/mint/bin/keys.sh b/deploy/mint/bin/keys.sh index 00e55d2..84809a3 100755 --- a/deploy/mint/bin/keys.sh +++ b/deploy/mint/bin/keys.sh @@ -110,7 +110,9 @@ case "$cmd" in status) [ $# -eq 0 ] || bad "status takes no options" if [ -f "$PEPPER_FILE" ]; then echo "pepper: present ($(stat -c %a "$PEPPER_FILE"))"; else echo "pepper: absent (jarvisctl keys pepper-init)"; fi - if [ -f "$SECRETS_DIR/app.env" ] && grep -qE '^JARVIS_KEY_TENANTS=(1|true|yes|on)$' "$SECRETS_DIR/app.env"; then echo "key authentication: ON in secrets/app.env"; else echo "key authentication: off (JARVIS_KEY_TENANTS not set in secrets/app.env)"; fi + # as the app reads it: the LAST assignment wins (compose env_file), surrounding quotes and blanks are dropped, and key_tenants_enabled() lowercases (1/true/yes/on) + flag="$(grep -E '^[[:space:]]*JARVIS_KEY_TENANTS[[:space:]]*=' "$SECRETS_DIR/app.env" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//; s/^[[:space:]]+//; s/[[:space:]]+$//; s/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/; s/^[[:space:]]+//; s/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]' || true)" + case "$flag" in 1|true|yes|on) echo "key authentication: ON in secrets/app.env" ;; *) echo "key authentication: off (JARVIS_KEY_TENANTS not enabled in secrets/app.env)" ;; esac if [ -f "$PEPPER_FILE" ]; then echo "tenants: $(tk list-tenants 2>/dev/null | grep -c '^t_' || true)" echo "keys: $(tk list-keys 2>/dev/null | grep -c '^[0-9a-f]\{16\}' || true)" diff --git a/tests/test_deploy_keys.py b/tests/test_deploy_keys.py index ddb84df..56cec57 100644 --- a/tests/test_deploy_keys.py +++ b/tests/test_deploy_keys.py @@ -157,3 +157,21 @@ def test_jarvisctl_dispatches_keys_and_lists_it_in_help(box): assert "keys pepper-init|tenant-create|key-create" in help_text r = subprocess.run([ctl, "keys", "nonsense"], env=box.env_, capture_output=True, text=True) assert r.returncode == 2 and "keys.sh" in r.stderr + + +@pytest.mark.parametrize("line,expected", [("JARVIS_KEY_TENANTS=1", "ON"), ("JARVIS_KEY_TENANTS=TRUE", "ON"), ("JARVIS_KEY_TENANTS=Yes", "ON"), ("JARVIS_KEY_TENANTS=on", "ON"), + ("JARVIS_KEY_TENANTS= 1 ", "ON"), ('JARVIS_KEY_TENANTS="true"', "ON"), ("JARVIS_KEY_TENANTS='ON'", "ON"), + ("JARVIS_KEY_TENANTS=0", "off"), ("JARVIS_KEY_TENANTS=false", "off"), ("JARVIS_KEY_TENANTS=", "off"), ("JARVIS_KEY_TENANTS=maybe", "off"), + ("# JARVIS_KEY_TENANTS=1", "off"), ("XJARVIS_KEY_TENANTS=1", "off")]) +def test_status_reads_the_flag_the_way_the_application_does(box, line, expected): + """Codex on #115: key_tenants_enabled() strips and lowercases, so status must not report 'off' for a value the app accepts (nor 'ON' for one it rejects).""" + (box.secrets / "app.env").write_text("JARVIS_API_KEY=x\n" + line + "\n") + out = box.run("status").stdout + assert f"key authentication: {expected}" in out, out + + +def test_status_uses_the_last_assignment_like_compose(box): + (box.secrets / "app.env").write_text("JARVIS_KEY_TENANTS=1\nJARVIS_KEY_TENANTS=0\n") + assert "key authentication: off" in box.run("status").stdout + (box.secrets / "app.env").write_text("JARVIS_KEY_TENANTS=0\nJARVIS_KEY_TENANTS=true\n") + assert "key authentication: ON" in box.run("status").stdout From c0f04f33fbd9b5ee026a809b86dfd9953a2c9d5c Mon Sep 17 00:00:00 2001 From: warheart1984-ctrl Date: Sun, 18 Oct 2026 16:41:51 -0400 Subject: [PATCH 3/4] jarvisctl keys status: parse compose inline comments; failed queries are 'unavailable', never zero (Codex on #115) Co-Authored-By: Claude Sonnet 5.5 --- deploy/mint/bin/keys.sh | 12 ++++++++---- tests/test_deploy_keys.py | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/deploy/mint/bin/keys.sh b/deploy/mint/bin/keys.sh index 84809a3..af473e9 100755 --- a/deploy/mint/bin/keys.sh +++ b/deploy/mint/bin/keys.sh @@ -110,12 +110,16 @@ case "$cmd" in status) [ $# -eq 0 ] || bad "status takes no options" if [ -f "$PEPPER_FILE" ]; then echo "pepper: present ($(stat -c %a "$PEPPER_FILE"))"; else echo "pepper: absent (jarvisctl keys pepper-init)"; fi - # as the app reads it: the LAST assignment wins (compose env_file), surrounding quotes and blanks are dropped, and key_tenants_enabled() lowercases (1/true/yes/on) - flag="$(grep -E '^[[:space:]]*JARVIS_KEY_TENANTS[[:space:]]*=' "$SECRETS_DIR/app.env" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//; s/^[[:space:]]+//; s/[[:space:]]+$//; s/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/; s/^[[:space:]]+//; s/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]' || true)" + # as the app reads it: the LAST assignment wins (compose env_file); a quoted value is what is inside the quotes (anything after the closing quote is a comment); an unquoted value ends + # at a space-prefixed "#" (compose inline comment); key_tenants_enabled() lowercases (1/true/yes/on) + flag="$(grep -E '^[[:space:]]*JARVIS_KEY_TENANTS[[:space:]]*=' "$SECRETS_DIR/app.env" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//; s/^[[:space:]]+//; /^"/{s/^"([^"]*)".*$/\1/;b done}; /^'"'"'/{s/^'"'"'([^'"'"']*)'"'"'.*$/\1/;b done}; s/[[:space:]]+#.*$//; :done; s/^[[:space:]]+//; s/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]' || true)" case "$flag" in 1|true|yes|on) echo "key authentication: ON in secrets/app.env" ;; *) echo "key authentication: off (JARVIS_KEY_TENANTS not enabled in secrets/app.env)" ;; esac if [ -f "$PEPPER_FILE" ]; then - echo "tenants: $(tk list-tenants 2>/dev/null | grep -c '^t_' || true)" - echo "keys: $(tk list-keys 2>/dev/null | grep -c '^[0-9a-f]\{16\}' || true)" + # a failed query is reported as unavailable (and fails the command), never as an empty deployment + rc=0 + if t="$(tk list-tenants 2>/dev/null)"; then echo "tenants: $(printf '%s\n' "$t" | grep -c '^t_' || true)"; else echo "tenants: unavailable (the control schema could not be queried)"; rc=1; fi + if k="$(tk list-keys 2>/dev/null)"; then echo "keys: $(printf '%s\n' "$k" | grep -c '^[0-9a-f]\{16\}' || true)"; else echo "keys: unavailable (the control schema could not be queried)"; rc=1; fi + exit "$rc" fi ;; *) usage ;; diff --git a/tests/test_deploy_keys.py b/tests/test_deploy_keys.py index 56cec57..770886d 100644 --- a/tests/test_deploy_keys.py +++ b/tests/test_deploy_keys.py @@ -26,7 +26,7 @@ def box(tmp_path): fake.mkdir() log = tmp_path / "docker.log" # a docker that records every argument (one line per call) and what the container would see in the environment, and answers like the CLI does - (fake / "docker").write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$FAKE_DOCKER_LOG"\ncase "$*" in *create-key*) echo "key 0123456789abcdef issued; token written to /keys/x.token" ;; *list-tenants*) echo "t_x active a" ;; esac\nexit 0\n') + (fake / "docker").write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$FAKE_DOCKER_LOG"\ncase "$*" in *create-key*) echo "key 0123456789abcdef issued; token written to /keys/x.token" ;; *list-tenants*) echo "t_x active a" ;; esac\nexit ${FAKE_DOCKER_RC:-0}\n') (fake / "docker").chmod(0o755) env = dict(os.environ, PATH=f"{fake}:{os.environ['PATH']}", JARVIS_HOME=str(tmp_path / "home"), FAKE_DOCKER_LOG=str(log), HOME=str(tmp_path)) @@ -162,7 +162,8 @@ def test_jarvisctl_dispatches_keys_and_lists_it_in_help(box): @pytest.mark.parametrize("line,expected", [("JARVIS_KEY_TENANTS=1", "ON"), ("JARVIS_KEY_TENANTS=TRUE", "ON"), ("JARVIS_KEY_TENANTS=Yes", "ON"), ("JARVIS_KEY_TENANTS=on", "ON"), ("JARVIS_KEY_TENANTS= 1 ", "ON"), ('JARVIS_KEY_TENANTS="true"', "ON"), ("JARVIS_KEY_TENANTS='ON'", "ON"), ("JARVIS_KEY_TENANTS=0", "off"), ("JARVIS_KEY_TENANTS=false", "off"), ("JARVIS_KEY_TENANTS=", "off"), ("JARVIS_KEY_TENANTS=maybe", "off"), - ("# JARVIS_KEY_TENANTS=1", "off"), ("XJARVIS_KEY_TENANTS=1", "off")]) + ("JARVIS_KEY_TENANTS=1 # enable tenant keys", "ON"), ('JARVIS_KEY_TENANTS="true" # enable', "ON"), ("JARVIS_KEY_TENANTS=0 # was 1", "off"), ("JARVIS_KEY_TENANTS=1#not-a-comment", "off"), + ("JARVIS_KEY_TENANTS='on' # x # y", "ON"), ("# JARVIS_KEY_TENANTS=1", "off"), ("XJARVIS_KEY_TENANTS=1", "off")]) def test_status_reads_the_flag_the_way_the_application_does(box, line, expected): """Codex on #115: key_tenants_enabled() strips and lowercases, so status must not report 'off' for a value the app accepts (nor 'ON' for one it rejects).""" (box.secrets / "app.env").write_text("JARVIS_API_KEY=x\n" + line + "\n") @@ -175,3 +176,15 @@ def test_status_uses_the_last_assignment_like_compose(box): assert "key authentication: off" in box.run("status").stdout (box.secrets / "app.env").write_text("JARVIS_KEY_TENANTS=0\nJARVIS_KEY_TENANTS=true\n") assert "key authentication: ON" in box.run("status").stdout + + +def test_status_does_not_turn_a_failed_query_into_an_empty_deployment(box): + """Codex on #115: an unavailable control plane must not read as 'tenants: 0 / keys: 0'.""" + with_pepper(box) + box.env_["FAKE_DOCKER_RC"] = "1" + r = box.run("status") + assert r.returncode == 1 and "tenants: unavailable" in r.stdout and "keys: unavailable" in r.stdout + assert "tenants: 0" not in r.stdout and "keys: 0" not in r.stdout + box.env_["FAKE_DOCKER_RC"] = "0" + r = box.run("status") + assert r.returncode == 0 and "tenants: 1" in r.stdout From b41a87e278aab3513b54fe8b8d37b79a4c30ac95 Mon Sep 17 00:00:00 2001 From: warheart1984-ctrl Date: Sun, 18 Oct 2026 16:53:43 -0400 Subject: [PATCH 4/4] jarvisctl keys: the app gets its own pepper copy (owner 10001); the operator copy never changes owner (Codex P1 on #115) Co-Authored-By: Claude Sonnet 5.5 --- deploy/mint/README.md | 2 +- deploy/mint/bin/jarvisctl | 2 +- deploy/mint/bin/keys.sh | 22 +++++++++++++++++++--- docs/tenant_keys.md | 13 ++++++++++--- tests/test_deploy_keys.py | 37 +++++++++++++++++++++++++++++++++++-- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/deploy/mint/README.md b/deploy/mint/README.md index 8967b98..404b0eb 100644 --- a/deploy/mint/README.md +++ b/deploy/mint/README.md @@ -232,7 +232,7 @@ 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|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|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. diff --git a/deploy/mint/bin/jarvisctl b/deploy/mint/bin/jarvisctl index 141ead1..7efa339 100755 --- a/deploy/mint/bin/jarvisctl +++ b/deploy/mint/bin/jarvisctl @@ -22,7 +22,7 @@ jarvisctl 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|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|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 diff --git a/deploy/mint/bin/keys.sh b/deploy/mint/bin/keys.sh index af473e9..e6a9953 100755 --- a/deploy/mint/bin/keys.sh +++ b/deploy/mint/bin/keys.sh @@ -10,7 +10,8 @@ # keys.sh tenant-status --tenant t_ --status active|suspended|quiesced|restoring|deleting|deleted # keys.sh tenants list tenants # keys.sh keys [--tenant t_] list keys (never a hash) -# keys.sh status pepper present? is JARVIS_KEY_TENANTS set in the app env? how many tenants and keys +# 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 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 # validated before Docker is touched; a rejected combination exits 2. The pepper and the tokens are never printed, logged or put on a command line. @@ -19,12 +20,13 @@ export LOG_NAME=keys # shellcheck source=lib.sh source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -usage() { sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2; } +usage() { sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2; } bad() { echo "$*" >&2; exit 2; } [ $# -ge 1 ] || usage cmd="$1"; shift PEPPER_FILE="$SECRETS_DIR/key-pepper" +APP_PEPPER_FILE="$SECRETS_DIR/key-pepper.app" # what the APP mounts: its own copy, owned by uid 10001. This CLI always uses the operator copy above, which never changes owner. KEYS_DIR="$SECRETS_DIR/issued-keys" TENANT_RE='^t_[0-9a-f]{32}$' KEYID_RE='^[0-9a-f]{16}$' @@ -54,7 +56,7 @@ tk() { -v "$PEPPER_FILE:/run/key-pepper:ro" -v "$KEYS_DIR:/keys" -e JARVIS_KEY_PEPPER_FILE=/run/key-pepper \ migrate python -m app.tenant_keys "$@" } -need_pepper() { [ -f "$PEPPER_FILE" ] || die "no pepper yet: run jarvisctl keys pepper-init first"; [ "$(stat -c %a "$PEPPER_FILE")" = 600 ] || die "$PEPPER_FILE must be mode 600"; mkdir -p "$KEYS_DIR"; chmod 700 "$KEYS_DIR"; } +need_pepper() { [ -f "$PEPPER_FILE" ] || die "no pepper yet: run jarvisctl keys pepper-init first"; [ "$(stat -c %a "$PEPPER_FILE")" = 600 ] || die "$PEPPER_FILE must be mode 600"; [ "$(stat -c %u "$PEPPER_FILE")" = "$(id -u)" ] || die "$PEPPER_FILE must belong to you (the app gets its own copy: jarvisctl keys pepper-install-app); never chown it"; [ -r "$PEPPER_FILE" ] || die "$PEPPER_FILE is not readable by you"; mkdir -p "$KEYS_DIR"; chmod 700 "$KEYS_DIR"; } case "$cmd" in pepper-init) @@ -66,6 +68,16 @@ case "$cmd" in chmod 600 "$PEPPER_FILE" log INFO "pepper written to $PEPPER_FILE (mode 600, $(head -c -1 "$PEPPER_FILE" | wc -c) characters; the value is not shown). Back it up with the secrets: keys issued under it verify only with it." ;; + pepper-install-app) + parse_opts "$@"; [ -z "$tenant$label$scopes$expires$out$status" ] || bad "pepper-install-app takes no options" + need_pepper + [ ! -e "$APP_PEPPER_FILE" ] || die "$APP_PEPPER_FILE already exists: it is never overwritten (delete it yourself to replace it; keys verify only under the pepper they were issued with)" + # a root container (the default) can read your file and chown the copy; the copy is the only thing that changes owner. Nothing about the value is passed on a command line. + docker run --rm --user 0 --read-only --cap-drop ALL --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER --network none --entrypoint sh \ + -v "$SECRETS_DIR:/s" "$APP_IMAGE" -c 'set -e; umask 077; cp /s/key-pepper /s/key-pepper.app; chown 10001:10001 /s/key-pepper.app; chmod 400 /s/key-pepper.app' \ + || 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." + ;; 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" @@ -110,6 +122,10 @@ case "$cmd" in status) [ $# -eq 0 ] || bad "status takes no options" if [ -f "$PEPPER_FILE" ]; then echo "pepper: present ($(stat -c %a "$PEPPER_FILE"))"; else echo "pepper: absent (jarvisctl keys pepper-init)"; fi + if [ -f "$APP_PEPPER_FILE" ]; then + if [ -f "$PEPPER_FILE" ] && [ "$(docker run --rm --user 0 --read-only --cap-drop ALL --cap-add DAC_OVERRIDE --network none --entrypoint sh -v "$SECRETS_DIR:/s:ro" "$APP_IMAGE" -c 'cmp -s /s/key-pepper /s/key-pepper.app && echo same || echo different' 2>/dev/null)" = same ]; then echo "app pepper copy: present, identical to the operator copy" + else echo "app pepper copy: present, but NOT confirmed identical to the operator copy (keys would not verify in the app)"; fi + else echo "app pepper copy: absent (jarvisctl keys pepper-install-app)"; fi # as the app reads it: the LAST assignment wins (compose env_file); a quoted value is what is inside the quotes (anything after the closing quote is a comment); an unquoted value ends # at a space-prefixed "#" (compose inline comment); key_tenants_enabled() lowercases (1/true/yes/on) flag="$(grep -E '^[[:space:]]*JARVIS_KEY_TENANTS[[:space:]]*=' "$SECRETS_DIR/app.env" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//; s/^[[:space:]]+//; /^"/{s/^"([^"]*)".*$/\1/;b done}; /^'"'"'/{s/^'"'"'([^'"'"']*)'"'"'.*$/\1/;b done}; s/[[:space:]]+#.*$//; :done; s/^[[:space:]]+//; s/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]' || true)" diff --git a/docs/tenant_keys.md b/docs/tenant_keys.md index 98adc72..ee430ac 100644 --- a/docs/tenant_keys.md +++ b/docs/tenant_keys.md @@ -53,6 +53,7 @@ python -m app.tenant_keys list-tenants | list-keys [--tenant t_] ``` jarvisctl keys pepper-init # once: secrets/key-pepper, random, mode 600, never overwritten, value not shown. Back it up with the secrets: keys verify only with it. +jarvisctl keys pepper-install-app # the app's own copy (owner 10001, mode 400) for step 1 of enabling; the operator copy never changes owner jarvisctl keys tenant-create --label alice # prints the new t_... id jarvisctl keys key-create --tenant t_... --out alice-agent [--label L] [--scopes memory.read,memory.write] [--expires-days N] jarvisctl keys key-revoke KEY_ID # the 16 hex characters after jlk_ @@ -60,9 +61,15 @@ jarvisctl keys tenant-status --tenant t_... --status suspended jarvisctl keys tenants | keys [--tenant t_...] | status ``` -Issuing a key does **not** turn key authentication on. To enable it: `jarvisctl keys pepper-init`, make the pepper readable by the app's uid (10001) and mount it into the app container, set -`JARVIS_KEY_TENANTS=1` and `JARVIS_KEY_PEPPER_FILE` in `secrets/app.env`, recreate the app only, and probe a key before announcing it (the 2026-10-17 drill is the template). Until then a `jlk_` token is an -ordinary wrong key (401). `tests/test_deploy_keys.py` pins the wrapper. +Issuing a key does **not** turn key authentication on. To enable it: + +1. `jarvisctl keys pepper-init` (once), then `jarvisctl keys pepper-install-app`. The operator's pepper (`secrets/key-pepper`) stays yours, mode 600, and is the one `jarvisctl keys` always uses, so + `key-revoke` keeps working after keys are on. The app gets its **own copy** (`secrets/key-pepper.app`, owner uid 10001, mode 400, made in a throwaway container; the value is never shown). Never `chown` + the operator copy: the management commands run as you and could no longer read it. `jarvisctl keys status` says whether the two copies are identical; they must be, or issued keys will not verify. +2. Mount **the app copy** (only that file) into the app container as `JARVIS_KEY_PEPPER_FILE`, set `JARVIS_KEY_TENANTS=1` in `secrets/app.env`, recreate the app only, and probe a key before announcing + it (the 2026-10-17 drill is the template). + +Until then a `jlk_` token is an ordinary wrong key (401). `tests/test_deploy_keys.py` pins the wrapper. The token exists in exactly one place: the output of `create-key`. Prefer `--token-file` (mode 600, never overwrites an existing file) so it never appears on a terminal. Listing never shows a hash. diff --git a/tests/test_deploy_keys.py b/tests/test_deploy_keys.py index 770886d..c299ece 100644 --- a/tests/test_deploy_keys.py +++ b/tests/test_deploy_keys.py @@ -26,7 +26,7 @@ def box(tmp_path): fake.mkdir() log = tmp_path / "docker.log" # a docker that records every argument (one line per call) and what the container would see in the environment, and answers like the CLI does - (fake / "docker").write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$FAKE_DOCKER_LOG"\ncase "$*" in *create-key*) echo "key 0123456789abcdef issued; token written to /keys/x.token" ;; *list-tenants*) echo "t_x active a" ;; esac\nexit ${FAKE_DOCKER_RC:-0}\n') + (fake / "docker").write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$FAKE_DOCKER_LOG"\ncase "$*" in *create-key*) echo "key 0123456789abcdef issued; token written to /keys/x.token" ;; *list-tenants*) echo "t_x active a" ;; *"cmp -s"*) echo same ;; esac\nexit ${FAKE_DOCKER_RC:-0}\n') (fake / "docker").chmod(0o755) env = dict(os.environ, PATH=f"{fake}:{os.environ['PATH']}", JARVIS_HOME=str(tmp_path / "home"), FAKE_DOCKER_LOG=str(log), HOME=str(tmp_path)) @@ -154,7 +154,7 @@ def test_status_reports_whether_key_authentication_is_on_and_issuing_a_key_does_ def test_jarvisctl_dispatches_keys_and_lists_it_in_help(box): ctl = str(box.dir / "bin" / "jarvisctl") help_text = subprocess.run([ctl, "help"], env=box.env_, capture_output=True, text=True).stdout - assert "keys pepper-init|tenant-create|key-create" in help_text + assert "keys pepper-init|pepper-install-app|tenant-create|key-create" in help_text r = subprocess.run([ctl, "keys", "nonsense"], env=box.env_, capture_output=True, text=True) assert r.returncode == 2 and "keys.sh" in r.stderr @@ -188,3 +188,36 @@ def test_status_does_not_turn_a_failed_query_into_an_empty_deployment(box): box.env_["FAKE_DOCKER_RC"] = "0" r = box.run("status") assert r.returncode == 0 and "tenants: 1" in r.stdout + + +def test_pepper_install_app_makes_a_separate_app_owned_copy_and_never_touches_the_operator_copy(box): + """Codex (P1) on #115: the app needs the pepper owned by uid 10001, and the operator CLI must keep reading ITS copy as the host user (key-revoke must work once keys are on).""" + pepper = with_pepper(box) + r = box.run("pepper-install-app") + assert r.returncode == 0, r.stderr + (call,) = box.docker_calls() + for needle in ("run --rm --user 0", "--network none", "--read-only", f"-v {box.secrets}:/s", "chown 10001:10001 /s/key-pepper.app", "chmod 400 /s/key-pepper.app", "cp /s/key-pepper /s/key-pepper.app"): + assert needle in call, needle + assert pepper not in call + r.stdout + r.stderr + assert (box.secrets / "key-pepper").read_text().strip() == pepper and stat.S_IMODE((box.secrets / "key-pepper").stat().st_mode) == 0o600 + # and the management commands still run as the host user against the operator copy, never the app copy + box.run("key-revoke", KEY_ID) + revoke = box.docker_calls()[-1] + assert f"--user {os.getuid()}:{os.getgid()}" in revoke and f"{box.secrets}/key-pepper:/run/key-pepper:ro" in revoke and "key-pepper.app" not in revoke + + +def test_the_app_copy_is_never_overwritten_and_needs_the_operator_pepper_first(box): + r = box.run("pepper-install-app") + assert r.returncode == 1 and "pepper-init" in r.stderr and box.docker_calls() == [] + with_pepper(box) + (box.secrets / "key-pepper.app").write_text("existing\n") + r = box.run("pepper-install-app") + assert r.returncode == 1 and "already exists" in r.stderr and box.docker_calls() == [] + assert (box.secrets / "key-pepper.app").read_text() == "existing\n" + + +def test_status_reports_the_app_copy(box): + with_pepper(box) + assert "app pepper copy: absent" in box.run("status").stdout + (box.secrets / "key-pepper.app").write_text("x\n") + assert "app pepper copy: present, identical" in box.run("status").stdout # the fake docker answers `same`