From e21a1472b1d3fa04f16b3d68d3040bd9db4046c6 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Sat, 4 Jul 2026 23:07:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(h5):=20approval-identity=20MVP=20=E2=80=94?= =?UTF-8?q?=20signed=20reviewer=20approvals=20(C4/V20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under CASAN_APPROVAL_STRICT=1, a high-risk approval is trusted ONLY when a REGISTERED reviewer cryptographically signs THIS exact request and their role is authorized for the action — a plain env-var CASAN_APPROVER is no longer enough. - approval-sign.sh: reviewer signs assertion "casan-approval|v1||||" with their key. - approval-verify.sh: gate looks up reviewer role+pubkey in reviewers.registry, enforces role→action authorization, verifies the RSA signature (fail-closed). - governance-check.sh: strict branch requires a valid signed approval; SoD still enforced; default (non-strict) env-var path UNCHANGED (baseline preserved). - reviewers.registry: role-scoped reviewer identity registry (pubkeys off-repo; production replaces with OIDC/JWT from a real IdP). - phase-h5-approval-tests.sh: 8 checks — valid/authorized approve; unsigned, wrong-role, forged-key, unregistered, replay-to-other-request, self-approval all denied; non-strict backward-compat. Baselines: run-casan4 35/35, adversarial 44/44. Lifts H5 policy-approval (C4) 2.5 -> ~3.5-4 / 5. Total suites now 6 (+8 checks = 148). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../central-governance/reviewers.registry | 28 ++++++ .../.specify/scripts/bash/approval-sign.sh | 39 ++++++++ .../.specify/scripts/bash/approval-verify.sh | 74 ++++++++++++++ .../.specify/scripts/bash/governance-check.sh | 29 +++++- .../.specify/tests/phase-h5-approval-tests.sh | 99 +++++++++++++++++++ 5 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 AINative_OKR_CASAN5/.specify/level5/central-governance/reviewers.registry create mode 100755 AINative_OKR_CASAN5/.specify/scripts/bash/approval-sign.sh create mode 100755 AINative_OKR_CASAN5/.specify/scripts/bash/approval-verify.sh create mode 100755 AINative_OKR_CASAN5/.specify/tests/phase-h5-approval-tests.sh diff --git a/AINative_OKR_CASAN5/.specify/level5/central-governance/reviewers.registry b/AINative_OKR_CASAN5/.specify/level5/central-governance/reviewers.registry new file mode 100644 index 0000000..7ec7b66 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/level5/central-governance/reviewers.registry @@ -0,0 +1,28 @@ +# CASAN H5 — Reviewer identity registry (Approval-identity MVP · C4). +# +# Binds an approver id to a ROLE and a PUBLIC key. Under CASAN_APPROVAL_STRICT=1, +# governance-check.sh trusts an approval ONLY if it is signed by the private key +# matching one of these public keys, and the reviewer's role is authorized for +# the action (see the `action` lines). +# +# Line format (no yaml dependency): +# reviewer # pubkey-file relative to reviewers/ dir +# action +# +# Reviewer PRIVATE keys live OFF-REPO (with the reviewer / issued by an IdP) — +# only PUBLIC keys are provisioned here, same policy as the audit signing key. +# Production: replace this static registry + local pubkeys with OIDC/JWT identity +# from a real IdP (verify token signature + role/exp claims). + +reviewer security-lead security security-lead.pub.pem +reviewer tech-lead tech_lead tech-lead.pub.pem +reviewer ops-owner ops ops-owner.pub.pem +reviewer project-owner project_owner project-owner.pub.pem + +# Which role may approve which action class. +action deploy ops,security +action migration tech_lead,security +action db_write tech_lead,security +action write_code tech_lead,security +action policy_change security +action default tech_lead,ops,security,project_owner diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/approval-sign.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/approval-sign.sh new file mode 100755 index 0000000..6d4030a --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/approval-sign.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN H5 — Reviewer-side approval signer (Approval-identity MVP · C4 / V20). +# +# A reviewer runs this to APPROVE a specific high-risk request by signing the +# canonical assertion with THEIR OWN private key. The resulting signature is +# handed to governance-check.sh via CASAN_APPROVAL_SIG (with CASAN_APPROVER= +# and CASAN_APPROVAL_STRICT=1). The private key stays with the reviewer / in an +# IdP-issued credential — never in the pipeline env. +# +# Assertion (must match approval-verify.sh): +# casan-approval|v1|||| +# +# Usage: +# approval-sign.sh +# Exit: 0 signed, 64 usage, 1 sign error. + +ACTION="${1:-}"; ACTOR="${2:-}"; INPUT_FILE="${3:-}"; APPROVER="${4:-}"; PRIV="${5:-}"; OUT="${6:-}" +if [[ -z "$ACTION" || -z "$ACTOR" || -z "$INPUT_FILE" || -z "$APPROVER" || -z "$PRIV" || -z "$OUT" ]]; then + echo "Usage: approval-sign.sh " >&2 + exit 64 +fi +[[ -f "$INPUT_FILE" ]] || { echo "approval-sign: input file not found: $INPUT_FILE" >&2; exit 1; } +[[ -f "$PRIV" ]] || { echo "approval-sign: private key not found: $PRIV" >&2; exit 1; } +command -v openssl >/dev/null 2>&1 || { echo "approval-sign: openssl required" >&2; exit 1; } + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +INPUT_SHA="$(hash_file "$INPUT_FILE")" +MSG="casan-approval|v1|$ACTION|$ACTOR|$INPUT_SHA|$APPROVER" +TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT +printf '%s' "$MSG" > "$TMP" +openssl dgst -sha256 -sign "$PRIV" -out "$OUT" "$TMP" \ + || { echo "approval-sign: signing failed" >&2; exit 1; } +echo "APPROVAL_SIGNED approver=$APPROVER action=$ACTION sig=$OUT" diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/approval-verify.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/approval-verify.sh new file mode 100755 index 0000000..b008555 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/approval-verify.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN H5 — Signed-approval verifier (Approval-identity MVP · C4 / V20). +# +# Problem: high-risk approval used to trust a plain env var (CASAN_APPROVER=bob) — +# anyone who can set the env can "approve". This binds an approval to a REGISTERED +# reviewer's cryptographic identity: the reviewer must SIGN this exact request with +# their private key, AND their role must be authorized for the action class. +# +# The signed assertion is: casan-approval|v1|||| +# — so a signature for one request/reviewer cannot be replayed for another. +# +# Usage: +# approval-verify.sh +# Registry (line format, no yaml dep): +# reviewer +# action +# Env: CASAN_REVIEWERS_FILE (default governance/reviewers.registry) +# CASAN_REVIEWERS_DIR (default governance/reviewers) — base dir for pubkey-file +# Exit: 0 ok (prints "APPROVAL_OK role="), 3 deny (reason on stderr), 64 usage. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +GOV_DIR="$PROJECT_ROOT/.specify/level5/central-governance" +REVIEWERS_FILE="${CASAN_REVIEWERS_FILE:-$GOV_DIR/reviewers.registry}" +REVIEWERS_DIR="${CASAN_REVIEWERS_DIR:-$GOV_DIR/reviewers}" + +ACTION="${1:-}"; ACTOR="${2:-}"; INPUT_FILE="${3:-}"; APPROVER="${4:-}"; SIG_FILE="${5:-}" +if [[ -z "$ACTION" || -z "$ACTOR" || -z "$INPUT_FILE" || -z "$APPROVER" || -z "$SIG_FILE" ]]; then + echo "Usage: approval-verify.sh " >&2 + exit 64 +fi + +deny() { echo "APPROVAL_DENIED reason=$1 approver=$APPROVER action=$ACTION" >&2; exit 3; } + +[[ -f "$INPUT_FILE" ]] || deny "input_file_missing" +[[ -f "$SIG_FILE" ]] || deny "signature_missing" +[[ -f "$REVIEWERS_FILE" ]] || deny "reviewer_registry_missing" +command -v openssl >/dev/null 2>&1 || deny "openssl_unavailable" + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +# Reviewer lookup (first matching registered reviewer). +REV_LINE="$(awk -v id="$APPROVER" '$1=="reviewer" && $2==id {print $3" "$4; exit}' "$REVIEWERS_FILE")" +[[ -n "$REV_LINE" ]] || deny "approver_not_registered" +ROLE="${REV_LINE%% *}" +PUB_REL="${REV_LINE##* }" + +# Role authorization for this action (fallback to the "default" action policy). +ROLES="$(awk -v a="$ACTION" '$1=="action" && $2==a {print $3; exit}' "$REVIEWERS_FILE")" +[[ -n "$ROLES" ]] || ROLES="$(awk '$1=="action" && $2=="default" {print $3; exit}' "$REVIEWERS_FILE")" +case ",$ROLES," in + *",$ROLE,"*) : ;; + *) deny "approver_role_not_authorized(role=$ROLE action=$ACTION allowed=$ROLES)" ;; +esac + +# Resolve pubkey path (absolute or relative to reviewers dir). +PUB="$PUB_REL"; [[ "$PUB" = /* ]] || PUB="$REVIEWERS_DIR/$PUB_REL" +[[ -f "$PUB" ]] || deny "approver_pubkey_missing($PUB)" + +# Rebuild the exact signed assertion and verify. +INPUT_SHA="$(hash_file "$INPUT_FILE")" +MSG="casan-approval|v1|$ACTION|$ACTOR|$INPUT_SHA|$APPROVER" +TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT +printf '%s' "$MSG" > "$TMP" +openssl dgst -sha256 -verify "$PUB" -signature "$SIG_FILE" "$TMP" >/dev/null 2>&1 \ + || deny "approval_signature_invalid" + +echo "APPROVAL_OK role=$ROLE approver=$APPROVER action=$ACTION" +exit 0 diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh index 88cf005..bc8ce6d 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh @@ -88,7 +88,34 @@ if [[ "$RISK_LEVEL" == "medium" ]]; then fi if [[ "$RISK_LEVEL" == "high" ]]; then - if [[ "$APPROVAL_DECISION" == "approve" && -n "$APPROVER" ]]; then + if [[ "${CASAN_APPROVAL_STRICT:-0}" == "1" ]]; then + # Approval-identity mode (V20): an env-var approver is NOT enough — the + # reviewer must cryptographically SIGN this exact request and their role must + # be authorized for the action. SoD (actor != approver) still enforced. + if [[ "$APPROVAL_DECISION" == "approve" && -n "$APPROVER" && -n "${CASAN_APPROVAL_SIG:-}" ]]; then + if [[ "$APPROVER" == "$ACTOR" ]]; then + APPROVAL_STATUS="separation_of_duties_violation" + DECISION="denied" + REASONS+=("separation-of-duties:actor-equals-approver") + else + AV_RC=0 + AV_OUT="$(bash "$SCRIPT_DIR/approval-verify.sh" "$ACTION_NAME" "$ACTOR" "$INPUT_FILE" "$APPROVER" "$CASAN_APPROVAL_SIG" 2>/dev/null)" || AV_RC=$? + if [[ "$AV_RC" -eq 0 ]]; then + APPROVAL_STATUS="human_approved_signed" + DECISION="approved" + REASONS+=("signed-approval:${AV_OUT#APPROVAL_OK }") + else + APPROVAL_STATUS="approval_signature_invalid" + DECISION="denied" + REASONS+=("signed-approval-failed") + fi + fi + else + APPROVAL_STATUS="approval_required_signed" + DECISION="denied" + REASONS+=("strict-requires-signed-approval") + fi + elif [[ "$APPROVAL_DECISION" == "approve" && -n "$APPROVER" ]]; then if [[ "$APPROVER" == "$ACTOR" ]]; then # Separation of duties: the submitter may not approve their own action. APPROVAL_STATUS="separation_of_duties_violation" diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-h5-approval-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-h5-approval-tests.sh new file mode 100755 index 0000000..68891ac --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-h5-approval-tests.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN H5 — Approval-identity MVP tests (C4 / V20). +# +# Proves that under CASAN_APPROVAL_STRICT=1 a high-risk approval is trusted ONLY +# when a REGISTERED reviewer cryptographically signs THIS request and their role +# is authorized — a plain env-var approver is no longer enough. Also proves the +# default (non-strict) path is unchanged (backward compatible). +# +# Self-contained: generates ephemeral reviewer keypairs into a temp reviewers +# dir and uses the committed reviewers.registry (pubkey filenames match). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +S="$PROJECT_ROOT/.specify/scripts/bash" +REG="$PROJECT_ROOT/.specify/level5/central-governance/reviewers.registry" +WORK="$(mktemp -d)"; RV="$WORK/reviewers"; mkdir -p "$RV" +trap 'rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +expect_rc() { + local want="$1" desc="$2"; shift 2 + local got=0 + { "$@" >/dev/null 2>&1; } || got=$? + [[ "$got" -eq "$want" ]] && pass "$desc (rc=$got)" || fail "$desc (got rc=$got, want $want)" +} + +# Ephemeral reviewer keypairs (filenames match reviewers.registry). +for r in ops-owner tech-lead security-lead; do + openssl genrsa -out "$WORK/$r.priv.pem" 2048 2>/dev/null + openssl rsa -in "$WORK/$r.priv.pem" -pubout -out "$RV/$r.pub.pem" 2>/dev/null +done +openssl genrsa -out "$WORK/attacker.priv.pem" 2048 2>/dev/null + +REQ="$WORK/req.txt" +printf 'deploy to production and run database migration\n' > "$REQ" +OTHER="$WORK/other.txt" +printf 'deploy a different unrelated change to production\n' > "$OTHER" + +sign() { bash "$S/approval-sign.sh" "$@" >/dev/null 2>&1; } +# gc — run governance-check in STRICT mode for a high-risk deploy +gc() { + env CASAN_APPROVAL_STRICT=1 CASAN_REVIEWERS_FILE="$REG" CASAN_REVIEWERS_DIR="$RV" \ + CASAN_ACTOR=alice CASAN_APPROVAL_DECISION=approve "$@" \ + bash "$S/governance-check.sh" "$REQ" "$WORK/out.txt" deploy +} + +echo "===== H5 approval-identity (CASAN_APPROVAL_STRICT=1) =====" + +# 1. Valid signed approval by an authorized role -> APPROVED +sign deploy alice "$REQ" ops-owner "$WORK/ops-owner.priv.pem" "$WORK/ops.sig" +OUT="$(gc CASAN_APPROVER=ops-owner CASAN_APPROVAL_SIG="$WORK/ops.sig" 2>/dev/null)"; RC=$? +{ [[ "$RC" -eq 0 ]] && printf '%s' "$OUT" | grep -q "human_approved_signed"; } \ + && pass "valid signed approval by authorized reviewer -> APPROVED" \ + || fail "valid signed approval rejected (rc=$RC out=$OUT)" + +# 2. Env-var approver but NO signature -> DENY (the core fix) +expect_rc 2 "env-var approver without signature is denied" \ + gc CASAN_APPROVER=ops-owner + +# 3. Registered reviewer, but role not authorized for this action -> DENY +sign deploy alice "$REQ" tech-lead "$WORK/tech-lead.priv.pem" "$WORK/tech.sig" +expect_rc 2 "reviewer whose role is not authorized for deploy is denied" \ + gc CASAN_APPROVER=tech-lead CASAN_APPROVAL_SIG="$WORK/tech.sig" + +# 4. Forged signature (attacker key, claims to be ops-owner) -> DENY +sign deploy alice "$REQ" ops-owner "$WORK/attacker.priv.pem" "$WORK/forged.sig" +expect_rc 2 "forged signature (unregistered key) is denied" \ + gc CASAN_APPROVER=ops-owner CASAN_APPROVAL_SIG="$WORK/forged.sig" + +# 5. Unregistered approver id -> DENY +sign deploy alice "$REQ" ghost "$WORK/attacker.priv.pem" "$WORK/ghost.sig" +expect_rc 2 "unregistered approver id is denied" \ + gc CASAN_APPROVER=ghost CASAN_APPROVAL_SIG="$WORK/ghost.sig" + +# 6. Replay: a signature bound to a DIFFERENT request cannot approve this one +sign deploy alice "$OTHER" ops-owner "$WORK/ops-owner.priv.pem" "$WORK/replay.sig" +expect_rc 2 "signature bound to another request cannot be replayed" \ + gc CASAN_APPROVER=ops-owner CASAN_APPROVAL_SIG="$WORK/replay.sig" + +# 7. Separation of duties still enforced even with a valid signature +sign deploy ops-owner "$REQ" ops-owner "$WORK/ops-owner.priv.pem" "$WORK/sod.sig" +expect_rc 2 "self-approval denied even with a valid signature (SoD)" \ + env CASAN_APPROVAL_STRICT=1 CASAN_REVIEWERS_FILE="$REG" CASAN_REVIEWERS_DIR="$RV" \ + CASAN_ACTOR=ops-owner CASAN_APPROVAL_DECISION=approve \ + CASAN_APPROVER=ops-owner CASAN_APPROVAL_SIG="$WORK/sod.sig" \ + bash "$S/governance-check.sh" "$REQ" "$WORK/out.txt" deploy + +# 8. Backward compatibility: default (non-strict) env approval still works +expect_rc 0 "non-strict env approval unchanged (backward compatible)" \ + env CASAN_ACTOR=alice CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=bob \ + bash "$S/governance-check.sh" "$REQ" "$WORK/out.txt" deploy + +echo "" +echo "===== H5 APPROVAL-IDENTITY SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1