Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**Collective memory, encrypted.**

[![tests: 424](https://img.shields.io/badge/tests-424-brightgreen?style=flat)](test/)
[![tests: 431](https://img.shields.io/badge/tests-431-brightgreen?style=flat)](test/)
![lints: 8](https://img.shields.io/badge/lints-8-blue?style=flat)
[![license: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)

Expand Down
55 changes: 25 additions & 30 deletions hooks/encryption → hooks/encryption.template
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -7,51 +7,46 @@
# commit. See KnickKnackLabs/notes#49.
set -eo pipefail

NOTES_TOOL_ROOT="__NOTES_TOOL_ROOT__"
source "$NOTES_TOOL_ROOT/lib/encryption.sh"

if ! command -v git-crypt &>/dev/null; then
echo "WARNING: git-crypt not found — skipping encryption check" >&2
exit 0
fi

staged_input=$(mktemp) || {
echo "ERROR: Failed to create encryption hook staged-path input" >&2
exit 1
}
attribute_input=$(mktemp) || {
rm -f "$staged_input"
echo "ERROR: Failed to create encryption hook attribute input" >&2
exit 1
}
trap 'rm -f "$staged_input" "$attribute_input"' EXIT

# Staged adds/copies/modifies/renames, NUL-delimited for path safety.
if ! git diff --cached --name-only --diff-filter=ACMR -z > "$staged_input"; then
echo "ERROR: Could not inspect staged paths for encryption" >&2
exit 1
fi
staged=()
while IFS= read -r -d '' path; do staged+=("$path"); done \
< <(git diff --cached --name-only --diff-filter=ACMR -z)
while IFS= read -r -d '' path; do staged+=("$path"); done < "$staged_input"
[ "${#staged[@]}" -eq 0 ] && exit 0

# Which staged paths are under an encrypted (filter=git-crypt) pattern?
# check-attr -z emits NUL-delimited <path> <attr> <value> triples.
if ! git check-attr --cached -z filter -- "${staged[@]}" > "$attribute_input"; then
echo "ERROR: Could not inspect staged encryption attributes" >&2
exit 1
fi
encrypted_staged=()
while IFS= read -r -d '' path \
&& IFS= read -r -d '' _attr \
&& IFS= read -r -d '' value; do
[ "$value" = "git-crypt" ] && encrypted_staged+=("$path")
done < <(git check-attr --cached -z filter -- "${staged[@]}")
done < "$attribute_input"
[ "${#encrypted_staged[@]}" -eq 0 ] && exit 0

# Flag any that are currently plaintext (e.g. git-crypt locked at stage time).
bad_files=()
for file in "${encrypted_staged[@]}"; do
status_output=""
status=0
if status_output=$(git-crypt status -- "$file" 2>&1); then
:
else
status=$?
fi

if grep -qi "not encrypted" <<< "$status_output"; then
bad_files+=("$file")
elif [ "$status" -ne 0 ]; then
echo "ERROR: git-crypt could not inspect staged encrypted path: $file" >&2
[ -z "$status_output" ] || printf '%s\n' "$status_output" >&2
exit 1
fi
done

if [ "${#bad_files[@]}" -gt 0 ]; then
echo "ERROR: Staged files should be encrypted but are plaintext:" >&2
printf ' %s\n' "${bad_files[@]}" >&2
echo "" >&2
echo "Run 'notes unlock' if git-crypt is locked, then re-stage." >&2
exit 1
fi
verify_encrypted_paths "${encrypted_staged[@]}"
67 changes: 58 additions & 9 deletions hooks/obfuscation.template
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,58 @@ NOTES_TOOL_ROOT="__NOTES_TOOL_ROOT__"
MISE_BIN="__MISE_BIN__"
MODE="${NOTES_OBFUSCATE_HOOK:-auto}"

source "$NOTES_TOOL_ROOT/lib/obfuscate.sh"

run_notes() {
local repo_root
repo_root="$(git rev-parse --show-toplevel)"
export NOTES_CALLER_PWD="$repo_root"
(cd "$NOTES_TOOL_ROOT" && "$MISE_BIN" run -q "$@")
}

if [ -f "$NOTES_ROOT/.manifest" ]; then
unobfuscated=()
build_staged_obfuscation_plan() {
local candidates="$1" plan="$2"
local staged="$candidates.staged"
local file relpath
: > "$candidates"

if ! git diff --cached --name-only --diff-filter=ACMR > "$staged"; then
rm -f "$staged"
return 1
fi
while IFS= read -r file; do
[[ "$file" != "$NOTES_ROOT/"* ]] && continue
base=$(basename "$file")
[[ "$base" == ".manifest" ]] && continue
# Check if basename is a known ID in the manifest
if ! grep -q "^${base} " "$NOTES_ROOT/.manifest"; then
unobfuscated+=("$file")
fi
done < <(git diff --cached --name-only --diff-filter=ACMR)
relpath="${file#"$NOTES_ROOT/"}"
[[ "$relpath" == ".manifest" ]] && continue
printf '%s\n' "$relpath" >> "$candidates"
done < "$staged"
rm -f "$staged"

classify_obfuscation_candidates \
"$NOTES_ROOT" "$NOTES_ROOT/.manifest" "$candidates" "$plan"
}

if [ -f "$NOTES_ROOT/.manifest" ]; then
candidates=$(mktemp) || {
echo "ERROR: Failed to create obfuscation hook candidates" >&2
exit 1
}
plan=$(mktemp) || {
rm -f "$candidates"
echo "ERROR: Failed to create obfuscation hook plan" >&2
exit 1
}
trap 'rm -f "$candidates" "$plan"' EXIT

if ! build_staged_obfuscation_plan "$candidates" "$plan"; then
echo "ERROR: Could not classify staged note filenames" >&2
exit 1
fi

unobfuscated=()
while IFS=$'\t' read -r _kind _id relpath; do
[ -n "$relpath" ] && unobfuscated+=("$NOTES_ROOT/$relpath")
done < "$plan"

if [ "${#unobfuscated[@]}" -gt 0 ]; then
if [ "$MODE" = "auto" ]; then
Expand All @@ -35,6 +69,21 @@ if [ -f "$NOTES_ROOT/.manifest" ]; then
# `notes` command happens to appear first on PATH.
# Pass only the staged unobfuscated files.
run_notes obfuscate "${unobfuscated[@]}" >&2

# Auto mode must prove the index no longer contains readable note paths.
# A staged file can be absent from the working tree and therefore cannot
# be repaired by obfuscation; fail rather than committing that path.
if ! build_staged_obfuscation_plan "$candidates" "$plan"; then
echo "ERROR: Could not verify staged note filenames" >&2
exit 1
fi
if [ -s "$plan" ]; then
echo "ERROR: Auto-obfuscation left staged non-obfuscated filenames:" >&2
while IFS=$'\t' read -r _kind _id relpath; do
[ -n "$relpath" ] && printf ' %s/%s\n' "$NOTES_ROOT" "$relpath" >&2
done < "$plan"
exit 1
fi
else
echo "ERROR: Staged notes have non-obfuscated filenames:" >&2
printf ' %s\n' "${unobfuscated[@]}" >&2
Expand Down
82 changes: 82 additions & 0 deletions lib/encryption.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# encryption.sh — staged encrypted-path validation

# Verify that indexed blobs for encrypted paths are encrypted.
# The common path checks all paths in one git-crypt call. If that call reports
# plaintext or fails, inspect paths individually so diagnostics remain precise
# and unexpected backend failures remain fail closed.
# Usage: verify_encrypted_paths <path...>
verify_encrypted_paths() {
local encrypted_paths=("$@")
local batch_output batch_status=0 marker_status=0

[ "${#encrypted_paths[@]}" -gt 0 ] || return 0

if batch_output=$(git-crypt status -- "${encrypted_paths[@]}" 2>&1); then
:
else
batch_status=$?
fi

# git-crypt reports plaintext paths in human output but still exits zero.
# Use the same marker as the existing per-path contract only to select the
# precise fallback; do not parse path names from batched output.
grep -qi "not encrypted" <<< "$batch_output" || marker_status=$?
case "$marker_status" in
0) ;;
1)
[ "$batch_status" -eq 0 ] && return 0
;;
*)
echo "ERROR: could not interpret git-crypt inspection output" >&2
[ -z "$batch_output" ] || printf '%s\n' "$batch_output" >&2
return 1
;;
esac

local bad_files=()
local file status_output status
for file in "${encrypted_paths[@]}"; do
status_output=""
status=0
marker_status=0
if status_output=$(git-crypt status -- "$file" 2>&1); then
:
else
status=$?
fi

grep -qi "not encrypted" <<< "$status_output" || marker_status=$?
case "$marker_status" in
0)
bad_files+=("$file")
;;
1)
if [ "$status" -ne 0 ]; then
echo "ERROR: git-crypt could not inspect staged encrypted path: $file" >&2
[ -z "$status_output" ] || printf '%s\n' "$status_output" >&2
return 1
fi
;;
*)
echo "ERROR: could not interpret git-crypt inspection output for: $file" >&2
[ -z "$status_output" ] || printf '%s\n' "$status_output" >&2
return 1
;;
esac
done

if [ "${#bad_files[@]}" -gt 0 ]; then
echo "ERROR: Staged files should be encrypted but are plaintext:" >&2
printf ' %s\n' "${bad_files[@]}" >&2
echo "" >&2
echo "Run 'notes unlock' if git-crypt is locked, then re-stage." >&2
return 1
fi

# The batched command failed, but no individual path explained the failure.
# Preserve fail-closed behavior rather than trusting an ambiguous result.
echo "ERROR: git-crypt could not inspect staged encrypted paths" >&2
[ -z "$batch_output" ] || printf '%s\n' "$batch_output" >&2
return 1
}
4 changes: 3 additions & 1 deletion lib/hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ ensure_hook_dispatcher() {
}

# Install the encryption pre-commit check.
# Render the source package path so the hook uses the exact Notes version that
# installed it rather than whichever `notes` command appears first on PATH.
install_encryption_hook() {
ensure_hook_dispatcher pre-commit
local target="$TARGET_DIR/.git/hooks/pre-commit.d/encryption"
cp "$HOOKS_DIR/encryption" "$target"
_render_notes_hook_template "$HOOKS_DIR/encryption.template" "." > "$target"
chmod +x "$target"
}

Expand Down
Loading
Loading