diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..209307b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TrustVC is a TypeScript library for signing and verifying W3C Verifiable Credentials and OpenAttestation Verifiable Documents. It wraps TradeTrust libraries and smart contracts (Token Registry V4/V5, Document Store) into a single package published as `@trustvc/trustvc` on npm. + +- Node.js >= 20.0.0 required +- License: Apache-2.0 + +## Commands + +```bash +# Install +npm install + +# Build (CJS + ESM + types via tsup) +npm run build + +# Run all unit tests (vitest, 15s timeout) +npm run test + +# Run tests in watch mode +npm run test:watch + +# Run a single test file +npx vitest --run src/__tests__/core/encryption.test.ts + +# Run tests matching a pattern +npx vitest --run --grep "pattern" + +# E2E tests (starts Hardhat node on port 8545, then runs tests) +npm run test:e2e + +# Type checking +npm run type-check + +# Lint (zero warnings tolerance) +npm run lint + +# Lint with auto-fix +npm run lint:fix +``` + +## Architecture + +Single-package library (not a monorepo). Source lives in `src/` with these modules: + +| Module | Purpose | +|---|---| +| `core/` | Encrypt/decrypt (ChaCha20), verify, endorsement-chain, documentBuilder | +| `w3c/` | W3C VC sign, verify, derive (selective disclosure). Sub-modules: context, issuer, vc, credential-status | +| `open-attestation/` | OA document wrapping (v2/v3), signing, verification, encryption | +| `document-store/` | Smart contract interactions: deploy, issue, revoke, grant/revoke roles, transfer ownership | +| `token-registry-v4/`, `token-registry-v5/` | TradeTrust Token Registry contract factories and utilities | +| `token-registry-functions/` | Token operations: transfer, mint, reject, return, ownerOf | +| `verify/` | Universal verification with fragment plugins | +| `utils/` | Network config, supported chains, gas station, AWS KMS signer, string utils, analytics | +| `dnsprove/` | DNS proof verification | +| `transaction/` | Transaction cancellation | +| `open-cert/` | OpenCert document verification | +| `deploy/` | Token registry and document store deployment | + +All modules are re-exported from `src/index.ts`. The package also provides subpath exports (e.g., `@trustvc/trustvc/core`, `@trustvc/trustvc/w3c`). + +## Build System + +Uses **tsup** (`tsup.config.ts`) producing three outputs: +- `dist/cjs/` - CommonJS +- `dist/esm/` - ES Modules (via `legacyOutput: true`) +- `dist/types/` - TypeScript declarations + +JSON files from `src/` are copied into CJS and ESM output dirs post-build. TypeScript config: target ESNext, module NodeNext, strict mode, path alias `src/*` -> `./src/*`. + +## Testing + +- **Vitest** with globals enabled (no imports needed for `describe`, `it`, `expect`) +- Tests in `src/__tests__/` mirror source structure; fixtures in `src/__tests__/fixtures/` +- E2E tests in `src/__tests__/e2e/` are excluded from unit test runs; they require a Hardhat node +- CI retries tests 3 times; local runs have no retries +- Coverage via V8 provider, output to `.coverage/` +- Environment variables loaded from `.env` + +## Code Style + +- **ESLint** flat config (`eslint.config.js`): typescript-eslint + prettier + jsdoc +- **Prettier**: single quotes, 100 char width, 2-space indent +- `@typescript-eslint/no-explicit-any` is allowed in test files and `*.types.ts` +- **Husky** pre-commit hook runs lint-staged (eslint --fix + prettier on staged `*.{js,ts}`) +- **Commitlint** enforces conventional commits on commit messages + +## Dual Ethers Versions + +The project uses both ethers v5 (peer/direct dependency as `ethers`) and ethers v6 (aliased as `ethersV6` via `npm:ethers@^6`). Be aware of which version a module uses when making changes. + +## Release + +Automated via `semantic-release` on push to `main` (release) or `v1` (alpha prerelease). Commit types: `feat` -> minor, `fix`/`perf` -> patch, `BREAKING CHANGE` -> major. diff --git a/README.md b/README.md index 0128a6d..79ace63 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ TrustVC is a comprehensive wrapper library designed to simplify the signing and - [8. **Document Builder**](#8-document-builder) - [9. **Document Store**](#9-document-store) - [10. **Transaction Cancel**](#10-transaction-cancel) + - [Telemetry](#telemetry) ## Installation @@ -1161,3 +1162,50 @@ const replacementHash2 = await cancelTransaction(signer, { gasPrice: '25000000000', // 25 gwei in wei }); ``` + +## Telemetry + +TrustVC collects anonymous usage telemetry to help improve the SDK. Telemetry data includes: + +- The type of operation performed (signing or verification) +- The document format used (W3C VC or OpenAttestation) +- The DID method (e.g. `did:web`, `did:ethr`) +- The cryptographic suite used (e.g. `ecdsa-sd-2023`) +- The SDK version +- An anonymous, randomly generated instance identifier (SHA-256 hashed) + +No personally identifiable information (PII) is collected. The instance identifier is a SHA-256 hash of a random UUID, generated locally and stored for consistency across sessions (`~/.trustvc/instance-id` on Node.js, `localStorage` in the browser). + +### Opting Out + +You can disable telemetry using either of the following methods: + +#### Environment Variable + +Set `TRUSTVC_TELEMETRY_DISABLED` to `1`, `true`, or `yes`: + +```bash +TRUSTVC_TELEMETRY_DISABLED=1 node your-app.js +``` + +Or in a `.env` file: + +``` +TRUSTVC_TELEMETRY_DISABLED=1 +``` + +#### Programmatic API + +```ts +import { disableTelemetry } from '@trustvc/trustvc'; + +disableTelemetry(); +``` + +To re-enable telemetry after disabling it programmatically: + +```ts +import { enableTelemetry } from '@trustvc/trustvc'; + +enableTelemetry(); +``` diff --git a/package-lock.json b/package-lock.json index 1c4ffaf..771ff4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1268,6 +1268,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1291,6 +1292,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2207,6 +2209,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@ethersproject/address": "^5.8.0", "@ethersproject/bignumber": "^5.8.0", @@ -2619,6 +2622,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@ethersproject/abstract-provider": "^5.8.0", "@ethersproject/abstract-signer": "^5.8.0", @@ -3496,6 +3500,7 @@ "integrity": "sha512-GFhTcJpB+MI6FhvXEI9b2K0snulNLWHqC/BbcJtyNYcKUiw7l3Lgis5ApsYncJ0leALX7/of4XfmXk+maT111w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@microsoft/api-extractor-model": "7.28.13", "@microsoft/tsdoc": "0.14.2", @@ -3917,6 +3922,7 @@ "integrity": "sha512-YhzPdzb612X591FOe68q+qXVXGG2ANZRvDo0RRUtimev85rCrAlv/TLMEZw5c+kq9AbzocLTVX/h2jVIFPL9Xg==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "ethers": "^5.0.0", "hardhat": "^2.0.0" @@ -3938,6 +3944,7 @@ "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^3.0.0", "@octokit/graphql": "^5.0.0", @@ -7003,6 +7010,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -7039,8 +7047,7 @@ "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.0", @@ -7088,6 +7095,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -7600,6 +7608,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8525,6 +8534,7 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -9460,6 +9470,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -10618,6 +10629,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10682,6 +10694,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10742,6 +10755,7 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -11290,6 +11304,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@ethersproject/abi": "5.8.0", "@ethersproject/abstract-provider": "5.8.0", @@ -12587,6 +12602,7 @@ "integrity": "sha512-du7ecjx1/ueAUjvtZhVkJvWytPCjlagG3ZktYTphfzAbc1Flc6sRolw5mhKL/Loub1EIFRaflutM4bdB/YsUUw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ethereumjs/util": "^9.1.0", "@ethersproject/abi": "^5.1.2", @@ -14628,6 +14644,7 @@ "resolved": "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz", "integrity": "sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.16" }, @@ -15494,6 +15511,7 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -20048,6 +20066,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -20116,6 +20135,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -21084,6 +21104,7 @@ "integrity": "sha512-sMIK9IaOdLP9hxzTxdTVHxINsazlDgv2gjZ1yeyRZXpIT3xAnuQUDEez8k+AC+lFUtGnfzA2Ct3V5lDyiMestw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^9.0.2", "@semantic-release/error": "^3.0.0", @@ -22155,8 +22176,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", - "license": "WTFPL OR MIT", - "peer": true + "license": "WTFPL OR MIT" }, "node_modules/string-width": { "version": "4.2.3", @@ -22801,6 +22821,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -22947,7 +22968,6 @@ "resolved": "https://registry.npmjs.org/ts-command-line-args/-/ts-command-line-args-2.5.1.tgz", "integrity": "sha512-H69ZwTw3rFHb5WYpQya40YAX2/w7Ut75uUECbgBIsLmM+BNuYnxsltfyyLMxy6sEeKxgijLTnQtLd0nKd6+IYw==", "license": "ISC", - "peer": true, "dependencies": { "chalk": "^4.1.0", "command-line-args": "^5.1.1", @@ -22963,7 +22983,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -22979,7 +22998,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -22996,7 +23014,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -23026,6 +23043,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -23238,7 +23256,6 @@ "resolved": "https://registry.npmjs.org/typechain/-/typechain-8.3.2.tgz", "integrity": "sha512-x/sQYr5w9K7yv3es7jo4KTX05CLxOf7TRWwoHlrjRh8H82G64g+k7VuWPJlgMo6qrjfCulOdfBjiaDtmhFYD/Q==", "license": "MIT", - "peer": true, "dependencies": { "@types/prettier": "^2.1.1", "debug": "^4.3.1", @@ -23263,7 +23280,6 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -23279,7 +23295,6 @@ "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", "deprecated": "Glob versions prior to v9 are no longer supported", "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -23299,15 +23314,13 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/typechain/node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -23317,7 +23330,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -23333,7 +23345,6 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -23417,6 +23428,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23683,6 +23695,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -24231,6 +24244,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", diff --git a/src/__tests__/core/documentBuilder.test.ts b/src/__tests__/core/documentBuilder.test.ts index f2e0375..a13f81c 100644 --- a/src/__tests__/core/documentBuilder.test.ts +++ b/src/__tests__/core/documentBuilder.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { DocumentBuilder } from '../../core/documentBuilder'; +import { TEST_BBS2020_KEY_PAIR } from '../fixtures/keys'; import { Bbs2023PrivateKeyPair, CryptoSuite, @@ -12,16 +13,6 @@ import { // Used for signing/verifying credentials only. Not for production. Do not control funds. // ----------------------------- -const BBS2020testPrivateKey: PrivateKeyPair = { - // test key for BBS 2020 signing - id: 'did:web:trustvc.github.io:did:1#keys-1', - controller: 'did:web:trustvc.github.io:did:1', - type: VerificationType.Bls12381G2Key2020, - publicKeyBase58: - 'oRfEeWFresvhRtXCkihZbxyoi2JER7gHTJ5psXhHsdCoU1MttRMi3Yp9b9fpjmKh7bMgfWKLESiK2YovRd8KGzJsGuamoAXfqDDVhckxuc9nmsJ84skCSTijKeU4pfAcxeJ', - privateKeyBase58: '4LDU56PUhA9ZEutnR1qCWQnUhtLtpLu2EHSq4h1o7vtF', -}; - const ECDSAtestPrivateKey: PrivateKeyPair = { // test key for ECDSA signing '@context': 'https://w3id.org/security/multikey/v1', @@ -287,7 +278,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { it('should throw error when trying to sign with BbsBlsSignature2020', async () => { await expect( - documentBuilder.sign(BBS2020testPrivateKey, 'BbsBlsSignature2020' as any), + documentBuilder.sign(TEST_BBS2020_KEY_PAIR, 'BbsBlsSignature2020' as any), ).rejects.toThrow( 'BbsBlsSignature2020 is no longer supported. Please use the latest cryptosuite versions instead', ); diff --git a/src/__tests__/fixtures/keys.ts b/src/__tests__/fixtures/keys.ts new file mode 100644 index 0000000..3a189e6 --- /dev/null +++ b/src/__tests__/fixtures/keys.ts @@ -0,0 +1,12 @@ +import type { PrivateKeyPair } from '@trustvc/w3c-issuer'; +import { VerificationType } from '@trustvc/w3c-issuer'; + +// Dummy/test cryptographic BBS 2020 key pair for local development and CI only. +export const TEST_BBS2020_KEY_PAIR: PrivateKeyPair = { + id: 'did:web:trustvc.github.io:did:1#keys-1', + controller: 'did:web:trustvc.github.io:did:1', + type: VerificationType.Bls12381G2Key2020, + publicKeyBase58: + 'oRfEeWFresvhRtXCkihZbxyoi2JER7gHTJ5psXhHsdCoU1MttRMi3Yp9b9fpjmKh7bMgfWKLESiK2YovRd8KGzJsGuamoAXfqDDVhckxuc9nmsJ84skCSTijKeU4pfAcxeJ', + privateKeyBase58: '4LDU56PUhA9ZEutnR1qCWQnUhtLtpLu2EHSq4h1o7vtF', +}; diff --git a/src/__tests__/open-attestation/telemetry.test.ts b/src/__tests__/open-attestation/telemetry.test.ts new file mode 100644 index 0000000..3ccdab5 --- /dev/null +++ b/src/__tests__/open-attestation/telemetry.test.ts @@ -0,0 +1,61 @@ +import { signOA, verifyOASignature } from '../..'; +import { + SAMPLE_SIGNING_KEYS, + SIGNED_WRAPPED_DOCUMENT_DID_V2, + SIGNED_WRAPPED_DOCUMENT_DNS_DID_V3, + WRAPPED_DOCUMENT_DID_V2, + WRAPPED_DOCUMENT_DNS_DID_V3, + WRAPPED_DOCUMENT_DNS_TXT_V2, +} from '../fixtures/fixtures'; +import { useTelemetryTestHarness } from '../utils/telemetry'; +import { emitOATelemetry } from '../../open-attestation/telemetry'; + +describe('OA telemetry extraction', () => { + const telemetry = useTelemetryTestHarness(); + + [ + { + name: 'emits DID for V2 DID-issuer document signing', + runOperation: () => signOA(WRAPPED_DOCUMENT_DID_V2, SAMPLE_SIGNING_KEYS), + expectedDidMethod: 'DID', + expectedCryptosuite: 'SHA3MerkleProof', + }, + { + name: 'emits DNS-DID for V3 DNS-DID document signing', + runOperation: () => signOA(WRAPPED_DOCUMENT_DNS_DID_V3, SAMPLE_SIGNING_KEYS), + expectedDidMethod: 'DNS-DID', + expectedCryptosuite: 'OpenAttestationMerkleProofSignature2018', + }, + { + name: 'emits DNS-TXT for V2 tokenRegistry document verification', + runOperation: () => verifyOASignature(WRAPPED_DOCUMENT_DNS_TXT_V2), + expectedDidMethod: 'DNS-TXT', + expectedCryptosuite: 'SHA3MerkleProof', + }, + { + name: 'emits DID for V2 signed DID-issuer document verification', + runOperation: () => verifyOASignature(SIGNED_WRAPPED_DOCUMENT_DID_V2), + expectedDidMethod: 'DID', + expectedCryptosuite: 'SHA3MerkleProof', + }, + { + name: 'emits DNS-DID for V3 signed DNS-DID document verification', + runOperation: () => verifyOASignature(SIGNED_WRAPPED_DOCUMENT_DNS_DID_V3), + expectedDidMethod: 'DNS-DID', + expectedCryptosuite: 'OpenAttestationMerkleProofSignature2018', + }, + ].forEach(({ name, runOperation, expectedDidMethod, expectedCryptosuite }) => { + it(name, async () => { + await telemetry.assertDidMethod(runOperation, expectedDidMethod); + expect(telemetry.getLastCryptosuite()).toBe(expectedCryptosuite); + }); + }); + + it('falls back to unknown telemetry fields for unsupported OA documents', async () => { + emitOATelemetry('verification', {}); + await telemetry.waitForTelemetry(); + + expect(telemetry.getLastDidMethod()).toBe('unknown'); + expect(telemetry.getLastCryptosuite()).toBe('unknown'); + }); +}); diff --git a/src/__tests__/utils/telemetry.ts b/src/__tests__/utils/telemetry.ts new file mode 100644 index 0000000..45e5ce5 --- /dev/null +++ b/src/__tests__/utils/telemetry.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, expect, vi } from 'vitest'; +import { + _resetForTesting, + disableTelemetry, + enableTelemetry, +} from '../../utils/telemetry/telemetry'; + +type FetchSpy = ReturnType; +type TelemetryPayload = Record & { + did_method?: string; + cryptosuite?: string; +}; + +const TELEMETRY_FLUSH_DELAY_MS = 10; + +export const readLastTelemetryPayload = (fetchSpy: FetchSpy): TelemetryPayload | undefined => { + const call = fetchSpy.mock.calls.at(-1); + const options = call?.[1] as RequestInit | undefined; + + if (!options || typeof options.body !== 'string') return undefined; + return JSON.parse(options.body) as TelemetryPayload; +}; + +export const waitForTelemetryFlush = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, TELEMETRY_FLUSH_DELAY_MS)); +}; + +export const useTelemetryTestHarness = () => { + let fetchSpy: FetchSpy; + + beforeEach(() => { + _resetForTesting(); + enableTelemetry(); + fetchSpy = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(async () => { + await waitForTelemetryFlush(); + disableTelemetry(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + _resetForTesting(); + }); + + return { + getFetchSpy: (): FetchSpy => fetchSpy, + getLastPayload: (): TelemetryPayload | undefined => readLastTelemetryPayload(fetchSpy), + getLastDidMethod: (): string | undefined => { + const payload = readLastTelemetryPayload(fetchSpy); + return typeof payload?.did_method === 'string' ? payload.did_method : undefined; + }, + getLastCryptosuite: (): string | undefined => { + const payload = readLastTelemetryPayload(fetchSpy); + return typeof payload?.cryptosuite === 'string' ? payload.cryptosuite : undefined; + }, + waitForTelemetry: waitForTelemetryFlush, + assertDidMethod: async ( + runOperation: () => Promise, + expectedDidMethod: string, + ): Promise => { + await runOperation(); + await waitForTelemetryFlush(); + expect(readLastTelemetryPayload(fetchSpy)?.did_method).toBe(expectedDidMethod); + }, + }; +}; diff --git a/src/__tests__/w3c/sign.test.ts b/src/__tests__/w3c/sign.test.ts index 770382b..b65db73 100644 --- a/src/__tests__/w3c/sign.test.ts +++ b/src/__tests__/w3c/sign.test.ts @@ -4,6 +4,7 @@ import { BBS2023_W3C_VERIFIABLE_DOCUMENT_V2_0, W3C_VERIFIABLE_DOCUMENT, } from '../fixtures/fixtures'; +import { TEST_BBS2020_KEY_PAIR } from '../fixtures/keys'; import { signW3C } from '../..'; import { VerificationType } from '@trustvc/w3c-issuer'; import type { CryptoSuiteName } from '@trustvc/w3c-vc'; @@ -19,18 +20,9 @@ describe('W3C sign', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { proof, id, ...documentWithoutProof } = W3C_VERIFIABLE_DOCUMENT; - // Note: Dummy/test cryptographic BBS 2020 key pairs for local development and CI/CD. - // Used for signing/verifying credentials only. Not for production. Do not control funds. const signingResult = await signW3C( documentWithoutProof, - { - id: 'did:web:trustvc.github.io:did:1#keys-1', - controller: 'did:web:trustvc.github.io:did:1', - type: VerificationType.Bls12381G2Key2020, - publicKeyBase58: - 'oRfEeWFresvhRtXCkihZbxyoi2JER7gHTJ5psXhHsdCoU1MttRMi3Yp9b9fpjmKh7bMgfWKLESiK2YovRd8KGzJsGuamoAXfqDDVhckxuc9nmsJ84skCSTijKeU4pfAcxeJ', - privateKeyBase58: '4LDU56PUhA9ZEutnR1qCWQnUhtLtpLu2EHSq4h1o7vtF', - }, + TEST_BBS2020_KEY_PAIR, 'BbsBlsSignature2020', ); expect(signingResult).toEqual({ diff --git a/src/__tests__/w3c/telemetry.test.ts b/src/__tests__/w3c/telemetry.test.ts new file mode 100644 index 0000000..177102e --- /dev/null +++ b/src/__tests__/w3c/telemetry.test.ts @@ -0,0 +1,45 @@ +import { signW3C, verifyW3CSignature } from '../..'; +import { W3C_VERIFIABLE_DOCUMENT } from '../fixtures/fixtures'; +import { TEST_BBS2020_KEY_PAIR } from '../fixtures/keys'; +import { useTelemetryTestHarness } from '../utils/telemetry'; + +describe('W3C telemetry extraction', () => { + const telemetry = useTelemetryTestHarness(); + + [ + { + name: 'emits did:web when signing with a string issuer', + runOperation: async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { proof, id, ...documentWithoutProof } = W3C_VERIFIABLE_DOCUMENT; + await signW3C(documentWithoutProof, TEST_BBS2020_KEY_PAIR, 'BbsBlsSignature2020'); + }, + expectedDidMethod: 'did:web', + expectedCryptosuite: 'BbsBlsSignature2020', + }, + { + name: 'emits did:web when verifying with a string issuer', + runOperation: () => verifyW3CSignature(W3C_VERIFIABLE_DOCUMENT), + expectedDidMethod: 'did:web', + expectedCryptosuite: 'BbsBlsSignature2020', + }, + { + name: 'emits did:key when verifying with an object issuer', + runOperation: () => + verifyW3CSignature({ + ...W3C_VERIFIABLE_DOCUMENT, + issuer: { + id: 'did:key:fake', + name: 'Test', + }, + }), + expectedDidMethod: 'did:key', + expectedCryptosuite: 'BbsBlsSignature2020', + }, + ].forEach(({ name, runOperation, expectedDidMethod, expectedCryptosuite }) => { + it(name, async () => { + await telemetry.assertDidMethod(runOperation, expectedDidMethod); + expect(telemetry.getLastCryptosuite()).toBe(expectedCryptosuite); + }); + }); +}); diff --git a/src/open-attestation/sign.ts b/src/open-attestation/sign.ts index 4cdc450..dc52a8d 100644 --- a/src/open-attestation/sign.ts +++ b/src/open-attestation/sign.ts @@ -8,6 +8,7 @@ import { v3, } from '@tradetrust-tt/tradetrust'; import { KeyPair } from './types'; +import { emitOATelemetry } from './telemetry'; export async function signOA( document: T, @@ -34,10 +35,14 @@ export async function signOA( keyPair: KeyPair | Signer, ): Promise> { // Sign the document using OpenAttestation's `signDocument` function with Secp256k1 algorithm - return signDocument( + const result = await signDocument( // eslint-disable-next-line @typescript-eslint/no-explicit-any document as any, SUPPORTED_SIGNING_ALGORITHM.Secp256k1VerificationKey2018, keyPair, ); + + emitOATelemetry('issuance', result); + + return result as SignedWrappedDocument; } diff --git a/src/open-attestation/telemetry.ts b/src/open-attestation/telemetry.ts new file mode 100644 index 0000000..d10de83 --- /dev/null +++ b/src/open-attestation/telemetry.ts @@ -0,0 +1,61 @@ +import { utils } from '@tradetrust-tt/tradetrust'; +import { emitTelemetry, type ActionType } from '../utils/telemetry'; +import { getDataV2 } from './utils'; + +const findNestedType = (value: unknown): string => { + if (Array.isArray(value)) { + for (const item of value) { + const type = findNestedType(item); + if (type) return type; + } + return ''; + } + + if (value && typeof value === 'object') { + const record = value as Record; + if (typeof record.type === 'string') return record.type; + + for (const nestedValue of Object.values(record)) { + const type = findNestedType(nestedValue); + if (type) return type; + } + } + + return ''; +}; + +const getIdentityProofType = (document: unknown): string => { + if (utils.isWrappedV3Document(document)) { + return document.openAttestationMetadata?.identityProof?.type ?? ''; + } + + if (utils.isWrappedV2Document(document)) { + return ( + getDataV2(document as Parameters[0])?.issuers?.[0]?.identityProof?.type ?? + '' + ); + } + + return ''; +}; + +const getOACryptosuite = (document: unknown): string => { + if (utils.isWrappedV3Document(document)) { + return findNestedType(document.proof); + } + + if (utils.isWrappedV2Document(document)) { + return findNestedType(document.signature); + } + + return ''; +}; + +export const emitOATelemetry = (actionType: ActionType, document: unknown): void => { + void emitTelemetry({ + action_type: actionType, + document_format: 'oa', + cryptosuite: getOACryptosuite(document) || 'unknown', + did_method: getIdentityProofType(document) || 'unknown', + }); +}; diff --git a/src/open-attestation/verify.ts b/src/open-attestation/verify.ts index cc2eed0..c7bc2aa 100644 --- a/src/open-attestation/verify.ts +++ b/src/open-attestation/verify.ts @@ -1,4 +1,5 @@ import { verifySignature, utils, v2, v3 } from '@tradetrust-tt/tradetrust'; +import { emitOATelemetry } from './telemetry'; /** * Asynchronously verifies the signature of an OpenAttestation wrapped document. @@ -17,7 +18,10 @@ export const verifyOASignature = async ( // Check if the document is of a supported version before verifying its signature if (utils.isWrappedV2Document(document) || utils.isWrappedV3Document(document)) { // Verify the document's signature using OpenAttestation's `verifySignature` function - return verifySignature(document); + const result = verifySignature(document); + emitOATelemetry('verification', document); + + return result; } else { // Return false if the document type is not recognized return false; diff --git a/src/utils/index.ts b/src/utils/index.ts index b717c16..7dc531c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './documents'; export * from './gasStation'; export * from './aws-kms-signer'; export * from './analytics'; +export * from './telemetry'; diff --git a/src/utils/telemetry/index.ts b/src/utils/telemetry/index.ts new file mode 100644 index 0000000..afa1bae --- /dev/null +++ b/src/utils/telemetry/index.ts @@ -0,0 +1,2 @@ +export { emitTelemetry, disableTelemetry, enableTelemetry, extractDidMethod } from './telemetry'; +export type { TelemetryInput, TelemetryEvent, ActionType, DocumentFormat } from './types'; diff --git a/src/utils/telemetry/telemetry.test.ts b/src/utils/telemetry/telemetry.test.ts new file mode 100644 index 0000000..4385704 --- /dev/null +++ b/src/utils/telemetry/telemetry.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, vi } from 'vitest'; +import { + emitTelemetry, + disableTelemetry, + enableTelemetry, + extractDidMethod, + _resetForTesting, + isTelemetryEnabled, + getInstanceId, + SDK_VERSION, +} from './telemetry'; +import type { TelemetryInput } from './types'; +import { readLastTelemetryPayload, waitForTelemetryFlush } from '../../__tests__/utils/telemetry'; + +describe('telemetry', () => { + let fetchSpy: ReturnType; + + const baseTelemetryInput: TelemetryInput = { + action_type: 'issuance', + document_format: 'w3c_vc', + did_method: 'did:web', + cryptosuite: 'ecdsa-sd-2023', + }; + + const getLastRequest = (): { + url: string; + options: RequestInit; + body: Record; + } => { + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, options] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const body = readLastTelemetryPayload(fetchSpy); + expect(body).toBeDefined(); + + return { + url, + options, + body: body as Record, + }; + }; + + const emitBaseTelemetry = async (overrides: Partial = {}): Promise => { + await emitTelemetry({ + ...baseTelemetryInput, + ...overrides, + }); + await waitForTelemetryFlush(); + }; + + beforeEach(() => { + _resetForTesting(); + fetchSpy = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchSpy); + vi.stubEnv('TRUSTVC_TELEMETRY_DISABLED', ''); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + _resetForTesting(); + }); + + describe('extractDidMethod', () => { + [ + { + name: 'extracts did:web', + input: 'did:web:trustvc.github.io:did:1', + expected: 'did:web', + }, + { + name: 'extracts did:ethr', + input: 'did:ethr:0xB26B4941941C51a4885E5B7D3A1B861E54405f90#controller', + expected: 'did:ethr', + }, + { + name: 'extracts did:key', + input: 'did:key:fake', + expected: 'did:key', + }, + { + name: 'extracts a DID method from a verification method with a fragment', + input: 'did:web:trustvc.github.io:did:1#multikey-1', + expected: 'did:web', + }, + { + name: 'returns unknown for an empty string', + input: '', + expected: 'unknown', + }, + { + name: 'returns unknown for a non-DID string', + input: 'https://example.com', + expected: 'unknown', + }, + { + name: 'returns unknown for a malformed DID', + input: 'did:', + expected: 'unknown', + }, + ].forEach(({ name, input, expected }) => { + it(name, () => { + expect(extractDidMethod(input)).toBe(expected); + }); + }); + }); + + describe('isTelemetryEnabled', () => { + it('should return true when TRUSTVC_TELEMETRY_DISABLED is empty', () => { + expect(isTelemetryEnabled()).toBe(true); + }); + + ['1', 'true', 'yes', 'YES'].forEach((value) => { + it(`should return false when TRUSTVC_TELEMETRY_DISABLED=${value}`, () => { + vi.stubEnv('TRUSTVC_TELEMETRY_DISABLED', value); + expect(isTelemetryEnabled()).toBe(false); + }); + }); + + it('should return true when TRUSTVC_TELEMETRY_DISABLED=0', () => { + vi.stubEnv('TRUSTVC_TELEMETRY_DISABLED', '0'); + expect(isTelemetryEnabled()).toBe(true); + }); + + it('should respect disableTelemetry() over env var', () => { + vi.stubEnv('TRUSTVC_TELEMETRY_DISABLED', ''); + disableTelemetry(); + expect(isTelemetryEnabled()).toBe(false); + }); + + it('should respect enableTelemetry() over env var', () => { + vi.stubEnv('TRUSTVC_TELEMETRY_DISABLED', '1'); + enableTelemetry(); + expect(isTelemetryEnabled()).toBe(true); + }); + }); + + describe('getInstanceId', () => { + it('should return a 64-char hex string', async () => { + const id = await getInstanceId(); + expect(id).toMatch(/^[a-f0-9]{64}$/); + }); + + it('should return the same ID on subsequent calls', async () => { + const id1 = await getInstanceId(); + const id2 = await getInstanceId(); + expect(id1).toBe(id2); + }); + }); + + describe('SDK_VERSION', () => { + it('should be a valid semver string', () => { + expect(SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + }); + + describe('emitTelemetry', () => { + it('should call fetch with correct payload when enabled', async () => { + enableTelemetry(); + await emitBaseTelemetry(); + + const { url, options, body } = getLastRequest(); + expect(url).toContain('/api/v1/telemetry'); + expect(options.method).toBe('POST'); + expect(options.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(body.action_type).toBe('issuance'); + expect(body.document_format).toBe('w3c_vc'); + expect(body.sdk_version).toMatch(/^\d+\.\d+\.\d+$/); + expect(body.did_method).toBe('did:web'); + expect(body.cryptosuite).toBe('ecdsa-sd-2023'); + expect(body.instance_id).toMatch(/^[a-f0-9]{64}$/); + }); + + it('should not call fetch when telemetry is disabled', async () => { + disableTelemetry(); + await emitBaseTelemetry(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should not call fetch when env var disables telemetry', async () => { + vi.stubEnv('TRUSTVC_TELEMETRY_DISABLED', '1'); + await emitBaseTelemetry({ + action_type: 'verification', + document_format: 'oa', + did_method: 'did:ethr', + cryptosuite: 'Secp256k1VerificationKey2018', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should not throw when fetch fails', async () => { + enableTelemetry(); + fetchSpy.mockRejectedValue(new Error('Network error')); + + await expect(emitBaseTelemetry()).resolves.toBeUndefined(); + }); + + it('should not throw when fetch is undefined', async () => { + enableTelemetry(); + vi.stubGlobal('fetch', undefined); + + await expect(emitBaseTelemetry()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/utils/telemetry/telemetry.ts b/src/utils/telemetry/telemetry.ts new file mode 100644 index 0000000..a7c8e72 --- /dev/null +++ b/src/utils/telemetry/telemetry.ts @@ -0,0 +1,162 @@ +/// +import { version as SDK_VERSION } from '../../../package.json'; +import { TelemetryInput } from './types'; + +const TELEMETRY_ENDPOINT = + (typeof process !== 'undefined' && process.env?.TRUSTVC_TELEMETRY_URL) || + 'https://api.trustvc.io/api/v1/telemetry'; + +const STORAGE_KEY = 'trustvc_instance_id'; + +let _telemetryOverride: boolean | null = null; +let _instanceId: string | null = null; + +export function disableTelemetry(): void { + _telemetryOverride = false; +} + +export function enableTelemetry(): void { + _telemetryOverride = true; +} + +function isTelemetryEnabled(): boolean { + if (_telemetryOverride !== null) return _telemetryOverride; + if (typeof process !== 'undefined' && process.env?.TRUSTVC_TELEMETRY_DISABLED) { + const val = process.env.TRUSTVC_TELEMETRY_DISABLED.toLowerCase().trim(); + if (val === '1' || val === 'true' || val === 'yes') return false; + } + return true; +} + +export function extractDidMethod(did: string): string { + const match = did.match(/^(did:[a-z0-9]+)/); + return match ? match[1] : 'unknown'; +} + +async function sha256Hex(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +async function generateInstanceId(): Promise { + const raw = globalThis.crypto.randomUUID(); + return sha256Hex(raw); +} + +async function loadNodeInstanceId(): Promise { + try { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const dir = path.join(os.homedir(), '.trustvc'); + const filePath = path.join(dir, 'instance-id'); + try { + const stored = fs.readFileSync(filePath, 'utf-8').trim(); + if (/^[a-f0-9]{64}$/.test(stored)) return stored; + } catch { + // file doesn't exist yet + } + const id = await generateInstanceId(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, id, 'utf-8'); + return id; + } catch { + return null; + } +} + +function loadBrowserInstanceId(): string | null { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && /^[a-f0-9]{64}$/.test(stored)) return stored; + return null; + } catch { + return null; + } +} + +async function saveBrowserInstanceId(): Promise { + try { + const id = await generateInstanceId(); + localStorage.setItem(STORAGE_KEY, id); + return id; + } catch { + return null; + } +} + +function isNodeEnvironment(): boolean { + return ( + typeof process !== 'undefined' && process.versions != null && process.versions.node != null + ); +} + +function isBrowserEnvironment(): boolean { + return typeof window !== 'undefined' && typeof localStorage !== 'undefined'; +} + +async function getInstanceId(): Promise { + if (_instanceId) return _instanceId; + + if (isNodeEnvironment()) { + const id = await loadNodeInstanceId(); + if (id) { + _instanceId = id; + return id; + } + } + + if (isBrowserEnvironment()) { + const stored = loadBrowserInstanceId(); + if (stored) { + _instanceId = stored; + return stored; + } + const id = await saveBrowserInstanceId(); + if (id) { + _instanceId = id; + return id; + } + } + + // Fallback: per-session ID + _instanceId = await generateInstanceId(); + return _instanceId; +} + +export async function emitTelemetry(input: TelemetryInput): Promise { + try { + if (!isTelemetryEnabled()) return; + + const instanceId = await getInstanceId(); + + globalThis + .fetch(TELEMETRY_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action_type: input.action_type, + document_format: input.document_format, + sdk_version: SDK_VERSION, + did_method: input.did_method, + cryptosuite: input.cryptosuite, + instance_id: instanceId, + }), + signal: AbortSignal.timeout(5000), + }) + .catch(() => {}); + } catch { + // Never throw from telemetry + } +} + +export function _resetForTesting(): void { + _telemetryOverride = null; + _instanceId = null; +} + +export { isTelemetryEnabled, getInstanceId, SDK_VERSION }; diff --git a/src/utils/telemetry/types.ts b/src/utils/telemetry/types.ts new file mode 100644 index 0000000..623657b --- /dev/null +++ b/src/utils/telemetry/types.ts @@ -0,0 +1,18 @@ +export type ActionType = 'issuance' | 'verification'; +export type DocumentFormat = 'w3c_vc' | 'oa'; + +export interface TelemetryEvent { + action_type: ActionType; + document_format: DocumentFormat; + sdk_version: string; + did_method: string; + cryptosuite: string; + instance_id: string; +} + +export interface TelemetryInput { + action_type: ActionType; + document_format: DocumentFormat; + did_method: string; + cryptosuite: string; +} diff --git a/src/w3c/derive.ts b/src/w3c/derive.ts index 3927425..ab58880 100644 --- a/src/w3c/derive.ts +++ b/src/w3c/derive.ts @@ -1,4 +1,5 @@ import { deriveCredential, DerivedResult, SignedVerifiableCredential } from '@trustvc/w3c-vc'; +import { emitW3CTelemetry, getProofCryptosuite } from './telemetry'; /** * Derives a credential with selective disclosure based on revealed attributes. @@ -11,5 +12,13 @@ export const deriveW3C = async ( credential: SignedVerifiableCredential, revealedAttributes: string[], ): Promise => { - return deriveCredential(credential, revealedAttributes); + const result = await deriveCredential(credential, revealedAttributes); + emitW3CTelemetry( + 'issuance', + credential, + getProofCryptosuite(credential.proof), + credential.proof?.verificationMethod, + ); + + return result; }; diff --git a/src/w3c/sign.ts b/src/w3c/sign.ts index e83cdeb..0540a47 100644 --- a/src/w3c/sign.ts +++ b/src/w3c/sign.ts @@ -1,5 +1,6 @@ import { CryptoSuiteName, signCredential } from '@trustvc/w3c-vc'; import { RawVerifiableCredential, SigningResult, PrivateKeyPair } from './types'; +import { emitW3CTelemetry } from './telemetry'; /** * Signs a W3C Verifiable Credential using the provided cryptographic suite and key pair. @@ -19,5 +20,8 @@ export const signW3C = async ( }, ): Promise => { // Call the signCredential function from the trustvc/w3c-vc package to sign the credential - return signCredential(credential, keyPair, cryptoSuite, options); + const result = await signCredential(credential, keyPair, cryptoSuite, options); + emitW3CTelemetry('issuance', credential, cryptoSuite); + + return result; }; diff --git a/src/w3c/telemetry.ts b/src/w3c/telemetry.ts new file mode 100644 index 0000000..1395ec2 --- /dev/null +++ b/src/w3c/telemetry.ts @@ -0,0 +1,27 @@ +import { emitTelemetry, extractDidMethod, type ActionType } from '../utils/telemetry'; + +type CredentialWithTelemetryFields = { + issuer?: string | { id?: string }; + proof?: { cryptosuite?: string; type?: string }; +}; + +const getIssuerDid = (credential: CredentialWithTelemetryFields): string => { + return typeof credential.issuer === 'string' ? credential.issuer : (credential.issuer?.id ?? ''); +}; + +export const getProofCryptosuite = (proof: CredentialWithTelemetryFields['proof']): string => + proof?.cryptosuite ?? proof?.type ?? 'unknown'; + +export const emitW3CTelemetry = ( + actionType: ActionType, + credential: CredentialWithTelemetryFields, + cryptosuite: string, + didSource?: string, +): void => { + void emitTelemetry({ + action_type: actionType, + document_format: 'w3c_vc', + cryptosuite, + did_method: extractDidMethod(didSource ?? getIssuerDid(credential)), + }); +}; diff --git a/src/w3c/verify.ts b/src/w3c/verify.ts index 1e1a744..372ac24 100644 --- a/src/w3c/verify.ts +++ b/src/w3c/verify.ts @@ -1,5 +1,6 @@ import { DocumentLoader, verifyCredential } from '@trustvc/w3c-vc'; import { SignedVerifiableCredential, VerificationResult } from './types'; +import { emitW3CTelemetry, getProofCryptosuite } from './telemetry'; /** * Verifies the signature of a W3C Verifiable Credential. @@ -13,5 +14,8 @@ export const verifyW3CSignature = async ( options?: { documentLoader?: DocumentLoader }, ): Promise => { // Call the verifyCredential function from the trustvc/w3c-vc package to verify the credential - return verifyCredential(credential, options); + const result = await verifyCredential(credential, options); + emitW3CTelemetry('verification', credential, getProofCryptosuite(credential.proof)); + + return result; }; diff --git a/tsconfig.json b/tsconfig.json index f779734..055a66b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "moduleResolution": "nodenext", "experimentalDecorators": true, "emitDecoratorMetadata": true, - "types": ["vitest/globals", "mocha", "gtag.js"], + "types": ["node", "vitest/globals", "mocha", "gtag.js"], "baseUrl": ".", "rootDir": ".", "paths": { diff --git a/vitest.config.ts b/vitest.config.ts index 7a76875..99b6d6d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ retry: process.env.CI ? 3 : 0, // setupFiles: ['dotenv/config'], //this line // eslint-disable-next-line @typescript-eslint/no-explicit-any - env: process.env as any, + env: { ...process.env, TRUSTVC_TELEMETRY_DISABLED: '1' } as any, server: { deps: { inline: ['@govtechsg/oa-verify', '@tradetrust-tt/tt-verify'], // Inline oa-verify package directly