diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..bc36f9a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +npm run lint +npm run format:check diff --git a/README.md b/README.md index 425c11c..03a3323 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A comprehensive command-line interface for managing W3C Verifiable Credentials, - ✅ **Key Pair Generation**: Generate cryptographic key pairs with Multikey format - ✅ **DID Management**: Create and manage did:web identifiers - ✅ **W3C Verifiable Credentials**: Sign, verify and manage W3C verifiable credentials -- ✅ **OpenAttestation**: Sign, verify and wrap/unwrap OpenAttestation v2/v3 documents +- ✅ **OpenAttestation**: Sign, verify, wrap/unwrap, and encrypt/decrypt OpenAttestation v2/v3 documents - ✅ **Token Registry**: Mint tokens to blockchain-based token registries - ✅ **Document Store**: Deploy and manage document store contracts - ✅ **Title Escrow**: Complete transferable records management (holder/beneficiary transfers) @@ -90,6 +90,12 @@ trustvc oa-wrap # Unwrap an OpenAttestation document trustvc oa-unwrap + +# Encrypt an Open Attestation document for safe sharing +trustvc oa-encrypt + +# Decrypt an encrypted Open Attestation document +trustvc oa-decrypt ``` ### Wallet Management @@ -176,6 +182,8 @@ trustvc title-escrow reject-transfer-owner-holder - **Document Unwrapping**: Uses `unwrapOA` to unwrap OpenAttestation documents. +- **Document Encryption**: Uses `oa-encrypt` to encrypt OA documents for safe sharing; use `oa-decrypt` with the same key to recover the document. + ### Blockchain Operations - **Token Registry**: Deploy token registry contracts and mint document hashes (tokenIds) to blockchain-based token registries across multiple networks (Ethereum, Polygon, XDC, Stability, Astron). @@ -200,7 +208,10 @@ trustvc title-escrow reject-transfer-owner-holder | | [`verify`](#verify) | Verify OpenAttestation documents | | | [`oa-wrap`](#oa-wrap) | Wrap OpenAttestation documents | | | [`oa-unwrap`](#oa-unwrap) | Unwrap OpenAttestation documents | -| **Token Registry** | [`token-registry deploy`](#token-registry-deploy) | Deploy token registry contracts | +| | [`oa-encrypt`](#oa-encrypt) | Encrypt an OA document for safe sharing and storage | +| | [`oa-decrypt`](#oa-decrypt) | Decrypt an OA document encrypted with oa-encrypt | +| **Token Registry** | [`mint`](#mint) | Mint tokens to blockchain registries | +| | [`token-registry deploy`](#token-registry-deploy) | Deploy token registry contracts | | | [`mint`](#mint) | Mint tokens to blockchain registries | | | `token-registry mint` | Alternative: `mint` | | **Document Store** | [`document-store deploy`](#document-store-deploy) | Deploy document store contracts | @@ -479,6 +490,57 @@ Unwrapped OpenAttestation document(s) in the specified directory. +
+

oa-encrypt

+ +Encrypts an Open Attestation document for safe sharing and storage. You will be prompted for an encryption key — remember it to decrypt later. + +**Usage:** + +```sh +trustvc oa-encrypt +``` + +**Interactive Prompts:** + +- Path to your Open Attestation document (raw or wrapped OA v2/v3) +- Path for the output encrypted file +- Encryption key (entered securely; not echoed) + +**Output:** + +Writes an encrypted document file containing `type: "encrypted-document"` and a `ciphertext` field. Only someone with the same key can decrypt it with `oa-decrypt`. + +**Supported Input:** + +- OpenAttestation v2 (raw or wrapped) +- OpenAttestation v3 (raw or wrapped) + +
+ +
+

oa-decrypt

+ +Decrypts an Open Attestation document that was encrypted using `oa-encrypt`. You will be prompted for the decryption key. + +**Usage:** + +```sh +trustvc oa-decrypt +``` + +**Interactive Prompts:** + +- Path to the encrypted document file +- Path for the output decrypted document +- Decryption key (entered securely; not echoed) + +**Output:** + +Writes the decrypted Open Attestation document (raw OA v2/v3 or wrapped OA v2/v3) to the specified path. Fails if the key is wrong or the file is not a valid encrypted OA document. + +
+

mint

@@ -1158,9 +1220,11 @@ npm test ``` src/commands/ ├── oa/ -│ └── sign.ts # Sign OpenAttestation documents -│ └── wrap.ts # Wrap OpenAttestation documents -│ └── unwrap.ts # Unwrap OpenAttestation documents +│ ├── sign.ts # Sign OpenAttestation documents +│ ├── wrap.ts # Wrap OpenAttestation documents +│ ├── unwrap.ts # Unwrap OpenAttestation documents +│ ├── encrypt.ts # Encrypt OA documents for safe sharing +│ └── decrypt.ts # Decrypt OA documents ├── token-registry/ │ ├── deploy.ts # Deploy token registry contracts │ └── mint.ts # Mint tokens to registry diff --git a/package-lock.json b/package-lock.json index 646518a..ac95eb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.9.1", + "@trustvc/trustvc": "^2.10.0", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "ethers": "^6.15.0", @@ -32,6 +32,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.8", "eslint-formatter-table": "^7.32.1", + "husky": "^9.0.11", "prettier": "^3.7.4", "tsup": "^8.2.4", "tsx": "^4.19.1", @@ -4198,9 +4199,9 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/trustvc": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.9.1.tgz", - "integrity": "sha512-ehKfXbf5Mrsd8Zcdhl17pT74viC8TZpUtc9Y9Bgst9x6zEXyKfh+FShvlYSXvyqyL77YmXO88hhNsOVxY2m6Pg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.10.0.tgz", + "integrity": "sha512-9Unnz6arLsMbTcRCoCBjuf2R640HYszjjS93Vm1cuwP540t152LMsnEgZpnizhgGFabi8tGmhnYKNSoYETbr0w==", "license": "Apache-2.0", "dependencies": { "@tradetrust-tt/dnsprove": "^2.18.0", @@ -4219,6 +4220,7 @@ "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", "node-fetch": "^2.7.0", + "node-forge": "^1.3.3", "ts-chacha20": "^1.2.0" }, "engines": { @@ -4299,6 +4301,15 @@ } } }, + "node_modules/@trustvc/trustvc/node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/@trustvc/w3c": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.0.2.tgz", @@ -7647,6 +7658,22 @@ "node": ">=16.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/package.json b/package.json index 1df3acf..7494f0a 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "start": "node dist/main.js", "dev": "npm run build && npm start", "build": "npm run clean && tsup", - "clean": "rm -rf dist/" + "clean": "rm -rf dist/", + "prepare": "husky" }, "keywords": [ "trustvc" @@ -34,7 +35,7 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.9.1", + "@trustvc/trustvc": "^2.10.0", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "ethers": "^6.15.0", @@ -53,6 +54,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.8", "eslint-formatter-table": "^7.32.1", + "husky": "^9.0.11", "prettier": "^3.7.4", "tsup": "^8.2.4", "tsx": "^4.19.1", diff --git a/src/commands/oa/decrypt.ts b/src/commands/oa/decrypt.ts new file mode 100644 index 0000000..d6f5641 --- /dev/null +++ b/src/commands/oa/decrypt.ts @@ -0,0 +1,155 @@ +import fs from 'fs'; +import { input, password } from '@inquirer/prompts'; +import crypto from 'crypto'; +import signale from 'signale'; +import { decryptString } from '@trustvc/trustvc'; +import { + readDocumentFile, + getCliErrorMessage, + isErrorWithMessage, + ensureInputFileExists, + resolveOutputJsonPath, + validateInputFileExists, +} from '../../utils'; + +/** Derive a 64-char hex key from passphrase for AES-256 (OPEN-ATTESTATION-TYPE-1). */ +const deriveKey = (passphrase: string): string => + crypto.createHash('sha256').update(passphrase, 'utf8').digest('hex'); + +export const command = 'oa-decrypt'; +export const describe = + 'Decrypt a document that was encrypted using oa-encrypt. You will be asked for the decryption key.'; + +type DecryptInput = { + inputEncryptedPath: string; + outputPath: string; + key: string; +}; + +// Payload format: OPEN-ATTESTATION-TYPE-1 (cipherText, iv, tag, type) +const ENCRYPTED_DOCUMENT_TYPE = 'OPEN-ATTESTATION-TYPE-1'; + +/** Message thrown by @trustvc/trustvc when decryption fails (wrong key or corrupted data). */ +const DECRYPT_FAILED_LIBRARY_MESSAGE = 'Error decrypting message'; + +const INVALID_PAYLOAD_MESSAGE = + 'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".'; + +export const promptForInputs = async (): Promise => { + const inputEncryptedPath = await input({ + message: 'Enter the path to the encrypted document:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') return 'Encrypted document path is required'; + return validateInputFileExists(value); + }, + }); + + const outputPath = await input({ + message: 'Enter the path to save the decrypted document:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') return 'Output path is required'; + return true; + }, + }); + + const key = await password({ + message: 'Enter the decryption key:', + mask: '*', + validate: (value: string) => { + if (!value || value.trim() === '') return 'Decryption key is required'; + return true; + }, + }); + + return { + inputEncryptedPath: inputEncryptedPath.trim(), + outputPath: outputPath.trim(), + key: key.trim(), + }; +}; + +type EncryptedPayload = { + cipherText: string; + iv: string; + tag: string; + type: string; +}; + +const DECRYPT_ERROR_OPTIONS = { + defaultMessage: 'An unexpected error occurred while decrypting the document.', + fileNotFound: 'Unable to read encrypted document. File not found at: {path}', + permissionDenied: 'Permission denied. Cannot write to: {path}', + invalidJson: (msg: string) => `Invalid encrypted file: the file is not valid JSON. ${msg}`, +} as const; + +/** Validates raw payload and returns typed fields or throws with a clear message. */ +function validateEncryptedPayload(payload: unknown): EncryptedPayload { + if ( + payload === null || + typeof payload !== 'object' || + !('cipherText' in payload) || + !('iv' in payload) || + !('tag' in payload) || + !('type' in payload) + ) { + throw new Error(INVALID_PAYLOAD_MESSAGE); + } + const { cipherText, iv, tag, type } = payload as EncryptedPayload; + if ( + typeof cipherText !== 'string' || + typeof iv !== 'string' || + typeof tag !== 'string' || + type !== ENCRYPTED_DOCUMENT_TYPE + ) { + throw new Error(INVALID_PAYLOAD_MESSAGE); + } + return { cipherText, iv, tag, type }; +} + +/** Decrypts payload with derived key; rethrows a user-friendly error on library failure. */ +function decryptPayload(payload: EncryptedPayload, key: string): string { + try { + return decryptString({ + ...payload, + key: deriveKey(key), + }); + } catch (err: unknown) { + if (isErrorWithMessage(err) && err.message === DECRYPT_FAILED_LIBRARY_MESSAGE) { + throw new Error( + 'Failed to decrypt document. The password/key is likely incorrect or the file is corrupted.', + ); + } + throw err; + } +} + +/** Loads encrypted file, validates, decrypts, writes plaintext and shows success message. */ +async function runDecrypt(answers: DecryptInput): Promise { + const { inputEncryptedPath, outputPath, key } = answers; + + ensureInputFileExists(inputEncryptedPath); + const rawPayload = readDocumentFile(inputEncryptedPath); + const payload = validateEncryptedPayload(rawPayload); + const documentString = decryptPayload(payload, key); + + const { path: outputFilePath, generated } = resolveOutputJsonPath(outputPath, 'decrypted'); + fs.writeFileSync(outputFilePath, documentString, 'utf8'); + if (generated) { + signale.success(`No output filename provided. Decrypted document saved to: ${outputFilePath}`); + } else { + signale.success(`Decrypted document saved to: ${outputFilePath}`); + } +} + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await runDecrypt(answers); + } catch (err: unknown) { + signale.error(getCliErrorMessage(err, DECRYPT_ERROR_OPTIONS)); + process.exitCode = 1; + } +}; diff --git a/src/commands/oa/encrypt.ts b/src/commands/oa/encrypt.ts new file mode 100644 index 0000000..5ef1ecd --- /dev/null +++ b/src/commands/oa/encrypt.ts @@ -0,0 +1,98 @@ +import { input, password } from '@inquirer/prompts'; +import crypto from 'crypto'; +import signale from 'signale'; +import { encryptString } from '@trustvc/trustvc'; +import { + readFile, + writeFile, + getCliErrorMessage, + ensureInputFileExists, + resolveOutputJsonPath, + validateInputFileExists, +} from '../../utils'; + +/** Derive a 64-char hex key from passphrase for AES-256 (OPEN-ATTESTATION-TYPE-1). */ +const deriveKey = (passphrase: string): string => + crypto.createHash('sha256').update(passphrase, 'utf8').digest('hex'); + +export const command = 'oa-encrypt'; +export const describe = + 'Encrypt a document for safe sharing and storage. You will be asked for an encryption key — remember it to decrypt later.'; + +type EncryptInput = { + inputDocumentPath: string; + outputEncryptedPath: string; + key: string; +}; + +export const promptForInputs = async (): Promise => { + const inputDocumentPath = await input({ + message: 'Enter the path to your document:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') return 'Document path is required'; + return validateInputFileExists(value); + }, + }); + + const outputEncryptedPath = await input({ + message: 'Enter the path to save the encrypted document:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') return 'Output path is required'; + return true; + }, + }); + + const key = await password({ + message: 'Enter the encryption key (remember it to decrypt later):', + mask: '*', + validate: (value: string) => { + if (!value || value.trim() === '') return 'Encryption key is required'; + return true; + }, + }); + + return { + inputDocumentPath: inputDocumentPath.trim(), + outputEncryptedPath: outputEncryptedPath.trim(), + key: key.trim(), + }; +}; + +const ENCRYPT_ERROR_OPTIONS = { + defaultMessage: 'An unexpected error occurred while encrypting the document.', + fileNotFound: 'Unable to read input document. File not found at: {path}', + permissionDenied: 'Permission denied. Cannot write to: {path}', +} as const; + +/** Reads document from disk, encrypts it, writes payload and shows success message. */ +async function runEncrypt(answers: EncryptInput): Promise { + const { inputDocumentPath, outputEncryptedPath, key } = answers; + + ensureInputFileExists(inputDocumentPath); + const documentString = readFile(inputDocumentPath); + const { cipherText, iv, tag, type } = encryptString(documentString, deriveKey(key)); + + const encryptedPayload = { cipherText, iv, tag, type }; + const { path: outputPath, generated } = resolveOutputJsonPath(outputEncryptedPath, 'encrypted'); + writeFile(outputPath, encryptedPayload, true); + + if (generated) { + signale.success(`No output filename provided. Encrypted document saved to: ${outputPath}`); + } else { + signale.success(`Encrypted document saved to: ${outputPath}`); + } + signale.warn('Remember the encryption key you entered — you will need it to decrypt.'); +} + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await runEncrypt(answers); + } catch (err: unknown) { + signale.error(getCliErrorMessage(err, ENCRYPT_ERROR_OPTIONS)); + process.exitCode = 1; + } +}; diff --git a/src/utils/cli-errors.ts b/src/utils/cli-errors.ts new file mode 100644 index 0000000..9a8a400 --- /dev/null +++ b/src/utils/cli-errors.ts @@ -0,0 +1,52 @@ +// CLI error helpers: turn caught errors into clear messages for the user. + +export type ErrnoException = NodeJS.ErrnoException; + +export type CliErrorOptions = { + defaultMessage: string; + fileNotFound?: string; // use {path} for the path + permissionDenied?: string; // use {path} for the path + invalidJson?: (syntaxMessage: string) => string; +}; + +const DEFAULT_PATH_PLACEHOLDER = 'the specified path'; + +export function isErrnoException(err: unknown): err is ErrnoException { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + typeof (err as ErrnoException).code === 'string' + ); +} + +export function isSyntaxError(err: unknown): err is SyntaxError { + return err instanceof SyntaxError; +} + +export function isErrorWithMessage(err: unknown): err is Error & { message: string } { + return err instanceof Error && typeof err.message === 'string'; +} + +/** Picks a user-facing message from options based on error type (ENOENT, EACCES, SyntaxError, or fallback). */ +export function getCliErrorMessage(err: unknown, options: CliErrorOptions): string { + if (isErrnoException(err)) { + const path = err.path ?? DEFAULT_PATH_PLACEHOLDER; + if (err.code === 'ENOENT' && options.fileNotFound) { + return options.fileNotFound.replace('{path}', String(path)); + } + if (err.code === 'EACCES' && options.permissionDenied) { + return options.permissionDenied.replace('{path}', String(path)); + } + } + + if (isSyntaxError(err) && options.invalidJson) { + return options.invalidJson(err.message); + } + + if (isErrorWithMessage(err)) { + return err.message; + } + + return options.defaultMessage; +} diff --git a/src/utils/file-io.ts b/src/utils/file-io.ts index 6ae926d..702e856 100644 --- a/src/utils/file-io.ts +++ b/src/utils/file-io.ts @@ -1,4 +1,5 @@ import signale from 'signale'; +import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import util from 'util'; @@ -133,3 +134,57 @@ export const isDirectoryValid = (path: string): boolean => { export const getEtherscanAddress = ({ network }: { network: string }): string => getSupportedNetwork(network).explorer; + +/** Throws if path does not exist or is not a file (e.g. is a directory). */ +export function ensureInputFileExists(filePath: string): void { + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + if (!isFile(filePath)) { + throw new Error(`Path is not a file: ${filePath}`); + } +} + +/** Returns an error message if the path does not exist or is not a file; otherwise true. Use in prompt validate. */ +export function validateInputFileExists(filePath: string): string | true { + const trimmed = filePath.trim(); + if (!trimmed) return 'Path is required'; + if (!fs.existsSync(trimmed)) return `File not found: ${trimmed}`; + if (!isFile(trimmed)) return `Path is not a file: ${trimmed}`; + return true; +} + +const JSON_EXT = '.json'; + +export type ResolveOutputResult = { path: string; generated: boolean }; + +/** + * If the given path ends with .json, use it (parent dirs created if needed). + * Otherwise generate a new filename with the given prefix and random suffix. + * Returns the resolved path and whether it was generated (true) or user-provided (false). + */ +export function resolveOutputJsonPath(givenPath: string, prefix: string): ResolveOutputResult { + const normalized = path.normalize(givenPath.trim()); + if (normalized.toLowerCase().endsWith(JSON_EXT)) { + const dir = path.dirname(normalized); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return { path: path.resolve(normalized), generated: false }; + } + let baseDir: string; + if (fs.existsSync(normalized) && isDir(normalized)) { + baseDir = path.resolve(normalized); + } else if (path.dirname(normalized) === '.' || path.dirname(normalized) === normalized) { + baseDir = process.cwd(); + } else { + // User path like "tests/valid" – create it as a directory and put the file inside + baseDir = path.resolve(normalized); + } + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true }); + } + const randomId = crypto.randomBytes(6).toString('hex'); + const fileName = `${prefix}-${randomId}${JSON_EXT}`; + return { path: path.join(baseDir, fileName), generated: true }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 683bcb1..2c5f3e6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,9 @@ // CLI Options and Types export * from './cli-options'; +// CLI error handling (type guards + user message mapping) +export * from './cli-errors'; + // File I/O Operations export * from './file-io'; diff --git a/tests/commands/oa/decrypt.test.ts b/tests/commands/oa/decrypt.test.ts new file mode 100644 index 0000000..d7586de --- /dev/null +++ b/tests/commands/oa/decrypt.test.ts @@ -0,0 +1,233 @@ +import path from 'path'; +import * as prompts from '@inquirer/prompts'; +import { afterEach, beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import crypto from 'crypto'; +import fs from 'fs'; +import { handler, promptForInputs } from '../../../src/commands/oa/decrypt'; +import { encryptString } from '@trustvc/trustvc'; + +vi.mock('signale', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, + Signale: vi.fn().mockImplementation(() => ({ + await: vi.fn(), + success: vi.fn(), + })), +})); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + password: vi.fn(), +})); + +vi.mock('../../../src/utils', async () => { + const actual = await vi.importActual('../../../src/utils'); + return { + ...actual, + readDocumentFile: vi.fn(), + ensureInputFileExists: vi.fn(), + validateInputFileExists: vi.fn().mockReturnValue(true), + resolveOutputJsonPath: (givenPath: string) => ({ path: givenPath, generated: false }), + }; +}); + +const TEST_KEY = 'test-decryption-key-32-bytes-long-hex!!'; +// Resolve fixture from project root so tests work regardless of cwd when run via npm test +const OA_FIXTURE_PATH = path.resolve( + process.cwd(), + 'tests/fixtures/wrap/oa_v3/raw_oa_docs_v3/raw-dns-did.json', +); + +describe('oa-decrypt', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + vi.spyOn(fs, 'writeFileSync'); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('promptForInputs', () => { + it('should return inputs when user provides encrypted path, output path and key', async () => { + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./encrypted.json') + .mockResolvedValueOnce('./decrypted.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + const result = await promptForInputs(); + + expect(result).toEqual({ + inputEncryptedPath: './encrypted.json', + outputPath: './decrypted.json', + key: TEST_KEY, + }); + }); + }); + + describe('handler', () => { + it('should decrypt valid encrypted payload and write document string', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + const actualUtils = + await vi.importActual('../../../src/utils'); + const oaDocument = actualUtils.readDocumentFile(OA_FIXTURE_PATH); + const documentString = JSON.stringify(oaDocument); + + const derivedKey = crypto.createHash('sha256').update(TEST_KEY, 'utf8').digest('hex'); + const { cipherText, iv, tag, type } = encryptString(documentString, derivedKey); + const readMock = utils.readDocumentFile as MockedFunction; + const successMock = (signale.default as any).success as MockedFunction; + + readMock.mockReturnValue({ cipherText, iv, tag, type }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./encrypted.json') + .mockResolvedValueOnce('./decrypted.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + await handler(); + + expect(readMock).toHaveBeenCalledWith('./encrypted.json'); + expect(fs.writeFileSync).toHaveBeenCalledWith('./decrypted.json', documentString, 'utf8'); + expect(successMock).toHaveBeenCalledWith('Decrypted document saved to: ./decrypted.json'); + }); + + it('should trim the key before decryption', async () => { + const utils = await import('../../../src/utils'); + const actualUtils = + await vi.importActual('../../../src/utils'); + const oaDocument = actualUtils.readDocumentFile(OA_FIXTURE_PATH); + const documentString = JSON.stringify(oaDocument); + const derivedKey = crypto.createHash('sha256').update(TEST_KEY, 'utf8').digest('hex'); + const { cipherText, iv, tag, type } = encryptString(documentString, derivedKey); + const readMock = utils.readDocumentFile as MockedFunction; + + readMock.mockReturnValue({ cipherText, iv, tag, type }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./encrypted.json') + .mockResolvedValueOnce('./decrypted.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(` ${TEST_KEY} `); + + await handler(); + + expect(fs.writeFileSync).toHaveBeenCalledWith('./decrypted.json', documentString, 'utf8'); + }); + + it('should log a clear error and set exitCode when encrypted payload has wrong type', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + + const readMock = utils.readDocumentFile as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + readMock.mockReturnValue({ type: 'wrong-type', cipherText: 'abc', iv: 'x', tag: 'y' }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./bad.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + await handler(); + + expect(errorMock).toHaveBeenCalledWith( + 'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".', + ); + expect(process.exitCode).toBe(1); + }); + + it('should log a clear error and set exitCode when encrypted payload is missing cipherText', async () => { + const utils = await import('../../../src/utils'); + const readMock = utils.readDocumentFile as MockedFunction; + readMock.mockReturnValue({ type: 'OPEN-ATTESTATION-TYPE-1' }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./bad.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + await handler(); + + expect(process.exitCode).toBe(1); + }); + + it('should log a friendly error and set exitCode when key is wrong', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + const actualUtils = + await vi.importActual('../../../src/utils'); + const oaDocument = actualUtils.readDocumentFile(OA_FIXTURE_PATH); + const derivedKey = crypto.createHash('sha256').update(TEST_KEY, 'utf8').digest('hex'); + const { cipherText, iv, tag, type } = encryptString(JSON.stringify(oaDocument), derivedKey); + const readMock = utils.readDocumentFile as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + + readMock.mockReturnValue({ cipherText, iv, tag, type }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./encrypted.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce('wrong-key'); + + await handler(); + + expect(errorMock).toHaveBeenCalledWith( + 'Failed to decrypt document. The password/key is likely incorrect or the file is corrupted.', + ); + expect(process.exitCode).toBe(1); + }); + + it('should log a friendly error and set exitCode when readDocumentFile throws', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + + const readMock = utils.readDocumentFile as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + readMock.mockImplementationOnce(() => { + throw new Error('File not found'); + }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('/nonexistent.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + await handler(); + + expect(errorMock).toHaveBeenCalledWith('File not found'); + expect(process.exitCode).toBe(1); + }); + + it('should show a file-not-found message when underlying error is ENOENT', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + + const readMock = utils.readDocumentFile as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + readMock.mockImplementationOnce(() => { + const err: NodeJS.ErrnoException = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + err.path = '/nonexistent.json'; + throw err; + }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('/nonexistent.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + await handler(); + + expect(errorMock).toHaveBeenCalledWith( + 'Unable to read encrypted document. File not found at: /nonexistent.json', + ); + expect(process.exitCode).toBe(1); + }); + }); +}); diff --git a/tests/commands/oa/encrypt.test.ts b/tests/commands/oa/encrypt.test.ts new file mode 100644 index 0000000..037ee27 --- /dev/null +++ b/tests/commands/oa/encrypt.test.ts @@ -0,0 +1,183 @@ +import path from 'path'; +import * as prompts from '@inquirer/prompts'; +import { afterEach, beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { handler, promptForInputs } from '../../../src/commands/oa/encrypt'; + +// Resolve fixture from project root so tests work regardless of cwd when run via npm test +const FIXTURE_DOC = path.resolve( + process.cwd(), + 'tests/fixtures/wrap/oa_v3/raw_oa_docs_v3/raw-dns-did.json', +); +const TEST_KEY = 'my-secret-encryption-key'; + +vi.mock('signale', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, + Signale: vi.fn().mockImplementation(() => ({ + await: vi.fn(), + success: vi.fn(), + })), +})); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + password: vi.fn(), +})); + +vi.mock('../../../src/utils', async () => { + const actual = await vi.importActual('../../../src/utils'); + return { + ...actual, + readFile: vi.fn(), + writeFile: vi.fn(), + ensureInputFileExists: vi.fn(), + validateInputFileExists: vi.fn().mockReturnValue(true), + resolveOutputJsonPath: (givenPath: string) => ({ path: givenPath, generated: false }), + }; +}); + +describe('oa-encrypt', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('promptForInputs', () => { + it('should return inputs when user provides document path, output path and key', async () => { + (prompts.input as MockedFunction) + .mockResolvedValueOnce(FIXTURE_DOC) + .mockResolvedValueOnce('./encrypted.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + const result = await promptForInputs(); + + expect(result).toEqual({ + inputDocumentPath: FIXTURE_DOC, + outputEncryptedPath: './encrypted.json', + key: TEST_KEY, + }); + }); + }); + + describe('handler', () => { + it('should read document, encrypt with prompted key, and write encrypted payload', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + const actualUtils = + await vi.importActual('../../../src/utils'); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce(FIXTURE_DOC) + .mockResolvedValueOnce('./tmp-encrypted-out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + const readMock = utils.readFile as MockedFunction; + const writeMock = utils.writeFile as MockedFunction; + const successMock = (signale.default as any).success as MockedFunction; + const warnMock = (signale.default as any).warn as MockedFunction; + + readMock.mockImplementation(actualUtils.readFile as unknown as any); + + await handler(); + + expect(readMock).toHaveBeenCalledWith(FIXTURE_DOC); + expect(writeMock).toHaveBeenCalledTimes(1); + const [writtenPath, payload] = writeMock.mock.calls[0]; + expect(writtenPath).toBe('./tmp-encrypted-out.json'); + expect(payload).toHaveProperty('type', 'OPEN-ATTESTATION-TYPE-1'); + expect(payload).toHaveProperty('cipherText'); + expect(payload).toHaveProperty('iv'); + expect(payload).toHaveProperty('tag'); + expect(typeof payload.cipherText).toBe('string'); + expect(successMock).toHaveBeenCalledWith( + 'Encrypted document saved to: ./tmp-encrypted-out.json', + ); + expect(warnMock).toHaveBeenCalledWith( + 'Remember the encryption key you entered — you will need it to decrypt.', + ); + }); + + it('should produce ciphertext decryptable with the key entered by user', async () => { + const utils = await import('../../../src/utils'); + const { decryptString } = await import('@trustvc/trustvc'); + const crypto = await import('crypto'); + const actualUtils = + await vi.importActual('../../../src/utils'); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce(FIXTURE_DOC) + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + const readMock = utils.readFile as MockedFunction; + const writeMock = utils.writeFile as MockedFunction; + readMock.mockImplementation(actualUtils.readFile as unknown as any); + + await handler(); + + const payload = writeMock.mock.calls[0][1]; + const derivedKey = crypto.createHash('sha256').update(TEST_KEY, 'utf8').digest('hex'); + const documentString = decryptString({ + cipherText: payload.cipherText, + iv: payload.iv, + tag: payload.tag, + key: derivedKey, + type: payload.type, + }); + const originalContent = actualUtils.readFile(FIXTURE_DOC); + expect(documentString).toStrictEqual(originalContent); + }); + + it('should log a friendly error and set exitCode when readFile throws', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('/nonexistent.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + const ensureMock = utils.ensureInputFileExists as MockedFunction; + ensureMock.mockImplementationOnce(() => { + throw new Error('File not found: /nonexistent.json'); + }); + + const errorMock = (signale.default as any).error as MockedFunction; + + await handler(); + + expect(errorMock).toHaveBeenCalledWith('File not found: /nonexistent.json'); + expect(process.exitCode).toBe(1); + }); + + it('should show a file-not-found message when input file does not exist', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('/nonexistent.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + const ensureMock = utils.ensureInputFileExists as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + ensureMock.mockImplementationOnce(() => { + throw new Error('File not found: /nonexistent.json'); + }); + + await handler(); + + expect(errorMock).toHaveBeenCalledWith('File not found: /nonexistent.json'); + expect(process.exitCode).toBe(1); + }); + }); +}); diff --git a/tests/fixtures/oa/valid-open-attestation-document.json b/tests/fixtures/oa/valid-open-attestation-document.json new file mode 100644 index 0000000..1961a7f --- /dev/null +++ b/tests/fixtures/oa/valid-open-attestation-document.json @@ -0,0 +1,23 @@ +{ + "reference": "ABCXXXXX00", + "name": "Certificate of whatever", + "template": { + "name": "CUSTOM_TEMPLATE", + "type": "EMBEDDED_RENDERER", + "url": "http://localhost:3000/rederer" + }, + "description": "A Document", + "validFrom": "2018-08-30T00:00:00+08:00", + "documentType": "Certificate of Origin", + "proof": { + "type": "OpenAttestationSignature2018", + "method": "DOCUMENT_STORE", + "value": "0x9178F546D3FF57D7A6352bD61B80cCCD46199C2d" + }, + "issuer": { + "id": "https://example.com", + "name": "Issuer name", + "identityProof": { "type": "DNS-TXT", "location": "some.io" } + }, + "recipient": { "name": "Reciepient Name", "address": "Sydney Australia", "country": "Australia" } +}