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" }
+}