{"id":48733782,"url":"https://github.com/dotcoocoo/hermitstash-sync","last_synced_at":"2026-05-18T08:08:43.346Z","repository":{"id":350772618,"uuid":"1208212213","full_name":"dotCooCoo/hermitstash-sync","owner":"dotCooCoo","description":"Desktop sync client for HermitStash. PQC TLS, WebSocket real-time sync, zero-dependency Node.js daemon.","archived":false,"fork":false,"pushed_at":"2026-05-09T01:54:56.000Z","size":6394,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-09T02:41:46.307Z","etag":null,"topics":["desktop-client","file-sync","hermitstash","ml-kem","mtls","nodejs","post-quantum","pqc-tls","websocket","zero-dependency"],"latest_commit_sha":null,"homepage":"https://hermitstash.com","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/dotCooCoo.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"ko_fi":"dotcoocoo"}},"created_at":"2026-04-12T01:08:36.000Z","updated_at":"2026-05-09T01:54:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/dotCooCoo/hermitstash-sync","commit_stats":null,"previous_names":["dotcoocoo/hermitstash-sync"],"tags_count":50,"template":false,"template_full_name":null,"purl":"pkg:github/dotCooCoo/hermitstash-sync","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash-sync","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash-sync/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash-sync/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash-sync/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/dotCooCoo","download_url":"https://codeload.github.com/dotCooCoo/hermitstash-sync/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash-sync/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32995915,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-13T13:14:54.681Z","status":"ssl_error","status_checked_at":"2026-05-13T13:14:51.610Z","response_time":115,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["desktop-client","file-sync","hermitstash","ml-kem","mtls","nodejs","post-quantum","pqc-tls","websocket","zero-dependency"],"created_at":"2026-04-12T03:03:49.169Z","updated_at":"2026-05-18T08:08:43.337Z","avatar_url":"https://github.com/dotCooCoo.png","language":"JavaScript","funding_links":["https://ko-fi.com/dotcoocoo"],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"public/img/logos/green.svg\" alt=\"HermitStash Sync\" width=\"120\" /\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003eHermitStash Sync\u003c/h1\u003e\n\n\u003cp align=\"center\"\u003e\n  Desktop file sync client for \u003ca href=\"https://github.com/dotCooCoo/hermitstash\"\u003eHermitStash\u003c/a\u003e — post-quantum encrypted, self-hosted file sync.\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"LICENSE\"\u003eAGPL-3.0\u003c/a\u003e · \u003ca href=\"SECURITY.md\"\u003eSecurity Policy\u003c/a\u003e · \u003ca href=\"https://ko-fi.com/dotcoocoo\"\u003eSupport on Ko-fi\u003c/a\u003e\n\u003c/p\u003e\n\n---\n\n### A quick note\n\nHermitStash Sync is the companion to [HermitStash](https://github.com/dotCooCoo/hermitstash) — same author, same philosophy, same caveats. If you haven't read the note at the top of the main repo, the short version is: this is a personal project, built by someone who is not a cryptographer, and it hasn't been audited.\n\nThis client inherits its security posture from HermitStash and from Node.js's OpenSSL 3.5 — I'm not rolling my own TLS or inventing key exchanges. But a sync client introduces its own surface area (file watching, state tracking, daemon lifecycle), and those parts are entirely my own work. I've tried to be careful, but \"tried to be careful\" is not a substitute for a professional review.\n\nIf you're already running HermitStash and you want your files to show up on the other end without dragging them there yourself — this is for that. If you're evaluating it for something where reliability and security truly matter, please factor in that it's one person, spare time, and zero formal review.\n\n— .CooCoo ([@dotCooCoo](https://github.com/dotCooCoo))\n\n\u003e **Status:** Personal project · Not audited · API may change · Use at your own risk\n\n---\n\n## Built on [blamejs](https://github.com/blamejs/blamejs)\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://github.com/blamejs/blamejs\"\u003e\n    \u003cimg src=\"https://raw.githubusercontent.com/blamejs/blamejs/main/assets/BlameJS_Logo.svg\" alt=\"blamejs\" width=\"120\" /\u003e\n  \u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003e\u003cem\u003eThe Node framework that owns its stack.\u003c/em\u003e\u003c/strong\u003e\n\u003c/p\u003e\n\nHermitStash Sync vendors [**blamejs**](https://github.com/blamejs/blamejs) — a single, audit-able framework that bundles its own crypto, transports, validation, and process-lifecycle primitives instead of pulling them from forty transitive npm packages. That's how this client ships with **zero npm runtime dependencies** while still getting:\n\n- **PQC TLS 1.3 agent** (`b.pqcAgent`) — `SecP384r1MLKEM1024:X25519MLKEM768:SecP256r1MLKEM768` posture pinned for both the mTLS sync transport and the auto-update GitHub-release downloader\n- **Hardened HTTP client** (`b.httpClient.request`) — SSRF gate with DNS pinning that closes the resolve-vs-connect TOCTOU window, AbortSignal, idle-vs-wall-clock timeout split, permanent-vs-transient classifier, h2 via ALPN\n- **Hardened WebSocket client** (`b.wsClient`) — RFC 6455 with decompression-bomb defence, UTF-8 fatal validation, ≤125-byte control-frame cap, RSV1-on-continuation rejection, permanent-error classifier\n- **Per-session encryption envelope** (`b.middleware.apiEncrypt` / `b.httpClient.encrypted`) — ML-KEM-1024 + ECDH P-384 + SHAKE256 + XChaCha20-Poly1305, identity-agile 4-byte version header\n- **Daemon lifecycle** (`b.daemon`, `b.appShutdown.pidLock`) — atomic-create pidfile, SIGTERM→SIGKILL escalation, phase-ordered graceful shutdown\n- **Cross-platform file watcher** (`b.watcher`) — fs.watch + event coalescing + symlink-skip + debounce on real kernels, polling fallback for bind mounts and FUSE filesystems\n- **Self-update lifecycle** (`b.selfUpdate.{poll,verify,swap,rollback}`) — semver-aware GitHub Releases poll, ECDSA P-384 detached-signature verify, atomic backup-and-rename swap with rollback\n- **Validation primitives** (`b.safeJson`, `b.safeUrl`, `b.atomicFile`, `b.retry`, `b.safeAsync.repeating`) — depth/size/prototype caps, atomic-rename + fsync, full-jitter exponential backoff, error-swallowing recurring timers\n- **Vendored ML-KEM-1024 + XChaCha20-Poly1305** via blamejs's own pinned `noble-post-quantum` / `noble-ciphers` cjs bundles\n\nThe client sits as a thin specialization on top — sync engine, file watcher integration, state DB, and CLI surface — while the wire layer, crypto layer, and process layer are blamejs's responsibility. Every refresh of `vendor/blamejs/` carries upstream security backports across all of those primitives at once.\n\nIf you're building something else and find yourself reaching for this same set of primitives, give blamejs a look: \u003chttps://github.com/blamejs/blamejs\u003e.\n\n---\n\n## What it does\n\nWatches a local folder and keeps it in sync with a HermitStash server:\n\n- **New files** are uploaded automatically\n- **Modified files** are re-uploaded (server detects and replaces)\n- **Renamed/moved files** are detected by checksum matching — server updates the path without re-uploading the file\n- **Deleted files** are removed from the server\n- **Server-side changes** are downloaded in real-time via WebSocket\n- **Conflict resolution** is last-write-wins — if both sides change a file, the most recent write takes priority\n\nAll connections use PQC TLS with TLS 1.3 minimum and a three-tier hybrid group list (`SecP384r1MLKEM1024` → `X25519MLKEM768` → `SecP256r1MLKEM768`, Level 5 preferred) plus optional mTLS client certificates. Client certs auto-renew on startup when within 60 days of expiry — no admin action needed.\n\n## Requirements\n\n- Node.js 24.14.1+ (vendored blamejs's effective floor; also covers `node:sqlite` and OpenSSL 3.5+ PQC support)\n- HermitStash server v1.9.19+ with sync features enabled. v1.9.19 ships blamejs v0.8.43+ which emits 0xE2-magic envelopes; this client (on blamejs v0.10.6) requires that posture. Servers below v1.9.19 still on the 0xE1 envelope are not compatible.\n\n## Install\n\n```bash\n# From source\ngit clone https://github.com/dotCooCoo/hermitstash-sync.git\ncd hermitstash-sync\n\n# Or use pre-built binary (no Node.js required)\n# Download from Releases for your platform\n\n# Or run in Docker (see below)\ndocker pull ghcr.io/dotcoocoo/hermitstash-sync:latest\n```\n\n## Docker\n\nThe image pulls the signed SEA binary from the matching GitHub Release during build, verifies its SHA3-512 checksum and P-384 ECDSA signature before installing, and runs non-root with a minimal Debian-slim base. Two volumes: `/config` holds persistent state (API key, mTLS certs, state DB, logs), `/data` is the sync folder.\n\n```bash\n# First run — enrolls and starts syncing\ndocker run -d \\\n  --name hermitstash-sync \\\n  -e HERMITSTASH_SERVER_URL=https://hermitstash.example.com \\\n  -e HERMITSTASH_ENROLLMENT_CODE=HSTASH-XXXX-XXXX-XXXX \\\n  -v hermitstash-sync-config:/config \\\n  -v /path/on/host:/data \\\n  --restart unless-stopped \\\n  ghcr.io/dotcoocoo/hermitstash-sync:latest\n```\n\nSubsequent restarts skip enrollment — the API key and mTLS certs persist on the `hermitstash-sync-config` volume. A Compose file is available at `docker-compose.example.yml`.\n\nAuto-update is always disabled inside the container (binary self-replace would violate the immutable-image model — pull a new image tag to upgrade). All other features (mTLS cert auto-renewal, PQC TLS, SHA3-512 checksums, sync bundle semantics) work identically to the native binary.\n\n**Testing:** PRs touching `Dockerfile`, `docker/`, or `scripts/verify-release.js` trigger `.github/workflows/docker-e2e.yml`, which builds the image against the latest published release and runs packaging checks (OCI labels, non-root user, volumes, env defaults, entrypoint error paths). Full container-to-server e2e (enrollment, bidirectional sync, restart persistence, graceful shutdown) runs locally via `node tests/run-all.js` when Docker is available on the dev machine.\n\n## Other deployment platforms\n\nIn addition to the Docker image, the repo ships reference configs for running the daemon natively or on common orchestration platforms. Each is self-contained and shows the sync-client-specific shape: outbound-only, two volumes (`/config` + `/data`), enrollment via env vars on first run.\n\n| Platform | File | Notes |\n| --- | --- | --- |\n| **Unraid** | `unraid-template.xml` | Community Apps template. Point the template URL at `https://raw.githubusercontent.com/dotCooCoo/hermitstash-sync/main/unraid-template.xml` to install. |\n| **systemd (native Linux)** | `deploy/install.sh` + `deploy/hermitstash-sync.service` | `curl | sudo bash` one-liner: downloads the signed SEA binary, verifies SHA3-512 + P-384 ECDSA, installs under `/usr/local/bin/hermitstash-sync`, creates a `hermit` system user, and lays down a hardened systemd unit. Pair with `deploy/update.sh` + timer for unattended updates. Uninstall via `deploy/uninstall.sh`. |\n| **Podman** | `deploy/podman.sh` | Rootless by default (RHEL/Fedora/Alma/Rocky idiomatic). Also generates a user or system systemd unit for auto-restart. Image carries the `io.containers.autoupdate=registry` label so `podman-auto-update.timer` can refresh it. |\n| **Kubernetes** | `deploy/kubernetes.yml` | Namespace + 2 PVCs + Deployment (replicas=1, strategy=Recreate) + Secret for enrollment. No Service — the client is outbound-only. `runAsNonRoot`, `readOnlyRootFilesystem`, dropped capabilities. |\n\nFor fleet deployment, use `deploy/install.sh` inside Ansible / SaltStack / your config-management tool of choice — it's idempotent and respects the standard `VERSION`, `INSTALL_DIR`, `CONFIG_DIR`, `SYNC_DIR`, `SERVICE_USER` env overrides.\n\n### Update mechanisms\n\nEach deployment shape has its own update path. The in-binary self-replace only runs when the daemon has write access to its own executable — otherwise an external updater handles it.\n\n| Deployment | Update path | Enabled by default |\n| --- | --- | --- |\n| **Bare binary** (no systemd) | In-daemon: polls GitHub every 6h, verifies SHA3-512 + ECDSA, renames current → `.prev`, spawns new, 60s probation + auto-rollback on crash. | Yes — config `\"autoUpdate\": true` |\n| **systemd** (via `install.sh`) | External: `hermitstash-sync-update.timer` fires daily + 4h random delay; `update.sh` downloads + verifies + atomic swap + `systemctl restart`, rolls back if the daemon doesn't report RUNNING within 60s. In-daemon path is disabled (can't cross the root/hermit permission boundary under `ProtectSystem=strict`). | Opt-in — install with `HERMITSTASH_AUTO_UPDATE=yes` to enable the timer |\n| **Docker** | Pull a new image tag manually (`docker pull ... \u0026\u0026 docker restart ...`). In-daemon self-replace is hard-disabled — mutating `/usr/local/bin` inside a running image violates the immutable-image model. | No |\n| **Podman** | `podman auto-update` reads the image's `io.containers.autoupdate=registry` label and pulls the newest digest for the current tag. Enable `podman-auto-update.timer` (system or `--user`) to run on a schedule. | Opt-in via timer |\n| **Kubernetes** | Bump the image tag in your manifest and `kubectl apply`. `imagePullPolicy: IfNotPresent` means you need to delete the pod or roll the Deployment to pick up a floating tag. Consider a pinned digest + a GitOps flow for production. | No |\n\n## Quick start\n\n```bash\n# 1. Set up the connection\nhermitstash-sync init\n\n# 2. Start syncing (foreground)\nhermitstash-sync start\n\n# 3. Or run as a background daemon\nhermitstash-sync start --daemon\n\n# 4. Check status\nhermitstash-sync status\n\n# 5. Stop the daemon\nhermitstash-sync stop\n```\n\n## Commands\n\n| Command | Description |\n| --- | --- |\n| `init` | Interactive setup — enrollment code or API key, server URL, sync folder |\n| `init --non-interactive` | Headless enrollment from env vars (`HERMITSTASH_SERVER_URL`, `HERMITSTASH_ENROLLMENT_CODE`, `HERMITSTASH_SYNC_FOLDER`, `HERMITSTASH_AUTO_UPDATE`) — intended for Docker/CI |\n| `start` | Start sync in foreground |\n| `start --daemon` | Start sync as background daemon |\n| `start --no-autoupdate` | Start without polling GitHub Releases for new binaries |\n| `status` | Show sync status, file count, last sync time, errors |\n| `stats` | Print daemon telemetry — uploads/downloads/retries, WS reconnects, upload circuit-breaker state. Reads `$CONFIG_DIR/stats.json` (refreshed every 15s by the daemon). `--json` for machine-readable output, `--prometheus` for textfile-collector exposition. |\n| `stop` | Stop the background daemon |\n| `log` | Show last 50 log lines |\n| `log --follow`, `-f` | Tail the log file in real-time |\n| `resync` | Force a full re-sync from scratch |\n| `repair` | Re-provision mTLS certificate using an admin-issued enrollment code (run this if your cert is revoked or the daemon can't connect after a certificate reissue) |\n| `diagnose` | Bundle non-secret operational state (redacted config, state DB schema, cert subject, rotated logs, stats snapshot, version banner) into a single `.zip` for support handoff. Excludes credentials, the mTLS private key, sync-folder contents, and DB rows. `--out \u003cpath\u003e` overrides the default `./hermitstash-sync-diagnose-\u003cISO\u003e.zip`. |\n| `version` | Show version and OpenSSL info |\n| `--help`, `-h` | Show usage information |\n\n## Configuration\n\nConfig file: `~/.hermitstash-sync/config.json` (or `$HERMITSTASH_SYNC_CONFIG_DIR/config.json` — useful for containers, where `/config` is a common mount point).\n\n```json\n{\n  \"server\": \"https://hermitstash.com\",\n  \"bundleId\": \"your-sync-bundle-id\",\n  \"shareId\": \"your-share-id\",\n  \"syncFolder\": \"/home/user/Documents/synced\",\n  \"mtls\": {\n    \"cert\": \"/path/to/client.crt\",\n    \"key\": \"/path/to/client.key\",\n    \"ca\": \"/path/to/ca.crt\"\n  },\n  \"ignore\": [\"*.log\", \"build/**\"],\n  \"include\": [],\n  \"pinnedServerSpki\": [],\n  \"logLevel\": \"info\",\n  \"autoUpdate\": true,\n  \"autoUpdateChannel\": \"stable\",\n  \"uploadConcurrency\": 4,\n  \"uploadBytesPerSec\": 0,\n  \"downloadBytesPerSec\": 0\n}\n```\n\n### Selective sync (subdir allowlist)\n\nBy default the daemon syncs every file in `syncFolder` not caught by an ignore pattern. To restrict sync to specific subtrees, set `\"include\": [...]` in `config.json` or drop a `.hermitstash-include` file in the sync folder. Pattern grammar matches the ignore matcher: exact path, basename (no `/`), `*.ext`, or `dir/**` for recursive subtree inclusion.\n\n```json\n{\n  \"include\": [\"work/**\", \"photos/2026/**\", \"notes.md\"]\n}\n```\n\nWith selective sync on, the daemon never uploads out-of-scope local files and never downloads out-of-scope server files. Server events for out-of-scope paths are seq-acknowledged but otherwise ignored — the daemon stays in sync without ever materializing the excluded files locally. A rename that crosses the scope boundary is handled as a delete (out-of-scope side) or add (in-scope side) at file granularity. Empty `include` array = everything (default behavior).\n\n### SPKI cert pinning (opt-in)\n\nFor deployments that need to bind the daemon to a specific server identity (defence against compromised public CAs, MITM via stolen-CA cert chains), set `pinnedServerSpki` to one or more SubjectPublicKeyInfo SHA-256 hashes in the `sha256/\u003cbase64\u003e` shape:\n\n```json\n{\n  \"pinnedServerSpki\": [\n    \"sha256/Lp4r8Y2nW/zk0+m9F1xLqHfQ4yC9XQz5RbY8WzVdH+s=\",\n    \"sha256/r1tEqyHFq4N6yL7/W/V1xWqHfQ4yC9XQz5RbY8WzVdH==\"\n  ]\n}\n```\n\nEvery TLS handshake (HTTPS + WebSocket) must produce a leaf cert whose SPKI hash matches one of the listed pins. Layered ON TOP of the default chain + hostname checks — pinning is additive, not a bypass.\n\nThe pin binds to the public-key bytes, not the cert. Cert rotation that reuses the same keypair keeps the pin valid (the deliberate key-continuity property). Planned key rotations are supported by listing both old + new pins during the cutover window.\n\nTo compute your server's pin: run `hermitstash-sync diagnose` and read the `spkiPin` field from `cert-info.json` inside the bundle. Or with openssl:\n\n```bash\nopenssl x509 -in server.crt -pubkey -noout \\\n  | openssl pkey -pubin -outform der \\\n  | openssl dgst -sha256 -binary \\\n  | base64\n# prefix with \"sha256/\" and add to config.pinnedServerSpki\n```\n\nEmpty array (default) = no pinning, the default trust chain wins. Get the pin wrong and the daemon refuses every connection — keep at least one current + one backup pin during rotations.\n\n### Bandwidth limit\n\nCap the bytes-per-second the daemon will push or pull across all concurrent transfers via `\"uploadBytesPerSec\"` / `\"downloadBytesPerSec\"` in `config.json`. Both default to `0` (unlimited). The limit is a shared token bucket per direction, so N parallel uploads share the same budget instead of each getting the full rate. Useful for users on shared connections or metered links.\n\n```json\n{\n  \"uploadBytesPerSec\":   524288,\n  \"downloadBytesPerSec\": 1048576\n}\n```\n\n### Ignore patterns\n\nThe following patterns are always excluded from sync:\n\n| Pattern | Reason |\n| --- | --- |\n| `.DS_Store`, `.Spotlight-V100/**`, `.Trashes/**` | macOS system files |\n| `Thumbs.db`, `ehthumbs.db`, `desktop.ini` | Windows system files |\n| `.git/**`, `.svn/**` | Version control |\n| `node_modules/**`, `__pycache__/**` | Package/build artifacts |\n| `*.tmp`, `*.swp`, `*.swo`, `*~` | Editor temp files |\n| `.hermitstash-sync/**` | Client config directory |\n\nAdd custom patterns in:\n\n- `config.json` → `ignore` array\n- `.hermitstash-ignore` file in the sync folder root (one pattern per line, `#` comments supported)\n\nSupported pattern syntax: exact filename (`file.txt`), extension (`*.log`), and directory recursion (`build/**`).\n\n### API key storage\n\nThe API key is stored in your OS keychain:\n\n- **macOS:** Keychain Access\n- **Linux:** GNOME Keyring / KDE Wallet (via `secret-tool`)\n- **Windows:** Windows Credential Manager\n\nFalls back to `~/.hermitstash-sync/credentials` (permissions `0600`) on headless systems.\n\n### Auto-update\n\nBinary (SEA) installs poll GitHub Releases every 6 hours. When a newer version exists, the daemon downloads the binary for its platform and verifies a P-384 ECDSA signature (DER) over the binary bytes against a public key embedded at build time. Only after the signature verifies does it copy the current binary to `.prev`, atomically rename the new one in place, and spawn itself. If the new binary crashes within 60 seconds of first start, the next launch restores `.prev`.\n\nThe verify path runs through `b.selfUpdate.verify` (auto-detects the algorithm from the embedded public key) and the swap runs through `b.selfUpdate.swap` (atomic rename with cross-device fallback and rollback on failure). Releases v0.6.13 and earlier signed the SHA3-512 digest with raw `ieee-p1363` encoding; v0.6.14 → v0.7.6 signed the binary directly with the curve-default SHA-384 + DER. **v0.7.7+ signs with SHA3-512 + DER** to match `b.selfUpdate.standaloneVerifier`'s hardcoded hash so the install-side (`deploy/install.sh`, Dockerfile verify stage) and the daemon-side auto-update path share one signature shape end-to-end. Existing v0.6.x and v0.7.0 → v0.7.6 binaries cannot auto-update across the v0.7.7 boundary — the signature-hash mismatch will be reported as a verification failure and the install refused. **Manually reinstall v0.7.7+ once** to bridge the gap (re-run `deploy/install.sh`, `docker pull`, or download the new binary from the GitHub Release page).\n\nSource installs (running from `git clone`) do not self-replace — the daemon logs a notice when a new version is out and expects you to `git pull` yourself.\n\nDisable per-invocation with `start --no-autoupdate`, or globally by setting `\"autoUpdate\": false` in `config.json`.\n\nThe signing key is held only by the release pipeline; the daemon cannot install a binary signed by anything else. If no pubkey is embedded in this build, auto-update is disabled and logged at startup.\n\n**Channels:** `\"autoUpdateChannel\": \"stable\"` (default) pins the daemon to releases that GitHub marks non-prerelease. Set `\"autoUpdateChannel\": \"beta\"` to also pick up prereleases — useful for following along with in-development versions. Both channels run the same P-384 ECDSA signature verification; the signing key is shared. Beta-channel rollouts that ship a stable version newer than the current beta will pick the stable one (highest semver-ish tag wins).\n\n### Windows SmartScreen on first launch\n\nWindows binaries are not Authenticode-signed. The first time you run `hermitstash-sync.exe`, Windows Defender SmartScreen may show a \"Windows protected your PC\" dialog and require you to click **More info → Run anyway**. This is a reputation-based warning for unsigned executables; it goes away as more users download and run the same binary.\n\nIf you want to verify authenticity before running:\n\n```bash\n# SHA3-512 checksum\nsha3sum -a 512 -c hermitstash-sync-vX.Y.Z-win-x64.exe.sha3-512\n\n# GPG signature (import the public key once, then verify)\ngpg --import gpg-public-key.asc\ngpg --verify hermitstash-sync-vX.Y.Z-win-x64.exe.asc hermitstash-sync-vX.Y.Z-win-x64.exe\n```\n\nBoth files are attached to every release. The GPG key fingerprint and the ECDSA auto-update pubkey are both baked into the binary itself, so once the first release is trusted, subsequent auto-updates verify against those keys without any further ceremony.\n\n## How sync works\n\n1. On startup, if an mTLS client certificate is configured and within 60 days of expiring, the daemon silently calls `POST /sync/renew-cert` to rotate it using the current API key. No admin action needed unless the cert has already been revoked (use `repair` for that).\n2. On first connection with a `shareId` configured, the client fetches the bundle manifest and downloads all existing files from the server, then uploads any local files not yet on the server. Existing local files are verified against server checksums using parallel SHA3-512 hashing across the worker thread pool.\n3. After initial sync, the client enters a real-time loop: a WebSocket receives change events (`file_added`, `file_replaced`, `file_removed`, `file_renamed`) and a file watcher detects local changes. Changes are debounced (500 ms) to avoid redundant uploads during active writes. All checksum computations are dispatched to the worker pool to keep the main thread responsive.\n4. If the connection drops, the client reconnects with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s, 60s, 120s, 300s). On reconnect, it sends the last known sequence number so the server can replay missed events.\n5. The server sends a heartbeat every 30 seconds. If no message arrives within 90 seconds, the client treats the connection as dead and reconnects.\n6. Failed uploads are retried up to 3 times with full-jitter exponential backoff (base 5s). After 5 consecutive uploads exhaust their retries, a per-target circuit breaker opens for 30 seconds and new uploads fast-fail without dialling the server. The breaker probes after the cooldown; 2 consecutive successful probes close it. This keeps the daemon from hammering a flapping server while the retry loop would otherwise happily keep firing.\n7. `hermitstash-sync stats` reads a JSON snapshot the daemon writes every 15 seconds (`$CONFIG_DIR/stats.json`) — uploads/downloads (ok/error/retries), WebSocket reconnects, upload circuit-breaker state, upload pool depth, conflict count, average upload + download latency, and last applied sequence number. Use `--json` for machine-readable output or `--prometheus` for textfile-collector-friendly exposition. The daemon also writes a Prometheus 0.0.4 sidecar at `$CONFIG_DIR/stats.prom` with full histogram bucket counts — `stats --prometheus` reads the sidecar when present and surfaces P50/P95/P99 territory via `histogram_quantile()` queries. When the daemon is STOPPED, `stats` clearly frames the values as historical (\"last known state before the daemon stopped\") and points at the State DB section for current truth.\n\n### Concurrency\n\nUploads run through a bounded-concurrency pool — default 4 in-flight at once. Both the initial-scan fan-out (when a fresh client connects to a bundle that already has files) and live watcher-driven changes route through the same pool, so a `cp -r 10000-files .` doesn't materialize 10 000 pending promises queueing for the HTTPS agent's socket pool. Tune via `\"uploadConcurrency\": N` in `config.json` (clamped 1..16). Higher values throughput-trade for memory and server-side load.\n\n### Conflict copies\n\nWhen the server sends a new version of a file you've also modified locally, the local copy is renamed to `\u003cname\u003e.conflict-\u003cUTC\u003e.\u003cext\u003e` before the download overwrites it. Example: `notes.md` becomes `notes.conflict-2026-05-17T19-30-00Z.md`. Neither version is lost — open both, merge by hand, save back over `notes.md`, delete the conflict copy. The `conflicts` counter in `hermitstash-sync stats` shows how many have been saved since daemon start.\n\n### Sync states\n\nThe `status` command shows which state the daemon is in:\n\n| State | Meaning |\n| --- | --- |\n| `DISCONNECTED` | Not connected to server |\n| `CONNECTING` | Establishing WebSocket connection |\n| `CATCHING_UP` | Downloading changes missed while offline |\n| `SYNCED` | Fully synchronized, watching for changes |\n| `UPLOADING` | Actively uploading files |\n| `DOWNLOADING` | Actively downloading files |\n| `RECONNECTING` | Connection lost, waiting to retry |\n| `ERROR` | Something went wrong (check logs) |\n\n## Security\n\n- **PQC TLS** on every connection — three-tier hybrid group list `SecP384r1MLKEM1024:X25519MLKEM768:SecP256r1MLKEM768` (NIST Level 5 preferred, Level 3 and Level 1 fallback for broad server compatibility). Both `ecdhCurve` and `groups` are set so Node negotiates the hybrid group even on older OpenSSL builds.\n- **TLS 1.3 minimum** — connections below TLS 1.3 are rejected\n- **mTLS** client certificates for server authentication (optional, certs cached in memory). Certificates auto-renew on startup when within 60 days of expiry — no admin intervention required.\n- **Per-session PQC envelope** on encryption-grade JSON POSTs (`/drop/init`, `/drop/finalize/:bundleId`, `/sync/rename`) — ML-KEM-1024 + ECDH P-384 + SHAKE256 + XChaCha20-Poly1305, server keypair fetched once from `/.well-known/blamejs-pubkey` and cached. Strict-monotonic counter on the wire blocks replay. Other Bearer-authed sync calls send plain JSON over the PQC TLS + mTLS layer — transport encryption is the floor; no Bearer-authed sync call ever sends plaintext on the wire.\n- **SHA3-512** checksums verified before file rename — mismatched downloads never appear in sync folder\n- **Path traversal protection** — all server-provided paths validated against sync folder boundary\n- **Symlink protection** — symlinks skipped during directory walk and file watching (prevents escape)\n- **API key** in OS keychain, never in plaintext config or log files\n- **Atomic writes** — downloads write to `.tmp` file, verify checksum, then rename\n- **Stale temp cleanup** — orphaned `.tmp` files from interrupted downloads removed on startup\n- **Download suppression** — files written by the sync engine don't trigger re-upload\n- **Disk space guard** — downloads pause if free space drops below 100 MB\n- **PID file locking** — exclusive create prevents two daemon instances from racing\n- **State DB integrity** — SQLite integrity check on startup with auto-recovery on corruption\n- **Filename sanitization** — multipart upload headers strip injection characters\n- **Response body limiting** — error responses capped at 64 KB to prevent memory exhaustion\n- **HTTP timeouts** — all requests time out after 30 seconds to prevent hangs\n- **Log rotation** — log file rotated at 10 MB to prevent disk exhaustion\n- **Log symlink protection** — log path checked for symlinks before opening\n- **Worker thread pool** — SHA3-512 checksums computed in parallel across CPU cores, keeping the main thread responsive to WebSocket heartbeats during bulk sync\n- **Hardened wire layer via blamejs primitives** — the WebSocket client (`b.wsClient`) inherits decompression-bomb defence, UTF-8 fatal validation on text + close-reason, control-frame ≤125-byte cap with FIN=1 enforcement, RSV1-on-continuation rejection, permanent-vs-transient error classifier (no auth-failure hammering); the HTTP client (`b.httpClient.request`) adds an SSRF gate with DNS pinning that closes the resolve-vs-connect TOCTOU window on the configured server, AbortSignal cancellation, and an idle vs wall-clock timeout split; the auto-update GitHub-release fetcher inherits the same posture with the SSRF gate left fully closed in production so a hijacked release index can't pivot the download to an internal target\n- **safeJson on every untrusted parse** — server response bodies, the user-edited `config.json`, and the enrollment response all run through `b.safeJson.parse`'s depth + size + prototype-pollution caps, so a malformed or hostile JSON document can't mutate `Object.prototype` or exhaust the heap\n- **Atomic config + cert writes** — `config.json` and the enrolled mTLS client/cert/key/CA trio are written via `b.atomicFile.writeSync` (temp file, fsync, rename, parent-dir fsync) so a crash mid-write leaves the previous good copy intact instead of a torn file\n- **Phase-ordered graceful shutdown** — SIGTERM/SIGINT routes through `b.appShutdown` with per-phase time budgets and idempotent semantics, so a double signal during drain doesn't kick off a parallel teardown\n- **Crypto-strength retry jitter** — upload retries use `b.retry.withRetry`'s full-jitter exponential backoff sourced from `crypto.randomInt`, so retry timing isn't predictable from `Math.random`\n- **Boot-time wall-clock gate** — `hermitstash-sync start` runs an SNTPv4 drift check (`b.ntpCheck.bootCheck`) against `pool.ntp.org` before opening the engine. The cert-renewal threshold and the auto-update probation window both depend on `Date.now()` being roughly correct; a laptop resuming from a long sleep, a container without an RTC, or a system whose NTP daemon died can drift far enough to mis-renew certs or false-clear probation. Default thresholds: warn at 5 min, fatal at 1 hr. Unreachable NTP is non-fatal so offline boots still work. Override with `HERMITSTASH_NTP_DISABLE=1` (skip) or `HERMITSTASH_NTP_STRICT=1` (refuse to boot if unreachable).\n- **CSAF 2.1 VEX disclosures on releases** — when transitive CVEs surface in the vendored runtime that don't reach a vulnerable code path here, an OASIS CSAF 2.1 VEX document (`hermitstash-sync-vX.Y.Z.vex.json`) is published alongside the binaries. CSAF-aware scanners (e.g. `trivy --vex`, `grype --vex`) can consume it to suppress alerts on declared `known_not_affected` findings with a justification. Generated via `b.vex` from the assessments committed at `vex/statements.json`.\n- **CycloneDX 1.6 SBOM on releases** — every release publishes `hermitstash-sync-vX.Y.Z.cdx.json` alongside the binaries. The SBOM enumerates the SEA's vendored surface (blamejs and its transitive noble-ciphers + noble-post-quantum bundles) with CPE 2.3 + purl identifiers and per-component SHA-256 hashes so CISA / NVD-driven scanners (Dependency-Track, OWASP Dependency-Check, Snyk SBOM Monitor) can CVE-match against the actual code shipping inside the binary, not just the empty `package.json` runtime-dep list.\n- **Zero npm dependencies** — entire codebase is auditable\n\n## Logging\n\nLogs are written to `~/.hermitstash-sync/hermitstash-sync.log` in JSON format — one object per line with `ts`, `level`, and `msg` fields.\n\nLog levels: `debug`, `info`, `warn`, `error`.\n\nThe log file is rotated at 10 MB. The current log is renamed to `.log.1` and a fresh file is started. Only one rotated copy is kept.\n\n## Platform notes\n\n| | macOS | Linux | Windows |\n| --- | --- | --- | --- |\n| Keychain | Keychain Access | GNOME Keyring / KDE Wallet | Credential Manager |\n| Daemon | `start --daemon` | `start --daemon` | `start --daemon` |\n| Reload + resync signal | `SIGHUP` | `SIGHUP` | Not supported — restart daemon |\n| Auto-start | launchd | systemd | Task Scheduler |\n\n**Windows long paths:** the daemon transparently prefixes paths over ~248 chars with `\\\\?\\` so deep node_modules-style trees keep working even on Windows installations without the `LongPathsEnabled` registry flag. POSIX paths and short Windows paths pass through unchanged.\n\n**SIGHUP** (Linux + macOS) re-reads `config.json` + `.hermitstash-ignore` + `.hermitstash-include`, pushes the new patterns to the running watcher + engine, then triggers a resync. Lets you edit selective-sync or ignore rules and apply them without bouncing the daemon.\n\n## Auto-start (optional)\n\n### Linux (systemd)\n\nThe system-wide hardened unit at `deploy/hermitstash-sync.service` is `Type=notify` with `WatchdogSec=120` — the daemon reports `READY=1` once the engine is up, pings `WATCHDOG=1` every 60 seconds, and emits `STOPPING=1` on shutdown via the `systemd-notify(1)` CLI (no native addon required). systemd auto-restarts the unit if two consecutive watchdog windows are missed (engine deadlock).\n\nFor a minimal user-level unit:\n\n```ini\n# ~/.config/systemd/user/hermitstash-sync.service\n[Unit]\nDescription=HermitStash Sync\nAfter=network-online.target\n\n[Service]\nType=notify\nNotifyAccess=main\nWatchdogSec=120\nExecStart=/usr/local/bin/hermitstash-sync start\nRestart=on-failure\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n```\n\n```bash\nsystemctl --user enable hermitstash-sync\nsystemctl --user start hermitstash-sync\n```\n\nOutside systemd (Docker, Windows, dev runs) the notify calls no-op cleanly — `$NOTIFY_SOCKET` is absent so the daemon never spawns `systemd-notify`.\n\n### macOS (launchd)\n\n```xml\n\u003c!-- ~/Library/LaunchAgents/com.hermitstash.sync.plist --\u003e\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003c!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"\u003e\n\u003cplist version=\"1.0\"\u003e\n\u003cdict\u003e\n  \u003ckey\u003eLabel\u003c/key\u003e\u003cstring\u003ecom.hermitstash.sync\u003c/string\u003e\n  \u003ckey\u003eProgramArguments\u003c/key\u003e\n  \u003carray\u003e\n    \u003cstring\u003e/usr/local/bin/hermitstash-sync\u003c/string\u003e\n    \u003cstring\u003estart\u003c/string\u003e\n  \u003c/array\u003e\n  \u003ckey\u003eRunAtLoad\u003c/key\u003e\u003ctrue/\u003e\n  \u003ckey\u003eKeepAlive\u003c/key\u003e\u003ctrue/\u003e\n\u003c/dict\u003e\n\u003c/plist\u003e\n```\n\n## File structure\n\n```\nbin/hermitstash-sync.js       CLI entry point\nlib/cli.js                    Command parser and dispatcher\nlib/config.js                 Config file management\nlib/conflict-path.js          Builds filesystem-portable conflict filenames\nlib/constants.js              All constants, message types, defaults\nlib/checksum.js               SHA3-512 hashing (single + worker pool)\nlib/daemon.js                 Daemonization, PID file, signal handlers\nlib/diagnose.js               `diagnose` bundle builder\nlib/http-client.js            HTTP client with PQC agent + blamejs apiEncrypt for write paths\nlib/keychain.js               OS keychain for API key storage\nlib/logger.js                 Structured JSON logger with rotation\nlib/long-path.js              Windows `\\\\?\\` prefix for paths over MAX_PATH\nlib/path-filter.js            Shared include/ignore pattern matcher\nlib/state-db.js               Local SQLite state database (node:sqlite)\nlib/sync-engine.js            Core sync loop orchestrator\nlib/systemd-notify.js         sd_notify wrapper (Type=notify support)\nlib/task-pool.js              Bounded-concurrency promise pool for parallel uploads\nlib/throttle-stream.js        Token-bucket bandwidth limiter + pass-through Transform\nlib/watcher.js                fs.watch with debounce and ignore patterns\nlib/worker-pool.js            Generic worker thread pool\nlib/workers/checksum-worker.js  SHA3-512 hashing worker thread\nlib/ws-client.js              Minimal WebSocket client with PQC TLS\n```\n\n## Deployment\n\n### Platform: Node.js SEA (Single Executable Application)\n\nThe sync client ships as a standalone binary — no Node.js installation required on the target machine.\n\n| | |\n|---|---|\n| **Runtime** | Node.js SEA binary (Node.js + app bundled into a single executable) |\n| **Build** | GitHub Actions on tag push (`v*`) — automated via `.github/workflows/release.yml` |\n| **Platforms** | Windows x64, Linux x64, Linux ARM64, macOS ARM64 (Intel Macs: use the ARM64 binary under Rosetta 2) |\n| **Artifacts** | `hermitstash-sync-vX.Y.Z-{win,linux,macos}-{x64,arm64}[.exe]` + SHA3-512 checksum + GPG signature, per platform |\n| **Signing** | GPG (P-384) for humans + P-384 ECDSA (DER) over the binary bytes for the auto-update channel (verified via `b.selfUpdate.verify`). No Authenticode — see Windows note below. |\n| **TLS** | PQC hybrid: `SecP384r1MLKEM1024 \u003e X25519MLKEM768 \u003e SecP256r1MLKEM768` (Level 5 preferred) |\n| **Dependencies** | Zero npm runtime packages — all vendored |\n\n### Release workflow\n\n```\ngit tag vX.Y.Z \u0026\u0026 git push origin vX.Y.Z\n```\n\nGitHub Actions automatically:\n1. Builds the SEA binary for Windows x64, Linux x64, Linux ARM64, and macOS ARM64\n2. Generates a SHA3-512 checksum per binary (matches the server's hash algorithm)\n3. Scans the Windows binary with Windows Defender (updated definitions)\n4. GPG-signs each binary + checksum (`secrets.GPG_PRIVATE_KEY`)\n5. Creates a GitHub Release attaching every platform's binary, checksum, and signature\n\nDownload the latest release from the [Releases page](https://github.com/dotCooCoo/hermitstash-sync/releases).\n\n### Building locally\n\n```bash\n# Requires Node.js 24.14.1+ and postject\nnode --experimental-sea-config build/sea-config.json\ncp $(which node) build/hermitstash-sync\nnpx postject build/hermitstash-sync NODE_SEA_BLOB build/hermitstash-sync.blob \\\n  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2\n```\n\nOr use the local release script: `bash scripts/release.sh` (builds + signs + optional VirusTotal scan).\n\n## Contributing\n\nI want to be straightforward about this: I'm not currently accepting code contributions, and I want to explain why rather than just saying no.\n\nHermitStash Sync is a security-focused project maintained by one person. Reviewing external code contributions to a cryptographic system is something I don't feel I can do responsibly right now — I'm still learning, and I'd rather not merge code I can't fully evaluate myself. Accepting PRs would mean either rubber-stamping changes I don't understand (bad) or asking contributors to wait indefinitely while I figure it out (also bad). The honest answer is that I'm not set up for it yet.\n\nThat said, there are a lot of ways to help that I genuinely welcome:\n\n- **Bug reports.** If something doesn't work, or works in a way that surprises you, please open an issue. Steps to reproduce help a lot.\n- **Security findings.** If you spot a cryptographic issue, a misuse of a primitive, or anything that contradicts a security claim in the README, please report it privately — see [SECURITY.md](SECURITY.md) for how.\n- **Feature requests.** Open an issue describing the use case. I can't promise I'll build it, but I want to hear what people would find useful.\n- **Documentation feedback.** If something in the README is unclear, wrong, or missing, an issue is great. Documentation issues are some of the most useful kinds of feedback I get.\n- **Questions.** If you're trying to use HermitStash Sync and something isn't clear, asking is welcome.\n\nIf you've built something on top of HermitStash, or you're running it somewhere interesting, I'd love to hear about that too — feel free to open an issue just to say hi.\n\nThis may change in the future. If HermitStash Sync grows to a point where I can responsibly review external code, I'll update this section. Until then: thank you for understanding, and thank you for being interested enough to consider contributing in the first place.\n\n## License\n\n[AGPL-3.0-or-later](LICENSE)\n\n---\n\n### A final note\n\nIf you've read this far — thank you. Building and sharing HermitStash has been one of the most rewarding things I've worked on, and the fact that you took the time to look at it means a lot.\n\nIf HermitStash has been useful to you and you'd like to buy me a coffee, you can do so at [ko-fi.com/dotcoocoo](https://ko-fi.com/dotcoocoo). It's never expected, always appreciated.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdotcoocoo%2Fhermitstash-sync","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdotcoocoo%2Fhermitstash-sync","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdotcoocoo%2Fhermitstash-sync/lists"}