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
6 changes: 6 additions & 0 deletions deploy/mint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|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.

### 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: 2 additions & 0 deletions deploy/mint/bin/jarvisctl
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +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
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 Expand Up @@ -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" "$@" ;;
Expand Down
142 changes: 142 additions & 0 deletions deploy/mint/bin/keys.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/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_<hex> --status active|suspended|quiesced|restoring|deleting|deleted
# 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 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.
set -Eeuo pipefail
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; }
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}$'
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)" \
Comment thread
warheart1984-ctrl marked this conversation as resolved.
-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"; [ "$(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)
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."
;;
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"
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 "$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)"
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
# 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 ;;
esac
27 changes: 26 additions & 1 deletion docs/tenant_keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ python -m app.tenant_keys set-status --tenant t_<hex32> --status suspended
python -m app.tenant_keys list-tenants | list-keys [--tenant t_<hex32>]
```

### 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 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_
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:

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.

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).
Expand Down Expand Up @@ -75,4 +100,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.
Loading
Loading