fix: make custody input explicit and remove partial decryptions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
tegwick 2026-10-12 10:53:30 +02:00
parent b97f9f616f
commit 4cf26a9bc8
6 changed files with 299 additions and 79 deletions

View file

@ -77,9 +77,12 @@ Use `sso-mfa/bootstrap/sops-custody-unlock.sh` when an operator needs to run a
SOPS-backed drill, recovery, or lockdown command but the private key is only in
the password safe or offline custody packet.
The helper disables shell tracing before handling input and refuses conflicting input modes.
Noninteractive callers must select `--from-stdin` or `--from-file` explicitly.
The helper:
- reads the private age key from a hidden prompt, stdin, or a source file;
- reads the private age key from one explicit source: hidden prompt, stdin, or a source file;
- derives the public age recipient with `age-keygen -y`;
- refuses to continue unless it matches the expected recipient;
- writes a `0600` temporary `SOPS_AGE_KEY_FILE`;
@ -152,3 +155,27 @@ its expected contents without printing values, then remove decrypted files and
retain a non-secret receipt. Acknowledgment of a guide is not that verification.
No live root or password was read or rotated during the source correction.
NK-WP-0049 tracks remaining native acceptance and other legacy custody callers.
## Direct helper correction — 2026-10-12
`encrypt-secrets.sh` defaults to the registered `keys/age.pub`. It accepts a public
recipient or public-recipient file and rejects private-key files instead of
loading them to derive metadata. `decrypt-secrets.sh` accepts an explicit key
argument or the custody helper's `NETKINGDOM_CUSTODY_AGE_KEY_FILE`; it no longer
searches HOME. Successful decryption publishes a new private output directory
only after every input decrypts. Failed/empty input removes partial staging;
existing files, directories and dangling symlinks are never replaced.
The custody helper disables shell tracing, rejects repeated/mixed input modes,
does not echo unknown arguments and clears competing inline/key-command SOPS
authentication before the child starts. HUP/INT/TERM now retain failure status
and EXIT cleanup rather than running cleanup and then continuing. Bash may defer
its trap while a foreground child runs; this is not a process-group supervisor,
SIGKILL cleanup guarantee or a runtime authority boundary. Temporary key cleanup
on command return does not certify that an arbitrary child retained no copies.
Remaining concrete caller defects stay in NK-WP-0049-T02: Make `sops-setup` /
`creds-init` still install or require permanent HOME keys; Make `sops-decrypt`
still emits plaintext; legacy rotation and privacyIDEA bootstrap helpers contain
secret-bearing output. Do not use those legacy paths as the recovery procedure
or treat these narrower helper repairs as closure of the whole custody surface.

View file

@ -1,62 +1,48 @@
#!/usr/bin/env bash
# decrypt-secrets.sh — decrypt secrets.enc/ to secrets/ using age
#
# Usage:
# ./decrypt-secrets.sh [OUTPUT_DIR] [AGE_KEY_FILE]
#
# OUTPUT_DIR where to write plaintext secrets (default: ./secrets)
# AGE_KEY_FILE age private key file (default: ~/.config/net-kingdom/age.key)
#
# Decrypts all *.age files in secrets.enc/ to OUTPUT_DIR for use by
# create-secrets.sh scripts. Shred OUTPUT_DIR when done:
# find secrets/ -type f -exec shred -u {} \; && rm -rf secrets/
#
# The age key must be present on the machine. Keep it outside the repo:
# ~/.config/net-kingdom/age.key
# Explicit attended custody input only. Publish plaintext only after all files
# decrypt successfully into a private staging directory; never emit values.
set +x
set -euo pipefail
umask 077
fail() { printf 'ERROR: %s\n' "$1" >&2; exit 1; }
OUTPUT_DIR="${1:-./secrets}"
AGE_KEY="${2:-$HOME/.config/net-kingdom/age.key}"
AGE_KEY="${2:-${NETKINGDOM_CUSTODY_AGE_KEY_FILE:-}}"
[[ $# -le 2 ]] || fail "unsupported decrypt argument"
[[ -n "$AGE_KEY" && -f "$AGE_KEY" ]] || fail "explicit temporary custody key required"
ENC_DIR="$(dirname "$OUTPUT_DIR")/secrets.enc"
[[ -d "$ENC_DIR" && ! -L "$ENC_DIR" ]] || fail "encrypted source directory unavailable"
[[ ! -e "$OUTPUT_DIR" && ! -L "$OUTPUT_DIR" ]] || fail "output already exists"
[[ -d "$(dirname "$OUTPUT_DIR")" ]] || fail "output parent unavailable"
if [[ ! -d "$ENC_DIR" ]]; then
echo "ERROR: encrypted secrets directory not found: $ENC_DIR" >&2
echo "Expected secrets.enc/ next to the output directory." >&2
exit 1
fi
if [[ ! -f "$AGE_KEY" ]]; then
echo "ERROR: age key not found: $AGE_KEY" >&2
echo "Copy your age key to $AGE_KEY or pass the path as the second argument." >&2
exit 1
fi
if [[ -e "$OUTPUT_DIR" ]]; then
echo "ERROR: $OUTPUT_DIR already exists. Remove it first or choose a different path." >&2
exit 1
fi
echo "Decrypting $ENC_DIR → $OUTPUT_DIR/"
echo ""
STAGING=$(mktemp -d "$(dirname "$OUTPUT_DIR")/.netkingdom-decrypt.XXXXXXXX")
cleanup() {
if [[ -d "$STAGING" ]]; then
find "$STAGING" -type f -exec shred -u {} \; 2>/dev/null || true
rm -rf -- "$STAGING"
fi
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
count=0
for component_dir in "$ENC_DIR"/*/; do
for component_dir in "$ENC_DIR"/*; do
[[ -e "$component_dir" || -L "$component_dir" ]] || continue
[[ -d "$component_dir" && ! -L "$component_dir" ]] || fail "unexpected encrypted component"
component=$(basename "$component_dir")
mkdir -p "$OUTPUT_DIR/$component"
for f in "$component_dir"*.age; do
[[ -f "$f" ]] || continue
fname=$(basename "${f%.age}")
out="$OUTPUT_DIR/$component/$fname"
age -d -i "$AGE_KEY" -o "$out" "$f"
echo " decrypted: secrets.enc/$component/$(basename "$f") → $component/$fname"
mkdir "$STAGING/$component"
for file in "$component_dir"/*.age; do
[[ -e "$file" || -L "$file" ]] || continue
[[ -f "$file" && ! -L "$file" ]] || fail "unexpected encrypted input"
name=$(basename "${file%.age}")
age -d -i "$AGE_KEY" -o "$STAGING/$component/$name" "$file" 2>/dev/null || fail "decryption failed; partial plaintext removed"
count=$((count + 1))
done
done
echo ""
echo "$count file(s) decrypted to $OUTPUT_DIR/"
echo ""
echo "Use create-secrets.sh scripts, then shred:"
echo " find $OUTPUT_DIR -type f -exec shred -u {} \\; && rm -rf $OUTPUT_DIR"
[[ "$count" -gt 0 ]] || fail "no encrypted files found"
# Never replace an output created by another process during decryption.
mv -Tn -- "$STAGING" "$OUTPUT_DIR" || fail "could not publish private output"
[[ ! -d "$STAGING" ]] || fail "output appeared during decryption"
printf '%s file(s) decrypted to private output; remove plaintext after the approved operation.\n' "$count"

View file

@ -2,12 +2,11 @@
# encrypt-secrets.sh — encrypt secrets/ directory to secrets.enc/ using age
#
# Usage:
# ./encrypt-secrets.sh [SECRETS_DIR] [AGE_RECIPIENT_OR_KEY_FILE]
# ./encrypt-secrets.sh [SECRETS_DIR] [AGE_PUBLIC_RECIPIENT_OR_FILE]
#
# SECRETS_DIR plaintext secrets directory (default: ./secrets)
# AGE_RECIPIENT_OR_KEY_FILE age public key, public-key file, or private-key
# file with public-key comment
# (default: ~/.config/net-kingdom/age.key)
# AGE_PUBLIC_RECIPIENT_OR_FILE age public recipient or public-recipient file
# (default: registered keys/age.pub)
#
# Encrypts each *.env file (and pi.enc if present) to
# secrets.enc/<component>/<filename>.age. Prefer passing a public age recipient
@ -19,15 +18,18 @@
# Run after gen-secrets.sh to store secrets safely in the repo.
# Commit secrets.enc/ to git.
set +x
set -euo pipefail
umask 077
SECRETS_DIR="${1:-./secrets}"
AGE_RECIPIENT_OR_KEY="${2:-$HOME/.config/net-kingdom/age.key}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AGE_RECIPIENT_OR_KEY="${2:-$SCRIPT_DIR/../../keys/age.pub}"
NO_SHRED=false
for arg in "$@"; do [[ "$arg" == "--no-shred" ]] && NO_SHRED=true; done
if [[ ! -d "$SECRETS_DIR" ]]; then
echo "ERROR: secrets directory not found: $SECRETS_DIR" >&2
echo "ERROR: source secrets directory is unavailable." >&2
echo "Run gen-secrets.sh first." >&2
exit 1
fi
@ -39,33 +41,27 @@ resolve_recipient() {
return 0
fi
if [[ ! -f "$source" ]]; then
echo "ERROR: age recipient/key file not found: $source" >&2
echo "ERROR: public recipient file is unavailable." >&2
echo "Pass an age public recipient such as age1... or a file containing it." >&2
return 1
fi
# Never derive public metadata by loading private custody material.
if grep -q 'AGE-SECRET-KEY-' "$source"; then
echo "ERROR: encryption requires a public recipient, never a private-key file." >&2
return 1
fi
local recipient
recipient=$(grep -m1 '^age1' "$source" || true)
if [[ -n "$recipient" ]]; then
printf '%s\n' "$recipient"
return 0
fi
recipient=$(grep -m1 'public key:' "$source" | awk '{print $NF}' || true)
if [[ -n "$recipient" ]]; then
printf '%s\n' "$recipient"
return 0
fi
if grep -q 'AGE-SECRET-KEY-1' "$source"; then
recipient=$(age-keygen -y "$source" 2>/dev/null || true)
if [[ -n "$recipient" ]]; then
printf '%s\n' "$recipient"
return 0
fi
fi
echo "ERROR: could not resolve an age public recipient from $source" >&2
echo "ERROR: public recipient is missing from the supplied file." >&2
return 1
}
PUBKEY=$(resolve_recipient "$AGE_RECIPIENT_OR_KEY")
printf '' | age -r "$PUBKEY" >/dev/null 2>&1 || { echo "ERROR: invalid public recipient." >&2; exit 1; }
ENC_DIR="$(dirname "$SECRETS_DIR")/secrets.enc"
mkdir -p "$ENC_DIR"

View file

@ -4,6 +4,7 @@
# The private age key is supplied only for this invocation, validated against an
# expected public recipient, written to a 0600 temp file, and removed on exit.
set +x
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -61,20 +62,24 @@ while [[ $# -gt 0 ]]; do
;;
--from-file)
[[ $# -ge 2 ]] || fail "--from-file requires a path"
[[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode"
SOURCE_FILE="$2"
INPUT_MODE="file"
shift 2
;;
--from-file=*)
[[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode"
SOURCE_FILE="${1#*=}"
INPUT_MODE="file"
shift
;;
--from-stdin)
[[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode"
INPUT_MODE="stdin"
shift
;;
--prompt)
[[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode"
INPUT_MODE="prompt"
shift
;;
@ -92,7 +97,7 @@ while [[ $# -gt 0 ]]; do
break
;;
*)
fail "unknown argument: $1"
fail "unknown custody argument"
;;
esac
done
@ -108,10 +113,14 @@ if [[ -z "$INPUT_MODE" ]]; then
if [[ -t 0 ]]; then
INPUT_MODE="prompt"
else
INPUT_MODE="stdin"
fail "noninteractive input requires explicit --from-stdin or --from-file"
fi
fi
if [[ "$CHECK_ONLY" == false && ${#COMMAND[@]} -eq 0 && ! -t 0 ]]; then
fail "an interactive custody shell requires a terminal; supply an explicit command"
fi
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netkingdom-sops-age.XXXXXX")"
KEY_FILE="$TMP_DIR/keys.txt"
cleanup() {
@ -124,13 +133,16 @@ cleanup() {
fi
rmdir "$TMP_DIR" 2>/dev/null || true
}
trap cleanup EXIT HUP INT TERM
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
umask 077
case "$INPUT_MODE" in
file)
[[ -f "$SOURCE_FILE" ]] || fail "custody age key file not found: $SOURCE_FILE"
cp "$SOURCE_FILE" "$KEY_FILE"
[[ -f "$SOURCE_FILE" ]] || fail "custody age key source is unavailable"
cp "$SOURCE_FILE" "$KEY_FILE" 2>/dev/null || fail "custody key copy failed"
chmod 600 "$KEY_FILE"
;;
stdin)
@ -162,8 +174,6 @@ DERIVED_RECIPIENT="$(age-keygen -y "$KEY_FILE" 2>/dev/null || true)"
if [[ "$DERIVED_RECIPIENT" != "$EXPECTED_RECIPIENT" ]]; then
printf 'ERROR: custody key does not match expected recipient\n' >&2
printf ' expected: %s\n' "$EXPECTED_RECIPIENT" >&2
printf ' derived : %s\n' "$DERIVED_RECIPIENT" >&2
exit 1
fi
@ -174,6 +184,8 @@ if [[ "$CHECK_ONLY" == true ]]; then
exit 0
fi
# The supplied key must not compete with an ambient inline key or key command.
unset SOPS_AGE_KEY SOPS_AGE_KEY_CMD
export SOPS_AGE_KEY_FILE="$KEY_FILE"
export NETKINGDOM_CUSTODY_AGE_KEY_FILE="$KEY_FILE"

View file

@ -0,0 +1,178 @@
"""Disposable age keys only; subprocess logs are never asserted with raw values."""
import os
from pathlib import Path
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
BOOT = ROOT / 'sso-mfa/bootstrap'
@pytest.fixture
def custody(tmp_path):
home = tmp_path / 'home'; home.mkdir()
key = tmp_path / 'disposable.key'
subprocess.run(['age-keygen', '-o', str(key)], capture_output=True, check=True)
public = subprocess.check_output(['age-keygen', '-y', str(key)], text=True).strip()
env = {'PATH': os.environ['PATH'], 'HOME': str(home), 'TMPDIR': str(tmp_path)}
return key, public, env
def unlock(custody, *args):
_, public, env = custody
return subprocess.run(['bash', str(BOOT / 'sops-custody-unlock.sh'), '--expected-recipient', public, *args],
env=env, capture_output=True, text=True, timeout=10)
def test_custody_unknown_arguments_are_not_echoed(custody):
result = unlock(custody, 'synthetic-secret-argument')
assert result.returncode != 0
assert 'synthetic-secret-argument' not in result.stdout + result.stderr
def test_signal_preserves_failure_status_and_removes_temporary_key(custody):
key, _, env = custody
result = unlock(custody, '--from-file', str(key), '--', 'sh', '-c', 'kill -TERM "$PPID"; exit 0')
assert result.returncode == 143
assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*'))
def test_ambiguous_custody_sources_are_refused_before_child(custody, tmp_path):
key, _, _ = custody
marker = tmp_path / 'child-called'
result = unlock(custody, '--from-file', str(key), '--from-file', str(key), '--', 'touch', str(marker))
assert result.returncode != 0
assert not marker.exists()
def test_explicit_custody_check_cleans_key(custody):
key, _, env = custody
result = unlock(custody, '--from-file', str(key), '--check-only')
assert result.returncode == 0
assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*'))
def test_prompt_does_not_leak_when_shell_tracing_is_requested(custody):
import pty, select, time
key, public, env = custody
private_line = next(line for line in key.read_bytes().splitlines() if line.startswith(b'AGE-SECRET-KEY-'))
master, slave = pty.openpty()
child = subprocess.Popen(['bash', '-x', str(BOOT / 'sops-custody-unlock.sh'),
'--expected-recipient', public, '--prompt', '--check-only'],
stdin=slave, stdout=slave, stderr=slave, env=env)
os.close(slave)
captured = bytearray(); sent = False
deadline = time.monotonic() + 10
try:
while time.monotonic() < deadline:
if select.select([master], [], [], 0.05)[0]:
try: chunk = os.read(master, 65536)
except OSError: break
if not chunk: break
captured.extend(chunk)
if b'input hidden): ' in captured and not sent:
os.write(master, private_line + b'\n'); sent = True
assert child.wait(timeout=2) == 0
assert bool(private_line in captured) is False
assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*'))
finally:
if child.poll() is None: child.kill(); child.wait()
os.close(master)
def encrypt_fixture(tmp_path, public):
secrets = tmp_path / 'secrets'; (secrets / 'service').mkdir(parents=True)
(secrets / 'service/one.env').write_text('TEST_VALUE=synthetic-private-payload\n')
out = tmp_path / 'secrets.enc/service'; out.mkdir(parents=True)
subprocess.run(['age', '-r', public, '-o', str(out / 'one.env.age'), str(secrets / 'service/one.env')],
capture_output=True, check=True)
return secrets, out
def test_encryption_refuses_private_key_as_recipient(custody, tmp_path):
key, public, env = custody
secrets, _ = encrypt_fixture(tmp_path, public)
result = subprocess.run(['bash', str(BOOT / 'encrypt-secrets.sh'), str(secrets), str(key), '--no-shred'],
env=env, capture_output=True, text=True, timeout=10)
assert result.returncode != 0
assert 'public recipient' in result.stderr
assert (secrets / 'service/one.env').exists()
def test_decryption_does_not_fall_back_to_home_key(custody, tmp_path):
key, public, env = custody
encrypt_fixture(tmp_path, public)
home_key = Path(env['HOME']) / '.config/net-kingdom/age.key'
home_key.parent.mkdir(parents=True); home_key.write_bytes(key.read_bytes())
output = tmp_path / 'restored'
result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output)],
env=env, capture_output=True, text=True, timeout=10)
assert result.returncode != 0
assert not output.exists()
def test_decryption_failure_removes_partial_plaintext(custody, tmp_path):
key, public, env = custody
_, encrypted = encrypt_fixture(tmp_path, public)
(encrypted / 'two.env.age').write_bytes(b'not-a-valid-ciphertext')
output = tmp_path / 'restored'
result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)],
env=env, capture_output=True, text=True, timeout=10)
assert result.returncode != 0
assert not output.exists()
assert not list(tmp_path.glob('.netkingdom-decrypt.*'))
def test_explicit_decryption_has_private_modes_and_no_output(custody, tmp_path):
key, public, env = custody
original, _ = encrypt_fixture(tmp_path, public)
output = tmp_path / 'restored'
result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)],
env=env, capture_output=True, text=True, timeout=10)
assert result.returncode == 0
restored = output / 'service/one.env'
assert restored.read_bytes() == (original / 'service/one.env').read_bytes()
assert output.stat().st_mode & 0o077 == 0
assert restored.stat().st_mode & 0o077 == 0
assert 'synthetic-private-payload' not in result.stdout + result.stderr
def test_default_encryption_uses_registered_public_file(custody, tmp_path):
import shutil
_, public, env = custody
checkout = tmp_path / 'fixture-repo'
script_dir = checkout / 'sso-mfa/bootstrap'; script_dir.mkdir(parents=True)
shutil.copyfile(BOOT / 'encrypt-secrets.sh', script_dir / 'encrypt-secrets.sh')
(checkout / 'keys').mkdir(); (checkout / 'keys/age.pub').write_text(public + '\n')
secrets = tmp_path / 'plaintext'; (secrets / 'service').mkdir(parents=True)
(secrets / 'service/test.env').write_text('TEST=synthetic-private-payload\n')
result = subprocess.run(['bash', str(script_dir / 'encrypt-secrets.sh'), str(secrets)],
env=env, capture_output=True, text=True, timeout=10)
assert result.returncode == 0
assert not secrets.exists()
assert (tmp_path / 'secrets.enc/service/test.env.age').is_file()
assert not (Path(env['HOME']) / '.config').exists()
def test_custody_child_does_not_inherit_competing_inline_key(custody):
key, public, env = custody
env = {**env, 'SOPS_AGE_KEY': 'synthetic-competing-key', 'SOPS_AGE_KEY_CMD': 'false'}
result = unlock((key, public, env), '--from-file', str(key), '--', 'sh', '-c',
'test -z "${SOPS_AGE_KEY+x}" && test -z "${SOPS_AGE_KEY_CMD+x}" && test -f "$SOPS_AGE_KEY_FILE"')
assert result.returncode == 0
@pytest.mark.parametrize('kind', ['file', 'directory', 'dangling-symlink'])
def test_decryption_never_overwrites_existing_output(custody, tmp_path, kind):
key, public, env = custody
encrypt_fixture(tmp_path, public)
output = tmp_path / 'restored'
if kind == 'file': output.write_bytes(b'existing-output')
elif kind == 'directory': output.mkdir(); (output / 'existing').write_bytes(b'existing-output')
else: output.symlink_to(tmp_path / 'uncreated')
result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)],
env=env, capture_output=True, text=True, timeout=10)
assert result.returncode != 0
if kind == 'file': assert output.read_bytes() == b'existing-output'
elif kind == 'directory': assert (output / 'existing').read_bytes() == b'existing-output'
else: assert output.is_symlink(); assert not (tmp_path / 'uncreated').exists()

View file

@ -49,7 +49,7 @@ Evidence and exact source manifest: Custodian
```task
id: NK-WP-0049-T02
status: todo
status: progress
priority: high
state_hub_task_id: "479d6970-6516-50f5-9436-bb5b56d58aad"
```
@ -62,6 +62,27 @@ Do not treat a tty check as identity, authority or agent isolation. Reuse
CUST-WP-0073/GLAS-WP-0012 for runtime isolation and Platform for root custody;
record any further exact source defects and repair them before native acceptance.
2026-10-12 caller audit: direct encrypt/decrypt helpers and custody unlock repaired.
Eight initial regressions failed against prior source. Encryption defaults to the
registered public file and refuses private-key inputs; decryption has no HOME
fallback and publishes only complete private output from staging, preserving
existing outputs and removing partial failures. Unlock suppresses xtrace before
hidden input, rejects ambiguous modes, removes competing inline/key-command
credentials and preserves signal failure status with EXIT cleanup. Disposable
crypto and prompt/child tests cover these boundaries. Bash foreground-child
signal deferral and arbitrary-child copy retention are explicitly not solved by
this helper. Evidence: Custodian `custody-entrypoints.json`.
Concrete uncovered callers remain (T02 stays progress):
- `Makefile` sops-setup/creds-init copy or require permanent HOME private keys.
- `Makefile` sops-decrypt sends plaintext to stdout.
- `sso-mfa/bootstrap/creds-rotate.sh` logs newly generated values and embeds a
password in a printed SQL command. Its source can be repaired without executing
founder-deferred rotations; no such rotation has been run.
- `sso-mfa/k8s/privacyidea/bootstrap-admin.sh` prints the trigger-admin password.
- Delegated create/apply helpers still need full tracing/error/Secret-annotation
output review; transport capture and native effect evidence remain mandatory.
## Verify owner-approved recovery and current native callers
```task