diff --git a/Dockerfile b/Dockerfile index 1911a43..c0145fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,8 +87,22 @@ RUN addgroup -g 1001 -S zeroauth && \ # npm workspaces, which complicates a single-package install). Trade-off # is acceptable for v0; full reproducible-build provenance is on the # roadmap per ADR-0005 / the verifier design doc. +# +# NOTES: +# 1. `better-sqlite3` is a native Node addon. The npm-published prebuilds +# don't cover alpine-linux-musl-arm64 (the VPS architecture), so +# prebuild-install falls back to a node-gyp source build. That needs +# python3 + make + g++. We install them, build, then DELETE them to +# keep the runtime image small (cleanup at the end of this RUN saves +# ~150MB). +# 2. We allow lifecycle scripts here (no `--ignore-scripts`) because +# better-sqlite3's postinstall is what triggers the build. All 4 +# other deps (express, snarkjs, uuid, winston) are JS-only. COPY verifier/package.json ./package.json -RUN npm install --omit=dev --ignore-scripts && npm cache clean --force +RUN apk add --no-cache --virtual .build-deps python3 make g++ \ + && npm install --omit=dev \ + && npm cache clean --force \ + && apk del .build-deps # Compiled JS from the verifier-build stage COPY --from=verifier-build /app/verifier/dist ./dist @@ -98,10 +112,17 @@ COPY --from=verifier-build /app/verifier/dist ./dist # unfindable. COPY circuits/build/verification_key.json /app/circuits/build/verification_key.json +# Writable data directory for the SQLite audit log (B02 design doc §4.3). +# Owned by the non-root user; mounted as a Docker volume in compose so +# the DB survives container restarts. Without this `mkdir` here, the +# non-root user can't create the dir at startup → service crashes. +RUN mkdir -p /app/data && chown -R zeroauth:zeroauth /app/data + USER zeroauth ENV NODE_ENV=production ENV VERIFIER_VKEY_PATH=/app/circuits/build/verification_key.json +ENV VERIFIER_AUDIT_DB_PATH=/app/data/audit.db ENV VERIFIER_BIND=0.0.0.0 ENV VERIFIER_PORT=3001 diff --git a/docker-compose.yml b/docker-compose.yml index 5e13b59..5d49041 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,12 @@ services: - NODE_ENV=production - VERIFIER_CIRCUIT_VERSION=v1 - LOG_LEVEL=info + # Persistent volume for the SQLite audit log (B02 design doc §4.3). + # Mounted at /app/data inside the container. Survives container + # restarts + image rebuilds. Backup story: snapshot the volume on + # deploy + nightly cron (TODO). + volumes: + - verifier-audit-data:/app/data restart: unless-stopped # 127.0.0.1 (not localhost) because alpine busybox wget hits IPv6 first # and the verifier binds 0.0.0.0 (IPv4 only). The Dockerfile carries @@ -176,3 +182,4 @@ volumes: postgres-data: caddy-data: caddy-config: + verifier-audit-data: diff --git a/package-lock.json b/package-lock.json index 0b41b01..e3dad70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3426,6 +3426,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/bn.js": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", @@ -4636,6 +4646,26 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -4655,6 +4685,20 @@ "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, "node_modules/bfj": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", @@ -4684,6 +4728,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/blake-hash": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/blake-hash/-/blake-hash-2.0.0.tgz", @@ -4946,6 +5010,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -5207,6 +5295,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -6017,6 +6111,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -6050,9 +6159,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4.0.0" } @@ -6131,6 +6238,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6318,6 +6434,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -7049,6 +7174,15 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -7324,6 +7458,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -7555,6 +7695,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -7826,6 +7972,12 @@ "node": ">=4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8519,6 +8671,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8615,9 +8787,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/interpret": { "version": "1.4.0", @@ -10235,6 +10405,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -10290,7 +10472,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10310,6 +10491,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mnemonist": { "version": "0.38.5", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.5.tgz", @@ -10510,6 +10697,12 @@ "integrity": "sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==", "license": "ISC" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10533,6 +10726,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", @@ -10709,7 +10914,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -11214,6 +11418,33 @@ "node": ">=0.10.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11323,6 +11554,16 @@ "license": "MIT", "peer": true }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11453,6 +11694,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -12395,6 +12660,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -13091,6 +13401,34 @@ "license": "MIT", "peer": true }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -13504,6 +13842,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -14218,7 +14568,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -14368,12 +14717,14 @@ "name": "@zeroauth/verifier", "version": "0.1.0", "dependencies": { + "better-sqlite3": "^12.10.0", "express": "^4.18.2", "snarkjs": "^0.7.6", "uuid": "^9.0.0", "winston": "^3.11.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.21", "@types/jest": "^29.5.14", "@types/node": "^20.10.6", diff --git a/verifier/package.json b/verifier/package.json index 111c26d..f22b20a 100644 --- a/verifier/package.json +++ b/verifier/package.json @@ -11,12 +11,14 @@ "test": "jest --forceExit --detectOpenHandles" }, "dependencies": { + "better-sqlite3": "^12.10.0", "express": "^4.18.2", "snarkjs": "^0.7.6", "uuid": "^9.0.0", "winston": "^3.11.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.21", "@types/jest": "^29.5.14", "@types/node": "^20.10.6", diff --git a/verifier/src/audit-log.ts b/verifier/src/audit-log.ts new file mode 100644 index 0000000..9f82bda --- /dev/null +++ b/verifier/src/audit-log.ts @@ -0,0 +1,342 @@ +import Database from 'better-sqlite3'; +import { createHash } from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; +import * as path from 'path'; +import * as fs from 'fs'; +import { logger } from './logger'; + +/** + * Verifier-local append-only audit log with hash chain (B02 design doc §4.3). + * + * Independent from the API's Postgres `audit_events` table. The point of + * having BOTH is defense in depth: if the Postgres audit log is rewritten + * (root-level DB compromise), the verifier's local SQLite copy is a + * tamper-evident replica an auditor can reconcile against. + * + * Append-only is enforced two ways: + * 1. SQL triggers blocking UPDATE + DELETE (the SQLite engine itself + * refuses the write) + * 2. The hash chain — any row tampered after the fact will fail + * verifyChain() because its computed entry_hash won't match what's + * stored, AND every subsequent row's prev_hash points at the + * compromised row's stored entry_hash + * + * Hash chain construction per design doc §5: + * entry_hash = sha256(canonical_serialize(entry_without_entry_hash) || prev_hash) + * + * Canonical serialization: JSON with sorted keys, no whitespace, UTF-8. + * Same input always produces the same hash — that's the load-bearing + * property for chain verification. + */ + +const SCHEMA = ` +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS verifier_events ( + id TEXT PRIMARY KEY, -- UUID v4 + sequence INTEGER NOT NULL, -- monotonic, starts at 0 (genesis) + tenant_id TEXT NOT NULL, -- 'system' for the genesis row + environment TEXT NOT NULL, -- 'live' | 'test' | 'system' + circuit_version TEXT NOT NULL, -- 'v1', 'v2', ... + correlation_id TEXT NOT NULL, -- traces back to caller's request + verified INTEGER NOT NULL, -- 0 | 1 + structural_fallback INTEGER NOT NULL, -- 0 | 1 (true when no vkey was loaded) + proof_hash TEXT NOT NULL, -- sha256 of canonical(proof) — full proof never stored + pub_signals_hash TEXT NOT NULL, -- sha256 of canonical(public_signals) + latency_us INTEGER NOT NULL, + created_at TEXT NOT NULL, -- ISO 8601 UTC + prev_hash TEXT NOT NULL, -- chain pointer; 64 hex chars + entry_hash TEXT NOT NULL UNIQUE -- sha256(canonical(row excl entry_hash) || prev_hash) +); + +CREATE INDEX IF NOT EXISTS idx_verifier_events_tenant_env_created + ON verifier_events (tenant_id, environment, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_verifier_events_sequence + ON verifier_events (sequence); + +-- Append-only triggers. Once a row is written, it cannot be modified or +-- deleted via the SQLite engine. A direct file write could still tamper, +-- but the hash chain catches that on the next verifyChain() run. +CREATE TRIGGER IF NOT EXISTS verifier_events_no_update + BEFORE UPDATE ON verifier_events + BEGIN SELECT RAISE(ABORT, 'verifier_events is append-only — UPDATE refused'); END; + +CREATE TRIGGER IF NOT EXISTS verifier_events_no_delete + BEFORE DELETE ON verifier_events + BEGIN SELECT RAISE(ABORT, 'verifier_events is append-only — DELETE refused'); END; +`; + +const GENESIS_PREV_HASH = '0'.repeat(64); + +let db: Database.Database | null = null; +let nextSequence = 0; +let lastEntryHash = GENESIS_PREV_HASH; + +export interface AuditAppendInput { + tenantId: string; + environment: 'live' | 'test'; + circuitVersion: string; + correlationId: string; + verified: boolean; + structuralFallback: boolean; + proofHash: string; + pubSignalsHash: string; + latencyUs: number; +} + +export interface AuditRow { + id: string; + sequence: number; + tenant_id: string; + environment: string; + circuit_version: string; + correlation_id: string; + verified: number; + structural_fallback: number; + proof_hash: string; + pub_signals_hash: string; + latency_us: number; + created_at: string; + prev_hash: string; + entry_hash: string; +} + +/** + * Initialize the audit log database. Idempotent — safe to call multiple + * times. Creates the file + schema + genesis row on first run. + * + * `dbPath` is the path to the SQLite file. In production this lives on + * a docker volume so it survives container restarts. In dev it lives + * under `verifier/data/` (gitignored). In tests we pass `:memory:`. + */ +export function initAuditLog(dbPath: string): void { + if (db) { + logger.warn('Audit log: already initialized, ignoring re-init', { dbPath }); + return; + } + + if (dbPath !== ':memory:') { + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + + db = new Database(dbPath); + db.exec(SCHEMA); + + const row = db.prepare('SELECT COUNT(*) AS count, MAX(sequence) AS max_seq FROM verifier_events').get() as { + count: number; + max_seq: number | null; + }; + + if (row.count === 0) { + insertGenesisRow(); + } else { + nextSequence = (row.max_seq ?? -1) + 1; + const lastRow = db.prepare( + 'SELECT entry_hash FROM verifier_events ORDER BY sequence DESC LIMIT 1', + ).get() as { entry_hash: string } | undefined; + lastEntryHash = lastRow?.entry_hash ?? GENESIS_PREV_HASH; + } + + logger.info('Audit log: initialized', { + dbPath, + rowCount: row.count, + nextSequence, + lastEntryHashPrefix: lastEntryHash.slice(0, 16), + }); +} + +function insertGenesisRow(): void { + if (!db) throw new Error('Audit log: not initialized'); + + const id = uuidv4(); + const createdAt = new Date().toISOString(); + const partialRow = { + id, + sequence: 0, + tenant_id: 'system', + environment: 'system', + circuit_version: 'genesis', + correlation_id: id, + verified: 1, + structural_fallback: 0, + proof_hash: GENESIS_PREV_HASH, + pub_signals_hash: GENESIS_PREV_HASH, + latency_us: 0, + created_at: createdAt, + prev_hash: GENESIS_PREV_HASH, + }; + const entryHash = computeEntryHash(partialRow, GENESIS_PREV_HASH); + insertRow({ ...partialRow, entry_hash: entryHash }); + nextSequence = 1; + lastEntryHash = entryHash; + logger.info('Audit log: genesis row written', { id, entryHash }); +} + +/** + * Append a verification event. Returns the row id (== verifierAuditId + * surfaced to callers). + */ +export function appendEvent(input: AuditAppendInput): string { + if (!db) throw new Error('Audit log: not initialized'); + + const id = uuidv4(); + const createdAt = new Date().toISOString(); + const partialRow = { + id, + sequence: nextSequence, + tenant_id: input.tenantId, + environment: input.environment, + circuit_version: input.circuitVersion, + correlation_id: input.correlationId, + verified: input.verified ? 1 : 0, + structural_fallback: input.structuralFallback ? 1 : 0, + proof_hash: input.proofHash, + pub_signals_hash: input.pubSignalsHash, + latency_us: input.latencyUs, + created_at: createdAt, + prev_hash: lastEntryHash, + }; + const entryHash = computeEntryHash(partialRow, lastEntryHash); + insertRow({ ...partialRow, entry_hash: entryHash }); + nextSequence += 1; + lastEntryHash = entryHash; + return id; +} + +function insertRow(row: AuditRow): void { + if (!db) throw new Error('Audit log: not initialized'); + const stmt = db.prepare(` + INSERT INTO verifier_events + (id, sequence, tenant_id, environment, circuit_version, correlation_id, + verified, structural_fallback, proof_hash, pub_signals_hash, latency_us, + created_at, prev_hash, entry_hash) + VALUES + (@id, @sequence, @tenant_id, @environment, @circuit_version, @correlation_id, + @verified, @structural_fallback, @proof_hash, @pub_signals_hash, @latency_us, + @created_at, @prev_hash, @entry_hash) + `); + stmt.run(row); +} + +/** + * Canonical serialization for hash-chain input. JSON with sorted keys, + * no whitespace, UTF-8. Excludes entry_hash itself (that's what we're + * computing). Then we concatenate with prev_hash. + * + * The same row must always produce the same string. If serialization + * changes (even whitespace), the chain breaks. Don't touch this without + * a migration plan. + */ +function canonicalSerialize(row: Omit): string { + const sortedKeys = Object.keys(row).sort() as Array; + const sorted: Record = {}; + for (const k of sortedKeys) { + sorted[k] = row[k]; + } + return JSON.stringify(sorted); +} + +function computeEntryHash(row: Omit, prevHash: string): string { + const canonical = canonicalSerialize(row); + return createHash('sha256').update(canonical + prevHash).digest('hex'); +} + +export interface ChainVerificationResult { + ok: boolean; + rowsChecked: number; + firstBadSequence?: number; + firstBadReason?: string; +} + +/** + * Walk the chain from sequence 0 and verify each row's entry_hash matches + * a recomputation from (canonical(row_without_entry_hash) || prev_hash), + * AND each row's prev_hash matches the previous row's stored entry_hash. + * + * O(N) over the whole table. Acceptable for periodic audit runs (daily + * cron / pre-evidence-pack-publish); not for every request. Surfaces the + * first failing sequence so operators can investigate. + */ +export function verifyChain(): ChainVerificationResult { + if (!db) throw new Error('Audit log: not initialized'); + + const rows = db.prepare( + 'SELECT * FROM verifier_events ORDER BY sequence ASC', + ).all() as AuditRow[]; + + let prevHash = GENESIS_PREV_HASH; + let checked = 0; + + for (const row of rows) { + if (row.prev_hash !== prevHash) { + return { + ok: false, + rowsChecked: checked, + firstBadSequence: row.sequence, + firstBadReason: `prev_hash mismatch: row claims prev=${row.prev_hash.slice(0, 12)} but chain has prev=${prevHash.slice(0, 12)}`, + }; + } + + const { entry_hash: stored, ...rest } = row; + const computed = computeEntryHash(rest, prevHash); + if (computed !== stored) { + return { + ok: false, + rowsChecked: checked, + firstBadSequence: row.sequence, + firstBadReason: `entry_hash mismatch: stored=${stored.slice(0, 12)} computed=${computed.slice(0, 12)}`, + }; + } + + prevHash = stored; + checked += 1; + } + + return { ok: true, rowsChecked: checked }; +} + +/** + * Test helpers / introspection. Not for production code paths. + */ +export function _getDatabaseForTests(): Database.Database | null { + return db; +} + +export function _resetForTests(): void { + if (db) { + db.close(); + db = null; + } + nextSequence = 0; + lastEntryHash = GENESIS_PREV_HASH; +} + +export function getStats(): { rowCount: number; nextSequence: number; lastEntryHashPrefix: string } { + if (!db) { + return { rowCount: 0, nextSequence: 0, lastEntryHashPrefix: '' }; + } + const row = db.prepare('SELECT COUNT(*) AS count FROM verifier_events').get() as { count: number }; + return { + rowCount: row.count, + nextSequence, + lastEntryHashPrefix: lastEntryHash.slice(0, 16), + }; +} + +/** + * Hash a proof or public-signals payload for storage. We never store the + * full proof in the audit log — just its SHA-256. That's enough to prove + * "this exact proof was verified at this time" without bloating the table + * (proofs are ~1KB each; multiplied by millions of verifications = real + * storage). + */ +export function hashPayload(payload: unknown): string { + const canonical = typeof payload === 'string' ? payload : JSON.stringify(payload); + return createHash('sha256').update(canonical).digest('hex'); +} diff --git a/verifier/src/server.ts b/verifier/src/server.ts index b7b7f58..6ec0cc0 100644 --- a/verifier/src/server.ts +++ b/verifier/src/server.ts @@ -1,14 +1,16 @@ import express, { Request, Response } from 'express'; -import { v4 as uuidv4 } from 'uuid'; import { initVerifier, verifyProof, isVkeyLoaded } from './groth16'; import { logger } from './logger'; import { VerifyRequest, VerifyResponse, HealthResponse } from './types'; +import { initAuditLog, appendEvent, getStats as auditStats, hashPayload, verifyChain } from './audit-log'; const PORT = parseInt(process.env.VERIFIER_PORT ?? '3001', 10); const BIND = process.env.VERIFIER_BIND ?? '127.0.0.1'; const VKEY_PATH = process.env.VERIFIER_VKEY_PATH ?? 'circuits/build/verification_key.json'; const CIRCUIT_VERSION = process.env.VERIFIER_CIRCUIT_VERSION ?? 'v1'; +const AUDIT_DB_PATH = + process.env.VERIFIER_AUDIT_DB_PATH ?? 'verifier/data/audit.db'; const START_TIME = Date.now(); // ─── Build the app (exported for tests) ────────────────────────────── @@ -22,8 +24,9 @@ export function createApp() { // it's loopback-only. The caller (API repo) is responsible for tenant // scoping, audit-log writes in the platform tables, and replay defense. app.post('/verify', async (req: Request, res: Response) => { + const t0Hr = process.hrtime.bigint(); const t0 = Date.now(); - const body = req.body as Partial; + const body = req.body as Partial & { tenantId?: string; environment?: string }; if (!body?.proof || !Array.isArray(body.publicSignals) || body.publicSignals.length !== 3) { res.status(400).json({ error: 'invalid_request', message: 'proof + publicSignals (length 3) are required' }); @@ -32,19 +35,37 @@ export function createApp() { try { const { verified, structuralFallback } = await verifyProof(body.proof, body.publicSignals); + const latencyMs = Date.now() - t0; + const latencyUs = Number((process.hrtime.bigint() - t0Hr) / 1000n); + + // Append to the verifier-local audit log (hash-chained, append-only). + // Per security-policy §10 we hash the proof + signals rather than + // storing them. The chain is verifyable later via /audit/verify. + const verifierAuditId = appendEvent({ + tenantId: body.tenantId ?? 'unspecified', + environment: (body.environment === 'test' ? 'test' : 'live'), + circuitVersion: body.circuitVersion ?? CIRCUIT_VERSION, + correlationId: body.correlationId ?? '', + verified, + structuralFallback, + proofHash: hashPayload(body.proof), + pubSignalsHash: hashPayload(body.publicSignals), + latencyUs, + }); + const response: VerifyResponse = { verified, - verifierAuditId: uuidv4(), - latencyMs: Date.now() - t0, + verifierAuditId, + latencyMs, circuitVersion: body.circuitVersion ?? CIRCUIT_VERSION, structuralFallback, }; logger.info('Verifier: verify result', { verified, structuralFallback, - latencyMs: response.latencyMs, + latencyMs, correlationId: body.correlationId, - verifierAuditId: response.verifierAuditId, + verifierAuditId, }); res.json(response); } catch (err) { @@ -53,13 +74,31 @@ export function createApp() { } }); + // Audit log introspection endpoints. Loopback-only — the verifier is + // not internet-exposed and the API never proxies these. Useful for + // ops + the evidence-pack assembler. + app.get('/audit/stats', (_req: Request, res: Response) => { + res.json(auditStats()); + }); + + app.get('/audit/verify-chain', (_req: Request, res: Response) => { + const result = verifyChain(); + res.status(result.ok ? 200 : 500).json(result); + }); + // GET /health — for the API's liveness check + ops. app.get('/health', (_req: Request, res: Response) => { + const a = auditStats(); const response: HealthResponse = { status: isVkeyLoaded() ? 'ok' : 'degraded', version: process.env.npm_package_version ?? '0.1.0', vkeyAvailable: isVkeyLoaded(), uptimeSeconds: Math.floor((Date.now() - START_TIME) / 1000), + audit: { + rowCount: a.rowCount, + nextSequence: a.nextSequence, + lastEntryHashPrefix: a.lastEntryHashPrefix, + }, }; res.json(response); }); @@ -71,9 +110,12 @@ export function createApp() { async function main() { await initVerifier(VKEY_PATH); + initAuditLog(AUDIT_DB_PATH); const app = createApp(); app.listen(PORT, BIND, () => { - logger.info('Verifier: listening', { bind: BIND, port: PORT, circuitVersion: CIRCUIT_VERSION }); + logger.info('Verifier: listening', { + bind: BIND, port: PORT, circuitVersion: CIRCUIT_VERSION, auditDbPath: AUDIT_DB_PATH, + }); }); } diff --git a/verifier/src/types.ts b/verifier/src/types.ts index 4fd24f3..bbe0bf7 100644 --- a/verifier/src/types.ts +++ b/verifier/src/types.ts @@ -38,4 +38,9 @@ export interface HealthResponse { version: string; vkeyAvailable: boolean; uptimeSeconds: number; + audit: { + rowCount: number; + nextSequence: number; + lastEntryHashPrefix: string; + }; } diff --git a/verifier/tests/audit-log.test.ts b/verifier/tests/audit-log.test.ts new file mode 100644 index 0000000..25f58ec --- /dev/null +++ b/verifier/tests/audit-log.test.ts @@ -0,0 +1,220 @@ +/** + * Tests for verifier/src/audit-log.ts — the SQLite append-only audit + * log with hash chain (B02 design doc §4.3). + * + * Uses an in-memory SQLite DB (`:memory:`) so tests are fast + don't + * touch the filesystem. Each test resets the module state via + * `_resetForTests()` so the genesis row is rewritten cleanly. + * + * Covered: + * - initAuditLog creates schema + genesis row on first run + * - genesis row is sequence=0, prev_hash=0*64, tenant_id='system' + * - re-init on a fresh DB is idempotent (no-op when already + * initialized in this process) + * - appendEvent inserts a row with sequence++, prev_hash = lastEntryHash + * - entry_hash is sha256(canonical(row excl entry_hash) || prev_hash) + * - verifyChain returns ok:true for a clean chain + * - verifyChain detects a tampered entry_hash (returns ok:false + + * firstBadSequence + firstBadReason) + * - verifyChain detects a tampered prev_hash linkage + * - the SQL triggers refuse UPDATE + DELETE + * - getStats reports rowCount, nextSequence, lastEntryHashPrefix + * - hashPayload returns a 64-char hex string (sha256) + */ + +import { + initAuditLog, + appendEvent, + verifyChain, + getStats, + hashPayload, + _resetForTests, + _getDatabaseForTests, +} from '../src/audit-log'; +import { createHash } from 'crypto'; + +describe('audit-log — hash-chained append-only SQLite', () => { + beforeEach(() => { + _resetForTests(); + initAuditLog(':memory:'); + }); + + describe('genesis row', () => { + it('writes a genesis row at sequence 0 with prev_hash = 0×64', () => { + const db = _getDatabaseForTests()!; + const row = db.prepare('SELECT * FROM verifier_events ORDER BY sequence LIMIT 1').get() as any; + expect(row).toBeDefined(); + expect(row.sequence).toBe(0); + expect(row.tenant_id).toBe('system'); + expect(row.environment).toBe('system'); + expect(row.circuit_version).toBe('genesis'); + expect(row.prev_hash).toBe('0'.repeat(64)); + expect(row.entry_hash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('starts nextSequence at 1 after genesis', () => { + const stats = getStats(); + expect(stats.rowCount).toBe(1); + expect(stats.nextSequence).toBe(1); + }); + }); + + describe('appendEvent', () => { + const baseInput = { + tenantId: 'tenant-A', + environment: 'live' as const, + circuitVersion: 'v1', + correlationId: 'cor-1', + verified: true, + structuralFallback: false, + proofHash: hashPayload({ proof: 'p1' }), + pubSignalsHash: hashPayload(['a', 'b', 'c']), + latencyUs: 12345, + }; + + it('returns a UUID v4 row id', () => { + const id = appendEvent(baseInput); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it('inserts at the next sequence with prev_hash = lastEntryHash', () => { + const db = _getDatabaseForTests()!; + const genesis = db.prepare('SELECT * FROM verifier_events WHERE sequence = 0').get() as any; + + appendEvent(baseInput); + + const row = db.prepare('SELECT * FROM verifier_events WHERE sequence = 1').get() as any; + expect(row.sequence).toBe(1); + expect(row.prev_hash).toBe(genesis.entry_hash); + expect(row.tenant_id).toBe('tenant-A'); + expect(row.environment).toBe('live'); + expect(row.verified).toBe(1); + expect(row.structural_fallback).toBe(0); + }); + + it('keeps the chain unbroken across 5 events', () => { + for (let i = 0; i < 5; i++) { + appendEvent({ ...baseInput, correlationId: `cor-${i}` }); + } + expect(getStats().rowCount).toBe(6); // genesis + 5 + expect(verifyChain()).toEqual({ ok: true, rowsChecked: 6 }); + }); + + it('persists the proof_hash and pub_signals_hash from the input', () => { + const id = appendEvent(baseInput); + const db = _getDatabaseForTests()!; + const row = db.prepare('SELECT * FROM verifier_events WHERE id = ?').get(id) as any; + expect(row.proof_hash).toBe(baseInput.proofHash); + expect(row.pub_signals_hash).toBe(baseInput.pubSignalsHash); + }); + }); + + describe('verifyChain — tamper detection', () => { + const baseInput = { + tenantId: 't', + environment: 'live' as const, + circuitVersion: 'v1', + correlationId: 'c', + verified: false, + structuralFallback: false, + proofHash: '0'.repeat(64), + pubSignalsHash: '0'.repeat(64), + latencyUs: 1, + }; + + it('ok:true on a clean chain', () => { + appendEvent(baseInput); + appendEvent(baseInput); + appendEvent(baseInput); + expect(verifyChain()).toEqual({ ok: true, rowsChecked: 4 }); + }); + + it('detects a tampered entry_hash (direct DB write bypasses triggers if performed before triggers exist; but verifyChain catches it)', () => { + appendEvent(baseInput); + const db = _getDatabaseForTests()!; + // Bypass the trigger by dropping it temporarily, mutating, then + // re-adding it. Simulates an attacker who has root access to the + // SQLite file and can do whatever they want. + db.exec('DROP TRIGGER verifier_events_no_update'); + db.exec(`UPDATE verifier_events SET verified = 1 WHERE sequence = 1`); + const r = verifyChain(); + expect(r.ok).toBe(false); + expect(r.firstBadSequence).toBe(1); + expect(r.firstBadReason).toMatch(/entry_hash mismatch/); + }); + + it('detects a tampered prev_hash linkage (chain reordered)', () => { + appendEvent(baseInput); + appendEvent(baseInput); + const db = _getDatabaseForTests()!; + db.exec('DROP TRIGGER verifier_events_no_update'); + // Break the chain: rewrite prev_hash on row 2 to point to a wrong place + db.exec("UPDATE verifier_events SET prev_hash = '" + 'f'.repeat(64) + "' WHERE sequence = 2"); + const r = verifyChain(); + expect(r.ok).toBe(false); + expect(r.firstBadSequence).toBe(2); + expect(r.firstBadReason).toMatch(/prev_hash mismatch/); + }); + }); + + describe('append-only SQL triggers', () => { + it('refuses UPDATE on verifier_events', () => { + appendEvent({ + tenantId: 't', environment: 'live', circuitVersion: 'v1', correlationId: 'c', + verified: true, structuralFallback: false, proofHash: '0'.repeat(64), + pubSignalsHash: '0'.repeat(64), latencyUs: 1, + }); + const db = _getDatabaseForTests()!; + expect(() => db.exec('UPDATE verifier_events SET verified = 0 WHERE sequence = 1')).toThrow( + /append-only/, + ); + }); + + it('refuses DELETE on verifier_events', () => { + const db = _getDatabaseForTests()!; + expect(() => db.exec('DELETE FROM verifier_events WHERE sequence = 0')).toThrow( + /append-only/, + ); + }); + }); + + describe('getStats', () => { + it('reflects the current state', () => { + const s1 = getStats(); + expect(s1.rowCount).toBe(1); + expect(s1.nextSequence).toBe(1); + expect(s1.lastEntryHashPrefix).toMatch(/^[a-f0-9]{16}$/); + + appendEvent({ + tenantId: 't', environment: 'live', circuitVersion: 'v1', correlationId: 'c', + verified: true, structuralFallback: false, proofHash: '0'.repeat(64), + pubSignalsHash: '0'.repeat(64), latencyUs: 1, + }); + + const s2 = getStats(); + expect(s2.rowCount).toBe(2); + expect(s2.nextSequence).toBe(2); + expect(s2.lastEntryHashPrefix).not.toBe(s1.lastEntryHashPrefix); + }); + }); + + describe('hashPayload', () => { + it('returns 64 hex chars (sha256)', () => { + const h = hashPayload({ foo: 'bar' }); + expect(h).toMatch(/^[a-f0-9]{64}$/); + }); + + it('is deterministic for identical input', () => { + expect(hashPayload({ x: 1, y: 2 })).toBe(hashPayload({ x: 1, y: 2 })); + }); + + it('produces different hashes for different inputs', () => { + expect(hashPayload({ x: 1 })).not.toBe(hashPayload({ x: 2 })); + }); + + it('hashes a string directly (skips JSON.stringify on string input)', () => { + const direct = createHash('sha256').update('hello').digest('hex'); + expect(hashPayload('hello')).toBe(direct); + }); + }); +}); diff --git a/verifier/tests/server.test.ts b/verifier/tests/server.test.ts index 8053006..0694f6f 100644 --- a/verifier/tests/server.test.ts +++ b/verifier/tests/server.test.ts @@ -9,6 +9,7 @@ import request from 'supertest'; import { createApp } from '../src/server'; import { initVerifier } from '../src/groth16'; +import { initAuditLog, _resetForTests } from '../src/audit-log'; const validProof = { proof: { @@ -24,6 +25,9 @@ const validProof = { beforeAll(async () => { // Run in structural-fallback mode — no real vkey on disk. await initVerifier('nonexistent.json'); + // Audit log on an in-memory SQLite, so /verify can write rows. + _resetForTests(); + initAuditLog(':memory:'); }); describe('verifier server — POST /verify', () => {