set -eo pipefail
if [ -n "${BASH_VERSION:-}" ]; then
_scrt4_bash_major="${BASH_VERSION%%.*}"
_scrt4_bash_minor="${BASH_VERSION#*.}"; _scrt4_bash_minor="${_scrt4_bash_minor%%.*}"
if [ "${_scrt4_bash_major:-0}" -lt 3 ] \
|| { [ "${_scrt4_bash_major:-0}" -eq 3 ] && [ "${_scrt4_bash_minor:-0}" -lt 2 ]; }; then
printf 'scrt4: requires bash 3.2 or newer; found %s\n' "$BASH_VERSION" >&2
printf ' macOS: brew install bash\n' >&2
printf ' linux: upgrade coreutils / bash package via your package manager\n' >&2
exit 1
fi
unset _scrt4_bash_major _scrt4_bash_minor
fi
VERSION="0.2.14-community"
if [ -n "${XDG_RUNTIME_DIR:-}" ]; then
SOCKET="${XDG_RUNTIME_DIR}/scrt4.sock"
else
SOCKET="/tmp/scrt4-$(id -u).sock"
fi
CONFIG_DIR="$HOME/.scrt4"
export CONFIG_DIR
RELAY_BASE="https://auth.llmsecrets.com"
RELAY_POLL="https://llmsecrets-auth.vercel.app"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
export RED GREEN YELLOW CYAN BOLD NC
FORCE_CLI=false
AGENT_MODE=false
declare -a _SCRT4_CMDS=()
declare -a _SCRT4_HANDLERS=()
declare -a _SCRT4_MODULES_REGISTERED=()
_register_command() {
local name="$1"
local handler="$2"
local existing
for existing in "${_SCRT4_CMDS[@]}"; do
if [ "$existing" = "$name" ]; then
printf 'scrt4-core: command "%s" already registered\n' "$name" >&2
return 1
fi
done
_SCRT4_CMDS+=("$name")
_SCRT4_HANDLERS+=("$handler")
}
_resolve_command() {
local name="$1"
local i
for i in "${!_SCRT4_CMDS[@]}"; do
if [ "${_SCRT4_CMDS[$i]}" = "$name" ]; then
echo "${_SCRT4_HANDLERS[$i]}"
return 0
fi
done
return 1
}
_module_loaded() {
local name="$1"
local m
for m in "${_SCRT4_MODULES_REGISTERED[@]}"; do
if [ "$m" = "$name" ]; then
return 0
fi
done
return 1
}
_modules_init() {
local fn
for fn in $(declare -F | awk '/^declare -f scrt4_module_.*_register$/ {print $3}'); do
local name="${fn#scrt4_module_}"
name="${name%_register}"
if _module_loaded "$name"; then
continue
fi
"$fn"
_SCRT4_MODULES_REGISTERED+=("$name")
done
}
_has_gui() {
if [ "${SCRT4_FORCE_GUI:-0}" != "1" ]; then
[ "${SCRT4_DEV_MODE:-0}" = "1" ] && return 1
[ "${SCRT4_NO_GUI:-0}" = "1" ] && return 1
fi
command -v zenity >/dev/null 2>&1 || return 1
[ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]
}
export -f _has_gui
_scrt4_gui_list_panel() {
local title="$1" subtitle="$2"; shift 2
local rows=()
local r marker id name summary
for r in "$@"; do
marker="${r%%$'\t'*}"; r="${r#*$'\t'}"
id="${r%%$'\t'*}"; r="${r#*$'\t'}"
name="${r%%$'\t'*}"; summary="${r#*$'\t'}"
[ "$marker" = "CRIT" ] && marker="●" || marker=" "
rows+=( "$marker" "$id" "$name" "$summary" )
done
zenity --list --title="$title" --text="$subtitle" \
--width=820 --height=420 \
--column="!" --column="ID" --column="Name" --column="Summary" \
--print-column=2 \
"${rows[@]}" 2>/dev/null || true
}
export -f _scrt4_gui_list_panel
_scrt4_gui_action_panel() {
local title="$1" body="$2"; shift 2
local critical=false ciphertext=false
while [ $# -gt 0 ]; do
case "$1" in
--critical) critical=true ;;
--already-ciphertext) ciphertext=true ;;
esac
shift
done
local banner=""
$critical && banner="⚠ CRITICAL — step-up auth required. Review carefully.
"
if $ciphertext; then
body="Body is encrypted. Hidden by design — nothing readable to show here.
(Metadata only is displayed below.)
${body}"
fi
zenity --question --title="$title" \
--width=640 \
--ok-label="Confirm" --cancel-label="Cancel" \
--text="${banner}${body}" 2>/dev/null
}
export -f _scrt4_gui_action_panel
_scrt4_gui_status_panel() {
local title="$1"; shift
local rows=()
local r state label detail icon
for r in "$@"; do
state="${r%%$'\t'*}"; r="${r#*$'\t'}"
label="${r%%$'\t'*}"; detail="${r#*$'\t'}"
case "$state" in
OK) icon="✓" ;;
WARN) icon="!" ;;
FAIL) icon="✗" ;;
*) icon="?" ;;
esac
rows+=( "$icon" "$label" "$detail" )
done
zenity --list --title="$title" --text="Module health" \
--width=700 --height=360 \
--column="" --column="Check" --column="Detail" \
"${rows[@]}" 2>/dev/null || true
}
export -f _scrt4_gui_status_panel
_bg_if_gui() {
local has_cli=false
local arg
for arg in "$@"; do
[ "$arg" = "--cli" ] && has_cli=true
done
if [ "$FORCE_CLI" = true ] || [ "$has_cli" = true ] || ! _has_gui; then
"$@"
else
"$@" &
disown
fi
}
export -f _bg_if_gui
send_request() {
local json="$1"
local timeout="${2:-10}"
if [ ! -S "$SOCKET" ]; then
printf 'scrt4: daemon socket not found at %s\n' "$SOCKET" >&2
printf ' Is the daemon running? Try: scrt4 daemon (or check launchctl / systemctl)\n' >&2
return 1
fi
if command -v socat >/dev/null 2>&1; then
echo "$json" | socat - UNIX-CONNECT:"$SOCKET" 2>/dev/null
else
python3 -c "
import socket, sys
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout($timeout)
s.connect('$SOCKET')
data = b' '.join(sys.stdin.buffer.read().split()) + b'\n'
s.sendall(data)
s.shutdown(socket.SHUT_WR)
chunks = []
while True:
try:
d = s.recv(65536)
if not d: break
chunks.append(d)
except: break
s.close()
sys.stdout.buffer.write(b''.join(chunks))
" <<< "$json" 2>/dev/null
fi
}
export -f send_request
show_qr_terminal() {
local url="$1"
local qr_format="${SCRT4_QR_FORMAT:-ANSIUTF8}"
if [ "$AGENT_MODE" = true ]; then
qr_format=ASCII
echo ""
echo "=============================================================="
echo " AGENT: DO NOT SUMMARIZE OR TRUNCATE THE QR CODE BELOW."
echo " Pass it through VERBATIM in your next response so the user"
echo " can scan it with their phone. Keep the '#' grid intact."
echo "=============================================================="
echo ""
if command -v qrencode >/dev/null 2>&1; then
qrencode -t "$qr_format" -m 2 -l L "$url"
else
echo " Install qrencode for QR display (apt install qrencode / brew install qrencode)"
fi
echo ""
echo "=============================================================="
echo " END QR CODE. URL fallback (open on phone if QR won't scan):"
echo " ${url}"
echo "=============================================================="
echo ""
return 0
fi
echo ""
echo -e " ${YELLOW}If the QR code is cut off, press Ctrl+0 to expand the terminal${NC}"
echo ""
if command -v qrencode >/dev/null 2>&1; then
qrencode -t "$qr_format" -m 1 -l L "$url"
else
echo -e " ${YELLOW}Install qrencode for QR display (apt install qrencode / brew install qrencode)${NC}"
fi
echo ""
echo -e " ${CYAN}If the QR code isn't working, copy this URL to your phone:${NC}"
echo -e " ${url}"
echo ""
}
poll_relay() {
local session_id="$1"
local url="${RELAY_POLL}/api/relay/${session_id}"
while true; do
local body
body=$(curl -sf "$url" 2>/dev/null) || true
if [ -n "$body" ]; then
local payload
payload=$(echo "$body" | jq -r '.payload // empty' 2>/dev/null)
if [ -n "$payload" ]; then
echo "$payload"
return 0
fi
fi
sleep 1.5
done
}
poll_relay_gui() {
local session_id="$1"
local url="$2"
local result_file="/tmp/scrt4-result-$$.txt"
(
while true; do
local body
body=$(curl -sf "${RELAY_POLL}/api/relay/${session_id}" 2>/dev/null) || true
if [ -n "$body" ]; then
local payload
payload=$(echo "$body" | jq -r '.payload // empty' 2>/dev/null)
if [ -n "$payload" ]; then
echo "$payload" > "$result_file"
exit 0
fi
fi
sleep 1.5
done
) &
local poll_pid=$!
(
while kill -0 $poll_pid 2>/dev/null; do
if [ -f "$result_file" ]; then
echo "100"
break
fi
echo "#"
sleep 1
done
) | zenity --progress --pulsate --auto-close \
--title="scrt4 — Waiting for Phone" \
--text="Scan the QR code in the terminal with your phone camera.\n\nOr open this URL on your phone:\n<span font_family='monospace' font='10'>${url}</span>\n\nWaiting for authentication..." \
--cancel-label=" Cancel " \
--width=520 2>/dev/null
local zenity_rc=$?
if [ $zenity_rc -ne 0 ] && [ ! -f "$result_file" ]; then
kill $poll_pid 2>/dev/null
wait $poll_pid 2>/dev/null
rm -f "$result_file"
return 1
fi
wait $poll_pid 2>/dev/null
local result=""
[ -f "$result_file" ] && result=$(cat "$result_file")
rm -f "$result_file"
if [ -z "$result" ]; then
return 1
fi
echo "$result"
}
AUTHCODE_DISPLAY=""
shorten_qr_url() {
local full_url="$1"
local session_id="$2"
AUTHCODE_DISPLAY=""
local shorten_resp
shorten_resp=$(curl -sf -X POST "${RELAY_POLL}/api/relay/shorten" \
-H "Content-Type: application/json" \
-d "{\"session_id\":\"${session_id}\"}" 2>/dev/null) || true
if [ -n "$shorten_resp" ]; then
local code
code=$(echo "$shorten_resp" | jq -r '.code // empty' 2>/dev/null)
if [ -n "$code" ]; then
AUTHCODE_DISPLAY="$code"
fi
fi
echo "$full_url"
}
trigger_push() {
local session_id="$1"
local auth_url="$2"
[ -z "$session_id" ] || [ -z "$auth_url" ] && return 0
local push_json
push_json=$(jq -nc --arg sid "$session_id" --arg url "$auth_url" \
'{session_id: $sid, auth_url: $url}')
curl -sf -X POST "${RELAY_POLL}/api/relay/push" \
-H "Content-Type: application/json" \
-d "$push_json" \
-o /dev/null 2>/dev/null &
}
open_browser() {
local url="$1"
if command -v wslpath >/dev/null 2>&1 && command -v cmd.exe >/dev/null 2>&1; then
cmd.exe /c start "" "$url" >/dev/null 2>&1
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "$url" >/dev/null 2>&1 &
elif command -v open >/dev/null 2>&1; then
open "$url" >/dev/null 2>&1 &
else
echo -e " ${YELLOW}Could not open browser. Open this URL manually:${NC}" >&2
echo -e " ${url}" >&2
fi
}
run_unlock_flow() {
local ttl="${1:-72000}"
local response
response=$(send_request "$(jq -nc --argjson ttl "$ttl" '{method:"unlock_webauthn",params:{ttl:$ttl}}')")
local success
success=$(echo "$response" | jq -r '.success // false')
if [ "$success" != "true" ]; then
local error
error=$(echo "$response" | jq -r '.error // "Unknown error"')
echo -e "${RED}Unlock failed: ${error}${NC}" >&2
return 1
fi
local full_url session_id wrapping_key
full_url=$(echo "$response" | jq -r '.data.url // empty')
session_id=$(echo "$response" | jq -r '.data.session_id // empty')
wrapping_key=$(echo "$response" | jq -r '.data.wrapping_key // empty')
if [ -z "$full_url" ]; then
local count
count=$(echo "$response" | jq -r '.data.count // empty')
if [ -n "$count" ]; then
echo -e "${GREEN}Unlocked (${count} secrets)${NC}"
else
echo -e "${GREEN}OK${NC}"
fi
return 0
fi
local url
url=$(shorten_qr_url "$full_url" "$session_id")
trigger_push "$session_id" "$full_url"
echo -e " ${CYAN}Auth URL:${NC} ${url}" >&2
if [ -n "$AUTHCODE_DISPLAY" ]; then
echo "" >&2
echo -e " ${CYAN}AuthCode:${NC} ${GREEN}${AUTHCODE_DISPLAY}${NC}" >&2
echo -e " Enter at ${CYAN}auth.llmsecrets.com${NC}" >&2
echo "" >&2
fi
show_qr_terminal "$url"
if [ -n "$AUTHCODE_DISPLAY" ]; then
echo -e " Scan the QR code or enter AuthCode ${GREEN}${AUTHCODE_DISPLAY}${NC} at auth.llmsecrets.com"
else
echo -e " Scan the QR code with your phone camera."
fi
echo -e " Then tap 'Unlock with Passkey' on the page."
echo ""
local payload=""
if [ "$FORCE_CLI" = false ] && _has_gui; then
payload=$(poll_relay_gui "$session_id" "$url") || {
echo -e "${YELLOW}GUI unavailable, switching to terminal mode...${NC}"
echo -ne " ${CYAN}Waiting for phone...${NC}"
payload=$(poll_relay "$session_id")
echo -e " ${GREEN}received!${NC}"
}
else
echo -ne " ${CYAN}Waiting for phone...${NC}"
payload=$(poll_relay "$session_id")
echo -e " ${GREEN}received!${NC}"
fi
echo -e "${CYAN}Decrypting vault...${NC}"
local complete_json
complete_json=$(jq -cn \
--arg payload "$payload" \
--arg key "$wrapping_key" \
--argjson ttl "$ttl" \
'{method:"unlock_webauthn_complete",params:{encrypted_payload:$payload,wrapping_key:$key,ttl:$ttl}}')
response=$(send_request "$complete_json")
success=$(echo "$response" | jq -r '.success // false')
if [ "$success" = "true" ]; then
local count
count=$(echo "$response" | jq -r '.data.count // 0')
echo -e "${GREEN}Unlocked ${count} secret(s).${NC}"
else
local error
error=$(echo "$response" | jq -r '.error // "Unknown error"')
echo -e "${RED}Unlock failed: ${error}${NC}" >&2
return 1
fi
}
run_setup_flow() {
local response
response=$(send_request '{"method":"setup_webauthn"}')
local success
success=$(echo "$response" | jq -r '.success // false')
if [ "$success" != "true" ]; then
local error
error=$(echo "$response" | jq -r '.error // "Unknown error"')
echo -e "${RED}Setup failed: ${error}${NC}" >&2
return 1
fi
local full_url session_id wrapping_key prf_salt_b64
full_url=$(echo "$response" | jq -r '.data.url')
session_id=$(echo "$response" | jq -r '.data.session_id')
wrapping_key=$(echo "$response" | jq -r '.data.wrapping_key')
prf_salt_b64=$(echo "$response" | jq -r '.data.prf_salt_b64')
local url
url=$(shorten_qr_url "$full_url" "$session_id")
trigger_push "$session_id" "$full_url"
echo -e " ${CYAN}Auth URL:${NC} ${url}" >&2
if [ -n "$AUTHCODE_DISPLAY" ]; then
echo "" >&2
echo -e " ${CYAN}AuthCode:${NC} ${GREEN}${AUTHCODE_DISPLAY}${NC}" >&2
echo -e " Enter at ${CYAN}auth.llmsecrets.com${NC}" >&2
echo "" >&2
fi
show_qr_terminal "$url"
if [ -n "$AUTHCODE_DISPLAY" ]; then
echo -e " Scan the QR code or enter AuthCode ${GREEN}${AUTHCODE_DISPLAY}${NC} at auth.llmsecrets.com"
fi
echo -e " Then tap 'Register Passkey' on the page."
echo ""
local payload=""
if [ "$FORCE_CLI" = false ] && _has_gui; then
payload=$(poll_relay_gui "$session_id" "$url") || {
echo -e "${YELLOW}GUI unavailable, switching to terminal mode...${NC}"
echo -ne " ${CYAN}Waiting for phone...${NC}"
payload=$(poll_relay "$session_id")
echo -e " ${GREEN}received!${NC}"
}
else
echo -ne " ${CYAN}Waiting for phone...${NC}"
payload=$(poll_relay "$session_id")
echo -e " ${GREEN}received!${NC}"
fi
echo -e "${CYAN}Completing registration...${NC}"
local complete_json
complete_json=$(jq -cn \
--arg payload "$payload" \
--arg key "$wrapping_key" \
--arg salt "$prf_salt_b64" \
'{method:"setup_webauthn_complete",params:{encrypted_payload:$payload,wrapping_key:$key,prf_salt_b64:$salt}}')
response=$(send_request "$complete_json")
success=$(echo "$response" | jq -r '.success // false')
if [ "$success" = "true" ]; then
echo -e "${GREEN}Credential registered successfully!${NC}"
echo -e "${GREEN}Empty secret store created.${NC}"
echo -e "${CYAN}Add secrets with: scrt4 add KEY=value${NC}"
else
local error
error=$(echo "$response" | jq -r '.error // "Unknown error"')
echo -e "${RED}Registration failed: ${error}${NC}" >&2
return 1
fi
}
ensure_unlocked() {
local response
response=$(send_request '{"method":"status"}' 2>/dev/null || true)
local active
active=$(echo "$response" | jq -r '.data.active // false' 2>/dev/null || echo "false")
if [ "$active" = "true" ]; then
return 0
fi
echo -e "${YELLOW}Session not active. Run: ${BOLD}scrt4 unlock${NC}" >&2
return 1
}
open_ceremony_browser() {
local url="$1"
local os
os=$(uname -s 2>/dev/null || echo unknown)
if [ "$os" = "Darwin" ]; then
for app in "Google Chrome" "Chromium" "Microsoft Edge" "Brave Browser"; do
if open -a "$app" "$url" >/dev/null 2>&1; then
echo -e " ${CYAN}Opened in ${app}.${NC}" >&2
return 0
fi
done
else
for bin in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge brave-browser; do
if command -v "$bin" >/dev/null 2>&1; then
"$bin" "$url" >/dev/null 2>&1 &
echo -e " ${CYAN}Opened in ${bin}.${NC}" >&2
return 0
fi
done
fi
echo -e " ${YELLOW}Chrome not found — using your default browser.${NC}" >&2
echo -e " ${YELLOW}If registration fails, try again in Chrome.${NC}" >&2
open_browser "$url"
}
run_setup_local_flow() {
local start
start=$(send_request '{"method":"setup_local"}' 2>/dev/null || true)
if [ "$(echo "$start" | jq -r '.success // false' 2>/dev/null)" != "true" ]; then
local err
err=$(echo "$start" | jq -r '.error // "unknown error"' 2>/dev/null)
echo -e "${RED}setup --local failed: ${err}${NC}" >&2
case "$err" in
*"ession"*|*"master key"*)
echo -e "${YELLOW}This adds a second way to open a vault that already exists,${NC}" >&2
echo -e "${YELLOW}so unlock first with your phone: ${NC}scrt4 unlock" >&2 ;;
esac
return 1
fi
local url
url=$(echo "$start" | jq -r '.data.url // empty' 2>/dev/null)
if [ -z "$url" ]; then
echo -e "${RED}setup --local failed: daemon returned no URL.${NC}" >&2
return 1
fi
echo -e "${CYAN}Registering this device's passkey...${NC}" >&2
open_ceremony_browser "$url"
echo -e " ${CYAN}URL:${NC} ${url}" >&2
echo -e " Register a passkey in the browser window (Touch ID, Windows Hello)." >&2
echo -e " ${YELLOW}Chrome is the tested browser for this step.${NC}" >&2
echo "" >&2
echo -ne " ${CYAN}Waiting for registration...${NC}" >&2
local done_resp
done_resp=$(send_request '{"method":"setup_local_complete"}' 180 2>/dev/null || true)
echo "" >&2
if [ "$(echo "$done_resp" | jq -r '.success // false' 2>/dev/null)" = "true" ]; then
echo -e "${GREEN}Device passkey registered.${NC}" >&2
echo -e "${GREEN}You can now unlock with: ${NC}scrt4 unlock --local" >&2
return 0
fi
echo -e "${RED}setup --local failed: $(echo "$done_resp" | jq -r '.error // "unknown"')${NC}" >&2
return 1
}
run_unlock_local_flow() {
local ttl="${1:-72000}"
local start
start=$(send_request "$(jq -nc --argjson t "$ttl" '{method:"unlock_local",params:{ttl:$t}}')" 2>/dev/null || true)
if [ "$(echo "$start" | jq -r '.success // false' 2>/dev/null)" != "true" ]; then
local err
err=$(echo "$start" | jq -r '.error // "unknown error"' 2>/dev/null)
echo -e "${RED}unlock --local failed: ${err}${NC}" >&2
echo -e "${YELLOW}If this device has no passkey yet: ${NC}scrt4 unlock && scrt4 setup --local" >&2
return 1
fi
local url
url=$(echo "$start" | jq -r '.data.url // empty' 2>/dev/null)
if [ -z "$url" ]; then
echo -e "${RED}unlock --local failed: daemon returned no URL.${NC}" >&2
return 1
fi
echo -e "${CYAN}Opening browser for authentication...${NC}" >&2
open_ceremony_browser "$url"
echo -e " ${CYAN}URL:${NC} ${url}" >&2
echo -e " Authenticate in the browser window." >&2
echo "" >&2
echo -ne " ${CYAN}Waiting for authentication...${NC}" >&2
local done_resp
done_resp=$(send_request "$(jq -nc --argjson t "$ttl" '{method:"unlock_local_complete",params:{ttl:$t}}')" 180 2>/dev/null || true)
echo "" >&2
if [ "$(echo "$done_resp" | jq -r '.success // false' 2>/dev/null)" = "true" ]; then
echo -e "${GREEN}Session active.${NC}" >&2
return 0
fi
echo -e "${RED}unlock --local failed: $(echo "$done_resp" | jq -r '.error // "unknown"')${NC}" >&2
return 1
}
_wa_gate() {
echo -e "${CYAN}WebAuthn verification required...${NC}" >&2
local _start
_start=$(send_request '{"method":"unlock_local","params":{"ttl":7200}}' 2>/dev/null || true)
if [ "$(echo "$_start" | jq -r '.success // false' 2>/dev/null)" = "true" ]; then
local _url
_url=$(echo "$_start" | jq -r '.data.url // empty' 2>/dev/null)
if [ -n "$_url" ]; then
echo -e "${CYAN}Opening browser for authentication...${NC}" >&2
open_browser "$_url"
echo -e " ${CYAN}URL:${NC} ${_url}" >&2
echo -e " Authenticate in the browser window." >&2
echo "" >&2
echo -ne " ${CYAN}Waiting for authentication...${NC}" >&2
local _done
_done=$(send_request '{"method":"unlock_local_complete","params":{"ttl":7200}}' 180 2>/dev/null || true)
echo "" >&2
if [ "$(echo "$_done" | jq -r '.success // false' 2>/dev/null)" = "true" ]; then
echo -e "${GREEN}WebAuthn verified.${NC}" >&2
return 0
fi
fi
fi
echo -e "${YELLOW}Trying remote authentication...${NC}" >&2
run_unlock_flow "7200" || {
echo -e "${RED}WebAuthn verification failed.${NC}" >&2
return 1
}
return 0
}
_run_with_injected_secrets() {
local cmd="$1"
local cwd="${2:-$PWD}"
local response
response=$(send_request "$(jq -nc --arg c "$cmd" --arg d "$cwd" '{method:"run",params:{command:$c,working_dir:$d}}')" 2>/dev/null || true)
local ok
ok=$(echo "$response" | jq -r '.success // false' 2>/dev/null || echo "false")
if [ "$ok" != "true" ]; then
local err
err=$(echo "$response" | jq -r '.error // "unknown error"' 2>/dev/null)
echo -e "${RED}scrt4 run failed: ${err}${NC}" >&2
return 1
fi
echo "$response" | jq -r '.data.output // ""'
local exit_code
exit_code=$(echo "$response" | jq -r '.data.exit_code // 0' 2>/dev/null)
return "$exit_code"
}
cmd_help() {
cat <<'EOF'
scrt4 — secure secret manager (v0.2 architecture)
USAGE:
scrt4 <command> [options]
CORE COMMANDS:
help Show this help
daemon Start scrt4-daemon in the foreground
status Check session status
setup [--agent] Register a WebAuthn passkey (first-time enrollment)
setup --local Add this device's own passkey (Touch ID / Windows
Hello) so unlocking needs no phone. Unlock first.
unlock [--local] [--agent] [ttl] Authenticate and start a session
(--local uses this device's own passkey)
(--agent renders the QR in plain ASCII with banner
markers so AI coding assistants pass it through
unmodified instead of truncating it)
extend [ttl] Reset session timer (optionally update TTL)
logout Lock the session (aliases: lock, clear)
list [--tags] [--tag T] List secret names (optionally with tags / filtered)
add [KEY=value ...] Add secrets (GUI notepad if no args)
run [--cwd DIR] 'cmd $env[K]'
Run a command with secret injection. Runs in the
current directory unless --cwd is given.
view [--cli] View secrets (GUI default, --cli for terminal)
rotate Rotate the vault master key (re-encrypts all secrets)
backup-vault [--local DIR] Tar the vault directory into a dated archive
backup-key [--save DIR] Show or save the master key
recover FILE Recover a master key from an encrypted backup
recover-key KEY [--reveal] Emergency vault recovery with a plaintext
base64 master key (FIDO2 not required)
backup-guide Open backup & recovery guide
list-encrypted List registered .scrt4 archives
cleanup-encrypted [--prune] Show or prune stale inventory entries
llm [--json] Print LLM/agent capability map (llms.txt)
upgrade [--check] Install the published release (verifies SHA256 first)
verify-self Verify the scrt4 binary against the published SHA256SUMS
EOF
printf '\nVersion: %s\n' "$VERSION"
local module_listed=false
local i
for i in "${!_SCRT4_CMDS[@]}"; do
local handler="${_SCRT4_HANDLERS[$i]}"
case "$handler" in
cmd_help|cmd_status|cmd_setup|cmd_unlock|cmd_extend|cmd_logout|cmd_list|cmd_add|cmd_run|cmd_view|cmd_rotate|cmd_list_encrypted|cmd_cleanup_encrypted|cmd_daemon|cmd_backup_vault|cmd_backup_key|cmd_recover|cmd_recover_key|cmd_backup_guide|cmd_verify_self|cmd_upgrade)
continue ;;
esac
if [ "$module_listed" = false ]; then
echo
echo "MODULE COMMANDS:"
module_listed=true
fi
printf ' %-20s (%s)\n' "${_SCRT4_CMDS[$i]}" "$handler"
done
}
cmd_extend() {
ensure_unlocked || return 1
local ttl="${1:-}"
local req
if [ -n "$ttl" ]; then
req=$(jq -nc --argjson ttl "$ttl" '{method:"extend",params:{ttl:$ttl}}')
else
req='{"method":"extend","params":{"ttl":null}}'
fi
local response
response=$(send_request "$req")
local ok
ok=$(echo "$response" | jq -r '.success // false')
if [ "$ok" != "true" ]; then
echo -e "${RED}extend failed: $(echo "$response" | jq -r '.error // "unknown"')${NC}" >&2
return 1
fi
local remaining hours mins
remaining=$(echo "$response" | jq -r '.data.remaining // 0')
hours=$((remaining / 3600))
mins=$(( (remaining % 3600) / 60 ))
echo -e "${GREEN}Session extended. ${hours}h ${mins}m remaining.${NC}"
}
cmd_status() {
local response
response=$(send_request '{"method":"status"}' 2>/dev/null || true)
if [ -z "$response" ]; then
echo -e "${YELLOW}Daemon not reachable. Start with: scrt4-daemon &${NC}" >&2
return 1
fi
echo "$response" | jq .
}
cmd_unlock() {
local _unlock_local=false
local ttl="72000"
while [ $# -gt 0 ]; do
case "$1" in
--local) _unlock_local=true; shift ;;
--agent) AGENT_MODE=true; FORCE_CLI=true; shift ;;
--cli|agent) FORCE_CLI=true; shift ;;
*) ttl="$1"; shift ;;
esac
done
if [ "$_unlock_local" = true ]; then
run_unlock_local_flow "${ttl:-72000}"
return $?
fi
run_unlock_flow "$ttl"
}
cmd_setup() {
local use_local=false
while [ $# -gt 0 ]; do
case "$1" in
--local) use_local=true; shift ;;
--agent) AGENT_MODE=true; FORCE_CLI=true; shift ;;
--cli|agent) FORCE_CLI=true; shift ;;
*) shift ;;
esac
done
if [ "$use_local" = true ]; then
run_setup_local_flow
return $?
fi
local existing_secrets=0
local list_resp
list_resp=$(send_request '{"method":"list"}' 2>/dev/null || true)
if [ -n "$list_resp" ]; then
existing_secrets=$(echo "$list_resp" | jq -r '.data.names | length // 0' 2>/dev/null || echo 0)
fi
if [ "$existing_secrets" -gt 0 ] 2>/dev/null; then
echo ""
echo -e "${RED}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${RED}║ WARNING: You have existing secrets in your vault! ║${NC}"
echo -e "${RED}╠══════════════════════════════════════════════════════════╣${NC}"
echo -e "${RED}║ Setting up WebAuthn generates new encryption keys. ║${NC}"
echo -e "${RED}║ ALL existing secrets will be permanently deleted. ║${NC}"
echo -e "${RED}╠══════════════════════════════════════════════════════════╣${NC}"
echo -e "${RED}║ Before proceeding, you should: ║${NC}"
echo -e "${RED}║ 1. Run 'scrt4 backup-key --save ~/Desktop' ║${NC}"
echo -e "${RED}║ (saves encrypted master key backup) ║${NC}"
echo -e "${RED}║ 2. Run 'scrt4 view' and copy your secret values ║${NC}"
echo -e "${RED}║ 3. After setup, re-add with 'scrt4 add KEY=value' ║${NC}"
echo -e "${RED}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -n "Type YES to proceed, or anything else to abort: "
local confirm
read -r confirm
if [ "$confirm" != "YES" ]; then
echo -e "${YELLOW}Aborted. Your secrets are safe.${NC}"
return 0
fi
echo ""
fi
run_setup_flow
}
cmd_list_encrypted() {
ensure_unlocked || return 1
local response
response=$(send_request '{"method":"list_encrypted"}')
local ok
ok=$(echo "$response" | jq -r '.success // false')
if [ "$ok" != "true" ]; then
echo -e "${RED}list_encrypted failed: $(echo "$response" | jq -r '.error // "unknown"')${NC}" >&2
return 1
fi
local count
count=$(echo "$response" | jq -r '.data.entries | length')
if [ "$count" = "0" ]; then
echo -e "${YELLOW}No encrypted folders registered.${NC}"
echo " Create one with: scrt4 encrypt-folder PATH"
return 0
fi
echo -e "${CYAN}Registered encrypted folders (${count}):${NC}"
echo "$response" | jq -r '.data.entries[] | " [\(if .exists then "ok" else "MISSING" end)] \(.folder_name) (\(.file_count) files, \(.archive_size) bytes)\n \(.path)\n id: \(.id)"'
}
cmd_cleanup_encrypted() {
ensure_unlocked || return 1
local remove_missing=false
while [ $# -gt 0 ]; do
case "$1" in
--prune) remove_missing=true; shift ;;
*) echo -e "${RED}Unknown flag: ${1}${NC}" >&2; return 1 ;;
esac
done
local req
req=$(jq -nc --argjson rm "$remove_missing" '{method:"cleanup_encrypted",params:{remove_missing:$rm}}')
local response
response=$(send_request "$req")
local ok
ok=$(echo "$response" | jq -r '.success // false')
if [ "$ok" != "true" ]; then
echo -e "${RED}cleanup_encrypted failed: $(echo "$response" | jq -r '.error // "unknown"')${NC}" >&2
return 1
fi
local present missing removed
present=$(echo "$response" | jq -r '.data.present_count')
missing=$(echo "$response" | jq -r '.data.missing_count')
removed=$(echo "$response" | jq -r '.data.removed_count')
echo -e "${CYAN}Encrypted inventory summary:${NC}"
echo " Present on disk: ${present}"
echo " Missing from disk: ${missing}"
echo " Removed this pass: ${removed}"
if [ "$missing" != "0" ]; then
echo ""
echo -e "${YELLOW}Missing paths:${NC}"
echo "$response" | jq -r '.data.missing_paths[]' | sed 's/^/ /'
if [ "$remove_missing" = false ]; then
echo ""
echo -e "${YELLOW}Pass --prune to remove missing entries from the inventory.${NC}"
fi
fi
}
cmd_rotate() {
ensure_unlocked || return 1
_wa_gate || {
echo -e "${RED}Step-up cancelled; vault rotation aborted.${NC}" >&2
return 1
}
local response
response=$(send_request '{"method":"rotate_vault"}' 30)
local ok
ok=$(echo "$response" | jq -r '.success // false')
if [ "$ok" != "true" ]; then
echo -e "${RED}rotate_vault failed: $(echo "$response" | jq -r '.error // "unknown"')${NC}" >&2
return 1
fi
local new_key count wrapper_stale
new_key=$(echo "$response" | jq -r '.data.new_master_key_b64')
count=$(echo "$response" | jq -r '.data.secret_count')
wrapper_stale=$(echo "$response" | jq -r '.data.wrapper_stale')
echo -e "${GREEN}Vault rotated.${NC}"
echo " Secrets re-encrypted: ${count}"
if [ "$wrapper_stale" = "true" ]; then
echo ""
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW} HARDENED MODE: master.key wrapper is now STALE${NC}"
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW} Your WebAuthn credential still unwraps the OLD master key,${NC}"
echo -e "${YELLOW} but the vault is now encrypted under a NEW master key.${NC}"
echo -e "${YELLOW} Before this session ends, do ONE of the following:${NC}"
echo ""
echo -e "${YELLOW} 1) scrt4 backup-key --save ~/Desktop${NC}"
echo -e "${YELLOW} Saves the NEW master key so you can recover manually${NC}"
echo -e "${YELLOW} 2) scrt4 setup${NC}"
echo -e "${YELLOW} Re-enrolls WebAuthn against the new master key${NC}"
echo ""
echo -e "${YELLOW} If you do neither and the session ends, the next unlock${NC}"
echo -e "${YELLOW} will fail because master.key unwraps to the old key.${NC}"
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
fi
echo ""
echo -e "${CYAN}New master key (base64, 32 bytes):${NC}" >&2
printf '%s\n' "$new_key" >&2
echo "" >&2
echo -e "${YELLOW}Store this somewhere safe. It's your recovery key.${NC}" >&2
}
cmd_logout() {
send_request '{"method":"clear"}'
}
cmd_backup_vault() {
local local_dest=""
while [ $# -gt 0 ]; do
case "$1" in
--local)
local_dest="${2:-}"
if [ -z "$local_dest" ]; then
echo -e "${RED}Usage: scrt4 backup-vault --local <directory>${NC}" >&2
return 1
fi
shift 2
;;
*)
echo -e "${RED}Unknown option: $1${NC}" >&2
echo "Usage: scrt4 backup-vault [--local <directory>]" >&2
return 1
;;
esac
done
[ -z "$local_dest" ] && local_dest="."
if [ ! -d "$CONFIG_DIR" ]; then
echo -e "${RED}Config directory not found: ${CONFIG_DIR}${NC}" >&2
return 1
fi
if [ ! -d "$local_dest" ]; then
echo -e "${RED}Destination directory not found: ${local_dest}${NC}" >&2
return 1
fi
local timestamp
timestamp=$(date +%Y-%m-%d)
local archive="${local_dest}/scrt4-backup-${timestamp}.tar.gz"
local file_count
file_count=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l)
if [ "$file_count" -eq 0 ]; then
echo -e "${RED}No files in ${CONFIG_DIR}${NC}" >&2
return 1
fi
echo -e "${CYAN}Backing up ${CONFIG_DIR} (${file_count} files)...${NC}"
local config_basename config_parent
config_basename=$(basename "$CONFIG_DIR")
config_parent=$(dirname "$CONFIG_DIR")
if ! tar -czf "$archive" -C "$config_parent" "$config_basename" 2>/dev/null; then
echo -e "${RED}tar failed${NC}" >&2
return 1
fi
local inventory_file="${CONFIG_DIR}/encrypted-inventory.json"
if [ -f "$inventory_file" ]; then
local inv_count
inv_count=$(jq -r '.entries | length' "$inventory_file" 2>/dev/null || echo "?")
echo -e "${CYAN} + cloud-crypt inventory: ${inv_count} entries tracked${NC}"
fi
local size
size=$(stat -c%s "$archive" 2>/dev/null || stat -f%z "$archive" 2>/dev/null || echo 0)
echo -e "${GREEN}Wrote ${archive} (${size} bytes)${NC}"
echo
echo -e "${YELLOW}Note: the vault file inside the archive is still encrypted.${NC}"
echo -e "${YELLOW}You also need the master key to recover. Run: scrt4 backup-key${NC}"
}
cmd_backup_key() {
local save_dir=""
case "${1:-}" in
--save)
save_dir="${2:-.}"
if [ ! -d "$save_dir" ]; then
echo -e "${RED}Directory not found: ${save_dir}${NC}" >&2
return 1
fi
;;
--to-drive)
shift
cmd_backup_key_to_drive "$@"
return $?
;;
esac
ensure_unlocked || return 1
_wa_gate || return 1
echo -e "${CYAN}Retrieving master key from daemon...${NC}"
local response
response=$(send_request '{"method":"backup_key"}')
local success
success=$(echo "$response" | jq -r '.success // false')
if [ "$success" != "true" ]; then
local err
err=$(echo "$response" | jq -r '.error // "Unknown error"')
echo -e "${RED}Failed to retrieve master key: ${err}${NC}" >&2
return 1
fi
local key
key=$(echo "$response" | jq -r '.data.key')
if [ -z "$save_dir" ]; then
echo -e "${GREEN}Master key (${#key} characters):${NC}"
echo
echo "$key"
echo
echo -e "${YELLOW}Store this somewhere safe and offline.${NC}"
echo -e "${YELLOW}Never paste it into Claude Code or any AI agent.${NC}"
return 0
fi
local abs_save_dir
abs_save_dir=$(cd "$save_dir" && pwd)
local out_file="${abs_save_dir}/encrypted-master-key-instructions.json"
echo
echo -e "${CYAN}Creating encrypted master key backup at:${NC}"
echo " $out_file"
echo
local password password2
if [ -n "${SCRT4_TEST_PASSWORD:-}" ]; then
password="$SCRT4_TEST_PASSWORD"
password2="$SCRT4_TEST_PASSWORD"
echo -e "${YELLOW}(using SCRT4_TEST_PASSWORD from environment)${NC}"
else
echo -n "Recovery password (min 8 chars): "
read -r -s password
echo
echo -n "Confirm password: "
read -r -s password2
echo
fi
if [ "$password" != "$password2" ]; then
echo -e "${RED}Passwords do not match. No file written.${NC}" >&2
return 1
fi
if [ ${#password} -lt 8 ]; then
echo -e "${RED}Password must be at least 8 characters. No file written.${NC}" >&2
return 1
fi
local encrypt_script
encrypt_script=$(mktemp)
cat > "$encrypt_script" << 'PYEOF'
import json, os, sys, hashlib, base64, subprocess, datetime
lines = sys.stdin.read().split('\n', 1)
master_key, password = lines[0], lines[1]
out_file = sys.argv[1]
salt = os.urandom(16)
iv = os.urandom(16)
derived_key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000, dklen=32)
proc = subprocess.run(
['openssl', 'enc', '-aes-256-cbc',
'-K', derived_key.hex(), '-iv', iv.hex(), '-nosalt'],
input=master_key.encode(), capture_output=True)
if proc.returncode != 0:
print('Encryption failed', file=sys.stderr); sys.exit(1)
backup = {
'Type': 'MasterKeyBackup',
'Version': '2.0',
'CreatedAt': datetime.datetime.now().astimezone().isoformat(),
'SecurityMode': 'webauthn-prf',
'Salt': base64.b64encode(salt).decode(),
'IV': base64.b64encode(iv).decode(),
'EncryptedMasterKey': base64.b64encode(proc.stdout).decode(),
'DecryptionInstructions': {
'Algorithm': 'AES-256-CBC',
'KeyDerivation': 'PBKDF2-SHA256',
'Iterations': 100000,
'KeyLength': 32,
},
}
with open(out_file, 'w') as f:
json.dump(backup, f, indent=2)
print('OK')
PYEOF
local result
result=$(printf '%s\n%s' "$key" "$password" | python3 "$encrypt_script" "$out_file")
local rc=$?
rm -f "$encrypt_script"
if [ $rc -ne 0 ] || [ "$result" != "OK" ]; then
echo -e "${RED}Failed to create encrypted backup.${NC}" >&2
return 1
fi
chmod 600 "$out_file"
echo -e "${GREEN}Wrote ${out_file}${NC}"
echo
echo -e "${YELLOW}Recover with: scrt4 recover ${out_file}${NC}"
echo -e "${YELLOW}You will need the password you just set.${NC}"
}
cmd_recover() {
if [ "${1:-}" = "--from-drive" ]; then
shift
cmd_recover_from_drive "$@"
return $?
fi
local backup_file="${1:-}"
if [ -z "$backup_file" ]; then
echo -e "${RED}Usage: scrt4 recover <encrypted-master-key-instructions.json>${NC}" >&2
echo -e "${RED} or: scrt4 recover --from-drive DRIVE_ID${NC}" >&2
return 1
fi
if [ ! -f "$backup_file" ]; then
echo -e "${RED}File not found: ${backup_file}${NC}" >&2
return 1
fi
if ! jq -e '.EncryptedMasterKey' "$backup_file" >/dev/null 2>&1; then
echo -e "${RED}Not a scrt4 master-key backup (missing EncryptedMasterKey field)${NC}" >&2
return 1
fi
echo -e "${CYAN}=== scrt4 master key recovery ===${NC}"
local created version
created=$(jq -r '.CreatedAt // "unknown"' "$backup_file")
version=$(jq -r '.Version // "1.0"' "$backup_file")
echo " Backup created: $created"
echo " Format version: $version"
echo
local password
if [ -n "${SCRT4_TEST_PASSWORD:-}" ]; then
password="$SCRT4_TEST_PASSWORD"
echo -e "${YELLOW}(using SCRT4_TEST_PASSWORD from environment)${NC}"
else
echo -n "Recovery password: "
read -r -s password
echo
fi
local decrypt_script
decrypt_script=$(mktemp)
cat > "$decrypt_script" << 'PYEOF'
import json, sys, hashlib, base64, subprocess
password = sys.stdin.read()
with open(sys.argv[1], encoding='utf-8-sig') as f:
backup = json.load(f)
salt = base64.b64decode(backup['Salt'])
iv = base64.b64decode(backup['IV'])
encrypted = base64.b64decode(backup['EncryptedMasterKey'])
iters = backup.get('DecryptionInstructions', {}).get('Iterations', 100000)
derived_key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iters, dklen=32)
result = subprocess.run(['openssl', 'enc', '-aes-256-cbc', '-d',
'-K', derived_key.hex(), '-iv', iv.hex(), '-nosalt'],
input=encrypted, capture_output=True)
if result.returncode != 0: sys.exit(1)
print(result.stdout.decode().rstrip(chr(0)))
PYEOF
local recovered_key
recovered_key=$(printf '%s' "$password" | python3 "$decrypt_script" "$backup_file" 2>/dev/null)
local rc=$?
rm -f "$decrypt_script"
if [ $rc -ne 0 ] || [ -z "$recovered_key" ]; then
echo -e "${RED}Decryption failed. Check your recovery password.${NC}" >&2
return 1
fi
echo
echo -e "${GREEN}SUCCESS — your master key:${NC}"
echo
echo " $recovered_key"
echo
echo " Length: ${#recovered_key} characters"
echo
echo -e "${CYAN}Next steps:${NC}"
echo " 1. Place this key in ~/.scrt4/master.key (or let setup do it)"
echo " 2. Run: scrt4 setup — register a new FIDO2 authenticator"
echo " 3. Run: scrt4 unlock — authenticate and start a session"
echo
echo " Your vault (secrets.enc) is still encrypted with this key."
echo " Once setup + unlock completes, all your secrets are accessible."
}
cmd_recover_key() {
local master_key="${1:-}"
local reveal=false
shift || true
while [ $# -gt 0 ]; do
case "$1" in
--reveal) reveal=true; shift ;;
*) shift ;;
esac
done
if [ -z "$master_key" ]; then
echo -e "${RED}Usage: scrt4 recover-key <base64-master-key> [--reveal]${NC}" >&2
echo "" >&2
echo " Emergency vault recovery using a plaintext master key." >&2
echo " Use when the FIDO2 authenticator is lost but you have" >&2
echo " the raw 32-byte master key (base64) saved elsewhere." >&2
return 1
fi
local vault_path="${CONFIG_DIR}/vault/secrets.enc"
if [ ! -f "$vault_path" ]; then
echo -e "${RED}Vault not found: ${vault_path}${NC}" >&2
return 1
fi
local decrypt_script
decrypt_script=$(mktemp)
cat > "$decrypt_script" << 'PYEOF'
import json, sys, base64, subprocess
master_key_b64, vault_path = sys.argv[1], sys.argv[2]
try:
key = base64.b64decode(master_key_b64)
except Exception as e:
print(f"ERR: invalid master key base64: {e}", file=sys.stderr); sys.exit(2)
if len(key) != 32:
print(f"ERR: master key must be 32 bytes, got {len(key)}", file=sys.stderr); sys.exit(2)
try:
with open(vault_path, encoding='utf-8-sig') as f:
env_json = json.load(f)
data = base64.b64decode(env_json['Data'])
except Exception as e:
print(f"ERR: cannot read vault: {e}", file=sys.stderr); sys.exit(2)
if len(data) < 17:
print("ERR: vault ciphertext too short", file=sys.stderr); sys.exit(2)
iv, ciphertext = data[:16], data[16:]
result = subprocess.run(
['openssl', 'enc', '-aes-256-cbc', '-d',
'-K', key.hex(), '-iv', iv.hex(), '-nosalt'],
input=ciphertext, capture_output=True)
if result.returncode != 0:
print("ERR: decryption failed — master key is wrong", file=sys.stderr); sys.exit(3)
sys.stdout.write(result.stdout.decode('utf-8', errors='replace'))
PYEOF
local plaintext
plaintext=$(python3 "$decrypt_script" "$master_key" "$vault_path" 2>/dev/null)
local rc=$?
rm -f "$decrypt_script"
if [ $rc -ne 0 ]; then
echo -e "${RED}Recovery failed. Check the master key.${NC}" >&2
return 1
fi
local names count
names=$(echo "$plaintext" | awk -F= '/^[A-Za-z_][A-Za-z0-9_]*=/ { print $1 }')
count=$(echo "$names" | grep -c . 2>/dev/null || echo 0)
echo -e "${GREEN}✓ Recovery successful — ${count} secret(s) decrypted from vault.${NC}"
echo ""
if [ "$reveal" = true ]; then
echo -e "${YELLOW}Printing plaintext secrets to stdout.${NC}" >&2
echo ""
echo "$plaintext"
else
echo -e "${CYAN}Secret names:${NC}"
echo "$names" | sed 's/^/ /'
echo ""
echo -e "${CYAN}To dump plaintext KEY=value pairs:${NC}"
echo " scrt4 recover-key <key> --reveal > recovered.env"
echo ""
echo -e "${CYAN}Then rebuild your vault and start a session:${NC}"
echo " scrt4 setup --agent # register a new FIDO2 passkey"
echo " scrt4 import-env recovered.env # restore all secrets"
echo " rm -f recovered.env # wipe the plaintext"
echo " scrt4 unlock --agent # access your secrets"
fi
}
cmd_backup_key_to_drive() {
ensure_unlocked || return 1
local master_key_file="${CONFIG_DIR}/master.key"
if [ ! -f "$master_key_file" ]; then
echo -e "${RED}Master key file not found: ${master_key_file}${NC}" >&2
echo -e "${RED}Run 'scrt4 setup' first.${NC}" >&2
return 1
fi
local cache_dir="${XDG_DATA_HOME:-$HOME/.local/share}/scrt4/cloud-crypt/archives"
mkdir -p "$cache_dir"
local timestamp bundle
timestamp=$(date +%Y-%m-%d-%H%M%S)
bundle="${cache_dir}/scrt4-master-key-${timestamp}.scrt4"
echo -e "${CYAN}Wrapping master.key in SCRT4ENC envelope...${NC}"
local wrap_script
wrap_script=$(mktemp)
cat > "$wrap_script" << 'PYEOF'
import json, struct, sys, time, base64
src, dst = sys.argv[1], sys.argv[2]
with open(src, 'rb') as f:
master_key_json_bytes = f.read()
try:
mk = json.loads(master_key_json_bytes.decode('utf-8'))
except Exception as e:
print(json.dumps({'error': f'master.key is not valid JSON: {e}'})); sys.exit(0)
for required in ('salt', 'nonce', 'ciphertext'):
if required not in mk:
print(json.dumps({'error': f'master.key missing field: {required}'})); sys.exit(0)
ciphertext = base64.b64decode(mk['ciphertext'])
header = json.dumps({
'kind': 'master-key-export',
'scrt4_version': 1,
'master_key_version': mk.get('version', 2),
'salt': mk['salt'],
'nonce': mk['nonce'],
'auth_method': mk.get('auth_method', 'WebAuthnPrf'),
'webauthn_credential_id': mk.get('webauthn_credential_id'),
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}, separators=(',', ':')).encode('utf-8')
with open(dst, 'wb') as f:
f.write(b'SCRT4ENC\x00')
f.write(b'\x01')
f.write(struct.pack('>I', len(header)))
f.write(header)
f.write(ciphertext)
print(json.dumps({'ok': True, 'path': dst, 'size': len(ciphertext) + len(header) + 14}))
PYEOF
local result
result=$(python3 "$wrap_script" "$master_key_file" "$bundle" 2>&1)
local rc=$?
rm -f "$wrap_script"
if [ $rc -ne 0 ] || ! echo "$result" | jq -e '.ok' >/dev/null 2>&1; then
echo -e "${RED}Failed to wrap master.key:${NC}" >&2
echo "$result" >&2
rm -f "$bundle" 2>/dev/null || true
return 1
fi
chmod 600 "$bundle"
echo -e "${GREEN}Bundle written: ${bundle}${NC}"
local magic_hex
magic_hex=$(head -c 9 "$bundle" | od -An -tx1 2>/dev/null | tr -d ' \n')
if [ "$magic_hex" != "5343525434454e4300" ]; then
echo -e "${RED}Bundle failed self-check (missing SCRT4ENC magic).${NC}" >&2
rm -f "$bundle"
return 1
fi
echo
echo -e "${CYAN}Uploading via cloud-crypt (Google Drive)...${NC}"
echo -e "${YELLOW}This requires 'scrt4 cloud-crypt auth' to be set up.${NC}"
if ! scrt4_module_cloud_crypt_dispatch push "$bundle" --yes; then
echo -e "${RED}cloud-crypt push failed.${NC}" >&2
echo -e "${YELLOW}Bundle left at: ${bundle}${NC}" >&2
echo -e "${YELLOW}You can retry: scrt4 cloud-crypt push '${bundle}'${NC}" >&2
return 1
fi
echo
echo -e "${GREEN}Master key exported to Google Drive.${NC}"
echo -e "${CYAN}Recover with: scrt4 recover --from-drive <drive_file_id>${NC}"
echo
echo -e "${YELLOW}The local bundle is kept at:${NC}"
echo " $bundle"
echo -e "${YELLOW}It is safe to delete; the Drive copy is the canonical backup.${NC}"
}
cmd_recover_from_drive() {
local drive_id="${1:-}"
if [ -z "$drive_id" ]; then
echo -e "${RED}Usage: scrt4 recover --from-drive DRIVE_ID [--out DIR]${NC}" >&2
return 1
fi
shift
local out_dir=""
while [ $# -gt 0 ]; do
case "$1" in
--out) out_dir="${2:-}"; shift 2 ;;
*) echo -e "${RED}Unknown flag: $1${NC}" >&2; return 1 ;;
esac
done
[ -z "$out_dir" ] && out_dir="${XDG_DATA_HOME:-$HOME/.local/share}/scrt4/cloud-crypt/inbox"
mkdir -p "$out_dir"
echo -e "${CYAN}Downloading master-key bundle from Drive...${NC}"
if ! scrt4_module_cloud_crypt_dispatch pull "$drive_id" --out "$out_dir" --yes; then
echo -e "${RED}cloud-crypt pull failed.${NC}" >&2
return 1
fi
local bundle
bundle=$(find "$out_dir" -maxdepth 1 -name 'scrt4-master-key-*.scrt4' -type f -printf '%T@ %p\n' 2>/dev/null \
| sort -nr | head -1 | awk '{print $2}')
if [ -z "$bundle" ] || [ ! -f "$bundle" ]; then
echo -e "${RED}Downloaded bundle not found under ${out_dir}${NC}" >&2
echo -e "${RED}The Drive file may not be a scrt4 master-key export.${NC}" >&2
return 1
fi
echo -e "${CYAN}Unwrapping SCRT4ENC envelope: ${bundle}${NC}"
local unwrap_script tmp_master
unwrap_script=$(mktemp)
tmp_master=$(mktemp -t scrt4-recovered-master-XXXXXX)
cat > "$unwrap_script" << 'PYEOF'
import json, struct, sys, base64
src, dst = sys.argv[1], sys.argv[2]
with open(src, 'rb') as f:
if f.read(9) != b'SCRT4ENC\x00':
print(json.dumps({'error': 'not a SCRT4ENC file'})); sys.exit(0)
if f.read(1) != b'\x01':
print(json.dumps({'error': 'unsupported version'})); sys.exit(0)
hlen = struct.unpack('>I', f.read(4))[0]
if hlen > 65536:
print(json.dumps({'error': 'header too large'})); sys.exit(0)
header = json.loads(f.read(hlen).decode('utf-8'))
body = f.read()
if header.get('kind') != 'master-key-export':
print(json.dumps({'error': f"not a master-key-export bundle (kind={header.get('kind')})"})); sys.exit(0)
mk = {
'version': header.get('master_key_version', 2),
'salt': header['salt'],
'nonce': header['nonce'],
'ciphertext': base64.b64encode(body).decode('ascii'),
'auth_method': header.get('auth_method', 'WebAuthnPrf'),
}
cid = header.get('webauthn_credential_id')
if cid:
mk['webauthn_credential_id'] = cid
with open(dst, 'w') as f:
json.dump(mk, f, indent=2)
print(json.dumps({'ok': True, 'path': dst, 'created': header.get('created', 'unknown')}))
PYEOF
local result
result=$(python3 "$unwrap_script" "$bundle" "$tmp_master" 2>&1)
local rc=$?
rm -f "$unwrap_script"
if [ $rc -ne 0 ] || ! echo "$result" | jq -e '.ok' >/dev/null 2>&1; then
echo -e "${RED}Failed to unwrap bundle:${NC}" >&2
echo "$result" >&2
rm -f "$tmp_master"
return 1
fi
local created
created=$(echo "$result" | jq -r '.created')
echo -e "${GREEN}Bundle parsed — created ${created}${NC}"
local target="${CONFIG_DIR}/master.key"
if [ -f "$target" ]; then
echo
echo -e "${YELLOW}A master.key already exists at ${target}${NC}"
echo -e "${YELLOW}Overwriting will make the local vault unusable until${NC}"
echo -e "${YELLOW}the recovered key is unlocked with its authenticator.${NC}"
if [ -n "${SCRT4_YES:-}" ]; then
echo -e "${YELLOW}(SCRT4_YES set — proceeding without prompt)${NC}"
else
echo -n "Overwrite? [y/N] "
local ans; read -r ans
if [ "$ans" != "y" ] && [ "$ans" != "Y" ]; then
echo "Aborted. Recovered key left at: $tmp_master"
return 1
fi
fi
cp "$target" "${target}.pre-recover.$(date +%s)" 2>/dev/null || true
fi
mkdir -p "$CONFIG_DIR"
mv "$tmp_master" "$target"
chmod 600 "$target"
echo -e "${GREEN}Wrote ${target}${NC}"
echo
echo -e "${CYAN}Next step:${NC}"
echo " scrt4 unlock # tap the authenticator that made the backup"
echo
echo -e "${YELLOW}Your vault (secrets.enc) remains encrypted with this key.${NC}"
echo -e "${YELLOW}Once unlock succeeds, all secrets are accessible.${NC}"
}
cmd_backup_guide() {
cat <<'GUIDE'
╔═══════════════════════════════════════════════════════════════════╗
║ SCRT4 — BACKUP & RECOVERY GUIDE ║
╚═══════════════════════════════════════════════════════════════════╝
Full guide: https://github.com/llmsecrets/llm-secrets/blob/main/BUILD.md
HOW SCRT4 AUTHENTICATION WORKS:
scrt4 uses FIDO2/WebAuthn — your hardware authenticator (YubiKey,
phone passkey, caBLE) IS the key. There are no passwords or TOTP
codes. The master key is derived from the FIDO2 hmac-secret
extension every time you authenticate.
PRIMARY RECOVERY (you still have your authenticator):
Just re-authenticate. Your authenticator derives the same master
key every time — no backup files or passwords needed.
scrt4 unlock # tap your authenticator → session active
scrt4 backup-key # prints the master key (if you need it)
DISASTER RECOVERY (authenticator lost or broken):
You need TWO things:
1. The encrypted vault file
scrt4 backup-vault # writes scrt4-backup-DATE.tar.gz
scrt4 backup-vault --local /path/to/USB
2. The master key (one of these):
- Paper printout from `scrt4 backup-key`
- Password-encrypted file from `scrt4 backup-key --save DIR`
- Cloud copy from `scrt4 backup-key --to-drive`
WITHOUT BOTH, RECOVERY IS IMPOSSIBLE BY DESIGN.
To recover:
scrt4 recover <encrypted-master-key-instructions.json> # local file
scrt4 recover --from-drive DRIVE_FILE_ID # cloud copy
CLOUD KEY ESCROW (`--to-drive` / `--from-drive`):
`scrt4 backup-key --to-drive` wraps your local master.key file in a
SCRT4ENC envelope (no re-encryption — the file is already AES-GCM
ciphertext under your FIDO2 authenticator) and uploads via
cloud-crypt to the `claude-crypt` folder in Google Drive. One tap:
the active session covers the operation.
`scrt4 recover --from-drive DRIVE_ID` downloads the bundle, unwraps
it, and writes ~/.scrt4/master.key. Then `scrt4 unlock` with the
SAME authenticator reconstitutes the full vault (one tap).
Security: the Drive copy is never plaintext. An attacker with access
to your Drive still needs your physical authenticator to decrypt it.
The cloud-crypt module's TCB gate refuses to upload anything that
is not already SCRT4ENC ciphertext — plaintext leakage is prevented
by construction.
WHAT'S IN THE BACKUP:
`scrt4 backup-vault` archives the entire ~/.scrt4 directory:
- secrets.enc — encrypted vault (still AES-256-GCM ciphertext)
- master.key — FIDO2-wrapped master key (still wrapped)
- encrypted-inventory.json — cloud-crypt ledger: names, Drive IDs,
sizes, timestamps, tags of archives pushed
to Drive. Preserved so `scrt4 recover`
rebuilds both vault AND cloud-crypt index.
- audit.log — append-only daemon audit trail
No plaintext is written. The archive is safe at rest; the master key
is needed to decrypt anything inside it.
BACKUP BEST PRACTICES:
- Run `scrt4 backup-vault` regularly (automated or weekly)
- Run `scrt4 backup-key --save /path/to/USB` at least once
- Store the USB/paper key offline (safe, lockbox)
- Never paste the master key into a chat, email, or repo
- After recovery, re-register a new authenticator with `scrt4 setup`
- For off-site encrypted key escrow: `scrt4 backup-key --to-drive`
(re-wraps the key under AES-GCM, uploads ciphertext to Drive via
cloud-crypt; plaintext never leaves the machine).
GUIDE
}
cmd_daemon() {
local bin
if command -v scrt4-daemon >/dev/null 2>&1; then
bin=scrt4-daemon
elif [ -x "/usr/local/bin/scrt4-daemon" ]; then
bin="/usr/local/bin/scrt4-daemon"
elif [ -x "$HOME/.local/bin/scrt4-daemon" ]; then
bin="$HOME/.local/bin/scrt4-daemon"
else
echo -e "${RED}scrt4-daemon binary not found on PATH or in the usual install locations.${NC}" >&2
echo "Install it or run the daemon directly from its build output." >&2
return 1
fi
echo -e "${CYAN}Starting scrt4-daemon...${NC}"
exec "$bin" "$@"
}
cmd_list() {
ensure_unlocked || return 1
local filter_tag=""
local show_tags=false
while [ $# -gt 0 ]; do
case "$1" in
--tag) filter_tag="${2:-}"; shift 2 ;;
--tags) show_tags=true; shift ;;
*) shift ;;
esac
done
local response
response=$(send_request '{"method":"list"}')
local ok
ok=$(echo "$response" | jq -r '.success // false')
if [ "$ok" != "true" ]; then
echo -e "${RED}list failed: $(echo "$response" | jq -r '.error // "unknown"')${NC}" >&2
return 1
fi
local names
names=$(echo "$response" | jq -r '.data.names[]?' 2>/dev/null)
if [ -z "$names" ]; then
echo -e "${YELLOW}No secrets stored. Add with: scrt4 add KEY=value${NC}"
return 0
fi
if [ -z "$filter_tag" ] && [ "$show_tags" = false ]; then
echo "$names"
return 0
fi
local tags_file="${CONFIG_DIR}/tags.json"
local tags_json='{}'
if [ -f "$tags_file" ]; then
tags_json=$(cat "$tags_file")
fi
if [ -n "$filter_tag" ]; then
local filtered=""
while IFS= read -r name; do
local has
has=$(printf '%s' "$tags_json" | jq -r --arg k "$name" --arg t "$filter_tag" \
'(.[$k] // []) | map(ascii_downcase) | if index($t | ascii_downcase) then "yes" else "no" end')
if [ "$has" = "yes" ]; then
filtered+="${name}"$'\n'
fi
done <<< "$names"
names="${filtered%$'\n'}"
if [ -z "$names" ]; then
echo -e "${YELLOW}No secrets with tag '${filter_tag}'.${NC}"
return 0
fi
fi
local count
count=$(printf '%s\n' "$names" | grep -c .)
if [ -n "$filter_tag" ]; then
echo -e "${CYAN}${count} secret(s) tagged '${filter_tag}':${NC}"
else
echo -e "${CYAN}${count} secret(s):${NC}"
fi
while IFS= read -r name; do
[ -z "$name" ] && continue
if [ "$show_tags" = true ] || [ -n "$filter_tag" ]; then
local t
t=$(printf '%s' "$tags_json" | jq -r --arg k "$name" '.[$k] // [] | join(", ")')
if [ -n "$t" ]; then
echo " ${name} [${t}]"
else
echo " ${name}"
fi
else
echo "$name"
fi
done <<< "$names"
}
_regen_claude_md() {
local target="${SCRT4_CLAUDE_MD:-}"
if [ -z "$target" ]; then
if [ -d "${HOME}/.claude" ]; then
target="${HOME}/.claude/CLAUDE.md"
else
return 0
fi
fi
local resp
resp=$(send_request '{"method":"list"}' 2>/dev/null) || return 0
local ok
ok=$(echo "$resp" | jq -r '.success // false' 2>/dev/null)
[ "$ok" = "true" ] || return 0
local names
names=$(echo "$resp" | jq -r '.data.names[]?' 2>/dev/null | sort)
local today
today=$(date -u +%Y-%m-%d)
local block
block=$(
echo '<!-- scrt4:begin -->'
echo '## scrt4 vault — available secret names'
echo ''
echo 'These secrets live in the local scrt4 vault. The values are encrypted at'
echo 'rest and only decrypted into subprocess environment for the lifetime of'
echo 'one command. **Never** ask the user for these values, never echo them,'
echo 'never write them to files.'
echo ''
echo 'Use them by writing commands with `$env[NAME]` placeholders:'
echo ''
echo '```bash'
echo "scrt4 run 'curl -H \"Authorization: Bearer \$env[GITHUB_PAT]\" https://api.github.com/user'"
echo '```'
echo ''
if [ -z "$names" ]; then
echo '_No secrets stored yet. Run `scrt4 add NAME=value` to add one._'
else
echo '| Name |'
echo '|------|'
while IFS= read -r n; do
[ -z "$n" ] && continue
printf '| `%s` |\n' "$n"
done <<< "$names"
fi
echo ''
echo "_Last updated: ${today} by \`scrt4 learn\`._"
echo '<!-- scrt4:end -->'
)
local tmp
tmp=$(mktemp "${target}.tmp.XXXXXX") || return 0
if [ -f "$target" ]; then
awk -v block="$block" '
BEGIN { in_block = 0 }
/<!-- scrt4:begin -->/ { in_block = 1; print block; next }
/<!-- scrt4:end -->/ { in_block = 0; next }
!in_block { print }
' "$target" > "$tmp"
if ! grep -q '<!-- scrt4:begin -->' "$target"; then
printf '\n%s\n' "$block" >> "$tmp"
fi
else
mkdir -p "$(dirname "$target")"
printf '%s\n' "$block" > "$tmp"
fi
mv -f "$tmp" "$target"
}
cmd_learn() {
ensure_unlocked || return 1
local target="${SCRT4_CLAUDE_MD:-}"
if [ -z "$target" ]; then
if [ -d "${HOME}/.claude" ]; then
target="${HOME}/.claude/CLAUDE.md"
else
echo -e "${YELLOW}No ~/.claude directory — skipping CLAUDE.md regen.${NC}"
echo -e "${YELLOW}Set SCRT4_CLAUDE_MD=/path/to/CLAUDE.md to override.${NC}"
return 0
fi
fi
_regen_claude_md
if [ -f "$target" ]; then
local n
n=$(awk '/<!-- scrt4:begin -->/,/<!-- scrt4:end -->/' "$target" | grep -c '^| `')
echo -e "${GREEN}Updated ${target} — ${n} secret name(s) in the vault block.${NC}"
else
echo -e "${YELLOW}Could not write ${target}.${NC}"
return 1
fi
}
cmd_add() {
ensure_unlocked || return 1
if [ $# -eq 0 ]; then
if ! _has_gui; then
echo -e "${RED}Usage: scrt4 add KEY=value [KEY=value ...]${NC}" >&2
echo -e "${YELLOW}GUI mode needs zenity + DISPLAY. This distribution has neither; pass KEY=value arguments on the command line.${NC}" >&2
return 1
fi
local tmpfile
tmpfile=$(mktemp /tmp/scrt4-add-XXXXXX.txt)
cat > "$tmpfile" << 'PLACEHOLDER'
# Paste your secrets below, one per line
# Format: KEY=value
# Lines starting with # are ignored
# Example:
# API_KEY=sk-abc123
# DB_PASSWORD=mysecretpassword
PLACEHOLDER
local input
input=$(zenity --text-info --editable \
--title="scrt4 — Add Secrets" \
--width=700 --height=500 \
--font="monospace" \
--filename="$tmpfile" 2>/dev/null)
local zenity_rc=$?
rm -f "$tmpfile"
if [ $zenity_rc -ne 0 ] || [ -z "$input" ]; then
echo -e "${YELLOW}Cancelled.${NC}"
return 0
fi
local parse_log="/tmp/scrt4-add-parse-$$.log"
local secrets_json
secrets_json=$(printf '%s' "$input" | python3 -c '
import sys, json, re
lines = sys.stdin.read().splitlines()
secrets = {}
skipped = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" in stripped:
key, value = stripped.split("=", 1)
key = key.strip()
if key:
secrets[key] = value
continue
if ":" in stripped:
key, value = stripped.split(":", 1)
key = key.strip()
value = value.strip()
if key and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key):
secrets[key] = value
continue
skipped.append(stripped)
for s in skipped:
print(f"SKIPPED:{s}", file=sys.stderr)
print(f"COUNT:{len(secrets)}", file=sys.stderr)
print(json.dumps(secrets), end="")
' 2>"$parse_log")
while IFS= read -r logline; do
if [[ "$logline" == SKIPPED:* ]]; then
echo -e "${YELLOW}Skipping: ${logline#SKIPPED:}${NC}" >&2
fi
done < "$parse_log"
local count
count=$(grep '^COUNT:' "$parse_log" | head -1 | cut -d: -f2)
rm -f "$parse_log"
if [ "${count:-0}" -eq 0 ]; then
echo -e "${YELLOW}No valid KEY=value lines found.${NC}"
return 0
fi
local req
req=$(jq -nc --argjson secrets "$secrets_json" '{method:"add_secrets",params:{secrets:$secrets}}')
local resp
resp=$(send_request "$req")
local ok
ok=$(echo "$resp" | jq -r '.success // false')
if [ "$ok" = "true" ]; then
echo -e "${GREEN}Added $(echo "$resp" | jq -r '.data.count // 0') secret(s).${NC}"
_regen_claude_md 2>/dev/null || true
else
echo -e "${RED}$(echo "$resp" | jq -r '.error // "unknown error"')${NC}" >&2
return 1
fi
return 0
fi
local secrets_json='{}'
local arg
for arg in "$@"; do
if [[ "$arg" != *=* ]]; then
echo -e "${RED}Invalid entry: ${arg} (expected KEY=value)${NC}" >&2
return 1
fi
local k="${arg%%=*}"
local v="${arg#*=}"
secrets_json=$(echo "$secrets_json" | jq --arg k "$k" --arg v "$v" '. + {($k):$v}')
done
local req
req=$(jq -nc --argjson secrets "$secrets_json" '{method:"add_secrets",params:{secrets:$secrets}}')
local resp
resp=$(send_request "$req")
local ok
ok=$(echo "$resp" | jq -r '.success // false')
if [ "$ok" = "true" ]; then
echo -e "${GREEN}Added $(echo "$resp" | jq -r '.data.count // 0') secret(s).${NC}"
_regen_claude_md 2>/dev/null || true
else
echo -e "${RED}$(echo "$resp" | jq -r '.error // "unknown error"')${NC}" >&2
return 1
fi
}
cmd_run() {
ensure_unlocked || return 1
local cwd="$PWD"
while [ $# -gt 0 ]; do
case "$1" in
--cwd)
[ $# -ge 2 ] || { echo "scrt4 run: --cwd needs a directory" >&2; return 1; }
cwd="$2"; shift 2 ;;
--cwd=*)
cwd="${1#--cwd=}"; shift ;;
*) break ;;
esac
done
if [ $# -eq 0 ]; then
echo "Usage: scrt4 run [--cwd DIR] 'cmd \$env[KEY]'" >&2
return 1
fi
if [ ! -d "$cwd" ]; then
echo "scrt4 run: no such directory: $cwd" >&2
return 1
fi
local cmd="$*"
_run_with_injected_secrets "$cmd" "$cwd"
}
cmd_view() {
ensure_unlocked || return 1
local cli_mode=false
if [ "${1:-}" = "--cli" ] || [ "$FORCE_CLI" = true ]; then
cli_mode=true
fi
if [ "$cli_mode" = false ] && ! _has_gui; then
cli_mode=true
fi
_wa_gate || return 1
local resp1
resp1=$(send_request '{"method":"reveal_all"}')
local ok1
ok1=$(echo "$resp1" | jq -r '.success // false')
if [ "$ok1" != "true" ]; then
echo -e "${RED}view failed: $(echo "$resp1" | jq -r '.error // "unknown"')${NC}" >&2
return 1
fi
local challenge code
challenge=$(echo "$resp1" | jq -r '.data.challenge')
code=$(echo "$resp1" | jq -r '.data.code')
local resp2
resp2=$(send_request "$(jq -nc --arg c "$challenge" --arg k "$code" '{method:"reveal_all_confirm",params:{challenge:$c,code:$k}}')")
local ok2
ok2=$(echo "$resp2" | jq -r '.success // false')
if [ "$ok2" != "true" ]; then
echo -e "${RED}view confirm failed: $(echo "$resp2" | jq -r '.error // "unknown"')${NC}" >&2
return 1
fi
local count
count=$(echo "$resp2" | jq -r '.data.secrets | length // 0')
if [ "${count:-0}" -eq 0 ] 2>/dev/null; then
echo -e "${YELLOW}No secrets stored. Add with: scrt4 add KEY=value${NC}"
return 0
fi
local secrets
secrets=$(echo "$resp2" | jq -r '.data.secrets | to_entries | sort_by(.key) | .[] | "\(.key)=\(.value)"')
if [ "$cli_mode" = true ]; then
echo ""
printf '%s\n' "$secrets"
echo ""
secrets="[CLEARED]"
resp2="[CLEARED]"
return 0
fi
local edited
edited=$(zenity --text-info --title="scrt4 — View All" \
--editable --width=800 --height=600 \
--font="monospace" \
--ok-label="Save" \
<<< "$secrets" 2>/dev/null)
local zenity_rc=$?
if [ $zenity_rc -ne 0 ]; then
secrets="[CLEARED]"; resp2="[CLEARED]"; return 0
fi
if [ -n "$edited" ]; then
local save_json save_count parse_log
parse_log="/tmp/scrt4-view-parse-$$.log"
save_json=$(printf '%s' "$edited" | python3 -c '
import sys, json, re
lines = sys.stdin.read().splitlines()
secrets = {}
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" in stripped:
key, value = stripped.split("=", 1)
key = key.strip()
if key:
secrets[key] = value
print(f"COUNT:{len(secrets)}", file=sys.stderr)
print(json.dumps(secrets), end="")
' 2>"$parse_log")
save_count=$(grep '^COUNT:' "$parse_log" | head -1 | cut -d: -f2)
rm -f "$parse_log"
if [ "${save_count:-0}" -gt 0 ]; then
local save_resp
save_resp=$(send_request "$(jq -nc --argjson s "$save_json" '{method:"add_secrets",params:{secrets:$s}}')")
local save_ok
save_ok=$(echo "$save_resp" | jq -r '.success // false')
if [ "$save_ok" = "true" ]; then
echo -e "${GREEN}Saved ${save_count} secret(s).${NC}"
_regen_claude_md 2>/dev/null || true
else
echo -e "${RED}Save failed: $(echo "$save_resp" | jq -r '.error // "unknown"')${NC}" >&2
fi
fi
fi
secrets="[CLEARED]"; edited="[CLEARED]"; resp2="[CLEARED]"
}
_parse_global_flags() {
local -a passthrough=()
while [ $# -gt 0 ]; do
case "$1" in
--cli)
FORCE_CLI=true
shift
;;
*)
passthrough+=("$1")
shift
;;
esac
done
printf '%s\n' "${passthrough[@]}"
}
cmd_llm() {
local format="text"
while [ $# -gt 0 ]; do
case "$1" in
--json) format="json"; shift ;;
--help|-h) echo "Usage: scrt4 llm [--json]"; return 0 ;;
*) shift ;;
esac
done
local status_resp unlocked="unknown"
status_resp=$(send_request '{"method":"status"}' 2>/dev/null || true)
if [ -n "$status_resp" ]; then
unlocked=$(echo "$status_resp" | jq -r '.data.unlocked // false' 2>/dev/null || echo "unknown")
fi
local -a modules=()
declare -F scrt4_module_cloud_crypt_register >/dev/null 2>&1 && modules+=("cloud-crypt")
declare -F scrt4_module_encrypt_folder_register >/dev/null 2>&1 && modules+=("encrypt-folder")
declare -F scrt4_module_import_env_register >/dev/null 2>&1 && modules+=("import-env")
declare -F scrt4_module_menu_register >/dev/null 2>&1 && modules+=("menu")
declare -F scrt4_module_wallet_register >/dev/null 2>&1 && modules+=("wallet")
declare -F scrt4_module_github_register >/dev/null 2>&1 && modules+=("github")
declare -F scrt4_module_stripe_register >/dev/null 2>&1 && modules+=("stripe")
declare -F scrt4_module_domain_register >/dev/null 2>&1 && modules+=("domain")
declare -F scrt4_module_gcp_register >/dev/null 2>&1 && modules+=("gcp")
declare -F scrt4_module_website_register >/dev/null 2>&1 && modules+=("website")
declare -F scrt4_module_messages_register >/dev/null 2>&1 && modules+=("messages")
declare -F scrt4_module_wizards_register >/dev/null 2>&1 && modules+=("wizards")
declare -F scrt4_module_quickstart_register >/dev/null 2>&1 && modules+=("quickstart")
local cc_auth="not configured"
if declare -F _scrt4_cc_resolve_oauth_name >/dev/null 2>&1; then
local cc_name; cc_name=$(_scrt4_cc_resolve_oauth_name)
if [ -n "${SCRT4_CC_DRIVE_TOKEN:-}" ]; then
cc_auth="env override (SCRT4_CC_DRIVE_TOKEN)"
elif [ "$unlocked" = "true" ] && declare -F _scrt4_cc_has_vault_oauth >/dev/null 2>&1 && _scrt4_cc_has_vault_oauth; then
cc_auth="vault OAuth ($cc_name)"
elif command -v gcloud >/dev/null 2>&1 && gcloud auth application-default print-access-token >/dev/null 2>&1; then
cc_auth="gcloud ADC"
fi
fi
if [ "$format" = "json" ]; then
jq -nc \
--arg version "$VERSION" \
--arg unlocked "$unlocked" \
--arg cc_auth "$cc_auth" \
--argjson modules "$(printf '%s\n' "${modules[@]}" | jq -R . | jq -sc .)" \
'{
name: "scrt4",
version: $version,
unlocked: ($unlocked == "true"),
modules: $modules,
capabilities: {
cloud_crypt_auth: $cc_auth
},
setup_commands: {
register_passkey: "scrt4 setup agent",
unlock: "scrt4 unlock",
cloud_crypt_auth_gws: "scrt4 cloud-crypt auth setup --from-gws",
cloud_crypt_auth_existing: "scrt4 cloud-crypt auth setup --from-secret personal_google_workspace",
cloud_crypt_auth_paste: "scrt4 cloud-crypt auth setup --paste",
cloud_crypt_auth_guide: "scrt4 cloud-crypt auth guide"
}
}'
return 0
fi
cat <<LLMEOF
# scrt4 — LLM capability map (llms.txt)
> Runtime secrets engine with FIDO2/WebAuthn-bound encryption.
> Single-user CLI + background daemon. Secrets are injected into a
> subprocess's environment at run time. LLM agents can USE them via
> \$env[NAME] placeholders but cannot SEE their values — the daemon
> substitutes values only inside the subprocess and redacts all
> stored-secret values from that subprocess's stdout/stderr before
> returning output.
## Current state
- version: $VERSION
- unlocked: $unlocked
- modules: ${modules[*]:-(none)}
## First-time user onboarding (agents: follow this order)
If the user is new to scrt4 or their session is locked, walk them through:
1. \`scrt4 setup\` — register a FIDO2 passkey (once per machine).
2. \`scrt4 unlock\` — open a session.
3. Run \`scrt4 llm\` again now that the session is live to see vault state.
4. Set up **encrypted Google Drive backup** (see cloud-crypt below). The
ciphertext goes to Drive; Google cannot read it. This is the recommended
disaster-recovery path — prioritize it over plain \`backup-key\`.
5. Save a master-key recovery copy: \`scrt4 backup-key --save \$HOME/Desktop\`,
then move it into a password manager — do not leave it on disk.
6. Offer to import existing \`.env\` files (see below). Ask first before
scanning the filesystem.
## Setup commands (for agents)
scrt4 llm # Print this doc
scrt4 llm --json # Same, machine-readable
## Importing existing .env files (import-env module)
Plaintext \`.env\` files on disk should move into scrt4. With user consent:
scrt4 import path/to/.env
Parser handles \`export KEY=value\`, quoted values, and \`#\` comments.
After a successful import, suggest deleting the plaintext file (confirm
first — some tooling still reads \`.env\` directly).
Finding candidate files (paths only, never content):
find \$HOME/your/projects -name '.env' -o -name '.env.local' \\
-o -name '.env.production' 2>/dev/null
## Core commands (always available)
scrt4 setup [agent] # Register a WebAuthn passkey (first-time only)
scrt4 unlock [ttl] # Authenticate & open a session (default 2h)
scrt4 status # Session status
scrt4 list # List secret NAMES (never values)
scrt4 add KEY=value ... # Add one or more secrets
scrt4 run 'cmd \$env[K]' # Run a command with secret injection
scrt4 view [--cli] # View secrets (GUI-only by default)
scrt4 logout # Lock the session
## Secret-injection contract (IMPORTANT for agents)
Write \`\$env[NAME]\` literally (NOT \`\$NAME\`, NOT \`\${NAME}\`) inside
the command string passed to \`scrt4 run\`. Example:
scrt4 run 'curl -H "Authorization: Bearer \$env[API_KEY]" https://api.example.com'
The daemon replaces \`\$env[API_KEY]\` with the literal secret value
before spawning the shell. Values never appear in argv of any child
process of this CLI and are scrubbed from stdout before return.
## Optional capabilities
### cloud-crypt — encrypted Google Drive backup
Status: ${cc_auth}
Push/pull/encrypt-and-push .scrt4 ciphertext archives to the user's
personal Google Drive. All crypto lives in Core (TCB); the module
only moves ciphertext + metadata. Needs a Drive-scoped OAuth token.
Setup paths (pick one):
scrt4 cloud-crypt auth setup --from-gws
Uses Google Workspace CLI (\`gws\`). Fastest — handles the
browser-consent + refresh-token dance automatically.
scrt4 cloud-crypt auth setup --from-secret personal_google_workspace
Reuses an existing OAuth blob already in the vault (e.g.
the \`personal_google_workspace\` secret). Nothing is copied;
cloud-crypt is pointed at the existing secret via
~/.scrt4/cloud-crypt.conf. Zero browser hops.
scrt4 cloud-crypt auth setup --paste
Prompts for the blob interactively. Format:
{client_id:X,client_secret:Y,refresh_token:Z,token_uri:...}
scrt4 cloud-crypt auth guide
Prints the full step-by-step walkthrough (install gws,
create OAuth client, enable APIs, run consent flow).
scrt4 cloud-crypt auth status
Shows which token source is currently active.
Commands (after setup):
scrt4 cloud-crypt list
scrt4 cloud-crypt encrypt-and-push PATH [PATH...]
scrt4 cloud-crypt push FILE.scrt4 [--yes]
scrt4 cloud-crypt pull DRIVE_ID [--out DIR] [--yes]
scrt4 cloud-crypt decrypt DRIVE_ID [--out DIR] [--yes]
### Other modules (if loaded)
Each module has its own subcommand set. Run \`scrt4 help\` for the
full list on this build, or \`scrt4 <module> help\` for per-module
help. Modules present in this build: ${modules[*]:-(none)}
## Agent-friendly flags
--json Machine-readable output on list/status/where
--yes / -y Skip confirmations (required in non-interactive shells)
--dry-run Preview a write without performing it
## Trust model (short version)
1. LLM agents never see secret values.
2. LLM agents CAN use secrets via \$env[NAME] in \`scrt4 run\`.
3. Vault is AES-256-GCM encrypted at rest.
4. Master key is hardware-bound (FIDO2 hmac-secret on a passkey/YubiKey).
5. Daemon scrubs known secret values from all subprocess output.
## Quick prompt for users
If you are Claude and the user asks to "set up scrt4" or "configure
scrt4 cloud storage", walk them through the onboarding steps above
(\`scrt4 setup\` → \`scrt4 unlock\` → cloud-crypt backup → import). For
a non-interactive audit, run:
scrt4 llm --json
LLMEOF
}
_scrt4_channel_base() {
case "$1" in
public) printf 'https://install.llmsecrets.com' ;;
*) return 1 ;;
esac
}
cmd_upgrade() {
local channel="public" want_version="" check_only=false force=false
while [ $# -gt 0 ]; do
case "$1" in
--channel) channel="${2:-}"; shift 2 ;;
--channel=*) channel="${1#--channel=}"; shift ;;
--version) want_version="${2:-}"; shift 2 ;;
--version=*) want_version="${1#--version=}"; shift ;;
--check) check_only=true; shift ;;
--force) force=true; shift ;;
*) echo -e "${RED}upgrade: unknown argument: $1${NC}" >&2
echo "Usage: scrt4 upgrade [--channel NAME] [--version TAG] [--check] [--force]" >&2
return 1 ;;
esac
done
local base
if ! base=$(_scrt4_channel_base "$channel"); then
echo -e "${RED}upgrade: unknown channel: ${channel}${NC}" >&2
echo "Available: public" >&2
return 1
fi
base="${SCRT4_RELEASE_HOST:-$base}"
local target="$want_version"
if [ -z "$target" ]; then
target=$(curl -fsSL --max-time 20 "${base}/releases/latest.txt" 2>/dev/null | tr -d '[:space:]')
if [ -z "$target" ]; then
echo -e "${RED}upgrade: could not read the published version from ${base}.${NC}" >&2
return 1
fi
fi
local current="v${VERSION#v}"
local wanted="v${target#v}"
echo -e "${CYAN}Installed:${NC} ${current}"
echo -e "${CYAN}Published:${NC} ${wanted} (${channel})"
if [ "$current" = "$wanted" ] && [ "$force" != true ]; then
echo -e "${GREEN}Already up to date.${NC}"
return 0
fi
if [ "$current" != "$wanted" ] && [ "$force" != true ] && [ -z "$want_version" ]; then
local lower
lower=$(printf '%s\n%s\n' "${current#v}" "${wanted#v}" | sort -V 2>/dev/null | head -1)
if [ "$lower" = "${wanted#v}" ]; then
echo -e "${YELLOW}The ${channel} channel is behind this build — not downgrading.${NC}"
echo -e "${YELLOW}Use --version ${wanted} to install it anyway.${NC}"
return 0
fi
fi
if [ "$check_only" = true ]; then
echo -e "${YELLOW}Update available. Run: scrt4 upgrade${NC}"
return 0
fi
local os arch
case "$(uname -s 2>/dev/null)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) echo -e "${RED}upgrade: unsupported OS.${NC}" >&2; return 1 ;;
esac
case "$(uname -m 2>/dev/null)" in
x86_64|amd64) arch=x86_64 ;;
aarch64|arm64) arch=aarch64 ;;
*) echo -e "${RED}upgrade: unsupported architecture.${NC}" >&2; return 1 ;;
esac
[ "$os" = darwin ] && arch=aarch64
local sha_cmd=""
if command -v sha256sum >/dev/null 2>&1; then
sha_cmd="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
sha_cmd="shasum -a 256"
else
echo -e "${RED}upgrade: no sha256sum or shasum — refusing to install unverified binaries.${NC}" >&2
return 1
fi
local cli_path="${BASH_SOURCE[0]:-$0}"
if command -v readlink >/dev/null 2>&1; then
local resolved
resolved=$(readlink -f "$cli_path" 2>/dev/null || true)
[ -n "$resolved" ] && cli_path="$resolved"
fi
local install_dir
install_dir=$(dirname "$cli_path")
local daemon_path="${install_dir}/scrt4-daemon"
if [ ! -w "$install_dir" ]; then
echo -e "${RED}upgrade: ${install_dir} is not writable.${NC}" >&2
echo -e "${YELLOW}Re-run with the permissions that installed scrt4.${NC}" >&2
return 1
fi
local tmp
tmp=$(mktemp -d "${TMPDIR:-/tmp}/scrt4-upgrade.XXXXXX") || return 1
trap "rm -rf '$tmp'" RETURN
local rel="${base}/releases/${wanted}"
local daemon_file="scrt4-daemon-${os}-${arch}"
echo -e "${CYAN}Downloading:${NC} ${rel}"
if ! curl -fsSL --max-time 300 "${rel}/scrt4" -o "${tmp}/scrt4" \
|| ! curl -fsSL --max-time 300 "${rel}/${daemon_file}" -o "${tmp}/${daemon_file}" \
|| ! curl -fsSL --max-time 60 "${rel}/SHA256SUMS" -o "${tmp}/SHA256SUMS"; then
echo -e "${RED}upgrade: download failed — nothing was changed.${NC}" >&2
return 1
fi
(
cd "$tmp" || exit 1
grep -E " [*]?(scrt4|${daemon_file})\$" SHA256SUMS > expected.sums 2>/dev/null
[ -s expected.sums ] || { echo "manifest has no entry for scrt4/${daemon_file}" >&2; exit 1; }
$sha_cmd -c expected.sums >/dev/null 2>&1
)
if [ $? -ne 0 ]; then
echo -e "${RED}upgrade: checksum verification FAILED — nothing was changed.${NC}" >&2
return 1
fi
echo -e "${GREEN}Checksums verified.${NC}"
chmod 755 "${tmp}/scrt4" "${tmp}/${daemon_file}"
mv -f "${tmp}/scrt4" "${cli_path}.new" && mv -f "${cli_path}.new" "$cli_path"
mv -f "${tmp}/${daemon_file}" "${daemon_path}.new" && mv -f "${daemon_path}.new" "$daemon_path"
echo -e "${GREEN}Installed ${wanted} to ${install_dir}.${NC}"
if command -v systemctl >/dev/null 2>&1 && systemctl --user is-enabled scrt4-daemon.service >/dev/null 2>&1; then
systemctl --user restart scrt4-daemon.service 2>/dev/null \
&& echo -e "${GREEN}Restarted scrt4-daemon.service.${NC}" \
|| echo -e "${YELLOW}Restart scrt4-daemon.service to finish.${NC}"
elif [ "$os" = darwin ] && command -v launchctl >/dev/null 2>&1; then
local plist="$HOME/Library/LaunchAgents/com.llmsecrets.scrt4-daemon.plist"
if [ -f "$plist" ]; then
launchctl unload "$plist" 2>/dev/null || true
launchctl load "$plist" 2>/dev/null \
&& echo -e "${GREEN}Reloaded the scrt4 launch agent.${NC}" \
|| echo -e "${YELLOW}Reload the scrt4 launch agent to finish.${NC}"
fi
else
echo -e "${YELLOW}Restart scrt4-daemon to finish.${NC}"
fi
echo -e "${YELLOW}Your session was not affected; run 'scrt4 status' to confirm.${NC}"
return 0
}
cmd_verify_self() {
local release_host="${SCRT4_RELEASE_HOST:-https://install.llmsecrets.com}"
local bin_path="${BASH_SOURCE[0]:-$0}"
if [ ! -f "$bin_path" ]; then
bin_path=$(command -v scrt4 2>/dev/null || true)
fi
if command -v readlink >/dev/null 2>&1; then
local resolved
resolved=$(readlink -f "$bin_path" 2>/dev/null || true)
[ -n "$resolved" ] && bin_path="$resolved"
fi
echo -e "${CYAN}Verifying scrt4 binary:${NC} ${bin_path}"
echo -e "${CYAN}Expected version:${NC} ${VERSION}"
local sha_cmd=""
if command -v sha256sum >/dev/null 2>&1; then
sha_cmd="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
sha_cmd="shasum -a 256"
else
echo -e "${RED}No sha256sum or shasum available — cannot verify.${NC}" >&2
return 1
fi
local local_hash
local_hash=$($sha_cmd "$bin_path" 2>/dev/null | awk '{print $1}')
if [ -z "$local_hash" ] || [ ${#local_hash} -ne 64 ]; then
echo -e "${RED}Failed to compute SHA256 of ${bin_path}.${NC}" >&2
return 1
fi
echo -e "${CYAN}Local hash:${NC} ${local_hash}"
local tag_with_v="v${VERSION#v}"
local sums_url="${release_host}/releases/${tag_with_v}/SHA256SUMS"
echo -e "${CYAN}Fetching:${NC} ${sums_url}"
local manifest
manifest=$(curl -fsSL --max-time 20 "$sums_url" 2>/dev/null || true)
if [ -z "$manifest" ]; then
local fallback_url="${release_host}/releases/${VERSION}/SHA256SUMS"
if [ "$fallback_url" != "$sums_url" ]; then
manifest=$(curl -fsSL --max-time 20 "$fallback_url" 2>/dev/null || true)
[ -n "$manifest" ] && sums_url="$fallback_url"
fi
fi
if [ -z "$manifest" ]; then
echo -e "${RED}Could not fetch ${sums_url}.${NC}" >&2
echo -e "${YELLOW}Check your network, or confirm ${VERSION} has been published.${NC}" >&2
return 1
fi
local expected_hash
expected_hash=$(printf '%s\n' "$manifest" | awk '$2 == "scrt4" || $2 == "*scrt4" {print $1; exit}')
if [ -z "$expected_hash" ]; then
echo -e "${RED}Manifest has no entry for 'scrt4'.${NC}" >&2
echo -e "${YELLOW}Published entries:${NC}" >&2
printf '%s\n' "$manifest" >&2
return 1
fi
echo -e "${CYAN}Expected hash:${NC} ${expected_hash}"
if [ "$local_hash" = "$expected_hash" ]; then
echo -e "${GREEN}✓ Match — this binary is the published ${VERSION} release.${NC}"
return 0
fi
echo -e "${RED}✗ Mismatch — local binary does not match ${VERSION}.${NC}" >&2
echo -e "${YELLOW}This could mean:${NC}" >&2
echo -e " - you are running a locally-patched or development build (fine for contributors)" >&2
echo -e " - your \$PATH is pointing at a different binary than you expect" >&2
echo -e " - the release tag has been rotated — re-install to get the latest" >&2
return 1
}
main_dispatch() {
_register_command help cmd_help
_register_command daemon cmd_daemon
_register_command status cmd_status
_register_command setup cmd_setup
_register_command unlock cmd_unlock
_register_command extend cmd_extend
_register_command logout cmd_logout
_register_command lock cmd_logout
_register_command clear cmd_logout
_register_command list cmd_list
_register_command add cmd_add
_register_command learn cmd_learn
_register_command run cmd_run
_register_command view cmd_view
_register_command rotate cmd_rotate
_register_command backup-vault cmd_backup_vault
_register_command backup-key cmd_backup_key
_register_command recover cmd_recover
_register_command recover-key cmd_recover_key
_register_command backup-guide cmd_backup_guide
_register_command list-encrypted cmd_list_encrypted
_register_command cleanup-encrypted cmd_cleanup_encrypted
_register_command llm cmd_llm
_register_command upgrade cmd_upgrade
_register_command verify-self cmd_verify_self
_modules_init
if [ $# -eq 0 ]; then
cmd_help
return 0
fi
local raw_cmd="$1"
shift
local -a rest=()
while [ $# -gt 0 ]; do
case "$1" in
--cli) FORCE_CLI=true; shift ;;
*) rest+=("$1"); shift ;;
esac
done
case "$raw_cmd" in
help|--help|-h) cmd_help; return 0 ;;
--version|-v) echo "scrt4 v${VERSION}"; return 0 ;;
esac
local handler
if handler=$(_resolve_command "$raw_cmd"); then
"$handler" "${rest[@]}"
return $?
fi
echo -e "${RED}Unknown command: ${raw_cmd}${NC}" >&2
echo "Run: scrt4 help" >&2
return 1
}
main_dispatch "$@"