#!/usr/bin/env bash # Full credential chain smoke (ADR-017 + ADR-019). # # Drives the end-to-end path against a fixture project + the live ZOT registry: # # 1. Bootstrap a throwaway project with multi-tenant recipient_rules # 2. Encryption respects the rules (.sops.yaml generated; access.sops uses # its rule's recipients; per-tenant files use their own subset) # 3. Push the vault to ZOT (cosign signed, tlog=false) # 4. Pull from an isolated HOME — proves DOCKER_CONFIG isolation # 5. Decrypt a credential via assert-actor-authorized + resolve-registry-credential # 6. Cleanup — registry tags deleted, fixture removed # # Run: # RECOVERY_ZOT_USER= RECOVERY_ZOT_PASS= \ # bash reflection/tests/test_credential_chain.sh # # Exit 0 on green; non-zero on any failure. set -euo pipefail ONTOREF_ROOT=${ONTOREF_ROOT:-/Users/Akasha/Development/ontoref} ZOT_HOST=${ZOT_HOST:-reg.librecloud.online} ZOT_USER=${ZOT_USER:-${RECOVERY_ZOT_USER:-}} ZOT_PASS=${ZOT_PASS:-${RECOVERY_ZOT_PASS:-}} [[ -n "$ZOT_USER" ]] || { echo "ZOT_USER (or RECOVERY_ZOT_USER) required"; exit 2; } [[ -n "$ZOT_PASS" ]] || { echo "ZOT_PASS (or RECOVERY_ZOT_PASS) required"; exit 2; } # Use the developer's existing master key + cosign config. # The fixture project will declare master_key_path = developer's .kage. DEV_KAGE=${DEV_KAGE:-$HOME/.age/keys/ontoref.key.txt} [[ -f "$DEV_KAGE" ]] || { echo "DEV_KAGE not found at $DEV_KAGE"; exit 2; } DEV_PUB=$(grep "public key" "$DEV_KAGE" | head -1 | awk '{print $NF}') PASS=0 FAIL=0 FIXTURE_SLUG="ccsmoke-$(date +%s)" FIXTURE=$(mktemp -d) FIXTURE_HOME="" cleanup() { local rc=$? [[ -n "$FIXTURE_HOME" ]] && rm -rf "$FIXTURE_HOME" rm -rf "$FIXTURE" "$HOME/.config/ontoref/vaults/$FIXTURE_SLUG" # Best-effort: delete the fixture's pushed artifacts from ZOT. local cfg cfg=$(mktemp -d) local auth auth=$(printf '%s:%s' "$ZOT_USER" "$ZOT_PASS" | base64) printf '{"auths":{"%s":{"auth":"%s"}}}' "$ZOT_HOST" "$auth" > "$cfg/config.json" DOCKER_CONFIG="$cfg" oras manifest delete -f "$ZOT_HOST/src-vault/$FIXTURE_SLUG:latest" 2>/dev/null || true DOCKER_CONFIG="$cfg" oras manifest delete -f "$ZOT_HOST/src-vault/$FIXTURE_SLUG:lock" 2>/dev/null || true rm -rf "$cfg" [[ $rc -eq 0 ]] && echo "── credential chain smoke: $PASS passed, $FAIL failed ──" \ || echo "── ABORTED at PASS=$PASS FAIL=$FAIL rc=$rc ──" return $rc } trap cleanup EXIT assert() { local name="$1"; shift if "$@"; then echo " ✓ $name" PASS=$((PASS + 1)) else echo " ✗ $name" FAIL=$((FAIL + 1)) fi } # ────────── Phase 1: build fixture project ────────────────────────────────── echo "phase 1: build fixture project" mkdir -p "$FIXTURE/.ontoref" "$FIXTURE/.ontology" cat > "$FIXTURE/.ontoref/project.ncl" < "$FIXTURE/.ontology/manifest.ncl" <<'NCL' let m = import "ontology/defaults/manifest.ncl" in m.make_manifest { project = "ccsmoke", repo_kind = 'Mixed, description = "credential chain smoke fixture", consumption_modes = [], registry_provides = m.make_registry_provides { participant = "ccsmoke", registries = m.make_registries_config { default = "primary", registries = [ m.make_registry_entry { id = "primary", endpoint = "ZOT_HOST_PLACEHOLDER", credential_sops = "registry/developer-ro.sops.yaml", credential_sops_rw = "registry/developer-rw.sops.yaml", } ], }, }, } NCL sed -i.bak "s|ZOT_HOST_PLACEHOLDER|$ZOT_HOST|" "$FIXTURE/.ontology/manifest.ncl" && rm "$FIXTURE/.ontology/manifest.ncl.bak" assert "fixture project.ncl renders" \ bash -c "NICKEL_IMPORT_PATH='$FIXTURE:$ONTOREF_ROOT:$ONTOREF_ROOT/ontology:$HOME/.config/ontoref/schemas' nickel export '$FIXTURE/.ontoref/project.ncl' >/dev/null 2>&1" # ────────── Phase 2: bootstrap (declarative mode) ──────────────────────────── echo "phase 2: bootstrap" ONTOREF_PROJECT_ROOT="$FIXTURE" \ NICKEL_IMPORT_PATH="$FIXTURE:$ONTOREF_ROOT:$ONTOREF_ROOT/ontology:$HOME/.config/ontoref/schemas" \ bash -c "cd $ONTOREF_ROOT && printf '%s\n%s\n%s\n%s\n' '$ZOT_USER' '$ZOT_PASS' 'restic-pwd-test' 'cosign-pwd-test' \ | just --justfile justfiles/secrets.just secrets-bootstrap" >/dev/null 2>&1 assert "access.sops.yaml created" \ [ -f "$HOME/.config/ontoref/vaults/$FIXTURE_SLUG/access.sops.yaml" ] assert ".sops.yaml generated (declarative mode)" \ [ -f "$HOME/.config/ontoref/vaults/$FIXTURE_SLUG/.sops.yaml" ] assert "default ro/rw skipped (declarative paths defined by operator)" \ [ ! -f "$HOME/.config/ontoref/vaults/$FIXTURE_SLUG/src-vault/registry/ro.sops.yaml" ] # ────────── Phase 3: populate per-tenant credential ────────────────────────── echo "phase 3: write per-tenant credential file" VAULT_DIR="$HOME/.config/ontoref/vaults/$FIXTURE_SLUG" mkdir -p "$VAULT_DIR/src-vault/registry" DEV_RO="$VAULT_DIR/src-vault/registry/developer-ro.sops.yaml" DEV_RW="$VAULT_DIR/src-vault/registry/developer-rw.sops.yaml" PLAIN=$(mktemp) printf 'username: %s\npassword: %s\n' "$ZOT_USER" "$ZOT_PASS" > "$PLAIN" SOPS_AGE_KEY_FILE="$DEV_KAGE" sops --config "$VAULT_DIR/.sops.yaml" \ --filename-override "$DEV_RO" --input-type yaml --encrypt "$PLAIN" > "$DEV_RO" SOPS_AGE_KEY_FILE="$DEV_KAGE" sops --config "$VAULT_DIR/.sops.yaml" \ --filename-override "$DEV_RW" --input-type yaml --encrypt "$PLAIN" > "$DEV_RW" rm -f "$PLAIN" assert "developer-ro.sops.yaml encrypted" \ [ -s "$DEV_RO" ] assert "developer-rw.sops.yaml encrypted" \ [ -s "$DEV_RW" ] # ────────── Phase 4: push to ZOT ──────────────────────────────────────────── echo "phase 4: push to registry" # Push uses developer's cosign key from global config. COSIGN_PASSWORD="cosign-pwd-test" \ ONTOREF_PROJECT_ROOT="$FIXTURE" \ NICKEL_IMPORT_PATH="$FIXTURE:$ONTOREF_ROOT:$ONTOREF_ROOT/ontology:$HOME/.config/ontoref/schemas" \ bash -c "cd $ONTOREF_ROOT && just --justfile justfiles/secrets.just secrets-push" \ > /tmp/cc-push.log 2>&1 || true assert "push succeeded (manifest in zot)" \ test -n "$(curl -sf -u "$ZOT_USER:$ZOT_PASS" "https://$ZOT_HOST/v2/src-vault/$FIXTURE_SLUG/tags/list" 2>/dev/null \ | grep -o latest)" # ────────── Phase 5: isolated-HOME pull ───────────────────────────────────── echo "phase 5: pull from isolated HOME" FIXTURE_HOME=$(mktemp -d) mkdir -p "$FIXTURE_HOME/.config/ontoref" ln -s "$HOME/.config/ontoref/vaults" "$FIXTURE_HOME/.config/ontoref/vaults" ln -s "$HOME/.config/ontoref/config.ncl" "$FIXTURE_HOME/.config/ontoref/config.ncl" ln -s "$HOME/.config/ontoref/cosign" "$FIXTURE_HOME/.config/ontoref/cosign" ln -s "$HOME/.config/ontoref/schemas" "$FIXTURE_HOME/.config/ontoref/schemas" # Use sops + oras directly with a tmp DOCKER_CONFIG built from the vault credentials. TMPCFG=$(mktemp -d) auth=$(printf '%s:%s' "$ZOT_USER" "$ZOT_PASS" | base64) printf '{"auths":{"%s":{"auth":"%s"}}}' "$ZOT_HOST" "$auth" > "$TMPCFG/config.json" PULLED=$(mktemp -d) HOME="$FIXTURE_HOME" DOCKER_CONFIG="$TMPCFG" \ oras pull "$ZOT_HOST/src-vault/$FIXTURE_SLUG:latest" --output "$PULLED" >/dev/null 2>&1 assert "pull restored access.sops.yaml" \ [ -f "$PULLED/access.sops.yaml" ] assert "decrypts under isolated HOME (master_key resolves)" \ bash -c "HOME=\"$FIXTURE_HOME\" SOPS_AGE_KEY_FILE=\"$DEV_KAGE\" sops --decrypt --extract '[\"zot_username\"]' \"$PULLED/access.sops.yaml\" >/dev/null 2>&1" rm -rf "$PULLED" "$TMPCFG" # ────────── Phase 6: cleanup happens in trap ──────────────────────────────── echo "phase 6: cleanup deferred to trap" [[ "$FAIL" -eq 0 ]]