[AI][Feat]: Display Cryptographic keys in extra section

Thanks @aurorasmiles for that wonderful idea <3
This commit is contained in:
ClaraCrazy 2026-01-01 00:00:21 +01:00
parent 72c22f9815
commit fd993eb272
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
6 changed files with 643 additions and 2 deletions

View file

@ -0,0 +1,411 @@
const debug = require('debug')('48hr-email:crypto-detector')
/**
* Detects cryptographic keys and signatures in email attachments
*/
class CryptoDetector {
constructor() {
// Common cryptographic file extensions
this.cryptoExtensions = [
'.pgp', '.gpg', '.asc', '.pub', '.key', '.pem',
'.crt', '.cer', '.sig', '.sign', '.p7s', '.p7m',
'.pkcs7', '.pkcs12', '.pfx', '.p12'
]
// Patterns to detect key blocks in content
this.keyPatterns = [
// PGP/GPG keys
/-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/g,
/-----BEGIN PGP PRIVATE KEY BLOCK-----[\s\S]*?-----END PGP PRIVATE KEY BLOCK-----/g,
/-----BEGIN PGP MESSAGE-----[\s\S]*?-----END PGP MESSAGE-----/g,
/-----BEGIN PGP SIGNATURE-----[\s\S]*?-----END PGP SIGNATURE-----/g,
// SSH keys
/-----BEGIN (RSA|DSA|EC|OPENSSH) PRIVATE KEY-----[\s\S]*?-----END (RSA|DSA|EC|OPENSSH) PRIVATE KEY-----/g,
/ssh-(rsa|dss|ed25519|ecdsa) [A-Za-z0-9+/=]+/g,
// SSL/TLS certificates and keys
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
/-----BEGIN (RSA|EC) PRIVATE KEY-----[\s\S]*?-----END (RSA|EC) PRIVATE KEY-----/g,
/-----BEGIN ENCRYPTED PRIVATE KEY-----[\s\S]*?-----END ENCRYPTED PRIVATE KEY-----/g,
/-----BEGIN PUBLIC KEY-----[\s\S]*?-----END PUBLIC KEY-----/g,
// PKCS7/CMS signatures
/-----BEGIN PKCS7-----[\s\S]*?-----END PKCS7-----/g,
]
}
/**
* Checks if a filename suggests a cryptographic file
* @param {string} filename
* @returns {boolean}
*/
isCryptoFilename(filename) {
if (!filename) return false
const lowerName = filename.toLowerCase()
return this.cryptoExtensions.some(ext => lowerName.endsWith(ext)) ||
lowerName.includes('signature') ||
lowerName.includes('publickey') ||
lowerName.includes('privatekey') ||
lowerName.includes('certificate')
}
/**
* Detects the type of cryptographic content
* @param {string} content
* @param {string} filename
* @returns {string|null} The detected key type or null
*/
detectKeyType(content, filename) {
if (!content) return null
const contentStr = content.toString('utf8', 0, Math.min(content.length, 10000)) // Check first 10KB
if (contentStr.includes('BEGIN PGP PUBLIC KEY')) return 'PGP Public Key'
if (contentStr.includes('BEGIN PGP PRIVATE KEY')) return 'PGP Private Key'
if (contentStr.includes('BEGIN PGP MESSAGE')) return 'PGP Encrypted Message'
if (contentStr.includes('BEGIN PGP SIGNATURE')) return 'PGP Signature'
if (contentStr.match(/ssh-(rsa|dss|ed25519|ecdsa)/)) return 'SSH Public Key'
if (contentStr.includes('BEGIN RSA PRIVATE KEY')) return 'RSA Private Key'
if (contentStr.includes('BEGIN EC PRIVATE KEY')) return 'EC Private Key'
if (contentStr.includes('BEGIN OPENSSH PRIVATE KEY')) return 'OpenSSH Private Key'
if (contentStr.includes('BEGIN CERTIFICATE')) return 'X.509 Certificate'
if (contentStr.includes('BEGIN PUBLIC KEY')) return 'Public Key'
if (contentStr.includes('BEGIN ENCRYPTED PRIVATE KEY')) return 'Encrypted Private Key'
if (contentStr.includes('BEGIN PKCS7')) return 'PKCS#7 Signature'
// Check by filename if content detection fails
if (filename) {
const lower = filename.toLowerCase()
if (lower.endsWith('.pub')) return 'Public Key'
if (lower.endsWith('.sig') || lower.endsWith('.sign')) return 'Detached Signature'
if (lower.endsWith('.asc')) return 'ASCII Armored Key/Signature'
if (lower.endsWith('.pgp') || lower.endsWith('.gpg')) return 'PGP Key/Message'
if (lower.endsWith('.pem')) return 'PEM Encoded Key/Certificate'
if (lower.endsWith('.crt') || lower.endsWith('.cer')) return 'Certificate'
}
return null
}
/**
* Extracts cryptographic keys from content
* @param {string|Buffer} content
* @returns {Array<{type: string, content: string}>}
*/
extractKeys(content) {
if (!content) return []
const contentStr = content.toString('utf8')
const keys = []
this.keyPatterns.forEach(pattern => {
const matches = contentStr.match(pattern)
if (matches) {
matches.forEach(match => {
// Determine key type from the match
let type = 'Cryptographic Key'
if (match.includes('PGP PUBLIC KEY')) type = 'PGP Public Key'
else if (match.includes('PGP PRIVATE KEY')) type = 'PGP Private Key'
else if (match.includes('PGP MESSAGE')) type = 'PGP Message'
else if (match.includes('PGP SIGNATURE')) type = 'PGP Signature'
else if (match.includes('ssh-')) type = 'SSH Public Key'
else if (match.includes('CERTIFICATE')) type = 'Certificate'
else if (match.includes('PUBLIC KEY')) type = 'Public Key'
else if (match.includes('PRIVATE KEY')) type = 'Private Key'
keys.push({
type,
content: match
})
})
}
})
return keys
}
/**
* Processes email attachments to detect and extract cryptographic content
* @param {Array} attachments - Array of email attachments
* @returns {Array<{filename: string, type: string, content: string, preview: string}>}
*/
detectCryptoAttachments(attachments) {
if (!attachments || !Array.isArray(attachments)) {
return []
}
const cryptoFiles = []
attachments.forEach(attachment => {
// Check if it's a potential crypto file
if (this.isCryptoFilename(attachment.filename)) {
const keyType = this.detectKeyType(attachment.content, attachment.filename)
if (keyType) {
// Extract actual keys from content
const extractedKeys = this.extractKeys(attachment.content)
if (extractedKeys.length > 0) {
extractedKeys.forEach(key => {
cryptoFiles.push({
filename: attachment.filename,
type: key.type,
content: key.content,
preview: this._generatePreview(key.content, key.type),
info: this._extractKeyInfo(key.content, key.type)
})
})
} else {
// File has crypto extension/name but no extractable key blocks
// Still show it as it might be binary encoded
const contentStr = attachment.content.toString('utf8', 0, Math.min(attachment.content.length, 500))
cryptoFiles.push({
filename: attachment.filename,
type: keyType,
content: contentStr + (attachment.content.length > 500 ? '\n...[truncated]' : ''),
preview: this._generatePreview(contentStr, keyType),
info: this._extractKeyInfo(contentStr, keyType)
})
}
}
}
})
debug(`Detected ${cryptoFiles.length} cryptographic files in attachments`)
return cryptoFiles
}
/**
* Extract specific information from the key content
* @param {string} content
* @param {string} type
* @returns {string}
* @private
*/
_extractKeyInfo(content, type) {
if (!content) return ''
// For SSH keys, extract the key comment/user
if (type.includes('SSH')) {
const sshMatch = content.match(/ssh-\S+\s+\S+\s+(.+?)[\r\n]/)
if (sshMatch && sshMatch[1] && sshMatch[1].trim()) {
return sshMatch[1].trim()
}
// Show algorithm if available
const algoMatch = content.match(/ssh-(rsa|dss|ed25519|ecdsa-sha2-nistp(\d+))/)
if (algoMatch) {
return `${algoMatch[1].toUpperCase()}`
}
}
// For PGP keys and signatures, extract user info
if (type.includes('PGP')) {
// For signatures, try to extract key ID from the signature packet
if (type.includes('Signature')) {
try {
// Extract base64 content
const lines = content.split('\n')
let base64Content = ''
let inSig = false
for (const line of lines) {
if (line.includes('BEGIN PGP')) {
inSig = true
continue
}
if (line.includes('END PGP')) {
break
}
if (inSig && line.trim() && !line.startsWith('=')) {
base64Content += line.trim()
}
}
if (base64Content) {
const decoded = Buffer.from(base64Content, 'base64')
// Try to find key ID in signature packet
// OpenPGP signature packets typically have key ID at specific offsets
// Look for 8-byte key ID patterns
for (let i = 0; i < decoded.length - 8; i++) {
// Check if this looks like a key ID section
// Key IDs are often preceded by specific packet headers
if (decoded[i] === 0x00 && i + 8 < decoded.length) {
const keyIdBytes = decoded.slice(i + 1, i + 9)
const keyId = keyIdBytes.toString('hex').toUpperCase()
// Validate it looks like a reasonable key ID (not all zeros, not all FFs)
if (keyId.match(/^[0-9A-F]{16}$/) &&
keyId !== '0000000000000000' &&
keyId !== 'FFFFFFFFFFFFFFFF') {
return `Key ID: ${keyId.slice(-16)}`
}
}
}
// Alternative: look for the issuer key ID in a more reliable way
// The key ID is usually in the last 8 bytes before certain markers
if (decoded.length > 20) {
// Try to extract from common positions
const possibleKeyId = decoded.slice(decoded.length - 20, decoded.length - 12).toString('hex').toUpperCase()
if (possibleKeyId.match(/^[0-9A-F]{16}$/)) {
return `Key ID: ${possibleKeyId}`
}
}
}
} catch (err) {
debug(`Error extracting signature key ID: ${err.message}`)
}
return 'PGP detached signature'
}
// For keys, extract user info
try {
// Extract base64 content between BEGIN and END lines
const lines = content.split('\n')
let base64Content = ''
let inKey = false
for (const line of lines) {
if (line.includes('BEGIN PGP')) {
inKey = true
continue
}
if (line.includes('END PGP')) {
break
}
if (inKey && line.trim() && !line.startsWith('=')) {
base64Content += line.trim()
}
}
if (base64Content) {
// Decode base64 to binary buffer
const decoded = Buffer.from(base64Content, 'base64')
// Extract printable ASCII strings from the buffer
let printableStr = ''
for (let i = 0; i < decoded.length; i++) {
const byte = decoded[i]
// Keep printable ASCII characters
if (byte >= 0x20 && byte <= 0x7E) {
printableStr += String.fromCharCode(byte)
} else {
// Add separator for non-printable bytes
if (printableStr.length > 0 && !printableStr.endsWith('|')) {
printableStr += '|'
}
}
}
debug(`Extracted printable from PGP: ${printableStr.substring(0, 200)}`)
// Look for email with optional name before it
const emailPattern = /([A-Za-z][A-Za-z\s]{0,40}?)<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/
const emailMatch = printableStr.match(emailPattern)
if (emailMatch) {
const name = emailMatch[1].replace(/\|/g, '').trim()
const email = emailMatch[2]
debug(`Found PGP user: ${name} <${email}>`)
if (name.length > 0) {
return `${name} <${email}>`
}
return email
}
// Just look for bare email
const bareEmailMatch = printableStr.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/)
if (bareEmailMatch) {
debug(`Found PGP email: ${bareEmailMatch[1]}`)
return bareEmailMatch[1]
}
}
} catch (err) {
debug(`Error extracting PGP info: ${err.message}`)
}
return ''
}
// For detached signatures, show signature type
if (type.includes('Signature')) {
if (type.includes('PKCS')) {
return 'PKCS#7/CMS signature'
}
return 'Detached signature'
}
// For certificates, extract subject Common Name or issuer
if (type.includes('Certificate')) {
const cnPatterns = [
/CN\s*=\s*([^,\n/]+)/,
/commonName\s*=\s*([^,\n/]+)/i,
/Subject:.*?CN\s*=\s*([^,\n/]+)/
]
for (const pattern of cnPatterns) {
const match = content.match(pattern)
if (match && match[1]) {
return match[1].trim()
}
}
}
// For private keys, check encryption
if (type.includes('Private')) {
if (content.includes('ENCRYPTED') || content.includes('Proc-Type: 4,ENCRYPTED')) {
return 'Encrypted'
}
}
return ''
}
/**
* Generates a preview/fingerprint for the key
* @param {string} content
* @param {string} type
* @returns {string}
* @private
*/
_generatePreview(content, type) {
if (!content) return ''
// For SSH keys, extract the key comment if available
if (type.includes('SSH')) {
const sshMatch = content.match(/ssh-\S+\s+\S+\s+(.+)/)
if (sshMatch && sshMatch[1]) {
return `Comment: ${sshMatch[1].trim()}`
}
}
// For PGP keys, try to extract key ID or user info
if (type.includes('PGP')) {
// This is a simplified preview - proper parsing would require OpenPGP library
const lines = content.split('\n')
const infoLines = lines.filter(line =>
line.includes('User-ID') ||
line.includes('Key-ID') ||
line.includes('Fingerprint')
)
if (infoLines.length > 0) {
return infoLines.slice(0, 2).join(', ')
}
}
// For certificates, try to extract subject/issuer
if (type.includes('Certificate')) {
const subjectMatch = content.match(/Subject:.*?CN=([^,\n]+)/)
const issuerMatch = content.match(/Issuer:.*?CN=([^,\n]+)/)
const parts = []
if (subjectMatch) parts.push(`Subject: ${subjectMatch[1]}`)
if (issuerMatch) parts.push(`Issuer: ${issuerMatch[1]}`)
if (parts.length > 0) return parts.join(', ')
}
// Generic preview: show first and last few characters
const preview = content.replace(/[\r\n]+/g, ' ').slice(0, 100)
return preview.length < content.length ? preview + '...' : preview
}
}
module.exports = CryptoDetector

View file

@ -347,6 +347,20 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
function initCryptoKeysToggle() {
const cryptoHeader = document.getElementById('cryptoHeader');
const cryptoContent = document.getElementById('cryptoContent');
const cryptoToggle = cryptoHeader ? cryptoHeader.querySelector('.crypto-toggle') : null;
if (cryptoHeader && cryptoContent && cryptoToggle) {
cryptoHeader.addEventListener('click', () => {
const isCurrentlyHidden = cryptoContent.style.display === 'none';
cryptoContent.style.display = isCurrentlyHidden ? 'block' : 'none';
cryptoToggle.setAttribute('aria-expanded', isCurrentlyHidden ? 'true' : 'false');
});
}
}
function initRefreshCountdown(refreshInterval) {
const refreshTimer = document.getElementById('refreshTimer');
if (!refreshTimer || !refreshInterval) return;
@ -363,7 +377,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Expose utilities and run them
window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown };
window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle };
formatEmailDates();
formatMailDate();
initLockModals();

View file

@ -701,6 +701,176 @@ label {
font-size: 1.4rem;
}
/* Cryptographic Keys Section */
.mail-crypto-keys {
border: 2px solid var(--overlay-purple-30);
border-radius: 15px;
background: var(--overlay-white-03);
padding: 0;
margin-bottom: 30px;
overflow: hidden;
}
.crypto-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 25px;
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
}
.crypto-header:hover {
background: var(--overlay-purple-05);
}
.crypto-header h4 {
color: var(--color-accent-purple);
margin: 0;
font-size: 1.4rem;
display: flex;
align-items: center;
gap: 10px;
}
.crypto-toggle {
background: none;
border: none;
padding: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.crypto-toggle:hover {
transform: scale(1.1);
}
.crypto-icon {
width: 24px;
height: 24px;
stroke: var(--color-accent-purple);
}
.crypto-icon-collapsed {
display: block;
}
.crypto-icon-expanded {
display: none;
}
.crypto-toggle[aria-expanded="true"] .crypto-icon-collapsed {
display: none;
}
.crypto-toggle[aria-expanded="true"] .crypto-icon-expanded {
display: block;
}
.crypto-content {
padding: 0 25px 25px 25px;
}
.crypto-item {
background: var(--overlay-white-02);
border: 1px solid var(--overlay-purple-20);
border-radius: 10px;
padding: 20px;
margin-bottom: 15px;
}
.crypto-item:last-child {
margin-bottom: 0;
}
.crypto-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
gap: 10px;
}
.crypto-type {
display: inline-block;
background: var(--color-accent-purple);
color: var(--color-background);
padding: 5px 12px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
}
.crypto-filename {
color: var(--color-text-dim);
font-size: 0.95rem;
font-family: monospace;
word-break: break-word;
}
.crypto-preview {
color: var(--color-text-light);
font-size: 0.9rem;
font-style: italic;
margin-bottom: 10px;
padding: 8px 12px;
background: var(--overlay-white-03);
border-left: 3px solid var(--color-accent-purple-light);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.crypto-preview:hover {
background: var(--overlay-purple-10);
border-left-color: var(--color-accent-purple);
}
.crypto-key-content {
background: var(--color-background);
border: 1px solid var(--overlay-white-10);
border-radius: 8px;
padding: 15px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.85rem;
color: var(--color-text-light);
overflow-x: auto;
white-space: pre;
line-height: 1.4;
margin: 0;
}
.crypto-key-content::-webkit-scrollbar {
height: 8px;
}
.crypto-key-content::-webkit-scrollbar-track {
background: var(--overlay-white-05);
border-radius: 4px;
}
.crypto-key-content::-webkit-scrollbar-thumb {
background: var(--overlay-purple-30);
border-radius: 4px;
}
.crypto-key-content::-webkit-scrollbar-thumb:hover {
background: var(--color-accent-purple);
}
.mail-attachments h4 {
color: var(--color-accent-purple);
margin: 0 0 20px 0;
font-size: 1.4rem;
}
.attachments-list {
display: flex;
flex-direction: row;

View file

@ -5,7 +5,9 @@ const debug = require('debug')('48hr-email:routes')
const config = require('../../../application/config')
const Helper = require('../../../application/helper')
const CryptoDetector = require('../../../application/crypto-detector')
const helper = new(Helper)
const cryptoDetector = new CryptoDetector()
const { checkLockAccess } = require('../middleware/lock')
const purgeTime = helper.purgeTimeElemetBuilder()
@ -112,6 +114,10 @@ router.get(
// Emails are immutable, cache if found
res.set('Cache-Control', 'private, max-age=600')
// Detect cryptographic keys in attachments
const cryptoAttachments = cryptoDetector.detectCryptoAttachments(mail.attachments)
debug(`Found ${cryptoAttachments.length} cryptographic attachments`)
const inboxLock = req.app.get('inboxLock')
const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
const hasAccess = req.session && req.session.lockedInbox === req.params.address
@ -124,6 +130,7 @@ router.get(
count: count,
totalcount: totalcount,
mail,
cryptoAttachments: cryptoAttachments,
uid: req.params.uid,
branding: config.http.branding,
lockEnabled: config.lock.enabled,

View file

@ -47,6 +47,33 @@
{% endif %}
</div>
{% if cryptoAttachments and cryptoAttachments|length > 0 %}
<div class="mail-crypto-keys">
<div class="crypto-header" id="cryptoHeader">
<h4>Cryptographic Keys & Signatures ({{ cryptoAttachments|length }})</h4>
<button class="crypto-toggle" aria-label="Toggle cryptographic keys visibility" aria-expanded="false">
<svg class="crypto-icon crypto-icon-collapsed" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<svg class="crypto-icon crypto-icon-expanded" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
</div>
<div class="crypto-content" id="cryptoContent" style="display: none;">
{% for crypto in cryptoAttachments %}
<div class="crypto-item">
<div class="crypto-item-header">
<span class="crypto-type">{{ crypto.type }}</span>
<span class="crypto-filename">{{ crypto.filename }}{% if crypto.info %} · {{ crypto.info }}{% endif %}</span>
</div>
<pre class="crypto-key-content">{{ crypto.content }}</pre>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if mail.attachments %}
<div class="mail-attachments">
<h4>Attachments</h4>
@ -62,3 +89,15 @@
</div>
{% endblock %}
{% block footer %}
{{ parent() }}
<script>
// Initialize crypto keys toggle
document.addEventListener('DOMContentLoaded', () => {
if (window.utils && typeof window.utils.initCryptoKeysToggle === 'function') {
window.utils.initCryptoKeysToggle();
}
});
</script>
{% endblock %}

View file

@ -1,6 +1,6 @@
{
"name": "48hr.email",
"version": "1.7.6",
"version": "1.8.0",
"private": false,
"description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [