diff --git a/package-lock.json b/package-lock.json index 0dcbac5..6b6e28a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mrterm", - "version": "0.13.2", + "version": "0.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mrterm", - "version": "0.13.2", + "version": "0.13.3", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 20fff73..f1943da 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mrterm", "productName": "MrTerm", - "version": "0.13.2", + "version": "0.13.3", "description": "Moderner SSH-, SFTP- und RDP-Client", "main": "src/main/main.js", "author": "MrBlake", diff --git a/src/main/ctap2.js b/src/main/ctap2.js index c76eaf9..33b6aa7 100644 --- a/src/main/ctap2.js +++ b/src/main/ctap2.js @@ -210,4 +210,48 @@ async function makeCredential(ui, { timeoutMs = 60000 } = {}) { } } -module.exports = { makeCredential, CtapError, CANCEL_CODES, PIN }; +// Entspricht WebAuthn-PRF (evalByCredential) über hmac-secret, ohne PIN (wie Chromium mit +// userVerification "discouraged"). creds: [{ credId: Buffer, salt: Buffer }] – salt ist der rohe PRF-Eingabewert. +// Liefert { credId: Buffer, secret: Buffer(32) }, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist. +async function getHmacSecret(ui, creds, { timeoutMs = 60000 } = {}) { + let dev; + try { dev = Device.open(); } catch { return null; } + if (!dev) return null; + let timer; + try { + await dev.init(); + const info = await dev.cbor(0x04); + if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null; + const rpId = 'localhost'; + const desc = (c) => new Map([['id', c.credId], ['type', 'public-key']]); + // Welches Credential liegt auf diesem Schlüssel? (Vorabprüfung ohne Berühren, up=false) + let cred = null; + for (const c of creds) { + try { + await dev.cbor(0x02, new Map([[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(c)]], [5, new Map([['up', false]])]])); + cred = c; break; + } catch (e) { if (e.code !== 0x2e) throw e; } + } + if (!cred) throw new CtapError(0x2e); + const { key, platformKey } = await sharedSecret(dev); + const salt = crypto.createHash('sha256').update(Buffer.concat([Buffer.from('WebAuthn PRF\0', 'latin1'), cred.salt])).digest(); + const saltEnc = aes('enc', key, salt); + const saltAuth = crypto.createHmac('sha256', key).update(saltEnc).digest().subarray(0, 16); + ui.touch(() => dev.cancel()); + timer = setTimeout(() => dev.cancel(), timeoutMs); + const r = await dev.cbor(0x02, new Map([ + [1, rpId], [2, crypto.randomBytes(32)], [3, [desc(cred)]], + [4, new Map([['hmac-secret', new Map([[1, platformKey], [2, saltEnc], [3, saltAuth]])]])], + ])); + const authData = r.get(2); + if (!(authData[32] & 0x80)) throw new CtapError(-1); + const ext = dec(authData, 37)[0].get('hmac-secret'); + if (!ext) throw new CtapError(-1); + return { credId: Buffer.from(cred.credId), secret: aes('dec', key, ext).subarray(0, 32) }; + } finally { + clearTimeout(timer); + dev.close(); + } +} + +module.exports = { makeCredential, getHmacSecret, CtapError, CANCEL_CODES, PIN }; diff --git a/src/main/fido.js b/src/main/fido.js index d7338e1..95720b9 100644 --- a/src/main/fido.js +++ b/src/main/fido.js @@ -99,6 +99,26 @@ async function registerNative(parent) { } } +// PRF-Abfrage direkt per CTAP2 (Linux); null, wenn kein FIDO2-Gerät per hidraw erreichbar ist +async function deriveNative(parent, creds, text) { + const w = await openWindow(parent, text || i18n.t('Touch your security key to unlock MrTerm.')); + let abort = () => {}; + let cancelled = false; + w.once('closed', () => { cancelled = true; abort(); }); + try { + const r = await ctap2.getHmacSecret({ touch(a) { abort = a; if (cancelled) a(); } }, + creds.map((c) => ({ credId: Buffer.from(c.credId, 'base64url'), salt: Buffer.from(c.prfSalt, 'base64url') }))); + return r && { credId: b64url(r.credId), secret: r.secret }; + } catch (e) { + if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.')); + if (e.code === 0x2e) throw new Error(i18n.t('Unknown security key.')); + if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.')); + throw e; + } finally { + if (!w.isDestroyed()) w.destroy(); + } +} + const b64url = (buf) => Buffer.from(buf).toString('base64url'); // Neuen Schlüssel registrieren; liefert die Credential-ID (base64url) @@ -124,6 +144,10 @@ async function register(parent) { // PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url) // Liefert { credId, secret: Buffer(32) } async function derive(parent, creds, text) { + if (process.platform === 'linux') { + const r = await deriveNative(parent, creds, text); + if (r) return r; + } const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), ` const creds = ${JSON.stringify(creds)}; const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }])); diff --git a/src/renderer/app.js b/src/renderer/app.js index 0460dd8..2d57532 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -1338,11 +1338,14 @@ async function importRdm() { // ============================================================ Updates let updateInfo = null; +let updateDialogOpen = false; // Auto-Prüfung beim Start und manuelle Prüfung sollen keine zwei Dialoge stapeln async function showUpdate(info) { updateInfo = info; const b = $('#updateBadge'); b.style.display = ''; b.querySelector('span').textContent = `Update ${info.version}`; + if (updateDialogOpen) return; + updateDialogOpen = true; const r = await modal({ title: T('MrTerm {v} is available', { v: info.version }), text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''), @@ -1350,7 +1353,7 @@ async function showUpdate(info) { ${info.kind === 'dev' ? `

${T('Development mode: please update via git pull.')}

` : !info.asset ? `

${T('The release contains no package for this system.')}

` : ''} `, buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])], - }); + }).finally(() => { updateDialogOpen = false; }); if (r === 'web') return api.call('shell:open', info.url); if (r !== true) return; toast(T('Downloading update …'));