ontoref/justfiles/secrets.just
Jesús Pérez 82a358f18d
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (push) Has been cancelled
feat: #[onto_mcp_tool] catalog, OCI credential vault layer, validate ADR-018 mode hierarchy
ontoref-derive: #[onto_mcp_tool] attribute macro registers MCP tool unit-structs in
  the catalog at link time via inventory::submit!; annotated item is emitted unchanged,
  ToolBase/AsyncTool impls stay on the struct. All 34 tools migrated from manual wiring
  (net +5: ontoref_list_projects, ontoref_search, ontoref_describe,
  ontoref_list_ontology_extensions, ontoref_get_ontology_extension).

  validate modes (ADR-018): reads level_hierarchy from workflow.ncl and checks every
  .ncl mode for level declared, strategy declared, delegate chain coherent, compose
  extends valid. mode resolve <id> shows which hierarchy level handles a mode and why.
  --self-test generates synthetic fixtures in a temp dir for CI smoke-testing.

  validate run-cargo: two-step Cargo.toml resolution — workspace layout first
  (crates/<check.crate>/Cargo.toml), single-crate fallback by package name or repo
  basename. Lets the same ADR constraint shape apply to workspace and single-crate repos.

  ontology/schemas/manifest.ncl: registry_topology_type contract — multi-registry
  coordination, push targets, participant scopes, per-namespace capability.

  reflection/requirements/base.ncl: oras ≥1.2.0, cosign ≥2.0.0, sops ≥3.9.0, age
  ≥1.1.0, restic declared as Hard/Soft requirements with version_min, check_cmd, and
  install_hint (ADR-017 toolchain surface).

  ADR-019: per-file recipient routing for tenant isolation without multi-vault. Schema
  additions: sops.recipient_groups + sops.recipient_rules in ontoref-project.ncl.
  secrets-bootstrap generates .sops.yaml from project.ncl in declarative mode. Three
  new secrets-audit checks: recipient-routing-coherent, recipient-routing-coverage,
  no-multi-vault. Adoption templates: single-team/, multi-tenant/, agent-first/.
  Integration templates: domain-producer/, mode-producer/, mode-consumer/.

  UI: project_picker surfaces registry badge (⟳ participant) and vault badge
  (⛁ vault_id · N, green=declarative / amber=legacy) per project card. Expanded panel
  adds collapsible Registry section with namespace, endpoint, and push/pull capability.
  manage.html gains Runtime Services card — MCP and GraphQL toggleable without restart
  via HTMX POST /ui/manage/services/{service}/toggle.

  describe.nu: capabilities JSON includes registry_topology and vault_state per project.
  sync.nu: drift check extended to detect //! absence on newly registered crates.
  qa.ncl: six entries — credential-vault-best-practice (layered data-flow diagram),
  credential-vault-templates (paths A/B/C), credential-vault-troubleshooting (15 named
  errors), integration-what-and-why (ADR-042 OCI federation), integration-how-to-implement,
  integration-troubleshooting.

  on+re: core.ncl + manifest.ncl updated to reflect OCI, MCP, and mode-hierarchy nodes.
  Deleted stale presentation assets (2026-02 slides + voice notes).
2026-05-12 04:46:15 +01:00

770 lines
38 KiB
Text

# Secrets Module — src-vault credential management (ADR-017)
# =============================================================
# All operations read credentials from ~/.config/ontoref/vaults/<vault_id>/access.sops.yaml,
# decrypted by the .kage master key resolved from project.ncl::sops.master_key_path
# (per-project override) or ~/.config/ontoref/config.ncl::vault.master_key_path (global).
# Cosign keypair for vault signing/verification lives in ~/.config/ontoref/config.ncl::vault.cosign.
# Direct restic/kopia or sops invocations bypass lock and audit — use these recipes instead.
#
# Common variable loading lives in justfiles/_secrets_lib.sh. Each recipe sources it via
# `{{source_directory()}}/_secrets_lib.sh` to get a consistent, validated env.
# Generate a new age keypair for a role. Prints public + private to stdout.
secrets-gen-key role:
#!/usr/bin/env bash
set -euo pipefail
which age-keygen > /dev/null || { echo "age-keygen not found — install age"; exit 1; }
echo "Generating age keypair for role: {{role}}"
age-keygen
# Bootstrap the vault for a new project. Admin-only.
# Collects all recipient public keys, creates access.sops.yaml encrypted for all recipients,
# initializes the local restic/kopia repo. Run secrets-push afterward to upload to ZOT.
#
# Prerequisites:
# - All recipient age public keys collected (one per role)
# - Admin .kage available at master_key_path
# - SOPS_AGE_RECIPIENTS set to comma-separated list of all recipient age public keys
secrets-bootstrap:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
mkdir -p "$VAULT_DIR/logs"
# Per-file recipient routing — when project declares recipient_rules, generate
# <vault_dir>/.sops.yaml so sops uses different recipient unions per file path.
# Otherwise, fall back to SOPS_AGE_RECIPIENTS env var (legacy single-set mode).
RULES_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
GROUPS_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
HAS_RULES=$(echo "$RULES_JSON" | jq 'length > 0')
SOPS_CFG_PATH="$VAULT_DIR/.sops.yaml"
if [[ "$HAS_RULES" == "true" ]]; then
# ADR-019 coherency check — every group referenced by a rule MUST exist
# as a key in recipient_groups, AND every recipient_group MUST resolve to
# at least one non-empty age public key. A typo or empty group produces
# an unreadable .sops.yaml — better to fail loud at bootstrap.
UNKNOWN_GROUPS=$(echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
[.[] | .groups[] | . as $g | select($groups | has($g) | not)] | unique | join(", ")
')
if [[ -n "$UNKNOWN_GROUPS" ]]; then
echo "[recipient-rules] groups referenced in recipient_rules but missing from recipient_groups: $UNKNOWN_GROUPS" >&2
echo " → declare them in project.ncl::sops.recipient_groups, or fix the typo in recipient_rules[].groups" >&2
exit 1
fi
EMPTY_GROUPS=$(echo "$GROUPS_JSON" | jq -r '
to_entries | map(select(.value | length == 0) | .key) | join(", ")
')
if [[ -n "$EMPTY_GROUPS" ]]; then
echo "[recipient-rules] recipient_groups with no age keys: $EMPTY_GROUPS" >&2
echo " → populate them in project.ncl::sops.recipient_groups, or remove the unused group(s)" >&2
exit 1
fi
UNREFERENCED_GROUPS=$(jq -nrc \
--argjson rules "$RULES_JSON" --argjson groups "$GROUPS_JSON" '
($groups | keys) - ($rules | map(.groups[]) | unique) | join(", ")
')
if [[ -n "$UNREFERENCED_GROUPS" ]]; then
echo "[recipient-rules] WARN: recipient_groups declared but never used by any rule: $UNREFERENCED_GROUPS" >&2
fi
# Resolve groups → age list per rule, write .sops.yaml
echo "creation_rules:" > "$SOPS_CFG_PATH"
# Use YAML single-quoted strings for path_regex so backslash escapes
# like \\. pass through to sops without YAML interpreting them.
echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
.[] | " - path_regex: '"'"'\(.path)'"'"'\n age: " +
([.groups[] | $groups[.] // []] | flatten | unique | join(","))
' >> "$SOPS_CFG_PATH"
echo "Generated $SOPS_CFG_PATH with $(echo "$RULES_JSON" | jq length) rule(s)."
# When sops sees --config, it uses it; we don't need --age flag.
SOPS_ENC_FLAGS=(--config "$SOPS_CFG_PATH")
else
[[ -n "${SOPS_AGE_RECIPIENTS:-}" ]] || {
echo "SOPS_AGE_RECIPIENTS not set — export age public keys, OR declare sops.recipient_groups+rules in project.ncl"
exit 1
}
SOPS_ENC_FLAGS=(--age "$SOPS_AGE_RECIPIENTS")
fi
if [[ -f "$ACCESS_SOPS" ]]; then
echo "access.sops.yaml already exists at $ACCESS_SOPS — skipping creation"
else
echo "Creating access.sops.yaml. Required inputs:"
echo " zot_username + zot_password : ZOT registry credentials for src-vault/<id> namespace"
echo " vault_key : restic/kopia repo password"
echo " cosign_password : password for vault.cosign.key_path (used by 'secrets push' to sign)"
echo
read -r -p "zot_username: " ZOT_USER
read -r -s -p "zot_password: " ZOT_PASS; echo
read -r -s -p "vault_key (restic/kopia password): " VAULT_KEY; echo
read -r -s -p "cosign_password (for cosign.key): " COSIGN_PWD; echo
printf 'zot_username: %s\nzot_password: %s\nvault_key: %s\ncosign_password: %s\n' \
"$ZOT_USER" "$ZOT_PASS" "$VAULT_KEY" "$COSIGN_PWD" \
| SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "${SOPS_ENC_FLAGS[@]}" \
--filename-override "$ACCESS_SOPS" \
--input-type yaml --encrypt /dev/stdin > "$ACCESS_SOPS"
unset ZOT_USER ZOT_PASS VAULT_KEY COSIGN_PWD
echo "access.sops.yaml created and encrypted for all recipients"
fi
REPO_PATH="$VAULT_DIR/repo"
if [[ -d "$REPO_PATH" ]]; then
echo "Vault repo already exists at $REPO_PATH — skipping init"
else
CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
VAULT_KEY_VAL=$(echo "$CREDS" | grep vault_key | awk '{print $2}')
case "$VAULT_TOOL" in
restic) RESTIC_PASSWORD="$VAULT_KEY_VAL" restic -r "$REPO_PATH" init ;;
kopia) KOPIA_PASSWORD="$VAULT_KEY_VAL" kopia repository create filesystem --path "$REPO_PATH" ;;
esac
unset VAULT_KEY_VAL CREDS
echo "Vault repo initialized at $REPO_PATH"
fi
# Initial audit entry — ensures access.jsonl exists for the first secrets-push.
# Subsequent operations append.
if [[ ! -f "$VAULT_DIR/logs/access.jsonl" ]]; then
printf '{"ts":"%s","actor":"%s","op":"bootstrap","detail":"vault initialized"}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}" \
> "$VAULT_DIR/logs/access.jsonl"
fi
# ── Layer 2 (ADR-017) — scopes + registry credentials inside src-vault/ ──
# The src-vault/ subtree IS the OCI artifact contents. domain_client.nu reads
# registry/{ro,rw}.sops.yaml via credential_sops/_rw paths from the manifest.
SRC_VAULT_DIR="$VAULT_DIR/src-vault"
mkdir -p "$SRC_VAULT_DIR/scopes" "$SRC_VAULT_DIR/registry"
# Generate scope files for each unique role declared in actor_key_bindings.
# Default scopes by role-name convention; edit later via 'ore secrets open'.
ROLES=$(echo "$PROJECT_JSON" | jq -r '[.sops.actor_key_bindings | .[]] | unique | .[]')
for ROLE in $ROLES; do
SCOPE_FILE="$SRC_VAULT_DIR/scopes/${ROLE}.ncl"
if [[ -f "$SCOPE_FILE" ]]; then continue; fi
# RW for human-operator roles, RO for automation roles. Convention only.
case "$ROLE" in
admin|developer|jesus|supervisor)
ACCESS="'rw"
OPS="['pull, 'push, 'verify, 'list]"
;;
*)
ACCESS="'ro"
OPS="['pull, 'verify, 'list]"
;;
esac
printf '{\n role = "%s",\n access = %s,\n bound_actor = [],\n namespaces = ["domains/*/", "modes/*/"],\n ops = %s,\n}\n' \
"$ROLE" "$ACCESS" "$OPS" > "$SCOPE_FILE"
echo "Created scope: scopes/${ROLE}.ncl"
done
# Default Layer 2 credentials: registry/ro.sops.yaml + registry/rw.sops.yaml.
# Skipped in declarative mode (recipient_rules declared) — the operator names
# files per their tenancy scheme (e.g. clientA-ro, agent-readonly) and
# populates them via 'ore secrets open' after bootstrap. Legacy mode keeps
# the default ro/rw pair seeded with the same zot credentials for both.
if [[ "$HAS_RULES" == "true" ]]; then
echo "Skipping default registry/ro|rw.sops.yaml — declarative recipient_rules in effect."
echo "Populate registry/<file>.sops.yaml entries that match your declared rules."
else
L2_CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
L2_USER=$(echo "$L2_CREDS" | grep '^zot_username:' | awk '{print $2}')
L2_PASS=$(echo "$L2_CREDS" | grep '^zot_password:' | awk '{print $2}')
for KIND in ro rw; do
SOPS_FILE="$SRC_VAULT_DIR/registry/${KIND}.sops.yaml"
if [[ -f "$SOPS_FILE" ]]; then continue; fi
printf 'username: %s\npassword: %s\n' "$L2_USER" "$L2_PASS" \
| SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "${SOPS_ENC_FLAGS[@]}" \
--input-type yaml --encrypt /dev/stdin > "$SOPS_FILE"
echo "Created registry/${KIND}.sops.yaml"
done
unset L2_CREDS L2_USER L2_PASS
fi
echo "Bootstrap complete. Run 'ore secrets push' to upload the vault to ZOT."
# Pull the latest src-vault OCI artifact from ZOT and restore to local vault dir.
secrets-sync:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }
mkdir -p "$VAULT_DIR/logs"
[[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — first sync needs admin to push the vault"; exit 1; }
CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
ZOT_USER=$(echo "$CREDS" | grep zot_username | awk '{print $2}')
ZOT_PASS=$(echo "$CREDS" | grep zot_password | awk '{print $2}')
TMPDIR_CFG=$(mktemp -d)
AUTH_B64=$(printf '%s:%s' "$ZOT_USER" "$ZOT_PASS" | base64)
printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY}" "$AUTH_B64" > "$TMPDIR_CFG/config.json"
# Hard constraint (ADR-017 src-vault-cosign-signed): verify before trusting artifact.
[[ -n "$COSIGN_PUB_PATH" ]] || { rm -rf "$TMPDIR_CFG"; echo "vault.cosign.pub_path not set in ~/.config/ontoref/config.ncl"; exit 1; }
[[ -f "$COSIGN_PUB_PATH" ]] || { rm -rf "$TMPDIR_CFG"; echo "Cosign pubkey not found at $COSIGN_PUB_PATH"; exit 1; }
echo "Verifying src-vault/$VAULT_ID:latest signature (tlog=$COSIGN_TLOG)..."
cosign verify $COSIGN_VERIFY_FLAGS --key "$COSIGN_PUB_PATH" "${REGISTRY}/src-vault/${VAULT_ID}:latest" \
|| { rm -rf "$TMPDIR_CFG"; echo "Vault artifact signature FAILED — rejecting pull"; exit 1; }
DOCKER_CONFIG="$TMPDIR_CFG" oras pull "${REGISTRY}/src-vault/${VAULT_ID}:latest" --output "$VAULT_DIR"
rm -rf "$TMPDIR_CFG"
unset ZOT_USER ZOT_PASS CREDS AUTH_B64
echo "Vault synced to $VAULT_DIR"
LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"pull","detail":"secrets-sync"}' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}")
echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"
# Push the local vault snapshot to ZOT as an OCI artifact (cosign-signed).
secrets-push:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }
[[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml — run secrets-bootstrap first"; exit 1; }
# Hard constraint (ADR-017 src-vault-cosign-signed): sign required.
[[ -n "$COSIGN_KEY_PATH" ]] || { echo "vault.cosign.key_path not set in ~/.config/ontoref/config.ncl"; exit 1; }
[[ -f "$COSIGN_KEY_PATH" ]] || { echo "Cosign key not found at $COSIGN_KEY_PATH"; exit 1; }
CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
ZOT_USER=$(echo "$CREDS" | grep '^zot_username:' | awk '{print $2}')
ZOT_PASS=$(echo "$CREDS" | grep '^zot_password:' | awk '{print $2}')
VAULT_KEY_VAL=$(echo "$CREDS" | grep '^vault_key:' | awk '{print $2}')
# cosign_password optional for back-compat with vaults bootstrapped before
# the field existed; fallback to existing $COSIGN_PASSWORD env if unset.
COSIGN_PWD_VAL=$(echo "$CREDS" | grep '^cosign_password:' | awk '{print $2}')
echo "Creating vault snapshot..."
case "$VAULT_TOOL" in
restic) RESTIC_PASSWORD="$VAULT_KEY_VAL" restic -r "$VAULT_DIR/repo" backup "$VAULT_DIR" --tag "secrets-push" ;;
kopia) KOPIA_PASSWORD="$VAULT_KEY_VAL" kopia --config-file "$VAULT_DIR/repo/kopia.config" snapshot create "$VAULT_DIR" ;;
esac
unset VAULT_KEY_VAL
TMPDIR_CFG=$(mktemp -d)
AUTH_B64=$(printf '%s:%s' "$ZOT_USER" "$ZOT_PASS" | base64)
printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY}" "$AUTH_B64" > "$TMPDIR_CFG/config.json"
unset ZOT_USER ZOT_PASS CREDS AUTH_B64
[[ -f "$VAULT_DIR/logs/access.jsonl" ]] || {
echo "access.jsonl missing — re-run 'ore secrets bootstrap' to initialize"
rm -rf "$TMPDIR_CFG"
exit 1
}
echo "Pushing src-vault/${VAULT_ID}:latest to $REGISTRY..."
# oras requires relative paths for layer files — cd into the vault dir.
# Walk src-vault/ to add scope files (.ncl), encrypted credentials (.sops.yaml),
# and recipient public keys (.age.pub) as typed layers.
LAYERS=("access.sops.yaml:application/vnd.ontoref.vault.access.v1"
"logs/access.jsonl:application/vnd.ontoref.vault.audit.v1")
if [[ -d "$VAULT_DIR/src-vault" ]]; then
while IFS= read -r f; do
REL="${f#${VAULT_DIR}/}"
case "$REL" in
src-vault/scopes/*.ncl) LAYERS+=("$REL:application/vnd.ontoref.vault.scope.v1") ;;
src-vault/registry/*.sops.yaml) LAYERS+=("$REL:application/vnd.ontoref.vault.credential.v1") ;;
esac
done < <(find "$VAULT_DIR/src-vault" -type f \( -name "*.ncl" -o -name "*.sops.yaml" \))
fi
PUSH_LOG=$(mktemp)
set +e
( cd "$VAULT_DIR" && DOCKER_CONFIG="$TMPDIR_CFG" oras push "${REGISTRY}/src-vault/${VAULT_ID}:latest" \
--artifact-type "application/vnd.ontoref.vault.v1" \
"${LAYERS[@]}" ) > "$PUSH_LOG" 2>&1
PUSH_EXIT=$?
set -e
cat "$PUSH_LOG"
if [[ $PUSH_EXIT -ne 0 ]]; then
rm -rf "$TMPDIR_CFG" "$PUSH_LOG"
echo "oras push failed (exit $PUSH_EXIT)"
exit 1
fi
VAULT_DIGEST=$(grep -oE 'sha256:[a-f0-9]{64}' "$PUSH_LOG" | head -1)
rm -f "$PUSH_LOG"
if [[ -z "$VAULT_DIGEST" ]]; then
rm -rf "$TMPDIR_CFG"
echo "Could not extract digest from oras push output — cannot sign"
exit 1
fi
IMAGE_REF="${REGISTRY}/src-vault/${VAULT_ID}@${VAULT_DIGEST}"
echo "Signing $IMAGE_REF (tlog=$COSIGN_TLOG)..."
# cosign needs DOCKER_CONFIG too — it HEADs the manifest before signing.
# COSIGN_PASSWORD: from access.sops.yaml when present, else fall back to env
# (back-compat with vaults bootstrapped before the field existed).
if [[ -n "$COSIGN_PWD_VAL" ]]; then
DOCKER_CONFIG="$TMPDIR_CFG" COSIGN_PASSWORD="$COSIGN_PWD_VAL" \
cosign sign $COSIGN_SIGN_FLAGS --key "$COSIGN_KEY_PATH" --yes "$IMAGE_REF"
else
DOCKER_CONFIG="$TMPDIR_CFG" \
cosign sign $COSIGN_SIGN_FLAGS --key "$COSIGN_KEY_PATH" --yes "$IMAGE_REF"
fi
unset COSIGN_PWD_VAL
echo "Vault artifact signed"
rm -rf "$TMPDIR_CFG"
LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"push","detail":"secrets-push digest=%s"}' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}" "$VAULT_DIGEST")
echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"
echo "Vault pushed and signed: $REGISTRY/src-vault/$VAULT_ID:latest"
# Open the vault for editing. Acquires an OCI lock at src-vault/<id>:lock with
# TTL (default 60 min). Conflicts: if another lock is active and not expired,
# error with the holder's identity. After editing, run 'ore secrets close'.
secrets-open:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
[[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — run secrets-sync first"; exit 1; }
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }
vault_zot_config_open || exit 1
trap 'rm -rf "$TMPDIR_CFG"' EXIT
NOW_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
NOW_EPOCH=$(date -u +%s)
EXPIRES_EPOCH=$(( NOW_EPOCH + VAULT_LOCK_TTL_MIN * 60 ))
EXPIRES_TS=$(date -u -r "$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| date -u -d "@$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| echo "")
HOLDER="${USER:-$(whoami)}"
ACTOR="${ONTOREF_ACTOR:-developer}"
EXISTING=$(vault_lock_fetch)
if [[ -n "$EXISTING" ]]; then
EXIST_BY=$(echo "$EXISTING" | jq -r .locked_by)
EXIST_ACTOR=$(echo "$EXISTING" | jq -r .actor)
EXIST_SINCE=$(echo "$EXISTING" | jq -r .since)
EXIST_EXP=$(echo "$EXISTING" | jq -r .expires)
# Compare expires_epoch with now to detect abandoned lock.
EXIST_EXP_EPOCH=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$EXIST_EXP" +%s 2>/dev/null \
|| date -u -d "$EXIST_EXP" +%s 2>/dev/null || echo 0)
if [[ "$EXIST_BY" == "$HOLDER" ]]; then
echo "Vault already locked by you ($HOLDER, since $EXIST_SINCE). Refreshing TTL."
elif [[ "$EXIST_EXP_EPOCH" -gt "$NOW_EPOCH" ]]; then
echo "Vault locked by $EXIST_BY (actor=$EXIST_ACTOR) since $EXIST_SINCE, expires $EXIST_EXP."
echo "If lock is abandoned and you have admin rights, run: ore secrets force-unlock"
exit 1
else
echo "Lock expired ($EXIST_EXP < $NOW_TS). Force-closing abandoned lock from $EXIST_BY."
fi
fi
vault_lock_push "$HOLDER" "$ACTOR" "$NOW_TS" "$EXPIRES_TS" \
|| { echo "Failed to push lock artifact (registry ACL may not allow)"; exit 1; }
echo "Lock acquired: $HOLDER until $EXPIRES_TS"
echo "Opening vault $VAULT_ID for editing..."
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "$ACCESS_SOPS"
echo "Edit complete. Run 'ore secrets close' to push and release the lock."
# Close the vault: impact report → push updated state → release lock.
# ADR-017 G2: list which credential files changed and which RegistryEntries
# they affect. Operator confirms before the push proceeds.
secrets-close:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
# Pre-flight impact analysis.
CHANGED=$(vault_impact_changed_creds)
if [[ -n "$CHANGED" ]]; then
echo "──────────────────────────────────────────────────────────────────"
echo " Credential changes detected since last snapshot:"
echo "$CHANGED" | sed 's/^/ /'
echo
IMPACT=$(echo "$CHANGED" | vault_impact_registries)
if [[ -n "$IMPACT" ]]; then
echo " Affected RegistryEntry references:"
echo "$IMPACT" | awk -F'\t' '{ printf " %s via %s (%s)\n", $1, $2, $3 }'
echo
echo " Domains/modes declaring uses_registry to any of the entries above"
echo " will resolve fresh credentials on next pull. Audit consumers if any"
echo " service uses cached credentials in ~/.cache/ontoref/."
else
echo " No RegistryEntry in this project's manifest references the changed file(s)."
echo " Consumers in OTHER projects may still depend on these credentials."
fi
echo "──────────────────────────────────────────────────────────────────"
if [[ -z "${ONTOREF_SECRETS_YES:-}" ]]; then
read -r -p " Proceed with push? [y/N] " ans
[[ "$ans" =~ ^[Yy] ]] || { echo "Aborted. Lock remains held."; exit 1; }
fi
else
echo "No credential changes since last snapshot."
fi
just secrets-push
vault_zot_config_open || { echo "[close] cannot resolve ZOT config — lock NOT released"; exit 1; }
trap 'rm -rf "$TMPDIR_CFG"' EXIT
EXISTING=$(vault_lock_fetch)
if [[ -z "$EXISTING" ]]; then
echo "No lock to release."
exit 0
fi
EXIST_BY=$(echo "$EXISTING" | jq -r .locked_by)
HOLDER="${USER:-$(whoami)}"
if [[ "$EXIST_BY" != "$HOLDER" ]]; then
echo "Lock held by $EXIST_BY, not $HOLDER. Refusing to release. Use force-unlock if needed."
exit 1
fi
vault_lock_release
echo "Lock released."
# Force-release an abandoned or stuck lock (admin only).
# Lock metadata records who force-closed for auditability.
secrets-force-unlock:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared"; exit 1; }
vault_zot_config_open || exit 1
trap 'rm -rf "$TMPDIR_CFG"' EXIT
EXISTING=$(vault_lock_fetch)
if [[ -z "$EXISTING" ]]; then
echo "No lock to release."
exit 0
fi
EXIST_BY=$(echo "$EXISTING" | jq -r .locked_by)
EXIST_SINCE=$(echo "$EXISTING" | jq -r .since)
HOLDER="${USER:-$(whoami)}"
NOW_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Force-unlocking vault $VAULT_ID."
echo " was held by: $EXIST_BY since $EXIST_SINCE"
echo " force-closed by: $HOLDER at $NOW_TS"
# Emit a tombstone-tagged audit before delete so the action is traceable.
LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"force-unlock","detail":"was-held-by=%s since=%s"}' \
"$NOW_TS" "${ONTOREF_ACTOR:-developer}" "$EXIST_BY" "$EXIST_SINCE")
echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"
vault_lock_release
echo "Lock released."
# Add a recipient. Two modes:
# - DECLARATIVE (project declares recipient_rules): error with guidance to edit
# project.ncl::sops.recipient_groups and run 'secrets rekey' — that is the
# canonical source of truth. Mutating sops files directly would diverge from
# the declared state and the next rekey would re-clobber the change.
# - LEGACY (no rules declared): apply --add-age across every *.sops.yaml in the vault.
secrets-add-key AGE_PUBKEY ROLE:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '(.sops.recipient_rules // []) | length > 0')
if [[ "$HAS_RULES" == "true" ]]; then
echo "This vault uses declarative recipient_rules — direct add-key would drift."
echo "Edit project.ncl: add the pubkey to sops.recipient_groups[\"{{ROLE}}\"]"
echo "Then: ore secrets rekey"
exit 1
fi
echo "Adding recipient (role: {{ROLE}}) to vault $VAULT_ID..."
while IFS= read -r f; do
echo " + ${f#${VAULT_DIR}/}"
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys --add-age "{{AGE_PUBKEY}}" "$f" >/dev/null
done < <(find "$VAULT_DIR" -name "*.sops.yaml")
echo "Recipient added to $(find "$VAULT_DIR" -name "*.sops.yaml" | wc -l | tr -d ' ') file(s)."
echo "Run 'ore secrets push' to distribute the updated vault."
# Remove a recipient. Same DECLARATIVE/LEGACY split as add-key.
secrets-remove-key AGE_PUBKEY:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '(.sops.recipient_rules // []) | length > 0')
if [[ "$HAS_RULES" == "true" ]]; then
echo "This vault uses declarative recipient_rules — direct remove-key would drift."
echo "Edit project.ncl: remove the pubkey from sops.recipient_groups[*]"
echo "Then: ore secrets rekey"
exit 1
fi
echo "Removing recipient from vault $VAULT_ID..."
while IFS= read -r f; do
echo " - ${f#${VAULT_DIR}/}"
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys --remove-age "{{AGE_PUBKEY}}" "$f" >/dev/null
done < <(find "$VAULT_DIR" -name "*.sops.yaml")
echo "Recipient removed from $(find "$VAULT_DIR" -name "*.sops.yaml" | wc -l | tr -d ' ') file(s)."
echo "Rotate registry tokens if the removed key was suspected of compromise."
# Re-encrypt all sops files. When project declares recipient_rules, regenerate
# <vault_dir>/.sops.yaml from project.ncl FIRST so updatekeys reads the fresh
# recipient unions per path. Otherwise, updatekeys uses whatever .sops.yaml
# already exists (legacy mode).
secrets-rekey:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
RULES_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
GROUPS_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
HAS_RULES=$(echo "$RULES_JSON" | jq 'length > 0')
if [[ "$HAS_RULES" == "true" ]]; then
SOPS_CFG_PATH="$VAULT_DIR/.sops.yaml"
echo "creation_rules:" > "$SOPS_CFG_PATH"
# Use YAML single-quoted strings for path_regex so backslash escapes
# like \\. pass through to sops without YAML interpreting them.
echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
.[] | " - path_regex: '"'"'\(.path)'"'"'\n age: " +
([.groups[] | $groups[.] // []] | flatten | unique | join(","))
' >> "$SOPS_CFG_PATH"
echo "Regenerated $SOPS_CFG_PATH from project.ncl ($(echo "$RULES_JSON" | jq length) rule(s))."
fi
echo "Re-keying all sops files in $VAULT_DIR..."
find "$VAULT_DIR" -name "*.sops.yaml" | while read -r f; do
echo " updatekeys: $f"
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys "$f"
done
echo "Re-key complete. Run 'ore secrets push' to distribute."
# Add or update the cosign_password field inside access.sops.yaml without
# opening the editor. Targets vaults bootstrapped before the field existed.
# Idempotent: if the field is already present, value is replaced.
# Recover a corrupted/missing access.sops.yaml by pulling the src-vault OCI
# artifact from ZOT. Verified disaster-recovery flow (qa.ncl::credential-vault-
# disaster-recovery). Requires the project.ncl::sops.registry_endpoint and
# valid ZOT credentials in your environment via DOCKER_CONFIG (or admin
# credentials passed inline).
secrets-recover:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared"; exit 1; }
# When access.sops.yaml is corrupt we cannot decrypt it to obtain credentials.
# Caller must provide ZOT credentials via env (RECOVERY_ZOT_USER + _PASS) OR
# have a pre-existing valid DOCKER_CONFIG pointing at this registry.
if [[ -n "${RECOVERY_ZOT_USER:-}" && -n "${RECOVERY_ZOT_PASS:-}" ]]; then
TMPCFG=$(mktemp -d)
AUTH=$(printf '%s:%s' "$RECOVERY_ZOT_USER" "$RECOVERY_ZOT_PASS" | base64)
printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY" "$AUTH" > "$TMPCFG/config.json"
export DOCKER_CONFIG="$TMPCFG"
trap 'rm -rf "$TMPCFG"' EXIT
echo "Using RECOVERY_ZOT_USER credentials."
elif [[ -n "${DOCKER_CONFIG:-}" ]]; then
echo "Using existing DOCKER_CONFIG=$DOCKER_CONFIG"
else
echo "No credentials available. Set RECOVERY_ZOT_USER + RECOVERY_ZOT_PASS"
echo "or DOCKER_CONFIG before invoking."
exit 1
fi
RECOVERY=$(mktemp -d)
echo "Pulling src-vault/${VAULT_ID}:latest from $REGISTRY..."
if ! oras pull "${REGISTRY}/src-vault/${VAULT_ID}:latest" --output "$RECOVERY"; then
rm -rf "$RECOVERY"
echo "oras pull failed — verify registry and credentials"
exit 1
fi
[[ -f "$RECOVERY/access.sops.yaml" ]] || {
rm -rf "$RECOVERY"
echo "Recovered artifact does not contain access.sops.yaml"
exit 1
}
mkdir -p "$VAULT_DIR" "$VAULT_DIR/logs" "$VAULT_DIR/src-vault"
cp "$RECOVERY/access.sops.yaml" "$ACCESS_SOPS"
[[ -d "$RECOVERY/logs" ]] && cp -R "$RECOVERY/logs/." "$VAULT_DIR/logs/" 2>/dev/null || true
[[ -d "$RECOVERY/src-vault" ]] && cp -R "$RECOVERY/src-vault/." "$VAULT_DIR/src-vault/" 2>/dev/null || true
rm -rf "$RECOVERY"
# Verify decrypts cleanly with the configured master key.
if SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS" >/dev/null 2>&1; then
echo "Recovered. access.sops.yaml decrypts with $MASTER_KEY"
printf '{"ts":"%s","actor":"%s","op":"recover","detail":"access.sops.yaml restored from %s"}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-developer}" \
"${REGISTRY}/src-vault/${VAULT_ID}:latest" >> "$VAULT_DIR/logs/access.jsonl"
else
echo "Restored but cannot decrypt — your .kage may not be a recipient"
exit 1
fi
secrets-set-cosign-password:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
[[ -f "$ACCESS_SOPS" ]] || { echo "access.sops.yaml absent — bootstrap first"; exit 1; }
read -r -s -p "cosign_password (for cosign.key): " COSIGN_PWD; echo
[[ -n "$COSIGN_PWD" ]] || { echo "empty password rejected"; exit 1; }
# `sops set` updates a single field in-place, preserving the original
# recipient set (which lives in the file's sops metadata block). No
# decrypt + re-encrypt cycle, no leak of plaintext to disk.
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops set "$ACCESS_SOPS" '["cosign_password"]' "\"$COSIGN_PWD\""
unset COSIGN_PWD
# Verify the field is present after the update.
if SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt --extract '["cosign_password"]' "$ACCESS_SOPS" >/dev/null 2>&1; then
echo " cosign_password set in $ACCESS_SOPS"
echo " Run 'ore secrets push' to upload the updated vault."
else
echo " set succeeded but field still not extractable — investigate"
exit 1
fi
# Audit vault constraints from ADR-017:
# --check bootstrap-credentials access.sops.yaml can be decrypted by master_key
# --check no-credential-env manifest does not declare credential_env
# --check recipients every .sops.yaml has ≥ 2 recipients (multi-recipient mandatory)
secrets-audit check="":
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
CHECK="${check:-all}"
PASS=0
if [[ "$CHECK" == "bootstrap-credentials" ]] || [[ "$CHECK" == "all" ]]; then
echo -n " bootstrap-credentials: "
if [[ ! -f "$ACCESS_SOPS" ]]; then
echo "FAIL (access.sops.yaml not found at $ACCESS_SOPS)"
PASS=1
else
DECRYPT_CHECK=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS" 2>&1)
if [[ $? -eq 0 ]]; then echo "OK"; else echo "FAIL (decrypt error)"; PASS=1; fi
fi
fi
if [[ "$CHECK" == "no-credential-env" ]] || [[ "$CHECK" == "all" ]]; then
echo -n " no-credential-env: "
if grep -r "credential_env" "${PROJECT_ROOT}/.ontology/manifest.ncl" 2>/dev/null | grep -q .; then
echo "FAIL (credential_env found in manifest.ncl — excluded by schema)"
PASS=1
else
echo "OK"
fi
fi
if [[ "$CHECK" == "recipients" ]] || [[ "$CHECK" == "all" ]]; then
echo -n " multi-recipient-mandatory: "
if [[ -f "$ACCESS_SOPS" ]]; then
RECIPIENT_COUNT=$(grep -c "age1" "$ACCESS_SOPS" 2>/dev/null || echo 0)
if [[ "$RECIPIENT_COUNT" -ge 2 ]]; then echo "OK ($RECIPIENT_COUNT recipients)";
else echo "FAIL (only $RECIPIENT_COUNT recipient — multi-recipient required)"; PASS=1; fi
else
echo "SKIP (no access.sops.yaml)"
fi
fi
# ADR-019 — recipient routing constraints (skipped in legacy mode).
RULES=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
GROUPS=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
HAS_RULES=$(echo "$RULES" | jq 'length > 0')
if [[ "$CHECK" == "recipient-routing-coherent" ]] || [[ "$CHECK" == "all" ]]; then
echo -n " recipient-routing-coherent: "
if [[ "$HAS_RULES" != "true" ]]; then
echo "SKIP (legacy single-set mode)"
else
# Constraint 1: every group referenced by a rule must be declared in groups
UNDECLARED=$(echo "$RULES" | jq -r --argjson g "$GROUPS" '
[.[] | .groups[] | select(($g | has(.)) | not)] | unique | join(",")
')
# Constraint 2: every rule must resolve to ≥ 1 recipient
EMPTY_RULES=$(echo "$RULES" | jq -r --argjson g "$GROUPS" '
[.[] | select((.groups | map($g[.] // []) | flatten | unique | length) == 0) | .path] | join(",")
')
if [[ -n "$UNDECLARED" ]]; then
echo "FAIL (undeclared groups referenced: $UNDECLARED)"
PASS=1
elif [[ -n "$EMPTY_RULES" ]]; then
echo "FAIL (rules resolve to zero recipients: $EMPTY_RULES)"
PASS=1
else
echo "OK ($(echo "$RULES" | jq length) rule(s), $(echo "$GROUPS" | jq 'keys | length') group(s))"
fi
fi
fi
if [[ "$CHECK" == "recipient-routing-coverage" ]] || [[ "$CHECK" == "all" ]]; then
echo -n " recipient-routing-coverage: "
if [[ "$HAS_RULES" != "true" ]]; then
echo "SKIP (legacy single-set mode)"
elif [[ ! -d "$VAULT_DIR" ]]; then
echo "SKIP (vault not bootstrapped)"
else
# For each *.sops.yaml in the vault, verify at least one rule.path matches.
UNCOVERED=""
while IFS= read -r f; do
rel="${f#${VAULT_DIR}/}"
# Try each rule's regex; rule order doesn't matter for coverage.
MATCHED=$(echo "$RULES" | jq -r --arg path "$rel" '
[.[] | select($path | test(.path))] | length
')
[[ "$MATCHED" == "0" ]] && UNCOVERED="$UNCOVERED $rel"
done < <(find "$VAULT_DIR" -name "*.sops.yaml" 2>/dev/null)
if [[ -n "$UNCOVERED" ]]; then
echo "FAIL (paths without matching rule:$UNCOVERED)"
PASS=1
else
echo "OK"
fi
fi
fi
if [[ "$CHECK" == "no-multi-vault" ]] || [[ "$CHECK" == "all" ]]; then
echo -n " no-multi-vault: "
if grep -q "sops\.vaults *=" "$PROJECT_NCL" 2>/dev/null; then
echo "FAIL (sops.vaults declaration found — multi-vault is unimplemented per ADR-019)"
PASS=1
else
echo "OK"
fi
fi
exit $PASS
# Read existing access.sops.yaml recipients and emit a recipient_groups +
# recipient_rules stanza ready to paste into project.ncl::sops. Read-only —
# does not mutate project.ncl. The default policy maps every existing recipient
# to BOTH `admin` and `developer` groups (preserves current behaviour exactly)
# and emits two rules: one for access.sops.yaml (admin+developer), one for
# everything else (developer-only). Operator edits the stanza before pasting
# to refine which keys belong to which group.
secrets-migrate-to-declarative:
#!/usr/bin/env bash
source "{{source_directory()}}/_secrets_lib.sh"
[[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — bootstrap or sync first"; exit 1; }
# Already declarative? Refuse to overwrite — operator must rekey from project.ncl.
HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '.sops.recipient_rules // [] | length > 0')
if [[ "$HAS_RULES" == "true" ]]; then
echo "[migrate] project.ncl already declares recipient_rules — nothing to migrate."
echo " Run 'just secrets-rekey' if you changed recipient_groups and need to re-encrypt."
exit 0
fi
# Extract recipients from the existing access.sops.yaml metadata. sops
# records them under sops.age[].recipient as age1... entries — file format
# may be YAML or JSON depending on input-type, so use a generic regex.
RECIPIENTS=$(grep -oE 'age1[a-z0-9]+' "$ACCESS_SOPS" | sort -u)
[[ -n "$RECIPIENTS" ]] || { echo "[migrate] no age recipients found in $ACCESS_SOPS"; exit 1; }
COUNT=$(echo "$RECIPIENTS" | wc -l | tr -d ' ')
echo "# ── recipient routing migration helper (read-only) ────────────────────"
echo "# Detected $COUNT recipient(s) in $ACCESS_SOPS:"
echo "$RECIPIENTS" | sed 's/^/# - /'
echo "#"
echo "# Default policy: all recipients in both 'admin' and 'developer' groups."
echo "# This preserves current behaviour exactly (no key loses access). Edit"
echo "# the groups below to scope down before pasting into project.ncl::sops."
echo "#"
echo "# Paste THIS into your project.ncl::sops record (replacing any existing"
echo "# recipient_groups / recipient_rules / SOPS_AGE_RECIPIENTS env reliance):"
echo "# ──────────────────────────────────────────────────────────────────────"
echo
AGE_LIST=$(echo "$RECIPIENTS" | awk '{printf " \"%s\",\n", $0}')
printf ' recipient_groups = {\n'
printf ' admin = [\n%s\n ],\n' "$AGE_LIST"
printf ' developer = [\n%s\n ],\n' "$AGE_LIST"
printf ' },\n'
printf ' recipient_rules = [\n'
printf ' { path = "access\\\\.sops\\\\.yaml$", groups = ["admin", "developer"] },\n'
printf ' { path = "registry/.*\\\\.sops\\\\.yaml$", groups = ["developer"] },\n'
printf ' { path = "scopes/.*\\\\.ncl$", groups = ["admin"] },\n'
printf ' ],\n'
echo
echo "# ──────────────────────────────────────────────────────────────────────"
echo "# Next steps:"
echo "# 1. Edit project.ncl::sops to insert the stanza above."
echo "# 2. Refine groups (move keys between admin/developer to scope access)."
echo "# 3. Refine recipient_rules paths to match your repo layout."
echo "# 4. Run: just secrets-rekey (re-encrypts every *.sops.yaml using"
echo "# the new declared rules — same recipients = idempotent no-op)."
echo "# 5. Verify: just secrets-audit (runs ADR-019 coherency checks)."