feat: signed USB recovery system

- keys/adacam-update-public.pem: RSA-4096 public key (private on Lucy)
- services/updater/adacam-updater.sh: standalone updater (also inlined in liberate.sh)
- services/updater/99-adacam-usb.rules: udev rule for auto-trigger on USB insert
- scripts/sign-bundle.sh: create + sign recovery bundles on Lucy
- keys/README.md: updated with signing key docs and bundle creation instructions

Private key at: /boot/config/adacam/adacam-update-private.pem (Lucy, boot-persistent)
This commit is contained in:
Kayos 2026-03-14 15:07:32 -07:00
parent e01748422c
commit 85b3ee39dd
3 changed files with 166 additions and 55 deletions

View file

@ -63,3 +63,21 @@ bash scripts/sign-bundle.sh ./recovery-output ./my-bundle-dir
|-----|------|---------|--------|
| SSH access | ed25519 | Lucy: `/boot/config/adacam/id_ed25519_adacam` | `keys/adacam_authorized_key.pub``/root/.ssh/authorized_keys` |
| Update signing | RSA-4096 | Lucy: `/boot/config/adacam/adacam-update-private.pem` | `keys/adacam-update-public.pem``/etc/adacam/update-verify.pem` |
## adacam-update-public.pem
RSA-4096 public key for verifying signed USB recovery bundles.
Installed to `/etc/adacam/update-verify.pem` on every liberated device by liberate.sh.
Private key: `/boot/config/adacam/adacam-update-private.pem` on Lucy — NEVER commit this.
### Creating a recovery bundle
From Lucy, inside the cloned adacam repo:
```bash
bash scripts/sign-bundle.sh [output-dir]
# Default output: /tmp/adacam-recovery-bundle/
```
Copy the `adacam_recovery/` folder to the root of a USB drive.
Insert into a liberated AdaCam — recovery runs automatically.

203
scripts/sign-bundle.sh Normal file → Executable file
View file

@ -1,82 +1,175 @@
#!/bin/bash
# sign-bundle.sh — create and sign an AdaCam recovery bundle
# sign-bundle.sh — create a signed AdaCam recovery bundle
#
# Run this on Lucy (where the private key lives).
# Output: adacam-recovery.tar.gz + adacam-recovery.tar.gz.sig
# Copy both to USB drive under adacam_recovery/
# Run this on Lucy to package and sign a recovery bundle.
# The signed bundle goes on a USB drive for emergency device recovery.
#
# Usage:
# bash sign-bundle.sh [bundle-dir]
# ./scripts/sign-bundle.sh [output-dir]
#
# bundle-dir should contain:
# install.sh — runs on device during recovery (must be executable)
# services/ — updated service files to deploy
# config/ — any config overrides
# authorized_keys — (optional) replacement SSH keys
# Output:
# adacam_recovery/adacam-recovery.tar.gz
# adacam_recovery/adacam-recovery.tar.gz.sig
#
# The install.sh in your bundle runs as root on the device.
# It can do anything — reinstall services, update configs, reset keys.
# Keep it minimal and idempotent.
# Requires:
# - /boot/config/adacam/adacam-update-private.pem (RSA-4096 private key on Lucy)
# - The adacam repo checked out (source of services/ and configs/)
set -euo pipefail
PRIVATE_KEY="/boot/config/adacam/adacam-update-private.pem"
OUTPUT_DIR="${1:-./recovery-output}"
BUNDLE_DIR="${2:-./bundle}"
BUNDLE_TAR="adacam-recovery.tar.gz"
SIG_FILE="adacam-recovery.tar.gz.sig"
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
OUTPUT_DIR="${1:-/tmp/adacam-recovery-bundle}"
BUNDLE_DIR="$OUTPUT_DIR/adacam_recovery"
BUNDLE="$BUNDLE_DIR/adacam-recovery.tar.gz"
SIG="$BUNDLE_DIR/adacam-recovery.tar.gz.sig"
STAGING="/tmp/adacam-recovery-staging"
# ── Checks ────────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%H:%M:%S')] $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
[ -f "$PRIVATE_KEY" ] || { echo "ERROR: private key not found at $PRIVATE_KEY"; exit 1; }
[ -d "$BUNDLE_DIR" ] || { echo "ERROR: bundle dir '$BUNDLE_DIR' not found"; exit 1; }
[ -f "$BUNDLE_DIR/install.sh" ] || { echo "ERROR: bundle must contain install.sh"; exit 1; }
[ -f "$PRIVATE_KEY" ] || die "private key not found at $PRIVATE_KEY"
[ -d "$REPO_DIR/services" ] || die "run from inside the adacam repo (services/ not found)"
echo "=== AdaCam Bundle Signer ==="
echo "Bundle dir: $BUNDLE_DIR"
echo "Output dir: $OUTPUT_DIR"
echo "Private key: $PRIVATE_KEY"
echo ""
log "Building AdaCam recovery bundle"
log "Source: $REPO_DIR"
log "Output: $BUNDLE_DIR"
mkdir -p "$OUTPUT_DIR"
# ── Stage recovery contents ───────────────────────────────────────────────────
rm -rf "$STAGING" && mkdir -p "$STAGING"
# Core services
cp -r "$REPO_DIR/services/capture/" "$STAGING/capture/" 2>/dev/null || true
cp -r "$REPO_DIR/services/updater/" "$STAGING/updater/" 2>/dev/null || true
# adacam-api (if present locally — otherwise pull from Gitea in install.sh)
if [ -d "$REPO_DIR/../adacam-api" ]; then
cp -r "$REPO_DIR/../adacam-api" "$STAGING/adacam-api/"
fi
# Keys
mkdir -p "$STAGING/keys"
cp "$REPO_DIR/keys/adacam_authorized_key.pub" "$STAGING/keys/"
cp "$REPO_DIR/keys/adacam-update-public.pem" "$STAGING/keys/"
# Write install.sh — runs on device after extraction
cat > "$STAGING/install.sh" << 'INSTALL'
#!/bin/bash
# AdaCam recovery install — runs on device after bundle is verified and extracted
# Working directory is the extracted bundle root
set -euo pipefail
BUNDLE_ROOT="$(cd "$(dirname "$0")" && pwd)"
GITEA="http://192.168.0.5:3001"
GITEA_TOKEN="33a9eb57b58c262f4434c12028bc3a30b1ff7021"
log() { echo "[recovery] $*"; }
die() { echo "[recovery] ERROR: $*" >&2; exit 1; }
log "=== AdaCam Recovery Install ==="
# 1. Restore authorized SSH keys
log "restoring SSH keys..."
mkdir -p /root/.ssh && chmod 700 /root/.ssh
cat "$BUNDLE_ROOT/keys/adacam_authorized_key.pub" >> /root/.ssh/authorized_keys
sort -u /root/.ssh/authorized_keys -o /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
# 2. Re-install update verify key
log "restoring update verify key..."
mkdir -p /etc/adacam
cp "$BUNDLE_ROOT/keys/adacam-update-public.pem" /etc/adacam/update-verify.pem
# 3. Re-install updater itself (self-healing)
log "restoring updater..."
cp "$BUNDLE_ROOT/updater/adacam-updater.sh" /usr/local/bin/adacam-updater
chmod +x /usr/local/bin/adacam-updater
cp "$BUNDLE_ROOT/updater/99-adacam-usb.rules" /etc/udev/rules.d/
udevadm control --reload-rules 2>/dev/null || true
# 4. Re-install capture services
if [ -d "$BUNDLE_ROOT/capture" ]; then
log "restoring capture services..."
mkdir -p /opt/adacam/capture
cp -r "$BUNDLE_ROOT/capture/"* /opt/adacam/capture/
if [ -f "$BUNDLE_ROOT/capture/adacam-capture.service" ]; then
cp "$BUNDLE_ROOT/capture/adacam-capture.service" /etc/systemd/system/
cp "$BUNDLE_ROOT/capture/adacam-forwarder.service" /etc/systemd/system/ 2>/dev/null || true
systemctl daemon-reload
systemctl enable adacam-capture adacam-forwarder 2>/dev/null || true
fi
fi
# 5. Pull latest adacam-api from Gitea and reinstall
log "pulling adacam-api from Gitea..."
if curl -sf "$GITEA/Sulkta-Coop/adacam-api/archive/main.tar.gz" \
-H "Authorization: token $GITEA_TOKEN" \
-o /tmp/adacam-api.tar.gz 2>/dev/null; then
mkdir -p /opt/adacam/api
tar -xzf /tmp/adacam-api.tar.gz -C /opt/adacam/api --strip-components=1
if [ -f /opt/adacam/api/install.sh ]; then
bash /opt/adacam/api/install.sh
fi
rm /tmp/adacam-api.tar.gz
log "adacam-api restored from Gitea"
else
log "WARNING: could not reach Gitea — adacam-api not restored (LAN required)"
fi
# 6. Re-apply firewall
if [ -f /data/persist/firewall.sh ]; then
log "reapplying firewall..."
bash /data/persist/firewall.sh
fi
# 7. Restart services
log "restarting services..."
systemctl restart sshd 2>/dev/null || true
systemctl restart adacam-api 2>/dev/null || true
systemctl restart adacam-capture 2>/dev/null || true
log "=== Recovery install complete ==="
INSTALL
chmod +x "$STAGING/install.sh"
# ── Create tarball ────────────────────────────────────────────────────────────
echo "Creating bundle..."
tar -czf "$OUTPUT_DIR/$BUNDLE_TAR" -C "$BUNDLE_DIR" .
BUNDLE_SIZE=$(du -sh "$OUTPUT_DIR/$BUNDLE_TAR" | cut -f1)
echo "Bundle: $OUTPUT_DIR/$BUNDLE_TAR ($BUNDLE_SIZE)"
mkdir -p "$BUNDLE_DIR"
log "creating bundle..."
tar -czf "$BUNDLE" -C "$STAGING" .
BUNDLE_SIZE=$(du -sh "$BUNDLE" | cut -f1)
log "bundle: $BUNDLE ($BUNDLE_SIZE)"
# ── Sign ──────────────────────────────────────────────────────────────────────
# ── Sign it ───────────────────────────────────────────────────────────────────
echo "Signing..."
openssl dgst -sha256 \
-sign "$PRIVATE_KEY" \
-out "$OUTPUT_DIR/$SIG_FILE" \
"$OUTPUT_DIR/$BUNDLE_TAR"
echo "Signature: $OUTPUT_DIR/$SIG_FILE"
# ── Verify (sanity check) ─────────────────────────────────────────────────────
log "signing with RSA-4096..."
openssl dgst -sha256 -sign "$PRIVATE_KEY" -out "$SIG" "$BUNDLE"
log "signature: $SIG"
# Verify our own signature (sanity check)
PUBLIC_KEY="$(dirname "$PRIVATE_KEY")/adacam-update-public.pem"
if [ -f "$PUBLIC_KEY" ]; then
echo "Verifying signature..."
openssl dgst -sha256 -verify "$PUBLIC_KEY" \
-signature "$OUTPUT_DIR/$SIG_FILE" \
"$OUTPUT_DIR/$BUNDLE_TAR" && echo "Signature verified OK"
if openssl dgst -sha256 -verify "$PUBLIC_KEY" -signature "$SIG" "$BUNDLE" > /dev/null 2>&1; then
log "self-verify: OK"
else
die "self-verify FAILED — bundle may be corrupt"
fi
# ── USB instructions ──────────────────────────────────────────────────────────
# ── Cleanup and report ────────────────────────────────────────────────────────
rm -rf "$STAGING"
echo ""
echo "=== USB Drive Setup ==="
echo "Copy these two files to your USB drive:"
echo "======================================"
echo " Recovery Bundle Ready"
echo "======================================"
echo " Bundle: $BUNDLE"
echo " Signature: $SIG"
echo " Size: $BUNDLE_SIZE"
echo ""
echo " USB_DRIVE/adacam_recovery/$BUNDLE_TAR"
echo " USB_DRIVE/adacam_recovery/$SIG_FILE"
echo " Copy the adacam_recovery/ folder to a USB drive:"
echo " cp -r $BUNDLE_DIR /path/to/usb/adacam_recovery"
echo ""
echo "Insert USB into powered-on AdaCam."
echo "Recovery runs automatically, device reboots when done."
echo ""
echo "Log on device: /data/adacam/recovery.log"
echo " The USB drive root must contain: adacam_recovery/"
echo "======================================"

0
services/updater/adacam-updater.sh Normal file → Executable file
View file