From 6dab3e7d7138c58e46406a97aa0cf08557d6d3ce Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Mon, 23 Feb 2026 23:08:03 +0530 Subject: [PATCH 1/9] feat: added encrypt/decrypt functions to the OA feature --- src/commands/oa/decrypt.ts | 102 +++++++++++++++ src/commands/oa/encrypt.ts | 94 ++++++++++++++ tests/commands/oa/decrypt.test.ts | 207 ++++++++++++++++++++++++++++++ tests/commands/oa/encrypt.test.ts | 160 +++++++++++++++++++++++ 4 files changed, 563 insertions(+) create mode 100644 src/commands/oa/decrypt.ts create mode 100644 src/commands/oa/encrypt.ts create mode 100644 tests/commands/oa/decrypt.test.ts create mode 100644 tests/commands/oa/encrypt.test.ts diff --git a/src/commands/oa/decrypt.ts b/src/commands/oa/decrypt.ts new file mode 100644 index 0000000..db5d13e --- /dev/null +++ b/src/commands/oa/decrypt.ts @@ -0,0 +1,102 @@ +import { input, password } from '@inquirer/prompts'; +import signale from 'signale'; +import { + decrypt, + isRawV2Document, + isRawV3Document, + isWrappedV2Document, + isWrappedV3Document, +} from '@trustvc/trustvc'; +import { readDocumentFile, writeFile } from '../../utils'; + +export const command = 'oa-decrypt'; +export const describe = + 'Decrypt an Open Attestation document that was encrypted using oa-encrypt. You will be asked for the decryption key.'; + +type DecryptInput = { + inputEncryptedPath: string; + outputPath: string; + key: string; +}; + +const ENCRYPTED_DOCUMENT_TYPE = 'encrypted-document'; + +const isOADocument = (doc: unknown): boolean => + isRawV2Document(doc) || + isRawV3Document(doc) || + isWrappedV2Document(doc) || + isWrappedV3Document(doc); + +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 true; + }, + }); + + 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(), + }; +}; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + const { inputEncryptedPath, outputPath, key } = answers; + + const encryptedPayload = readDocumentFile(inputEncryptedPath); + + if (encryptedPayload?.type !== ENCRYPTED_DOCUMENT_TYPE || !encryptedPayload?.ciphertext) { + throw new Error( + 'Invalid encrypted document: expected an object with type "encrypted-document" and "ciphertext" field.', + ); + } + + const documentString = decrypt(encryptedPayload.ciphertext, key); + let document: unknown; + try { + document = JSON.parse(documentString); + } catch { + throw new Error( + 'Decryption succeeded but the result is not valid JSON. The key may be incorrect.', + ); + } + + if (!isOADocument(document)) { + throw new Error( + 'Decrypted content is not a valid Open Attestation document. Expected raw OA v2/v3 or wrapped OA v2/v3.', + ); + } + + writeFile(outputPath, document, true); + signale.success(`Decrypted document saved to: ${outputPath}`); + } catch (err: unknown) { + signale.error(err instanceof Error ? err.message : String(err)); + throw err; + } +}; diff --git a/src/commands/oa/encrypt.ts b/src/commands/oa/encrypt.ts new file mode 100644 index 0000000..900b543 --- /dev/null +++ b/src/commands/oa/encrypt.ts @@ -0,0 +1,94 @@ +import { input, password } from '@inquirer/prompts'; +import signale from 'signale'; +import { + encrypt, + isRawV2Document, + isRawV3Document, + isWrappedV2Document, + isWrappedV3Document, +} from '@trustvc/trustvc'; +import { readDocumentFile, writeFile } from '../../utils'; + +export const command = 'oa-encrypt'; +export const describe = + 'Encrypt an Open Attestation 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; +}; + +const ENCRYPTED_DOCUMENT_TYPE = 'encrypted-document'; + +const isOADocument = (doc: unknown): boolean => + isRawV2Document(doc) || + isRawV3Document(doc) || + isWrappedV2Document(doc) || + isWrappedV3Document(doc); + +export const promptForInputs = async (): Promise => { + const inputDocumentPath = await input({ + message: 'Enter the path to your Open Attestation document:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') return 'Document path is required'; + return true; + }, + }); + + 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(), + }; +}; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + const { inputDocumentPath, outputEncryptedPath, key } = answers; + + const document = readDocumentFile(inputDocumentPath); + if (!isOADocument(document)) { + throw new Error( + 'The document is not a valid Open Attestation document. Expected raw OA v2/v3 or wrapped OA v2/v3.', + ); + } + const documentString = JSON.stringify(document); + + const ciphertext = encrypt(documentString, key); + + const encryptedPayload = { + type: ENCRYPTED_DOCUMENT_TYPE, + ciphertext, + }; + + writeFile(outputEncryptedPath, encryptedPayload, true); + signale.success(`Encrypted document saved to: ${outputEncryptedPath}`); + signale.warn('Remember the encryption key you entered — you will need it to decrypt.'); + } catch (err: unknown) { + signale.error(err instanceof Error ? err.message : String(err)); + throw err; + } +}; diff --git a/tests/commands/oa/decrypt.test.ts b/tests/commands/oa/decrypt.test.ts new file mode 100644 index 0000000..ed3013a --- /dev/null +++ b/tests/commands/oa/decrypt.test.ts @@ -0,0 +1,207 @@ +import path from 'path'; +import * as prompts from '@inquirer/prompts'; +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { handler, promptForInputs } from '../../../src/commands/oa/decrypt'; +import { encrypt } 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(), + writeFile: vi.fn(), + }; +}); + +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(); + }); + + 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 OA payload and write document', 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 ciphertext = encrypt(JSON.stringify(oaDocument), TEST_KEY); + const readMock = utils.readDocumentFile as MockedFunction; + const writeMock = utils.writeFile as MockedFunction; + const successMock = (signale.default as any).success as MockedFunction; + + readMock.mockReturnValue({ type: 'encrypted-document', ciphertext }); + + (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(writeMock).toHaveBeenCalledWith('./decrypted.json', oaDocument, true); + 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 ciphertext = encrypt(JSON.stringify(oaDocument), TEST_KEY); + const readMock = utils.readDocumentFile as MockedFunction; + const writeMock = utils.writeFile as MockedFunction; + + readMock.mockReturnValue({ type: 'encrypted-document', ciphertext }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./encrypted.json') + .mockResolvedValueOnce('./decrypted.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(` ${TEST_KEY} `); + + await handler(); + + expect(writeMock).toHaveBeenCalledWith('./decrypted.json', oaDocument, true); + }); + + it('should throw 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' }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./bad.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + await expect(handler()).rejects.toThrow( + 'Invalid encrypted document: expected an object with type "encrypted-document" and "ciphertext" field.', + ); + expect(errorMock).toHaveBeenCalled(); + }); + + it('should throw when encrypted payload is missing ciphertext', async () => { + const utils = await import('../../../src/utils'); + const readMock = utils.readDocumentFile as MockedFunction; + readMock.mockReturnValue({ type: 'encrypted-document' }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./bad.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + await expect(handler()).rejects.toThrow( + 'Invalid encrypted document: expected an object with type "encrypted-document" and "ciphertext" field.', + ); + }); + + it('should throw 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 ciphertext = encrypt(JSON.stringify(oaDocument), TEST_KEY); + const readMock = utils.readDocumentFile as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + + readMock.mockReturnValue({ type: 'encrypted-document', ciphertext }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./encrypted.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce('wrong-key'); + + await expect(handler()).rejects.toThrow( + 'Decryption succeeded but the result is not valid JSON. The key may be incorrect.', + ); + expect(errorMock).toHaveBeenCalled(); + }); + + it('should throw when decrypted content is not a valid OA document', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + + const nonOADocument = { foo: 'bar', notOA: true }; + const ciphertext = encrypt(JSON.stringify(nonOADocument), TEST_KEY); + const readMock = utils.readDocumentFile as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + + readMock.mockReturnValue({ type: 'encrypted-document', ciphertext }); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./encrypted.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + await expect(handler()).rejects.toThrow( + 'Decrypted content is not a valid Open Attestation document. Expected raw OA v2/v3 or wrapped OA v2/v3.', + ); + expect(errorMock).toHaveBeenCalled(); + }); + + it('should call signale.error and rethrow 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 expect(handler()).rejects.toThrow('File not found'); + expect(errorMock).toHaveBeenCalledWith('File not found'); + }); + }); +}); diff --git a/tests/commands/oa/encrypt.test.ts b/tests/commands/oa/encrypt.test.ts new file mode 100644 index 0000000..46cdd60 --- /dev/null +++ b/tests/commands/oa/encrypt.test.ts @@ -0,0 +1,160 @@ +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, + readDocumentFile: vi.fn(), + writeFile: vi.fn(), + }; +}); + +describe('oa-encrypt', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + 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 OA 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.readDocumentFile 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.readDocumentFile 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', 'encrypted-document'); + expect(payload).toHaveProperty('ciphertext'); + 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 trustvc = await import('@trustvc/trustvc'); + 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.readDocumentFile as MockedFunction; + const writeMock = utils.writeFile as MockedFunction; + readMock.mockImplementation(actualUtils.readDocumentFile as unknown as any); + + await handler(); + + const payload = writeMock.mock.calls[0][1]; + const documentString = trustvc.decrypt(payload.ciphertext, TEST_KEY); + const document = JSON.parse(documentString); + const original = actualUtils.readDocumentFile(FIXTURE_DOC); + expect(document).toStrictEqual(original); + }); + + it('should throw when document is not a valid Open Attestation document', async () => { + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('./not-oa.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + const readMock = utils.readDocumentFile as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + readMock.mockReturnValue({ foo: 'bar', notOA: true }); + + await expect(handler()).rejects.toThrow( + 'The document is not a valid Open Attestation document. Expected raw OA v2/v3 or wrapped OA v2/v3.', + ); + expect(errorMock).toHaveBeenCalled(); + }); + + it('should call signale.error and rethrow when readDocumentFile 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 readMock = utils.readDocumentFile as MockedFunction; + const errorMock = (signale.default as any).error as MockedFunction; + readMock.mockImplementationOnce(() => { + throw new Error('File not found'); + }); + + await expect(handler()).rejects.toThrow('File not found'); + expect(errorMock).toHaveBeenCalledWith('File not found'); + }); + }); +}); From 8fa493ab5e16216ac248cc6b2b971029eccaa854 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Mon, 23 Feb 2026 23:11:03 +0530 Subject: [PATCH 2/9] feat: updated the readme --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a0298ab..8742976 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 @@ -173,6 +179,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**: Uses `mint` to mint document hashes (tokenIds) to blockchain-based token registries across multiple networks (Ethereum, Polygon, XDC, Stability, Astron). @@ -197,6 +205,8 @@ 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 | +| | [`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 mint` | Alternative: `mint` | | **Document Store** | [`document-store deploy`](#document-store-deploy) | Deploy document store contracts | @@ -475,6 +485,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

@@ -1122,9 +1183,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/ │ └── mint.ts # Mint tokens to registry ├── document-store/ From 70f3ea510a099645e2b5a104c2dc80d71bb975ad Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Mon, 23 Feb 2026 23:26:46 +0530 Subject: [PATCH 3/9] feat: added husky for lint checks --- .husky/pre-commit | 3 +++ package-lock.json | 17 +++++++++++++++++ package.json | 6 ++++-- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 .husky/pre-commit 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/package-lock.json b/package-lock.json index 7b3224c..2510b94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -7696,6 +7697,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 8637371..6f690ef 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" @@ -57,7 +58,8 @@ "tsup": "^8.2.4", "tsx": "^4.19.1", "typescript": "^5.5.4", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "husky": "^9.0.11" }, "repository": { "type": "git", From 661c27e59a72f31312539ddd95898c20a33aac3c Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Mon, 23 Feb 2026 23:29:52 +0530 Subject: [PATCH 4/9] feat: fixed the prettier formats --- tests/commands/oa/decrypt.test.ts | 5 ++++- tests/commands/oa/encrypt.test.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/commands/oa/decrypt.test.ts b/tests/commands/oa/decrypt.test.ts index ed3013a..41db7fc 100644 --- a/tests/commands/oa/decrypt.test.ts +++ b/tests/commands/oa/decrypt.test.ts @@ -33,7 +33,10 @@ vi.mock('../../../src/utils', async () => { 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'); +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(() => { diff --git a/tests/commands/oa/encrypt.test.ts b/tests/commands/oa/encrypt.test.ts index 46cdd60..cc3cbef 100644 --- a/tests/commands/oa/encrypt.test.ts +++ b/tests/commands/oa/encrypt.test.ts @@ -4,7 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, MockedFunction, vi } from 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 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', () => ({ @@ -89,7 +92,9 @@ describe('oa-encrypt', () => { expect(payload).toHaveProperty('type', 'encrypted-document'); expect(payload).toHaveProperty('ciphertext'); expect(typeof payload.ciphertext).toBe('string'); - expect(successMock).toHaveBeenCalledWith('Encrypted document saved to: ./tmp-encrypted-out.json'); + 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.', ); From 7a1e3f98de40b1b277355c69bf407aa2e93123bc Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Tue, 24 Feb 2026 12:47:56 +0530 Subject: [PATCH 5/9] feature: added encrypt decrypt functionlities of documents --- src/commands/oa/decrypt.ts | 58 ++++++-------- src/commands/oa/encrypt.ts | 46 ++++------- tests/commands/oa/decrypt.test.ts | 78 +++++++------------ tests/commands/oa/encrypt.test.ts | 60 ++++++-------- tests/fixtures/oa/did-dns-decrypted.json | 65 ++++++++++++++++ tests/fixtures/oa/did-dns-encrypted.json | 6 ++ .../oa/valid-open-attestation-document.json | 23 ++++++ 7 files changed, 185 insertions(+), 151 deletions(-) create mode 100644 tests/fixtures/oa/did-dns-decrypted.json create mode 100644 tests/fixtures/oa/did-dns-encrypted.json create mode 100644 tests/fixtures/oa/valid-open-attestation-document.json diff --git a/src/commands/oa/decrypt.ts b/src/commands/oa/decrypt.ts index db5d13e..6e9f449 100644 --- a/src/commands/oa/decrypt.ts +++ b/src/commands/oa/decrypt.ts @@ -1,17 +1,17 @@ +import fs from 'fs'; import { input, password } from '@inquirer/prompts'; +import crypto from 'crypto'; import signale from 'signale'; -import { - decrypt, - isRawV2Document, - isRawV3Document, - isWrappedV2Document, - isWrappedV3Document, -} from '@trustvc/trustvc'; -import { readDocumentFile, writeFile } from '../../utils'; +import { decryptString } from '@trustvc/trustvc'; +import { readDocumentFile } 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 an Open Attestation document that was encrypted using oa-encrypt. You will be asked for the decryption key.'; + 'Decrypt a document that was encrypted using oa-encrypt. You will be asked for the decryption key.'; type DecryptInput = { inputEncryptedPath: string; @@ -19,13 +19,8 @@ type DecryptInput = { key: string; }; -const ENCRYPTED_DOCUMENT_TYPE = 'encrypted-document'; - -const isOADocument = (doc: unknown): boolean => - isRawV2Document(doc) || - isRawV3Document(doc) || - isWrappedV2Document(doc) || - isWrappedV3Document(doc); +// Payload format: OPEN-ATTESTATION-TYPE-1 (cipherText, iv, tag, type) +const ENCRYPTED_DOCUMENT_TYPE = 'OPEN-ATTESTATION-TYPE-1'; export const promptForInputs = async (): Promise => { const inputEncryptedPath = await input({ @@ -70,30 +65,23 @@ export const handler = async (): Promise => { const { inputEncryptedPath, outputPath, key } = answers; const encryptedPayload = readDocumentFile(inputEncryptedPath); - - if (encryptedPayload?.type !== ENCRYPTED_DOCUMENT_TYPE || !encryptedPayload?.ciphertext) { - throw new Error( - 'Invalid encrypted document: expected an object with type "encrypted-document" and "ciphertext" field.', - ); - } - - const documentString = decrypt(encryptedPayload.ciphertext, key); - let document: unknown; - try { - document = JSON.parse(documentString); - } catch { + + const { cipherText, iv, tag, type } = encryptedPayload; + if (!cipherText || !iv || !tag || type !== ENCRYPTED_DOCUMENT_TYPE) { throw new Error( - 'Decryption succeeded but the result is not valid JSON. The key may be incorrect.', + 'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".', ); } - if (!isOADocument(document)) { - throw new Error( - 'Decrypted content is not a valid Open Attestation document. Expected raw OA v2/v3 or wrapped OA v2/v3.', - ); - } + const documentString = decryptString({ + cipherText, + iv, + tag, + key: deriveKey(key), + type, + }); - writeFile(outputPath, document, true); + fs.writeFileSync(outputPath, documentString, 'utf8'); signale.success(`Decrypted document saved to: ${outputPath}`); } catch (err: unknown) { signale.error(err instanceof Error ? err.message : String(err)); diff --git a/src/commands/oa/encrypt.ts b/src/commands/oa/encrypt.ts index 900b543..ea7c982 100644 --- a/src/commands/oa/encrypt.ts +++ b/src/commands/oa/encrypt.ts @@ -1,17 +1,16 @@ import { input, password } from '@inquirer/prompts'; +import crypto from 'crypto'; import signale from 'signale'; -import { - encrypt, - isRawV2Document, - isRawV3Document, - isWrappedV2Document, - isWrappedV3Document, -} from '@trustvc/trustvc'; -import { readDocumentFile, writeFile } from '../../utils'; +import { encryptString } from '@trustvc/trustvc'; +import { readFile, writeFile } 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 an Open Attestation document for safe sharing and storage. You will be asked for an encryption key — remember it to decrypt later.'; + '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; @@ -19,17 +18,9 @@ type EncryptInput = { key: string; }; -const ENCRYPTED_DOCUMENT_TYPE = 'encrypted-document'; - -const isOADocument = (doc: unknown): boolean => - isRawV2Document(doc) || - isRawV3Document(doc) || - isWrappedV2Document(doc) || - isWrappedV3Document(doc); - export const promptForInputs = async (): Promise => { const inputDocumentPath = await input({ - message: 'Enter the path to your Open Attestation document:', + message: 'Enter the path to your document:', required: true, validate: (value: string) => { if (!value || value.trim() === '') return 'Document path is required'; @@ -69,21 +60,10 @@ export const handler = async (): Promise => { const { inputDocumentPath, outputEncryptedPath, key } = answers; - const document = readDocumentFile(inputDocumentPath); - if (!isOADocument(document)) { - throw new Error( - 'The document is not a valid Open Attestation document. Expected raw OA v2/v3 or wrapped OA v2/v3.', - ); - } - const documentString = JSON.stringify(document); - - const ciphertext = encrypt(documentString, key); - - const encryptedPayload = { - type: ENCRYPTED_DOCUMENT_TYPE, - ciphertext, - }; - + const documentString = readFile(inputDocumentPath); + const { cipherText, iv, tag, type } = encryptString(documentString, deriveKey(key)); + + const encryptedPayload = { cipherText, iv, tag, type }; writeFile(outputEncryptedPath, encryptedPayload, true); signale.success(`Encrypted document saved to: ${outputEncryptedPath}`); signale.warn('Remember the encryption key you entered — you will need it to decrypt.'); diff --git a/tests/commands/oa/decrypt.test.ts b/tests/commands/oa/decrypt.test.ts index 41db7fc..e7e3927 100644 --- a/tests/commands/oa/decrypt.test.ts +++ b/tests/commands/oa/decrypt.test.ts @@ -1,8 +1,10 @@ import path from 'path'; import * as prompts from '@inquirer/prompts'; -import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +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 { encrypt } from '@trustvc/trustvc'; +import { encryptString } from '@trustvc/trustvc'; vi.mock('signale', () => ({ default: { @@ -24,11 +26,7 @@ vi.mock('@inquirer/prompts', () => ({ vi.mock('../../../src/utils', async () => { const actual = await vi.importActual('../../../src/utils'); - return { - ...actual, - readDocumentFile: vi.fn(), - writeFile: vi.fn(), - }; + return { ...actual, readDocumentFile: vi.fn() }; }); const TEST_KEY = 'test-decryption-key-32-bytes-long-hex!!'; @@ -42,6 +40,11 @@ describe('oa-decrypt', () => { beforeEach(() => { vi.clearAllMocks(); vi.resetAllMocks(); + vi.spyOn(fs, 'writeFileSync'); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); describe('promptForInputs', () => { @@ -62,19 +65,20 @@ describe('oa-decrypt', () => { }); describe('handler', () => { - it('should decrypt valid encrypted OA payload and write document', async () => { + 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 ciphertext = encrypt(JSON.stringify(oaDocument), TEST_KEY); + 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 writeMock = utils.writeFile as MockedFunction; const successMock = (signale.default as any).success as MockedFunction; - readMock.mockReturnValue({ type: 'encrypted-document', ciphertext }); + readMock.mockReturnValue({ cipherText, iv, tag, type }); (prompts.input as MockedFunction) .mockResolvedValueOnce('./encrypted.json') @@ -84,7 +88,7 @@ describe('oa-decrypt', () => { await handler(); expect(readMock).toHaveBeenCalledWith('./encrypted.json'); - expect(writeMock).toHaveBeenCalledWith('./decrypted.json', oaDocument, true); + expect(fs.writeFileSync).toHaveBeenCalledWith('./decrypted.json', documentString, 'utf8'); expect(successMock).toHaveBeenCalledWith('Decrypted document saved to: ./decrypted.json'); }); @@ -93,11 +97,12 @@ describe('oa-decrypt', () => { const actualUtils = await vi.importActual('../../../src/utils'); const oaDocument = actualUtils.readDocumentFile(OA_FIXTURE_PATH); - const ciphertext = encrypt(JSON.stringify(oaDocument), TEST_KEY); + 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 writeMock = utils.writeFile as MockedFunction; - readMock.mockReturnValue({ type: 'encrypted-document', ciphertext }); + readMock.mockReturnValue({ cipherText, iv, tag, type }); (prompts.input as MockedFunction) .mockResolvedValueOnce('./encrypted.json') @@ -106,7 +111,7 @@ describe('oa-decrypt', () => { await handler(); - expect(writeMock).toHaveBeenCalledWith('./decrypted.json', oaDocument, true); + expect(fs.writeFileSync).toHaveBeenCalledWith('./decrypted.json', documentString, 'utf8'); }); it('should throw when encrypted payload has wrong type', async () => { @@ -115,7 +120,7 @@ describe('oa-decrypt', () => { const readMock = utils.readDocumentFile as MockedFunction; const errorMock = (signale.default as any).error as MockedFunction; - readMock.mockReturnValue({ type: 'wrong-type', ciphertext: 'abc' }); + readMock.mockReturnValue({ type: 'wrong-type', cipherText: 'abc', iv: 'x', tag: 'y' }); (prompts.input as MockedFunction) .mockResolvedValueOnce('./bad.json') @@ -123,15 +128,15 @@ describe('oa-decrypt', () => { (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); await expect(handler()).rejects.toThrow( - 'Invalid encrypted document: expected an object with type "encrypted-document" and "ciphertext" field.', + 'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".', ); expect(errorMock).toHaveBeenCalled(); }); - it('should throw when encrypted payload is missing ciphertext', async () => { + it('should throw when encrypted payload is missing cipherText', async () => { const utils = await import('../../../src/utils'); const readMock = utils.readDocumentFile as MockedFunction; - readMock.mockReturnValue({ type: 'encrypted-document' }); + readMock.mockReturnValue({ type: 'OPEN-ATTESTATION-TYPE-1' }); (prompts.input as MockedFunction) .mockResolvedValueOnce('./bad.json') @@ -139,7 +144,7 @@ describe('oa-decrypt', () => { (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); await expect(handler()).rejects.toThrow( - 'Invalid encrypted document: expected an object with type "encrypted-document" and "ciphertext" field.', + 'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".', ); }); @@ -149,42 +154,19 @@ describe('oa-decrypt', () => { const actualUtils = await vi.importActual('../../../src/utils'); const oaDocument = actualUtils.readDocumentFile(OA_FIXTURE_PATH); - const ciphertext = encrypt(JSON.stringify(oaDocument), TEST_KEY); + 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({ type: 'encrypted-document', ciphertext }); + readMock.mockReturnValue({ cipherText, iv, tag, type }); (prompts.input as MockedFunction) .mockResolvedValueOnce('./encrypted.json') .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce('wrong-key'); - await expect(handler()).rejects.toThrow( - 'Decryption succeeded but the result is not valid JSON. The key may be incorrect.', - ); - expect(errorMock).toHaveBeenCalled(); - }); - - it('should throw when decrypted content is not a valid OA document', async () => { - const utils = await import('../../../src/utils'); - const signale = await import('signale'); - - const nonOADocument = { foo: 'bar', notOA: true }; - const ciphertext = encrypt(JSON.stringify(nonOADocument), TEST_KEY); - const readMock = utils.readDocumentFile as MockedFunction; - const errorMock = (signale.default as any).error as MockedFunction; - - readMock.mockReturnValue({ type: 'encrypted-document', ciphertext }); - - (prompts.input as MockedFunction) - .mockResolvedValueOnce('./encrypted.json') - .mockResolvedValueOnce('./out.json'); - (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - - await expect(handler()).rejects.toThrow( - 'Decrypted content is not a valid Open Attestation document. Expected raw OA v2/v3 or wrapped OA v2/v3.', - ); + await expect(handler()).rejects.toThrow('Error decrypting message'); expect(errorMock).toHaveBeenCalled(); }); diff --git a/tests/commands/oa/encrypt.test.ts b/tests/commands/oa/encrypt.test.ts index cc3cbef..8c8feb1 100644 --- a/tests/commands/oa/encrypt.test.ts +++ b/tests/commands/oa/encrypt.test.ts @@ -32,7 +32,7 @@ vi.mock('../../../src/utils', async () => { const actual = await vi.importActual('../../../src/utils'); return { ...actual, - readDocumentFile: vi.fn(), + readFile: vi.fn(), writeFile: vi.fn(), }; }); @@ -65,7 +65,7 @@ describe('oa-encrypt', () => { }); describe('handler', () => { - it('should read OA document, encrypt with prompted key, and write encrypted payload', async () => { + 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 = @@ -76,12 +76,12 @@ describe('oa-encrypt', () => { .mockResolvedValueOnce('./tmp-encrypted-out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - const readMock = utils.readDocumentFile as MockedFunction; + 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.readDocumentFile as unknown as any); + readMock.mockImplementation(actualUtils.readFile as unknown as any); await handler(); @@ -89,9 +89,11 @@ describe('oa-encrypt', () => { expect(writeMock).toHaveBeenCalledTimes(1); const [writtenPath, payload] = writeMock.mock.calls[0]; expect(writtenPath).toBe('./tmp-encrypted-out.json'); - expect(payload).toHaveProperty('type', 'encrypted-document'); - expect(payload).toHaveProperty('ciphertext'); - expect(typeof payload.ciphertext).toBe('string'); + 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', ); @@ -102,7 +104,8 @@ describe('oa-encrypt', () => { it('should produce ciphertext decryptable with the key entered by user', async () => { const utils = await import('../../../src/utils'); - const trustvc = await import('@trustvc/trustvc'); + const { decryptString } = await import('@trustvc/trustvc'); + const crypto = await import('crypto'); const actualUtils = await vi.importActual('../../../src/utils'); @@ -111,39 +114,26 @@ describe('oa-encrypt', () => { .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - const readMock = utils.readDocumentFile as MockedFunction; + const readMock = utils.readFile as MockedFunction; const writeMock = utils.writeFile as MockedFunction; - readMock.mockImplementation(actualUtils.readDocumentFile as unknown as any); + readMock.mockImplementation(actualUtils.readFile as unknown as any); await handler(); const payload = writeMock.mock.calls[0][1]; - const documentString = trustvc.decrypt(payload.ciphertext, TEST_KEY); - const document = JSON.parse(documentString); - const original = actualUtils.readDocumentFile(FIXTURE_DOC); - expect(document).toStrictEqual(original); - }); - - it('should throw when document is not a valid Open Attestation document', async () => { - const utils = await import('../../../src/utils'); - const signale = await import('signale'); - - (prompts.input as MockedFunction) - .mockResolvedValueOnce('./not-oa.json') - .mockResolvedValueOnce('./out.json'); - (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - - const readMock = utils.readDocumentFile as MockedFunction; - const errorMock = (signale.default as any).error as MockedFunction; - readMock.mockReturnValue({ foo: 'bar', notOA: true }); - - await expect(handler()).rejects.toThrow( - 'The document is not a valid Open Attestation document. Expected raw OA v2/v3 or wrapped OA v2/v3.', - ); - expect(errorMock).toHaveBeenCalled(); + 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 call signale.error and rethrow when readDocumentFile throws', async () => { + it('should call signale.error and rethrow when readFile throws', async () => { const utils = await import('../../../src/utils'); const signale = await import('signale'); @@ -152,7 +142,7 @@ describe('oa-encrypt', () => { .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - const readMock = utils.readDocumentFile as MockedFunction; + const readMock = utils.readFile as MockedFunction; const errorMock = (signale.default as any).error as MockedFunction; readMock.mockImplementationOnce(() => { throw new Error('File not found'); diff --git a/tests/fixtures/oa/did-dns-decrypted.json b/tests/fixtures/oa/did-dns-decrypted.json new file mode 100644 index 0000000..aca6890 --- /dev/null +++ b/tests/fixtures/oa/did-dns-decrypted.json @@ -0,0 +1,65 @@ +{ + "version": "https://schema.openattestation.com/2.0/schema.json", + "data": { + "id": "3280e86b-4caa-4243-b2d0-c5fa02189fe5:string:SGCNM21566325", + "$template": { + "name": "ade6c04b-84ff-4fae-9c92-913c60ce2991:string:CERTIFICATE_OF_NON_MANIPULATION", + "type": "9ac8bc3b-f036-4e49-80b9-645e22059996:string:EMBEDDED_RENDERER", + "url": "8fb2c9c4-a20c-4bf4-b639-bf3af16a6ec7:string:https://demo-cnm.openattestation.com" + }, + "issuers": [ + { + "id": "0fea2337-fd03-430c-8f03-f008dd92f156:string:did:ethr:0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69", + "name": "1ec2e081-e108-4435-9f8a-120d53de89f0:string:DEMO STORE", + "revocation": { + "type": "9eac45c9-06d6-4f83-ba20-0f4c7b55452f:string:NONE" + }, + "identityProof": { + "type": "2c5c6fe5-5061-4aac-8035-fe4d2f210862:string:DNS-DID", + "key": "a51ab9e9-7b1b-458b-ab88-455e694975f7:string:did:ethr:0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69#controller", + "location": "bb241fcf-11fa-4607-aa73-dfc86fefaa68:string:donotverify.testing.openattestation.com" + } + } + ], + "recipient": { + "name": "0151d7dc-3a80-4f45-b302-bfd9f13cfe59:string:SG FREIGHT", + "address": { + "street": "2c0935eb-ee9b-417d-81aa-6c30c2394245:string:101 ORCHARD ROAD", + "country": "15dfddf5-5776-4232-88b9-7c8c08f5fd1c:string:SINGAPORE" + } + }, + "consignment": { + "description": "c4f245cf-7255-4fd2-a1b8-b6ad766fe56d:string:16667 CARTONS OF RED WINE", + "quantity": { + "value": "013be6b5-cabd-42c2-b5b3-90a05e34366c:number:5000", + "unit": "96d4e88a-0db3-4dd0-b4f1-d24cb93a9ea5:string:LITRES" + }, + "countryOfOrigin": "e5ff63f1-74d4-4f7e-b5a4-d89f62d6b2dd:string:AUSTRALIA", + "outwardBillNo": "0fc1e46f-30af-4ff1-bcdf-fe6307f7bd18:string:AQSIQ170923130", + "dateOfDischarge": "43caa208-843c-4963-baf9-6a449a929c52:string:2018-01-26", + "dateOfDeparture": "c3b3e65a-9162-4658-864c-f5f6a1e8d485:string:2018-01-30", + "countryOfFinalDestination": "cf1b625b-f33b-4886-b7cb-2349e4ce88da:string:CHINA", + "outgoingVehicleNo": "7db188ee-68f8-4f29-9531-d11ea8ecca21:string:COSCO JAPAN 074E/30-JAN" + }, + "declaration": { + "name": "1db3a389-0053-4b59-a8a1-19e8ed53a55b:string:PETER LEE", + "designation": "9ccc4931-265f-4977-8a85-559287f44dc2:string:SHIPPING MANAGER", + "date": "a12d3d74-888b-44a4-9c4d-77a738bcf5d4:string:2018-01-28" + } + }, + "signature": { + "type": "SHA3MerkleProof", + "targetHash": "12e27173dcef6761c3eaa31a4a23412412e314abaec790eef1706e752fb8d51f", + "proof": [], + "merkleRoot": "12e27173dcef6761c3eaa31a4a23412412e314abaec790eef1706e752fb8d51f" + }, + "proof": [ + { + "type": "OpenAttestationSignature2018", + "created": "2020-10-05T09:05:35.171Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:ethr:0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69#controller", + "signature": "0x04b56b7a8405364cc925143c2fc28f91ee7a3e340402ba66e616f275110cbc446e140716f88f418581e02a371d246e1b98f07c937ee9b96d7bc8aebb470345c71b" + } + ] +} diff --git a/tests/fixtures/oa/did-dns-encrypted.json b/tests/fixtures/oa/did-dns-encrypted.json new file mode 100644 index 0000000..5b6f13a --- /dev/null +++ b/tests/fixtures/oa/did-dns-encrypted.json @@ -0,0 +1,6 @@ +{ + "cipherText": "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", + "iv": "VqV2hD/NOJXAmpj+", + "tag": "8sc7yDurFCN5KC6T+sryHw==", + "type": "OPEN-ATTESTATION-TYPE-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" } +} From 27ef0cfd58bd171f2bd70b2b95a9a767bbb89973 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Tue, 24 Feb 2026 22:47:19 +0530 Subject: [PATCH 6/9] feat: added the gracefull error handling --- src/commands/oa/decrypt.ts | 96 ++++++++++++++++++++++++------- src/commands/oa/encrypt.ts | 37 +++++++----- src/utils/cli-errors.ts | 52 +++++++++++++++++ src/utils/index.ts | 3 + tests/commands/oa/decrypt.test.ts | 59 +++++++++++++++---- tests/commands/oa/encrypt.test.ts | 33 ++++++++++- 6 files changed, 231 insertions(+), 49 deletions(-) create mode 100644 src/utils/cli-errors.ts diff --git a/src/commands/oa/decrypt.ts b/src/commands/oa/decrypt.ts index 6e9f449..c2007e8 100644 --- a/src/commands/oa/decrypt.ts +++ b/src/commands/oa/decrypt.ts @@ -3,7 +3,7 @@ import { input, password } from '@inquirer/prompts'; import crypto from 'crypto'; import signale from 'signale'; import { decryptString } from '@trustvc/trustvc'; -import { readDocumentFile } from '../../utils'; +import { readDocumentFile, getCliErrorMessage, isErrorWithMessage } from '../../utils'; /** Derive a 64-char hex key from passphrase for AES-256 (OPEN-ATTESTATION-TYPE-1). */ const deriveKey = (passphrase: string): string => @@ -22,6 +22,12 @@ type DecryptInput = { // 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:', @@ -57,34 +63,80 @@ export const promptForInputs = async (): Promise => { }; }; -export const handler = async (): Promise => { - try { - const answers = await promptForInputs(); - if (!answers) return; +type EncryptedPayload = { + cipherText: string; + iv: string; + tag: string; + type: string; +}; - const { inputEncryptedPath, outputPath, key } = answers; +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; - const encryptedPayload = readDocumentFile(inputEncryptedPath); - - const { cipherText, iv, tag, type } = encryptedPayload; - if (!cipherText || !iv || !tag || type !== ENCRYPTED_DOCUMENT_TYPE) { +/** 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( - 'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".', + 'Failed to decrypt document. The password/key is likely incorrect or the file is corrupted.', ); } + throw err; + } +} - const documentString = decryptString({ - cipherText, - iv, - tag, - key: deriveKey(key), - type, - }); +/** Loads encrypted file, validates, decrypts, writes plaintext and shows success message. */ +async function runDecrypt(answers: DecryptInput): Promise { + const { inputEncryptedPath, outputPath, key } = answers; + + const rawPayload = readDocumentFile(inputEncryptedPath); + const payload = validateEncryptedPayload(rawPayload); + const documentString = decryptPayload(payload, key); - fs.writeFileSync(outputPath, documentString, 'utf8'); - signale.success(`Decrypted document saved to: ${outputPath}`); + fs.writeFileSync(outputPath, documentString, 'utf8'); + signale.success(`Decrypted document saved to: ${outputPath}`); +} + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + await runDecrypt(answers); } catch (err: unknown) { - signale.error(err instanceof Error ? err.message : String(err)); - throw err; + signale.error(getCliErrorMessage(err, DECRYPT_ERROR_OPTIONS)); + process.exitCode = 1; } }; diff --git a/src/commands/oa/encrypt.ts b/src/commands/oa/encrypt.ts index ea7c982..63db752 100644 --- a/src/commands/oa/encrypt.ts +++ b/src/commands/oa/encrypt.ts @@ -2,7 +2,7 @@ import { input, password } from '@inquirer/prompts'; import crypto from 'crypto'; import signale from 'signale'; import { encryptString } from '@trustvc/trustvc'; -import { readFile, writeFile } from '../../utils'; +import { readFile, writeFile, getCliErrorMessage } from '../../utils'; /** Derive a 64-char hex key from passphrase for AES-256 (OPEN-ATTESTATION-TYPE-1). */ const deriveKey = (passphrase: string): string => @@ -53,22 +53,33 @@ export const promptForInputs = async (): Promise => { }; }; +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; + + const documentString = readFile(inputDocumentPath); + const { cipherText, iv, tag, type } = encryptString(documentString, deriveKey(key)); + + const encryptedPayload = { cipherText, iv, tag, type }; + writeFile(outputEncryptedPath, encryptedPayload, true); + + signale.success(`Encrypted document saved to: ${outputEncryptedPath}`); + 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; - - const { inputDocumentPath, outputEncryptedPath, key } = answers; - - const documentString = readFile(inputDocumentPath); - const { cipherText, iv, tag, type } = encryptString(documentString, deriveKey(key)); - - const encryptedPayload = { cipherText, iv, tag, type }; - writeFile(outputEncryptedPath, encryptedPayload, true); - signale.success(`Encrypted document saved to: ${outputEncryptedPath}`); - signale.warn('Remember the encryption key you entered — you will need it to decrypt.'); + await runEncrypt(answers); } catch (err: unknown) { - signale.error(err instanceof Error ? err.message : String(err)); - throw err; + 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/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 index e7e3927..61b00e5 100644 --- a/tests/commands/oa/decrypt.test.ts +++ b/tests/commands/oa/decrypt.test.ts @@ -41,6 +41,7 @@ describe('oa-decrypt', () => { vi.clearAllMocks(); vi.resetAllMocks(); vi.spyOn(fs, 'writeFileSync'); + process.exitCode = undefined; }); afterEach(() => { @@ -114,7 +115,7 @@ describe('oa-decrypt', () => { expect(fs.writeFileSync).toHaveBeenCalledWith('./decrypted.json', documentString, 'utf8'); }); - it('should throw when encrypted payload has wrong type', async () => { + 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'); @@ -127,13 +128,15 @@ describe('oa-decrypt', () => { .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - await expect(handler()).rejects.toThrow( + await handler(); + + expect(errorMock).toHaveBeenCalledWith( 'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".', ); - expect(errorMock).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - it('should throw when encrypted payload is missing cipherText', async () => { + 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' }); @@ -143,12 +146,12 @@ describe('oa-decrypt', () => { .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - await expect(handler()).rejects.toThrow( - 'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".', - ); + await handler(); + + expect(process.exitCode).toBe(1); }); - it('should throw when key is wrong', async () => { + 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 = @@ -166,11 +169,15 @@ describe('oa-decrypt', () => { .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce('wrong-key'); - await expect(handler()).rejects.toThrow('Error decrypting message'); - expect(errorMock).toHaveBeenCalled(); + 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 call signale.error and rethrow when readDocumentFile throws', async () => { + it('should log a friendly error and set exitCode when readDocumentFile throws', async () => { const utils = await import('../../../src/utils'); const signale = await import('signale'); @@ -185,8 +192,36 @@ describe('oa-decrypt', () => { .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - await expect(handler()).rejects.toThrow('File not found'); + 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 index 8c8feb1..e7a93c0 100644 --- a/tests/commands/oa/encrypt.test.ts +++ b/tests/commands/oa/encrypt.test.ts @@ -41,6 +41,7 @@ describe('oa-encrypt', () => { beforeEach(() => { vi.clearAllMocks(); vi.resetAllMocks(); + process.exitCode = undefined; }); afterEach(() => { @@ -133,7 +134,7 @@ describe('oa-encrypt', () => { expect(documentString).toStrictEqual(originalContent); }); - it('should call signale.error and rethrow when readFile throws', async () => { + it('should log a friendly error and set exitCode when readFile throws', async () => { const utils = await import('../../../src/utils'); const signale = await import('signale'); @@ -148,8 +149,36 @@ describe('oa-encrypt', () => { throw new Error('File not found'); }); - await expect(handler()).rejects.toThrow('File not found'); + 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'); + + (prompts.input as MockedFunction) + .mockResolvedValueOnce('/nonexistent.json') + .mockResolvedValueOnce('./out.json'); + (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); + + const readMock = utils.readFile 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; + }); + + await handler(); + + expect(errorMock).toHaveBeenCalledWith( + 'Unable to read input document. File not found at: /nonexistent.json', + ); + expect(process.exitCode).toBe(1); }); }); }); From eecf1fb2a21bd9a98550965eb6119e31fa099e01 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Tue, 24 Feb 2026 22:50:54 +0530 Subject: [PATCH 7/9] feat: remove example files --- tests/fixtures/oa/did-dns-decrypted.json | 65 ------------------------ tests/fixtures/oa/did-dns-encrypted.json | 6 --- 2 files changed, 71 deletions(-) delete mode 100644 tests/fixtures/oa/did-dns-decrypted.json delete mode 100644 tests/fixtures/oa/did-dns-encrypted.json diff --git a/tests/fixtures/oa/did-dns-decrypted.json b/tests/fixtures/oa/did-dns-decrypted.json deleted file mode 100644 index aca6890..0000000 --- a/tests/fixtures/oa/did-dns-decrypted.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "version": "https://schema.openattestation.com/2.0/schema.json", - "data": { - "id": "3280e86b-4caa-4243-b2d0-c5fa02189fe5:string:SGCNM21566325", - "$template": { - "name": "ade6c04b-84ff-4fae-9c92-913c60ce2991:string:CERTIFICATE_OF_NON_MANIPULATION", - "type": "9ac8bc3b-f036-4e49-80b9-645e22059996:string:EMBEDDED_RENDERER", - "url": "8fb2c9c4-a20c-4bf4-b639-bf3af16a6ec7:string:https://demo-cnm.openattestation.com" - }, - "issuers": [ - { - "id": "0fea2337-fd03-430c-8f03-f008dd92f156:string:did:ethr:0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69", - "name": "1ec2e081-e108-4435-9f8a-120d53de89f0:string:DEMO STORE", - "revocation": { - "type": "9eac45c9-06d6-4f83-ba20-0f4c7b55452f:string:NONE" - }, - "identityProof": { - "type": "2c5c6fe5-5061-4aac-8035-fe4d2f210862:string:DNS-DID", - "key": "a51ab9e9-7b1b-458b-ab88-455e694975f7:string:did:ethr:0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69#controller", - "location": "bb241fcf-11fa-4607-aa73-dfc86fefaa68:string:donotverify.testing.openattestation.com" - } - } - ], - "recipient": { - "name": "0151d7dc-3a80-4f45-b302-bfd9f13cfe59:string:SG FREIGHT", - "address": { - "street": "2c0935eb-ee9b-417d-81aa-6c30c2394245:string:101 ORCHARD ROAD", - "country": "15dfddf5-5776-4232-88b9-7c8c08f5fd1c:string:SINGAPORE" - } - }, - "consignment": { - "description": "c4f245cf-7255-4fd2-a1b8-b6ad766fe56d:string:16667 CARTONS OF RED WINE", - "quantity": { - "value": "013be6b5-cabd-42c2-b5b3-90a05e34366c:number:5000", - "unit": "96d4e88a-0db3-4dd0-b4f1-d24cb93a9ea5:string:LITRES" - }, - "countryOfOrigin": "e5ff63f1-74d4-4f7e-b5a4-d89f62d6b2dd:string:AUSTRALIA", - "outwardBillNo": "0fc1e46f-30af-4ff1-bcdf-fe6307f7bd18:string:AQSIQ170923130", - "dateOfDischarge": "43caa208-843c-4963-baf9-6a449a929c52:string:2018-01-26", - "dateOfDeparture": "c3b3e65a-9162-4658-864c-f5f6a1e8d485:string:2018-01-30", - "countryOfFinalDestination": "cf1b625b-f33b-4886-b7cb-2349e4ce88da:string:CHINA", - "outgoingVehicleNo": "7db188ee-68f8-4f29-9531-d11ea8ecca21:string:COSCO JAPAN 074E/30-JAN" - }, - "declaration": { - "name": "1db3a389-0053-4b59-a8a1-19e8ed53a55b:string:PETER LEE", - "designation": "9ccc4931-265f-4977-8a85-559287f44dc2:string:SHIPPING MANAGER", - "date": "a12d3d74-888b-44a4-9c4d-77a738bcf5d4:string:2018-01-28" - } - }, - "signature": { - "type": "SHA3MerkleProof", - "targetHash": "12e27173dcef6761c3eaa31a4a23412412e314abaec790eef1706e752fb8d51f", - "proof": [], - "merkleRoot": "12e27173dcef6761c3eaa31a4a23412412e314abaec790eef1706e752fb8d51f" - }, - "proof": [ - { - "type": "OpenAttestationSignature2018", - "created": "2020-10-05T09:05:35.171Z", - "proofPurpose": "assertionMethod", - "verificationMethod": "did:ethr:0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69#controller", - "signature": "0x04b56b7a8405364cc925143c2fc28f91ee7a3e340402ba66e616f275110cbc446e140716f88f418581e02a371d246e1b98f07c937ee9b96d7bc8aebb470345c71b" - } - ] -} diff --git a/tests/fixtures/oa/did-dns-encrypted.json b/tests/fixtures/oa/did-dns-encrypted.json deleted file mode 100644 index 5b6f13a..0000000 --- a/tests/fixtures/oa/did-dns-encrypted.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "cipherText": "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", - "iv": "VqV2hD/NOJXAmpj+", - "tag": "8sc7yDurFCN5KC6T+sryHw==", - "type": "OPEN-ATTESTATION-TYPE-1" -} From a07c6a322af820b07c6d230151c2b9d209bb3996 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Wed, 25 Feb 2026 17:00:07 +0530 Subject: [PATCH 8/9] fix: updated the file handling error and folder creation for outputs --- src/commands/oa/decrypt.ts | 21 +++++++++--- src/commands/oa/encrypt.ts | 21 +++++++++--- src/utils/file-io.ts | 55 +++++++++++++++++++++++++++++++ tests/commands/oa/decrypt.test.ts | 8 ++++- tests/commands/oa/encrypt.test.ts | 29 ++++++++-------- 5 files changed, 110 insertions(+), 24 deletions(-) diff --git a/src/commands/oa/decrypt.ts b/src/commands/oa/decrypt.ts index c2007e8..d6f5641 100644 --- a/src/commands/oa/decrypt.ts +++ b/src/commands/oa/decrypt.ts @@ -3,7 +3,14 @@ import { input, password } from '@inquirer/prompts'; import crypto from 'crypto'; import signale from 'signale'; import { decryptString } from '@trustvc/trustvc'; -import { readDocumentFile, getCliErrorMessage, isErrorWithMessage } from '../../utils'; +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 => @@ -34,7 +41,7 @@ export const promptForInputs = async (): Promise => { required: true, validate: (value: string) => { if (!value || value.trim() === '') return 'Encrypted document path is required'; - return true; + return validateInputFileExists(value); }, }); @@ -122,12 +129,18 @@ function decryptPayload(payload: EncryptedPayload, key: string): string { 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); - fs.writeFileSync(outputPath, documentString, 'utf8'); - signale.success(`Decrypted document saved to: ${outputPath}`); + 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 => { diff --git a/src/commands/oa/encrypt.ts b/src/commands/oa/encrypt.ts index 63db752..5ef1ecd 100644 --- a/src/commands/oa/encrypt.ts +++ b/src/commands/oa/encrypt.ts @@ -2,7 +2,14 @@ import { input, password } from '@inquirer/prompts'; import crypto from 'crypto'; import signale from 'signale'; import { encryptString } from '@trustvc/trustvc'; -import { readFile, writeFile, getCliErrorMessage } from '../../utils'; +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 => @@ -24,7 +31,7 @@ export const promptForInputs = async (): Promise => { required: true, validate: (value: string) => { if (!value || value.trim() === '') return 'Document path is required'; - return true; + return validateInputFileExists(value); }, }); @@ -63,13 +70,19 @@ const ENCRYPT_ERROR_OPTIONS = { 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 }; - writeFile(outputEncryptedPath, encryptedPayload, true); + const { path: outputPath, generated } = resolveOutputJsonPath(outputEncryptedPath, 'encrypted'); + writeFile(outputPath, encryptedPayload, true); - signale.success(`Encrypted document saved to: ${outputEncryptedPath}`); + 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.'); } 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/tests/commands/oa/decrypt.test.ts b/tests/commands/oa/decrypt.test.ts index 61b00e5..d7586de 100644 --- a/tests/commands/oa/decrypt.test.ts +++ b/tests/commands/oa/decrypt.test.ts @@ -26,7 +26,13 @@ vi.mock('@inquirer/prompts', () => ({ vi.mock('../../../src/utils', async () => { const actual = await vi.importActual('../../../src/utils'); - return { ...actual, readDocumentFile: vi.fn() }; + 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!!'; diff --git a/tests/commands/oa/encrypt.test.ts b/tests/commands/oa/encrypt.test.ts index e7a93c0..037ee27 100644 --- a/tests/commands/oa/encrypt.test.ts +++ b/tests/commands/oa/encrypt.test.ts @@ -34,6 +34,9 @@ vi.mock('../../../src/utils', async () => { ...actual, readFile: vi.fn(), writeFile: vi.fn(), + ensureInputFileExists: vi.fn(), + validateInputFileExists: vi.fn().mockReturnValue(true), + resolveOutputJsonPath: (givenPath: string) => ({ path: givenPath, generated: false }), }; }); @@ -143,19 +146,20 @@ describe('oa-encrypt', () => { .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - const readMock = utils.readFile as MockedFunction; - const errorMock = (signale.default as any).error as MockedFunction; - readMock.mockImplementationOnce(() => { - throw new Error('File not found'); + 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'); + expect(errorMock).toHaveBeenCalledWith('File not found: /nonexistent.json'); expect(process.exitCode).toBe(1); }); - it('should show a file-not-found message when underlying error is ENOENT', async () => { + 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'); @@ -164,20 +168,15 @@ describe('oa-encrypt', () => { .mockResolvedValueOnce('./out.json'); (prompts.password as MockedFunction).mockResolvedValueOnce(TEST_KEY); - const readMock = utils.readFile as MockedFunction; + const ensureMock = utils.ensureInputFileExists 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; + ensureMock.mockImplementationOnce(() => { + throw new Error('File not found: /nonexistent.json'); }); await handler(); - expect(errorMock).toHaveBeenCalledWith( - 'Unable to read input document. File not found at: /nonexistent.json', - ); + expect(errorMock).toHaveBeenCalledWith('File not found: /nonexistent.json'); expect(process.exitCode).toBe(1); }); }); From 7f9712c64770bfa4ad3531f9ccbddd4aa2729e0b Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 26 Feb 2026 10:08:32 +0530 Subject: [PATCH 9/9] feat: updated packages --- package-lock.json | 18 ++++++++++++++---- package.json | 6 +++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2510b94..cf2371b 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.8.0", + "@trustvc/trustvc": "^2.10.0", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "ethers": "^6.15.0", @@ -4248,9 +4248,9 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/trustvc": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.8.0.tgz", - "integrity": "sha512-9ALOVQkkNFc7Ngx/Lm/FMP6kqWpWI6wTSSNeDrb81vaRl51uQ8RcUW4ErFHNrfGJkiR0WrjxnGEHNRFSnWlYtw==", + "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", @@ -4269,6 +4269,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": { @@ -4349,6 +4350,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", diff --git a/package.json b/package.json index 6f690ef..7494f0a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.8.0", + "@trustvc/trustvc": "^2.10.0", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "ethers": "^6.15.0", @@ -54,12 +54,12 @@ "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", "typescript": "^5.5.4", - "vitest": "^1.6.0", - "husky": "^9.0.11" + "vitest": "^1.6.0" }, "repository": { "type": "git",