# Secrets Module — src-vault credential management (ADR-017) # ============================================================= # All operations read credentials from ~/.config/ontoref/vaults//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 # /.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/ 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/.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/: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 # /.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)."