From d82a8e5ca506c699cb5616fe264732517f598c6f Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Sat, 22 Aug 2026 17:48:32 +0200 Subject: [PATCH] feat(plugins): new plugin API method: getPublicKeyFromWKD --- lib/plugin-sandbox/host-api.ts | 138 +++++++++++++++++++++++++++++++++ lib/plugin-sandbox/protocol.ts | 2 +- lib/plugin-sandbox/runtime.tsx | 3 +- 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 1f8672aa6..df7719a62 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -28,6 +28,7 @@ import { fileStorage } from '../plugin-storage'; import { generateUUID } from '../utils'; import { ContactCard, Identity } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecurityStore } from '@/stores/account-security-store'; +import { createHash } from 'crypto'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -55,6 +56,7 @@ const PRIVILEGED_ONLY_METHODS = new Set([ 'crypto.removePublicKey', 'crypto.getEncryptionAtRest', 'crypto.setEncryptionAtRest', + 'crypto.getPublicKeyFromWKD', // Replacing the bytes of a file the user is about to send is strictly more // dangerous than reading them, so the write stays privileged-only. // This entry used to read `upfiles.set`, which matches no dispatched method @@ -104,6 +106,7 @@ const PERM_PER_METHOD: Record = { 'crypto.removePublicKey': 'crypto:full', 'crypto.getEncryptionAtRest': 'crypto:full', 'crypto.setEncryptionAtRest': 'crypto:full', + 'crypto.getPublicKeyFromWKD': 'crypto:full', // contact 'contact.get': 'contacts:read', 'contact.update': 'contacts:write', @@ -718,6 +721,140 @@ async function doSetEncryptionAtRest(config: EncryptionAtRestConfig): Promise= 5) { + bits -= 5; + output.push(ZBASE32_ALPHABET[(value >>> bits) & 31]); + } + + // Keep only the remaining unread bits (< 5) to prevent 32-bit integer overflow + value &= (1 << bits) - 1; + } + + // Handle remaining trailing bits (1 to 4 bits left) + if (bits > 0) { + // Left-pad with zeros to complete the final 5-bit character + output.push(ZBASE32_ALPHABET[(value << (5 - bits)) & 31]); + } + + return output.join(''); +} + +/** + * Generates the WKD hash (SHA-1 in z-base-32) from the local-part. + * RFC 9292: Lowercase in UTF-8 before hashing. + */ +function getWkdHash(localPart: string): string { + // NFC normalization + lowercasing + const normalizedLocal = localPart.normalize('NFC').toLowerCase(); + + // Explicit TextEncoder usage to guarantee UTF-8 bytes + const utf8Bytes = new TextEncoder().encode(normalizedLocal); + const sha1Buffer = createHash('sha1').update(utf8Bytes).digest(); + + return zBase32Encode(sha1Buffer); +} + +/** + * Validates OpenPGP key format (Binary mandatory for WKD, ASCII tolerated). + */ +function isValidPgpKey(data: ArrayBuffer): { isValid: boolean, type: 'ARMORED' | 'BINARY' } { + const bytes = new Uint8Array(data); + if (bytes.length < 5) return { isValid: false, type: 'BINARY' }; + + // Safe ASCII-Armored detection against binary decoding errors + const textHead = new TextDecoder('utf-8', { fatal: false }).decode(bytes.subarray(0, 200)); + if (textHead.includes('-----BEGIN PGP PUBLIC KEY BLOCK-----')) return { isValid: true, type: 'ARMORED' }; + + // Binary validation (RFC 4880 + RFC 9580) + const firstByte = bytes[0]; + if ((firstByte & 0x80) === 0) return { isValid: false, type: 'BINARY' }; // Bit 7 must always be 1 + + const isOldFormat = (firstByte & 0x40) === 0; + const tag = isOldFormat ? (firstByte >> 2) & 0x0f : firstByte & 0x3f; + + // Tag 6 = Public-Key Packet, Tag 14 = Public-Subkey Packet + return { isValid: tag === 6 || tag === 14, type: 'BINARY' }; +} + +export interface WkdResult { + status: 'FOUND' | 'NOT_FOUND' | 'ERROR'; + rawKey?: ArrayBuffer; + error?: string; + type?: 'ARMORED' | 'BINARY'; +} + +export async function doGetPublicKeyFromWKD(email: string): Promise { + const cleanEmail = email.trim(); + const atIndex = cleanEmail.lastIndexOf('@'); + + if (atIndex <= 0 || atIndex === cleanEmail.length - 1) { + return { status: 'ERROR', error: 'Invalid email address' }; + } + + const localPart = cleanEmail.slice(0, atIndex); + const domain = cleanEmail.slice(atIndex + 1).toLowerCase(); + const hash = getWkdHash(localPart); + + const urls = [ + `https://openpgpkey.${domain}/.well-known/openpgpkey/hu/${hash}?l=${encodeURIComponent(localPart)}`, + `https://${domain}/.well-known/openpgpkey/hu/${hash}?l=${encodeURIComponent(localPart)}` + ]; + + for (const url of urls) { + try { + // 5-second request timeout controller + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + method: 'GET', + credentials: 'omit', + headers: { 'Accept': 'application/pgp-keys' }, + cache: 'no-cache', + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (response.ok) { + // Size limitation protection (e.g., max 2 MB) + const contentLength = response.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > 2 * 1024 * 1024) { + continue; + } + + const buffer = await response.arrayBuffer(); + + const pgpKeyResult = isValidPgpKey(buffer); + if (pgpKeyResult.isValid) { + return { status: 'FOUND', rawKey: buffer, type: pgpKeyResult.type }; + } + } + } catch { + // Network error, timeout, : move to next URL + continue; + } + } + + return { status: 'NOT_FOUND' }; +} + // ─── Uploaded files in IndexedDB (privileged tier) ────────────────────────── async function getFile(fileID:string): Promise { @@ -1206,6 +1343,7 @@ export async function dispatchApiCall( case 'crypto.removePublicKey': return doRemovePublicKey(args[0] as string); case 'crypto.getEncryptionAtRest': return doGetEncryptionAtRest(); case 'crypto.setEncryptionAtRest': return doSetEncryptionAtRest(args[0] as EncryptionAtRestConfig); + case 'crypto.getPublicKeyFromWKD': return doGetPublicKeyFromWKD(args[0] as string); case 'contact.get': return doContactGet(args[0] as string); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 5570a834f..ae32ab431 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -241,7 +241,7 @@ export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged'; export const API_METHODS = [ 'storage.get', 'storage.set', 'storage.remove', 'storage.keys', 'http.post', 'http.fetch', - 'crypto.createWebAuthn', 'crypto.getWebAuthn', 'crypto.getPublicKeys', 'crypto.createPublicKey', 'crypto.removePublicKey', 'crypto.getEncryptionAtRest', 'crypto.setEncryptionAtRest', + 'crypto.createWebAuthn', 'crypto.getWebAuthn', 'crypto.getPublicKeys', 'crypto.createPublicKey', 'crypto.removePublicKey', 'crypto.getEncryptionAtRest', 'crypto.setEncryptionAtRest', 'crypto.getPublicKeyFromWKD', 'jmap.fetchBlob', 'jmap.uploadBlob', 'jmap.sendRaw', // Narrow keyword helpers. Unlike the raw-blob methods, these are available // to untrusted plugins with email:read/email:write as appropriate. diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index c3a3c7525..1307e1ea1 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -195,7 +195,8 @@ function buildPluginApi(manifest: PluginManifest) { setEncryptionAtRest: (config: EncryptionAtRestConfig) => callApi('crypto.setEncryptionAtRest', [config]), getEncryptionAtRest: () => callApi('crypto.getEncryptionAtRest', []), getWebAuthn: (masterCredentialIdBytes: number[]) => callApi('crypto.getWebAuthn', [masterCredentialIdBytes, manifest.id], 0), - createWebAuthn: (name: string, displayName: string) => callApi('crypto.createWebAuthn', [manifest.id, name, displayName], 0) + createWebAuthn: (name: string, displayName: string) => callApi('crypto.createWebAuthn', [manifest.id, name, displayName], 0), + getPublicKeyFromWKD: (email: string) => callApi('crypto.getPublicKeyFromWKD', [email]), }, storage: { get: (key: string) => callApi('storage.get', [key]),