#!/usr/bin/env bash
# sign.sh — A5 of the wake canon (EPIC #892, W3): the NON-CIRCULAR HMAC signer.
#
# CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md):
#   §2.5  Integrity — non-circular HMAC tuple + stated threat boundary.
#   §1.2  three-cursor entries carry an `hmac` field (W2 left it a placeholder).
#
# NON-CIRCULAR CONSTRUCTION (§2.5), verbatim intent:
#   - `wake_id` is generated INDEPENDENTLY: a fresh unique id at emit. It is NOT
#     derived from, and does NOT appear inside, the signed field-tuple. It is
#     prepended to the MAC input to BIND it to its payload — never self-referenced.
#   - wake_mac = HMAC(key, wake_id || agent_identity || mission_generation ||
#                     observed_seq || emit_ts || content_hash)
#     The MAC is computed OVER wake_id PLUS the field-tuple. The value whose
#     authenticity the MAC establishes (wake_mac itself) is NEVER an input to its
#     own computation — that is what makes this genuinely non-circular.
#
#   Rendered envelope makes the split explicit:
#     { wake_id,                         # independent id, prepended to MAC input
#       signed:{ agent_identity, mission_generation, observed_seq, emit_ts,
#                content_hash },         # the signed field-tuple (no wake_id, no mac)
#       wake_mac }                       # HMAC over wake_id || signed-tuple
#
# KEY HANDLING (§2.5): the key is resolved BY NAME from the operator credential
# store (the same store `load_credentials` reads), NEVER passed inline, NEVER
# echoed, NEVER placed in a unit, digest, or ledger. This tool exposes NO flag
# that accepts key material — only a key NAME.
#
# THREAT BOUNDARY, STATED HONESTLY (§2.5, §7.3): this is a SAME-UID signer. A
# same-uid attacker can read the credential file directly (and can observe the
# key in `openssl`'s argv while a MAC is computed) — so a same-uid compromise is
# OUT OF SCOPE for this tool. Closing it requires an OFF-UID signer (a separate
# privilege-separated process), which is tracked as a §4 G6 gate, NOT silently
# assumed away here.
#
# Operator-agnostic: key location via XDG/credential-store resolution only; no
# operator paths, names, or secrets appear in this file.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

_need() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "sign.sh: $1 is required" >&2
    exit 3
  }
}

usage() {
  cat >&2 <<'EOF'
Usage: sign.sh <command> [options]

Commands:
  sign [field opts]              Emit a signed wake ENVELOPE (JSON) over an
                                 independently-generated wake_id + the field-tuple.
  verify                         Read an envelope on stdin; recompute and compare
                                 its wake_mac. Exit 0 = authentic, 1 = tampered.
  sign-entry [field opts]        Read a durable store entry (hmac:"") on stdin;
                                 fill its `hmac` with wake_mac + add `wake_id`.
  verify-entry                   Read a signed entry on stdin; recompute and
                                 compare its `hmac`. Exit 0/1.
  sign-digest [field opts]       Read rendered digest TEXT on stdin; hash it into
                                 content_hash and emit a signed envelope.

Field options (all optional unless noted):
  --key-name NAME       credential-store key NAME to sign with (never the key
                        itself). Default: $WAKE_HMAC_KEY_NAME or "default".
  --agent A             agent_identity   (default: $WAKE_AGENT or "default").
  --mission-generation G                 (default: $WAKE_MISSION_GENERATION or 0).
  --observed-seq N      observed_seq (required for `sign`; taken from the entry
                        for sign-entry/verify-entry).
  --emit-ts T           epoch seconds (default: now, or the entry's emit_ts).
  --content-hash H      precomputed sha256 of the wake content.
  --content STR         content to hash into content_hash (use "-" to read stdin).
  --wake-id ID          override the independent wake_id (default: fresh random).

Environment:
  WAKE_HMAC_KEY_NAME       default credential-store key name.
  WAKE_AGENT               agent identity / per-agent namespace.
  WAKE_MISSION_GENERATION  mission generation counter.
  MOSAIC_CREDENTIALS_FILE  credential store the key NAME resolves against.
EOF
}

# --- key resolution (BY NAME, never inline) --------------------------------
# Resolves the HMAC key from the operator credential store at
# `.wake.hmac_keys."<name>"`. Sources the shared credentials lib ONLY to reuse
# its store-location resolution (MOSAIC_CREDENTIALS_FILE / XDG defaults) and its
# reader — the key is never taken from a flag or env literal.
_wake_load_key() {
  local name="$1" key='' cred_lib
  _need jq
  cred_lib="$SCRIPT_DIR/../_lib/credentials.sh"
  if [ -f "$cred_lib" ]; then
    # shellcheck source=../_lib/credentials.sh disable=SC1091
    . "$cred_lib"
  fi
  # MOSAIC_CREDENTIALS_FILE is exported by the lib; fall back to the XDG default.
  local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
  if [ ! -f "$cred_file" ]; then
    echo "sign.sh: credential store not found ($cred_file) — cannot resolve key '$name'" >&2
    return 1
  fi
  key="$(jq -r --arg n "$name" '.wake.hmac_keys[$n] // empty' "$cred_file" 2>/dev/null)"
  if [ -z "$key" ]; then
    echo "sign.sh: HMAC key '$name' not found in credential store (.wake.hmac_keys) — FAIL-LOUD, refusing to sign unsigned" >&2
    return 1
  fi
  printf '%s' "$key"
}

# _reject_newline LABEL VALUE — the MAC input is newline-delimited, so a field
# containing a newline would make the concatenation ambiguous. ids/ints/hex are
# newline-free by construction; anything else is rejected fail-loud.
_reject_newline() {
  local nl=$'\n'
  case "$2" in
  *"$nl"*)
    echo "sign.sh: $1 must not contain a newline (MAC-input ambiguity)" >&2
    exit 2
    ;;
  esac
}

# _fresh_wake_id — an INDEPENDENT unique id, generated at emit. Not derived from
# any field-tuple value (§2.5). Random + a monotonic-ish time+pid salt so it is
# unique per emit even for byte-identical content.
_fresh_wake_id() {
  local r=''
  if command -v openssl >/dev/null 2>&1; then
    r="$(openssl rand -hex 16 2>/dev/null || true)"
  fi
  [ -n "$r" ] || r="$(date +%s%N 2>/dev/null)$$${RANDOM:-0}${RANDOM:-0}"
  printf 'wake_%s' "$r"
}

# _sha256 (stdin) — hex sha256 of stdin.
_sha256() {
  openssl dgst -sha256 -r 2>/dev/null | awk '{print $1}'
}

# _wake_mac KEY WID AGENT GEN SEQ TS CHASH — the non-circular MAC.
# Input = wake_id || agent || gen || observed_seq || emit_ts || content_hash,
# newline-delimited (unambiguous: every field is newline-free). wake_mac is the
# OUTPUT and is never fed back in.
_wake_mac() {
  local key="$1" wid="$2" agent="$3" gen="$4" seq="$5" ts="$6" chash="$7"
  printf '%s\n%s\n%s\n%s\n%s\n%s' "$wid" "$agent" "$gen" "$seq" "$ts" "$chash" |
    openssl dgst -sha256 -hmac "$key" -r 2>/dev/null | awk '{print $1}'
}

# Shared flag parser -> sets the field vars used by every subcommand.
_KEY_NAME='' _AGENT='' _GEN='' _SEQ='' _TS='' _CHASH='' _CONTENT='' _CONTENT_SET=0 _WID=''
_parse_fields() {
  _KEY_NAME="${WAKE_HMAC_KEY_NAME:-default}"
  _AGENT="${WAKE_AGENT:-default}"
  _GEN="${WAKE_MISSION_GENERATION:-0}"
  _SEQ='' _TS='' _CHASH='' _CONTENT='' _CONTENT_SET=0 _WID=''
  while [ $# -gt 0 ]; do
    case "$1" in
    --key-name) _KEY_NAME="${2:-}"; shift 2 ;;
    --agent) _AGENT="${2:-}"; shift 2 ;;
    --mission-generation) _GEN="${2:-}"; shift 2 ;;
    --observed-seq) _SEQ="${2:-}"; shift 2 ;;
    --emit-ts) _TS="${2:-}"; shift 2 ;;
    --content-hash) _CHASH="${2:-}"; shift 2 ;;
    --content) _CONTENT="${2:-}"; _CONTENT_SET=1; shift 2 ;;
    --wake-id) _WID="${2:-}"; shift 2 ;;
    *)
      echo "sign.sh: unknown option '$1'" >&2
      exit 2
      ;;
    esac
  done
}

# Resolve _CHASH from --content(-)/--content-hash if needed.
_resolve_content_hash() {
  if [ -z "$_CHASH" ] && [ "$_CONTENT_SET" = "1" ]; then
    if [ "$_CONTENT" = "-" ]; then
      _CHASH="$(_sha256)"
    else
      _CHASH="$(printf '%s' "$_CONTENT" | _sha256)"
    fi
  fi
}

# Emit the signed envelope JSON for the current field vars + a resolved key.
_emit_envelope() {
  local key="$1" wid mac
  wid="${_WID:-$(_fresh_wake_id)}"
  _reject_newline "wake_id" "$wid"
  _reject_newline "agent_identity" "$_AGENT"
  _reject_newline "mission_generation" "$_GEN"
  _reject_newline "observed_seq" "$_SEQ"
  _reject_newline "emit_ts" "$_TS"
  _reject_newline "content_hash" "$_CHASH"
  mac="$(_wake_mac "$key" "$wid" "$_AGENT" "$_GEN" "$_SEQ" "$_TS" "$_CHASH")"
  [ -n "$mac" ] || {
    echo "sign.sh: MAC computation failed" >&2
    exit 1
  }
  jq -cn \
    --arg wid "$wid" \
    --arg agent "$_AGENT" \
    --arg gen "$_GEN" \
    --arg seq "$_SEQ" \
    --arg ts "$_TS" \
    --arg chash "$_CHASH" \
    --arg mac "$mac" \
    '{wake_id:$wid,
      signed:{agent_identity:$agent, mission_generation:$gen,
              observed_seq:$seq, emit_ts:$ts, content_hash:$chash},
      wake_mac:$mac}'
}

cmd_sign() {
  _need jq
  _need openssl
  _parse_fields "$@"
  [ -n "$_SEQ" ] || {
    echo "sign.sh sign: --observed-seq is required" >&2
    exit 2
  }
  [ -n "$_TS" ] || _TS="$(date +%s)"
  _resolve_content_hash
  [ -n "$_CHASH" ] || {
    echo "sign.sh sign: need --content-hash or --content" >&2
    exit 2
  }
  local key
  key="$(_wake_load_key "$_KEY_NAME")" || exit 1
  _emit_envelope "$key"
}

cmd_sign_digest() {
  _need jq
  _need openssl
  _parse_fields "$@"
  # content_hash is the sha256 of the rendered digest text on stdin.
  _CHASH="$(_sha256)"
  [ -n "$_SEQ" ] || _SEQ="0"
  [ -n "$_TS" ] || _TS="$(date +%s)"
  local key
  key="$(_wake_load_key "$_KEY_NAME")" || exit 1
  _emit_envelope "$key"
}

cmd_verify() {
  _need jq
  _need openssl
  local key_name="${WAKE_HMAC_KEY_NAME:-default}"
  # Only --key-name is honored for verify; the rest come from the envelope.
  while [ $# -gt 0 ]; do
    case "$1" in
    --key-name) key_name="${2:-}"; shift 2 ;;
    *)
      echo "sign.sh verify: unknown option '$1'" >&2
      exit 2
      ;;
    esac
  done
  local env
  env="$(cat)"
  printf '%s' "$env" | jq -e . >/dev/null 2>&1 || {
    echo "sign.sh verify: stdin is not a JSON envelope" >&2
    exit 2
  }
  local wid agent gen seq ts chash claimed
  wid="$(printf '%s' "$env" | jq -r '.wake_id')"
  agent="$(printf '%s' "$env" | jq -r '.signed.agent_identity')"
  gen="$(printf '%s' "$env" | jq -r '.signed.mission_generation')"
  seq="$(printf '%s' "$env" | jq -r '.signed.observed_seq')"
  ts="$(printf '%s' "$env" | jq -r '.signed.emit_ts')"
  chash="$(printf '%s' "$env" | jq -r '.signed.content_hash')"
  claimed="$(printf '%s' "$env" | jq -r '.wake_mac')"
  local key recomputed
  key="$(_wake_load_key "$key_name")" || exit 1
  recomputed="$(_wake_mac "$key" "$wid" "$agent" "$gen" "$seq" "$ts" "$chash")"
  if [ "$recomputed" = "$claimed" ] && [ -n "$claimed" ]; then
    echo "AUTHENTIC"
    return 0
  fi
  echo "TAMPERED" >&2
  return 1
}

# Canonical content-hash for a store entry: sha256 over sorted-key {observed_seq,
# class, emit_ts, locators}. Binds the entry's obligation payload; the `hmac`
# placeholder and any wake_id are intentionally EXCLUDED (they are not content).
_entry_content_hash() {
  jq -cS '{observed_seq, class, emit_ts, locators}' | _sha256
}

cmd_sign_entry() {
  _need jq
  _need openssl
  _parse_fields "$@"
  local entry
  entry="$(cat)"
  printf '%s' "$entry" | jq -e . >/dev/null 2>&1 || {
    echo "sign.sh sign-entry: stdin is not a JSON entry" >&2
    exit 2
  }
  _SEQ="$(printf '%s' "$entry" | jq -r '.observed_seq')"
  _TS="$(printf '%s' "$entry" | jq -r '.emit_ts')"
  _CHASH="$(printf '%s' "$entry" | _entry_content_hash)"
  local wid key mac
  wid="${_WID:-$(_fresh_wake_id)}"
  _reject_newline "wake_id" "$wid"
  key="$(_wake_load_key "$_KEY_NAME")" || exit 1
  mac="$(_wake_mac "$key" "$wid" "$_AGENT" "$_GEN" "$_SEQ" "$_TS" "$_CHASH")"
  [ -n "$mac" ] || {
    echo "sign.sh sign-entry: MAC computation failed" >&2
    exit 1
  }
  # Fill the hmac placeholder W2 left, and attach the independent wake_id.
  printf '%s' "$entry" | jq -c \
    --arg mac "$mac" --arg wid "$wid" \
    '.hmac = $mac | .wake_id = $wid'
}

cmd_verify_entry() {
  _need jq
  _need openssl
  _parse_fields "$@"
  local entry
  entry="$(cat)"
  printf '%s' "$entry" | jq -e . >/dev/null 2>&1 || {
    echo "sign.sh verify-entry: stdin is not a JSON entry" >&2
    exit 2
  }
  local wid seq ts chash claimed
  wid="$(printf '%s' "$entry" | jq -r '.wake_id // ""')"
  seq="$(printf '%s' "$entry" | jq -r '.observed_seq')"
  ts="$(printf '%s' "$entry" | jq -r '.emit_ts')"
  claimed="$(printf '%s' "$entry" | jq -r '.hmac // ""')"
  # Recompute content hash over the SAME canonical projection.
  chash="$(printf '%s' "$entry" | _entry_content_hash)"
  local key recomputed
  key="$(_wake_load_key "$_KEY_NAME")" || exit 1
  recomputed="$(_wake_mac "$key" "$wid" "$_AGENT" "$_GEN" "$seq" "$ts" "$chash")"
  if [ "$recomputed" = "$claimed" ] && [ -n "$claimed" ]; then
    echo "AUTHENTIC"
    return 0
  fi
  echo "TAMPERED" >&2
  return 1
}

main() {
  [ $# -ge 1 ] || {
    usage
    exit 2
  }
  local cmd="$1"
  shift
  case "$cmd" in
  sign) cmd_sign "$@" ;;
  sign-digest) cmd_sign_digest "$@" ;;
  sign-entry) cmd_sign_entry "$@" ;;
  verify) cmd_verify "$@" ;;
  verify-entry) cmd_verify_entry "$@" ;;
  -h | --help | help) usage ;;
  *)
    echo "sign.sh: unknown command '$cmd'" >&2
    usage
    exit 2
    ;;
  esac
}

main "$@"
