diff --git a/.env.example b/.env.example
index ee4bbaf..dde2350 100644
--- a/.env.example
+++ b/.env.example
@@ -48,6 +48,15 @@ BACKUP_TMP_DIR=./tmp
# B2_OBJECT_LOCK_DAYS=30
# B2_OBJECT_LOCK_MODE=GOVERNANCE
+# ─── Manifest signing (tamper-evidence, Ed25519) ──────────────────────────────
+# Generate a keypair:
+# node -e "const s=require('./src/lib/manifest-signing');const k=s.generateKeyPair();
+# require('fs').writeFileSync('signing.key',k.privateKey);
+# require('fs').writeFileSync('signing.pub',k.publicKey)"
+# BACKUP_SIGNING_KEY= # PEM Ed25519 PRIVATE key → writes manifest.json.sig
+# BACKUP_SIGNING_PUBLIC_KEY= # PEM PUBLIC key → verified on restore; also fingerprinted in summary
+# BACKUP_REQUIRE_SIGNATURE=false # true = restore aborts if signature missing/invalid
+
# Optional AES-256-CBC encryption at rest (64 hex chars = 32 bytes). Required
# for both backup (encrypt) and restore (decrypt) — store it in a secrets vault.
# BACKUP_ENCRYPTION_KEY=
diff --git a/.github/workflows/monthly-restore-test.yml b/.github/workflows/monthly-restore-test.yml
index 46ac9cc..a67debb 100644
--- a/.github/workflows/monthly-restore-test.yml
+++ b/.github/workflows/monthly-restore-test.yml
@@ -45,12 +45,50 @@ jobs:
BACKUP_TMP_DIR: /tmp/gh-restore-test
run: |
echo "Starting monthly dry-run restore integrity test..."
+ echo "RESTORE_START=$(date +%s%3N)" >> $GITHUB_ENV
node src/restore/index.js
+ echo "RESTORE_END=$(date +%s%3N)" >> $GITHUB_ENV
echo "RESTORE_RESULT=success" >> $GITHUB_ENV
- name: Mark result on failure
if: steps.restore_test.outcome == 'failure'
- run: echo "RESTORE_RESULT=failure" >> $GITHUB_ENV
+ run: |
+ echo "RESTORE_END=$(date +%s%3N)" >> $GITHUB_ENV
+ echo "RESTORE_RESULT=failure" >> $GITHUB_ENV
+
+ - name: Publish recovery scorecard
+ if: always()
+ continue-on-error: true
+ uses: actions/github-script@v7
+ env:
+ RESTORE_RESULT: ${{ env.RESTORE_RESULT }}
+ RESTORE_START: ${{ env.RESTORE_START }}
+ RESTORE_END: ${{ env.RESTORE_END }}
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { buildScorecard } = require(process.env.GITHUB_WORKSPACE + '/src/lib/recovery-scorecard');
+ const start = parseInt(process.env.RESTORE_START || '0', 10);
+ const end = parseInt(process.env.RESTORE_END || String(Date.now()), 10);
+ const sc = buildScorecard({
+ session: 'latest',
+ ok: (process.env.RESTORE_RESULT || 'failure') === 'success',
+ startedAt: start, finishedAt: end,
+ });
+ const content = Buffer.from(JSON.stringify(sc, null, 2)).toString('base64');
+ let sha;
+ try {
+ const res = await github.rest.repos.getContent({
+ owner: context.repo.owner, repo: context.repo.repo, path: 'docs/recovery-scorecard.json'
+ });
+ sha = res.data.sha;
+ } catch (_) {}
+ await github.rest.repos.createOrUpdateFileContents({
+ owner: context.repo.owner, repo: context.repo.repo,
+ path: 'docs/recovery-scorecard.json',
+ message: 'chore: update recovery scorecard [skip ci]',
+ content, ...(sha ? { sha } : {})
+ });
- name: Append restore-test audit entry
if: always()
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..ef88ace
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,65 @@
+name: Publish Docker image (GHCR)
+
+# Builds and publishes the container image to GitHub Container Registry on each
+# release (and on manual dispatch), with an SBOM and signed build provenance.
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Image tag to publish (e.g. v5.0.0 or latest)'
+ required: false
+ default: 'latest'
+
+permissions:
+ contents: read
+ packages: write
+ id-token: write # sign provenance
+ attestations: write
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ env:
+ IMAGE: ghcr.io/${{ github.repository }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Derive image metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.IMAGE }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
+
+ - name: Build and push
+ id: build
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ provenance: true
+ sbom: true
+
+ - name: Attest build provenance
+ uses: actions/attest-build-provenance@v4
+ with:
+ subject-name: ${{ env.IMAGE }}
+ subject-digest: ${{ steps.build.outputs.digest }}
+ push-to-registry: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15aac6f..d93aeab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [5.0.0] — 2026-07-20
+
+### Added — Trust & verification
+- **Signed manifests (Ed25519)** — `src/lib/manifest-signing.js`; `BACKUP_SIGNING_KEY`
+ signs `manifest.json` → `manifest.json.sig`, verified on restore with
+ `BACKUP_SIGNING_PUBLIC_KEY` (`BACKUP_REQUIRE_SIGNATURE=true` to enforce).
+- **Recovery scorecard** — `src/lib/recovery-scorecard.js`; the monthly restore
+ drill publishes `docs/recovery-scorecard.json` (last verified restore + RTO),
+ shown as a README endpoint badge and a dashboard tile.
+- **Tamper-evident audit log** — audit entries are now hash-chained with a
+ `verifyChain()` validator.
+
+### Added — Distribution
+- **GitHub Action** (`action.yml`) — `uses: OmarRao/github-gdrive-backup@v5`,
+ no fork required; outputs a JSON `summary`.
+- **GHCR publish workflow** (`publish.yml`) — container image on each release
+ with SBOM + signed build provenance.
+
+### Changed
+- Dashboard System Health adds **Restore Verified** and **Manifest Signature** tiles.
+- Docs, `.env.example`, and screenshots updated; SW cache → v6.
+
+### Notes
+- New crypto/scorecard/audit logic is unit-tested (16 new tests, 97 total).
+ No change to existing backup/restore data formats.
+
+---
+
## [4.1.0] — 2026-07-17
### Performance
diff --git a/DEPENDENCY-LICENSE-REVIEW.md b/DEPENDENCY-LICENSE-REVIEW.md
index afe3139..826d614 100644
--- a/DEPENDENCY-LICENSE-REVIEW.md
+++ b/DEPENDENCY-LICENSE-REVIEW.md
@@ -109,3 +109,26 @@ informational only:
purport to relicense third-party dependencies.
Re-run this inventory whenever dependencies change (e.g. after Dependabot updates).
+
+## Known security advisories (open)
+
+| Package | Advisory | Severity | Fix | Status |
+|---|---|---|---|---|
+| `extract-zip` | [GHSA-jmr9-qjv8-65gv](https://github.com/advisories/GHSA-jmr9-qjv8-65gv) — unvalidated symlink path traversal during extraction | High | **No patched version available** | Tracked follow-up |
+
+**Where it's used:** restore only (`src/restore/index.js`) — extracting a repo's
+zip archive into a temp directory on an ephemeral CI runner.
+
+**Exposure & mitigation:**
+- The archives extracted are the project's **own** backups (bare git mirrors,
+ which do not contain symlinks). The risk is a *maliciously crafted* archive in
+ the storage bucket.
+- v5 adds **signed manifests** (`BACKUP_SIGNING_KEY` / `BACKUP_SIGNING_PUBLIC_KEY`)
+ and `BACKUP_REQUIRE_SIGNATURE=true`, giving tamper-evidence at the manifest
+ level — restore from a session whose manifest fails verification is aborted.
+- Restore only from storage you control, and keep restore runners ephemeral.
+
+**Planned remediation:** replace `extract-zip` with a streaming, path-safe
+extractor (rejecting `..`, absolute paths, and symlink entries). Deferred here
+because the swap requires installing a replacement package and must be validated
+against the restore path; it will be done as a focused, tested change.
diff --git a/README.md b/README.md
index d9a480f..b433a25 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
[](COMMERCIAL-LICENSE.md)
[](https://github.com/OmarRao/github-gdrive-backup/actions)
[](https://github.com/OmarRao/github-gdrive-backup/actions)
+[](https://github.com/OmarRao/github-gdrive-backup/actions/workflows/monthly-restore-test.yml)
[](https://omarrao.github.io/github-gdrive-backup/)
[](https://nodejs.org/)
[](https://drive.google.com/)
@@ -190,6 +191,11 @@ The dashboard surfaces the latest session's **delta composition** (full / delta
| **First-run onboarding** | Setup checklist gates the dashboard until GitHub + Drive are configured; no stale/upstream data on first login |
| **Fork-aware targeting** | Dashboard auto-derives the target repo from the Pages URL, so each fork shows its own live data (override in Settings) |
| **Dynamic demo data** | Demo stats, graphs, composition & fan-out are all derived from one dataset — always internally consistent |
+| **Signed manifests** | Optional Ed25519 signature over `manifest.json` (`BACKUP_SIGNING_KEY`); verified on restore to detect tampering/forgery, not just corruption |
+| **Recovery scorecard** | Monthly restore drill publishes a "last verified restore + RTO" scorecard (`docs/recovery-scorecard.json`) surfaced as a README badge and dashboard tile |
+| **Tamper-evident audit log** | Hash-chained JSON-lines audit entries — editing or deleting past entries is detectable |
+| **GitHub Action** | Use as `uses: OmarRao/github-gdrive-backup@v5` in any workflow — no fork required |
+| **Container image (GHCR)** | Published to `ghcr.io/omarrao/github-gdrive-backup` on each release, with SBOM + signed provenance |
| **SBOM generation** | Optional `include_sbom=true` input generates SPDX SBOM via `anchore/sbom-action@v0` |
| **Auto-restore test** | Monthly `monthly-restore-test.yml` dry-run verifies restore integrity; result appended to audit log |
| **PWA / offline** | `manifest.json` + cache-first service worker — install dashboard to home screen, works offline |
@@ -617,6 +623,55 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project structure,
---
+## Trust & Verification
+
+A backup is only as good as its restore — so v5 makes recoverability and integrity **provable**.
+
+
+
+- **Signed manifests (Ed25519).** Set `BACKUP_SIGNING_KEY` (a PEM Ed25519 private key) and each session's `manifest.json` is signed to `manifest.json.sig`. On restore, set `BACKUP_SIGNING_PUBLIC_KEY` to verify it; SHA-256 catches *corruption*, the signature catches *tampering/forgery*. Set `BACKUP_REQUIRE_SIGNATURE=true` to abort a restore on a missing/invalid signature. Generate a keypair:
+ ```bash
+ node -e "const s=require('./src/lib/manifest-signing');const k=s.generateKeyPair();require('fs').writeFileSync('signing.key',k.privateKey);require('fs').writeFileSync('signing.pub',k.publicKey);console.log('wrote signing.key + signing.pub')"
+ ```
+- **Recovery scorecard.** The monthly restore drill (`monthly-restore-test.yml`) publishes `docs/recovery-scorecard.json` — *last verified restore + RTO* — shown as the **Restore Verified** badge above and a dashboard tile. "Backups exist" becomes "restores are verified."
+- **Tamper-evident audit log.** Audit entries (`src/audit/log.js`) are hash-chained: each carries the previous entry's SHA-256, so editing or deleting history is detectable via `verifyChain()`.
+
+---
+
+## Use as a GitHub Action
+
+No fork required — compose it into any workflow:
+
+```yaml
+- uses: OmarRao/github-gdrive-backup@v5
+ with:
+ github-token: ${{ secrets.GH_BACKUP_TOKEN }}
+ gdrive-folder-id: ${{ secrets.GDRIVE_FOLDER_ID }}
+ google-client-secret: ${{ secrets.GOOGLE_CLIENT_SECRET }}
+ google-token: ${{ secrets.GOOGLE_TOKEN }}
+ incremental-mode: delta # optional
+ mirror-targets: s3,b2 # optional 3-2-1 fan-out
+ signing-key: ${{ secrets.BACKUP_SIGNING_KEY }} # optional
+```
+
+See [`action.yml`](action.yml) for all inputs. The action outputs a JSON `summary` (totals, delta composition, fan-out, signature).
+
+---
+
+## Container image (GHCR)
+
+Published to the GitHub Container Registry on each release with an SBOM and signed build provenance:
+
+```bash
+docker run --rm \
+ -e GITHUB_TOKEN=ghp_... -e GITHUB_USER=you \
+ -e GDRIVE_FOLDER_ID=... \
+ -v "$PWD/credentials:/app/credentials" \
+ ghcr.io/omarrao/github-gdrive-backup:v5 backup
+```
+
+---
+
## Licensing
**Copyright © 2026 Omar Rao.**
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 0000000..41da47b
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,32 @@
+# Support
+
+Thanks for using **github-gdrive-backup**.
+
+## Community support (free, AGPL-3.0)
+
+- **Bugs & feature requests:** open a [GitHub Issue](https://github.com/OmarRao/github-gdrive-backup/issues).
+- **Questions & ideas:** use [GitHub Discussions](https://github.com/OmarRao/github-gdrive-backup/discussions) if enabled, or an Issue.
+- **Security reports:** see [SECURITY.md](SECURITY.md) — please do **not** file public issues for vulnerabilities.
+
+Community support is best-effort, provided by the maintainer and other users under
+the terms of the [AGPL-3.0 license](LICENSE). There is no SLA.
+
+## Commercial support & licensing
+
+The project is dual-licensed (see [COMMERCIAL-LICENSE.md](COMMERCIAL-LICENSE.md)).
+A commercial license is required for closed-source, internal-proprietary,
+SaaS/hosted, integration, redistribution, resale, distributor, or white-label use.
+
+Commercial engagements can include (by separate written agreement):
+
+- a commercial license grant (no AGPL source-disclosure obligation);
+- priority support and defined response targets;
+- deployment/onboarding assistance;
+- private feature or integration work.
+
+To discuss commercial licensing or support, contact **Omar Rao** —
+`omarsrao@gmail.com`. Please include your organization, intended use, deployment
+model, and scale so a suitable proposal can be prepared.
+
+> This document is informational. Any commercial terms arise solely from a
+> separate written agreement, and nothing here is legal advice.
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..ed6639a
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,130 @@
+# Copyright (c) 2026 Omar Rao
+# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
+name: 'GitHub → Google Drive Backup'
+description: 'Back up GitHub repositories (code, issues, PRs, releases, wiki) to Google Drive or S3/Azure/B2, with delta uploads, 3-2-1 fan-out, encryption and signed manifests.'
+author: 'Omar Rao'
+branding:
+ icon: 'hard-drive'
+ color: 'blue'
+
+inputs:
+ github-token:
+ description: 'GitHub token (PAT) with repo, workflow, read:org, read:user scopes.'
+ required: true
+ github-user:
+ description: 'GitHub username or org to back up (blank = the token owner).'
+ required: false
+ default: ''
+ gdrive-folder-id:
+ description: 'Google Drive destination folder ID.'
+ required: true
+ google-client-secret:
+ description: 'Contents of the Google OAuth client-secret JSON.'
+ required: true
+ google-token:
+ description: 'Contents of the Google OAuth token JSON.'
+ required: true
+ repos:
+ description: 'Comma-separated repo names to back up (blank = all).'
+ required: false
+ default: ''
+ include:
+ description: 'Data to include: code,issues,pull_requests,releases,wiki,labels,milestones,config'
+ required: false
+ default: 'code,issues,pull_requests,releases,wiki,labels,milestones'
+ incremental-mode:
+ description: "Archive strategy: 'full' (zip) or 'delta' (git bundle chain)."
+ required: false
+ default: 'full'
+ storage-target:
+ description: 'Primary storage: drive, s3, azure, or b2.'
+ required: false
+ default: 'drive'
+ mirror-targets:
+ description: 'Comma-separated secondary mirrors for 3-2-1 fan-out, e.g. "s3,b2".'
+ required: false
+ default: ''
+ zip-level:
+ description: 'Zip compression level 0-9 (lower = faster).'
+ required: false
+ default: '6'
+ encryption-key:
+ description: 'Optional AES-256-CBC key (64 hex chars) for encryption at rest.'
+ required: false
+ default: ''
+ signing-key:
+ description: 'Optional Ed25519 PEM private key to sign the manifest.'
+ required: false
+ default: ''
+ node-version:
+ description: 'Node.js version to run with.'
+ required: false
+ default: '22'
+
+outputs:
+ summary:
+ description: 'JSON backup summary (totals, mirror + delta composition, signature).'
+ value: ${{ steps.run.outputs.summary }}
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Set up Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: ${{ inputs.node-version }}
+
+ - name: Install dependencies
+ shell: bash
+ working-directory: ${{ github.action_path }}
+ run: npm ci --omit=dev || npm install --omit=dev
+
+ - name: Write Google credentials
+ shell: bash
+ working-directory: ${{ github.action_path }}
+ env:
+ GOOGLE_CLIENT_SECRET_JSON: ${{ inputs.google-client-secret }}
+ GOOGLE_TOKEN_JSON: ${{ inputs.google-token }}
+ run: |
+ mkdir -p credentials
+ printf '%s' "$GOOGLE_CLIENT_SECRET_JSON" > credentials/google-client-secret.json
+ printf '%s' "$GOOGLE_TOKEN_JSON" > credentials/google-token.json
+
+ - name: Run backup
+ id: run
+ shell: bash
+ working-directory: ${{ github.action_path }}
+ env:
+ GITHUB_TOKEN: ${{ inputs.github-token }}
+ GITHUB_USER: ${{ inputs.github-user }}
+ GDRIVE_FOLDER_ID: ${{ inputs.gdrive-folder-id }}
+ GOOGLE_CLIENT_SECRET_PATH: ./credentials/google-client-secret.json
+ GOOGLE_TOKEN_PATH: ./credentials/google-token.json
+ BACKUP_INCLUDE: ${{ inputs.include }}
+ INCREMENTAL_MODE: ${{ inputs.incremental-mode }}
+ STORAGE_TARGET: ${{ inputs.storage-target }}
+ BACKUP_MIRROR_TARGETS: ${{ inputs.mirror-targets }}
+ BACKUP_ZIP_LEVEL: ${{ inputs.zip-level }}
+ BACKUP_ENCRYPTION_KEY: ${{ inputs.encryption-key }}
+ BACKUP_SIGNING_KEY: ${{ inputs.signing-key }}
+ BACKUP_TMP_DIR: ${{ runner.temp }}/gh-backup
+ REPOS: ${{ inputs.repos }}
+ run: |
+ node -e "
+ const { runBackup } = require('./src/backup/index');
+ const repos = process.env.REPOS ? process.env.REPOS.split(',').map(r => r.trim()).filter(Boolean) : undefined;
+ runBackup(repos ? { repos } : {})
+ .then(s => {
+ const line = JSON.stringify(s).replace(/\n/g, ' ');
+ require('fs').appendFileSync(process.env.GITHUB_OUTPUT, 'summary=' + line + '\n');
+ console.log('Backup complete:', s.success + '/' + s.total, 'succeeded');
+ process.exit(s.failed > 0 ? 1 : 0);
+ })
+ .catch(e => { console.error(e); process.exit(1); });
+ "
+
+ - name: Clean up credentials
+ if: always()
+ shell: bash
+ working-directory: ${{ github.action_path }}
+ run: rm -rf credentials
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index cd8b30b..eed2c83 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -1,5 +1,5 @@
# GitHub → Google Drive Backup — Technical User Guide
-**Version 4.0.0** | Last updated: 2026-07-17
+**Version 5.0.0** | Last updated: 2026-07-20
---
@@ -68,6 +68,7 @@
15. [PWA & Offline Support](#15-pwa--offline-support)
15A. [Dashboard Insights & Productivity (v3.1)](#15a-dashboard-insights--productivity-v31)
15B. [Reliability & Operations (v3.5)](#15b-reliability--operations-v35)
+15C. [Trust & Distribution (v5.0)](#15c-trust--distribution-v50)
16. [Troubleshooting](#16-troubleshooting)
17. [FAQ](#17-faq)
18. [Version History](#18-version-history)
@@ -991,6 +992,29 @@ The **Reports** tab now offers an **Export JSON** button alongside CSV and the c
---
+## 15C. Trust & Distribution (v5.0)
+
+Version 5.0 makes recoverability and integrity **provable**, and packages the tool for one-line adoption.
+
+
+
+### Signed manifests (Ed25519)
+SHA-256 hashes in the manifest catch corruption; they do not prove a backup wasn't *forged* by whoever can write to the bucket. Set `BACKUP_SIGNING_KEY` to a PEM Ed25519 private key and each session's `manifest.json` is signed to `manifest.json.sig` (`src/lib/manifest-signing.js`). On restore, set `BACKUP_SIGNING_PUBLIC_KEY` to verify it; the result (`valid` / `invalid` / `unsigned` / `no-key`) is logged and returned. Set `BACKUP_REQUIRE_SIGNATURE=true` to **abort** a restore on a missing or invalid signature. The signing public-key fingerprint is recorded in `backup-summary.json`. Generate a keypair with `require('./src/lib/manifest-signing').generateKeyPair()`.
+
+### Recovery scorecard
+The monthly restore drill (`monthly-restore-test.yml`) times the dry-run and publishes `docs/recovery-scorecard.json` via `src/lib/recovery-scorecard.js` — `status`, `last_verified`, and `rto_seconds`. It renders as the **Restore Verified** shields.io endpoint badge in the README and a **Restore Verified** tile in the dashboard's System Health panel, turning "backups exist" into "restores are verified, and here's how fast."
+
+### Tamper-evident audit log
+Audit entries (`src/audit/log.js`) are hash-chained: each entry carries `prev` (the SHA-256 of the previous entry) and its own `hash`. `verifyChain()` detects any edit (`hash-mismatch`) or deletion (`prev-mismatch`), so the audit trail cannot be silently rewritten.
+
+### Use as a GitHub Action
+`action.yml` exposes the backup as a composite action — `uses: OmarRao/github-gdrive-backup@v5` — so consumers compose it into their own workflows without forking. Inputs cover token/folder/credentials plus `incremental-mode`, `mirror-targets`, `zip-level`, `encryption-key`, and `signing-key`; it emits a JSON `summary` output.
+
+### Container image (GHCR)
+`publish.yml` builds and pushes `ghcr.io/omarrao/github-gdrive-backup` on each release, attaching an SBOM and a signed build-provenance attestation. Run it directly: `docker run ghcr.io/omarrao/github-gdrive-backup:v5 backup`.
+
+---
+
## 15B. Reliability & Operations (v3.5)
Version 3.5 is a reliability, DR-depth, and operations release. Highlights:
@@ -1177,6 +1201,8 @@ The dashboard is now installable as a Progressive Web App:
| Version | Date | Highlights |
|---|---|---|
+| **5.0.0** | 2026-07-20 | **Trust & Distribution**: Ed25519 signed manifests + restore verification, recovery scorecard badge/tile, hash-chained audit log, GitHub Action (`action.yml`), GHCR container publish with SBOM + provenance. |
+| **4.1.0** | 2026-07-17 | Performance: streaming SHA-256/AES crypto, batched git object checks, tunable zip level, SPA resource hints. |
| **4.0.0** | 2026-07-17 | **Relicensed to AGPL-3.0 + commercial dual-license** (was MIT). New `LICENSE` (AGPL-3.0), `COMMERCIAL-LICENSE.md`, `TRADEMARKS.md`, `DEPENDENCY-LICENSE-REVIEW.md`; SPDX headers on all owned source; README/USERGUIDE licensing sections. No application behavior changed. |
| **3.0.0** | 2026-06-30 | 14 new features: Email Digest (SendGrid), MS Teams webhook, PAT Rotation Reminder, Session Diff, GFS Retention, SLA Breach Alerts, Compliance CSV Export, Anomaly Detection, Azure Blob, Backblaze B2, SBOM, Monthly Auto-Restore Test, PWA/offline, Repo Search |
| **2.1.0** | 2026-06-26 | **Firebase Google Sign-In**, **Demo Mode** (sample data, yellow banner, no live calls), full UX overhaul, refreshed stat cards and reports, keyboard shortcuts, dark mode polish |
diff --git a/docs/index.html b/docs/index.html
index f708bcc..1a6e161 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -2624,6 +2624,9 @@
Recent Runs
targets: ['s3', 'b2'],
perTarget: { s3: { ok: archived, failed: 0 }, b2: { ok: archived - 1, failed: 1 } },
},
+ // Trust signals: last verified restore drill + manifest signature.
+ recovery: { status: 'verified', rto_seconds: 252, last_verified: ago(2 * D), session: sessions[2].name },
+ signature: { algorithm: 'ed25519', key_fingerprint: 'a1b2c3d4e5f6a7b8' },
};
})();
@@ -2927,6 +2930,8 @@ Recent Runs
// Pull the latest session's backup-summary.json for composition/fan-out.
_latestSummary = await loadLatestSummary(files).catch(() => null);
} catch { document.getElementById('stat-sessions').textContent = '—'; }
+ // Recovery scorecard is published to the site root (same-origin), not Drive.
+ try { window._recovery = await fetch('recovery-scorecard.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : null); } catch { window._recovery = null; }
_dashRuns = allRuns.slice(0,8);
renderRuns(_dashRuns, document.getElementById('dash-runs'));
renderDashboardWidgets(_liveSessions, allRuns);
@@ -3063,10 +3068,16 @@ Recent Runs
const quota = demo ? DEMO.quota : null;
const quotaPct = quota ? Math.round(quota.usedGB / quota.totalGB * 100) : null;
+ // Recovery scorecard (last verified restore) + manifest signature status.
+ const rec = demo ? DEMO.recovery : window._recovery;
+ const sig = demo ? DEMO.signature : (_latestSummary && _latestSummary.signature);
+
const items = [
{ name: 'Last Backup', val: lastOk == null ? 'No data' : (lastOk ? 'Success' : 'Failed'), led: lastOk == null ? 'idle' : (lastOk ? 'ok' : 'err') },
{ name: 'SLA', val: slaOk == null ? '—' : (slaOk ? `Within ${slaHours}h` : 'Breached'), led: slaOk == null ? 'idle' : (slaOk ? 'ok' : 'err') },
{ name: 'Anomaly', val: anomaly == null ? 'No data' : (anomaly ? 'Detected' : 'Normal'), led: anomaly == null ? 'idle' : (anomaly ? 'warn' : 'ok') },
+ { name: 'Restore Verified', val: !rec || rec.status === 'pending' ? 'No drill yet' : (rec.status === 'verified' ? `${Math.round(rec.rto_seconds/60*10)/10}m RTO` : 'Failed'), led: !rec || rec.status === 'pending' ? 'idle' : (rec.status === 'verified' ? 'ok' : 'err') },
+ { name: 'Manifest Signature', val: !sig ? 'Unsigned' : `Signed (${sig.key_fingerprint ? sig.key_fingerprint.slice(0,8) : sig.algorithm || 'ed25519'})`, led: sig ? 'ok' : 'idle' },
{ name: 'Storage', val: quotaPct == null ? '—' : `${quotaPct}% used`, led: quotaPct == null ? 'idle' : (quotaPct > 90 ? 'err' : quotaPct > 80 ? 'warn' : 'ok') },
{ name: 'GitHub', val: (demo || ghToken) ? 'Connected' : 'Not linked', led: (demo || ghToken) ? 'ok' : 'idle' },
{ name: 'Drive', val: (demo || driveToken) ? 'Connected' : 'Not linked', led: (demo || driveToken) ? 'ok' : 'idle' },
diff --git a/docs/recovery-scorecard.json b/docs/recovery-scorecard.json
new file mode 100644
index 0000000..c0cf4c1
--- /dev/null
+++ b/docs/recovery-scorecard.json
@@ -0,0 +1,12 @@
+{
+ "schemaVersion": 1,
+ "label": "restore verified",
+ "message": "pending first drill",
+ "color": "lightgrey",
+ "status": "pending",
+ "last_verified": null,
+ "rto_seconds": null,
+ "session": null,
+ "repos": null,
+ "signature": null
+}
diff --git a/docs/screenshots/trust-distribution.svg b/docs/screenshots/trust-distribution.svg
new file mode 100644
index 0000000..8f7f8a2
--- /dev/null
+++ b/docs/screenshots/trust-distribution.svg
@@ -0,0 +1,57 @@
+
diff --git a/docs/sw.js b/docs/sw.js
index 63e9064..75c254f 100644
--- a/docs/sw.js
+++ b/docs/sw.js
@@ -3,7 +3,7 @@
// This file is available under the GNU Affero General Public License v3.0
// or under a separate commercial license.
// Service Worker — cache-first for the app shell
-const CACHE = 'gh-backup-v5';
+const CACHE = 'gh-backup-v6';
const SHELL = [
'/github-gdrive-backup/',
'/github-gdrive-backup/index.html',
diff --git a/package-lock.json b/package-lock.json
index 6b7c313..588a3a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "github-gdrive-backup",
- "version": "4.1.0",
+ "version": "5.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "github-gdrive-backup",
- "version": "4.1.0",
+ "version": "5.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@aws-sdk/client-s3": "^3.600.0",
diff --git a/package.json b/package.json
index fd7c880..52d47a3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "github-gdrive-backup",
- "version": "4.1.0",
+ "version": "5.0.0",
"description": "Back up every GitHub repository to Google Drive — code, issues, PRs, releases, wiki, labels, milestones — and restore with one click.",
"main": "src/server/app.js",
"bin": {
diff --git a/src/audit/log.js b/src/audit/log.js
index 658fab6..d74bcc5 100644
--- a/src/audit/log.js
+++ b/src/audit/log.js
@@ -3,16 +3,35 @@
// This file is available under the GNU Affero General Public License v3.0
// or under a separate commercial license.
/**
- * Structured JSON-lines audit log. Each entry is one JSON object per line so the
- * log is both append-only-cheap and machine-parseable (filterable in the UI,
- * ingestible by a SIEM). Replaces the previous free-text `docs/audit.log`.
+ * Structured, tamper-evident JSON-lines audit log.
+ *
+ * Each entry is one JSON object per line (append-only-cheap, SIEM-ingestible).
+ * Entries are hash-chained: every entry carries `prev`, the SHA-256 of the
+ * previous entry's canonical bytes. Deleting or editing any past entry breaks
+ * the chain, which `verifyChain` detects — so the log cannot be silently
+ * rewritten after the fact.
*/
const fs = require('fs');
const path = require('path');
+const crypto = require('crypto');
-/** Build a single audit entry object (pure — easy to test). */
-function buildEntry(event, fields = {}) {
- return { ts: new Date().toISOString(), event, ...fields };
+const GENESIS = '0'.repeat(64);
+
+/** SHA-256 (hex) of an entry's canonical JSON (excludes the `hash` field). */
+function hashEntry(entry) {
+ const { hash, ...rest } = entry; // eslint-disable-line no-unused-vars
+ return crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex');
+}
+
+/**
+ * Build a single audit entry, chained to the previous entry's hash.
+ * @param {string} event
+ * @param {object} fields
+ * @param {string} prevHash hash of the previous entry (GENESIS for the first)
+ */
+function buildEntry(event, fields = {}, prevHash = GENESIS) {
+ const base = { ts: new Date().toISOString(), event, ...fields, prev: prevHash };
+ return { ...base, hash: hashEntry(base) };
}
/** Serialize an entry to a single JSONL line (no embedded newlines). */
@@ -20,17 +39,46 @@ function formatLine(entry) {
return JSON.stringify(entry).replace(/\n/g, ' ') + '\n';
}
-/** Append an event to a JSONL audit file, creating parent dirs as needed. */
-function append(file, event, fields = {}) {
- fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
- fs.appendFileSync(file, formatLine(buildEntry(event, fields)));
-}
-
-/** Parse a JSONL audit file into an array of entries (skips malformed lines). */
+/** Parse a JSONL audit file into entries (skips malformed lines). */
function parse(text) {
return (text || '').split('\n').filter(Boolean).map(l => {
try { return JSON.parse(l); } catch { return null; }
}).filter(Boolean);
}
-module.exports = { buildEntry, formatLine, append, parse };
+/** Hash of the last entry in a file (GENESIS if none/unreadable). */
+function lastHash(file) {
+ try {
+ const entries = parse(fs.readFileSync(file, 'utf8'));
+ if (!entries.length) return GENESIS;
+ return entries[entries.length - 1].hash || hashEntry(entries[entries.length - 1]);
+ } catch {
+ return GENESIS;
+ }
+}
+
+/** Append an event, chaining it to the current tail of the file. */
+function append(file, event, fields = {}) {
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
+ const entry = buildEntry(event, fields, lastHash(file));
+ fs.appendFileSync(file, formatLine(entry));
+ return entry;
+}
+
+/**
+ * Verify the hash chain of a parsed/serialized log.
+ * @returns {{ok:boolean, brokenAt:number|null, reason?:string}}
+ */
+function verifyChain(textOrEntries) {
+ const entries = Array.isArray(textOrEntries) ? textOrEntries : parse(textOrEntries);
+ let prev = GENESIS;
+ for (let i = 0; i < entries.length; i++) {
+ const e = entries[i];
+ if (e.prev !== prev) return { ok: false, brokenAt: i, reason: 'prev-mismatch' };
+ if (e.hash !== hashEntry(e)) return { ok: false, brokenAt: i, reason: 'hash-mismatch' };
+ prev = e.hash;
+ }
+ return { ok: true, brokenAt: null };
+}
+
+module.exports = { GENESIS, buildEntry, hashEntry, formatLine, parse, lastHash, append, verifyChain };
diff --git a/src/backup/index.js b/src/backup/index.js
index 4363039..75746aa 100644
--- a/src/backup/index.js
+++ b/src/backup/index.js
@@ -13,6 +13,7 @@ const fanout = require('./storage/fanout');
const incremental = require('./incremental');
const { renderIssuesHtml } = require('./issues-archive');
const { sha256File, encryptFile } = require('../lib/archive-crypto');
+const signing = require('../lib/manifest-signing');
const logger = require('../logger');
const ZIP_LEVEL = Math.min(9, Math.max(0, parseInt(process.env.BACKUP_ZIP_LEVEL || '6', 10)));
@@ -495,11 +496,34 @@ async function runBackup(options = {}) {
};
const manifestPath = path.join(TMP, `manifest-${timestamp}.json`);
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
+ const manifestBytes = JSON.stringify(manifest, null, 2);
+ fs.writeFileSync(manifestPath, manifestBytes);
await drive.uploadFile(manifestPath, sessionFolder);
if (mirrorFolders) {
await fanout.mirrorFile(manifestPath, mirrorFolders, 'manifest.json');
}
+
+ // Optional Ed25519 manifest signing (tamper-evidence). Set BACKUP_SIGNING_KEY
+ // to a PEM Ed25519 private key; a manifest.json.sig is written alongside.
+ let signature = null;
+ const signingKey = process.env.BACKUP_SIGNING_KEY;
+ if (signingKey) {
+ try {
+ const sig = signing.sign(manifestBytes, signingKey);
+ const fp = process.env.BACKUP_SIGNING_PUBLIC_KEY
+ ? signing.fingerprint(process.env.BACKUP_SIGNING_PUBLIC_KEY) : null;
+ const sigPath = path.join(TMP, `manifest-${timestamp}.json.sig`);
+ fs.writeFileSync(sigPath, sig);
+ await drive.uploadFile(sigPath, sessionFolder, 'text/plain', 'manifest.json.sig');
+ if (mirrorFolders) await fanout.mirrorFile(sigPath, mirrorFolders, 'manifest.json.sig');
+ fs.rmSync(sigPath, { force: true });
+ signature = { algorithm: 'ed25519', ...(fp ? { key_fingerprint: fp } : {}) };
+ logger.info(`Manifest signed (ed25519${fp ? ', key ' + fp : ''})`);
+ } catch (e) {
+ logger.error(`Manifest signing failed: ${e.message}`);
+ }
+ }
+
fs.rmSync(manifestPath, { force: true });
logger.info(`Manifest written with ${manifestRepos.length} entries`);
@@ -532,6 +556,7 @@ async function runBackup(options = {}) {
failed: results.filter(r => r.status === 'failed').length,
...(mirror ? { mirror } : {}),
...(incrementalSummary ? { incremental: incrementalSummary } : {}),
+ ...(signature ? { signature } : {}),
results,
};
diff --git a/src/lib/manifest-signing.js b/src/lib/manifest-signing.js
new file mode 100644
index 0000000..f4cdd3e
--- /dev/null
+++ b/src/lib/manifest-signing.js
@@ -0,0 +1,50 @@
+// Copyright (c) 2026 Omar Rao
+// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
+// This file is available under the GNU Affero General Public License v3.0
+// or under a separate commercial license.
+/**
+ * Cryptographic manifest signing (Ed25519).
+ *
+ * SHA-256 hashes in the manifest catch *corruption*; they do not prove a backup
+ * wasn't *forged* by whoever can write to the storage bucket. Signing the
+ * manifest with a private key held only by the backup owner makes tampering
+ * detectable: a restore verifies the signature with the corresponding public
+ * key before trusting the session.
+ *
+ * Uses Node's built-in Ed25519 — no external binary, keys are standard PEM.
+ */
+const crypto = require('crypto');
+
+/** Generate an Ed25519 keypair as PEM strings (helper for setup/tests). */
+function generateKeyPair() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
+ return {
+ privateKey: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
+ publicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
+ };
+}
+
+/** Sign a string/Buffer with an Ed25519 PEM private key → base64 signature. */
+function sign(data, privateKeyPem) {
+ const key = crypto.createPrivateKey(privateKeyPem);
+ // Ed25519 signs the message directly (algorithm arg must be null).
+ return crypto.sign(null, Buffer.from(data), key).toString('base64');
+}
+
+/** Verify a base64 Ed25519 signature over data with a PEM public key. */
+function verify(data, signatureB64, publicKeyPem) {
+ try {
+ const key = crypto.createPublicKey(publicKeyPem);
+ return crypto.verify(null, Buffer.from(data), key, Buffer.from(signatureB64, 'base64'));
+ } catch {
+ return false;
+ }
+}
+
+/** Short, human-readable fingerprint of a public key (SHA-256, first 16 hex). */
+function fingerprint(publicKeyPem) {
+ const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
+ return crypto.createHash('sha256').update(der).digest('hex').slice(0, 16);
+}
+
+module.exports = { generateKeyPair, sign, verify, fingerprint };
diff --git a/src/lib/recovery-scorecard.js b/src/lib/recovery-scorecard.js
new file mode 100644
index 0000000..62503c8
--- /dev/null
+++ b/src/lib/recovery-scorecard.js
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Omar Rao
+// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
+// This file is available under the GNU Affero General Public License v3.0
+// or under a separate commercial license.
+/**
+ * Recovery scorecard — turns a restore drill into a publishable proof of
+ * recoverability: when it last succeeded and how long it took (RTO). Rendered
+ * as a README badge and a dashboard tile so "backups exist" becomes "restores
+ * are verified".
+ */
+
+/**
+ * @param {object} o
+ * @param {string} o.session Session name that was drilled.
+ * @param {boolean} o.ok Whether the drill succeeded.
+ * @param {number} o.startedAt epoch ms when the drill started.
+ * @param {number} o.finishedAt epoch ms when it finished.
+ * @param {number} [o.repos] Repos verified.
+ * @param {string} [o.signature] Manifest signature status (valid/unsigned/…).
+ * @returns {object} scorecard suitable for JSON + shields.io endpoint badge.
+ */
+function buildScorecard(o) {
+ const rto = Math.max(0, Math.round(((o.finishedAt || 0) - (o.startedAt || 0)) / 1000));
+ const status = o.ok ? 'verified' : 'failed';
+ return {
+ schemaVersion: 1, // shields.io endpoint badge fields ↓
+ label: 'restore verified',
+ message: o.ok ? `${rto}s RTO · ${new Date(o.finishedAt).toISOString().slice(0, 10)}` : 'FAILED',
+ color: o.ok ? 'brightgreen' : 'red',
+ // ↑ badge fields · ↓ detail fields
+ status,
+ last_verified: new Date(o.finishedAt || Date.now()).toISOString(),
+ rto_seconds: rto,
+ session: o.session || null,
+ repos: (o.repos !== null && o.repos !== undefined) ? o.repos : null,
+ signature: o.signature || null,
+ };
+}
+
+/** Format RTO seconds as a compact human string (e.g. "4m 12s"). */
+function formatRto(seconds) {
+ const s = Math.max(0, Math.round(seconds || 0));
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ return `${m}m ${s % 60}s`;
+}
+
+module.exports = { buildScorecard, formatRto };
diff --git a/src/restore/index.js b/src/restore/index.js
index b7ac36d..b985d87 100644
--- a/src/restore/index.js
+++ b/src/restore/index.js
@@ -11,6 +11,7 @@ const GoogleDriveClient = require('../backup/gdrive');
const { getProvider } = require('./providers');
const incremental = require('../backup/incremental');
const { sha256File, decryptFile: decryptFileStream } = require('../lib/archive-crypto');
+const signing = require('../lib/manifest-signing');
const logger = require('../logger');
const TMP = path.resolve(process.env.BACKUP_TMP_DIR || './tmp');
@@ -52,6 +53,47 @@ async function loadManifest(drive, files) {
}
}
+/**
+ * Verify the Ed25519 signature over manifest.json for a session, when a
+ * manifest.json.sig is present and a public key is configured
+ * (BACKUP_SIGNING_PUBLIC_KEY). Returns 'valid' | 'invalid' | 'unsigned' |
+ * 'no-key'. If BACKUP_REQUIRE_SIGNATURE=true, an invalid/missing signature
+ * throws to abort the restore.
+ */
+async function verifySessionSignature(drive, files) {
+ const require_ = String(process.env.BACKUP_REQUIRE_SIGNATURE || '').toLowerCase() === 'true';
+ const manifestFile = files.find(f => f.name === 'manifest.json');
+ const sigFile = files.find(f => f.name === 'manifest.json.sig');
+ const pubKey = process.env.BACKUP_SIGNING_PUBLIC_KEY;
+
+ if (!sigFile) {
+ if (require_) throw new Error('Signature required but manifest.json.sig is missing for this session.');
+ return 'unsigned';
+ }
+ if (!pubKey) {
+ if (require_) throw new Error('Signature present but BACKUP_SIGNING_PUBLIC_KEY is not set.');
+ logger.warn('Session is signed but no BACKUP_SIGNING_PUBLIC_KEY provided — signature not verified.');
+ return 'no-key';
+ }
+ const mTmp = path.join(TMP, `verify-manifest-${Date.now()}.json`);
+ const sTmp = path.join(TMP, `verify-sig-${Date.now()}.sig`);
+ try {
+ await drive.downloadFile(manifestFile.id, mTmp);
+ await drive.downloadFile(sigFile.id, sTmp);
+ const ok = signing.verify(fs.readFileSync(mTmp), fs.readFileSync(sTmp, 'utf8').trim(), pubKey);
+ if (!ok) {
+ if (require_) throw new Error('Manifest signature verification FAILED — aborting restore.');
+ logger.warn('Manifest signature verification FAILED for this session.');
+ return 'invalid';
+ }
+ logger.info('Manifest signature verified ✓');
+ return 'valid';
+ } finally {
+ fs.rmSync(mTmp, { force: true });
+ fs.rmSync(sTmp, { force: true });
+ }
+}
+
/** Download a bundle file, decrypting a .enc archive if needed. Returns local path. */
async function fetchBundle(drive, bundleFile, destBase) {
const isEnc = bundleFile.name.endsWith('.enc');
@@ -291,6 +333,9 @@ async function runRestore(options = {}) {
const repoFolders = await drive.listFolderContents(sessionId);
const repoList = repoFolders.filter(f => f.mimeType === 'application/vnd.google-apps.folder');
+ // Tamper-evidence: verify the session's manifest signature (if present/required).
+ const signatureStatus = await verifySessionSignature(drive, repoFolders);
+
const reposToRestore = options.repos?.length
? repoList.filter(r => options.repos.includes(r.name))
: repoList;
@@ -334,7 +379,8 @@ async function runRestore(options = {}) {
}
}
- logger.info(`Restore complete: ${results.filter(r => r.status === 'success').length}/${results.length} succeeded`);
+ logger.info(`Restore complete: ${results.filter(r => r.status === 'success').length}/${results.length} succeeded (manifest signature: ${signatureStatus})`);
+ results.signatureStatus = signatureStatus;
return results;
}
diff --git a/tests/audit.test.js b/tests/audit.test.js
index d3a9185..f569219 100644
--- a/tests/audit.test.js
+++ b/tests/audit.test.js
@@ -41,3 +41,34 @@ describe('audit JSONL', () => {
expect(entries.map(e => e.event)).toEqual(['a', 'b']);
});
});
+
+describe('audit hash chain (tamper-evidence)', () => {
+ test('appended entries form a valid chain', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-chain-'));
+ const file = path.join(dir, 'audit.jsonl');
+ audit.append(file, 'backup', { ok: 1 });
+ audit.append(file, 'restore', { ok: 2 });
+ audit.append(file, 'cleanup', { deleted: 3 });
+ const text = fs.readFileSync(file, 'utf8');
+ fs.rmSync(dir, { recursive: true, force: true });
+ const entries = audit.parse(text);
+ expect(entries[0].prev).toBe(audit.GENESIS);
+ expect(entries[1].prev).toBe(entries[0].hash);
+ expect(audit.verifyChain(entries)).toEqual({ ok: true, brokenAt: null });
+ });
+
+ test('editing a past entry breaks the chain', () => {
+ const a = audit.buildEntry('backup', { ok: 1 });
+ const b = audit.buildEntry('restore', { ok: 2 }, a.hash);
+ const tampered = { ...a, ok: 999 }; // edit content, keep old hash
+ expect(audit.verifyChain([tampered, b])).toMatchObject({ ok: false, brokenAt: 0, reason: 'hash-mismatch' });
+ });
+
+ test('deleting an entry breaks the chain', () => {
+ const a = audit.buildEntry('backup', { ok: 1 });
+ const b = audit.buildEntry('restore', { ok: 2 }, a.hash);
+ const c = audit.buildEntry('cleanup', { ok: 3 }, b.hash);
+ // Remove the middle entry — c.prev no longer matches a.hash.
+ expect(audit.verifyChain([a, c])).toMatchObject({ ok: false, brokenAt: 1, reason: 'prev-mismatch' });
+ });
+});
diff --git a/tests/manifest-signing.test.js b/tests/manifest-signing.test.js
new file mode 100644
index 0000000..131c5c2
--- /dev/null
+++ b/tests/manifest-signing.test.js
@@ -0,0 +1,59 @@
+// Copyright (c) 2026 Omar Rao
+// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
+// This file is available under the GNU Affero General Public License v3.0
+// or under a separate commercial license.
+'use strict';
+
+/**
+ * Tests for Ed25519 manifest signing — sign/verify round-trip, tamper and
+ * wrong-key rejection, and stable public-key fingerprints.
+ */
+
+const signing = require('../src/lib/manifest-signing');
+
+describe('manifest signing (ed25519)', () => {
+ let keys;
+ beforeAll(() => { keys = signing.generateKeyPair(); });
+
+ test('generateKeyPair returns PEM private + public keys', () => {
+ expect(keys.privateKey).toMatch(/BEGIN PRIVATE KEY/);
+ expect(keys.publicKey).toMatch(/BEGIN PUBLIC KEY/);
+ });
+
+ test('a valid signature verifies', () => {
+ const data = JSON.stringify({ session: 'backup-x', repos: [{ repo: 'a', sha256: 'deadbeef' }] });
+ const sig = signing.sign(data, keys.privateKey);
+ expect(signing.verify(data, sig, keys.publicKey)).toBe(true);
+ });
+
+ test('tampered data fails verification', () => {
+ const data = 'original manifest bytes';
+ const sig = signing.sign(data, keys.privateKey);
+ expect(signing.verify('tampered manifest bytes', sig, keys.publicKey)).toBe(false);
+ });
+
+ test('wrong public key fails verification', () => {
+ const other = signing.generateKeyPair();
+ const data = 'manifest';
+ const sig = signing.sign(data, keys.privateKey);
+ expect(signing.verify(data, sig, other.publicKey)).toBe(false);
+ });
+
+ test('garbage signature is rejected, not thrown', () => {
+ expect(signing.verify('data', 'not-a-real-signature', keys.publicKey)).toBe(false);
+ });
+
+ test('fingerprint is stable and 16 hex chars', () => {
+ const fp1 = signing.fingerprint(keys.publicKey);
+ const fp2 = signing.fingerprint(keys.publicKey);
+ expect(fp1).toBe(fp2);
+ expect(fp1).toMatch(/^[0-9a-f]{16}$/);
+ expect(signing.fingerprint(signing.generateKeyPair().publicKey)).not.toBe(fp1);
+ });
+
+ test('Buffer input signs and verifies (matches file-bytes usage)', () => {
+ const buf = Buffer.from('binary\x00manifest\xff', 'binary');
+ const sig = signing.sign(buf, keys.privateKey);
+ expect(signing.verify(buf, sig, keys.publicKey)).toBe(true);
+ });
+});
diff --git a/tests/recovery-scorecard.test.js b/tests/recovery-scorecard.test.js
new file mode 100644
index 0000000..1b4cd0e
--- /dev/null
+++ b/tests/recovery-scorecard.test.js
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 Omar Rao
+// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
+// This file is available under the GNU Affero General Public License v3.0
+// or under a separate commercial license.
+'use strict';
+
+const { buildScorecard, formatRto } = require('../src/lib/recovery-scorecard');
+
+describe('recovery scorecard', () => {
+ test('successful drill → verified badge with RTO + detail fields', () => {
+ const start = Date.parse('2026-07-20T00:00:00Z');
+ const sc = buildScorecard({ session: 'backup-x', ok: true, startedAt: start, finishedAt: start + 252000, repos: 5, signature: 'valid' });
+ expect(sc.status).toBe('verified');
+ expect(sc.rto_seconds).toBe(252);
+ expect(sc.color).toBe('brightgreen');
+ expect(sc.label).toBe('restore verified');
+ expect(sc.message).toContain('252s RTO');
+ expect(sc.repos).toBe(5);
+ expect(sc.signature).toBe('valid');
+ expect(sc.schemaVersion).toBe(1); // shields.io endpoint contract
+ });
+
+ test('failed drill → red FAILED badge', () => {
+ const t = Date.now();
+ const sc = buildScorecard({ session: 'backup-y', ok: false, startedAt: t, finishedAt: t + 1000 });
+ expect(sc.status).toBe('failed');
+ expect(sc.color).toBe('red');
+ expect(sc.message).toBe('FAILED');
+ });
+
+ test('negative/backwards timing clamps to 0', () => {
+ const t = Date.now();
+ expect(buildScorecard({ ok: true, startedAt: t, finishedAt: t - 5000 }).rto_seconds).toBe(0);
+ });
+
+ test('formatRto renders seconds and minutes', () => {
+ expect(formatRto(45)).toBe('45s');
+ expect(formatRto(252)).toBe('4m 12s');
+ expect(formatRto(0)).toBe('0s');
+ });
+});