From d9f8fb9b4afe0c1645615b682b9c7314c94283b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 15:15:39 +0000 Subject: [PATCH 1/3] Build @certifieddata/verify CLI v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RFC 8785 JCS canonicalizer (hand-written, ~90 lines, audit-friendly) - Ed25519 verifier in src/verify.ts using node:crypto only — no third-party crypto deps; whole verification path under 100 lines - CLI with --dataset, --json, --offline, --keys, --no-cache flags and six documented exit codes - Trusted-keys loader with TTL cache at ~/.certifieddata/keys.json - Fixture generator produces a real Ed25519 keypair and signs the four canonical test cases (valid, tampered, unknown-key, malformed) - 34 tests across canonicalize, verify, and CLI suites - CI matrix: Node 20/22 × {linux,macos,windows} - Publish workflow uses npm OIDC trusted publishing with provenance --- .github/workflows/ci.yml | 31 + .github/workflows/publish.yml | 25 + .gitignore | 8 + CHANGELOG.md | 20 + LICENSE | 2 +- README.md | 109 ++- SECURITY.md | 30 + eslint.config.js | 23 + fixtures/README.md | 33 + fixtures/generate.mjs | 130 +++ fixtures/keys.json | 12 + fixtures/malformed-cert.json | 14 + fixtures/tampered-cert.json | 15 + fixtures/unknown-key-cert.json | 15 + fixtures/valid-cert.json | 15 + fixtures/valid-dataset.csv | 4 + package-lock.json | 1511 ++++++++++++++++++++++++++++++++ package.json | 63 ++ src/canonicalize.test.ts | 122 +++ src/canonicalize.ts | 90 ++ src/cli.test.ts | 154 ++++ src/cli.ts | 182 ++++ src/fetch-cert.ts | 46 + src/hash.ts | 26 + src/index.ts | 13 + src/keys.ts | 81 ++ src/types.ts | 70 ++ src/verify.test.ts | 78 ++ src/verify.ts | 117 +++ tsconfig.json | 26 + 30 files changed, 3063 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md create mode 100644 eslint.config.js create mode 100644 fixtures/README.md create mode 100644 fixtures/generate.mjs create mode 100644 fixtures/keys.json create mode 100644 fixtures/malformed-cert.json create mode 100644 fixtures/tampered-cert.json create mode 100644 fixtures/unknown-key-cert.json create mode 100644 fixtures/valid-cert.json create mode 100644 fixtures/valid-dataset.csv create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/canonicalize.test.ts create mode 100644 src/canonicalize.ts create mode 100644 src/cli.test.ts create mode 100644 src/cli.ts create mode 100644 src/fetch-cert.ts create mode 100644 src/hash.ts create mode 100644 src/index.ts create mode 100644 src/keys.ts create mode 100644 src/types.ts create mode 100644 src/verify.test.ts create mode 100644 src/verify.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc66c59 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Node ${{ matrix.node }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + node: [20, 22] + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm pack --dry-run diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5c36c85 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,25 @@ +name: Publish to npm + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm publish --provenance --access public diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2203f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +.env.local +coverage/ +*.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f138139 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +## [0.1.0] - Unreleased + +### Added + +- Initial public release of `@certifieddata/verify`. +- `certifieddata-verify` and `cd-verify` binaries. +- RFC 8785 JCS canonicalizer (`canonicalize.ts`). +- Ed25519 signature verification using `node:crypto` only — zero third-party crypto dependencies. +- `cert.v1` schema support. +- `--dataset`, `--json`, `--offline`, `--keys`, `--no-cache` flags. +- Trusted-keys document fetched from `https://certifieddata.io/.well-known/certifieddata-keys.json` with TTL cache at `~/.certifieddata/keys.json`. +- Six exit codes documented in the README and `--help`. +- 34 tests across canonicalize, verify, and CLI suites. diff --git a/LICENSE b/LICENSE index e6ff1bc..2d3ccb2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Certified Data +Copyright (c) 2026 CertifiedData.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b6e91b2..cbf3de3 100644 --- a/README.md +++ b/README.md @@ -1 +1,108 @@ -# verify \ No newline at end of file +# @certifieddata/verify + +> Verify CertifiedData.io certificates from the command line. Audit-friendly, zero crypto dependencies. + +## Install + verify in three lines + +```bash +npm install -g @certifieddata/verify +certifieddata-verify ce_01HXYZ123abc... --dataset path/to/data.csv +# → ✓ VALID certification_id ce_01HXYZ123abc... +``` + +## What this verifies + +- **The signature.** `cert.signature` is an Ed25519 signature over the RFC 8785 JCS canonicalization of the rest of the certificate. We re-canonicalize, re-verify, and refuse to claim a cert is valid unless the signature checks out. +- **The signer.** `cert.key_id` must appear in the issuer's published [`.well-known` keys document](https://certifieddata.io/.well-known/certifieddata-keys.json) and must not be revoked. +- **The dataset (optional).** When `--dataset ` is supplied, we stream-hash the file and refuse to claim a match unless its SHA-256 is bit-identical to `cert.dataset_hash`. + +## Why audit-friendly + +The whole verification routine lives in [`src/verify.ts`](src/verify.ts) — under 100 lines, no clever indirection, no third-party crypto. We use `node:crypto` directly: + +```ts +const ok = crypto.verify('ed25519', canonicalBytes, publicKey, signatureBytes); +``` + +If you can read TypeScript, you can audit our verifier in five minutes. + +## Exit codes + +| Code | Verdict | Meaning | +|------|---------|---------| +| 0 | `VALID` | Signature verified and key is trusted (and dataset matches if `--dataset` was passed) | +| 1 | `INVALID` / `DATASET_MISMATCH` | Signature does not verify, or recomputed dataset hash differs | +| 2 | `UNKNOWN_KEY` | `key_id` is not in the trusted keys document, or has been revoked | +| 3 | `MALFORMED` | Certificate JSON is missing required fields, has bad base64, etc. | +| 4 | `NETWORK` | Could not reach the API or `.well-known` endpoint and no fresh cache is available | +| 64 | `USAGE` | Bad command-line flags | + +## `--json` schema + +```json +{ + "verdict": "VALID | INVALID | UNKNOWN_KEY | DATASET_MISMATCH | MALFORMED", + "certification_id": "ce_...", + "key_id": "ck_...", + "issuer": "CertifiedData.io", + "algorithm": "CTGAN", + "signed_at": "2026-03-18T20:31:45Z", + "dataset_hash_expected": "sha256:...", + "dataset_hash_actual": "sha256:... | null", + "checks": { + "signature": "pass | fail | skipped", + "key_trust": "pass | fail | skipped", + "dataset_match": "pass | fail | skipped" + }, + "reason": "human-readable explanation" +} +``` + +## Use in CI + +```yaml +- name: Verify training-data certificate + run: | + npm install -g @certifieddata/verify + certifieddata-verify "${{ env.TRAINING_CERT_ID }}" --dataset data/training.csv --json \ + | tee verify-result.json +- uses: actions/upload-artifact@v4 + with: { name: cert-verification, path: verify-result.json } +``` + +The non-zero exit codes fail the job automatically — a CI run will not pass if your training data has drifted from the cert. + +## Offline / air-gapped audit + +```bash +# Pre-stage a copy of the issuer's keys document, then verify with no network. +curl -O https://certifieddata.io/.well-known/certifieddata-keys.json +certifieddata-verify ./received-cert.json --keys ./certifieddata-keys.json --offline +``` + +`--offline` refuses to make any network call. Combined with `--keys`, it produces a fully reproducible audit you can replay months later. + +## How CertifiedData certificates work + +CertifiedData.io issues `cert.v1` documents that bind together: + +1. A **dataset hash** — `sha256(file_bytes)` for binary data (CSV, Parquet) or `sha256(JCS(payload))` for structured data. +2. **Provenance** — the algorithm used, row/column counts, the issuance timestamp, and an opaque `certification_id`. +3. A **signer** — `key_id`, with the public key fetched from the issuer's `.well-known` endpoint. + +The signature is computed over the RFC 8785 JCS canonicalization of the certificate **with the `signature` field omitted** — this is the only sane way to sign a JSON document and have it round-trip through arbitrary JSON parsers. + +We use Ed25519 because it is fast, deterministic, has small keys (32 bytes) and small signatures (64 bytes), and is built into Node's `crypto` module. We never sign the field that contains the signature, and we never claim a verdict beyond what the cert actually says — for example, we will not call a CTGAN cert "differentially private" unless the metadata explicitly carries a non-null `epsilon` and the algorithm is `DP-CTGAN`. + +## Reporting vulnerabilities + +See [SECURITY.md](SECURITY.md). Please do not open a public issue for cryptographic findings — email `security@certifieddata.io` and we will respond within 48 hours. + +## Related projects + +- [`@certifieddata/pii-scan`](https://github.com/certifieddata/pii-scan) — scan datasets for PII before certifying them +- [`certifieddata/reference-impl`](https://github.com/certifieddata/reference-impl) — a 50-line EU AI Act Article 12 reference application that uses this CLI + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..578e7b6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security policy + +## Reporting a vulnerability + +Please report security issues privately to **security@certifieddata.io**. + +- Please do not open a public GitHub issue for cryptographic findings until a fix is released. +- We aim to acknowledge reports within **48 hours**, ship a fix or workaround within **7 days** for high-severity findings, and request a CVE for any cryptographic finding. +- We will credit you in the release notes unless you ask us not to. + +## Scope + +In scope: + +- Bypass of signature verification in `verifyCertificate` (false `VALID` verdict on a cert that should not verify). +- Incorrect handling of revoked keys, malformed payloads, or non-canonical JSON that produces an exploitable mismatch between signed and verified bytes. +- Any path where the CLI returns exit code 0 for a certificate that does not actually verify. +- Cache poisoning of `~/.certifieddata/keys.json` that could elevate an untrusted key to "trusted". + +Out of scope: + +- Issues in upstream Node.js `node:crypto` — please report those to the Node.js project. +- DoS on a single host (e.g. very large fixtures making `sha256File` slow). +- Anything depending on a compromised local environment that already has write access to your home directory. + +## Supported versions + +Until 1.0.0, we support the latest minor release on the `0.x` line. After 1.0.0, we will support the two most recent minor versions. + +This package follows [semantic versioning](https://semver.org/). Any breaking change to the verification path or to the `cert.v1` shape is a major version bump. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c41e675 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["dist/", "fixtures/", "node_modules/"], + }, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + }, + }, +); diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..b4c6c53 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,33 @@ +# Test fixtures + +These files are committed so reviewers can verify the verifier without +trusting a pre-built artifact. They are produced by `generate.mjs`, +which: + +1. Generates a fresh Ed25519 keypair via `node:crypto`. +2. Hashes a tiny CSV dataset. +3. Signs a `cert.v1` certificate over its JCS canonicalization (with + the `signature` field omitted). +4. Writes the corresponding `keys.json`, plus three failure-case + certs: tampered, unknown-key, malformed. + +Regenerate with: + +```bash +npm run fixtures +``` + +After regeneration, all three CLI tests should still pass — the test +script depends only on the structural shape of the fixtures, not the +specific key material. + +## Files + +| File | Purpose | Expected verdict | +|---|---|---| +| `valid-cert.json` | Cleanly-signed demo cert | `VALID` | +| `tampered-cert.json` | `rows` mutated post-signature | `INVALID` | +| `unknown-key-cert.json` | Signed by a key not in `keys.json` | `UNKNOWN_KEY` | +| `malformed-cert.json` | `signature` field removed | `MALFORMED` | +| `keys.json` | Trusted-keys document for the test issuer | — | +| `valid-dataset.csv` | The dataset hashed into `valid-cert.json` | — | diff --git a/fixtures/generate.mjs b/fixtures/generate.mjs new file mode 100644 index 0000000..19b31b8 --- /dev/null +++ b/fixtures/generate.mjs @@ -0,0 +1,130 @@ +// Regenerates fixtures/*.json from a fresh Ed25519 keypair so reviewers can verify +// the verifier against real signatures rather than hand-edited blobs. +// +// Usage: node fixtures/generate.mjs +// +// This script is intentionally self-contained — it does not import from src/ so it +// can run before the package builds. + +import { generateKeyPairSync, sign, createHash } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// --- minimal JCS canonicalizer (mirrors src/canonicalize.ts) --- +function canonicalize(v) { + if (v === null) return "null"; + if (v === true) return "true"; + if (v === false) return "false"; + if (typeof v === "string") return jsString(v); + if (typeof v === "number") { + if (!Number.isFinite(v)) throw new RangeError("non-finite number"); + return Object.is(v, -0) ? "0" : JSON.stringify(v); + } + if (Array.isArray(v)) return "[" + v.map(canonicalize).join(",") + "]"; + if (typeof v === "object") { + const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort(); + return "{" + keys.map((k) => jsString(k) + ":" + canonicalize(v[k])).join(",") + "}"; + } + throw new TypeError("unsupported value"); +} +function jsString(s) { + let o = '"'; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 0x22) o += '\\"'; + else if (c === 0x5c) o += "\\\\"; + else if (c === 0x08) o += "\\b"; + else if (c === 0x09) o += "\\t"; + else if (c === 0x0a) o += "\\n"; + else if (c === 0x0c) o += "\\f"; + else if (c === 0x0d) o += "\\r"; + else if (c < 0x20) o += "\\u" + c.toString(16).padStart(4, "0"); + else o += s[i]; + } + return o + '"'; +} + +// --- key generation --- +function rawEd25519PublicKey(publicKey) { + // Strip the 12-byte SPKI prefix to get the raw 32-byte key, then base64-encode. + const der = publicKey.export({ type: "spki", format: "der" }); + return Buffer.from(der.subarray(der.length - 32)).toString("base64"); +} + +const { privateKey, publicKey } = generateKeyPairSync("ed25519"); +const keyId = "ck_2026_test_01"; + +// --- dataset fixture --- +const datasetCsv = "name,age,balance\nAlice,30,1000\nBob,25,2500\nCharlie,40,500\n"; +writeFileSync(join(here, "valid-dataset.csv"), datasetCsv); +const datasetHex = createHash("sha256").update(datasetCsv).digest("hex"); +const datasetHash = `sha256:${datasetHex}`; + +// --- valid certificate --- +function signCert(unsigned) { + const bytes = Buffer.from(canonicalize(unsigned), "utf8"); + const sig = sign(null, bytes, privateKey); + return { ...unsigned, signature: sig.toString("base64") }; +} + +const validUnsigned = { + certification_id: "ce_01HXYZTEST00000000000000", + timestamp: "2026-03-18T20:31:45Z", + issuer: "CertifiedData.io", + dataset_hash: datasetHash, + algorithm: "CTGAN", + rows: 3, + columns: 3, + schema_version: "cert.v1", + key_id: keyId, + metadata: { description: "demo synthetic dataset for verifier fixtures" }, +}; + +const valid = signCert(validUnsigned); +writeFileSync(join(here, "valid-cert.json"), JSON.stringify(valid, null, 2) + "\n"); + +// --- tampered certificate (signature does not verify) --- +const tampered = { ...valid, rows: 999999 }; +writeFileSync(join(here, "tampered-cert.json"), JSON.stringify(tampered, null, 2) + "\n"); + +// --- unknown-key certificate (signed by a different key) --- +const otherKp = generateKeyPairSync("ed25519"); +const unknownSigBytes = sign(null, Buffer.from(canonicalize({ ...validUnsigned, certification_id: "ce_unknown_key_demo" }), "utf8"), otherKp.privateKey); +const unknown = { + ...validUnsigned, + certification_id: "ce_unknown_key_demo", + key_id: "ck_NOT_IN_TRUSTED_LIST", + signature: unknownSigBytes.toString("base64"), +}; +writeFileSync(join(here, "unknown-key-cert.json"), JSON.stringify(unknown, null, 2) + "\n"); + +// --- malformed certificate (missing field) --- +const malformed = { ...valid }; +delete malformed.signature; +writeFileSync(join(here, "malformed-cert.json"), JSON.stringify(malformed, null, 2) + "\n"); + +// --- keys document --- +const keysDoc = { + issuer: "CertifiedData.io", + keys: [ + { + key_id: keyId, + public_key: rawEd25519PublicKey(publicKey), + algorithm: "ed25519", + created_at: "2026-01-01T00:00:00Z", + label: "test-fixture", + }, + ], +}; +writeFileSync(join(here, "keys.json"), JSON.stringify(keysDoc, null, 2) + "\n"); + +console.log("fixtures regenerated:"); +console.log(" keys.json ->", keyId); +console.log(" valid-cert.json -> dataset_hash", datasetHash); +console.log(" tampered-cert.json -> rows mutated to 999999"); +console.log(" unknown-key-cert.json -> key_id ck_NOT_IN_TRUSTED_LIST"); +console.log(" malformed-cert.json -> signature field removed"); +console.log(" valid-dataset.csv -> 3 rows × 3 columns"); diff --git a/fixtures/keys.json b/fixtures/keys.json new file mode 100644 index 0000000..7cc0bce --- /dev/null +++ b/fixtures/keys.json @@ -0,0 +1,12 @@ +{ + "issuer": "CertifiedData.io", + "keys": [ + { + "key_id": "ck_2026_test_01", + "public_key": "6MPbLAJwOsgv4DCOR6264m3s5z0IxRBNqAy8suzZPoU=", + "algorithm": "ed25519", + "created_at": "2026-01-01T00:00:00Z", + "label": "test-fixture" + } + ] +} diff --git a/fixtures/malformed-cert.json b/fixtures/malformed-cert.json new file mode 100644 index 0000000..8645695 --- /dev/null +++ b/fixtures/malformed-cert.json @@ -0,0 +1,14 @@ +{ + "certification_id": "ce_01HXYZTEST00000000000000", + "timestamp": "2026-03-18T20:31:45Z", + "issuer": "CertifiedData.io", + "dataset_hash": "sha256:b11de6b05ffb6ab370f57828cb22399fd0eff0163a9bca4ffd9eb394e8a03ac1", + "algorithm": "CTGAN", + "rows": 3, + "columns": 3, + "schema_version": "cert.v1", + "key_id": "ck_2026_test_01", + "metadata": { + "description": "demo synthetic dataset for verifier fixtures" + } +} diff --git a/fixtures/tampered-cert.json b/fixtures/tampered-cert.json new file mode 100644 index 0000000..25ada95 --- /dev/null +++ b/fixtures/tampered-cert.json @@ -0,0 +1,15 @@ +{ + "certification_id": "ce_01HXYZTEST00000000000000", + "timestamp": "2026-03-18T20:31:45Z", + "issuer": "CertifiedData.io", + "dataset_hash": "sha256:b11de6b05ffb6ab370f57828cb22399fd0eff0163a9bca4ffd9eb394e8a03ac1", + "algorithm": "CTGAN", + "rows": 999999, + "columns": 3, + "schema_version": "cert.v1", + "key_id": "ck_2026_test_01", + "metadata": { + "description": "demo synthetic dataset for verifier fixtures" + }, + "signature": "Iy6EgIUxRFS8JJJAI5Qox/BicG3/hStZoQMUnU4VeKvD/d65kmAY4OlvmCYa1PbpFMeVUBwr1zH1cUZX8pGqDg==" +} diff --git a/fixtures/unknown-key-cert.json b/fixtures/unknown-key-cert.json new file mode 100644 index 0000000..9768805 --- /dev/null +++ b/fixtures/unknown-key-cert.json @@ -0,0 +1,15 @@ +{ + "certification_id": "ce_unknown_key_demo", + "timestamp": "2026-03-18T20:31:45Z", + "issuer": "CertifiedData.io", + "dataset_hash": "sha256:b11de6b05ffb6ab370f57828cb22399fd0eff0163a9bca4ffd9eb394e8a03ac1", + "algorithm": "CTGAN", + "rows": 3, + "columns": 3, + "schema_version": "cert.v1", + "key_id": "ck_NOT_IN_TRUSTED_LIST", + "metadata": { + "description": "demo synthetic dataset for verifier fixtures" + }, + "signature": "YIdv+H6zosaAYc0ofVA8QnpamVOUDIZETZfrevzsBHopUCmLdGpspknTg/P69OBKPtEHfeUwEVfrsM/pMhedAA==" +} diff --git a/fixtures/valid-cert.json b/fixtures/valid-cert.json new file mode 100644 index 0000000..83b413d --- /dev/null +++ b/fixtures/valid-cert.json @@ -0,0 +1,15 @@ +{ + "certification_id": "ce_01HXYZTEST00000000000000", + "timestamp": "2026-03-18T20:31:45Z", + "issuer": "CertifiedData.io", + "dataset_hash": "sha256:b11de6b05ffb6ab370f57828cb22399fd0eff0163a9bca4ffd9eb394e8a03ac1", + "algorithm": "CTGAN", + "rows": 3, + "columns": 3, + "schema_version": "cert.v1", + "key_id": "ck_2026_test_01", + "metadata": { + "description": "demo synthetic dataset for verifier fixtures" + }, + "signature": "Iy6EgIUxRFS8JJJAI5Qox/BicG3/hStZoQMUnU4VeKvD/d65kmAY4OlvmCYa1PbpFMeVUBwr1zH1cUZX8pGqDg==" +} diff --git a/fixtures/valid-dataset.csv b/fixtures/valid-dataset.csv new file mode 100644 index 0000000..7c07186 --- /dev/null +++ b/fixtures/valid-dataset.csv @@ -0,0 +1,4 @@ +name,age,balance +Alice,30,1000 +Bob,25,2500 +Charlie,40,500 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e02ce60 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1511 @@ +{ + "name": "@certifieddata/verify", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@certifieddata/verify", + "version": "0.1.0", + "license": "MIT", + "bin": { + "cd-verify": "dist/cli.js", + "certifieddata-verify": "dist/cli.js" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/node": "^22.10.0", + "eslint": "^9.15.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..50b5819 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "@certifieddata/verify", + "version": "0.1.0", + "description": "Verify CertifiedData.io certificates from the command line. Audit-friendly, zero crypto dependencies.", + "type": "module", + "bin": { + "certifieddata-verify": "dist/cli.js", + "cd-verify": "dist/cli.js" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist/", + "!dist/**/*.test.*", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "license": "MIT", + "author": "CertifiedData.io", + "repository": { + "type": "git", + "url": "git+https://github.com/certifieddata/verify.git" + }, + "homepage": "https://github.com/certifieddata/verify#readme", + "bugs": { + "url": "https://github.com/certifieddata/verify/issues" + }, + "keywords": [ + "certifieddata", + "verification", + "ed25519", + "synthetic-data", + "cryptography", + "cli", + "audit", + "rfc8785", + "jcs" + ], + "scripts": { + "build": "tsc -p tsconfig.json && chmod +x dist/cli.js", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "test": "tsc -p tsconfig.json && node --test dist/canonicalize.test.js dist/verify.test.js dist/cli.test.js", + "fixtures": "node fixtures/generate.mjs", + "prepublishOnly": "npm run build && npm test" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.6.0", + "eslint": "^9.15.0", + "typescript-eslint": "^8.15.0", + "@eslint/js": "^9.15.0" + } +} diff --git a/src/canonicalize.test.ts b/src/canonicalize.test.ts new file mode 100644 index 0000000..b3878f6 --- /dev/null +++ b/src/canonicalize.test.ts @@ -0,0 +1,122 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { canonicalize, canonicalizeToBytes } from "./canonicalize.js"; + +test("primitives", () => { + assert.equal(canonicalize(null), "null"); + assert.equal(canonicalize(true), "true"); + assert.equal(canonicalize(false), "false"); + assert.equal(canonicalize(0), "0"); + assert.equal(canonicalize(-0), "0"); + assert.equal(canonicalize(1), "1"); + assert.equal(canonicalize(1.5), "1.5"); +}); + +test("strings — minimal RFC 8259 escapes", () => { + assert.equal(canonicalize(""), '""'); + assert.equal(canonicalize("hello"), '"hello"'); + assert.equal(canonicalize('a"b'), '"a\\"b"'); + assert.equal(canonicalize("a\\b"), '"a\\\\b"'); + assert.equal(canonicalize("a\nb"), '"a\\nb"'); + assert.equal(canonicalize("a\tb"), '"a\\tb"'); + assert.equal(canonicalize("a\rb"), '"a\\rb"'); + assert.equal(canonicalize("\b\f"), '"\\b\\f"'); + // Other control chars use \u00XX. + assert.equal(canonicalize(""), '"\\u0001"'); + assert.equal(canonicalize(""), '"\\u001f"'); + // Non-ASCII characters pass through unescaped (UTF-8 in the byte form). + assert.equal(canonicalize("ä"), '"ä"'); +}); + +test("arrays — preserve order", () => { + assert.equal(canonicalize([]), "[]"); + assert.equal(canonicalize([1, 2, 3]), "[1,2,3]"); + assert.equal(canonicalize(["b", "a"]), '["b","a"]'); +}); + +test("objects — keys sorted by UTF-16 code units", () => { + assert.equal(canonicalize({}), "{}"); + assert.equal(canonicalize({ b: 1, a: 2 }), '{"a":2,"b":1}'); + // RFC 8785 §3.2.3 sorting example (code-unit order, NOT Unicode codepoint order). + // The keys "ä" (ä) and "\" (\) and "€" (€) sort by UTF-16 units. + const input = { "€": "Euro", "ä": "a-umlaut", a: "ascii" }; + // Code-unit values: 0x61 (a), 0xe4 (ä), 0x20ac (€). + assert.equal(canonicalize(input), '"a":"ascii","ä":"a-umlaut","€":"Euro"'.replace(/^/, "{") + "}"); +}); + +test("RFC 8785 §3.2.3 sample (sorting nested objects)", () => { + // Adapted from RFC 8785 example: keys including non-ASCII chars and surrogate pairs + // must compare as UTF-16 code-unit sequences. + const input = { + peach: "This sorting order", + péché: "is wrong according to French", + pêche: "but canonicalization MUST", + sin: "ignore locale", + }; + // Sort by UTF-16 code units of the keys. + // peach (p,e,a,c,h) — 0x70 0x65 ... + // péché (p,é=0xe9,c,h,é) + // pêche (p,ê=0xea,c,h,e) + // sin (s=0x73, ...) + // Order by first differing unit: peach < péché < pêche < sin. + const out = canonicalize(input); + assert.match(out, /^\{"peach":/); + // Confirm the four keys appear in the expected order. + const order = ["peach", "péché", "pêche", "sin"]; + let idx = -1; + for (const k of order) { + const next = out.indexOf(`"${k}":`); + assert.ok(next > idx, `expected ${k} after position ${idx}, got ${next}`); + idx = next; + } +}); + +test("nested objects + arrays", () => { + const input = { z: [3, 2, 1], a: { y: 1, x: 2 } }; + assert.equal(canonicalize(input), '{"a":{"x":2,"y":1},"z":[3,2,1]}'); +}); + +test("undefined keys are dropped", () => { + // RFC 8785 inputs come from JSON; JS undefined has no JSON encoding, so we drop. + const input = { a: 1, b: undefined, c: 3 } as Record; + assert.equal(canonicalize(input), '{"a":1,"c":3}'); +}); + +test("non-finite numbers throw", () => { + assert.throws(() => canonicalize(NaN), RangeError); + assert.throws(() => canonicalize(Infinity), RangeError); + assert.throws(() => canonicalize(-Infinity), RangeError); +}); + +test("canonicalizeToBytes round-trips through UTF-8", () => { + const bytes = canonicalizeToBytes({ a: "ä" }); + assert.equal(new TextDecoder().decode(bytes), '{"a":"ä"}'); +}); + +test("idempotent: canonicalize(JSON.parse(canonicalize(x))) === canonicalize(x)", () => { + const x = { z: 1, a: { c: [1, 2], b: "x" } }; + const once = canonicalize(x); + const twice = canonicalize(JSON.parse(once)); + assert.equal(once, twice); +}); + +test("number formatting matches JSON.stringify for finite numbers", () => { + for (const n of [0, 1, -1, 1.5, -1.5, 1e20, 1e-7, 0.1 + 0.2]) { + assert.equal(canonicalize(n), JSON.stringify(n)); + } +}); + +test("removes only the named field — signature stripping pattern", () => { + const cert = { a: 1, signature: "AAA", z: 9 }; + const { signature: _s, ...rest } = cert; + assert.equal(canonicalize(rest), '{"a":1,"z":9}'); +}); + +test("array of objects sorts each object's keys independently", () => { + const input = [{ b: 1, a: 2 }, { d: 4, c: 3 }]; + assert.equal(canonicalize(input), '[{"a":2,"b":1},{"c":3,"d":4}]'); +}); + +test("empty key string", () => { + assert.equal(canonicalize({ "": "x", a: "y" }), '{"":"x","a":"y"}'); +}); diff --git a/src/canonicalize.ts b/src/canonicalize.ts new file mode 100644 index 0000000..acb2f59 --- /dev/null +++ b/src/canonicalize.ts @@ -0,0 +1,90 @@ +// RFC 8785 — JSON Canonicalization Scheme (JCS). +// Hand-written so reviewers can confirm there is no surprise behavior. +// +// Rules summarized: +// - Object keys are sorted lexicographically by their UTF-16 code-unit sequence. +// - Strings are escaped using the minimal RFC 8259 §7 escapes (",\,\b,\f,\n,\r,\t) +// plus \u00XX for any other control character (U+0000..U+001F). +// - Numbers are emitted via the ECMAScript Number-to-String algorithm (ES2020 +// §7.1.12.1), which is what JSON.stringify already produces for finite numbers. +// Non-finite numbers (NaN, ±Infinity) MUST NOT appear in canonical JSON. +// - No insignificant whitespace anywhere. +// - Arrays preserve insertion order; null/true/false serialize as their literals. + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [k: string]: JsonValue }; + +export function canonicalize(value: unknown): string { + return serialize(value as JsonValue); +} + +export function canonicalizeToBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(canonicalize(value)); +} + +function serialize(value: JsonValue): string { + if (value === null) return "null"; + if (value === true) return "true"; + if (value === false) return "false"; + if (typeof value === "string") return serializeString(value); + if (typeof value === "number") return serializeNumber(value); + if (Array.isArray(value)) return serializeArray(value); + if (typeof value === "object") return serializeObject(value as Record); + throw new TypeError(`canonicalize: unsupported value of type ${typeof value}`); +} + +function serializeArray(arr: JsonValue[]): string { + const parts: string[] = []; + for (const item of arr) parts.push(serialize(item)); + return "[" + parts.join(",") + "]"; +} + +function serializeObject(obj: Record): string { + // RFC 8785 §3.2.3: sort by UTF-16 code units. JS strings are UTF-16, and the + // default Array#sort comparator on strings compares code-unit-by-code-unit, which + // is exactly the JCS requirement. + const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(); + const parts: string[] = []; + for (const k of keys) { + parts.push(serializeString(k) + ":" + serialize(obj[k])); + } + return "{" + parts.join(",") + "}"; +} + +function serializeNumber(n: number): string { + if (!Number.isFinite(n)) { + throw new RangeError(`canonicalize: non-finite number ${n}`); + } + // ECMAScript Number-to-String, which JSON.stringify already invokes for finite + // numbers. JCS aligns with this exact serialization. + if (Object.is(n, -0)) return "0"; + return JSON.stringify(n); +} + +function serializeString(s: string): string { + let out = '"'; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + switch (c) { + case 0x22: out += '\\"'; break; + case 0x5c: out += "\\\\"; break; + case 0x08: out += "\\b"; break; + case 0x09: out += "\\t"; break; + case 0x0a: out += "\\n"; break; + case 0x0c: out += "\\f"; break; + case 0x0d: out += "\\r"; break; + default: + if (c < 0x20) { + out += "\\u" + c.toString(16).padStart(4, "0"); + } else { + out += s[i]; + } + } + } + return out + '"'; +} diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..fd62c88 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,154 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const cliPath = join(here, "cli.js"); +const fixturesDir = join(here, "..", "fixtures"); +const keysPath = join(fixturesDir, "keys.json"); + +interface Run { stdout: string; stderr: string; code: number; } + +function run(args: string[], input?: string): Promise { + return new Promise((resolve) => { + const env = { ...process.env, NO_COLOR: "1" }; + const child = spawn(process.execPath, [cliPath, ...args], { env }); + let stdout = ""; let stderr = ""; + child.stdout.on("data", (d) => (stdout += d.toString())); + child.stderr.on("data", (d) => (stderr += d.toString())); + if (input !== undefined) { child.stdin.write(input); child.stdin.end(); } + child.on("close", (code) => resolve({ stdout, stderr, code: code ?? -1 })); + }); +} + +test("VALID — exits 0 against a clean cert + dataset", async () => { + const r = await run([ + join(fixturesDir, "valid-cert.json"), + "--keys", keysPath, + "--offline", + "--dataset", join(fixturesDir, "valid-dataset.csv"), + ]); + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.match(r.stdout, /VALID/); +}); + +test("INVALID — exits 1 against a tampered cert", async () => { + const r = await run([ + join(fixturesDir, "tampered-cert.json"), + "--keys", keysPath, + "--offline", + ]); + assert.equal(r.code, 1); + assert.match(r.stdout, /INVALID/); +}); + +test("UNKNOWN_KEY — exits 2 when key_id is not in trusted set", async () => { + const r = await run([ + join(fixturesDir, "unknown-key-cert.json"), + "--keys", keysPath, + "--offline", + ]); + assert.equal(r.code, 2); + assert.match(r.stdout, /UNKNOWN_KEY/); +}); + +test("MALFORMED — exits 3 when required fields are missing", async () => { + const r = await run([ + join(fixturesDir, "malformed-cert.json"), + "--keys", keysPath, + "--offline", + ]); + assert.equal(r.code, 3); + assert.match(r.stdout, /MALFORMED/); +}); + +test("DATASET_MISMATCH — exits 1 with a clear reason", async () => { + const r = await run([ + join(fixturesDir, "valid-cert.json"), + "--keys", keysPath, + "--offline", + "--dataset", keysPath, // wrong file -> wrong hash + ]); + assert.equal(r.code, 1); + assert.match(r.stdout, /DATASET_MISMATCH/); +}); + +test("--json — emits a structured result with all expected fields", async () => { + const r = await run([ + join(fixturesDir, "valid-cert.json"), + "--keys", keysPath, + "--offline", + "--json", + ]); + assert.equal(r.code, 0); + const parsed = JSON.parse(r.stdout); + assert.equal(parsed.verdict, "VALID"); + assert.equal(parsed.issuer, "CertifiedData.io"); + assert.equal(parsed.checks.signature, "pass"); + assert.equal(parsed.checks.key_trust, "pass"); + assert.equal(parsed.checks.dataset_match, "skipped"); + assert.ok(typeof parsed.certification_id === "string"); +}); + +test("--json on tampered cert returns verdict INVALID with signature=fail", async () => { + const r = await run([ + join(fixturesDir, "tampered-cert.json"), + "--keys", keysPath, + "--offline", + "--json", + ]); + assert.equal(r.code, 1); + const parsed = JSON.parse(r.stdout); + assert.equal(parsed.verdict, "INVALID"); + assert.equal(parsed.checks.signature, "fail"); +}); + +test("--help — exits 0 and prints usage", async () => { + const r = await run(["--help"]); + assert.equal(r.code, 0); + assert.match(r.stdout, /certifieddata-verify/); + assert.match(r.stdout, /Exit codes/); +}); + +test("--version — exits 0 and prints package version", async () => { + const r = await run(["--version"]); + assert.equal(r.code, 0); + assert.match(r.stdout, /@certifieddata\/verify/); +}); + +test("USAGE — exits 64 on unknown flag", async () => { + const r = await run(["--made-up-flag"]); + assert.equal(r.code, 64); + assert.match(r.stderr, /unknown option/); +}); + +test("USAGE — exits 64 when no positional argument is given", async () => { + const r = await run(["--keys", keysPath, "--offline"]); + assert.equal(r.code, 64); +}); + +test("stdin input via '-' — exits 0 against a piped valid cert", async () => { + const { readFile } = await import("node:fs/promises"); + const certBody = await readFile(join(fixturesDir, "valid-cert.json"), "utf8"); + const r = await run(["-", "--keys", keysPath, "--offline"], certBody); + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.match(r.stdout, /VALID/); +}); + +test("checks pass/fail/skipped for each verdict in --json mode", async () => { + const cases: Array<[string, string, Record]> = [ + ["valid-cert.json", "VALID", { signature: "pass", key_trust: "pass", dataset_match: "skipped" }], + ["tampered-cert.json", "INVALID", { signature: "fail", key_trust: "pass", dataset_match: "skipped" }], + ["unknown-key-cert.json", "UNKNOWN_KEY", { signature: "skipped", key_trust: "fail", dataset_match: "skipped" }], + ]; + for (const [file, expected, checks] of cases) { + const r = await run([join(fixturesDir, file), "--keys", keysPath, "--offline", "--json"]); + const parsed = JSON.parse(r.stdout); + assert.equal(parsed.verdict, expected, `case ${file}`); + for (const [k, v] of Object.entries(checks)) { + assert.equal(parsed.checks[k], v, `${file}.checks.${k}`); + } + } +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..5851c54 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,182 @@ +#!/usr/bin/env node +import { fetchCert } from "./fetch-cert.js"; +import { loadKeys } from "./keys.js"; +import { verifyCertificate } from "./verify.js"; +import type { VerifyResult } from "./types.js"; + +interface CliArgs { + positional: string[]; + dataset?: string; + keys?: string; + json: boolean; + offline: boolean; + noCache: boolean; + help: boolean; + version: boolean; +} + +const HELP = `certifieddata-verify [options] + +Verify a CertifiedData.io certificate. + +Inputs: + certification UUID (resolved against the public API) + local certificate file + direct URL to a certificate JSON + - read certificate JSON from stdin + +Options: + --dataset recompute SHA-256 of dataset file and compare to cert.dataset_hash + --keys use a local keys document instead of fetching .well-known + --offline do not touch the network (requires --keys or a fresh cache) + --no-cache bypass ~/.certifieddata/keys.json cache + --json machine-readable output + --version print version + --help this message + +Exit codes: + 0 VALID 1 INVALID 2 UNKNOWN_KEY 3 MALFORMED + 4 NETWORK 64 USAGE`; + +const EXIT = { + VALID: 0, INVALID: 1, UNKNOWN_KEY: 2, MALFORMED: 3, NETWORK: 4, USAGE: 64, +} as const; + +const COLOR = process.stdout.isTTY && !process.env.NO_COLOR; +const c = { + green: (s: string) => COLOR ? `\x1b[32m${s}\x1b[0m` : s, + red: (s: string) => COLOR ? `\x1b[31m${s}\x1b[0m` : s, + yellow:(s: string) => COLOR ? `\x1b[33m${s}\x1b[0m` : s, + dim: (s: string) => COLOR ? `\x1b[2m${s}\x1b[0m` : s, +}; + +export async function main(argv: string[]): Promise { + let args: CliArgs; + try { args = parseArgs(argv); } catch (e) { + process.stderr.write(`error: ${(e as Error).message}\n${HELP}\n`); + return EXIT.USAGE; + } + + if (args.help) { process.stdout.write(HELP + "\n"); return EXIT.VALID; } + if (args.version) { process.stdout.write(await readVersion() + "\n"); return EXIT.VALID; } + + if (args.positional.length !== 1) { + process.stderr.write(`error: expected exactly one certificate argument\n${HELP}\n`); + return EXIT.USAGE; + } + + const target = args.positional[0]; + let result: VerifyResult; + try { + const cert = await fetchCert(target, { offline: args.offline }); + const keys = await loadKeys({ keysFile: args.keys, offline: args.offline, noCache: args.noCache }); + result = await verifyCertificate(cert, keys, args.dataset); + } catch (err) { + const reason = (err as Error).message; + const isNetwork = /failed to fetch|HTTP \d|ENOTFOUND|ECONN|getaddrinfo/i.test(reason); + if (args.json) { + process.stdout.write(JSON.stringify(networkErrorResult(reason)) + "\n"); + } else { + process.stderr.write(`${c.red("✗ ERROR")} ${reason}\n`); + } + return isNetwork ? EXIT.NETWORK : EXIT.MALFORMED; + } + + if (args.json) { + process.stdout.write(JSON.stringify(result) + "\n"); + } else { + printHuman(result); + } + return verdictToExit(result); +} + +function parseArgs(argv: string[]): CliArgs { + const out: CliArgs = { positional: [], json: false, offline: false, noCache: false, help: false, version: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case "--help": case "-h": out.help = true; break; + case "--version": case "-v": out.version = true; break; + case "--json": out.json = true; break; + case "--offline": out.offline = true; break; + case "--no-cache": out.noCache = true; break; + case "--dataset": out.dataset = requireValue(argv, ++i, a); break; + case "--keys": out.keys = requireValue(argv, ++i, a); break; + default: + if (a.startsWith("--")) throw new Error(`unknown option: ${a}`); + out.positional.push(a); + } + } + return out; +} + +function requireValue(argv: string[], i: number, flag: string): string { + const v = argv[i]; + if (v === undefined) throw new Error(`${flag} requires a value`); + return v; +} + +function verdictToExit(r: VerifyResult): number { + switch (r.verdict) { + case "VALID": return EXIT.VALID; + case "INVALID": case "DATASET_MISMATCH": return EXIT.INVALID; + case "UNKNOWN_KEY": return EXIT.UNKNOWN_KEY; + case "MALFORMED": return EXIT.MALFORMED; + } +} + +function printHuman(r: VerifyResult): void { + const id = r.certification_id ?? "(unknown)"; + switch (r.verdict) { + case "VALID": { + process.stdout.write(`${c.green("✓ VALID")} certification_id ${id}\n`); + const label = r.key_label ? `${r.key_id} (${r.issuer}, ${r.key_label})` : `${r.key_id} (${r.issuer})`; + process.stdout.write(` ${c.dim("signed by")} ${label}\n`); + const rows = (r.rows ?? 0).toLocaleString("en-US"); + const cols = (r.columns ?? 0).toLocaleString("en-US"); + process.stdout.write(` ${c.dim("algorithm")} ${r.algorithm} · ${rows} rows × ${cols} cols · signed ${r.signed_at}\n`); + if (r.checks.dataset_match === "pass") { + process.stdout.write(` ${c.dim("dataset")} ${r.dataset_hash_actual} ${c.green("matches")}\n`); + } + break; + } + case "INVALID": + process.stdout.write(`${c.red("✗ INVALID")} certification_id ${id}\n ${r.reason}\n`); + break; + case "DATASET_MISMATCH": + process.stdout.write(`${c.red("✗ DATASET_MISMATCH")} certification_id ${id}\n`); + process.stdout.write(` expected ${r.dataset_hash_expected}\n actual ${r.dataset_hash_actual}\n`); + break; + case "UNKNOWN_KEY": + process.stdout.write(`${c.yellow("? UNKNOWN_KEY")} certification_id ${id}\n ${r.reason}\n`); + break; + case "MALFORMED": + process.stdout.write(`${c.red("✗ MALFORMED")} ${r.reason}\n`); + break; + } +} + +function networkErrorResult(reason: string): VerifyResult { + return { + verdict: "MALFORMED", + certification_id: null, key_id: null, issuer: null, algorithm: null, signed_at: null, + dataset_hash_expected: null, dataset_hash_actual: null, + checks: { signature: "skipped", key_trust: "skipped", dataset_match: "skipped" }, + reason, + }; +} + +async function readVersion(): Promise { + try { + const { readFile } = await import("node:fs/promises"); + const { fileURLToPath } = await import("node:url"); + const { dirname, join } = await import("node:path"); + const here = dirname(fileURLToPath(import.meta.url)); + const pkg = JSON.parse(await readFile(join(here, "..", "package.json"), "utf8")); + return `@certifieddata/verify ${pkg.version}`; + } catch { return "@certifieddata/verify (unknown version)"; } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(process.argv.slice(2)).then((code) => process.exit(code)); +} diff --git a/src/fetch-cert.ts b/src/fetch-cert.ts new file mode 100644 index 0000000..7f0dc20 --- /dev/null +++ b/src/fetch-cert.ts @@ -0,0 +1,46 @@ +import { readFile } from "node:fs/promises"; +import type { Certificate } from "./types.js"; + +export const DEFAULT_CERT_API = "https://certifieddata.io/api/v1/certificates"; + +export interface FetchCertOptions { + apiBase?: string; + offline?: boolean; +} + +export async function fetchCert(idOrPathOrUrl: string, opts: FetchCertOptions = {}): Promise { + if (idOrPathOrUrl === "-") { + return parseCertJson(await readStdin()); + } + if (idOrPathOrUrl.endsWith(".json") || idOrPathOrUrl.startsWith("./") || idOrPathOrUrl.startsWith("/")) { + return parseCertJson(await readFile(idOrPathOrUrl, "utf8")); + } + if (/^https?:\/\//.test(idOrPathOrUrl)) { + if (opts.offline) throw new Error("cannot fetch URL in --offline mode"); + return parseCertJson(await fetchText(idOrPathOrUrl)); + } + if (opts.offline) { + throw new Error("cannot resolve certification id in --offline mode (pass a local file)"); + } + const base = opts.apiBase ?? DEFAULT_CERT_API; + const url = `${base.replace(/\/$/, "")}/${encodeURIComponent(idOrPathOrUrl)}`; + return parseCertJson(await fetchText(url)); +} + +async function fetchText(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + return res.text(); +} + +function parseCertJson(body: string): Certificate { + return JSON.parse(body) as Certificate; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/hash.ts b/src/hash.ts new file mode 100644 index 0000000..4899aa4 --- /dev/null +++ b/src/hash.ts @@ -0,0 +1,26 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; + +export function sha256Hex(bytes: Uint8Array | string): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export async function sha256File(path: string): Promise { + return new Promise((resolve, reject) => { + const h = createHash("sha256"); + const s = createReadStream(path); + s.on("error", reject); + s.on("data", (chunk) => h.update(chunk)); + s.on("end", () => resolve(h.digest("hex"))); + }); +} + +export function formatDigest(hex: string): string { + return `sha256:${hex}`; +} + +export function parseDigest(value: string): { algo: string; hex: string } { + const m = /^([a-z0-9-]+):([0-9a-f]+)$/i.exec(value); + if (!m) throw new Error(`malformed digest: ${value}`); + return { algo: m[1].toLowerCase(), hex: m[2].toLowerCase() }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ef75e1d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +export { verifyCertificate } from "./verify.js"; +export { canonicalize, canonicalizeToBytes } from "./canonicalize.js"; +export { sha256Hex, sha256File, formatDigest, parseDigest } from "./hash.js"; +export { loadKeys, findKey, DEFAULT_KEYS_URL } from "./keys.js"; +export { fetchCert, DEFAULT_CERT_API } from "./fetch-cert.js"; +export type { + Certificate, + KeyDoc, + KeyEntry, + VerifyResult, + Verdict, + CheckResult, +} from "./types.js"; diff --git a/src/keys.ts b/src/keys.ts new file mode 100644 index 0000000..8595384 --- /dev/null +++ b/src/keys.ts @@ -0,0 +1,81 @@ +import { readFile, writeFile, mkdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { KeyDoc, KeyEntry } from "./types.js"; + +export const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/certifieddata-keys.json"; +export const CACHE_PATH = join(homedir(), ".certifieddata", "keys.json"); +export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +export interface LoadKeysOptions { + url?: string; + keysFile?: string; + noCache?: boolean; + offline?: boolean; + cachePath?: string; +} + +export async function loadKeys(opts: LoadKeysOptions = {}): Promise { + if (opts.keysFile) { + return parseKeyDoc(await readFile(opts.keysFile, "utf8")); + } + + const cachePath = opts.cachePath ?? CACHE_PATH; + + if (opts.offline) { + if (opts.noCache) { + throw new Error("offline mode requires --keys when --no-cache is set"); + } + return parseKeyDoc(await readFile(cachePath, "utf8")); + } + + if (!opts.noCache) { + const fresh = await readCacheIfFresh(cachePath); + if (fresh) return fresh; + } + + const url = opts.url ?? DEFAULT_KEYS_URL; + let body: string; + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + body = await res.text(); + } catch (err) { + if (!opts.noCache) { + const stale = await readFile(cachePath, "utf8").catch(() => null); + if (stale) return parseKeyDoc(stale); + } + throw new Error(`failed to fetch keys from ${url}: ${(err as Error).message}`); + } + + const doc = parseKeyDoc(body); + if (!opts.noCache) await writeCache(cachePath, body); + return doc; +} + +export function findKey(doc: KeyDoc, keyId: string): KeyEntry | undefined { + return doc.keys.find((k) => k.key_id === keyId); +} + +function parseKeyDoc(body: string): KeyDoc { + const parsed = JSON.parse(body) as KeyDoc; + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.keys)) { + throw new Error("invalid key document: missing keys[]"); + } + return parsed; +} + +async function readCacheIfFresh(path: string): Promise { + try { + const s = await stat(path); + if (Date.now() - s.mtimeMs > CACHE_TTL_MS) return null; + return parseKeyDoc(await readFile(path, "utf8")); + } catch { + return null; + } +} + +async function writeCache(path: string, body: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, body, "utf8"); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c87dbb9 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,70 @@ +export interface Certificate { + certification_id: string; + timestamp: string; + issuer: string; + dataset_hash: string; + algorithm: "CTGAN" | "GaussianCopula" | "DP-CTGAN" | string; + rows: number; + columns: number; + schema_version: "cert.v1"; + signature: string; + key_id: string; + metadata?: Record & { epsilon?: number | null }; +} + +export interface KeyEntry { + key_id: string; + public_key: string; + algorithm: "ed25519"; + created_at: string; + revoked_at?: string | null; + label?: string; +} + +export interface KeyDoc { + issuer: string; + keys: KeyEntry[]; + fetched_at?: string; +} + +export type Verdict = + | "VALID" + | "INVALID" + | "UNKNOWN_KEY" + | "DATASET_MISMATCH" + | "MALFORMED"; + +export type CheckResult = "pass" | "fail" | "skipped"; + +export interface VerifyResult { + verdict: Verdict; + certification_id: string | null; + key_id: string | null; + issuer: string | null; + algorithm: string | null; + signed_at: string | null; + dataset_hash_expected: string | null; + dataset_hash_actual: string | null; + checks: { + signature: CheckResult; + key_trust: CheckResult; + dataset_match: CheckResult; + }; + reason: string; + rows?: number; + columns?: number; + key_label?: string; +} + +export const REQUIRED_CERT_FIELDS: ReadonlyArray = [ + "certification_id", + "timestamp", + "issuer", + "dataset_hash", + "algorithm", + "rows", + "columns", + "schema_version", + "signature", + "key_id", +]; diff --git a/src/verify.test.ts b/src/verify.test.ts new file mode 100644 index 0000000..c59d176 --- /dev/null +++ b/src/verify.test.ts @@ -0,0 +1,78 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { verifyCertificate } from "./verify.js"; +import type { Certificate, KeyDoc } from "./types.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = join(here, "..", "fixtures"); + +async function loadJson(name: string): Promise { + return JSON.parse(await readFile(join(fixturesDir, name), "utf8")) as T; +} + +test("verifies a clean cert against trusted keys", async () => { + const cert = await loadJson("valid-cert.json"); + const keys = await loadJson("keys.json"); + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "VALID", r.reason); + assert.equal(r.checks.signature, "pass"); + assert.equal(r.checks.key_trust, "pass"); + assert.equal(r.checks.dataset_match, "skipped"); +}); + +test("rejects a cert whose payload was mutated after signing", async () => { + const cert = await loadJson("tampered-cert.json"); + const keys = await loadJson("keys.json"); + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "INVALID"); + assert.equal(r.checks.signature, "fail"); + assert.equal(r.checks.key_trust, "pass"); +}); + +test("returns UNKNOWN_KEY when the cert references an unlisted key", async () => { + const cert = await loadJson("unknown-key-cert.json"); + const keys = await loadJson("keys.json"); + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "UNKNOWN_KEY"); + assert.equal(r.checks.key_trust, "fail"); + assert.equal(r.checks.signature, "skipped"); +}); + +test("returns MALFORMED for missing required fields", async () => { + const cert = await loadJson("malformed-cert.json"); + const keys = await loadJson("keys.json"); + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "MALFORMED"); + assert.match(r.reason, /missing required field: signature/); +}); + +test("returns DATASET_MISMATCH when the dataset hash does not match", async () => { + const cert = await loadJson("valid-cert.json"); + const keys = await loadJson("keys.json"); + const wrongDataset = join(fixturesDir, "keys.json"); // hash will differ + const r = await verifyCertificate(cert, keys, wrongDataset); + assert.equal(r.verdict, "DATASET_MISMATCH"); + assert.equal(r.checks.dataset_match, "fail"); + assert.notEqual(r.dataset_hash_actual, r.dataset_hash_expected); +}); + +test("returns VALID when the supplied dataset matches", async () => { + const cert = await loadJson("valid-cert.json"); + const keys = await loadJson("keys.json"); + const dataset = join(fixturesDir, "valid-dataset.csv"); + const r = await verifyCertificate(cert, keys, dataset); + assert.equal(r.verdict, "VALID"); + assert.equal(r.checks.dataset_match, "pass"); +}); + +test("rejects a revoked key", async () => { + const cert = await loadJson("valid-cert.json"); + const keys = await loadJson("keys.json"); + keys.keys[0].revoked_at = "2026-04-01T00:00:00Z"; + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "UNKNOWN_KEY"); + assert.match(r.reason, /revoked/); +}); diff --git a/src/verify.ts b/src/verify.ts new file mode 100644 index 0000000..f4cc448 --- /dev/null +++ b/src/verify.ts @@ -0,0 +1,117 @@ +// Core verification path. Read top-to-bottom — there is no clever indirection. +// 1. Validate certificate shape. +// 2. Look up cert.key_id in the trusted keys document; reject if missing/revoked. +// 3. Build the canonical payload (cert minus signature) per RFC 8785 JCS. +// 4. crypto.verify('ed25519', canonicalBytes, publicKey, signatureBytes). +// 5. If a dataset path was supplied, recompute SHA-256 and compare to dataset_hash. + +import { createPublicKey, verify as cryptoVerify } from "node:crypto"; +import { canonicalizeToBytes } from "./canonicalize.js"; +import { sha256File, formatDigest, parseDigest } from "./hash.js"; +import { findKey } from "./keys.js"; +import { REQUIRED_CERT_FIELDS } from "./types.js"; +import type { Certificate, KeyDoc, VerifyResult } from "./types.js"; + +export async function verifyCertificate( + cert: Certificate, + trustedKeys: KeyDoc, + datasetPath?: string, +): Promise { + const result = blankResult(cert); + + const shapeError = validateShape(cert); + if (shapeError) return finish(result, "MALFORMED", shapeError); + + result.certification_id = cert.certification_id; + result.key_id = cert.key_id; + result.issuer = cert.issuer; + result.algorithm = cert.algorithm; + result.signed_at = cert.timestamp; + result.dataset_hash_expected = cert.dataset_hash; + result.rows = cert.rows; + result.columns = cert.columns; + + const key = findKey(trustedKeys, cert.key_id); + if (!key || key.revoked_at || key.algorithm !== "ed25519") { + result.checks.key_trust = "fail"; + const reason = !key + ? `key_id ${cert.key_id} not in trusted keys` + : key.revoked_at + ? `key_id ${cert.key_id} was revoked at ${key.revoked_at}` + : `key ${cert.key_id} is not ed25519`; + return finish(result, "UNKNOWN_KEY", reason); + } + result.checks.key_trust = "pass"; + result.key_label = key.label; + + const { signature: _sig, ...withoutSig } = cert; + const canonicalBytes = canonicalizeToBytes(withoutSig); + const sigBytes = decodeSignature(cert.signature); + if (!sigBytes) return finish(result, "MALFORMED", "signature is not valid base64"); + const publicKey = createPublicKey({ key: pemFromRawEd25519(key.public_key), format: "pem" }); + const sigOk = cryptoVerify(null, canonicalBytes, publicKey, sigBytes); + result.checks.signature = sigOk ? "pass" : "fail"; + if (!sigOk) return finish(result, "INVALID", "ed25519 signature does not verify against canonicalized payload"); + + if (datasetPath) { + const actualHex = await sha256File(datasetPath); + const actual = formatDigest(actualHex); + result.dataset_hash_actual = actual; + const expected = parseDigest(cert.dataset_hash); + if (expected.algo !== "sha256" || expected.hex !== actualHex) { + result.checks.dataset_match = "fail"; + return finish(result, "DATASET_MISMATCH", `dataset hash mismatch (expected ${cert.dataset_hash}, got ${actual})`); + } + result.checks.dataset_match = "pass"; + } + + return finish(result, "VALID", "signature verified and key is trusted"); +} + +function validateShape(c: Certificate): string | null { + if (!c || typeof c !== "object") return "certificate is not an object"; + for (const f of REQUIRED_CERT_FIELDS) if (c[f] === undefined || c[f] === null) return `missing required field: ${f}`; + if (c.schema_version !== "cert.v1") return `unsupported schema_version: ${c.schema_version}`; + if (!/^sha256:[0-9a-f]{64}$/i.test(c.dataset_hash)) return "dataset_hash must be sha256:<64-hex>"; + if (typeof c.rows !== "number" || typeof c.columns !== "number") return "rows and columns must be numbers"; + return null; +} + +function decodeSignature(b64: string): Buffer | null { + try { + const buf = Buffer.from(b64, "base64"); + if (buf.length !== 64) return null; + return buf; + } catch { return null; } +} + +function pemFromRawEd25519(material: string): string { + if (material.includes("BEGIN PUBLIC KEY")) return material; + // Wrap a base64 raw 32-byte Ed25519 public key in the standard SPKI prefix. + const raw = Buffer.from(material, "base64"); + if (raw.length !== 32) throw new Error(`expected 32-byte ed25519 key, got ${raw.length}`); + const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex"); + const der = Buffer.concat([spkiPrefix, raw]).toString("base64"); + return `-----BEGIN PUBLIC KEY-----\n${der.match(/.{1,64}/g)!.join("\n")}\n-----END PUBLIC KEY-----\n`; +} + +function blankResult(cert: Partial): VerifyResult { + return { + verdict: "MALFORMED", + certification_id: (cert.certification_id as string) ?? null, + key_id: (cert.key_id as string) ?? null, + issuer: (cert.issuer as string) ?? null, + algorithm: (cert.algorithm as string) ?? null, + signed_at: (cert.timestamp as string) ?? null, + dataset_hash_expected: (cert.dataset_hash as string) ?? null, + dataset_hash_actual: null, + checks: { signature: "skipped", key_trust: "skipped", dataset_match: "skipped" }, + reason: "", + }; +} + +function finish(r: VerifyResult, verdict: VerifyResult["verdict"], reason: string): VerifyResult { + r.verdict = verdict; + r.reason = reason; + return r; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5c43975 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "fixtures"] +} From c6a0e6e727e85f9d90f67885932f11f8285f4e92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 15:18:11 +0000 Subject: [PATCH 2/3] Fix Windows CI: portable script-entry guard, chmod, and CRLF-safe fixtures The cli.ts entry guard `import.meta.url === \`file://${process.argv[1]}\`` never matches on Windows, where import.meta.url is `file:///C:/...` but process.argv[1] is `C:\...`. Use pathToFileURL to produce a comparable file URL. Replace `chmod +x` in the build script with a node one-liner so it works on Windows shells that lack chmod. Add .gitattributes to keep the CSV and JSON fixtures byte-identical across platforms (CRLF would change the dataset SHA-256). --- .gitattributes | 3 +++ package.json | 2 +- src/cli.ts | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..04a2a72 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +fixtures/*.csv binary +fixtures/*.json binary diff --git a/package.json b/package.json index 50b5819..55be910 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "jcs" ], "scripts": { - "build": "tsc -p tsconfig.json && chmod +x dist/cli.js", + "build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", "typecheck": "tsc --noEmit", "lint": "eslint src/", "test": "tsc -p tsconfig.json && node --test dist/canonicalize.test.js dist/verify.test.js dist/cli.test.js", diff --git a/src/cli.ts b/src/cli.ts index 5851c54..afdbde1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; import { fetchCert } from "./fetch-cert.js"; import { loadKeys } from "./keys.js"; import { verifyCertificate } from "./verify.js"; @@ -177,6 +178,6 @@ async function readVersion(): Promise { } catch { return "@certifieddata/verify (unknown version)"; } } -if (import.meta.url === `file://${process.argv[1]}`) { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(process.argv.slice(2)).then((code) => process.exit(code)); } From 908f5d5b11f961c53bd3db6e21efbdbcd6b18507 Mon Sep 17 00:00:00 2001 From: wawri Date: Wed, 6 May 2026 10:58:37 -0600 Subject: [PATCH 3/3] docs(readme): add npm + CI + license + Node badges The npm badge will render "no version found" until @certifieddata/verify v0.1.0 publishes; once the publish workflow fires on a v0.1.0 release, the badge auto-updates without further changes. CI badge tracks the ci.yml workflow on main; license badge is static MIT; Node badge reads the engines.node range from package.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index cbf3de3..f61e7c7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # @certifieddata/verify +[![npm](https://img.shields.io/npm/v/@certifieddata/verify.svg)](https://www.npmjs.com/package/@certifieddata/verify) +[![CI](https://github.com/certifieddata/verify/actions/workflows/ci.yml/badge.svg)](https://github.com/certifieddata/verify/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Node](https://img.shields.io/node/v/@certifieddata/verify.svg)](package.json) + > Verify CertifiedData.io certificates from the command line. Audit-friendly, zero crypto dependencies. ## Install + verify in three lines