diff --git a/smime/manifest.json b/smime/manifest.json index 19f6ae2..9fa6a01 100644 --- a/smime/manifest.json +++ b/smime/manifest.json @@ -1,7 +1,7 @@ { "id": "smime", "name": "S/MIME", - "version": "1.0.2", + "version": "1.0.3", "author": "Bulwark Mail Community", "description": "End-to-end S/MIME for webmail: sign and encrypt outgoing messages, and automatically verify signatures and decrypt incoming CMS (PKCS#7) mail. Private keys are imported from a PKCS#12 (.p12/.pfx) file, encrypted at rest with a passphrase, and unlocked into non-extractable WebCrypto keys that never leave your browser. Runs in the privileged (same-origin) plugin tier so all cryptography happens locally with bundled pkijs/asn1js.", "type": "ui-extension", @@ -29,7 +29,10 @@ "label": "Content encryption algorithm", "description": "Symmetric cipher used to encrypt the message body. AES-256-GCM is recommended; AES-128-GCM is slightly smaller and still strong.", "default": "aes-256", - "options": ["aes-256", "aes-128"] + "options": [ + "aes-256", + "aes-128" + ] }, "autoImportSignerCerts": { "type": "boolean", @@ -49,17 +52,5 @@ "description": "Show a caution banner when an incoming signature validates against a self-signed certificate (not chained to a trusted CA).", "default": true } - }, - "locales": { - "en": { - "banner.signed_valid": "Signature valid", - "banner.signed_invalid": "Signature invalid", - "banner.encrypted": "Encrypted message", - "banner.decrypted": "Decrypted", - "banner.locked": "Encrypted — unlock your key to read", - "toolbar.sign": "Sign", - "toolbar.encrypt": "Encrypt", - "settings.title": "S/MIME keys & certificates" - } } } diff --git a/smime/package-lock.json b/smime/package-lock.json index a08d283..e1000cf 100644 --- a/smime/package-lock.json +++ b/smime/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-plugin-smime", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-plugin-smime", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "asn1js": "^3.0.10", "pkijs": "^3.4.0", diff --git a/smime/package.json b/smime/package.json index f88ae4a..de60f12 100644 --- a/smime/package.json +++ b/smime/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-plugin-smime", - "version": "1.0.2", + "version": "1.0.3", "private": true, "type": "module", "scripts": { diff --git a/smime/smime.zip b/smime/smime.zip index bf1fbb7..2218aa1 100644 Binary files a/smime/smime.zip and b/smime/smime.zip differ diff --git a/smime/src/index.js b/smime/src/index.js index 817a3bf..21e047e 100644 --- a/smime/src/index.js +++ b/smime/src/index.js @@ -23,9 +23,10 @@ const { useState, useEffect, useCallback, useRef } = React; import { buildMimeMessage, wrapCmsAsSmimeMessage, base64Encode } from './mime-builder.js'; import { smimeSign } from './smime-sign.js'; import { smimeEncrypt } from './smime-encrypt.js'; -import { smimeVerify } from './smime-verify.js'; +import { smimeVerify, smimeVerifyDetached } from './smime-verify.js'; import { smimeDecrypt, normalizeCmsBytes, SmimeKeyLockedError } from './smime-decrypt.js'; import { detectSmime } from './smime-detect.js'; +import { splitMultipartSigned } from './mime-signed.js'; import { parseMime } from './mime-parse.js'; import { importPkcs12, unlockPrivateKey } from './pkcs12.js'; import { parseCertificatePemOrDer, extractCertificateInfo } from './certificate-utils.js'; @@ -42,6 +43,1467 @@ const PREFS_KEY = 'prefs.v1'; const INTENT_KEY = 'composeIntent.v1'; const VERIFY_PREFIX = 'verify:'; +// ─── i18n ──────────────────────────────────────────────────────────── +// Uses Bulwark's own plugin i18n API (host.i18n.t), which reads the +// "locales" bundle in manifest.json and follows the same locale the rest +// of the app is already showing (Accept-Language + the user's stored +// preference) — no separate language detection needed on our side. +// The fallback dictionary below only kicks in if host.i18n is somehow +// unavailable (e.g. an older Bulwark version predating the i18n API). +const I18N_FALLBACK_EN = { + 'banner.encryption': 'Encryption', + 'banner.decrypted': 'Decrypted', + 'banner.encryptedLocked': 'Encrypted — unlock your key to read', + 'banner.encryptedFailed': 'Encrypted — couldn’t be decrypted with your keys', + 'banner.encryptedMessage': 'Encrypted message', + 'banner.signature': 'Signature', + 'banner.validSignature': 'Valid signature', + 'banner.validSignatureBy': 'Valid signature — {email}', + 'banner.signedMessage': 'Signed message', + 'banner.invalidSignature': 'Invalid signature: {reason}', + 'banner.selfSignedBadge': 'self-signed', + 'banner.signerMismatchBadge': 'signer ≠ From', + 'banner.unlockNow': 'Unlock now', + 'banner.unlocking': 'Unlocking…', + 'banner.viewCertDetails': 'View certificate details', + 'banner.hideCertDetails': 'Hide certificate details', + 'banner.downloadCert': 'Download certificate', + 'banner.copyCert': 'Copy to clipboard', + 'banner.copyChain': 'Copy with chain', + 'banner.copied': 'Copied!', + 'banner.certChain': 'Chain', + 'banner.certChainCount': '{count} additional certificate(s) included', + 'banner.certSubject': 'Subject', + 'banner.certIssuer': 'Issuer', + 'banner.certEmail': 'Email', + 'banner.certValid': 'Valid', + 'banner.certFingerprint': 'Fingerprint (SHA-256)', +}; + +const SETTINGS_EN = { + 'settings.notActive': 'S/MIME is not active', + 'settings.yourKeys': 'Your keys', + 'settings.yourKeysDesc': 'Import a PKCS#12 (.p12/.pfx) file containing your certificate and private key. The key is encrypted in your browser and never leaves it.', + 'settings.importKey': 'Import key', + 'settings.noKeys': 'No keys imported yet.', + 'settings.certUnknown': 'Certificate', + 'settings.validRange': 'valid {from} – {to}', + 'settings.expired': 'EXPIRED', + 'settings.capSign': 'sign', + 'settings.capEncrypt': 'encrypt', + 'settings.lock': 'Lock', + 'settings.unlock': 'Unlock', + 'settings.delete': 'Delete', + 'settings.recipientCerts': 'Recipient certificates', + 'settings.recipientCertsDesc': 'Public certificates (PEM/DER) of people you want to send encrypted mail to. Signer certificates from validly signed mail are saved automatically.', + 'settings.importCert': 'Import certificate', + 'settings.noCerts': 'No recipient certificates.', + 'settings.certExpires': 'expires {date}', + 'settings.remove': 'Remove', + 'settings.defaults': 'Defaults for new messages', + 'settings.defaultSign': 'Sign new messages by default', + 'settings.defaultEncrypt': 'Encrypt new messages by default (when all recipients have certificates)', + 'settings.importKeyDialogTitle': 'Import S/MIME key', + 'settings.importKeyDialogMessage': 'Importing "{file}".', + 'settings.importDialogConfirm': 'Import', + 'settings.p12PassLabel': 'Passphrase protecting the .p12/.pfx file', + 'settings.p12PassPlaceholder': 'Leave blank if the file has none', + 'settings.storagePassLabel': 'New passphrase to protect this key in your browser', + 'settings.storagePassRequired': 'A storage passphrase is required', + 'settings.importKeySuccess': 'Imported S/MIME key for {email}', + 'settings.importKeyFailed': 'Import failed: {reason}', + 'settings.unlockDialogTitle': 'Unlock {email}', + 'settings.unlockConfirm': 'Unlock', + 'settings.storagePassForKeyLabel': 'Storage passphrase for this key', + 'settings.unlockSuccess': 'Unlocked {email}', + 'settings.unlockFailed': 'Unlock failed', + 'settings.keyFallback': 'key', + 'settings.lockedToast': 'Locked {email}', + 'settings.deleteDialogTitle': 'Delete S/MIME key', + 'settings.deleteDialogMessage': 'Delete the private key and certificate for {email}? You will no longer be able to decrypt mail encrypted to it.', + 'settings.deleteDialogFallbackIdentity': 'this identity', + 'settings.deleteConfirm': 'Delete', + 'settings.keyDeletedToast': 'Key deleted', + 'settings.certNoEmail': 'Certificate has no email address', + 'settings.importCertSuccess': 'Imported certificate for {email}', + 'settings.importCertFailed': 'Certificate import failed: {reason}', + 'settings.unlockSendDialogTitle': 'Unlock S/MIME key', + 'settings.unlockSendDialogMessage': 'Your key for {email} is locked. Enter its storage passphrase to sign and send.', + 'settings.unlockAndSend': 'Unlock & send', + 'settings.storagePassphrase': 'Storage passphrase', + 'settings.unlockFailedWrongPass': 'Unlock failed — wrong passphrase?', + 'settings.notPrivilegedToast': 'Cannot sign/encrypt: S/MIME is not running in the privileged tier.', +}; +Object.assign(I18N_FALLBACK_EN, SETTINGS_EN); + +// ====================================================================== +// >>> SELF-DETECTED LOCALE FALLBACK — EASY TO FIND / REMOVE LATER <<< +// host.i18n.t() exists and correctly finds our manifest.json locale keys, +// but as of Bulwark v1.8.1 it appears to always resolve to English inside +// this privileged plugin iframe, regardless of the user's actual account +// language (confirmed: host.i18n.t('banner.signature') returns "Signature" +// even with the account set to German). This block works around that by +// detecting the active locale ourselves and using our own copy of the +// manifest.json locale strings, bypassing host.i18n.t() entirely whenever +// we have a matching local dictionary. Delete this whole block (and the +// two lines marked below in t()) once the host bug is fixed upstream. +const LOCAL_I18N = +{ + "en": { + "banner.encryption": "Encryption", + "banner.decrypted": "Decrypted", + "banner.encryptedLocked": "Encrypted — unlock your key to read", + "banner.encryptedFailed": "Encrypted — couldn’t be decrypted with your keys", + "banner.encryptedMessage": "Encrypted message", + "banner.signature": "Signature", + "banner.validSignature": "Valid signature", + "banner.validSignatureBy": "Valid signature — {email}", + "banner.signedMessage": "Signed message", + "banner.invalidSignature": "Invalid signature: {reason}", + "banner.selfSignedBadge": "self-signed", + "banner.signerMismatchBadge": "signer ≠ From", + "banner.unlockNow": "Unlock now", + "banner.unlocking": "Unlocking…", + "banner.viewCertDetails": "View certificate details", + "banner.hideCertDetails": "Hide certificate details", + "banner.downloadCert": "Download certificate", + "banner.certSubject": "Subject", + "banner.certIssuer": "Issuer", + "banner.certEmail": "Email", + "banner.certValid": "Valid", + "banner.certFingerprint": "Fingerprint (SHA-256)", + "toolbar.sign": "Sign", + "toolbar.encrypt": "Encrypt", + "toolbar.signTitle": "Digitally sign this message", + "toolbar.encryptTitle": "Encrypt this message to its recipients", + "toolbar.needKey": "S/MIME: import a key in Settings to sign/encrypt", + "settings.title": "S/MIME keys & certificates", + "banner.copyCert": "Copy to clipboard", + "banner.copied": "Copied!", + "banner.copyChain": "Copy with chain", + "banner.certChain": "Chain", + "banner.certChainCount": "{count} additional certificate(s) included", + "settings.notActive": "S/MIME is not active", + "settings.yourKeys": "Your keys", + "settings.yourKeysDesc": "Import a PKCS#12 (.p12/.pfx) file containing your certificate and private key. The key is encrypted in your browser and never leaves it.", + "settings.importKey": "Import key", + "settings.noKeys": "No keys imported yet.", + "settings.certUnknown": "Certificate", + "settings.validRange": "valid {from} – {to}", + "settings.expired": "EXPIRED", + "settings.capSign": "sign", + "settings.capEncrypt": "encrypt", + "settings.lock": "Lock", + "settings.unlock": "Unlock", + "settings.delete": "Delete", + "settings.recipientCerts": "Recipient certificates", + "settings.recipientCertsDesc": "Public certificates (PEM/DER) of people you want to send encrypted mail to. Signer certificates from validly signed mail are saved automatically.", + "settings.importCert": "Import certificate", + "settings.noCerts": "No recipient certificates.", + "settings.certExpires": "expires {date}", + "settings.remove": "Remove", + "settings.defaults": "Defaults for new messages", + "settings.defaultSign": "Sign new messages by default", + "settings.defaultEncrypt": "Encrypt new messages by default (when all recipients have certificates)", + "settings.importKeyDialogTitle": "Import S/MIME key", + "settings.importKeyDialogMessage": "Importing \"{file}\".", + "settings.importDialogConfirm": "Import", + "settings.p12PassLabel": "Passphrase protecting the .p12/.pfx file", + "settings.p12PassPlaceholder": "Leave blank if the file has none", + "settings.storagePassLabel": "New passphrase to protect this key in your browser", + "settings.storagePassRequired": "A storage passphrase is required", + "settings.importKeySuccess": "Imported S/MIME key for {email}", + "settings.importKeyFailed": "Import failed: {reason}", + "settings.unlockDialogTitle": "Unlock {email}", + "settings.unlockConfirm": "Unlock", + "settings.storagePassForKeyLabel": "Storage passphrase for this key", + "settings.unlockSuccess": "Unlocked {email}", + "settings.unlockFailed": "Unlock failed", + "settings.keyFallback": "key", + "settings.lockedToast": "Locked {email}", + "settings.deleteDialogTitle": "Delete S/MIME key", + "settings.deleteDialogMessage": "Delete the private key and certificate for {email}? You will no longer be able to decrypt mail encrypted to it.", + "settings.deleteDialogFallbackIdentity": "this identity", + "settings.deleteConfirm": "Delete", + "settings.keyDeletedToast": "Key deleted", + "settings.certNoEmail": "Certificate has no email address", + "settings.importCertSuccess": "Imported certificate for {email}", + "settings.importCertFailed": "Certificate import failed: {reason}", + "settings.unlockSendDialogTitle": "Unlock S/MIME key", + "settings.unlockSendDialogMessage": "Your key for {email} is locked. Enter its storage passphrase to sign and send.", + "settings.unlockAndSend": "Unlock & send", + "settings.storagePassphrase": "Storage passphrase", + "settings.unlockFailedWrongPass": "Unlock failed — wrong passphrase?", + "settings.notPrivilegedToast": "Cannot sign/encrypt: S/MIME is not running in the privileged tier." + }, + "de": { + "banner.encryption": "Verschlüsselung", + "banner.decrypted": "Entschlüsselt", + "banner.encryptedLocked": "Verschlüsselt — Schlüssel entsperren, um zu lesen", + "banner.encryptedFailed": "Verschlüsselt — konnte mit Ihren Schlüsseln nicht entschlüsselt werden", + "banner.encryptedMessage": "Verschlüsselte Nachricht", + "banner.signature": "Signatur", + "banner.validSignature": "Gültige Signatur", + "banner.validSignatureBy": "Gültige Signatur — {email}", + "banner.signedMessage": "Signierte Nachricht", + "banner.invalidSignature": "Ungültige Signatur: {reason}", + "banner.selfSignedBadge": "selbstsigniert", + "banner.signerMismatchBadge": "Unterzeichner ≠ Absender", + "banner.unlockNow": "Jetzt entsperren", + "banner.unlocking": "Entsperre…", + "banner.viewCertDetails": "Zertifikatsdetails anzeigen", + "banner.hideCertDetails": "Zertifikatsdetails ausblenden", + "banner.downloadCert": "Zertifikat herunterladen", + "banner.certSubject": "Inhaber", + "banner.certIssuer": "Aussteller", + "banner.certEmail": "E-Mail", + "banner.certValid": "Gültig", + "banner.certFingerprint": "Fingerabdruck (SHA-256)", + "toolbar.sign": "Signieren", + "toolbar.encrypt": "Verschlüsseln", + "toolbar.signTitle": "Diese Nachricht digital signieren", + "toolbar.encryptTitle": "Diese Nachricht für die Empfänger verschlüsseln", + "toolbar.needKey": "S/MIME: Schlüssel in den Einstellungen importieren, um zu signieren/verschlüsseln", + "settings.title": "S/MIME-Schlüssel & Zertifikate", + "banner.copyCert": "In Zwischenablage kopieren", + "banner.copied": "Kopiert!", + "banner.copyChain": "Mit Kette kopieren", + "banner.certChain": "Kette", + "banner.certChainCount": "{count} weitere(s) Zertifikat(e) enthalten", + "settings.notActive": "S/MIME ist nicht aktiv", + "settings.yourKeys": "Ihre Schlüssel", + "settings.yourKeysDesc": "Importieren Sie eine PKCS#12-Datei (.p12/.pfx) mit Ihrem Zertifikat und privaten Schlüssel. Der Schlüssel wird in Ihrem Browser verschlüsselt und verlässt ihn nie.", + "settings.importKey": "Schlüssel importieren", + "settings.noKeys": "Noch keine Schlüssel importiert.", + "settings.certUnknown": "Zertifikat", + "settings.validRange": "gültig {from} – {to}", + "settings.expired": "ABGELAUFEN", + "settings.capSign": "signieren", + "settings.capEncrypt": "verschlüsseln", + "settings.lock": "Sperren", + "settings.unlock": "Entsperren", + "settings.delete": "Löschen", + "settings.recipientCerts": "Empfängerzertifikate", + "settings.recipientCertsDesc": "Öffentliche Zertifikate (PEM/DER) von Personen, denen Sie verschlüsselte Mail senden möchten. Signierer-Zertifikate aus gültig signierten Mails werden automatisch gespeichert.", + "settings.importCert": "Zertifikat importieren", + "settings.noCerts": "Keine Empfängerzertifikate.", + "settings.certExpires": "läuft ab am {date}", + "settings.remove": "Entfernen", + "settings.defaults": "Standardeinstellungen für neue Nachrichten", + "settings.defaultSign": "Neue Nachrichten standardmäßig signieren", + "settings.defaultEncrypt": "Neue Nachrichten standardmäßig verschlüsseln (wenn alle Empfänger Zertifikate haben)", + "settings.importKeyDialogTitle": "S/MIME-Schlüssel importieren", + "settings.importKeyDialogMessage": "Importiere „{file}\".", + "settings.importDialogConfirm": "Importieren", + "settings.p12PassLabel": "Kennwort, das die .p12/.pfx-Datei schützt", + "settings.p12PassPlaceholder": "Leer lassen, falls die Datei keines hat", + "settings.storagePassLabel": "Neues Kennwort, um diesen Schlüssel im Browser zu schützen", + "settings.storagePassRequired": "Ein Speicher-Kennwort ist erforderlich", + "settings.importKeySuccess": "S/MIME-Schlüssel für {email} importiert", + "settings.importKeyFailed": "Import fehlgeschlagen: {reason}", + "settings.unlockDialogTitle": "{email} entsperren", + "settings.unlockConfirm": "Entsperren", + "settings.storagePassForKeyLabel": "Speicher-Kennwort für diesen Schlüssel", + "settings.unlockSuccess": "{email} entsperrt", + "settings.unlockFailed": "Entsperren fehlgeschlagen", + "settings.keyFallback": "Schlüssel", + "settings.lockedToast": "{email} gesperrt", + "settings.deleteDialogTitle": "S/MIME-Schlüssel löschen", + "settings.deleteDialogMessage": "Privaten Schlüssel und Zertifikat für {email} löschen? Sie können damit verschlüsselte Mails danach nicht mehr entschlüsseln.", + "settings.deleteDialogFallbackIdentity": "diese Identität", + "settings.deleteConfirm": "Löschen", + "settings.keyDeletedToast": "Schlüssel gelöscht", + "settings.certNoEmail": "Zertifikat hat keine E-Mail-Adresse", + "settings.importCertSuccess": "Zertifikat für {email} importiert", + "settings.importCertFailed": "Zertifikatsimport fehlgeschlagen: {reason}", + "settings.unlockSendDialogTitle": "S/MIME-Schlüssel entsperren", + "settings.unlockSendDialogMessage": "Ihr Schlüssel für {email} ist gesperrt. Geben Sie das Speicher-Kennwort ein, um zu signieren und zu senden.", + "settings.unlockAndSend": "Entsperren & senden", + "settings.storagePassphrase": "Speicher-Kennwort", + "settings.unlockFailedWrongPass": "Entsperren fehlgeschlagen — falsches Kennwort?", + "settings.notPrivilegedToast": "Signieren/Verschlüsseln nicht möglich: S/MIME läuft nicht im privilegierten Tier." + }, + "es": { + "banner.encryption": "Cifrado", + "banner.decrypted": "Descifrado", + "banner.encryptedLocked": "Cifrado — desbloquee su clave para leer", + "banner.encryptedFailed": "Cifrado — no se pudo descifrar con sus claves", + "banner.encryptedMessage": "Mensaje cifrado", + "banner.signature": "Firma", + "banner.validSignature": "Firma válida", + "banner.validSignatureBy": "Firma válida — {email}", + "banner.signedMessage": "Mensaje firmado", + "banner.invalidSignature": "Firma no válida: {reason}", + "banner.selfSignedBadge": "autofirmado", + "banner.signerMismatchBadge": "firmante ≠ remitente", + "banner.unlockNow": "Desbloquear ahora", + "banner.unlocking": "Desbloqueando…", + "banner.viewCertDetails": "Ver detalles del certificado", + "banner.hideCertDetails": "Ocultar detalles del certificado", + "banner.downloadCert": "Descargar certificado", + "banner.certSubject": "Titular", + "banner.certIssuer": "Emisor", + "banner.certEmail": "Correo electrónico", + "banner.certValid": "Válido", + "banner.certFingerprint": "Huella digital (SHA-256)", + "toolbar.sign": "Firmar", + "toolbar.encrypt": "Cifrar", + "toolbar.signTitle": "Firmar digitalmente este mensaje", + "toolbar.encryptTitle": "Cifrar este mensaje para sus destinatarios", + "toolbar.needKey": "S/MIME: importe una clave en Configuración para firmar/cifrar", + "settings.title": "Claves y certificados S/MIME", + "banner.copyCert": "Copiar al portapapeles", + "banner.copied": "¡Copiado!", + "banner.copyChain": "Copiar con cadena", + "banner.certChain": "Cadena", + "banner.certChainCount": "{count} certificado(s) adicional(es) incluido(s)", + "settings.notActive": "S/MIME no está activo", + "settings.yourKeys": "Sus claves", + "settings.yourKeysDesc": "Importe un archivo PKCS#12 (.p12/.pfx) que contenga su certificado y clave privada. La clave se cifra en su navegador y nunca sale de él.", + "settings.importKey": "Importar clave", + "settings.noKeys": "Aún no se han importado claves.", + "settings.certUnknown": "Certificado", + "settings.validRange": "válido {from} – {to}", + "settings.expired": "CADUCADO", + "settings.capSign": "firmar", + "settings.capEncrypt": "cifrar", + "settings.lock": "Bloquear", + "settings.unlock": "Desbloquear", + "settings.delete": "Eliminar", + "settings.recipientCerts": "Certificados de destinatarios", + "settings.recipientCertsDesc": "Certificados públicos (PEM/DER) de las personas a las que desea enviar correo cifrado. Los certificados del firmante de correo válidamente firmado se guardan automáticamente.", + "settings.importCert": "Importar certificado", + "settings.noCerts": "No hay certificados de destinatarios.", + "settings.certExpires": "caduca el {date}", + "settings.remove": "Quitar", + "settings.defaults": "Valores predeterminados para mensajes nuevos", + "settings.defaultSign": "Firmar mensajes nuevos de forma predeterminada", + "settings.defaultEncrypt": "Cifrar mensajes nuevos de forma predeterminada (cuando todos los destinatarios tengan certificados)", + "settings.importKeyDialogTitle": "Importar clave S/MIME", + "settings.importKeyDialogMessage": "Importando \"{file}\".", + "settings.importDialogConfirm": "Importar", + "settings.p12PassLabel": "Contraseña que protege el archivo .p12/.pfx", + "settings.p12PassPlaceholder": "Déjelo en blanco si el archivo no tiene ninguna", + "settings.storagePassLabel": "Nueva contraseña para proteger esta clave en su navegador", + "settings.storagePassRequired": "Se requiere una contraseña de almacenamiento", + "settings.importKeySuccess": "Clave S/MIME importada para {email}", + "settings.importKeyFailed": "Error al importar: {reason}", + "settings.unlockDialogTitle": "Desbloquear {email}", + "settings.unlockConfirm": "Desbloquear", + "settings.storagePassForKeyLabel": "Contraseña de almacenamiento para esta clave", + "settings.unlockSuccess": "{email} desbloqueada", + "settings.unlockFailed": "Error al desbloquear", + "settings.keyFallback": "clave", + "settings.lockedToast": "{email} bloqueada", + "settings.deleteDialogTitle": "Eliminar clave S/MIME", + "settings.deleteDialogMessage": "¿Eliminar la clave privada y el certificado de {email}? Ya no podrá descifrar el correo cifrado para ella.", + "settings.deleteDialogFallbackIdentity": "esta identidad", + "settings.deleteConfirm": "Eliminar", + "settings.keyDeletedToast": "Clave eliminada", + "settings.certNoEmail": "El certificado no tiene dirección de correo", + "settings.importCertSuccess": "Certificado importado para {email}", + "settings.importCertFailed": "Error al importar el certificado: {reason}", + "settings.unlockSendDialogTitle": "Desbloquear clave S/MIME", + "settings.unlockSendDialogMessage": "Su clave para {email} está bloqueada. Introduzca su contraseña de almacenamiento para firmar y enviar.", + "settings.unlockAndSend": "Desbloquear y enviar", + "settings.storagePassphrase": "Contraseña de almacenamiento", + "settings.unlockFailedWrongPass": "Error al desbloquear — ¿contraseña incorrecta?", + "settings.notPrivilegedToast": "No se puede firmar/cifrar: S/MIME no se ejecuta en el nivel privilegiado." + }, + "fr": { + "banner.encryption": "Chiffrement", + "banner.decrypted": "Déchiffré", + "banner.encryptedLocked": "Chiffré — déverrouillez votre clé pour lire", + "banner.encryptedFailed": "Chiffré — impossible de déchiffrer avec vos clés", + "banner.encryptedMessage": "Message chiffré", + "banner.signature": "Signature", + "banner.validSignature": "Signature valide", + "banner.validSignatureBy": "Signature valide — {email}", + "banner.signedMessage": "Message signé", + "banner.invalidSignature": "Signature invalide : {reason}", + "banner.selfSignedBadge": "auto-signé", + "banner.signerMismatchBadge": "signataire ≠ expéditeur", + "banner.unlockNow": "Déverrouiller maintenant", + "banner.unlocking": "Déverrouillage…", + "banner.viewCertDetails": "Voir les détails du certificat", + "banner.hideCertDetails": "Masquer les détails du certificat", + "banner.downloadCert": "Télécharger le certificat", + "banner.certSubject": "Objet", + "banner.certIssuer": "Émetteur", + "banner.certEmail": "E-mail", + "banner.certValid": "Valide", + "banner.certFingerprint": "Empreinte (SHA-256)", + "toolbar.sign": "Signer", + "toolbar.encrypt": "Chiffrer", + "toolbar.signTitle": "Signer numériquement ce message", + "toolbar.encryptTitle": "Chiffrer ce message pour ses destinataires", + "toolbar.needKey": "S/MIME : importez une clé dans les paramètres pour signer/chiffrer", + "settings.title": "Clés et certificats S/MIME", + "banner.copyCert": "Copier dans le presse-papiers", + "banner.copied": "Copié !", + "banner.copyChain": "Copier avec la chaîne", + "banner.certChain": "Chaîne", + "banner.certChainCount": "{count} certificat(s) supplémentaire(s) inclus", + "settings.notActive": "S/MIME n'est pas actif", + "settings.yourKeys": "Vos clés", + "settings.yourKeysDesc": "Importez un fichier PKCS#12 (.p12/.pfx) contenant votre certificat et votre clé privée. La clé est chiffrée dans votre navigateur et n'en sort jamais.", + "settings.importKey": "Importer une clé", + "settings.noKeys": "Aucune clé importée pour l'instant.", + "settings.certUnknown": "Certificat", + "settings.validRange": "valide du {from} au {to}", + "settings.expired": "EXPIRÉ", + "settings.capSign": "signer", + "settings.capEncrypt": "chiffrer", + "settings.lock": "Verrouiller", + "settings.unlock": "Déverrouiller", + "settings.delete": "Supprimer", + "settings.recipientCerts": "Certificats des destinataires", + "settings.recipientCertsDesc": "Certificats publics (PEM/DER) des personnes à qui vous souhaitez envoyer du courrier chiffré. Les certificats des signataires de courriers valablement signés sont enregistrés automatiquement.", + "settings.importCert": "Importer un certificat", + "settings.noCerts": "Aucun certificat de destinataire.", + "settings.certExpires": "expire le {date}", + "settings.remove": "Retirer", + "settings.defaults": "Valeurs par défaut pour les nouveaux messages", + "settings.defaultSign": "Signer les nouveaux messages par défaut", + "settings.defaultEncrypt": "Chiffrer les nouveaux messages par défaut (si tous les destinataires ont un certificat)", + "settings.importKeyDialogTitle": "Importer une clé S/MIME", + "settings.importKeyDialogMessage": "Importation de « {file} ».", + "settings.importDialogConfirm": "Importer", + "settings.p12PassLabel": "Mot de passe protégeant le fichier .p12/.pfx", + "settings.p12PassPlaceholder": "Laissez vide si le fichier n'en a pas", + "settings.storagePassLabel": "Nouveau mot de passe pour protéger cette clé dans votre navigateur", + "settings.storagePassRequired": "Un mot de passe de stockage est requis", + "settings.importKeySuccess": "Clé S/MIME importée pour {email}", + "settings.importKeyFailed": "Échec de l'importation : {reason}", + "settings.unlockDialogTitle": "Déverrouiller {email}", + "settings.unlockConfirm": "Déverrouiller", + "settings.storagePassForKeyLabel": "Mot de passe de stockage pour cette clé", + "settings.unlockSuccess": "{email} déverrouillée", + "settings.unlockFailed": "Échec du déverrouillage", + "settings.keyFallback": "clé", + "settings.lockedToast": "{email} verrouillée", + "settings.deleteDialogTitle": "Supprimer la clé S/MIME", + "settings.deleteDialogMessage": "Supprimer la clé privée et le certificat de {email} ? Vous ne pourrez plus déchiffrer le courrier qui lui est destiné.", + "settings.deleteDialogFallbackIdentity": "cette identité", + "settings.deleteConfirm": "Supprimer", + "settings.keyDeletedToast": "Clé supprimée", + "settings.certNoEmail": "Le certificat n'a pas d'adresse e-mail", + "settings.importCertSuccess": "Certificat importé pour {email}", + "settings.importCertFailed": "Échec de l'import du certificat : {reason}", + "settings.unlockSendDialogTitle": "Déverrouiller la clé S/MIME", + "settings.unlockSendDialogMessage": "Votre clé pour {email} est verrouillée. Saisissez son mot de passe de stockage pour signer et envoyer.", + "settings.unlockAndSend": "Déverrouiller et envoyer", + "settings.storagePassphrase": "Mot de passe de stockage", + "settings.unlockFailedWrongPass": "Échec du déverrouillage — mot de passe incorrect ?", + "settings.notPrivilegedToast": "Impossible de signer/chiffrer : S/MIME ne fonctionne pas dans le niveau privilégié." + }, + "it": { + "banner.encryption": "Crittografia", + "banner.decrypted": "Decrittografato", + "banner.encryptedLocked": "Crittografato — sblocca la tua chiave per leggere", + "banner.encryptedFailed": "Crittografato — impossibile decrittografare con le tue chiavi", + "banner.encryptedMessage": "Messaggio crittografato", + "banner.signature": "Firma", + "banner.validSignature": "Firma valida", + "banner.validSignatureBy": "Firma valida — {email}", + "banner.signedMessage": "Messaggio firmato", + "banner.invalidSignature": "Firma non valida: {reason}", + "banner.selfSignedBadge": "autofirmato", + "banner.signerMismatchBadge": "firmatario ≠ mittente", + "banner.unlockNow": "Sblocca ora", + "banner.unlocking": "Sblocco in corso…", + "banner.viewCertDetails": "Visualizza dettagli certificato", + "banner.hideCertDetails": "Nascondi dettagli certificato", + "banner.downloadCert": "Scarica certificato", + "banner.certSubject": "Intestatario", + "banner.certIssuer": "Emittente", + "banner.certEmail": "Email", + "banner.certValid": "Valido", + "banner.certFingerprint": "Impronta digitale (SHA-256)", + "toolbar.sign": "Firma", + "toolbar.encrypt": "Crittografa", + "toolbar.signTitle": "Firma digitalmente questo messaggio", + "toolbar.encryptTitle": "Crittografa questo messaggio per i destinatari", + "toolbar.needKey": "S/MIME: importa una chiave nelle Impostazioni per firmare/crittografare", + "settings.title": "Chiavi e certificati S/MIME", + "banner.copyCert": "Copia negli appunti", + "banner.copied": "Copiato!", + "banner.copyChain": "Copia con catena", + "banner.certChain": "Catena", + "banner.certChainCount": "{count} certificato/i aggiuntivo/i incluso/i", + "settings.notActive": "S/MIME non è attivo", + "settings.yourKeys": "Le tue chiavi", + "settings.yourKeysDesc": "Importa un file PKCS#12 (.p12/.pfx) contenente il tuo certificato e la chiave privata. La chiave viene crittografata nel browser e non ne esce mai.", + "settings.importKey": "Importa chiave", + "settings.noKeys": "Nessuna chiave importata finora.", + "settings.certUnknown": "Certificato", + "settings.validRange": "valido {from} – {to}", + "settings.expired": "SCADUTO", + "settings.capSign": "firma", + "settings.capEncrypt": "crittografia", + "settings.lock": "Blocca", + "settings.unlock": "Sblocca", + "settings.delete": "Elimina", + "settings.recipientCerts": "Certificati destinatari", + "settings.recipientCertsDesc": "Certificati pubblici (PEM/DER) delle persone a cui vuoi inviare posta crittografata. I certificati del firmatario di posta validamente firmata vengono salvati automaticamente.", + "settings.importCert": "Importa certificato", + "settings.noCerts": "Nessun certificato destinatario.", + "settings.certExpires": "scade il {date}", + "settings.remove": "Rimuovi", + "settings.defaults": "Impostazioni predefinite per i nuovi messaggi", + "settings.defaultSign": "Firma i nuovi messaggi per impostazione predefinita", + "settings.defaultEncrypt": "Crittografa i nuovi messaggi per impostazione predefinita (quando tutti i destinatari hanno un certificato)", + "settings.importKeyDialogTitle": "Importa chiave S/MIME", + "settings.importKeyDialogMessage": "Importazione di \"{file}\".", + "settings.importDialogConfirm": "Importa", + "settings.p12PassLabel": "Passphrase che protegge il file .p12/.pfx", + "settings.p12PassPlaceholder": "Lascia vuoto se il file non ne ha una", + "settings.storagePassLabel": "Nuova passphrase per proteggere questa chiave nel browser", + "settings.storagePassRequired": "È richiesta una passphrase di archiviazione", + "settings.importKeySuccess": "Chiave S/MIME importata per {email}", + "settings.importKeyFailed": "Importazione non riuscita: {reason}", + "settings.unlockDialogTitle": "Sblocca {email}", + "settings.unlockConfirm": "Sblocca", + "settings.storagePassForKeyLabel": "Passphrase di archiviazione per questa chiave", + "settings.unlockSuccess": "{email} sbloccata", + "settings.unlockFailed": "Sblocco non riuscito", + "settings.keyFallback": "chiave", + "settings.lockedToast": "{email} bloccata", + "settings.deleteDialogTitle": "Elimina chiave S/MIME", + "settings.deleteDialogMessage": "Eliminare la chiave privata e il certificato per {email}? Non sarà più possibile decrittografare la posta a lei destinata.", + "settings.deleteDialogFallbackIdentity": "questa identità", + "settings.deleteConfirm": "Elimina", + "settings.keyDeletedToast": "Chiave eliminata", + "settings.certNoEmail": "Il certificato non ha un indirizzo email", + "settings.importCertSuccess": "Certificato importato per {email}", + "settings.importCertFailed": "Importazione del certificato non riuscita: {reason}", + "settings.unlockSendDialogTitle": "Sblocca chiave S/MIME", + "settings.unlockSendDialogMessage": "La tua chiave per {email} è bloccata. Inserisci la passphrase di archiviazione per firmare e inviare.", + "settings.unlockAndSend": "Sblocca e invia", + "settings.storagePassphrase": "Passphrase di archiviazione", + "settings.unlockFailedWrongPass": "Sblocco non riuscito — passphrase errata?", + "settings.notPrivilegedToast": "Impossibile firmare/crittografare: S/MIME non è in esecuzione nel livello privilegiato." + }, + "pt": { + "banner.encryption": "Criptografia", + "banner.decrypted": "Descriptografado", + "banner.encryptedLocked": "Criptografado — desbloqueie sua chave para ler", + "banner.encryptedFailed": "Criptografado — não foi possível descriptografar com suas chaves", + "banner.encryptedMessage": "Mensagem criptografada", + "banner.signature": "Assinatura", + "banner.validSignature": "Assinatura válida", + "banner.validSignatureBy": "Assinatura válida — {email}", + "banner.signedMessage": "Mensagem assinada", + "banner.invalidSignature": "Assinatura inválida: {reason}", + "banner.selfSignedBadge": "autoassinado", + "banner.signerMismatchBadge": "signatário ≠ remetente", + "banner.unlockNow": "Desbloquear agora", + "banner.unlocking": "Desbloqueando…", + "banner.viewCertDetails": "Ver detalhes do certificado", + "banner.hideCertDetails": "Ocultar detalhes do certificado", + "banner.downloadCert": "Baixar certificado", + "banner.certSubject": "Titular", + "banner.certIssuer": "Emissor", + "banner.certEmail": "E-mail", + "banner.certValid": "Válido", + "banner.certFingerprint": "Impressão digital (SHA-256)", + "toolbar.sign": "Assinar", + "toolbar.encrypt": "Criptografar", + "toolbar.signTitle": "Assinar digitalmente esta mensagem", + "toolbar.encryptTitle": "Criptografar esta mensagem para os destinatários", + "toolbar.needKey": "S/MIME: importe uma chave em Configurações para assinar/criptografar", + "settings.title": "Chaves e certificados S/MIME", + "banner.copyCert": "Copiar para a área de transferência", + "banner.copied": "Copiado!", + "banner.copyChain": "Copiar com cadeia", + "banner.certChain": "Cadeia", + "banner.certChainCount": "{count} certificado(s) adicional(is) incluído(s)", + "settings.notActive": "O S/MIME não está ativo", + "settings.yourKeys": "Suas chaves", + "settings.yourKeysDesc": "Importe um arquivo PKCS#12 (.p12/.pfx) contendo seu certificado e chave privada. A chave é criptografada no seu navegador e nunca sai dele.", + "settings.importKey": "Importar chave", + "settings.noKeys": "Nenhuma chave importada ainda.", + "settings.certUnknown": "Certificado", + "settings.validRange": "válido {from} – {to}", + "settings.expired": "EXPIRADO", + "settings.capSign": "assinar", + "settings.capEncrypt": "criptografar", + "settings.lock": "Bloquear", + "settings.unlock": "Desbloquear", + "settings.delete": "Excluir", + "settings.recipientCerts": "Certificados de destinatários", + "settings.recipientCertsDesc": "Certificados públicos (PEM/DER) das pessoas para quem deseja enviar e-mail criptografado. Os certificados do signatário de e-mails validamente assinados são salvos automaticamente.", + "settings.importCert": "Importar certificado", + "settings.noCerts": "Nenhum certificado de destinatário.", + "settings.certExpires": "expira em {date}", + "settings.remove": "Remover", + "settings.defaults": "Padrões para novas mensagens", + "settings.defaultSign": "Assinar novas mensagens por padrão", + "settings.defaultEncrypt": "Criptografar novas mensagens por padrão (quando todos os destinatários tiverem certificados)", + "settings.importKeyDialogTitle": "Importar chave S/MIME", + "settings.importKeyDialogMessage": "Importando \"{file}\".", + "settings.importDialogConfirm": "Importar", + "settings.p12PassLabel": "Senha que protege o arquivo .p12/.pfx", + "settings.p12PassPlaceholder": "Deixe em branco se o arquivo não tiver", + "settings.storagePassLabel": "Nova senha para proteger esta chave no navegador", + "settings.storagePassRequired": "É necessária uma senha de armazenamento", + "settings.importKeySuccess": "Chave S/MIME importada para {email}", + "settings.importKeyFailed": "Falha na importação: {reason}", + "settings.unlockDialogTitle": "Desbloquear {email}", + "settings.unlockConfirm": "Desbloquear", + "settings.storagePassForKeyLabel": "Senha de armazenamento para esta chave", + "settings.unlockSuccess": "{email} desbloqueada", + "settings.unlockFailed": "Falha ao desbloquear", + "settings.keyFallback": "chave", + "settings.lockedToast": "{email} bloqueada", + "settings.deleteDialogTitle": "Excluir chave S/MIME", + "settings.deleteDialogMessage": "Excluir a chave privada e o certificado de {email}? Você não poderá mais descriptografar e-mails criptografados para ela.", + "settings.deleteDialogFallbackIdentity": "esta identidade", + "settings.deleteConfirm": "Excluir", + "settings.keyDeletedToast": "Chave excluída", + "settings.certNoEmail": "O certificado não tem endereço de e-mail", + "settings.importCertSuccess": "Certificado importado para {email}", + "settings.importCertFailed": "Falha ao importar certificado: {reason}", + "settings.unlockSendDialogTitle": "Desbloquear chave S/MIME", + "settings.unlockSendDialogMessage": "Sua chave para {email} está bloqueada. Digite a senha de armazenamento para assinar e enviar.", + "settings.unlockAndSend": "Desbloquear e enviar", + "settings.storagePassphrase": "Senha de armazenamento", + "settings.unlockFailedWrongPass": "Falha ao desbloquear — senha incorreta?", + "settings.notPrivilegedToast": "Não é possível assinar/criptografar: o S/MIME não está em execução no nível privilegiado." + }, + "nl": { + "banner.encryption": "Versleuteling", + "banner.decrypted": "Ontsleuteld", + "banner.encryptedLocked": "Versleuteld — ontgrendel uw sleutel om te lezen", + "banner.encryptedFailed": "Versleuteld — kon niet worden ontsleuteld met uw sleutels", + "banner.encryptedMessage": "Versleuteld bericht", + "banner.signature": "Handtekening", + "banner.validSignature": "Geldige handtekening", + "banner.validSignatureBy": "Geldige handtekening — {email}", + "banner.signedMessage": "Ondertekend bericht", + "banner.invalidSignature": "Ongeldige handtekening: {reason}", + "banner.selfSignedBadge": "zelfondertekend", + "banner.signerMismatchBadge": "ondertekenaar ≠ afzender", + "banner.unlockNow": "Nu ontgrendelen", + "banner.unlocking": "Ontgrendelen…", + "banner.viewCertDetails": "Certificaatgegevens weergeven", + "banner.hideCertDetails": "Certificaatgegevens verbergen", + "banner.downloadCert": "Certificaat downloaden", + "banner.certSubject": "Onderwerp", + "banner.certIssuer": "Uitgever", + "banner.certEmail": "E-mail", + "banner.certValid": "Geldig", + "banner.certFingerprint": "Vingerafdruk (SHA-256)", + "toolbar.sign": "Ondertekenen", + "toolbar.encrypt": "Versleutelen", + "toolbar.signTitle": "Dit bericht digitaal ondertekenen", + "toolbar.encryptTitle": "Dit bericht versleutelen voor de ontvangers", + "toolbar.needKey": "S/MIME: importeer een sleutel in Instellingen om te ondertekenen/versleutelen", + "settings.title": "S/MIME-sleutels & certificaten", + "banner.copyCert": "Naar klembord kopiëren", + "banner.copied": "Gekopieerd!", + "banner.copyChain": "Kopiëren met keten", + "banner.certChain": "Keten", + "banner.certChainCount": "{count} extra certifica(a)t(en) inbegrepen", + "settings.notActive": "S/MIME is niet actief", + "settings.yourKeys": "Uw sleutels", + "settings.yourKeysDesc": "Importeer een PKCS#12-bestand (.p12/.pfx) met uw certificaat en privésleutel. De sleutel wordt in uw browser versleuteld en verlaat deze nooit.", + "settings.importKey": "Sleutel importeren", + "settings.noKeys": "Nog geen sleutels geïmporteerd.", + "settings.certUnknown": "Certificaat", + "settings.validRange": "geldig {from} – {to}", + "settings.expired": "VERLOPEN", + "settings.capSign": "ondertekenen", + "settings.capEncrypt": "versleutelen", + "settings.lock": "Vergrendelen", + "settings.unlock": "Ontgrendelen", + "settings.delete": "Verwijderen", + "settings.recipientCerts": "Certificaten van ontvangers", + "settings.recipientCertsDesc": "Openbare certificaten (PEM/DER) van personen aan wie u versleutelde mail wilt sturen. Certificaten van ondertekenaars uit geldig ondertekende mail worden automatisch opgeslagen.", + "settings.importCert": "Certificaat importeren", + "settings.noCerts": "Geen certificaten van ontvangers.", + "settings.certExpires": "verloopt op {date}", + "settings.remove": "Verwijderen", + "settings.defaults": "Standaardinstellingen voor nieuwe berichten", + "settings.defaultSign": "Nieuwe berichten standaard ondertekenen", + "settings.defaultEncrypt": "Nieuwe berichten standaard versleutelen (als alle ontvangers certificaten hebben)", + "settings.importKeyDialogTitle": "S/MIME-sleutel importeren", + "settings.importKeyDialogMessage": "Bezig met importeren van \"{file}\".", + "settings.importDialogConfirm": "Importeren", + "settings.p12PassLabel": "Wachtwoord dat het .p12/.pfx-bestand beschermt", + "settings.p12PassPlaceholder": "Laat leeg als het bestand er geen heeft", + "settings.storagePassLabel": "Nieuw wachtwoord om deze sleutel in uw browser te beschermen", + "settings.storagePassRequired": "Er is een opslagwachtwoord vereist", + "settings.importKeySuccess": "S/MIME-sleutel geïmporteerd voor {email}", + "settings.importKeyFailed": "Importeren mislukt: {reason}", + "settings.unlockDialogTitle": "{email} ontgrendelen", + "settings.unlockConfirm": "Ontgrendelen", + "settings.storagePassForKeyLabel": "Opslagwachtwoord voor deze sleutel", + "settings.unlockSuccess": "{email} ontgrendeld", + "settings.unlockFailed": "Ontgrendelen mislukt", + "settings.keyFallback": "sleutel", + "settings.lockedToast": "{email} vergrendeld", + "settings.deleteDialogTitle": "S/MIME-sleutel verwijderen", + "settings.deleteDialogMessage": "Privésleutel en certificaat voor {email} verwijderen? U kunt dan geen mail meer ontsleutelen die daarvoor versleuteld is.", + "settings.deleteDialogFallbackIdentity": "deze identiteit", + "settings.deleteConfirm": "Verwijderen", + "settings.keyDeletedToast": "Sleutel verwijderd", + "settings.certNoEmail": "Certificaat heeft geen e-mailadres", + "settings.importCertSuccess": "Certificaat geïmporteerd voor {email}", + "settings.importCertFailed": "Certificaat importeren mislukt: {reason}", + "settings.unlockSendDialogTitle": "S/MIME-sleutel ontgrendelen", + "settings.unlockSendDialogMessage": "Uw sleutel voor {email} is vergrendeld. Voer het opslagwachtwoord in om te ondertekenen en te verzenden.", + "settings.unlockAndSend": "Ontgrendelen & verzenden", + "settings.storagePassphrase": "Opslagwachtwoord", + "settings.unlockFailedWrongPass": "Ontgrendelen mislukt — onjuist wachtwoord?", + "settings.notPrivilegedToast": "Kan niet ondertekenen/versleutelen: S/MIME draait niet in de privileged tier." + }, + "ru": { + "banner.encryption": "Шифрование", + "banner.decrypted": "Расшифровано", + "banner.encryptedLocked": "Зашифровано — разблокируйте ключ для чтения", + "banner.encryptedFailed": "Зашифровано — не удалось расшифровать имеющимися ключами", + "banner.encryptedMessage": "Зашифрованное сообщение", + "banner.signature": "Подпись", + "banner.validSignature": "Подпись действительна", + "banner.validSignatureBy": "Подпись действительна — {email}", + "banner.signedMessage": "Подписанное сообщение", + "banner.invalidSignature": "Недействительная подпись: {reason}", + "banner.selfSignedBadge": "самоподписанный", + "banner.signerMismatchBadge": "подписант ≠ отправитель", + "banner.unlockNow": "Разблокировать", + "banner.unlocking": "Разблокировка…", + "banner.viewCertDetails": "Показать данные сертификата", + "banner.hideCertDetails": "Скрыть данные сертификата", + "banner.downloadCert": "Скачать сертификат", + "banner.certSubject": "Владелец", + "banner.certIssuer": "Издатель", + "banner.certEmail": "Эл. почта", + "banner.certValid": "Срок действия", + "banner.certFingerprint": "Отпечаток (SHA-256)", + "toolbar.sign": "Подписать", + "toolbar.encrypt": "Зашифровать", + "toolbar.signTitle": "Подписать это сообщение цифровой подписью", + "toolbar.encryptTitle": "Зашифровать это сообщение для получателей", + "toolbar.needKey": "S/MIME: импортируйте ключ в настройках, чтобы подписывать/шифровать", + "settings.title": "Ключи и сертификаты S/MIME", + "banner.copyCert": "Скопировать в буфер обмена", + "banner.copied": "Скопировано!", + "banner.copyChain": "Копировать с цепочкой", + "banner.certChain": "Цепочка", + "banner.certChainCount": "включено доп. сертификатов: {count}", + "settings.notActive": "S/MIME не активен", + "settings.yourKeys": "Ваши ключи", + "settings.yourKeysDesc": "Импортируйте файл PKCS#12 (.p12/.pfx) с вашим сертификатом и закрытым ключом. Ключ шифруется в браузере и никогда не покидает его.", + "settings.importKey": "Импортировать ключ", + "settings.noKeys": "Ключи ещё не импортированы.", + "settings.certUnknown": "Сертификат", + "settings.validRange": "действителен {from} – {to}", + "settings.expired": "ИСТЁК", + "settings.capSign": "подпись", + "settings.capEncrypt": "шифрование", + "settings.lock": "Заблокировать", + "settings.unlock": "Разблокировать", + "settings.delete": "Удалить", + "settings.recipientCerts": "Сертификаты получателей", + "settings.recipientCertsDesc": "Открытые сертификаты (PEM/DER) людей, которым вы хотите отправлять зашифрованную почту. Сертификаты подписавших из корректно подписанных писем сохраняются автоматически.", + "settings.importCert": "Импортировать сертификат", + "settings.noCerts": "Нет сертификатов получателей.", + "settings.certExpires": "истекает {date}", + "settings.remove": "Удалить", + "settings.defaults": "Значения по умолчанию для новых сообщений", + "settings.defaultSign": "Подписывать новые сообщения по умолчанию", + "settings.defaultEncrypt": "Шифровать новые сообщения по умолчанию (если у всех получателей есть сертификаты)", + "settings.importKeyDialogTitle": "Импорт ключа S/MIME", + "settings.importKeyDialogMessage": "Импорт файла «{file}».", + "settings.importDialogConfirm": "Импортировать", + "settings.p12PassLabel": "Пароль, защищающий файл .p12/.pfx", + "settings.p12PassPlaceholder": "Оставьте пустым, если у файла его нет", + "settings.storagePassLabel": "Новый пароль для защиты этого ключа в браузере", + "settings.storagePassRequired": "Требуется пароль для хранения", + "settings.importKeySuccess": "Ключ S/MIME импортирован для {email}", + "settings.importKeyFailed": "Ошибка импорта: {reason}", + "settings.unlockDialogTitle": "Разблокировать {email}", + "settings.unlockConfirm": "Разблокировать", + "settings.storagePassForKeyLabel": "Пароль хранения для этого ключа", + "settings.unlockSuccess": "{email} разблокирован", + "settings.unlockFailed": "Не удалось разблокировать", + "settings.keyFallback": "ключ", + "settings.lockedToast": "{email} заблокирован", + "settings.deleteDialogTitle": "Удалить ключ S/MIME", + "settings.deleteDialogMessage": "Удалить закрытый ключ и сертификат для {email}? Вы больше не сможете расшифровывать почту, зашифрованную для него.", + "settings.deleteDialogFallbackIdentity": "эту личность", + "settings.deleteConfirm": "Удалить", + "settings.keyDeletedToast": "Ключ удалён", + "settings.certNoEmail": "У сертификата нет адреса эл. почты", + "settings.importCertSuccess": "Сертификат импортирован для {email}", + "settings.importCertFailed": "Ошибка импорта сертификата: {reason}", + "settings.unlockSendDialogTitle": "Разблокировать ключ S/MIME", + "settings.unlockSendDialogMessage": "Ваш ключ для {email} заблокирован. Введите пароль хранения, чтобы подписать и отправить.", + "settings.unlockAndSend": "Разблокировать и отправить", + "settings.storagePassphrase": "Пароль хранения", + "settings.unlockFailedWrongPass": "Не удалось разблокировать — неверный пароль?", + "settings.notPrivilegedToast": "Невозможно подписать/зашифровать: S/MIME не работает в привилегированном режиме." + }, + "zh": { + "banner.encryption": "加密", + "banner.decrypted": "已解密", + "banner.encryptedLocked": "已加密 — 请解锁密钥以阅读", + "banner.encryptedFailed": "已加密 — 无法使用您的密钥解密", + "banner.encryptedMessage": "加密邮件", + "banner.signature": "签名", + "banner.validSignature": "签名有效", + "banner.validSignatureBy": "签名有效 — {email}", + "banner.signedMessage": "已签名邮件", + "banner.invalidSignature": "签名无效:{reason}", + "banner.selfSignedBadge": "自签名", + "banner.signerMismatchBadge": "签名人 ≠ 发件人", + "banner.unlockNow": "立即解锁", + "banner.unlocking": "正在解锁…", + "banner.viewCertDetails": "查看证书详情", + "banner.hideCertDetails": "隐藏证书详情", + "banner.downloadCert": "下载证书", + "banner.certSubject": "主体", + "banner.certIssuer": "颁发者", + "banner.certEmail": "电子邮件", + "banner.certValid": "有效期", + "banner.certFingerprint": "指纹(SHA-256)", + "toolbar.sign": "签名", + "toolbar.encrypt": "加密", + "toolbar.signTitle": "对此邮件进行数字签名", + "toolbar.encryptTitle": "为收件人加密此邮件", + "toolbar.needKey": "S/MIME:请在设置中导入密钥以签名/加密", + "settings.title": "S/MIME 密钥与证书", + "banner.copyCert": "复制到剪贴板", + "banner.copied": "已复制!", + "banner.copyChain": "复制(含证书链)", + "banner.certChain": "证书链", + "banner.certChainCount": "包含 {count} 个附加证书", + "settings.notActive": "S/MIME 未启用", + "settings.yourKeys": "您的密钥", + "settings.yourKeysDesc": "导入包含您的证书和私钥的 PKCS#12(.p12/.pfx)文件。密钥在浏览器中加密,永远不会离开浏览器。", + "settings.importKey": "导入密钥", + "settings.noKeys": "尚未导入任何密钥。", + "settings.certUnknown": "证书", + "settings.validRange": "有效期 {from} 至 {to}", + "settings.expired": "已过期", + "settings.capSign": "签名", + "settings.capEncrypt": "加密", + "settings.lock": "锁定", + "settings.unlock": "解锁", + "settings.delete": "删除", + "settings.recipientCerts": "收件人证书", + "settings.recipientCertsDesc": "您想向其发送加密邮件的人员的公钥证书(PEM/DER)。来自有效签名邮件的签名者证书会自动保存。", + "settings.importCert": "导入证书", + "settings.noCerts": "没有收件人证书。", + "settings.certExpires": "{date} 到期", + "settings.remove": "移除", + "settings.defaults": "新邮件的默认设置", + "settings.defaultSign": "默认对新邮件签名", + "settings.defaultEncrypt": "默认加密新邮件(当所有收件人都有证书时)", + "settings.importKeyDialogTitle": "导入 S/MIME 密钥", + "settings.importKeyDialogMessage": "正在导入 \"{file}\"。", + "settings.importDialogConfirm": "导入", + "settings.p12PassLabel": "保护 .p12/.pfx 文件的密码", + "settings.p12PassPlaceholder": "如果文件没有密码请留空", + "settings.storagePassLabel": "用于在浏览器中保护此密钥的新密码", + "settings.storagePassRequired": "需要设置存储密码", + "settings.importKeySuccess": "已为 {email} 导入 S/MIME 密钥", + "settings.importKeyFailed": "导入失败:{reason}", + "settings.unlockDialogTitle": "解锁 {email}", + "settings.unlockConfirm": "解锁", + "settings.storagePassForKeyLabel": "此密钥的存储密码", + "settings.unlockSuccess": "已解锁 {email}", + "settings.unlockFailed": "解锁失败", + "settings.keyFallback": "密钥", + "settings.lockedToast": "已锁定 {email}", + "settings.deleteDialogTitle": "删除 S/MIME 密钥", + "settings.deleteDialogMessage": "删除 {email} 的私钥和证书?之后将无法解密发给该地址的加密邮件。", + "settings.deleteDialogFallbackIdentity": "该身份", + "settings.deleteConfirm": "删除", + "settings.keyDeletedToast": "密钥已删除", + "settings.certNoEmail": "证书没有电子邮件地址", + "settings.importCertSuccess": "已为 {email} 导入证书", + "settings.importCertFailed": "证书导入失败:{reason}", + "settings.unlockSendDialogTitle": "解锁 S/MIME 密钥", + "settings.unlockSendDialogMessage": "您用于 {email} 的密钥已锁定。请输入其存储密码以签名并发送。", + "settings.unlockAndSend": "解锁并发送", + "settings.storagePassphrase": "存储密码", + "settings.unlockFailedWrongPass": "解锁失败——密码错误?", + "settings.notPrivilegedToast": "无法签名/加密:S/MIME 未在特权层级中运行。" + }, + "ja": { + "banner.encryption": "暗号化", + "banner.decrypted": "復号済み", + "banner.encryptedLocked": "暗号化されています — 読むには鍵のロックを解除してください", + "banner.encryptedFailed": "暗号化されています — お使いの鍵では復号できませんでした", + "banner.encryptedMessage": "暗号化されたメッセージ", + "banner.signature": "署名", + "banner.validSignature": "有効な署名", + "banner.validSignatureBy": "有効な署名 — {email}", + "banner.signedMessage": "署名付きメッセージ", + "banner.invalidSignature": "無効な署名: {reason}", + "banner.selfSignedBadge": "自己署名", + "banner.signerMismatchBadge": "署名者 ≠ 差出人", + "banner.unlockNow": "今すぐロック解除", + "banner.unlocking": "ロック解除中…", + "banner.viewCertDetails": "証明書の詳細を表示", + "banner.hideCertDetails": "証明書の詳細を隠す", + "banner.downloadCert": "証明書をダウンロード", + "banner.certSubject": "サブジェクト", + "banner.certIssuer": "発行者", + "banner.certEmail": "メール", + "banner.certValid": "有効期間", + "banner.certFingerprint": "フィンガープリント(SHA-256)", + "toolbar.sign": "署名", + "toolbar.encrypt": "暗号化", + "toolbar.signTitle": "このメッセージにデジタル署名する", + "toolbar.encryptTitle": "受信者向けにこのメッセージを暗号化する", + "toolbar.needKey": "S/MIME: 署名・暗号化するには設定で鍵をインポートしてください", + "settings.title": "S/MIME 鍵と証明書", + "banner.copyCert": "クリップボードにコピー", + "banner.copied": "コピーしました!", + "banner.copyChain": "チェーンを含めてコピー", + "banner.certChain": "証明書チェーン", + "banner.certChainCount": "追加証明書 {count} 件を含む", + "settings.notActive": "S/MIME は有効になっていません", + "settings.yourKeys": "あなたの鍵", + "settings.yourKeysDesc": "証明書と秘密鍵を含む PKCS#12(.p12/.pfx)ファイルをインポートします。鍵はブラウザ内で暗号化され、外部に出ることはありません。", + "settings.importKey": "鍵をインポート", + "settings.noKeys": "まだ鍵がインポートされていません。", + "settings.certUnknown": "証明書", + "settings.validRange": "有効期間 {from} ~ {to}", + "settings.expired": "期限切れ", + "settings.capSign": "署名", + "settings.capEncrypt": "暗号化", + "settings.lock": "ロック", + "settings.unlock": "ロック解除", + "settings.delete": "削除", + "settings.recipientCerts": "受信者証明書", + "settings.recipientCertsDesc": "暗号化メールを送りたい相手の公開証明書(PEM/DER)。有効に署名されたメールの署名者証明書は自動的に保存されます。", + "settings.importCert": "証明書をインポート", + "settings.noCerts": "受信者証明書はありません。", + "settings.certExpires": "有効期限 {date}", + "settings.remove": "削除", + "settings.defaults": "新規メッセージの既定値", + "settings.defaultSign": "新規メッセージにデフォルトで署名する", + "settings.defaultEncrypt": "新規メッセージをデフォルトで暗号化する(すべての受信者が証明書を持っている場合)", + "settings.importKeyDialogTitle": "S/MIME 鍵をインポート", + "settings.importKeyDialogMessage": "\"{file}\" をインポート中です。", + "settings.importDialogConfirm": "インポート", + "settings.p12PassLabel": "「.p12/.pfx」ファイルを保護するパスフレーズ", + "settings.p12PassPlaceholder": "ファイルにパスフレーズがない場合は空欄のままにしてください", + "settings.storagePassLabel": "この鍵をブラウザ内で保護するための新しいパスフレーズ", + "settings.storagePassRequired": "保存用パスフレーズが必要です", + "settings.importKeySuccess": "{email} 用の S/MIME 鍵をインポートしました", + "settings.importKeyFailed": "インポートに失敗しました: {reason}", + "settings.unlockDialogTitle": "{email} のロックを解除", + "settings.unlockConfirm": "ロック解除", + "settings.storagePassForKeyLabel": "この鍵の保存用パスフレーズ", + "settings.unlockSuccess": "{email} のロックを解除しました", + "settings.unlockFailed": "ロック解除に失敗しました", + "settings.keyFallback": "鍵", + "settings.lockedToast": "{email} をロックしました", + "settings.deleteDialogTitle": "S/MIME 鍵を削除", + "settings.deleteDialogMessage": "{email} の秘密鍵と証明書を削除しますか? 削除後は、この宛先向けに暗号化されたメールを復号できなくなります。", + "settings.deleteDialogFallbackIdentity": "この ID", + "settings.deleteConfirm": "削除", + "settings.keyDeletedToast": "鍵を削除しました", + "settings.certNoEmail": "証明書にメールアドレスがありません", + "settings.importCertSuccess": "{email} 用の証明書をインポートしました", + "settings.importCertFailed": "証明書のインポートに失敗しました: {reason}", + "settings.unlockSendDialogTitle": "S/MIME 鍵のロックを解除", + "settings.unlockSendDialogMessage": "{email} 用の鍵がロックされています。署名して送信するには、保存用パスフレーズを入力してください。", + "settings.unlockAndSend": "ロック解除して送信", + "settings.storagePassphrase": "保存用パスフレーズ", + "settings.unlockFailedWrongPass": "ロック解除に失敗しました — パスフレーズが違いますか?", + "settings.notPrivilegedToast": "署名・暗号化できません: S/MIME が privileged tier で動作していません。" + }, + "ko": { + "banner.encryption": "암호화", + "banner.decrypted": "복호화됨", + "banner.encryptedLocked": "암호화됨 — 읽으려면 키 잠금을 해제하세요", + "banner.encryptedFailed": "암호화됨 — 보유한 키로 복호화할 수 없습니다", + "banner.encryptedMessage": "암호화된 메시지", + "banner.signature": "서명", + "banner.validSignature": "유효한 서명", + "banner.validSignatureBy": "유효한 서명 — {email}", + "banner.signedMessage": "서명된 메시지", + "banner.invalidSignature": "유효하지 않은 서명: {reason}", + "banner.selfSignedBadge": "자체 서명", + "banner.signerMismatchBadge": "서명자 ≠ 보낸 사람", + "banner.unlockNow": "지금 잠금 해제", + "banner.unlocking": "잠금 해제 중…", + "banner.viewCertDetails": "인증서 세부정보 보기", + "banner.hideCertDetails": "인증서 세부정보 숨기기", + "banner.downloadCert": "인증서 다운로드", + "banner.certSubject": "주체", + "banner.certIssuer": "발급자", + "banner.certEmail": "이메일", + "banner.certValid": "유효 기간", + "banner.certFingerprint": "지문(SHA-256)", + "toolbar.sign": "서명", + "toolbar.encrypt": "암호화", + "toolbar.signTitle": "이 메시지에 전자 서명하기", + "toolbar.encryptTitle": "수신자를 위해 이 메시지 암호화하기", + "toolbar.needKey": "S/MIME: 서명/암호화하려면 설정에서 키를 가져오세요", + "settings.title": "S/MIME 키 및 인증서", + "banner.copyCert": "클립보드에 복사", + "banner.copied": "복사됨!", + "banner.copyChain": "체인 포함하여 복사", + "banner.certChain": "체인", + "banner.certChainCount": "추가 인증서 {count}개 포함", + "settings.notActive": "S/MIME이 활성화되어 있지 않습니다", + "settings.yourKeys": "내 키", + "settings.yourKeysDesc": "인증서와 개인 키가 포함된 PKCS#12(.p12/.pfx) 파일을 가져옵니다. 키는 브라우저에서 암호화되며 브라우저를 벗어나지 않습니다.", + "settings.importKey": "키 가져오기", + "settings.noKeys": "아직 가져온 키가 없습니다.", + "settings.certUnknown": "인증서", + "settings.validRange": "유효 기간 {from} – {to}", + "settings.expired": "만료됨", + "settings.capSign": "서명", + "settings.capEncrypt": "암호화", + "settings.lock": "잠금", + "settings.unlock": "잠금 해제", + "settings.delete": "삭제", + "settings.recipientCerts": "수신자 인증서", + "settings.recipientCertsDesc": "암호화된 메일을 보낼 사람의 공개 인증서(PEM/DER)입니다. 유효하게 서명된 메일의 서명자 인증서는 자동으로 저장됩니다.", + "settings.importCert": "인증서 가져오기", + "settings.noCerts": "수신자 인증서가 없습니다.", + "settings.certExpires": "{date} 만료", + "settings.remove": "제거", + "settings.defaults": "새 메시지 기본값", + "settings.defaultSign": "새 메시지에 기본적으로 서명", + "settings.defaultEncrypt": "새 메시지 기본적으로 암호화(모든 수신자가 인증서를 보유한 경우)", + "settings.importKeyDialogTitle": "S/MIME 키 가져오기", + "settings.importKeyDialogMessage": "\"{file}\" 가져오는 중입니다.", + "settings.importDialogConfirm": "가져오기", + "settings.p12PassLabel": ".p12/.pfx 파일을 보호하는 암호", + "settings.p12PassPlaceholder": "파일에 암호가 없으면 비워 두세요", + "settings.storagePassLabel": "이 키를 브라우저에서 보호할 새 암호", + "settings.storagePassRequired": "저장용 암호가 필요합니다", + "settings.importKeySuccess": "{email}의 S/MIME 키를 가져왔습니다", + "settings.importKeyFailed": "가져오기 실패: {reason}", + "settings.unlockDialogTitle": "{email} 잠금 해제", + "settings.unlockConfirm": "잠금 해제", + "settings.storagePassForKeyLabel": "이 키의 저장용 암호", + "settings.unlockSuccess": "{email} 잠금 해제됨", + "settings.unlockFailed": "잠금 해제 실패", + "settings.keyFallback": "키", + "settings.lockedToast": "{email} 잠금됨", + "settings.deleteDialogTitle": "S/MIME 키 삭제", + "settings.deleteDialogMessage": "{email}의 개인 키와 인증서를 삭제하시겠습니까? 이후 해당 주소로 암호화된 메일을 복호화할 수 없습니다.", + "settings.deleteDialogFallbackIdentity": "이 신원", + "settings.deleteConfirm": "삭제", + "settings.keyDeletedToast": "키 삭제됨", + "settings.certNoEmail": "인증서에 이메일 주소가 없습니다", + "settings.importCertSuccess": "{email}의 인증서를 가져왔습니다", + "settings.importCertFailed": "인증서 가져오기 실패: {reason}", + "settings.unlockSendDialogTitle": "S/MIME 키 잠금 해제", + "settings.unlockSendDialogMessage": "{email}의 키가 잠겨 있습니다. 서명하고 보내려면 저장용 암호를 입력하세요.", + "settings.unlockAndSend": "잠금 해제 후 보내기", + "settings.storagePassphrase": "저장용 암호", + "settings.unlockFailedWrongPass": "잠금 해제 실패 — 암호가 틀렸나요?", + "settings.notPrivilegedToast": "서명/암호화할 수 없음: S/MIME이 privileged tier에서 실행되고 있지 않습니다." + }, + "ar": { + "banner.encryption": "التشفير", + "banner.decrypted": "تم فك التشفير", + "banner.encryptedLocked": "مشفّرة — افتح قفل مفتاحك للقراءة", + "banner.encryptedFailed": "مشفّرة — تعذّر فك التشفير بالمفاتيح المتاحة لديك", + "banner.encryptedMessage": "رسالة مشفّرة", + "banner.signature": "التوقيع", + "banner.validSignature": "توقيع صالح", + "banner.validSignatureBy": "توقيع صالح — {email}", + "banner.signedMessage": "رسالة موقّعة", + "banner.invalidSignature": "توقيع غير صالح: {reason}", + "banner.selfSignedBadge": "موقّع ذاتيًا", + "banner.signerMismatchBadge": "الموقّع ≠ المُرسِل", + "banner.unlockNow": "فتح القفل الآن", + "banner.unlocking": "جارٍ فتح القفل…", + "banner.viewCertDetails": "عرض تفاصيل الشهادة", + "banner.hideCertDetails": "إخفاء تفاصيل الشهادة", + "banner.downloadCert": "تنزيل الشهادة", + "banner.certSubject": "الجهة", + "banner.certIssuer": "المُصدِر", + "banner.certEmail": "البريد الإلكتروني", + "banner.certValid": "صالحة من", + "banner.certFingerprint": "البصمة (SHA-256)", + "toolbar.sign": "توقيع", + "toolbar.encrypt": "تشفير", + "toolbar.signTitle": "توقيع هذه الرسالة رقميًا", + "toolbar.encryptTitle": "تشفير هذه الرسالة للمستلمين", + "toolbar.needKey": "S/MIME: استورد مفتاحًا من الإعدادات للتوقيع/التشفير", + "settings.title": "مفاتيح وشهادات S/MIME", + "banner.copyCert": "نسخ إلى الحافظة", + "banner.copied": "تم النسخ!", + "banner.copyChain": "نسخ مع السلسلة", + "banner.certChain": "السلسلة", + "banner.certChainCount": "تتضمن {count} شهادة إضافية", + "settings.notActive": "S/MIME غير مُفعّل", + "settings.yourKeys": "مفاتيحك", + "settings.yourKeysDesc": "استورد ملف PKCS#12 (.p12/.pfx) يحتوي على شهادتك ومفتاحك الخاص. يُشفَّر المفتاح في متصفحك ولا يغادره أبدًا.", + "settings.importKey": "استيراد مفتاح", + "settings.noKeys": "لم يتم استيراد أي مفاتيح بعد.", + "settings.certUnknown": "شهادة", + "settings.validRange": "صالحة من {from} إلى {to}", + "settings.expired": "منتهية الصلاحية", + "settings.capSign": "التوقيع", + "settings.capEncrypt": "التشفير", + "settings.lock": "قفل", + "settings.unlock": "فتح القفل", + "settings.delete": "حذف", + "settings.recipientCerts": "شهادات المستلمين", + "settings.recipientCertsDesc": "الشهادات العامة (PEM/DER) للأشخاص الذين تريد إرسال بريد مشفّر إليهم. تُحفَظ شهادات الموقّعين من الرسائل الموقّعة بشكل صحيح تلقائيًا.", + "settings.importCert": "استيراد شهادة", + "settings.noCerts": "لا توجد شهادات مستلمين.", + "settings.certExpires": "تنتهي في {date}", + "settings.remove": "إزالة", + "settings.defaults": "الإعدادات الافتراضية للرسائل الجديدة", + "settings.defaultSign": "توقيع الرسائل الجديدة افتراضيًا", + "settings.defaultEncrypt": "تشفير الرسائل الجديدة افتراضيًا (عندما يملك جميع المستلمين شهادات)", + "settings.importKeyDialogTitle": "استيراد مفتاح S/MIME", + "settings.importKeyDialogMessage": "جارٍ استيراد \"{file}\".", + "settings.importDialogConfirm": "استيراد", + "settings.p12PassLabel": "كلمة المرور التي تحمي ملف .p12/.pfx", + "settings.p12PassPlaceholder": "اتركه فارغًا إذا لم يكن للملف كلمة مرور", + "settings.storagePassLabel": "كلمة مرور جديدة لحماية هذا المفتاح في متصفحك", + "settings.storagePassRequired": "كلمة مرور التخزين مطلوبة", + "settings.importKeySuccess": "تم استيراد مفتاح S/MIME للعنوان {email}", + "settings.importKeyFailed": "فشل الاستيراد: {reason}", + "settings.unlockDialogTitle": "فتح قفل {email}", + "settings.unlockConfirm": "فتح القفل", + "settings.storagePassForKeyLabel": "كلمة مرور التخزين لهذا المفتاح", + "settings.unlockSuccess": "تم فتح قفل {email}", + "settings.unlockFailed": "فشل فتح القفل", + "settings.keyFallback": "مفتاح", + "settings.lockedToast": "تم قفل {email}", + "settings.deleteDialogTitle": "حذف مفتاح S/MIME", + "settings.deleteDialogMessage": "هل تريد حذف المفتاح الخاص والشهادة لـ {email}؟ لن تتمكن بعد ذلك من فك تشفير البريد المشفّر لهذا العنوان.", + "settings.deleteDialogFallbackIdentity": "هذه الهوية", + "settings.deleteConfirm": "حذف", + "settings.keyDeletedToast": "تم حذف المفتاح", + "settings.certNoEmail": "الشهادة لا تحتوي على عنوان بريد إلكتروني", + "settings.importCertSuccess": "تم استيراد الشهادة للعنوان {email}", + "settings.importCertFailed": "فشل استيراد الشهادة: {reason}", + "settings.unlockSendDialogTitle": "فتح قفل مفتاح S/MIME", + "settings.unlockSendDialogMessage": "مفتاحك الخاص بـ {email} مُقفل. أدخل كلمة مرور التخزين للتوقيع والإرسال.", + "settings.unlockAndSend": "فتح القفل والإرسال", + "settings.storagePassphrase": "كلمة مرور التخزين", + "settings.unlockFailedWrongPass": "فشل فتح القفل — كلمة مرور خاطئة؟", + "settings.notPrivilegedToast": "تعذّر التوقيع/التشفير: S/MIME لا يعمل في الطبقة المميزة." + }, + "tr": { + "banner.encryption": "Şifreleme", + "banner.decrypted": "Şifresi çözüldü", + "banner.encryptedLocked": "Şifreli — okumak için anahtarınızın kilidini açın", + "banner.encryptedFailed": "Şifreli — mevcut anahtarlarınızla çözülemedi", + "banner.encryptedMessage": "Şifreli ileti", + "banner.signature": "İmza", + "banner.validSignature": "Geçerli imza", + "banner.validSignatureBy": "Geçerli imza — {email}", + "banner.signedMessage": "İmzalı ileti", + "banner.invalidSignature": "Geçersiz imza: {reason}", + "banner.selfSignedBadge": "kendinden imzalı", + "banner.signerMismatchBadge": "imzalayan ≠ gönderen", + "banner.unlockNow": "Şimdi kilidi aç", + "banner.unlocking": "Kilit açılıyor…", + "banner.viewCertDetails": "Sertifika ayrıntılarını görüntüle", + "banner.hideCertDetails": "Sertifika ayrıntılarını gizle", + "banner.downloadCert": "Sertifikayı indir", + "banner.certSubject": "Konu", + "banner.certIssuer": "Veren", + "banner.certEmail": "E-posta", + "banner.certValid": "Geçerlilik", + "banner.certFingerprint": "Parmak izi (SHA-256)", + "toolbar.sign": "İmzala", + "toolbar.encrypt": "Şifrele", + "toolbar.signTitle": "Bu iletiyi dijital olarak imzala", + "toolbar.encryptTitle": "Bu iletiyi alıcılar için şifrele", + "toolbar.needKey": "S/MIME: imzalamak/şifrelemek için Ayarlar'dan bir anahtar içe aktarın", + "settings.title": "S/MIME anahtarları ve sertifikaları", + "banner.copyCert": "Panoya kopyala", + "banner.copied": "Kopyalandı!", + "banner.copyChain": "Zincirle birlikte kopyala", + "banner.certChain": "Zincir", + "banner.certChainCount": "{count} ek sertifika dahil", + "settings.notActive": "S/MIME etkin değil", + "settings.yourKeys": "Anahtarlarınız", + "settings.yourKeysDesc": "Sertifikanızı ve özel anahtarınızı içeren bir PKCS#12 (.p12/.pfx) dosyası içe aktarın. Anahtar tarayıcınızda şifrelenir ve tarayıcıyı asla terk etmez.", + "settings.importKey": "Anahtar içe aktar", + "settings.noKeys": "Henüz anahtar içe aktarılmadı.", + "settings.certUnknown": "Sertifika", + "settings.validRange": "geçerlilik {from} – {to}", + "settings.expired": "SÜRESİ DOLDU", + "settings.capSign": "imzalama", + "settings.capEncrypt": "şifreleme", + "settings.lock": "Kilitle", + "settings.unlock": "Kilidi aç", + "settings.delete": "Sil", + "settings.recipientCerts": "Alıcı sertifikaları", + "settings.recipientCertsDesc": "Şifreli posta göndermek istediğiniz kişilerin genel sertifikaları (PEM/DER). Geçerli şekilde imzalanmış postalardaki imzalayan sertifikaları otomatik olarak kaydedilir.", + "settings.importCert": "Sertifika içe aktar", + "settings.noCerts": "Alıcı sertifikası yok.", + "settings.certExpires": "{date} tarihinde sona eriyor", + "settings.remove": "Kaldır", + "settings.defaults": "Yeni iletiler için varsayılanlar", + "settings.defaultSign": "Yeni iletileri varsayılan olarak imzala", + "settings.defaultEncrypt": "Yeni iletileri varsayılan olarak şifrele (tüm alıcıların sertifikası olduğunda)", + "settings.importKeyDialogTitle": "S/MIME anahtarı içe aktar", + "settings.importKeyDialogMessage": "\"{file}\" içe aktarılıyor.", + "settings.importDialogConfirm": "İçe aktar", + "settings.p12PassLabel": ".p12/.pfx dosyasını koruyan parola", + "settings.p12PassPlaceholder": "Dosyanın parolası yoksa boş bırakın", + "settings.storagePassLabel": "Bu anahtarı tarayıcınızda korumak için yeni parola", + "settings.storagePassRequired": "Bir depolama parolası gerekli", + "settings.importKeySuccess": "{email} için S/MIME anahtarı içe aktarıldı", + "settings.importKeyFailed": "İçe aktarma başarısız: {reason}", + "settings.unlockDialogTitle": "{email} kilidini aç", + "settings.unlockConfirm": "Kilidi aç", + "settings.storagePassForKeyLabel": "Bu anahtar için depolama parolası", + "settings.unlockSuccess": "{email} kilidi açıldı", + "settings.unlockFailed": "Kilit açma başarısız", + "settings.keyFallback": "anahtar", + "settings.lockedToast": "{email} kilitlendi", + "settings.deleteDialogTitle": "S/MIME anahtarını sil", + "settings.deleteDialogMessage": "{email} için özel anahtar ve sertifika silinsin mi? Bundan sonra ona şifrelenmiş postaların şifresini çözemezsiniz.", + "settings.deleteDialogFallbackIdentity": "bu kimlik", + "settings.deleteConfirm": "Sil", + "settings.keyDeletedToast": "Anahtar silindi", + "settings.certNoEmail": "Sertifikada e-posta adresi yok", + "settings.importCertSuccess": "{email} için sertifika içe aktarıldı", + "settings.importCertFailed": "Sertifika içe aktarma başarısız: {reason}", + "settings.unlockSendDialogTitle": "S/MIME anahtalının kilidini aç", + "settings.unlockSendDialogMessage": "{email} için anahtarınız kilitli. İmzalamak ve göndermek için depolama parolasını girin.", + "settings.unlockAndSend": "Kilidi aç ve gönder", + "settings.storagePassphrase": "Depolama parolası", + "settings.unlockFailedWrongPass": "Kilit açma başarısız — yanlış parola mı?", + "settings.notPrivilegedToast": "İmzalanamıyor/şifrelenemiyor: S/MIME ayrıcalıklı katmanda çalışmıyor." + }, + "pl": { + "banner.encryption": "Szyfrowanie", + "banner.decrypted": "Odszyfrowano", + "banner.encryptedLocked": "Zaszyfrowane — odblokuj klucz, aby odczytać", + "banner.encryptedFailed": "Zaszyfrowane — nie udało się odszyfrować dostępnymi kluczami", + "banner.encryptedMessage": "Zaszyfrowana wiadomość", + "banner.signature": "Podpis", + "banner.validSignature": "Podpis prawidłowy", + "banner.validSignatureBy": "Podpis prawidłowy — {email}", + "banner.signedMessage": "Podpisana wiadomość", + "banner.invalidSignature": "Nieprawidłowy podpis: {reason}", + "banner.selfSignedBadge": "samopodpisany", + "banner.signerMismatchBadge": "podpisujący ≠ nadawca", + "banner.unlockNow": "Odblokuj teraz", + "banner.unlocking": "Odblokowywanie…", + "banner.viewCertDetails": "Pokaż szczegóły certyfikatu", + "banner.hideCertDetails": "Ukryj szczegóły certyfikatu", + "banner.downloadCert": "Pobierz certyfikat", + "banner.certSubject": "Podmiot", + "banner.certIssuer": "Wystawca", + "banner.certEmail": "E-mail", + "banner.certValid": "Ważność", + "banner.certFingerprint": "Odcisk (SHA-256)", + "toolbar.sign": "Podpisz", + "toolbar.encrypt": "Zaszyfruj", + "toolbar.signTitle": "Podpisz cyfrowo tę wiadomość", + "toolbar.encryptTitle": "Zaszyfruj tę wiadomość dla odbiorców", + "toolbar.needKey": "S/MIME: zaimportuj klucz w Ustawieniach, aby podpisywać/szyfrować", + "settings.title": "Klucze i certyfikaty S/MIME", + "banner.copyCert": "Kopiuj do schowka", + "banner.copied": "Skopiowano!", + "banner.copyChain": "Kopiuj z łańcuchem", + "banner.certChain": "Łańcuch", + "banner.certChainCount": "dołączono dodatkowych certyfikatów: {count}", + "settings.notActive": "S/MIME nie jest aktywne", + "settings.yourKeys": "Twoje klucze", + "settings.yourKeysDesc": "Zaimportuj plik PKCS#12 (.p12/.pfx) zawierający Twój certyfikat i klucz prywatny. Klucz jest szyfrowany w przeglądarce i nigdy jej nie opuszcza.", + "settings.importKey": "Importuj klucz", + "settings.noKeys": "Nie zaimportowano jeszcze żadnych kluczy.", + "settings.certUnknown": "Certyfikat", + "settings.validRange": "ważny {from} – {to}", + "settings.expired": "WYGASŁ", + "settings.capSign": "podpis", + "settings.capEncrypt": "szyfrowanie", + "settings.lock": "Zablokuj", + "settings.unlock": "Odblokuj", + "settings.delete": "Usuń", + "settings.recipientCerts": "Certyfikaty odbiorców", + "settings.recipientCertsDesc": "Certyfikaty publiczne (PEM/DER) osób, do których chcesz wysyłać szyfrowaną pocztę. Certyfikaty podpisujących z poprawnie podpisanych wiadomości są zapisywane automatycznie.", + "settings.importCert": "Importuj certyfikat", + "settings.noCerts": "Brak certyfikatów odbiorców.", + "settings.certExpires": "wygasa {date}", + "settings.remove": "Usuń", + "settings.defaults": "Ustawienia domyślne dla nowych wiadomości", + "settings.defaultSign": "Domyślnie podpisuj nowe wiadomości", + "settings.defaultEncrypt": "Domyślnie szyfruj nowe wiadomości (gdy wszyscy odbiorcy mają certyfikaty)", + "settings.importKeyDialogTitle": "Importuj klucz S/MIME", + "settings.importKeyDialogMessage": "Importowanie „{file}”.", + "settings.importDialogConfirm": "Importuj", + "settings.p12PassLabel": "Hasło chroniące plik .p12/.pfx", + "settings.p12PassPlaceholder": "Pozostaw puste, jeśli plik go nie ma", + "settings.storagePassLabel": "Nowe hasło do ochrony tego klucza w przeglądarce", + "settings.storagePassRequired": "Wymagane jest hasło przechowywania", + "settings.importKeySuccess": "Zaimportowano klucz S/MIME dla {email}", + "settings.importKeyFailed": "Import nie powiódł się: {reason}", + "settings.unlockDialogTitle": "Odblokuj {email}", + "settings.unlockConfirm": "Odblokuj", + "settings.storagePassForKeyLabel": "Hasło przechowywania dla tego klucza", + "settings.unlockSuccess": "Odblokowano {email}", + "settings.unlockFailed": "Odblokowanie nie powiodło się", + "settings.keyFallback": "klucz", + "settings.lockedToast": "Zablokowano {email}", + "settings.deleteDialogTitle": "Usuń klucz S/MIME", + "settings.deleteDialogMessage": "Usunąć klucz prywatny i certyfikat dla {email}? Nie będzie już można odszyfrować poczty zaszyfrowanej dla tego adresu.", + "settings.deleteDialogFallbackIdentity": "tej tożsamości", + "settings.deleteConfirm": "Usuń", + "settings.keyDeletedToast": "Klucz usunięty", + "settings.certNoEmail": "Certyfikat nie ma adresu e-mail", + "settings.importCertSuccess": "Zaimportowano certyfikat dla {email}", + "settings.importCertFailed": "Import certyfikatu nie powiódł się: {reason}", + "settings.unlockSendDialogTitle": "Odblokuj klucz S/MIME", + "settings.unlockSendDialogMessage": "Twój klucz dla {email} jest zablokowany. Wprowadź hasło przechowywania, aby podpisać i wysłać.", + "settings.unlockAndSend": "Odblokuj i wyślij", + "settings.storagePassphrase": "Hasło przechowywania", + "settings.unlockFailedWrongPass": "Odblokowanie nie powiodło się — błędne hasło?", + "settings.notPrivilegedToast": "Nie można podpisać/zaszyfrować: S/MIME nie działa w warstwie uprzywilejowanej." + } +} +; + +/** Detect the active locale ourselves: top-level app first (reflects the account's language setting), then our own iframe's , then navigator.language. */ +function detectLocale() { + try { + // Same-origin, privileged iframe — the top-level Bulwark page's + // is set server-side from the account's language, unlike navigator.language + // which reflects the browser/OS and may not match (e.g. German account on + // an English-language browser). + const topLang = (typeof window !== 'undefined' && window.top && window.top.document + && window.top.document.documentElement && window.top.document.documentElement.lang) || ''; + const topBase = topLang.slice(0, 2).toLowerCase(); + if (LOCAL_I18N[topBase]) return topBase; + } catch { /* cross-origin or blocked — fall through */ } + try { + const docLang = (typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) || ''; + const base = docLang.slice(0, 2).toLowerCase(); + if (LOCAL_I18N[base]) return base; + } catch { /* cross-origin or unavailable — fall through */ } + try { + const navLang = (typeof navigator !== 'undefined' && navigator.language) || ''; + const base = navLang.slice(0, 2).toLowerCase(); + if (LOCAL_I18N[base]) return base; + } catch { /* ignore */ } + return 'en'; +} +// <<< END SELF-DETECTED LOCALE FALLBACK >>> +// ====================================================================== + +function interpolate(str, params) { + if (!params) return str; + return str.replace(/\{(\w+)\}/g, (m, k) => (params[k] !== undefined ? params[k] : m)); +} + +let i18nDiagLogged = false; +function t(key, params) { + if (!i18nDiagLogged) { + i18nDiagLogged = true; + let topLang = '(error)'; + try { topLang = window.top.document.documentElement.lang || '(empty)'; } catch (e) { topLang = '(blocked: ' + e.message + ')'; } + // eslint-disable-next-line no-console + console.log('[smime i18n diag] detectLocale() =', detectLocale(), + '| window.top html lang =', topLang, + '| own iframe html lang =', (document.documentElement && document.documentElement.lang) || '(empty)', + '| navigator.language =', navigator.language, + '| host.i18n =', host.i18n, + '| host sample lookup for "banner.signature" =', host.i18n && typeof host.i18n.t === 'function' ? host.i18n.t('banner.signature') : '(n/a)'); + } + + // >>> SELF-DETECTED LOCALE FALLBACK: takes priority over host.i18n.t() <<< + // See the LOCAL_I18N / detectLocale() block above for why. Remove this + // "if" block (and go back to host.i18n.t() alone) once the host bug is + // fixed upstream. + const locale = detectLocale(); + const localDict = LOCAL_I18N[locale]; + if (localDict && localDict[key]) return interpolate(localDict[key], params); + // <<< END SELF-DETECTED LOCALE FALLBACK >>> + + try { + if (host.i18n && typeof host.i18n.t === 'function') { + const out = host.i18n.t(key, params); + if (out) return out; + } + } catch (err) { + // eslint-disable-next-line no-console + console.log('[smime i18n diag] host.i18n.t threw:', err); + } + return interpolate(I18N_FALLBACK_EN[key] || key, params); +} + +/** Wrap a base64 DER string into standard 64-col PEM. */ +function pemFromBase64(b64, label) { + const lines = []; + for (let i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64)); + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} + +/** Certificate PEM text, regardless of whether .certificate is a base64 string (from storage) or an ArrayBuffer (fresh, not yet round-tripped through storage). Returns null (and logs) for garbage/empty input instead of silently producing an empty PEM. */ +function certificatePem(cert) { + if (!cert || !cert.certificate) { console.log('[smime cert pem] no cert.certificate on', cert); return null; } + const der = cert.certificate; + let b64; + if (typeof der === 'string') { + b64 = der; + } else { + try { b64 = bytesToBase64(der); } catch (err) { console.log('[smime cert pem] bytesToBase64 threw:', err, 'der was:', der); return null; } + } + // A real DER-encoded X.509 certificate is at minimum a few hundred bytes + // (base64: a few hundred chars). Anything drastically shorter means the + // underlying value was never a real certificate — most likely a stale, + // pre-fix corrupted object ({}) still sitting in host.storage. Reopening + // the email (not just re-rendering) re-verifies and overwrites it. + if (!b64 || b64.length < 100) { + console.log('[smime cert pem] suspiciously short/empty base64 (len=' + (b64 ? b64.length : 0) + ') — likely stale data; try closing and reopening this email.', cert); + return null; + } + return pemFromBase64(b64, 'CERTIFICATE'); +} + +/** + * PEM bundle of the signer's leaf certificate plus any chain certificates + * (usually intermediate CAs) the sender's CMS structure carried. This is + * "whatever the mail brought along" — not a verified or complete chain to a + * trusted root, since roots are rarely included and nothing here is checked + * against a trust store. Falls back to leaf-only if there's no chain data. + */ +function certificateChainPem(cert) { + const leafPem = certificatePem(cert); + if (!leafPem) return null; + const chain = cert && cert.chainCertificates; + if (!Array.isArray(chain) || chain.length === 0) return leafPem; + const parts = [leafPem]; + for (const der of chain) { + let b64; + if (typeof der === 'string') b64 = der; + else { + try { b64 = bytesToBase64(der); } catch { continue; } + } + if (b64 && b64.length >= 100) parts.push(pemFromBase64(b64, 'CERTIFICATE')); + } + return parts.join('\n'); +} + +/** Fallback for sandboxed iframes that block programmatic downloads: copy the PEM to the clipboard instead. */ +async function copyCertificateToClipboard(cert, setCopied, includeChain) { + const pem = includeChain ? certificateChainPem(cert) : certificatePem(cert); + if (!pem) return; + try { + await navigator.clipboard.writeText(pem); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.log('[smime cert copy] failed:', err); + } +} + const DEFAULT_PREFS = { defaultSign: false, defaultEncrypt: false }; async function getPrefs() { @@ -163,11 +1625,11 @@ async function ensureKeyUnlocked(keyRecord) { if (existing && existing.signingKey) return existing; const answers = await host.ui.prompt({ - title: 'Unlock S/MIME key', - message: `Your key for ${keyRecord.email || 'this identity'} is locked. Enter its storage passphrase to sign and send.`, - confirmLabel: 'Unlock & send', + title: t('settings.unlockSendDialogTitle'), + message: t('settings.unlockSendDialogMessage', { email: keyRecord.email || t('settings.deleteDialogFallbackIdentity') }), + confirmLabel: t('settings.unlockAndSend'), fields: [ - { name: 'pass', label: 'Storage passphrase', type: 'password', required: true }, + { name: 'pass', label: t('settings.storagePassphrase'), type: 'password', required: true }, ], }); if (!answers) return null; // cancelled @@ -179,7 +1641,7 @@ async function ensureKeyUnlocked(keyRecord) { await saveSessionKeys({ id: keyRecord.id, signingKey, decryptionKey, legacyDecryptionKey }); return await getSessionKeys(keyRecord.id); } catch (err) { - host.toast.error(err && err.message ? err.message : 'Unlock failed — wrong passphrase?'); + host.toast.error(err && err.message ? err.message : t('settings.unlockFailedWrongPass')); return null; } } @@ -257,7 +1719,7 @@ async function onComposeSend(req) { if (!sign && !encrypt) return undefined; // not our job — host sends normally if (!(await isCapable())) { - host.toast.error('Cannot sign/encrypt: S/MIME is not running in the privileged tier.'); + host.toast.error(t('settings.notPrivilegedToast')); return false; // refuse rather than send plaintext when sign/encrypt was requested } @@ -408,9 +1870,43 @@ const ICONS = { h('path', { d: 'M12 8h.01' })), }; +/** ArrayBuffer/TypedArray → base64 string (no line wrapping — that happens at PEM-render time). */ +function bytesToBase64(bytes) { + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ''; + for (let i = 0; i < u8.length; i++) binary += String.fromCharCode(u8[i]); + return btoa(binary); +} + +/** + * host.storage.set appears to JSON-serialize its value, which silently + * destroys an ArrayBuffer (comes back as `{}` on read, breaking any later + * base64/PEM conversion — see downloadCertificate). Convert the signer + * certificate to a plain base64 string before persisting so it survives the + * round-trip; savePublicCert() (real IndexedDB, used for encryption) is + * unaffected and keeps working with the original ArrayBuffer. + */ +function toStorageSafeStatus(status) { + const signerCert = status && status.signerCert; + if (!signerCert) return status; + const cert = signerCert.certificate; + const chain = signerCert.chainCertificates; + const certNeedsConversion = cert && typeof cert !== 'string'; + const chainNeedsConversion = Array.isArray(chain) && chain.some((c) => typeof c !== 'string'); + if (!certNeedsConversion && !chainNeedsConversion) return status; + try { + const safeSignerCert = { ...signerCert }; + if (certNeedsConversion) safeSignerCert.certificate = bytesToBase64(cert); + if (chainNeedsConversion) safeSignerCert.chainCertificates = chain.map((c) => (typeof c === 'string' ? c : bytesToBase64(c))); + return { ...status, signerCert: safeSignerCert }; + } catch { + return status; + } +} + async function persistVerifyStatus(emailId, status) { if (!emailId) return; - try { await host.storage.set(VERIFY_PREFIX + emailId, status); } catch { /* ignore */ } + try { await host.storage.set(VERIFY_PREFIX + emailId, toStorageSafeStatus(status)); } catch { /* ignore */ } } async function onRenderEmailBody(body, ctx) { @@ -437,7 +1933,42 @@ async function onRenderEmailBody(body, ctx) { try { const raw = await host.jmap.fetchBlob(blobId); - const der = normalizeCmsBytes(bytesArrayBuffer(raw instanceof Uint8Array ? raw : new Uint8Array(raw))); + const rawBytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw); + + // Detached (multipart/signed) mail is NOT a single CMS blob — it's a MIME + // container we split ourselves — so it must NOT go through + // normalizeCmsBytes (which assumes/extracts exactly one CMS structure and + // would otherwise mistake part 1's own MIME headers for the message's + // headers and mangle the boundary-delimited body). + if (detection.type === 'detached-sig') { + try { + const { contentBytes, signatureBytes } = splitMultipartSigned(rawBytes, detection.boundary); + const v = await smimeVerifyDetached(contentBytes, bytesArrayBuffer(signatureBytes), fromEmail); + await maybeAutoImportSigner(v.status); + const parsed = parseMime(v.mimeBytes); + await persistVerifyStatus(ctx.id, v.status); + return { + ...body, + handledBy: 'smime', + html: parsed.html || '', + text: parsed.text || '', + attachments: parsed.attachments, + verification: v.status, + }; + } catch (err) { + host.log.warn('S/MIME detached verify failed', err); + const status = { + isSigned: true, + isEncrypted: false, + signatureValid: false, + signatureError: err && err.message ? err.message : String(err), + }; + await persistVerifyStatus(ctx.id, status); + return { ...body, handledBy: 'smime', html: '', text: '', attachments: [], verification: status }; + } + } + + const der = normalizeCmsBytes(bytesArrayBuffer(rawBytes)); if (detection.type === 'enveloped-data') { const { keyRecords, unlockedKeys, legacyUnlockedKeys } = await unlockedDecryptMaps(); @@ -536,7 +2067,7 @@ const btn = { color: 'var(--color-foreground, #0f172a)', cursor: 'pointer', }; -const btnPrimary = { ...btn, background: 'var(--color-primary, #2563eb)', color: '#fff', border: '1px solid var(--color-primary, #2563eb)' }; +const btnPrimary = { ...btn, background: 'var(--color-primary, #2563eb)', color: 'var(--color-primary-foreground, #fff)', border: '1px solid var(--color-primary, #2563eb)' }; const input = { font: 'inherit', padding: '6px 8px', @@ -587,28 +2118,28 @@ function ComposerToolbar() { const pill = (active) => ({ ...btn, background: active ? 'var(--color-primary, #2563eb)' : 'var(--color-muted, #f1f5f9)', - color: active ? '#fff' : 'var(--color-foreground, #0f172a)', + color: active ? 'var(--color-primary-foreground, #fff)' : 'var(--color-foreground, #0f172a)', border: active ? '1px solid var(--color-primary, #2563eb)' : '1px solid var(--color-input, #cbd5e1)', }); if (!ready) { return h('span', { style: { fontSize: '12px', color: 'var(--color-muted-foreground, #64748b)' } }, - 'S/MIME: import a key in Settings to sign/encrypt'); + t('toolbar.needKey')); } return h('div', { style: { display: 'inline-flex', gap: '6px', alignItems: 'center' } }, h('button', { type: 'button', style: pill(intent.sign), - title: 'Digitally sign this message', + title: t('toolbar.signTitle'), onClick: () => toggle('sign'), - }, intent.sign ? '✓ Sign' : 'Sign'), + }, intent.sign ? `✓ ${t('toolbar.sign')}` : t('toolbar.sign')), h('button', { type: 'button', style: pill(intent.encrypt), - title: 'Encrypt this message to its recipients', + title: t('toolbar.encryptTitle'), onClick: () => toggle('encrypt'), - }, intent.encrypt ? '✓ Encrypt' : 'Encrypt'), + }, intent.encrypt ? `✓ ${t('toolbar.encrypt')}` : t('toolbar.encrypt')), ); } @@ -619,6 +2150,8 @@ function EmailBanner(props) { const [status, setStatus] = useState(null); const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(false); + const [showCert, setShowCert] = useState(false); + const [copiedCert, setCopiedCert] = useState(false); // "Unlock now" action for the locked-encryption banner: unlock any locked key // (prompting for the storage passphrase), then ask the host to re-run the @@ -668,8 +2201,7 @@ function EmailBanner(props) { const ct = email.headers && (email.headers['Content-Type'] || email.headers['content-type']); const det = detectSmime(Array.isArray(ct) ? ct[0] : ct, undefined, undefined); if (det.type === 'enveloped-data') s = { isEncrypted: true }; - else if (det.type === 'signed-data') s = { isSigned: true }; - else if (det.type === 'detached-sig') s = { isSigned: true, unsupportedReason: 'detached signature' }; + else if (det.type === 'signed-data' || det.type === 'detached-sig') s = { isSigned: true }; } if (alive) { setStatus(s || null); setLoaded(true); } })(); @@ -682,31 +2214,35 @@ function EmailBanner(props) { const warnSelfSigned = settings().warnOnSelfSigned !== false; if (status.isEncrypted) { - if (status.decryptionSuccess) rows.push({ icon: 'lockOpen', eyebrow: 'Encryption', text: 'Decrypted', tone: 'success' }); - else if (status.decryptionError === 'locked') rows.push({ icon: 'lock', eyebrow: 'Encryption', text: 'Encrypted — unlock your key to read', tone: 'warning', action: 'unlock' }); - else if (status.decryptionError) rows.push({ icon: 'lock', eyebrow: 'Encryption', text: 'Encrypted — couldn’t be decrypted with your keys', tone: 'destructive' }); - else rows.push({ icon: 'lock', eyebrow: 'Encryption', text: 'Encrypted message', tone: 'info' }); + if (status.decryptionSuccess) rows.push({ icon: 'lockOpen', eyebrow: t('banner.encryption'), text: t('banner.decrypted'), tone: 'success' }); + else if (status.decryptionError === 'locked') rows.push({ icon: 'lock', eyebrow: t('banner.encryption'), text: t('banner.encryptedLocked'), tone: 'warning', action: 'unlock' }); + else if (status.decryptionError) rows.push({ icon: 'lock', eyebrow: t('banner.encryption'), text: t('banner.encryptedFailed'), tone: 'destructive' }); + else rows.push({ icon: 'lock', eyebrow: t('banner.encryption'), text: t('banner.encryptedMessage'), tone: 'info' }); } if (status.isSigned || status.signerCert) { if (status.signatureValid) { - const who = status.signerCert && status.signerCert.email ? ` by ${status.signerCert.email}` : ''; - const mismatch = status.signerEmailMatch === false ? ' · signer ≠ From' : ''; + const signerEmail = status.signerCert && status.signerCert.email; + const mismatch = status.signerEmailMatch === false ? ` · ${t('banner.signerMismatchBadge')}` : ''; const selfSigned = warnSelfSigned && status.selfSigned; - const ss = selfSigned ? ' · self-signed' : ''; + const ss = selfSigned ? ` · ${t('banner.selfSignedBadge')}` : ''; // A valid signature only reads as trusted-green when it also chains to a // CA and the signer matches the From. A self-signed cert or a signer≠From // mismatch downgrades to an amber warning (still "valid", just untrusted). const untrusted = status.signerEmailMatch === false || selfSigned; + const headline = signerEmail + ? t('banner.validSignatureBy', { email: signerEmail }) + : t('banner.validSignature'); rows.push({ icon: untrusted ? 'shieldAlert' : 'shieldCheck', - eyebrow: 'Signature', - text: `Valid signature${who}${ss}${mismatch}`, + eyebrow: t('banner.signature'), + text: `${headline}${ss}${mismatch}`, tone: untrusted ? 'warning' : 'success', + cert: status.signerCert, }); } else if (status.signatureError) { - rows.push({ icon: 'shieldAlert', eyebrow: 'Signature', text: `Invalid signature: ${status.signatureError}`, tone: 'destructive' }); + rows.push({ icon: 'shieldAlert', eyebrow: t('banner.signature'), text: t('banner.invalidSignature', { reason: status.signatureError }), tone: 'destructive', cert: status.signerCert }); } else { - rows.push({ icon: 'shieldCheck', eyebrow: 'Signature', text: 'Signed message', tone: 'info' }); + rows.push({ icon: 'shieldCheck', eyebrow: t('banner.signature'), text: t('banner.signedMessage'), tone: 'info', cert: status.signerCert }); } } if (status.unsupportedReason) rows.push({ icon: 'info', eyebrow: 'S/MIME', text: status.unsupportedReason, tone: 'info' }); @@ -756,7 +2292,59 @@ function EmailBanner(props) { background: 'transparent', color: 'var(--color-foreground, #0f172a)', cursor: busy ? 'not-allowed' : 'pointer', opacity: busy ? 0.6 : 1, }, - }, ICONS.lockOpen(15), busy ? 'Unlocking…' : 'Unlock now'), + }, ICONS.lockOpen(15), busy ? t('banner.unlocking') : t('banner.unlockNow')), + ), + r.cert && h('div', { style: { marginTop: '6px' } }, + h('button', { + type: 'button', + onClick: () => setShowCert((v) => !v), + style: { + fontSize: '13px', padding: 0, border: 'none', background: 'transparent', + color: 'var(--color-info, #0284c7)', cursor: 'pointer', textDecoration: 'underline', + }, + }, showCert ? t('banner.hideCertDetails') : t('banner.viewCertDetails')), + showCert && h('div', { + style: { + marginTop: '8px', padding: '10px 12px', borderRadius: '8px', + border: '1px solid var(--color-border, #e2e8f0)', + background: 'color-mix(in srgb, var(--color-muted, #f1f5f9) 40%, transparent)', + fontSize: '12px', fontFamily: 'ui-monospace, SFMono-Regular, monospace', + display: 'flex', flexDirection: 'column', gap: '4px', overflowWrap: 'anywhere', + }, + }, + h('div', null, h('strong', null, `${t('banner.certSubject')}: `), r.cert.subject), + h('div', null, h('strong', null, `${t('banner.certIssuer')}: `), r.cert.issuer), + r.cert.email && h('div', null, h('strong', null, `${t('banner.certEmail')}: `), r.cert.email), + h('div', null, h('strong', null, `${t('banner.certValid')}: `), `${fmtDate(r.cert.notBefore)} – ${fmtDate(r.cert.notAfter)}`), + h('div', null, h('strong', null, `${t('banner.certFingerprint')}: `), r.cert.fingerprint), + Array.isArray(r.cert.chainCertificates) && r.cert.chainCertificates.length > 0 && + h('div', null, h('strong', null, `${t('banner.certChain')}: `), t('banner.certChainCount', { count: r.cert.chainCertificates.length })), + h('div', { style: { marginTop: '4px', display: 'flex', gap: '8px', flexWrap: 'wrap' } }, + h('button', { + type: 'button', + onClick: () => copyCertificateToClipboard(r.cert, setCopiedCert, false), + style: { + display: 'inline-flex', alignItems: 'center', gap: '6px', + fontSize: '12px', padding: '5px 10px', borderRadius: '6px', minHeight: '30px', + border: '1px solid var(--color-border, #e2e8f0)', + background: 'transparent', color: 'var(--color-foreground, #0f172a)', + cursor: 'pointer', fontFamily: 'inherit', + }, + }, copiedCert ? t('banner.copied') : t('banner.copyCert')), + Array.isArray(r.cert.chainCertificates) && r.cert.chainCertificates.length > 0 && + h('button', { + type: 'button', + onClick: () => copyCertificateToClipboard(r.cert, setCopiedCert, true), + style: { + display: 'inline-flex', alignItems: 'center', gap: '6px', + fontSize: '12px', padding: '5px 10px', borderRadius: '6px', minHeight: '30px', + border: '1px solid var(--color-border, #e2e8f0)', + background: 'transparent', color: 'var(--color-foreground, #0f172a)', + cursor: 'pointer', fontFamily: 'inherit', + }, + }, copiedCert ? t('banner.copied') : t('banner.copyChain')), + ), + ), ), ), ); @@ -789,7 +2377,7 @@ function SettingsSection() { if (!capable) { return h('div', { style: { ...card, borderColor: 'var(--color-destructive, #dc2626)', color: 'var(--color-destructive, #dc2626)', maxWidth: '720px' } }, - h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'S/MIME is not active'), + h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, t('settings.notActive')), h('div', { style: { fontSize: '13px', lineHeight: 1.5 } }, NOT_PRIVILEGED_MSG), ); } @@ -798,28 +2386,28 @@ function SettingsSection() { const file = fileRef.current && fileRef.current.files && fileRef.current.files[0]; if (!file) return; const answers = await host.ui.prompt({ - title: 'Import S/MIME key', - message: `Importing "${file.name}".`, - confirmLabel: 'Import', + title: t('settings.importKeyDialogTitle'), + message: t('settings.importKeyDialogMessage', { file: file.name }), + confirmLabel: t('settings.importDialogConfirm'), fields: [ - { name: 'p12pass', label: 'Passphrase protecting the .p12/.pfx file', type: 'password', placeholder: 'Leave blank if the file has none' }, - { name: 'storagePass', label: 'New passphrase to protect this key in your browser', type: 'password', required: true }, + { name: 'p12pass', label: t('settings.p12PassLabel'), type: 'password', placeholder: t('settings.p12PassPlaceholder') }, + { name: 'storagePass', label: t('settings.storagePassLabel'), type: 'password', required: true }, ], }); if (!answers) return; // cancelled const p12pass = answers.p12pass || ''; const storagePass = answers.storagePass || ''; - if (!storagePass) { host.toast.error('A storage passphrase is required'); return; } + if (!storagePass) { host.toast.error(t('settings.storagePassRequired')); return; } setBusy(true); try { const buf = await file.arrayBuffer(); const { keyRecord } = await importPkcs12(buf, p12pass, storagePass); await saveKeyRecord(keyRecord); - host.toast.success(`Imported S/MIME key for ${keyRecord.email || 'certificate'}`); + host.toast.success(t('settings.importKeySuccess', { email: keyRecord.email || 'certificate' })); if (fileRef.current) fileRef.current.value = ''; await refresh(); } catch (err) { - host.toast.error(`Import failed: ${err && err.message ? err.message : String(err)}`); + host.toast.error(t('settings.importKeyFailed', { reason: err && err.message ? err.message : String(err) })); } finally { setBusy(false); } @@ -827,10 +2415,10 @@ function SettingsSection() { async function unlock(rec) { const answers = await host.ui.prompt({ - title: `Unlock ${rec.email || 'S/MIME key'}`, - confirmLabel: 'Unlock', + title: t('settings.unlockDialogTitle', { email: rec.email || t('settings.certUnknown') }), + confirmLabel: t('settings.unlockConfirm'), fields: [ - { name: 'pass', label: 'Storage passphrase for this key', type: 'password', required: true }, + { name: 'pass', label: t('settings.storagePassForKeyLabel'), type: 'password', required: true }, ], }); if (!answers) return; // cancelled @@ -840,10 +2428,10 @@ function SettingsSection() { try { const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(rec, pass); await saveSessionKeys({ id: rec.id, signingKey, decryptionKey, legacyDecryptionKey }); - host.toast.success(`Unlocked ${rec.email || 'key'}`); + host.toast.success(t('settings.unlockSuccess', { email: rec.email || t('settings.keyFallback') })); await refresh(); } catch (err) { - host.toast.error(err && err.message ? err.message : 'Unlock failed'); + host.toast.error(err && err.message ? err.message : t('settings.unlockFailed')); } finally { setBusy(false); } @@ -851,21 +2439,21 @@ function SettingsSection() { async function lock(rec) { await deleteSessionKeys(rec.id); - host.toast.info(`Locked ${rec.email || 'key'}`); + host.toast.info(t('settings.lockedToast', { email: rec.email || t('settings.keyFallback') })); await refresh(); } async function removeKey(rec) { const ok = await host.ui.confirm({ - title: 'Delete S/MIME key', - message: `Delete the private key and certificate for ${rec.email || 'this identity'}? You will no longer be able to decrypt mail encrypted to it.`, + title: t('settings.deleteDialogTitle'), + message: t('settings.deleteDialogMessage', { email: rec.email || t('settings.deleteDialogFallbackIdentity') }), danger: true, - confirmLabel: 'Delete', + confirmLabel: t('settings.deleteConfirm'), }); if (!ok) return; await deleteSessionKeys(rec.id); await deleteKeyRecord(rec.id); - host.toast.success('Key deleted'); + host.toast.success(t('settings.keyDeletedToast')); await refresh(); } @@ -879,7 +2467,7 @@ function SettingsSection() { const der = cert.toSchema(true).toBER(false); const info = await extractCertificateInfo(cert, der); const email = (info.emailAddresses[0] || '').toLowerCase(); - if (!email) throw new Error('Certificate has no email address'); + if (!email) throw new Error(t('settings.certNoEmail')); await savePublicCert({ id: generateUUID(), email, @@ -891,11 +2479,11 @@ function SettingsSection() { fingerprint: info.fingerprint, source: 'manual', }); - host.toast.success(`Imported certificate for ${email}`); + host.toast.success(t('settings.importCertSuccess', { email })); if (certFileRef.current) certFileRef.current.value = ''; await refresh(); } catch (err) { - host.toast.error(`Certificate import failed: ${err && err.message ? err.message : String(err)}`); + host.toast.error(t('settings.importCertFailed', { reason: err && err.message ? err.message : String(err) })); } finally { setBusy(false); } @@ -914,36 +2502,36 @@ function SettingsSection() { return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '720px' } }, h('div', null, - h('h3', { style: { margin: '0 0 4px', fontSize: '15px', fontWeight: 600 } }, 'Your keys'), + h('h3', { style: { margin: '0 0 4px', fontSize: '15px', fontWeight: 600 } }, t('settings.yourKeys')), h('p', { style: { margin: '0 0 8px', fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, - 'Import a PKCS#12 (.p12/.pfx) file containing your certificate and private key. The key is encrypted in your browser and never leaves it.'), + t('settings.yourKeysDesc')), h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '12px' } }, h('input', { ref: fileRef, type: 'file', accept: '.p12,.pfx', style: { fontSize: '13px' } }), - h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: importKeyFile }, 'Import key'), + h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: importKeyFile }, t('settings.importKey')), ), keys.length === 0 - ? h('div', { style: { ...card, fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, 'No keys imported yet.') + ? h('div', { style: { ...card, fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, t('settings.noKeys')) : h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } }, keys.map((rec) => h('div', { key: rec.id, style: card }, h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } }, h('div', null, - h('div', { style: { fontWeight: 600, fontSize: '14px' } }, rec.email || rec.subject || 'Certificate'), + h('div', { style: { fontWeight: 600, fontSize: '14px' } }, rec.email || rec.subject || t('settings.certUnknown')), h('div', { style: { fontSize: '12px', color: 'var(--color-muted-foreground, #64748b)' } }, - `${rec.algorithm} · valid ${fmtDate(rec.notBefore)} – ${fmtDate(rec.notAfter)}${isExpired(rec.notAfter) ? ' · EXPIRED' : ''}`), + `${rec.algorithm} · ${t('settings.validRange', { from: fmtDate(rec.notBefore), to: fmtDate(rec.notAfter) })}${isExpired(rec.notAfter) ? ` · ${t('settings.expired')}` : ''}`), h('div', { style: { fontSize: '11px', fontFamily: 'monospace', color: 'var(--color-muted-foreground, #64748b)', wordBreak: 'break-all' } }, rec.fingerprint), h('div', { style: { fontSize: '11px', color: 'var(--color-muted-foreground, #64748b)' } }, - `${rec.capabilities && rec.capabilities.canSign ? 'sign' : ''}${rec.capabilities && rec.capabilities.canSign && rec.capabilities.canEncrypt ? ' · ' : ''}${rec.capabilities && rec.capabilities.canEncrypt ? 'encrypt' : ''}`), + `${rec.capabilities && rec.capabilities.canSign ? t('settings.capSign') : ''}${rec.capabilities && rec.capabilities.canSign && rec.capabilities.canEncrypt ? ' · ' : ''}${rec.capabilities && rec.capabilities.canEncrypt ? t('settings.capEncrypt') : ''}`), ), h('div', { style: { display: 'flex', gap: '6px', alignItems: 'flex-start' } }, unlocked[rec.id] - ? h('button', { type: 'button', style: btn, disabled: busy, onClick: () => lock(rec) }, '🔓 Lock') - : h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: () => unlock(rec) }, '🔒 Unlock'), + ? h('button', { type: 'button', style: btn, disabled: busy, onClick: () => lock(rec) }, `🔓 ${t('settings.lock')}`) + : h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: () => unlock(rec) }, `🔒 ${t('settings.unlock')}`), h('button', { type: 'button', style: { ...btn, color: 'var(--color-destructive, #dc2626)', borderColor: 'var(--color-destructive, #dc2626)' }, disabled: busy, onClick: () => removeKey(rec), - }, 'Delete'), + }, t('settings.delete')), ), ), )), @@ -951,35 +2539,35 @@ function SettingsSection() { ), h('div', null, - h('h3', { style: { margin: '0 0 4px', fontSize: '15px', fontWeight: 600 } }, 'Recipient certificates'), + h('h3', { style: { margin: '0 0 4px', fontSize: '15px', fontWeight: 600 } }, t('settings.recipientCerts')), h('p', { style: { margin: '0 0 8px', fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, - 'Public certificates (PEM/DER) of people you want to send encrypted mail to. Signer certificates from validly signed mail are saved automatically.'), + t('settings.recipientCertsDesc')), h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '12px' } }, h('input', { ref: certFileRef, type: 'file', accept: '.pem,.crt,.cer,.der', style: { fontSize: '13px' } }), - h('button', { type: 'button', style: btn, disabled: busy, onClick: importCertFile }, 'Import certificate'), + h('button', { type: 'button', style: btn, disabled: busy, onClick: importCertFile }, t('settings.importCert')), ), certs.length === 0 - ? h('div', { style: { ...card, fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, 'No recipient certificates.') + ? h('div', { style: { ...card, fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, t('settings.noCerts')) : h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, certs.map((c) => h('div', { key: c.id, style: { ...card, display: 'flex', justifyContent: 'space-between', gap: '8px', alignItems: 'center' } }, h('div', null, h('div', { style: { fontWeight: 600, fontSize: '13px' } }, c.email || c.subject), h('div', { style: { fontSize: '11px', color: 'var(--color-muted-foreground, #64748b)' } }, - `${c.source} · expires ${fmtDate(c.notAfter)}${isExpired(c.notAfter) ? ' · EXPIRED' : ''}`), + `${c.source} · ${t('settings.certExpires', { date: fmtDate(c.notAfter) })}${isExpired(c.notAfter) ? ` · ${t('settings.expired')}` : ''}`), ), - h('button', { type: 'button', style: { ...btn, color: 'var(--color-destructive, #dc2626)' }, onClick: () => removeCert(c) }, 'Remove'), + h('button', { type: 'button', style: { ...btn, color: 'var(--color-destructive, #dc2626)' }, onClick: () => removeCert(c) }, t('settings.remove')), )), ), ), h('div', null, - h('h3', { style: { margin: '0 0 8px', fontSize: '15px', fontWeight: 600 } }, 'Defaults for new messages'), + h('h3', { style: { margin: '0 0 8px', fontSize: '15px', fontWeight: 600 } }, t('settings.defaults')), h('label', { style: { display: 'flex', gap: '8px', alignItems: 'center', fontSize: '13px', marginBottom: '6px' } }, h('input', { type: 'checkbox', checked: !!prefs.defaultSign, onChange: (e) => setPref('defaultSign', e.target.checked) }), - 'Sign new messages by default'), + t('settings.defaultSign')), h('label', { style: { display: 'flex', gap: '8px', alignItems: 'center', fontSize: '13px' } }, h('input', { type: 'checkbox', checked: !!prefs.defaultEncrypt, onChange: (e) => setPref('defaultEncrypt', e.target.checked) }), - 'Encrypt new messages by default (when all recipients have certificates)'), + t('settings.defaultEncrypt')), ), ); } diff --git a/smime/src/mime-signed.js b/smime/src/mime-signed.js new file mode 100644 index 0000000..818bceb --- /dev/null +++ b/smime/src/mime-signed.js @@ -0,0 +1,142 @@ +/** + * Byte-exact extraction of the protected content and detached CMS signature + * from a multipart/signed (RFC 1847 / RFC 5751 §3.1) MIME entity. + * + * Detached S/MIME verification requires the EXACT transport-encoded octets + * of the first (protected) body part — including its own MIME headers and + * CRLF line endings — because that is what the signature was computed over + * (RFC 5751 §3.1.1). JMAP's decoded, per-part representation is NOT + * guaranteed to preserve this, so this module operates on the raw bytes of + * the whole multipart/signed section (as returned by host.jmap.fetchBlob for + * the top-level body), sliced at the MIME boundary ourselves — mirroring how + * the opaque path already treats blobs as "a full MIME part" in + * smime-decrypt.js:normalizeCmsBytes. + */ + +/** + * @param {Uint8Array} raw Raw bytes of the multipart/signed body + * (boundary-delimited, as transmitted). + * @param {string} boundary The boundary parameter from this part's own + * Content-Type header (multipart/signed; + * boundary="..."). + * @returns {{ contentBytes: Uint8Array, signatureBytes: Uint8Array }} + * contentBytes — part 1, canonicalised to CRLF, exactly as signed. + * signatureBytes — part 2's body, CTE-decoded to raw CMS DER bytes. + */ +export function splitMultipartSigned(raw, boundary) { + if (!boundary) { + throw new Error('multipart/signed: missing boundary parameter — cannot split parts'); + } + + const delim = new TextEncoder().encode(`--${boundary}`); + const offsets = findAll(raw, delim); + if (offsets.length < 2) { + throw new Error('multipart/signed: could not locate two MIME boundary delimiters'); + } + + // Part 1: right after the first delimiter line's CRLF, up to (but not + // including) the CRLF that immediately precedes the second delimiter — that + // CRLF belongs to the delimiter itself, not to the content (RFC 2046 §5.1.1). + const part1Start = skipDelimiterLineEnd(raw, offsets[0] + delim.length); + const part1End = stripTrailingLineBreak(raw, offsets[1]); + + // Part 2: the pkcs7-signature part, up to the closing boundary (or EOF if + // the closing "--boundary--" wasn't included in what we fetched). + const part2Start = skipDelimiterLineEnd(raw, offsets[1] + delim.length); + const part2End = stripTrailingLineBreak(raw, offsets[2] !== undefined ? offsets[2] : raw.length); + + const part1 = raw.slice(part1Start, part1End); + const part2 = raw.slice(part2Start, part2End); + + return { + contentBytes: canonicalizeToCrlf(part1), + signatureBytes: decodeMimePartBody(part2), + }; +} + +// ── Byte search helpers ───────────────────────────────────────────── + +function findAll(haystack, needle) { + const out = []; + let from = 0; + for (;;) { + const idx = indexOfBytes(haystack, needle, from); + if (idx === -1) break; + out.push(idx); + from = idx + needle.length; + } + return out; +} + +function indexOfBytes(haystack, needle, from) { + outer: for (let i = from; i <= haystack.length - needle.length; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer; + } + return i; + } + return -1; +} + +function skipDelimiterLineEnd(bytes, offset) { + let i = offset; + if (bytes[i] === 0x0d) i++; // \r + if (bytes[i] === 0x0a) i++; // \n + return i; +} + +function stripTrailingLineBreak(bytes, offset) { + if (offset >= 2 && bytes[offset - 2] === 0x0d && bytes[offset - 1] === 0x0a) return offset - 2; + if (offset >= 1 && bytes[offset - 1] === 0x0a) return offset - 1; + return offset; +} + +/** + * RFC 5751 requires CRLF canonical form for the signed content. Most + * transports already deliver CRLF; some storage layers normalise to + * LF-only, which would otherwise break verification silently. + */ +function canonicalizeToCrlf(bytes) { + let hasLoneLf = false; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0x0a && bytes[i - 1] !== 0x0d) { hasLoneLf = true; break; } + } + if (!hasLoneLf) return bytes; + + const out = []; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0x0a && bytes[i - 1] !== 0x0d) out.push(0x0d); + out.push(bytes[i]); + } + return new Uint8Array(out); +} + +/** Parse the pkcs7-signature part's own header block and decode its CTE. */ +function decodeMimePartBody(raw) { + const text = latin1String(raw); + const sep = text.match(/\r?\n\r?\n/); + const headerText = sep ? text.slice(0, sep.index) : ''; + const bodyText = sep ? text.slice(sep.index + sep[0].length) : text; + + const cteMatch = headerText.match(/content-transfer-encoding:\s*([^\r\n]+)/i); + const cte = (cteMatch ? cteMatch[1] : '7bit').trim().toLowerCase(); + + if (cte === 'base64') { + const cleaned = bodyText.replace(/[^A-Za-z0-9+/=]/g, ''); + const bin = atob(cleaned); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + + // 7bit / 8bit / binary — body text is already a latin1 view of raw bytes. + const out = new Uint8Array(bodyText.length); + for (let i = 0; i < bodyText.length; i++) out[i] = bodyText.charCodeAt(i) & 0xff; + return out; +} + +function latin1String(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return s; +} diff --git a/smime/src/smime-detect.js b/smime/src/smime-detect.js index a92f0a8..a826347 100644 --- a/smime/src/smime-detect.js +++ b/smime/src/smime-detect.js @@ -30,8 +30,17 @@ export function detectSmime(contentType, bodyStructure, attachments) { } } - if (ct.includes('multipart/signed') && ct.includes('application/pkcs7-signature')) { - return { type: 'detached-sig', supported: false }; + if (ct.includes('multipart/signed') && + (ct.includes('application/pkcs7-signature') || ct.includes('application/x-pkcs7-signature'))) { + const boundary = extractBoundary(contentType); + const parts = findDetachedSigParts(bodyStructure); + return { + type: 'detached-sig', + supported: !!boundary, // still refuse gracefully if we truly can't split it + boundary, + contentPart: parts?.contentPart, + sigPart: parts?.sigPart, + }; } } @@ -80,8 +89,16 @@ function walkBodyStructure(part) { } if (type === 'multipart/signed') { - if (part.subParts?.some((sp) => sp.type?.toLowerCase().includes('application/pkcs7-signature'))) { - return { type: 'detached-sig', supported: false }; + const parts = findDetachedSigParts(part); + if (parts) { + const boundary = extractBoundary(part.type); + return { + type: 'detached-sig', + supported: !!boundary, + boundary, + contentPart: parts.contentPart, + sigPart: parts.sigPart, + }; } } @@ -110,6 +127,34 @@ function findCmsPart(bodyStructure, _smimeType) { return null; } +/** Extract the boundary= parameter from a raw Content-Type header string. */ +function extractBoundary(contentType) { + if (!contentType) return null; + const m = contentType.match(/boundary\s*=\s*"?([^";]+)"?/i); + return m ? m[1] : null; +} + +/** + * Given a multipart/signed bodyStructure node, find its two children: the + * protected content part and the application/pkcs7-signature part. Returns + * blobId/partId for each (used as a fallback reference; the actual byte-exact + * extraction happens on the raw parent blob via mime-signed.js, not on these + * individually-decoded child blobs — see index.js). + */ +function findDetachedSigParts(part) { + if (!part?.subParts) return null; + const sigPart = part.subParts.find((sp) => { + const t = sp.type?.toLowerCase() || ''; + return t.includes('application/pkcs7-signature') || t.includes('application/x-pkcs7-signature'); + }); + if (!sigPart) return null; + const contentPart = part.subParts.find((sp) => sp !== sigPart); + return { + sigPart: { blobId: sigPart.blobId, partId: sigPart.partId }, + contentPart: contentPart ? { blobId: contentPart.blobId, partId: contentPart.partId } : undefined, + }; +} + function inferSmimeTypeFromContentType(ct) { const lower = ct.toLowerCase(); if (lower.includes('smime-type=enveloped-data')) return 'enveloped-data'; diff --git a/smime/src/smime-verify.js b/smime/src/smime-verify.js index 417620b..ead3187 100644 --- a/smime/src/smime-verify.js +++ b/smime/src/smime-verify.js @@ -59,10 +59,12 @@ export async function smimeVerify(cmsBytes, fromHeader) { if (certNotYetValid && !signatureError) signatureError = 'Signer certificate is not yet valid'; const signerEmail = certInfo.emailAddresses[0] ?? ''; + const chainCertificates = extractChainCertificates(signedData, signerCert); const signerPublicCert = { id: `signer-${certInfo.fingerprint}`, email: signerEmail.toLowerCase(), certificate: certDer, + chainCertificates, issuer: certInfo.issuer, subject: certInfo.subject, notBefore: certInfo.notBefore, @@ -94,6 +96,103 @@ export async function smimeVerify(cmsBytes, fromHeader) { }; } +/** + * Verify a DETACHED CMS SignedData (RFC 5751 §3.1.2 / multipart/signed) + * against externally supplied content bytes. Unlike opaque SignedData, the + * CMS structure here carries no eContent — the caller must supply the exact + * signed octets (see mime-signed.js for byte-exact extraction). + * + * @param {Uint8Array} contentBytes The protected MIME entity, exactly as + * it was signed (headers + CRLF body). + * @param {ArrayBuffer|Uint8Array} signatureCmsDer The detached CMS DER. + * @param {string} [fromHeader] + * @returns { mimeBytes: Uint8Array, status: SmimeStatus } + */ +export async function smimeVerifyDetached(contentBytes, signatureCmsDer, fromHeader) { + const contentInfo = parseContentInfo(signatureCmsDer); + const signedData = extractSignedData(contentInfo); + + const signerCert = extractSignerCertificate(signedData); + if (!signerCert) { + return { + mimeBytes: contentBytes, + status: { + isSigned: true, + isEncrypted: false, + signatureValid: false, + signatureError: 'Signer certificate not found in CMS structure', + }, + }; + } + + let signatureValid = false; + let signatureError; + const contentBuffer = contentBytes.buffer.slice( + contentBytes.byteOffset, + contentBytes.byteOffset + contentBytes.byteLength, + ); + + try { + // Detached verification: pass the externally-recovered content as `data` + // since encapContentInfo.eContent is absent for multipart/signed CMS. + signatureValid = await signedData.verify( + { signer: 0, data: contentBuffer, checkChain: false }, + nativeEngine(), + ); + } catch (err) { + signatureError = err instanceof Error ? err.message : 'Signature verification failed'; + } + + const certDer = signerCert.toSchema(true).toBER(false); + const certInfo = await extractCertificateInfo(signerCert, certDer); + + const now = new Date(); + const notBefore = new Date(certInfo.notBefore); + const notAfter = new Date(certInfo.notAfter); + const certExpired = now > notAfter; + const certNotYetValid = now < notBefore; + + if (certExpired && !signatureError) signatureError = 'Signer certificate has expired'; + if (certNotYetValid && !signatureError) signatureError = 'Signer certificate is not yet valid'; + + const signerEmail = certInfo.emailAddresses[0] ?? ''; + const chainCertificates = extractChainCertificates(signedData, signerCert); + const signerPublicCert = { + id: `signer-${certInfo.fingerprint}`, + email: signerEmail.toLowerCase(), + certificate: certDer, + chainCertificates, + issuer: certInfo.issuer, + subject: certInfo.subject, + notBefore: certInfo.notBefore, + notAfter: certInfo.notAfter, + fingerprint: certInfo.fingerprint, + source: 'signed-email', + }; + + let signerEmailMatch; + if (fromHeader && signerEmail) { + signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase(); + } + + const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false)); + const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false)); + const selfSigned = arraysEqual(issuerDer, subjectDer); + + return { + mimeBytes: contentBytes, + status: { + isSigned: true, + isEncrypted: false, + signatureValid: signatureValid && !certExpired && !certNotYetValid, + signatureError, + signerCert: signerPublicCert, + signerEmailMatch, + selfSigned, + }, + }; +} + // --- Internal helpers --- function parseContentInfo(der) { @@ -162,3 +261,26 @@ function extractSignerCertificate(signedData) { return null; } + +/** + * Any other X.509 certificates the sender's CMS structure carried besides the + * signer's own leaf cert — in practice this is usually the intermediate CA(s), + * since many S/MIME gateways (SEPPmail included) bundle them in by default so + * recipients without the CA already trusted can still build the chain. A root + * CA is rarely included (roots are meant to be distributed out-of-band), so + * don't assume completeness — this is "whatever the sender chose to attach", + * not a guaranteed full chain to a trusted root. + */ +function extractChainCertificates(signedData, signerCert) { + if (!signedData.certificates?.length) return []; + const signerDer = signerCert.toSchema(true).toBER(false); + const signerHex = toHex(new Uint8Array(signerDer)); + const chain = []; + for (const certItem of signedData.certificates) { + if (!(certItem instanceof pkijs.Certificate)) continue; + const der = certItem.toSchema(true).toBER(false); + if (toHex(new Uint8Array(der)) === signerHex) continue; // skip the leaf itself + chain.push(der); + } + return chain; +}