Skip to content

Commit e3874cd

Browse files
authored
Merge pull request #18 from TrustVC/feat/TT-907-encrypt-decrypt-oa-functions
Feat/tt 907 encrypt decrypt oa functions
2 parents 0f5c523 + 1d996ce commit e3874cd

12 files changed

Lines changed: 909 additions & 11 deletions

File tree

.husky/pre-commit

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env sh
2+
npm run lint
3+
npm run format:check

README.md

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A comprehensive command-line interface for managing W3C Verifiable Credentials,
88
-**Key Pair Generation**: Generate cryptographic key pairs with Multikey format
99
-**DID Management**: Create and manage did:web identifiers
1010
-**W3C Verifiable Credentials**: Sign, verify and manage W3C verifiable credentials
11-
-**OpenAttestation**: Sign, verify and wrap/unwrap OpenAttestation v2/v3 documents
11+
-**OpenAttestation**: Sign, verify, wrap/unwrap, and encrypt/decrypt OpenAttestation v2/v3 documents
1212
-**Token Registry**: Mint tokens to blockchain-based token registries
1313
-**Document Store**: Deploy and manage document store contracts
1414
-**Title Escrow**: Complete transferable records management (holder/beneficiary transfers)
@@ -90,6 +90,12 @@ trustvc oa-wrap
9090

9191
# Unwrap an OpenAttestation document
9292
trustvc oa-unwrap
93+
94+
# Encrypt an Open Attestation document for safe sharing
95+
trustvc oa-encrypt
96+
97+
# Decrypt an encrypted Open Attestation document
98+
trustvc oa-decrypt
9399
```
94100

95101
### Wallet Management
@@ -176,6 +182,8 @@ trustvc title-escrow reject-transfer-owner-holder
176182

177183
- **Document Unwrapping**: Uses `unwrapOA` to unwrap OpenAttestation documents.
178184

185+
- **Document Encryption**: Uses `oa-encrypt` to encrypt OA documents for safe sharing; use `oa-decrypt` with the same key to recover the document.
186+
179187
### Blockchain Operations
180188

181189
- **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
200208
| | [`verify`](#verify) | Verify OpenAttestation documents |
201209
| | [`oa-wrap`](#oa-wrap) | Wrap OpenAttestation documents |
202210
| | [`oa-unwrap`](#oa-unwrap) | Unwrap OpenAttestation documents |
203-
| **Token Registry** | [`token-registry deploy`](#token-registry-deploy) | Deploy token registry contracts |
211+
| | [`oa-encrypt`](#oa-encrypt) | Encrypt an OA document for safe sharing and storage |
212+
| | [`oa-decrypt`](#oa-decrypt) | Decrypt an OA document encrypted with oa-encrypt |
213+
| **Token Registry** | [`mint`](#mint) | Mint tokens to blockchain registries |
214+
| | [`token-registry deploy`](#token-registry-deploy) | Deploy token registry contracts |
204215
| | [`mint`](#mint) | Mint tokens to blockchain registries |
205216
| | `token-registry mint` | Alternative: `mint` |
206217
| **Document Store** | [`document-store deploy`](#document-store-deploy) | Deploy document store contracts |
@@ -479,6 +490,57 @@ Unwrapped OpenAttestation document(s) in the specified directory.
479490

480491
</details>
481492

493+
<details>
494+
<summary><h4 id="oa-encrypt">oa-encrypt</h4></summary>
495+
496+
Encrypts an Open Attestation document for safe sharing and storage. You will be prompted for an encryption key — remember it to decrypt later.
497+
498+
**Usage:**
499+
500+
```sh
501+
trustvc oa-encrypt
502+
```
503+
504+
**Interactive Prompts:**
505+
506+
- Path to your Open Attestation document (raw or wrapped OA v2/v3)
507+
- Path for the output encrypted file
508+
- Encryption key (entered securely; not echoed)
509+
510+
**Output:**
511+
512+
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`.
513+
514+
**Supported Input:**
515+
516+
- OpenAttestation v2 (raw or wrapped)
517+
- OpenAttestation v3 (raw or wrapped)
518+
519+
</details>
520+
521+
<details>
522+
<summary><h4 id="oa-decrypt">oa-decrypt</h4></summary>
523+
524+
Decrypts an Open Attestation document that was encrypted using `oa-encrypt`. You will be prompted for the decryption key.
525+
526+
**Usage:**
527+
528+
```sh
529+
trustvc oa-decrypt
530+
```
531+
532+
**Interactive Prompts:**
533+
534+
- Path to the encrypted document file
535+
- Path for the output decrypted document
536+
- Decryption key (entered securely; not echoed)
537+
538+
**Output:**
539+
540+
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.
541+
542+
</details>
543+
482544
<details>
483545
<summary><h4 id="mint">mint</h4></summary>
484546

@@ -1158,9 +1220,11 @@ npm test
11581220
```
11591221
src/commands/
11601222
├── oa/
1161-
│ └── sign.ts # Sign OpenAttestation documents
1162-
│ └── wrap.ts # Wrap OpenAttestation documents
1163-
│ └── unwrap.ts # Unwrap OpenAttestation documents
1223+
│ ├── sign.ts # Sign OpenAttestation documents
1224+
│ ├── wrap.ts # Wrap OpenAttestation documents
1225+
│ ├── unwrap.ts # Unwrap OpenAttestation documents
1226+
│ ├── encrypt.ts # Encrypt OA documents for safe sharing
1227+
│ └── decrypt.ts # Decrypt OA documents
11641228
├── token-registry/
11651229
│ ├── deploy.ts # Deploy token registry contracts
11661230
│ └── mint.ts # Mint tokens to registry

package-lock.json

Lines changed: 31 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"start": "node dist/main.js",
2323
"dev": "npm run build && npm start",
2424
"build": "npm run clean && tsup",
25-
"clean": "rm -rf dist/"
25+
"clean": "rm -rf dist/",
26+
"prepare": "husky"
2627
},
2728
"keywords": [
2829
"trustvc"
@@ -34,7 +35,7 @@
3435
},
3536
"dependencies": {
3637
"@inquirer/prompts": "^5.3.8",
37-
"@trustvc/trustvc": "^2.9.1",
38+
"@trustvc/trustvc": "^2.10.0",
3839
"@types/yargs": "^17.0.32",
3940
"chalk": "^4.1.2",
4041
"ethers": "^6.15.0",
@@ -53,6 +54,7 @@
5354
"eslint": "^8.57.0",
5455
"eslint-config-prettier": "^10.1.8",
5556
"eslint-formatter-table": "^7.32.1",
57+
"husky": "^9.0.11",
5658
"prettier": "^3.7.4",
5759
"tsup": "^8.2.4",
5860
"tsx": "^4.19.1",

src/commands/oa/decrypt.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import fs from 'fs';
2+
import { input, password } from '@inquirer/prompts';
3+
import crypto from 'crypto';
4+
import signale from 'signale';
5+
import { decryptString } from '@trustvc/trustvc';
6+
import {
7+
readDocumentFile,
8+
getCliErrorMessage,
9+
isErrorWithMessage,
10+
ensureInputFileExists,
11+
resolveOutputJsonPath,
12+
validateInputFileExists,
13+
} from '../../utils';
14+
15+
/** Derive a 64-char hex key from passphrase for AES-256 (OPEN-ATTESTATION-TYPE-1). */
16+
const deriveKey = (passphrase: string): string =>
17+
crypto.createHash('sha256').update(passphrase, 'utf8').digest('hex');
18+
19+
export const command = 'oa-decrypt';
20+
export const describe =
21+
'Decrypt a document that was encrypted using oa-encrypt. You will be asked for the decryption key.';
22+
23+
type DecryptInput = {
24+
inputEncryptedPath: string;
25+
outputPath: string;
26+
key: string;
27+
};
28+
29+
// Payload format: OPEN-ATTESTATION-TYPE-1 (cipherText, iv, tag, type)
30+
const ENCRYPTED_DOCUMENT_TYPE = 'OPEN-ATTESTATION-TYPE-1';
31+
32+
/** Message thrown by @trustvc/trustvc when decryption fails (wrong key or corrupted data). */
33+
const DECRYPT_FAILED_LIBRARY_MESSAGE = 'Error decrypting message';
34+
35+
const INVALID_PAYLOAD_MESSAGE =
36+
'Invalid encrypted document: expected cipherText, iv, tag and type "OPEN-ATTESTATION-TYPE-1".';
37+
38+
export const promptForInputs = async (): Promise<DecryptInput | null> => {
39+
const inputEncryptedPath = await input({
40+
message: 'Enter the path to the encrypted document:',
41+
required: true,
42+
validate: (value: string) => {
43+
if (!value || value.trim() === '') return 'Encrypted document path is required';
44+
return validateInputFileExists(value);
45+
},
46+
});
47+
48+
const outputPath = await input({
49+
message: 'Enter the path to save the decrypted document:',
50+
required: true,
51+
validate: (value: string) => {
52+
if (!value || value.trim() === '') return 'Output path is required';
53+
return true;
54+
},
55+
});
56+
57+
const key = await password({
58+
message: 'Enter the decryption key:',
59+
mask: '*',
60+
validate: (value: string) => {
61+
if (!value || value.trim() === '') return 'Decryption key is required';
62+
return true;
63+
},
64+
});
65+
66+
return {
67+
inputEncryptedPath: inputEncryptedPath.trim(),
68+
outputPath: outputPath.trim(),
69+
key: key.trim(),
70+
};
71+
};
72+
73+
type EncryptedPayload = {
74+
cipherText: string;
75+
iv: string;
76+
tag: string;
77+
type: string;
78+
};
79+
80+
const DECRYPT_ERROR_OPTIONS = {
81+
defaultMessage: 'An unexpected error occurred while decrypting the document.',
82+
fileNotFound: 'Unable to read encrypted document. File not found at: {path}',
83+
permissionDenied: 'Permission denied. Cannot write to: {path}',
84+
invalidJson: (msg: string) => `Invalid encrypted file: the file is not valid JSON. ${msg}`,
85+
} as const;
86+
87+
/** Validates raw payload and returns typed fields or throws with a clear message. */
88+
function validateEncryptedPayload(payload: unknown): EncryptedPayload {
89+
if (
90+
payload === null ||
91+
typeof payload !== 'object' ||
92+
!('cipherText' in payload) ||
93+
!('iv' in payload) ||
94+
!('tag' in payload) ||
95+
!('type' in payload)
96+
) {
97+
throw new Error(INVALID_PAYLOAD_MESSAGE);
98+
}
99+
const { cipherText, iv, tag, type } = payload as EncryptedPayload;
100+
if (
101+
typeof cipherText !== 'string' ||
102+
typeof iv !== 'string' ||
103+
typeof tag !== 'string' ||
104+
type !== ENCRYPTED_DOCUMENT_TYPE
105+
) {
106+
throw new Error(INVALID_PAYLOAD_MESSAGE);
107+
}
108+
return { cipherText, iv, tag, type };
109+
}
110+
111+
/** Decrypts payload with derived key; rethrows a user-friendly error on library failure. */
112+
function decryptPayload(payload: EncryptedPayload, key: string): string {
113+
try {
114+
return decryptString({
115+
...payload,
116+
key: deriveKey(key),
117+
});
118+
} catch (err: unknown) {
119+
if (isErrorWithMessage(err) && err.message === DECRYPT_FAILED_LIBRARY_MESSAGE) {
120+
throw new Error(
121+
'Failed to decrypt document. The password/key is likely incorrect or the file is corrupted.',
122+
);
123+
}
124+
throw err;
125+
}
126+
}
127+
128+
/** Loads encrypted file, validates, decrypts, writes plaintext and shows success message. */
129+
async function runDecrypt(answers: DecryptInput): Promise<void> {
130+
const { inputEncryptedPath, outputPath, key } = answers;
131+
132+
ensureInputFileExists(inputEncryptedPath);
133+
const rawPayload = readDocumentFile(inputEncryptedPath);
134+
const payload = validateEncryptedPayload(rawPayload);
135+
const documentString = decryptPayload(payload, key);
136+
137+
const { path: outputFilePath, generated } = resolveOutputJsonPath(outputPath, 'decrypted');
138+
fs.writeFileSync(outputFilePath, documentString, 'utf8');
139+
if (generated) {
140+
signale.success(`No output filename provided. Decrypted document saved to: ${outputFilePath}`);
141+
} else {
142+
signale.success(`Decrypted document saved to: ${outputFilePath}`);
143+
}
144+
}
145+
146+
export const handler = async (): Promise<void> => {
147+
try {
148+
const answers = await promptForInputs();
149+
if (!answers) return;
150+
await runDecrypt(answers);
151+
} catch (err: unknown) {
152+
signale.error(getCliErrorMessage(err, DECRYPT_ERROR_OPTIONS));
153+
process.exitCode = 1;
154+
}
155+
};

0 commit comments

Comments
 (0)