mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-09 11:19:36 +01:00
411 lines
No EOL
17 KiB
JavaScript
411 lines
No EOL
17 KiB
JavaScript
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 |