Build a Secure Message CLI with TypeScript
Build a CLI that signs, verifies, and encrypts messages using Ed25519 and AES-256-GCM.
Introduction
30 Second Summary
Every time you send a private message, connect to a server with SSH, or visit a website over HTTPS, cryptographic primitives are working behind the scenes. Most developers rely on these systems daily without ever seeing how they actually work.
In this project, you will build a command-line tool that uses the same cryptographic primitives powering SSH and HTTPS to sign, verify, encrypt, and decrypt messages between two identities.
What You'll Build
A TypeScript CLI called secure-msg that generates key pairs, signs messages with digital signatures, and encrypts messages using Diffie-Hellman key exchange and AES-256-GCM.
By the end of this project, you'll have:
- Generated Ed25519 key pairs stored as PEM files, the same format SSH and TLS use.
- A working digital signature flow that proves who sent a message and detects tampering.
- Encrypted messaging between two identities using X25519 key exchange and AES-256-GCM authenticated encryption.
- Secret Mission: A sign-then-encrypt protocol that combines authentication and confidentiality into a single step.
Are there any prerequisites?
You should have basic TypeScript or JavaScript knowledge, Node.js installed on your machine, and VS Code ready to go. No prior cryptography experience is needed.
Set Up the TypeScript Project
Before writing any cryptographic code, you need a TypeScript project configured to handle modern ES modules and strict type checking. TypeScript's strict mode catches type errors at compile time, which matters when dealing with Buffers, key formats, and crypto APIs.
You'll use tsx to run TypeScript files directly without a separate compilation step. This keeps the development loop fast while still giving you full type safety.
In this step, get ready to:
- Create the project directory and install TypeScript dependencies.
- Configure TypeScript compilation with strict mode and ES2022 target.
- Set up the source file structure and verify the project runs.
Create the project and install dependencies
You need three development dependencies for this project. The TypeScript compiler handles type checking, tsx runs TypeScript files directly using esbuild, and @types/node provides type definitions for Node.js APIs like the crypto module.
- In VS Code, open the integrated terminal by pressing Ctrl+`.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir secure-msg
cd secure-msg
- Initialize the project and install your dependencies by running these commands:
npm init -y
npm install --save-dev typescript@7.0.2 tsx@4.23.0 @types/node@22.15.0
You should see output confirming that packages were added. The terminal displays the number of packages installed and the time taken.
Seeing an error during install?
Make sure you have an active internet connection. If you see a permission error, try running the command with sudo on macOS or as Administrator on Windows.
If you see a version not found error, double check you typed the exact version numbers: typescript@7.0.2, tsx@4.23.0, @types/node@22.15.0.
Help me troubleshoot my npm install error
- Open the secure-msg folder in VS Code by clicking File in the menu bar.
- Click Open Folder.
- Select the secure-msg folder on your Desktop, then click Open.
VS Code reloads with secure-msg as your workspace. You should see package.json and a node_modules folder in the Explorer sidebar.
The default package.json that npm init -y created has extra fields you don't need. You'll replace it with a clean version that adds ES module support and a CLI script.
- Open package.json in the Explorer sidebar.
- Select all the content and replace it with the following:
{
"name": "secure-msg",
"version": "1.0.0",
"type": "module",
"scripts": {
"cli": "tsx src/cli.ts"
},
"devDependencies": {
"typescript": "7.0.2",
"tsx": "4.23.0",
"@types/node": "22.15.0"
}
}
What does this configuration do?
- "type": "module" tells Node.js to treat .js files as ES modules, enabling import/export syntax instead of require().
- The "cli" script lets you run your CLI with npm run cli as a shortcut for npx tsx src/cli.ts.
- All three packages are devDependencies because they're only needed during development, not at runtime.
- Save package.json.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
{
"name": "secure-msg",
"version": "1.0.0",
"type": "module",
"scripts": {
"cli": "tsx src/cli.ts"
},
"devDependencies": {
"typescript": "7.0.2",
"tsx": "4.23.0",
"@types/node": "22.15.0"
}
}
Configure TypeScript compilation
TypeScript needs a configuration file to know how to compile your code. The settings you'll use enable strict type checking and target modern JavaScript features that Node.js already supports.
- Create a new file in the root of your secure-msg folder by clicking the new file icon in the Explorer sidebar.
- Name it tsconfig.json.
- Paste the following configuration:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
What do these settings mean?
- "strict": true enables all strict type-checking options. This catches potential bugs early, especially important when working with cryptographic APIs that are sensitive to data types.
- "target": "ES2022" and "module": "ES2022" tell TypeScript to output modern JavaScript that Node.js natively understands.
- "moduleResolution": "bundler" allows importing files with .js extensions, which is required for ES module projects.
- "rootDir": "./src" means all source code lives in the src/ folder. "include": ["src"] tells TypeScript to only compile files inside that folder.
- Save tsconfig.json.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
Create the source file structure and verify the setup
Your CLI will be split across four source files, each responsible for one area of cryptography. For now, you'll create them as placeholders and add a simple print statement to confirm everything runs.
- Open the integrated terminal in VS Code by pressing Ctrl+`.
- Create the src/ directory and empty source files by running these commands:
mkdir src
touch src/keys.ts src/sign.ts src/encrypt.ts src/cli.ts
- Confirm the files were created by running:
ls src
You should see cli.ts, encrypt.ts, keys.ts, and sign.ts listed in the output.
What is each file for?
- keys.ts will handle generating and storing cryptographic key pairs.
- sign.ts will handle digital signatures (proving who sent a message).
- encrypt.ts will handle encryption and decryption (keeping messages secret).
- cli.ts is the entry point that ties all the commands together.
Now add a placeholder to cli.ts so you can verify the project runs correctly.
- Open src/cli.ts in the Explorer sidebar.
- Add the following code:
// placeholder - prints help menu
console.log('Secure Message CLI');
- Save src/cli.ts.
- Verify the setup works by running this command in the terminal:
npx tsx src/cli.ts
You should see Secure Message CLI printed in the terminal.
Not seeing the expected output?
If you see a "Cannot find module" error, make sure you saved src/cli.ts and that the file is inside the src/ folder.
If you see a "command not found" error for npx, your terminal may not be in the secure-msg directory. Run pwd to check your current location.
Help me figure out why npx tsx src/cli.ts isn't working
✔️ Awesome, I've got everything!
Great. Double check you've saved src/cli.ts before moving on.
ⓧ I'd like to double check the full code
// placeholder - prints help menu
console.log('Secure Message CLI');
Your project foundation is solid. Next up, you'll write actual cryptographic code to generate key pairs that work just like SSH keys.
Generate and Store Key Pairs
Your project scaffolding is in place. Now it's time to give your CLI the ability to create cryptographic identities.
Every secure messaging system starts with key pairs. In this step, you'll generate two types: Ed25519 keys for signing (proving who sent a message) and X25519 keys for encryption (keeping messages secret). These are the same algorithms that power SSH and TLS 1.3.
In this step, get ready to:
- Generate Ed25519 signing key pairs and export them as PEM files.
- Generate X25519 encryption key pairs using the Web Crypto API.
- Wire up the keygen command so running it creates 4 key files per identity.
Implement Ed25519 signing key generation
Digital signatures let you prove who authored a message. Ed25519 is the algorithm SSH uses by default for this purpose. Your signing module needs to generate a key pair, then export the keys in PEM format so they're portable and human-readable.
- In VS Code, open src/keys.ts from the Explorer sidebar and replace its placeholder content with this code:
import { generateKeyPairSync } from "node:crypto";
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
// Directory where all key files are stored
const KEYS_DIR = "./keys";
// Access the Web Crypto API for X25519 operations later
const { subtle } = globalThis.crypto;
// Create the keys/ directory if it doesn't exist yet
export function ensureKeysDir(): void {
if (!existsSync(KEYS_DIR)) {
mkdirSync(KEYS_DIR, { recursive: true });
}
}
// Generate an Ed25519 signing key pair in PEM format
export function generateSigningKeyPair(): { publicKey: string; privateKey: string } {
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
return { publicKey, privateKey };
}
What does this code do?
The imports pull in Node.js built-in modules for cryptography and file system access. No external packages needed.
KEYS_DIR sets a consistent location for all key files. ensureKeysDir() creates that folder on first run so the file-writing functions don't fail.
generateSigningKeyPair() calls Node's built-in generateKeyPairSync('ed25519') and exports the public key as SPKI and the private key as PKCS8. These are standard formats that interoperate with OpenSSL and SSH tooling.
- Save keys.ts.
You won't see output yet. The next chunk adds encryption key generation.
Seeing a red underline on the imports?
Make sure your tsconfig.json has "module": "ES2022" and "moduleResolution": "bundler". Without these, TypeScript won't resolve node:crypto correctly.
Also confirm @types/node is listed in your package.json devDependencies and that you ran npm install in the previous step.
help me fix TypeScript import errors for node:crypto in my keys.ts file
Implement X25519 encryption key generation
Signing proves who sent a message, but doesn't hide its contents. For confidentiality, you need a separate key pair designed for key agreement. X25519 is the modern elliptic curve used in TLS 1.3 and the Signal Protocol for exactly this purpose.
- In src/keys.ts, add this function below generateSigningKeyPair():
// Generate an X25519 encryption key pair using the Web Crypto API
export async function generateEncryptionKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
const keyPair = await subtle.generateKey(
{ name: "X25519" },
true,
["deriveKey", "deriveBits"]
);
// Export public key as raw 32 bytes, private as PKCS8 structure
const pubRaw = Buffer.from(await subtle.exportKey("raw", keyPair.publicKey));
const privRaw = Buffer.from(await subtle.exportKey("pkcs8", keyPair.privateKey));
return {
publicKey: pubRaw.toString("base64"),
privateKey: privRaw.toString("base64"),
};
}
Why the Web Crypto API instead of Node's crypto?
Node's generateKeyPairSync works great for Ed25519 signing keys. But for X25519 key agreement, the Web Crypto API (subtle.generateKey) is the officially documented path. It handles the elliptic curve math and lets you derive shared secrets later with subtle.deriveBits().
The function is async because Web Crypto operations return Promises. The raw export gives you compact 32-byte keys stored as base64 strings.
- Save keys.ts.
You won't see a change yet. The next substep wires both key generators into a single command.
Type error on subtle.generateKey?
If TypeScript complains about the X25519 algorithm name, confirm your tsconfig.json targets ES2022 and that @types/node version 22.15.0 is installed. Older type definitions may not include X25519 support.
help me fix the X25519 type error in my generateEncryptionKeyPair function
Wire up the keygen command
You have functions that generate both types of key pairs. Now you need a saveKeyPair() function that calls both generators and writes four files to disk, plus a CLI command that triggers it.
- In src/keys.ts, add these two functions below generateEncryptionKeyPair():
// Orchestrate key generation: create both signing and encryption pairs, write to disk
export async function saveKeyPair(name: string): Promise<void> {
ensureKeysDir();
const signing = generateSigningKeyPair();
const encryption = await generateEncryptionKeyPair();
writeFileSync(join(KEYS_DIR, `${name}.sign.pub`), signing.publicKey);
writeFileSync(join(KEYS_DIR, `${name}.sign.key`), signing.privateKey);
writeFileSync(join(KEYS_DIR, `${name}.enc.pub`), encryption.publicKey);
writeFileSync(join(KEYS_DIR, `${name}.enc.key`), encryption.privateKey);
console.log(`Key pairs generated for "${name}":`);
console.log(` Signing: keys/${name}.sign.pub / .sign.key`);
console.log(` Encryption: keys/${name}.enc.pub / .enc.key`);
}
// Read a key file from disk and return its contents
export function loadKey(path: string): string {
return readFileSync(path, "utf-8");
}
What does saveKeyPair do?
saveKeyPair() is the orchestrator. It calls both generateSigningKeyPair() and generateEncryptionKeyPair(), then writes four files per identity: a signing public/private pair and an encryption public/private pair.
loadKey() is a utility that reads any key file back from disk. You'll use it in later steps when signing and encrypting.
- Save keys.ts.
The key generation logic is complete. Now update cli.ts to expose it as a command.
- Open src/cli.ts and replace its entire contents with this code:
import { saveKeyPair, loadKey } from "./keys.js";
// Parse the command and arguments from the terminal input
const [command, ...args] = process.argv.slice(2);
async function main(): Promise<void> {
switch (command) {
case "keygen": {
// Use provided name or fall back to "default"
const name = args[0] || "default";
await saveKeyPair(name);
break;
}
default:
console.log(`
Secure Message CLI
Commands:
keygen [name] Generate key pairs
`);
}
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});
What does this CLI structure do?
process.argv.slice(2) grabs everything the learner types after npx tsx src/cli.ts. The first word becomes the command, and the rest become args.
The switch block routes to the right handler. Right now only keygen is wired up. The default case prints a help menu when no recognized command is given. You'll add more commands in later steps.
- Save cli.ts.
- Generate Alice's identity by running this command in your terminal:
npx tsx src/cli.ts keygen alice
- You should see output confirming the key files were created:
Key pairs generated for "alice":
Signing: keys/alice.sign.pub / .sign.key
Encryption: keys/alice.enc.pub / .enc.key
- Generate Bob's identity by running:
npx tsx src/cli.ts keygen bob
- Confirm all 8 key files exist by running:
ls keys/
You should see 8 files listed: alice.enc.key, alice.enc.pub, alice.sign.key, alice.sign.pub, bob.enc.key, bob.enc.pub, bob.sign.key, bob.sign.pub.
Seeing "Error: Cannot find module ./keys.js"?
The import path in cli.ts uses ./keys.js (with the .js extension), even though the source file is keys.ts. This is required for ESM resolution. The tsx runner handles the mapping at runtime.
If you still see the error, confirm that src/keys.ts exists and is saved with all the functions from this step.
help me fix the Cannot find module ./keys.js error in my secure-msg CLI
✔️ Awesome, I've got everything!
Great. Double check you've saved both src/keys.ts and src/cli.ts.
ⓧ I'd like to double check the full code
Here is the complete src/keys.ts at this point:
import { generateKeyPairSync } from "node:crypto";
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
const KEYS_DIR = "./keys";
const { subtle } = globalThis.crypto;
export function ensureKeysDir(): void {
if (!existsSync(KEYS_DIR)) {
mkdirSync(KEYS_DIR, { recursive: true });
}
}
export function generateSigningKeyPair(): { publicKey: string; privateKey: string } {
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
return { publicKey, privateKey };
}
export async function generateEncryptionKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
const keyPair = await subtle.generateKey(
{ name: "X25519" },
true,
["deriveKey", "deriveBits"]
);
const pubRaw = Buffer.from(await subtle.exportKey("raw", keyPair.publicKey));
const privRaw = Buffer.from(await subtle.exportKey("pkcs8", keyPair.privateKey));
return {
publicKey: pubRaw.toString("base64"),
privateKey: privRaw.toString("base64"),
};
}
export async function saveKeyPair(name: string): Promise<void> {
ensureKeysDir();
const signing = generateSigningKeyPair();
const encryption = await generateEncryptionKeyPair();
writeFileSync(join(KEYS_DIR, `${name}.sign.pub`), signing.publicKey);
writeFileSync(join(KEYS_DIR, `${name}.sign.key`), signing.privateKey);
writeFileSync(join(KEYS_DIR, `${name}.enc.pub`), encryption.publicKey);
writeFileSync(join(KEYS_DIR, `${name}.enc.key`), encryption.privateKey);
console.log(`Key pairs generated for "${name}":`);
console.log(` Signing: keys/${name}.sign.pub / .sign.key`);
console.log(` Encryption: keys/${name}.enc.pub / .enc.key`);
}
export function loadKey(path: string): string {
return readFileSync(path, "utf-8");
}
And here is the complete src/cli.ts at this point:
import { saveKeyPair, loadKey } from "./keys.js";
const [command, ...args] = process.argv.slice(2);
async function main(): Promise<void> {
switch (command) {
case "keygen": {
const name = args[0] || "default";
await saveKeyPair(name);
break;
}
default:
console.log(`
Secure Message CLI
Commands:
keygen [name] Generate key pairs
`);
}
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});
You now have two complete cryptographic identities on disk. Next up, you'll use Alice's signing key to prove she authored a message and detect any tampering.
Sign and Verify Messages
You have key pairs for both Alice and Bob, but keys alone are just random numbers sitting in files. The real power of Ed25519 comes when you use those keys to prove who authored a message.
A digital signature is a cryptographic stamp only the private key holder can produce. Anyone with the matching public key can verify it. In this step, you will build the signing and verification functions that make this possible.
In this step, get ready to:
- Implement signing and verification functions in src/sign.ts.
- Wire up sign and verify commands in the CLI.
- Test that signatures prove authorship and detect tampering.
Implement the signMessage function
When you sign a message with Ed25519, the algorithm takes your private key and the message bytes, then produces a unique 64-byte signature. Only your private key can produce that signature, but anyone with your public key can verify it.
- In VS Code, open src/sign.ts (currently empty).
- Add the import and the signMessage function by pasting this code:
import { sign, verify } from "node:crypto";
export function signMessage(message: string, privateKeyPem: string): string {
// Convert the message string to a Buffer of raw bytes
const data = Buffer.from(message, "utf-8");
// Ed25519 has built-in hashing, so the first arg (algorithm) is null
const signature = sign(null, data, privateKeyPem);
// Return the signature as a base64 string for easy transport
return signature.toString("base64");
}
What does this code do?
The sign(null, data, privateKeyPem) call passes null as the algorithm because Ed25519 includes its own internal hashing. You do not need to specify SHA-256 or any other digest separately.
The PEM string you pass in is the same PKCS8-encoded private key you generated in the previous step. Node.js automatically detects the key type from the PEM header and applies Ed25519 signing.
Implement the verifyMessage function
Verification is the mirror of signing. It takes the original message, the signature, and the signer's public key, then returns a simple true or false.
- Add the verifyMessage function below signMessage in src/sign.ts:
export function verifyMessage(
message: string,
signatureBase64: string,
publicKeyPem: string
): boolean {
// Convert inputs back to Buffers for the crypto API
const data = Buffer.from(message, "utf-8");
const signature = Buffer.from(signatureBase64, "base64");
// Returns true if the signature matches, false otherwise
return verify(null, data, publicKeyPem, signature);
}
What does this code do?
The verify function reconstructs the signature from base64 and checks it against the public key. If the message was altered by even one character, the mathematical relationship breaks and verify returns false.
This is how SSH knows a server is who it claims to be. The server signs a challenge with its private key, and your SSH client verifies the signature against the known public key.
- Save src/sign.ts.
Seeing a red underline in VS Code?
Make sure you imported both sign and verify from "node:crypto" on the first line. If only one is imported, the other function call will show as undefined.
help me fix a TypeScript error in my sign.ts file
✔️ Awesome, I've got everything!
Great. Double check you have saved src/sign.ts before moving on.
ⓧ I'd like to double check the full code
import { sign, verify } from "node:crypto";
export function signMessage(message: string, privateKeyPem: string): string {
const data = Buffer.from(message, "utf-8");
const signature = sign(null, data, privateKeyPem);
return signature.toString("base64");
}
export function verifyMessage(
message: string,
signatureBase64: string,
publicKeyPem: string
): boolean {
const data = Buffer.from(message, "utf-8");
const signature = Buffer.from(signatureBase64, "base64");
return verify(null, data, publicKeyPem, signature);
}
Wire up sign and verify commands in the CLI
The functions exist, but the CLI does not know about them yet. You need to import them and add two new case blocks to the switch statement in src/cli.ts.
- In src/cli.ts, find this line at the top:
import { saveKeyPair, loadKey } from "./keys.js";
- Add this import directly below it:
import { signMessage, verifyMessage } from "./sign.js";
- Find the closing } of the case "keygen" block. Add the sign case directly after it:
case "sign": {
const message = args[0];
const keyPath = args[1] || "./keys/default.sign.key";
if (!message) {
console.error("Usage: sign <message> [key-path]");
process.exit(1);
}
const privateKey = loadKey(keyPath);
const signature = signMessage(message, privateKey);
console.log("Signature:", signature);
break;
}
What does this code do?
The sign command takes a message as the first argument and an optional key path as the second. It loads the private key from disk, calls signMessage, and prints the resulting base64 signature.
The key path defaults to ./keys/default.sign.key, but you will override it with Alice's or Bob's specific key file when testing.
- Add the verify case directly after the sign case:
case "verify": {
const message = args[0];
const signature = args[1];
const keyPath = args[2] || "./keys/default.sign.pub";
if (!message || !signature) {
console.error("Usage: verify <message> <signature> [pubkey-path]");
process.exit(1);
}
const publicKey = loadKey(keyPath);
const valid = verifyMessage(message, signature, publicKey);
console.log(valid ? "Valid signature" : "INVALID signature");
break;
}
What does this code do?
The verify command takes a message, a base64 signature, and an optional public key path. It prints either Valid signature or INVALID signature based on the result.
This is the moment of truth. If the signature was produced by the matching private key and the message has not changed, you get Valid signature. Anything else fails.
- Finally, update the default case's help text. Find the Commands: section inside the template literal and replace it so the full default block looks like this:
default:
console.log(`
Secure Message CLI
Commands:
keygen [name] Generate key pairs
sign <message> [key-path] Sign a message
verify <message> <signature> [pubkey-path] Verify a signature
`);
- Save src/cli.ts.
- Verify the help menu works by running:
npx tsx src/cli.ts
You should see the updated help text listing keygen, sign, and verify as available commands.
Seeing a module not found error?
Make sure the import path uses ./sign.js (with the .js extension), not ./sign.ts. TypeScript ESM requires .js extensions in import paths even when the source file is .ts.
help me fix a module not found error in my cli.ts imports
✔️ Awesome, I've got everything!
Great. Double check you have saved src/cli.ts before moving on.
ⓧ I'd like to double check the full code
import { saveKeyPair, loadKey } from "./keys.js";
import { signMessage, verifyMessage } from "./sign.js";
const [command, ...args] = process.argv.slice(2);
async function main(): Promise<void> {
switch (command) {
case "keygen": {
const name = args[0] || "default";
await saveKeyPair(name);
break;
}
case "sign": {
const message = args[0];
const keyPath = args[1] || "./keys/default.sign.key";
if (!message) {
console.error("Usage: sign <message> [key-path]");
process.exit(1);
}
const privateKey = loadKey(keyPath);
const signature = signMessage(message, privateKey);
console.log("Signature:", signature);
break;
}
case "verify": {
const message = args[0];
const signature = args[1];
const keyPath = args[2] || "./keys/default.sign.pub";
if (!message || !signature) {
console.error("Usage: verify <message> <signature> [pubkey-path]");
process.exit(1);
}
const publicKey = loadKey(keyPath);
const valid = verifyMessage(message, signature, publicKey);
console.log(valid ? "Valid signature" : "INVALID signature");
break;
}
default:
console.log(`
Secure Message CLI
Commands:
keygen [name] Generate key pairs
sign <message> [key-path] Sign a message
verify <message> <signature> [pubkey-path] Verify a signature
`);
}
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});
Test the full signing and verification flow
Now for the real test. You will sign a message as Alice, then verify it with Alice's public key (should pass), then verify it with Bob's public key (should fail).
- Sign the message hello using Alice's private signing key by running:
npx tsx src/cli.ts sign "hello" ./keys/alice.sign.key
You should see output like Signature: followed by a long base64 string. Copy that entire base64 string (everything after Signature: ).
- Verify the signature with Alice's public key by running this command (replace the signature placeholder with the base64 string you copied):
npx tsx src/cli.ts verify "hello" [[SIGNATURE="paste your base64 signature here"]] ./keys/alice.sign.pub
You should see Valid signature printed. Alice's public key confirms that Alice's private key produced this signature.
- Now try verifying the same signature with Bob's public key:
npx tsx src/cli.ts verify "hello" [[SIGNATURE="paste your base64 signature here"]] ./keys/bob.sign.pub
You should see INVALID signature. Bob's key did not produce this signature, so verification fails.
- Finally, test tamper detection. Try verifying the original signature against a different message:
npx tsx src/cli.ts verify "hello!" [[SIGNATURE="paste your base64 signature here"]] ./keys/alice.sign.pub
Even with Alice's correct public key, you see INVALID signature because the message changed. One extra character is enough to break the mathematical proof.
Why does this matter?
You just demonstrated the two guarantees that digital signatures provide. Authentication means the signature proves who sent the message. Integrity means any modification to the message invalidates the signature.
Every SSH connection and every HTTPS handshake relies on this same mechanism to prevent impersonation and tampering.
Seeing INVALID for Alice's own signature?
Make sure you copied the full base64 signature with no trailing spaces or line breaks. Also ensure the message string in the verify command matches exactly what you signed (including quotes and casing).
help me debug why my signature verification is failing
Your CLI can now prove authorship and detect tampering. Next up, you will add encryption so that messages are unreadable to everyone except the intended recipient.
Encrypt and Decrypt Messages
Digital signatures prove who sent a message, but they don't hide what it says. Anyone who intercepts a signed message can read it in plain text.
In this step, you'll add confidentiality using the same pattern as HTTPS: a Diffie-Hellman key agreement to compute a shared secret, then AES-256-GCM to encrypt the message so only the intended recipient can read it.
In this step, get ready to:
- Compute shared secrets using X25519 key agreement.
- Encrypt and decrypt messages with AES-256-GCM.
- Test encrypted messaging between Alice and Bob.
Implement X25519 key import helpers
The X25519 encryption keys you generated in Step 2 are stored as raw base64 strings. To use them with the Web Crypto API for key agreement, you need to import them back into CryptoKey objects.
- In VS Code, open src/encrypt.ts and add the following code:
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
const { subtle } = globalThis.crypto;
async function importX25519Private(base64Key: string): Promise<CryptoKey> {
const keyData = Buffer.from(base64Key, "base64");
return subtle.importKey(
"pkcs8",
keyData,
{ name: "X25519" },
false,
["deriveBits"]
);
}
async function importX25519Public(base64Key: string): Promise<CryptoKey> {
const keyData = Buffer.from(base64Key, "base64");
return subtle.importKey(
"raw",
keyData,
{ name: "X25519" },
false,
[]
);
}
What does this code do?
These two helper functions convert raw base64 key strings back into CryptoKey objects that the Web Crypto API can work with.
- importX25519Private uses the pkcs8 format and grants the key permission to deriveBits (compute shared secrets).
- importX25519Public uses the raw format and has no key usage permissions (public keys are inputs to the derivation, not active participants).
You won't see a change yet. The next chunk uses these helpers to compute a shared secret.
Derive a shared secret with X25519
X25519 key agreement is the magic that lets two people compute the same shared secret without ever sending it over the wire. Alice uses her private key and Bob's public key. Bob uses his private key and Alice's public key. Both arrive at the same 256-bit value.
- Add this function below importX25519Public in src/encrypt.ts:
async function deriveSharedSecret(
privateKeyBase64: string,
publicKeyBase64: string
): Promise<Buffer> {
const privateKey = await importX25519Private(privateKeyBase64);
const publicKey = await importX25519Public(publicKeyBase64);
const sharedBits = await subtle.deriveBits(
{ name: "X25519", public: publicKey },
privateKey,
256
);
return Buffer.from(sharedBits);
}
What does this code do?
subtle.deriveBits() performs the X25519 elliptic curve computation. It takes one party's private key and the other party's public key, then outputs exactly 256 bits (32 bytes) of shared secret material.
This 32-byte output maps perfectly to AES-256, which requires a 256-bit key. No key stretching or hashing needed.
Why is this called Diffie-Hellman?
X25519 is a modern elliptic-curve version of the Diffie-Hellman key exchange protocol from 1976. The core idea is the same: two parties can agree on a secret over an insecure channel without ever transmitting the secret itself. TLS 1.3, Signal, and WireGuard all use X25519 for this step.
You won't see a change yet. The next chunk uses this shared secret to encrypt actual messages.
Implement encrypt and decrypt functions
With the shared secret ready, you can now encrypt messages using AES-256-GCM. GCM stands for Galois/Counter Mode. It provides both confidentiality (nobody can read the message) and integrity (nobody can tamper with it without detection).
- Add the encryptMessage function below deriveSharedSecret in src/encrypt.ts:
export async function encryptMessage(
message: string,
recipientPublicKeyBase64: string,
senderPrivateKeyBase64: string
): Promise<string> {
const sharedSecret = await deriveSharedSecret(
senderPrivateKeyBase64,
recipientPublicKeyBase64
);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", sharedSecret, iv);
const encrypted = Buffer.concat([
cipher.update(message, "utf-8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
const packed = Buffer.concat([iv, authTag, encrypted]);
return packed.toString("base64");
}
What does this code do?
- randomBytes(12) generates a 12-byte initialization vector (IV). AES-GCM requires a unique IV for each encryption to stay secure.
- createCipheriv("aes-256-gcm", sharedSecret, iv) creates the cipher using the 32-byte shared secret as the encryption key.
- getAuthTag() returns a 16-byte authentication tag. This tag is what GCM uses to detect tampering during decryption.
- The final output packs IV (12 bytes) + authTag (16 bytes) + ciphertext into a single base64 string. The recipient needs all three pieces to decrypt.
You won't see a change yet. The decrypt function below completes the pair.
- Add the decryptMessage function below encryptMessage in src/encrypt.ts:
export async function decryptMessage(
encryptedBase64: string,
senderPublicKeyBase64: string,
recipientPrivateKeyBase64: string
): Promise<string> {
const sharedSecret = await deriveSharedSecret(
recipientPrivateKeyBase64,
senderPublicKeyBase64
);
const packed = Buffer.from(encryptedBase64, "base64");
const iv = packed.subarray(0, 12);
const authTag = packed.subarray(12, 28);
const ciphertext = packed.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", sharedSecret, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return decrypted.toString("utf-8");
}
What does this code do?
- deriveSharedSecret is called with the keys flipped: the recipient's private key and the sender's public key. X25519 guarantees both sides arrive at the same secret.
- The packed buffer is sliced back into its three components using the known offsets: IV (bytes 0-11), authTag (bytes 12-27), ciphertext (bytes 28+).
- setAuthTag() tells AES-GCM what tag to check against. If any byte was tampered with, decipher.final() throws an error instead of returning corrupted data.
- Save src/encrypt.ts.
Seeing a TypeScript error?
Make sure both encryptMessage and decryptMessage have the export keyword. The helper functions (importX25519Private, importX25519Public, deriveSharedSecret) should NOT be exported.
Help me debug my encrypt.ts file
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
const { subtle } = globalThis.crypto;
async function importX25519Private(base64Key: string): Promise<CryptoKey> {
const keyData = Buffer.from(base64Key, "base64");
return subtle.importKey(
"pkcs8",
keyData,
{ name: "X25519" },
false,
["deriveBits"]
);
}
async function importX25519Public(base64Key: string): Promise<CryptoKey> {
const keyData = Buffer.from(base64Key, "base64");
return subtle.importKey(
"raw",
keyData,
{ name: "X25519" },
false,
[]
);
}
async function deriveSharedSecret(
privateKeyBase64: string,
publicKeyBase64: string
): Promise<Buffer> {
const privateKey = await importX25519Private(privateKeyBase64);
const publicKey = await importX25519Public(publicKeyBase64);
const sharedBits = await subtle.deriveBits(
{ name: "X25519", public: publicKey },
privateKey,
256
);
return Buffer.from(sharedBits);
}
export async function encryptMessage(
message: string,
recipientPublicKeyBase64: string,
senderPrivateKeyBase64: string
): Promise<string> {
const sharedSecret = await deriveSharedSecret(
senderPrivateKeyBase64,
recipientPublicKeyBase64
);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", sharedSecret, iv);
const encrypted = Buffer.concat([
cipher.update(message, "utf-8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
const packed = Buffer.concat([iv, authTag, encrypted]);
return packed.toString("base64");
}
export async function decryptMessage(
encryptedBase64: string,
senderPublicKeyBase64: string,
recipientPrivateKeyBase64: string
): Promise<string> {
const sharedSecret = await deriveSharedSecret(
recipientPrivateKeyBase64,
senderPublicKeyBase64
);
const packed = Buffer.from(encryptedBase64, "base64");
const iv = packed.subarray(0, 12);
const authTag = packed.subarray(12, 28);
const ciphertext = packed.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", sharedSecret, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return decrypted.toString("utf-8");
}
Wire up encrypt and decrypt commands
The encryption logic is ready. Now you need to expose it through the CLI so you can test it from the terminal.
- In src/cli.ts, find the import lines at the top of the file:
import { saveKeyPair, loadKey } from "./keys.js";
import { signMessage, verifyMessage } from "./sign.js";
- Add the import for encrypt.js directly below them, so the top of the file now looks like this:
import { saveKeyPair, loadKey } from "./keys.js";
import { signMessage, verifyMessage } from "./sign.js";
import { encryptMessage, decryptMessage } from "./encrypt.js";
- Next, find the default: case inside the switch statement. Add the encrypt and decrypt cases directly above default::
case "encrypt": {
const message = args[0];
const recipientPubPath = args[1];
const senderKeyPath = args[2] || "./keys/default.enc.key";
if (!message || !recipientPubPath) {
console.error("Usage: encrypt <message> <recipient-pub-path> [sender-key-path]");
process.exit(1);
}
const recipientPub = loadKey(recipientPubPath).trim();
const senderKey = loadKey(senderKeyPath).trim();
const encrypted = await encryptMessage(message, recipientPub, senderKey);
console.log("Encrypted:", encrypted);
break;
}
case "decrypt": {
const encrypted = args[0];
const senderPubPath = args[1];
const recipientKeyPath = args[2] || "./keys/default.enc.key";
if (!encrypted || !senderPubPath) {
console.error("Usage: decrypt <encrypted> <sender-pub-path> [recipient-key-path]");
process.exit(1);
}
const senderPub = loadKey(senderPubPath).trim();
const recipientKey = loadKey(recipientKeyPath).trim();
const decrypted = await decryptMessage(encrypted, senderPub, recipientKey);
console.log("Decrypted:", decrypted);
break;
}
What does this code do?
Both cases follow the same pattern you used for sign and verify: read arguments, validate them, load key files, call the function, and print the result.
- The encrypt command needs the recipient's public encryption key (so only they can decrypt) and uses the sender's private key for the key agreement.
- The decrypt command needs the sender's public key (to recompute the same shared secret) and uses the recipient's private key.
- .trim() strips any trailing newline from the key files. Base64 keys are sensitive to whitespace.
- Finally, update the default case's help text. Find the existing help output:
default:
console.log(`
Secure Message CLI
Commands:
keygen [name] Generate key pairs
sign <message> [key-path] Sign a message
verify <message> <signature> [pubkey-path] Verify a signature
`);
- Replace it with this updated version that includes the new commands:
default:
console.log(`
Secure Message CLI
Commands:
keygen [name] Generate key pairs
sign <message> [key-path] Sign a message
verify <message> <signature> [pubkey-path] Verify a signature
encrypt <message> <recipient-pub> [key] Encrypt for recipient
decrypt <encrypted> <sender-pub> [key] Decrypt a message
`);
- Save src/cli.ts.
- Verify the help menu shows the new commands by running:
npx tsx src/cli.ts
You should see all five commands listed: keygen, sign, verify, encrypt, and decrypt.
Seeing a module or import error?
Check that your import path is ./encrypt.js (with the .js extension), not ./encrypt.ts. ESM modules require the .js extension even for TypeScript files.
Help me fix my import error in cli.ts
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on to testing.
ⓧ I'd like to double check the full code
import { saveKeyPair, loadKey } from "./keys.js";
import { signMessage, verifyMessage } from "./sign.js";
import { encryptMessage, decryptMessage } from "./encrypt.js";
const [command, ...args] = process.argv.slice(2);
async function main(): Promise<void> {
switch (command) {
case "keygen": {
const name = args[0] || "default";
await saveKeyPair(name);
break;
}
case "sign": {
const message = args[0];
const keyPath = args[1] || "./keys/default.sign.key";
if (!message) {
console.error("Usage: sign <message> [key-path]");
process.exit(1);
}
const privateKey = loadKey(keyPath);
const signature = signMessage(message, privateKey);
console.log("Signature:", signature);
break;
}
case "verify": {
const message = args[0];
const signature = args[1];
const keyPath = args[2] || "./keys/default.sign.pub";
if (!message || !signature) {
console.error("Usage: verify <message> <signature> [pubkey-path]");
process.exit(1);
}
const publicKey = loadKey(keyPath);
const valid = verifyMessage(message, signature, publicKey);
console.log(valid ? "Valid signature" : "INVALID signature");
break;
}
case "encrypt": {
const message = args[0];
const recipientPubPath = args[1];
const senderKeyPath = args[2] || "./keys/default.enc.key";
if (!message || !recipientPubPath) {
console.error("Usage: encrypt <message> <recipient-pub-path> [sender-key-path]");
process.exit(1);
}
const recipientPub = loadKey(recipientPubPath).trim();
const senderKey = loadKey(senderKeyPath).trim();
const encrypted = await encryptMessage(message, recipientPub, senderKey);
console.log("Encrypted:", encrypted);
break;
}
case "decrypt": {
const encrypted = args[0];
const senderPubPath = args[1];
const recipientKeyPath = args[2] || "./keys/default.enc.key";
if (!encrypted || !senderPubPath) {
console.error("Usage: decrypt <encrypted> <sender-pub-path> [recipient-key-path]");
process.exit(1);
}
const senderPub = loadKey(senderPubPath).trim();
const recipientKey = loadKey(recipientKeyPath).trim();
const decrypted = await decryptMessage(encrypted, senderPub, recipientKey);
console.log("Decrypted:", decrypted);
break;
}
default:
console.log(`
Secure Message CLI
Commands:
keygen [name] Generate key pairs
sign <message> [key-path] Sign a message
verify <message> <signature> [pubkey-path] Verify a signature
encrypt <message> <recipient-pub> [key] Encrypt for recipient
decrypt <encrypted> <sender-pub> [key] Decrypt a message
`);
}
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});
Test the full encrypt and decrypt flow
Time to prove that only the intended recipient can read a message. You'll encrypt a message from Alice to Bob, have Bob decrypt it, then show that Alice herself cannot decrypt it (because she doesn't have Bob's private key).
- Encrypt a message from Alice to Bob by running:
npx tsx src/cli.ts encrypt "secret message" ./keys/bob.enc.pub ./keys/alice.enc.key
You should see a long base64 string after Encrypted:. This is the IV + authTag + ciphertext packed together. It's completely unreadable without Bob's private key.
- Copy the entire base64 string from the output (everything after Encrypted: ).
- Decrypt it as Bob by running:
npx tsx src/cli.ts decrypt "[[CIPHERTEXT="paste your encrypted base64 string here"]]" ./keys/alice.enc.pub ./keys/bob.enc.key
You should see Decrypted: secret message. Bob successfully recovered the original plaintext.
- Now try decrypting with Alice's key instead of Bob's to prove that only the recipient can read the message:
npx tsx src/cli.ts decrypt "[[CIPHERTEXT="paste your encrypted base64 string here"]]" ./keys/alice.enc.pub ./keys/alice.enc.key
This should fail with an error. Alice's private key combined with Alice's public key produces a different shared secret than what was used to encrypt. AES-GCM detects the mismatch and rejects the decryption.
Why does Alice's own decryption fail?
The message was encrypted using the shared secret derived from Alice's private key + Bob's public key. To decrypt, you need the same shared secret, which can only be recomputed using Bob's private key + Alice's public key.
Using Alice's private key + Alice's public key produces a completely different 256-bit value. AES-GCM's authentication tag catches this mismatch and throws an error rather than returning garbage data.
Getting an error on the encrypt command?
Make sure you have key files for both Alice and Bob in the keys/ directory. If they're missing, regenerate them with npx tsx src/cli.ts keygen alice and npx tsx src/cli.ts keygen bob.
Also check that you're wrapping the message in quotes. Without quotes, the shell splits multi-word messages into separate arguments.
Help me debug my encrypt or decrypt command
You now have a working encrypted messaging system. Alice can send confidential messages that only Bob can read. Next up, you'll bring it all together in a demo script that simulates a full secure conversation and demonstrates tamper detection.
Simulate a Secure Conversation
You now have a fully working CLI that can generate keys, sign messages, verify signatures, encrypt, and decrypt.
Individual commands are great, but real secure communication involves multiple steps happening in sequence. In this step, you will create a demo script that simulates a full conversation between Alice and Bob, showing every cryptographic operation in action.
In this step, get ready to:
- Create a demo script that generates two identities and runs the full sign-encrypt-decrypt flow.
- Display key fingerprints and raw ciphertext to visualize the encryption.
- Demonstrate tamper detection by modifying ciphertext and watching AES-GCM reject it.
Create the demo script with identity generation and signing
The demo script ties together everything you have built so far. It creates two identities from scratch and walks through a full conversation.
- Create a new file called src/demo.ts and add the following code:
import { createHash } from "node:crypto";
import { saveKeyPair, loadKey } from "./keys.js";
import { signMessage, verifyMessage } from "./sign.js";
import { encryptMessage, decryptMessage } from "./encrypt.js";
// Create a short identifier from a public key for display
function fingerprint(keyBase64: string): string {
const hash = createHash("sha256").update(keyBase64).digest("hex");
return hash.substring(0, 8);
}
async function main(): Promise<void> {
console.log("=== Generating Identities ===\n");
await saveKeyPair("alice");
await saveKeyPair("bob");
// Load all keys needed for the conversation
const aliceSignKey = loadKey("./keys/alice.sign.key");
const aliceSignPub = loadKey("./keys/alice.sign.pub");
const aliceEncKey = loadKey("./keys/alice.enc.key").trim();
const aliceEncPub = loadKey("./keys/alice.enc.pub").trim();
const bobEncKey = loadKey("./keys/bob.enc.key").trim();
const bobEncPub = loadKey("./keys/bob.enc.pub").trim();
// Display fingerprints so each party can verify identity
console.log(`\nAlice's encryption fingerprint: ${fingerprint(aliceEncPub)}`);
console.log(`Bob's encryption fingerprint: ${fingerprint(bobEncPub)}`);
console.log("\n=== Alice Signs a Message ===\n");
const message = "Hey Bob, let's meet at 3pm tomorrow.";
const signature = signMessage(message, aliceSignKey);
console.log(`Message: "${message}"`);
console.log(`Signature: ${signature.substring(0, 40)}...`);
const isValid = verifyMessage(message, signature, aliceSignPub);
console.log(`Verified with Alice's key: ${isValid}`);
}
What does this code do?
The fingerprint() function hashes a public key with SHA-256 and takes the first 8 hex characters. This gives each identity a short, recognizable ID similar to how SSH shows key fingerprints when you connect to a server.
Inside main(), the script generates fresh key pairs for both Alice and Bob, then loads all six key files needed for the conversation. The signing section proves Alice authored the message by creating and verifying a digital signature.
- Save src/demo.ts.
Add encryption and decryption output
Signing proves who sent a message, but anyone can read it. Encryption makes the message unreadable to everyone except the intended recipient.
- In src/demo.ts, find the closing } of the main() function. Add the following code just above it:
console.log("\n=== Alice Encrypts for Bob ===\n");
const encrypted = await encryptMessage(message, bobEncPub, aliceEncKey);
console.log(`Ciphertext: ${encrypted.substring(0, 50)}...`);
console.log("(Unreadable without Bob's private key)");
console.log("\n=== Bob Decrypts ===\n");
const decrypted = await decryptMessage(encrypted, aliceEncPub, bobEncKey);
console.log(`Decrypted: "${decrypted}"`);
What does this code do?
Alice encrypts using Bob's public encryption key and her own private encryption key. The X25519 key agreement means both parties compute the same shared secret without ever transmitting it.
The ciphertext is printed as a truncated base64 string to show it is completely unreadable. Bob then decrypts using Alice's public key and his own private key, recovering the original plaintext.
- Save src/demo.ts.
Demonstrate tamper detection
Encryption alone proves confidentiality. But what if someone intercepts the ciphertext and flips a bit? AES-256-GCM provides authenticated encryption, meaning any modification to the ciphertext is detected and rejected.
- In src/demo.ts, add this code below the decryption output line (still inside main()):
console.log("\n=== Tamper Detection ===\n");
// Modify one character of the ciphertext to simulate an attack
const tampered = encrypted.substring(0, 10) + "X" + encrypted.substring(11);
try {
await decryptMessage(tampered, aliceEncPub, bobEncKey);
console.log("ERROR: Tampered message was accepted!");
} catch {
console.log("Tampered message REJECTED (authentication failed)");
console.log("AES-GCM detected the modification.");
}
console.log("\n=== Done ===");
}
Why does changing one character break everything?
AES-GCM includes an authentication tag computed over the entire ciphertext. When you change even a single byte, the tag no longer matches and decryptMessage() throws an error. This is the "integrity" guarantee that separates authenticated encryption from basic encryption.
This same mechanism protects every HTTPS connection you use. If a network attacker modifies data in transit, AES-GCM detects it and drops the connection.
- Finally, add the entry point at the very bottom of src/demo.ts, after the closing brace of main():
main().catch(console.error);
- Save src/demo.ts.
- Run the demo by executing this command:
npx tsx src/demo.ts
You should see output showing each stage: identity generation, signing, encryption (a wall of unreadable base64), decryption (the original message recovered), and tamper detection (the modified ciphertext rejected).
Seeing an error about missing key files?
The demo script generates fresh keys for Alice and Bob each time it runs. If you see an error about file paths, make sure you are running the command from the secure-msg project root (the folder containing package.json).
If you see a type error about catch, make sure you are using the catch { syntax without a parameter variable.
Still stuck? Help me debug my demo.ts script
✔️ Awesome, I've got everything!
Great. Double check you have saved src/demo.ts before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/demo.ts file:
import { createHash } from "node:crypto";
import { saveKeyPair, loadKey } from "./keys.js";
import { signMessage, verifyMessage } from "./sign.js";
import { encryptMessage, decryptMessage } from "./encrypt.js";
function fingerprint(keyBase64: string): string {
const hash = createHash("sha256").update(keyBase64).digest("hex");
return hash.substring(0, 8);
}
async function main(): Promise<void> {
console.log("=== Generating Identities ===\n");
await saveKeyPair("alice");
await saveKeyPair("bob");
const aliceSignKey = loadKey("./keys/alice.sign.key");
const aliceSignPub = loadKey("./keys/alice.sign.pub");
const aliceEncKey = loadKey("./keys/alice.enc.key").trim();
const aliceEncPub = loadKey("./keys/alice.enc.pub").trim();
const bobEncKey = loadKey("./keys/bob.enc.key").trim();
const bobEncPub = loadKey("./keys/bob.enc.pub").trim();
console.log(`\nAlice's encryption fingerprint: ${fingerprint(aliceEncPub)}`);
console.log(`Bob's encryption fingerprint: ${fingerprint(bobEncPub)}`);
console.log("\n=== Alice Signs a Message ===\n");
const message = "Hey Bob, let's meet at 3pm tomorrow.";
const signature = signMessage(message, aliceSignKey);
console.log(`Message: "${message}"`);
console.log(`Signature: ${signature.substring(0, 40)}...`);
const isValid = verifyMessage(message, signature, aliceSignPub);
console.log(`Verified with Alice's key: ${isValid}`);
console.log("\n=== Alice Encrypts for Bob ===\n");
const encrypted = await encryptMessage(message, bobEncPub, aliceEncKey);
console.log(`Ciphertext: ${encrypted.substring(0, 50)}...`);
console.log("(Unreadable without Bob's private key)");
console.log("\n=== Bob Decrypts ===\n");
const decrypted = await decryptMessage(encrypted, aliceEncPub, bobEncKey);
console.log(`Decrypted: "${decrypted}"`);
console.log("\n=== Tamper Detection ===\n");
const tampered = encrypted.substring(0, 10) + "X" + encrypted.substring(11);
try {
await decryptMessage(tampered, aliceEncPub, bobEncKey);
console.log("ERROR: Tampered message was accepted!");
} catch {
console.log("Tampered message REJECTED (authentication failed)");
console.log("AES-GCM detected the modification.");
}
console.log("\n=== Done ===");
}
main().catch(console.error);
You have just simulated the same cryptographic flow that protects every SSH session and HTTPS connection. Your CLI now demonstrates the complete picture: identity, authentication, confidentiality, and integrity.
Secret mission
Sign-Then-Encrypt Protocol
Right now your CLI can sign OR encrypt, but not both at once. Real secure messaging systems like Signal combine both operations into a single protocol step. Can you build a command that proves who sent the message AND keeps it confidential?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files to continue experimenting, or delete them to free up disk space.
Resources you used:
- The secure-msg project folder on your Desktop (source code, node_modules, and generated key files).
Keep everything running
No action needed. Choose this if you want to keep experimenting with the CLI, generate more identities, or extend the project further.
- Your project folder stays on your Desktop at ~/Desktop/secure-msg.
- Generated key files remain in the keys/ directory inside the project.
- The tsx and typescript packages are local to this project's node_modules folder. They do not affect your system globally.
Pause - I'll come back to this later
Since this project has no running processes or background services, there is nothing to pause. Your files stay on disk until you delete them.
- Close the VS Code window for the project.
- Close any terminal sessions you used for the project.
- Your project will be exactly as you left it when you return.
Delete - I don't want to use this again
Remove all project files from your machine. This deletes your source code, generated keys, and installed packages.
- Close VS Code and any terminal sessions for the project.
- Delete the project folder by running the appropriate command for your operating system.
macOS
- Open a new terminal and run:
cd ~/Desktop
rm -rf secure-msg
Windows
- Open a new PowerShell window and run:
cd ~\Desktop
Remove-Item -Recurse -Force secure-msg
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see secure-msg in the output.
That's a Wrap!
That's a Wrap!
Nice work. You just built a cryptographic CLI that uses the same primitives powering SSH, HTTPS, and Signal.
You've learned how to:
- Generate Ed25519 and X25519 key pairs and store them as PEM files, the same format SSH and TLS use.
- Sign messages with digital signatures that prove authorship and detect tampering.
- Encrypt messages using Diffie-Hellman key agreement and AES-256-GCM authenticated encryption, the same pattern HTTPS uses.
- Secret Mission: Built a sign-then-encrypt protocol combining authentication and confidentiality into a single sealed envelope.
Ready to quiz yourself?