Skip to content
Closed
50 changes: 42 additions & 8 deletions scripts/key.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ source "$SCRIPT_DIR/lib/operator-guidance.sh"
source "$SCRIPT_DIR/lib/shquote.sh"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/roster-journal.sh"
# agmsg_sha256 -- `shasum` is absent in Git for Windows' Git Bash, and every
# digest below is either a fingerprint a human compares or an E2EE checkpoint.
# shellcheck source=lib/hash.sh
source "$SCRIPT_DIR/lib/hash.sh"

TEAMS_DIR="$CONNECTION_ROOT/teams"
CRED_ROOT="$CONNECTION_ROOT/run/remote-credentials"
Expand Down Expand Up @@ -86,12 +90,25 @@ _key_read_config_field() {
# Short, human-comparable digest of a recipient string (SSH-key-fingerprint
# style grouping) — for the H7 fingerprint-verification step:
# two people compare this same short string over a separate channel.
#
# Fails rather than returning a short string it could not compute: see the
# callers, which now take the value into a variable of its own before printing
# it. An empty fingerprint is the worst possible output here -- both people see
# the same blank and agree.
#
# THAT REFUSAL RIDES ON `set -o pipefail`, LINE 2. `agmsg_sha256` is in the
# middle of this pipeline, and `cut` and `sed` are perfectly happy with the
# empty input a failed digest leaves them: without pipefail the pipeline exits 0
# with an empty string, the caller's assignment succeeds, and the label prints
# with nothing after it. Dropping `pipefail` reddens both "no blank
# fingerprint" cases in tests/test_key.bats, which is the control for this
# paragraph -- if you are here because you want to simplify line 2, run them.
_key_fingerprint() {
printf '%s' "$1" | shasum -a 256 | cut -c1-16 | sed 's/\(....\)/\1-/g;s/-$//'
printf '%s' "$1" | agmsg_sha256 | cut -c1-16 | sed 's/\(....\)/\1-/g;s/-$//'
}

_key_fingerprint_sha256() {
printf '%s' "$1" | shasum -a 256 | awk '{print $1}'
printf '%s' "$1" | agmsg_sha256
}

# A timestamp alone collides when two epochs are minted within the same
Expand Down Expand Up @@ -321,8 +338,17 @@ cmd_generate() {
_key_write_epoch_locked "$cfg" "$(_key_epoch_json "$key_id" 0 0 "$recipient" null "$created_at")"
agmsg_lock_release

# Computed into a variable of its own, NOT inline in the echo. A command
# substitution that fails inside a simple command's arguments leaves that
# command's own status untouched, so `echo` succeeded and printed the label
# with nothing after it. A bare assignment's status IS the substitution's, so
# `set -e` stops here instead. (Same reasoning as remote.sh's `existing=` note;
# `local fp_short="$(...)"` would put the status back on the declaration and
# undo it.)
local fp_short
fp_short="$(_key_fingerprint "$recipient")"
echo "Generated a new key for team '$team'."
echo "Recipient fingerprint: $(_key_fingerprint "$recipient")"
echo "Recipient fingerprint: $fp_short"
echo
# What the key IS, always: true whoever ran this, so it is never held back.
#
Expand Down Expand Up @@ -415,8 +441,10 @@ cmd_show() {
fi

if [ "$reveal" -eq 0 ]; then
local fp_short
fp_short="$(_key_fingerprint "$recipient")"
echo "Team: $team"
echo "Recipient fingerprint: $(_key_fingerprint "$recipient")"
echo "Recipient fingerprint: $fp_short"
echo "Public recipient: $recipient"
return
fi
Expand Down Expand Up @@ -578,8 +606,10 @@ cmd_import() {
_key_write_identity_atomic "$cred_dir/$staged_key_id.key" "$identity"
agmsg_lock_release
unset identity
local fp_short
fp_short="$(_key_fingerprint "$recipient")"
echo "Imported replacement key for team '$team' (key_id=$staged_key_id)."
echo "Recipient fingerprint: $(_key_fingerprint "$recipient")"
echo "Recipient fingerprint: $fp_short"
return
fi
# Matches the existing epoch: just store this device's copy of the
Expand All @@ -598,8 +628,10 @@ cmd_import() {
fi
unset identity

local fp_short
fp_short="$(_key_fingerprint "$recipient")"
echo "Imported key for team '$team'."
echo "Recipient fingerprint: $(_key_fingerprint "$recipient")"
echo "Recipient fingerprint: $fp_short"
}

cmd_rotate() {
Expand Down Expand Up @@ -762,7 +794,7 @@ EOF
echo "agmsg: could not read the current authority-confirmed epoch snapshot." >&2
exit 1
fi
previous_snapshot_sha="$(shasum -a 256 "$previous_snapshot" | awk '{print $1}')"
previous_snapshot_sha="$(agmsg_sha256 < "$previous_snapshot")"
rm -f "$previous_snapshot"
writer_generation="$(agmsg_sqlite_mem \
"SELECT CAST('$(_agmsg_sqlesc "$(_key_read_config_field "$cfg" '$.remote_key.current.writer_generation')")' AS INTEGER) + 1;")"
Expand All @@ -783,8 +815,10 @@ EOF
fi
agmsg_lock_release

local fp_short
fp_short="$(_key_fingerprint "$recipient")"
echo "Generated replacement key for team '$team' (epoch=$next_epoch, key_id=$key_id)."
echo "Recipient fingerprint: $(_key_fingerprint "$recipient")"
echo "Recipient fingerprint: $fp_short"
echo "The private key was not written to the journal; distribute it out of band."
echo "On an interactive terminal, run:"
echo " bash $(agmsg_shq "$SKILL_DIR/scripts/key.sh") show $(agmsg_shq "$team") --key-id $(agmsg_shq "$key_id") --reveal-secret"
Expand Down
167 changes: 167 additions & 0 deletions scripts/lib/hash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,170 @@ agmsg_sha1() {
cksum | awk '{print $1}'
fi
}

# Portable SHA-256 of stdin, emitting the bare hex digest.
#
# Same absence as above -- `shasum` is not in Git for Windows' Git Bash -- and
# the same fixed order, so a given machine always answers with one tool:
# shasum -a 256 (macOS/Linux) -> sha256sum (Git Bash/Linux) -> openssl.
#
# THE LAST RESORT IS DIFFERENT ON PURPOSE, and it is the point of this helper.
# agmsg_sha1 ends in `cksum` because its callers name a socket after the digest
# and need only that the same input give the same name on the same machine.
# These callers need the opposite property. The digest is
#
# * the fingerprint two people read to each other over a separate channel to
# confirm they are talking to the key they think they are, and
# * the age-v1 checkpoint that says an epoch snapshot is the snapshot it
# claims to be.
#
# A non-cryptographic stand-in does not weaken those, it makes them say
# something untrue. So when no tool here can compute SHA-256 this FAILS, and
# every caller is expected to stop rather than carry an empty or substitute
# value forward.
#
# Do not add a `cksum` arm to "make this consistent with agmsg_sha1". The
# inconsistency is the decision.
#
# THE FAILURE IS THE HELPER'S OWN, not the caller's shell options. Written as
# `shasum -a 256 | awk …` this function's status was the status of `awk`, which
# is delighted by the empty input a failed digest hands it — so "this FAILS"
# held only because `key.sh` and `remote.sh` happen to `set -o pipefail` on
# their second line. A caller without it got an empty success and carried it
# into a fingerprint. Each tool is now run as its own command substitution with
# its status checked here, so the refusal travels with the function.
#
# WHICH ARM RUNS IS DECIDED BY PRESENCE, AND A CHOSEN ARM THAT FAILS IS THE END.
# The fallback exists for a tool that is ABSENT, not for one that is broken:
# there is no second attempt after `shasum` is found and then fails. That is
# deliberate — a machine whose `shasum` is broken has something wrong with it
# that a quiet substitution would hide — and it is asserted below, so changing
# it has to be a decision rather than a drift.
#
# The answer is then checked for being 64 lowercase hex. A tool that exits 0 and
# prints a warning, a path, or an empty line has not failed as far as `$?` is
# concerned, and this value is not a label: it is what two people read to each
# other to confirm a key, and what the age-v1 checkpoint pins a snapshot with.
# Lowercase specifically, because all three arms emit lowercase and a fourth
# that did not would leave two machines disagreeing about a fingerprint that is
# "the same".
#
# The two checks overlap on purpose and are not redundant: a tool that exits
# non-zero while still printing a well-formed digest is caught only by the
# status, and one that exits zero while printing anything else only by the
# shape. There is a case for each below.
# The arms and the shape check, without the self-test below -- so the self-test
# can call it without calling itself.
_agmsg_sha256_selected() {
local raw
if command -v shasum >/dev/null 2>&1; then
raw="$(shasum -a 256)" || return 1
raw="${raw%% *}"
elif command -v sha256sum >/dev/null 2>&1; then
raw="$(sha256sum)" || return 1
raw="${raw%% *}"
elif command -v openssl >/dev/null 2>&1; then
raw="$(openssl dgst -sha256)" || return 1
raw="${raw##* }"
else
echo "agmsg: no SHA-256 tool found on PATH (looked for shasum, sha256sum, openssl)." >&2
echo "One of these is required for key fingerprints and end-to-end-encryption checkpoints." >&2
return 1
fi
case "$raw" in
*[!0-9a-f]*|'')
echo "agmsg: the SHA-256 tool on PATH answered with something that is not a digest." >&2
return 1
;;
esac
[ "${#raw}" -eq 64 ] || {
echo "agmsg: the SHA-256 tool on PATH answered with ${#raw} characters, not a 64-hex digest." >&2
return 1
}
printf '%s\n' "$raw"
}

# ASK THE SELECTED TOOL A QUESTION WE KNOW THE ANSWER TO, BEFORE EVERY DIGEST.
#
# `_agmsg_sha256_selected` accepts any 64 lowercase hex, which is the shape of
# a digest and not the proof of one: a tool that exits 0 and prints a plausible
# but wrong value is accepted, and that value then becomes a fingerprint two
# people read to each other, or the checkpoint that says a snapshot is the
# snapshot it claims to be.
#
# The check lives HERE rather than at the callers because the alternative is a
# list of entry points that must be kept complete -- `key.sh` is its own CLI and
# `generate`, `show`, `import` and `rotate` all reach a digest without going
# anywhere near `connect`'s preflight. A list like that is exactly what was
# already missed once.
#
# RUN BEFORE EVERY DIGEST, AND NOT MEMOISED. It was, on one head, keyed on a
# shell variable -- which review took apart twice over. The flag was read
# straight from the environment, so `_AGMSG_SHA256_VERIFIED=1` in a preseeded
# environment meant "already checked" and skipped the check outright: an
# undocumented env override that turned a fail-closed contract off. And it did
# not even work: every production call is `printf | agmsg_sha256` or
# `x="$(agmsg_sha256 …)"`, both subshells, so the flag never reached the parent
# and the self-test ran again anyway. The saving was imaginary and the hole was
# not.
#
# So the cost is stated instead of avoided: one extra digest of a 5-byte input
# per digest taken. The command that takes the most is `key rotate` with an
# accepted rotation to check -- the accepted recipient's fingerprint, the new
# recipient's journal fingerprint, the previous snapshot, and the short
# fingerprint printed at the end: FOUR digests, so eight runs of the tool. Every
# one of them sits beside file and lock work that dwarfs it.
_agmsg_sha256_selftest() {
local probe
probe="$(printf '%s' probe | _agmsg_sha256_selected)" || return 1
if [ "$probe" != 'ba9c736f19e7f60b7f6764adb0b7908c0a2b394e09b6c09863528c7f2bc86095' ]; then
echo "agmsg: the SHA-256 tool on PATH returned the wrong digest for a known input." >&2
echo "Its answers cannot be used for key fingerprints or encryption checkpoints." >&2
return 1
fi
}

agmsg_sha256() {
_agmsg_sha256_selftest || return 1
_agmsg_sha256_selected
}

# True when agmsg_sha256 has something to run. Separated so a caller can ASK
# before it starts, rather than discover it at the digest.
#
# The order matters more than it looks: the first SHA-256 in a `connect --e2ee`
# comes AFTER the team has been registered with the server, so without this the
# operator's first news of a missing tool is a half-finished connect. Same
# category as the `age` check next to it -- a prerequisite of end-to-end
# encryption, not of agmsg -- and asked at the same moment.
# Probed by RUNNING it, not by `command -v`. The question is whether this
# machine can produce a SHA-256, and presence on PATH is only a proxy for that:
# a tool that is installed and fails answers "yes" to the proxy and "no" to the
# question, which is the direction that hurts -- the preflight passes and the
# digest fails later, which is the shape of #861 all over again. Costs two runs
# of the tool -- `agmsg_sha256`'s self-test and this probe's own digest -- on a
# command that is about to make a network round trip.
#
# Nothing more than "can this machine produce one", because the correctness
# question moved into `agmsg_sha256` itself -- a preflight that knows something
# the digest path does not is the shape of #861, and for one head this function
# was the only thing checking the answer while `key.sh` reached a digest by four
# routes that never call it.
agmsg_sha256_usable() {
printf '%s' probe | agmsg_sha256 >/dev/null 2>&1
}

# Refuse to proceed without one, with the same install guidance shape the age
# preflight uses.
agmsg_require_sha256() {
if ! agmsg_sha256_usable; then
echo "agmsg: end-to-end encryption needs a working SHA-256 tool, and this device has none." >&2
echo "One may be installed: a tool that is present but fails, or answers a known input" >&2
echo "wrongly, is reported here the same way as one that is absent -- neither can be used." >&2
echo "agmsg looks for 'shasum', then 'sha256sum', then 'openssl'. Install or repair one:" >&2
echo " macOS (Homebrew): brew install openssl" >&2
echo " Debian/Ubuntu: sudo apt install coreutils" >&2
echo " Windows (Git Bash): ships with Git for Windows; reinstall it if 'sha256sum' is missing" >&2
return 1
fi
}
36 changes: 35 additions & 1 deletion scripts/remote.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ source "$SCRIPT_DIR/lib/node.sh"
# keyed on watcher-only concepts (session/actas) this engine does not have.
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/instance-id.sh"
# agmsg_sha256 -- the age-v1 checkpoint below is a SHA-256 of the snapshot, and
# `shasum` is absent in Git for Windows' Git Bash.
# shellcheck source=lib/hash.sh
source "$SCRIPT_DIR/lib/hash.sh"

TEAMS_DIR="$CONNECTION_ROOT/teams"
CRED_ROOT="$CONNECTION_ROOT/run/remote-credentials"
Expand Down Expand Up @@ -249,6 +253,26 @@ cmd_doctor() {
echo "See https://github.com/FiloSottile/age for other install methods."
fi
echo
# Reported with age, and optional for the same reason: only end-to-end
# encryption needs it. Not failed=1 -- a machine syncing with cipher "none"
# is fully functional without it. Listed at all because when it IS missing
# the symptom lands after the team is registered, which reads like a server
# problem rather than a missing tool.
# "usable", not "on PATH": presence and usability are different questions and
# this line answers the second one -- a tool that is installed and fails, or
# that returns the wrong digest for a known input, reports unusable here.
if agmsg_sha256_usable; then
echo " [x] usable SHA-256 tool (optional)"
else
echo " [ ] usable SHA-256 tool (optional)"
echo
echo "End-to-end encryption needs one of 'shasum', 'sha256sum' or 'openssl' for key"
echo "fingerprints and the age-v1 checkpoint. Remote sync without --e2ee does not use it:"
echo " macOS (Homebrew): brew install openssl"
echo " Debian/Ubuntu: sudo apt install coreutils"
echo " Windows (Git Bash): ships with Git for Windows; reinstall it if 'sha256sum' is missing"
fi
echo
if agmsg_python3_usable; then
echo " [x] python3 on PATH"
else
Expand Down Expand Up @@ -1821,7 +1845,7 @@ _remote_configure_keyed_team() {
echo "agmsg: could not export the initial age-v1 snapshot for team '$team'; sync was not started." >&2
return 1
fi
snapshot_sha="$(shasum -a 256 "$snapshot_file" | awk '{print $1}')"
snapshot_sha="$(agmsg_sha256 < "$snapshot_file")"
if ! bash "$SCRIPT_DIR/remote-sync.sh" configure \
--team "$team" \
--server "$endpoint" \
Expand Down Expand Up @@ -1875,6 +1899,16 @@ cmd_connect() {
;;
esac
key_id="$(_remote_read_config_field "$cfg" '$.remote_key.current.key_id')"
# Asked HERE: before the key is minted and before the registration POST.
# Every SHA-256 in the e2ee path -- the fingerprint printed by `key.sh
# generate`, and the age-v1 checkpoint that starts the sync engine -- happens
# at or after those, so a device without one used to register the team and
# only then fail, reporting "binding recorded, sync engine not started" for
# what is a missing command-line tool. Same category as the `age` preflight:
# a prerequisite of end-to-end encryption, so it is only asked under --e2ee.
if [ "$e2ee" -eq 1 ]; then
agmsg_require_sha256 || exit 1
fi
if [ "$e2ee" -eq 1 ] && { [ -z "$key_id" ] || [ "$key_id" = "null" ]; }; then
bash "$SCRIPT_DIR/key.sh" generate "$team" || exit 1
key_id="$(_remote_read_config_field "$cfg" '$.remote_key.current.key_id')"
Expand Down
Loading
Loading