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
15 changes: 15 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ COPY scripts/ops/install-pinned-openssl.sh /tmp/install-pinned-openssl.sh

RUN sh /tmp/install-pinned-openssl.sh && rm /tmp/install-pinned-openssl.sh

FROM openssl-pinned AS privacy-ops

ENV NODE_ENV=production
LABEL org.wctm.runtime="privacy-ops"

RUN apk add --repositories-file /dev/null --no-network ca-certificates-bundle \
&& apk del --repositories-file /dev/null --no-network apk-tools \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
/usr/local/lib/node_modules/corepack /usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-v1.22.22

USER node

ENTRYPOINT ["node"]

FROM openssl-pinned AS dependencies

WORKDIR /app
Expand Down
6 changes: 4 additions & 2 deletions docs/operations/P7_2_P7_3_RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# P7.2/P7.3 Production Operations Runbook

> PPR-2 implementation note: new off-site sets are encrypted before upload and
> require `WCTM_BACKUP_CRYPTO_KEY_FILE` plus a host Node runtime in the scheduled
> service. This change has **not** been production validated. For any restore of
> require `WCTM_BACKUP_CRYPTO_KEY_FILE` plus the locally available, reviewed
> immutable Node base image for a network-disabled encryption container. A
> host Node installation is not required. This change has **not** been
> production validated. For any restore of
> an older backup, keep the application stopped and replay the external erasure
> ledger before it becomes authoritative. See
> `docs/privacy/PPR2_DATA_PROTECTION.md` for the key, legacy plaintext and
Expand Down
20 changes: 15 additions & 5 deletions docs/privacy/PPR2_DATA_PROTECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ for that Tenant/Store. After a merchant export has been securely delivered,
the operator runs this scoped cleanup to finish that request:

```sh
node scripts/ops/privacy-export-artifacts.mjs purge /var/lib/wctm/privacy TENANT_ID STORE_ID
scripts/ops/privacy-export-artifacts.sh purge /var/lib/wctm/privacy TENANT_ID STORE_ID
```

Delivery
Expand All @@ -60,7 +60,9 @@ exports. Install and enable the reviewed
canonical `/var/lib/wctm/privacy` directory, owned by the `wctm` service
account with mode 0700. The timer runs under that existing account and does not
require another account to receive Docker or ledger access; its unit hides the
Docker socket and production `.env`. Install the two
Docker socket and production `.env`. Cleanup uses the host's existing Bash,
coreutils and OpenSSL tools; it needs no host Node, Docker access or network.
Install the two
unit files from `ops/systemd/`, reload systemd, enable the timer and verify the
first one-shot sweep succeeds before using real merchant exports. The hourly
timer deletes exact export artifacts at
Expand Down Expand Up @@ -136,9 +138,17 @@ using a separately stored 32-byte mode-0600 key before rclone upload. OneDrive
receives only `.dump.enc`, `.dump.enc.json` and `.dump.enc.sha256` for new sets.
AES-256-GCM authenticates ciphertext; local SHA-256 verifies the original dump,
and remote content SHA-256 verifies uploaded ciphertext. The key must be owned
by the dedicated backup account outside Git and OneDrive. The scheduled service
requires a host Node runtime and a readable key file; these are new deployment
prerequisites requiring live validation. The decrypted dump is only created in
by the dedicated backup account outside Git and OneDrive. Encryption, metadata
selection and restore use the dedicated `privacy-ops` target in
`backend/Dockerfile`. Build it locally before backup or privacy operations:
`docker build --pull=false --target privacy-ops -t wctm-privacy-ops:node24.20.0-alpine3.24-openssl3.5.9-r0 -f backend/Dockerfile .`.
It inherits the immutable reviewed Node 24.20.0 / Alpine 3.24 base and the
SHA-256-verified OpenSSL 3.5.9-r0 package installation, then removes global npm,
Corepack, Yarn and APK tooling. The runner resolves the reviewed local tag to an immutable
image ID and uses it with `--pull=never`, no network, a read-only root and only
explicit input/key/output mounts. Docker access, that locally built ops image
and the readable key file require live validation; no host Node installation is
needed. The decrypted dump is only created in
an operator-controlled 0700 directory, then used with the existing isolated
restore procedure. Encryption does not replace checksum or restore tests.

Expand Down
2 changes: 1 addition & 1 deletion ops/systemd/wctm-privacy-export-retention.service
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Type=oneshot
User=wctm
Group=wctm
WorkingDirectory=/srv/wctm
ExecStart=/usr/bin/env node /srv/wctm/scripts/ops/privacy-export-artifacts.mjs sweep /var/lib/wctm/privacy
ExecStart=/srv/wctm/scripts/ops/privacy-export-artifacts.sh sweep /var/lib/wctm/privacy
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
Expand Down
7 changes: 5 additions & 2 deletions scripts/ops/archive-erasure-ledger.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,21 @@ key_file=${WCTM_BACKUP_CRYPTO_KEY_FILE:-}
echo >&2 'erasure archive refused: protected ledger, key and rclone configuration are required'
exit 66
}
command -v node >/dev/null 2>&1 && command -v rclone >/dev/null 2>&1 || {
command -v docker >/dev/null 2>&1 && command -v rclone >/dev/null 2>&1 || {
echo >&2 'erasure archive failed: required local tool is unavailable'
exit 69
}
ledger=$(cd "$(dirname "$ledger")" && pwd -P)/$(basename "$ledger")
temporary_dir=$(mktemp -d)
trap 'rm -rf -- "$temporary_dir"' EXIT HUP INT TERM
timestamp=$(date -u '+%Y%m%dT%H%M%SZ')
ledger_sha256=$(sha256sum "$ledger" | awk '{print $1}')
name="erasure-ledger-$timestamp-${ledger_sha256:0:12}.jsonl.enc"
encrypted="$temporary_dir/$name"
crypto_script=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/backup-crypto.mjs
node "$crypto_script" encrypt "$key_file" "$ledger" "$encrypted" || {
scripts/ops/run-ops-node.sh --ro "$crypto_script" --ro "$key_file" \
--ro "$ledger" --rw "$temporary_dir" -- \
"$crypto_script" encrypt "$key_file" "$ledger" "$encrypted" || {
echo >&2 'erasure archive failed: encryption failed'
exit 1
}
Expand Down
11 changes: 7 additions & 4 deletions scripts/ops/decrypt-offsite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ key_file=${WCTM_BACKUP_CRYPTO_KEY_FILE:-}
echo >&2 'off-site decrypt refused: encrypted set, protected key and explicit output directory are required'
exit 66
}
command -v node >/dev/null 2>&1 || {
echo >&2 'off-site decrypt failed: local Node runtime is unavailable'
command -v docker >/dev/null 2>&1 || {
echo >&2 'off-site decrypt failed: local Docker runtime is unavailable'
exit 69
}
encrypted=$(cd "$(dirname "$encrypted")" && pwd -P)/$(basename "$encrypted")
metadata=$(cd "$(dirname "$metadata")" && pwd -P)/$(basename "$metadata")
directory=$(cd "$directory" && pwd -P)
(cd "$(dirname "$encrypted")" && sha256sum --check --status "$(basename "$encrypted.sha256")") || {
echo >&2 'off-site decrypt refused: encrypted content checksum mismatch'
Expand All @@ -32,7 +33,7 @@ name=$(basename "$encrypted")
echo >&2 'off-site decrypt refused: backup set names do not match'
exit 65
}
expected=$(node -e '
expected=$(scripts/ops/run-ops-node.sh --ro "$metadata" -- -e '
const fs = require("node:fs");
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
if (data.format !== "postgresql-custom" || data.offsiteEncryption?.format !== "WCTMBACKUPAESG1" ||
Expand All @@ -50,7 +51,9 @@ dump="$directory/${name%.enc}"
exit 73
}
crypto_script=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/backup-crypto.mjs
node "$crypto_script" decrypt "$key_file" "$encrypted" "$dump" || {
scripts/ops/run-ops-node.sh --ro "$crypto_script" --ro "$key_file" \
--ro "$encrypted" --rw "$directory" -- \
"$crypto_script" decrypt "$key_file" "$encrypted" "$dump" || {
echo >&2 'off-site decrypt failed: authentication failed'
exit 1
}
Expand Down
12 changes: 8 additions & 4 deletions scripts/ops/offsite-rclone.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ command -v rclone >/dev/null 2>&1 || {
echo >&2 'off-site copy failed: rclone is unavailable'
exit 69
}
command -v node >/dev/null 2>&1 || {
echo >&2 'off-site copy failed: local Node runtime is unavailable for encryption'
command -v docker >/dev/null 2>&1 || {
echo >&2 'off-site copy failed: local Docker runtime is unavailable for encryption'
exit 69
}
key_file=${WCTM_BACKUP_CRYPTO_KEY_FILE:-}
Expand All @@ -47,10 +47,14 @@ local_dump_sha256=$(sha256sum "$dump" | awk '{print $1}')
}

crypto_script=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/backup-crypto.mjs
dump=$(cd "$(dirname "$dump")" && pwd -P)/$(basename "$dump")
metadata=$(cd "$(dirname "$metadata")" && pwd -P)/$(basename "$metadata")
temporary_dir=$(mktemp -d "$(dirname "$dump")/.wctm-offsite.XXXXXXXX")
trap 'rm -rf -- "$temporary_dir"' EXIT HUP INT TERM
encrypted="$temporary_dir/$(basename "$dump").enc"
node "$crypto_script" encrypt "$key_file" "$dump" "$encrypted" || {
scripts/ops/run-ops-node.sh --ro "$crypto_script" --ro "$key_file" \
--ro "$dump" --rw "$temporary_dir" -- \
"$crypto_script" encrypt "$key_file" "$dump" "$encrypted" || {
echo >&2 'off-site copy failed: local backup encryption failed'
exit 1
}
Expand All @@ -59,7 +63,7 @@ encrypted_sha256=$(sha256sum "$encrypted" | awk '{print $1}')
printf '%s %s\n' "$encrypted_sha256" "$(basename "$encrypted")" >"$encrypted_checksum"
encrypted_metadata="$encrypted.json"
key_id=$(sha256sum "$key_file" | awk '{print substr($1, 1, 16)}')
node -e '
scripts/ops/run-ops-node.sh --ro "$metadata" --rw "$temporary_dir" -- -e '
const fs = require("node:fs");
const [source, target, keyId, ciphertextSha256] = process.argv.slice(1);
const record = JSON.parse(fs.readFileSync(source, "utf8"));
Expand Down
4 changes: 2 additions & 2 deletions scripts/ops/offsite-retention.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ days=$2
echo >&2 'off-site retention refused: max age must be positive whole days'
exit 64
}
command -v rclone >/dev/null 2>&1 && command -v node >/dev/null 2>&1 || {
command -v rclone >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 || {
echo >&2 'off-site retention failed: required local tool is unavailable'
exit 69
}
Expand All @@ -20,7 +20,7 @@ listing=$(rclone lsf --files-only "$destination") || {
echo >&2 'off-site retention failed: remote listing unavailable'
exit 1
}
selected=$(printf '%s\n' "$listing" | node "$selector" "$days") || {
selected=$(printf '%s\n' "$listing" | scripts/ops/run-ops-node.sh --ro "$selector" -- "$selector" "$days") || {
echo >&2 'off-site retention failed: selection unavailable'
exit 1
}
Expand Down
6 changes: 3 additions & 3 deletions scripts/ops/pilot-privacy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ case "$mode" in
esac

# Expired exports are swept on every operator entry as well as by the hourly timer.
node scripts/ops/privacy-export-artifacts.mjs sweep "$privacy_directory"
scripts/ops/privacy-export-artifacts.sh sweep "$privacy_directory"

target_ids() {
tenant_id=''
Expand All @@ -82,7 +82,7 @@ if [[ "$mode" = export ]]; then
exit 64
}
target_ids "$@" || { echo >&2 'privacy export refused: exact target IDs are required'; exit 64; }
output=$(node scripts/ops/privacy-export-artifacts.mjs new-path "$privacy_directory" "$tenant_id" "$store_id")
output=$(scripts/ops/privacy-export-artifacts.sh new-path "$privacy_directory" "$tenant_id" "$store_id")
set -- "$@" --output "$output"
fi

Expand Down Expand Up @@ -125,7 +125,7 @@ if [[ "$mode" = erase && " $* " = *' --execute '* ]]; then
scripts/ops/archive-erasure-ledger.sh "$ledger" "${WCTM_OFFSITE_DESTINATION%/}/privacy-erasure-ledger"
run_privacy erase "$@"
target_ids "$@"
node scripts/ops/privacy-export-artifacts.mjs purge "$privacy_directory" "$tenant_id" "$store_id"
scripts/ops/privacy-export-artifacts.sh purge "$privacy_directory" "$tenant_id" "$store_id"
else
run_privacy "$mode" "$@"
if [[ "$mode" = export ]]; then
Expand Down
99 changes: 0 additions & 99 deletions scripts/ops/privacy-export-artifacts.mjs

This file was deleted.

69 changes: 69 additions & 0 deletions scripts/ops/privacy-export-artifacts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077

fail() {
echo >&2 'privacy export cleanup failed; inspect protected directory and operator configuration'
exit 1
}

if [[ $# != 2 && $# != 4 ]] ||
{ [[ $1 = sweep && $# != 2 ]] || [[ $1 != sweep && $# != 4 ]]; }; then
echo >&2 'usage: privacy-export-artifacts.sh new-path|purge DIRECTORY TENANT_ID STORE_ID | sweep DIRECTORY'
exit 64
fi
mode=$1
directory=$2
[[ $mode = sweep || $mode = purge || $mode = new-path ]] || fail
[[ $directory = /* && $directory != / && /$directory/ != */../* &&
-d $directory && ! -L $directory ]] || fail
directory_mode=$(stat -c '%a' "$directory" 2>/dev/null || stat -f '%Lp' "$directory") || fail
[[ $directory_mode = 700 ]] || fail
directory=$(cd "$directory" && pwd -P) || fail
ledger=$directory/erasure-ledger.jsonl
[[ -f $ledger && ! -L $ledger ]] || fail
ledger_mode=$(stat -c '%a' "$ledger" 2>/dev/null || stat -f '%Lp' "$ledger") || fail
[[ $ledger_mode = 600 ]] || fail

scope_hash() {
[[ $1 =~ ^ten_[A-Za-z0-9_-]+$ && $2 =~ ^sto_[A-Za-z0-9_-]+$ ]] || fail
if command -v sha256sum >/dev/null 2>&1; then
printf '%s\0%s' "$1" "$2" | sha256sum | awk '{print $1}'
else
printf '%s\0%s' "$1" "$2" | shasum -a 256 | awk '{print $1}'
fi
}

if [[ $mode = new-path ]]; then
scope=$(scope_hash "$3" "$4") || fail
timestamp=$(date -u '+%s%3N' 2>/dev/null) || timestamp=''
[[ $timestamp =~ ^[0-9]{13}$ ]] || timestamp="$(date -u '+%s')000"
nonce=$(openssl rand -hex 8) || fail
printf '%s/wctm-privacy-export-%s-%s-%s.json\n' "$directory" "$scope" "$timestamp" "$nonce"
exit 0
fi

selected_scope=''
[[ $mode != purge ]] || selected_scope=$(scope_hash "$3" "$4") || fail
now=$(date -u '+%s') || fail
removed=0
shopt -s nullglob
for path in "$directory"/wctm-privacy-export-*; do
name=${path##*/}
[[ $name =~ ^wctm-privacy-export-([0-9a-f]{64})-([0-9]{13})-([0-9a-f]{16})\.json$ ]] || continue
[[ -z $selected_scope || ${BASH_REMATCH[1]} = "$selected_scope" ]] || continue
[[ -f $path && ! -L $path ]] || fail
file_mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path") || fail
[[ $file_mode = 600 ]] || fail
if [[ $mode = sweep ]]; then
oldest=$((${BASH_REMATCH[2]:0:10}))
modified=$(stat -c '%Y' "$path" 2>/dev/null || stat -f '%m' "$path") || fail
(( modified < oldest )) && oldest=$modified
born=$(stat -c '%W' "$path" 2>/dev/null || stat -f '%B' "$path") || fail
(( born > 0 && born < oldest )) && oldest=$born
(( now - oldest >= 23 * 3600 )) || continue
fi
rm -f -- "$path" || fail
removed=$((removed + 1))
done
printf 'privacy export cleanup: PASS removed=%s\n' "$removed"
Loading
Loading