Repository: https://github.com/x0prc/PentaMail
Why We Built This
Email security shouldn’t be an afterthought. We built PentaMail to explore how Public Key Infrastructure (PKI) could be integrated into a modern web application for secure certificate-based email authentication. The goal was straightforward: create a system where users can request certificates, have them stored securely, and use them for identity verification when sending emails.
The Certificate Generation Flow
Here’s where things get interesting. When a user requests a certificate, we generate a 2048-bit RSA keypair right on the server:
// logic/utils/cryptoUtils.js
const crypto = require('crypto');
const generateKeyPair = () => {
return new Promise((resolve, reject) => {
crypto.generateKeyPair('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
}, (err, publicKey, privateKey) => {
if (err) {
return reject(err);
}
resolve({ publicKey, privateKey });
});
});
};We chose Node’s native crypto module over external libraries like node-rsa for one simple reason: fewer dependencies, less attack surface. The generateKeyPair API has been stable since Node 10.12.0, and using native bindings means we’re not trusting some random npm package with our cryptographic operations.
The keys get stored in MongoDB via our User model:
// models/userModel.js
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
publicKey: {
type: String,
required: true,
},
privateKey: {
type: String,
required: true,
},
createdAt: {
type: Date,
default: Date.now,
},
updatedAt: {
type: Date,
default: Date.now,
},
});Real talk: Storing private keys in a database is something we debated internally. In a production system, you’d want to encrypt these at rest or use an HSM (Hardware Security Module). For this proof-of-concept, we’re keeping them plaintext to focus on the PKI flow, but we’ve got TODOs in the backlog for proper key management.
The Email Service Layer
The email service is surprisingly simple. We’re using Nodemailer with Gmail’s SMTP:
// services/emailService.js
class EmailService {
constructor() {
this.transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
}
async sendEmail(recipient, subject, message) {
const mailOptions = {
from: process.env.EMAIL_USER,
to: recipient,
subject: subject,
text: message,
};
try {
await this.transporter.sendMail(mailOptions);
return { success: true, message: 'Email sent successfully!' };
} catch (error) {
console.error('Error sending email:', error);
throw new Error('Failed to send email.');
}
}
}We learned the hard way that Gmail’s “Less Secure Apps” setting is a pain. If you’re following along at home, you’ll need an App Password from Google’s security settings. Also, process.env.EMAIL_PASS shouldn’t be your actual Gmail password—it needs to be an App Password if you have 2FA enabled (which you should).
Frontend: Atomic Design in Practice
The React frontend follows Brad Frost’s Atomic Design methodology. We’ve got atoms (Button, Input), molecules (FormGroup), organisms (LoginForm), and templates (AuthTemplate).
The CertificateForm component shows how we’re handling the certificate request flow:
// src/components/CertificateForm.js
function CertificateForm() {
const [email, setEmail] = useState('');
const handleRequestCertificate = async (e) => {
e.preventDefault();
try {
await axios.post('http://localhost:5000/api/certificate/request', { email });
alert('Certificate requested successfully!');
} catch (error) {
alert('Error requesting certificate');
}
};
return (
<form onSubmit={handleRequestCertificate} className="form">
<h2>Request Certificate</h2>
<input type="email" placeholder="Your Email"
onChange={e => setEmail(e.target.value)} required />
<button type="submit">Request Certificate</button>
</form>
);
}We’re using functional components with hooks because it’s 2024 and class components are dead to us. The axios calls hit our Express API, and we kept error handling minimal—just alerts for now. A real app would have proper toast notifications and error boundaries.
OpenSSL Integration
For the certificate purists out there, we included bash scripts that use OpenSSL directly:
#!/bin/bash
# certauth/issueCertificate.js
USER_KEY="user.key"
USER_CSR="user.csr"
USER_CERT="user.crt"
CA_KEY="ca.key"
CA_CERT="ca.crt"
DAYS_VALID=365
openssl genrsa -out $USER_KEY 2048
openssl req -new -key $USER_KEY -out $USER_CSR \
-subj "/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=User"
openssl x509 -req -in $USER_CSR -CA $CA_CERT -CAkey $CA_KEY \
-CAcreateserial -out $USER_CERT -days $DAYS_VALID -sha256We wrote these scripts for scenarios where you want to use a real Certificate Authority instead of our Node.js crypto implementation. The revoke script is even simpler—it just deletes the files:
#!/bin/bash
# certauth/revokeCertificate.js
USER_CERT="user.crt"
USER_KEY="user.key"
if [ ! -f "$USER_CERT" ]; then
echo "User certificate not found: $USER_CERT"
exit 1
fi
rm "$USER_CERT" "$USER_KEY"
echo "User certificate revoked"Brutal but effective. In a real PKI system, you’d publish to a CRL (Certificate Revocation List) or use OCSP (Online Certificate Status Protocol). But for a demo, file deletion works.
The Config System
We’re using dotenv for environment management. The config module centralizes everything:
// config/config.js
const dotenv = require('dotenv');
dotenv.config();
module.exports = {
mongoURI: process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/pkiEmail',
emailUser: process.env.EMAIL_USER || 'your-email@gmail.com',
emailPass: process.env.EMAIL_PASS || 'your-email-password',
serverPort: process.env.PORT || 5001,
caKeyPath: process.env.CA_KEY_PATH || './ca.key',
caCertPath: process.env.CA_CERT_PATH || './ca.crt',
};Pro tip: Always have sensible defaults for development. The || operators mean you can clone this repo and run it immediately with local MongoDB, no .env file required. But please, for the love of all that is holy, don’t commit your actual .env file to git. We’ve got it in .gitignore for a reason.
Database Schema Design
MongoDB was an easy choice for this project. The User schema is simple but effective:
userSchema.pre('save', function (next) {
this.updatedAt = Date.now();
next();
});That pre-save hook ensures updatedAt gets refreshed on every save. It’s a small thing, but it helps with debugging when you’re trying to figure out when someone’s certificate was last touched.
We also added indexes on the email field (via unique: true) which gives us both data integrity and query performance. MongoDB automatically creates an index on unique fields.
Error Handling Philosophy
We implemented centralized error handling in our server entry point:
// src/index.js
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception thrown:', error);
server.close(() => {
process.exit(1);
});
});
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).send({
error: {
message: err.message || 'Internal Server Error',
},
});
});The combination of unhandledRejection for async errors and uncaughtException for sync errors means we’re not leaving zombie processes when things go sideways. And that Express error middleware at the bottom catches anything that bubbles up through the route handlers.
What We’d Do Differently
- Key Storage: Move private keys to an HSM or at least encrypt them at rest with AES-256
- Email Provider: Swap Gmail for AWS SES or SendGrid in production
- Rate Limiting: Add rate limiting on certificate requests (we don’t want someone spamming RSA key generation)
- Testing: We need actual tests. Jest is in the devDependencies but we haven’t written the test files yet. Shame on us.
- Docker: Containerize everything. Right now you’re manually installing MongoDB and managing Node versions.
The Code That Taught Us the Most
The certificate controller taught us a lot about async/await error handling in Express:
// controllers/certificateController.js
exports.requestCertificate = async (req, res) => {
const { email } = req.body;
try {
const { publicKey, privateKey } = await generateKeyPair();
const newUser = new User({
email,
publicKey,
privateKey
});
await newUser.save();
res.status(201).send('Certificate requested successfully and keys generated.');
} catch (error) {
console.error(error);
res.status(500).send('Error generating certificate and keys.');
}
};The lesson here: always wrap async controller functions in try-catch. If generateKeyPair() throws (and it can if the entropy pool is exhausted), you don’t want an unhandled promise rejection crashing your server.
Final Thoughts
PentaMail started as a weekend project to understand PKI integration better. What we ended up with is a solid foundation for certificate-based email authentication. It’s not production-ready (seriously, don’t deploy this to handle real user data yet), but it demonstrates the concepts clearly.
The code is organized, documented, and follows modern JavaScript patterns. We learned a ton about Node.js crypto, Express middleware patterns, and React component architecture. More importantly, we now have a template for building secure communication systems.
If you’re building something similar, feel free to fork this. Just remember: security is hard, crypto is harder, and storing private keys is hardest of all. Use this as a learning tool, then build the real thing with proper key management.
Happy hacking.