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](https://img.shields.io/badge/Commercial%20License-available-7c3aed)](COMMERCIAL-LICENSE.md) [![GitHub Actions](https://img.shields.io/badge/Automated-GitHub%20Actions-1a7f37?logo=github-actions&logoColor=white)](https://github.com/OmarRao/github-gdrive-backup/actions) [![Backup Status](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FOmarRao%2Fgithub-gdrive-backup%2Fmain%2Fdocs%2Fstatus.json&query=%24.status&label=Backup%20Status&color=22c55e&logo=githubactions&logoColor=white)](https://github.com/OmarRao/github-gdrive-backup/actions) +[![Restore Verified](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FOmarRao%2Fgithub-gdrive-backup%2Fmain%2Fdocs%2Frecovery-scorecard.json)](https://github.com/OmarRao/github-gdrive-backup/actions/workflows/monthly-restore-test.yml) [![Live Dashboard](https://img.shields.io/badge/Live%20Dashboard-GitHub%20Pages-2563eb?logo=github&logoColor=white)](https://omarrao.github.io/github-gdrive-backup/) [![Node.js](https://img.shields.io/badge/Node.js-22-339933?logo=node.js&logoColor=white)](https://nodejs.org/) [![Google Drive](https://img.shields.io/badge/Storage-Google%20Drive-4285F4?logo=googledrive&logoColor=white)](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**. + +![Trust & Distribution](docs/screenshots/trust-distribution.svg) + +- **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. + +![Trust & Distribution](screenshots/trust-distribution.svg) + +### 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 @@ + + + Trust & Distribution + Signed backups, verified restores, tamper-evident audit — packaged as a GitHub Action & container image. + + + + 🔏 + Signed manifests + Ed25519 signature over manifest.json + detects forgery, not just corruption. + + + Signature verified ✓ key a1b2c3d4 + BACKUP_SIGNING_KEY · verified on restore + + + + 🩺 + Recovery scorecard + Monthly restore drill publishes proof + of recoverability + RTO. + + restore verified + + 4m 12s + docs/recovery-scorecard.json → README badge + + + + ⛓️ + Hash-chained audit + Each entry carries the prior entry's + hash — editing history is detectable. + + { event:backup, prev:0000… } + { event:restore, prev:9f3a… } + { event:cleanup, prev:c1d8… } + + + + + One-line adoption + GitHub Action (Marketplace) — drop into any workflow: + + + - uses: OmarRao/github-gdrive-backup@v5 + with: + github-token: ${{ secrets.GH_TOKEN }} + gdrive-folder-id: ${{ secrets.FOLDER }} + + Container image (GHCR): + + docker run ghcr.io/omarrao/ + github-gdrive-backup:v5 + Published with SBOM + signed provenance. + 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'); + }); +});