From 805b2db67012ee76ea8baf57cf83c4dc31b72832 Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Tue, 28 Oct 2025 16:55:28 +0100 Subject: [PATCH] luci-mod-network: Implement WiFi QR Codes This implements T:, R:, S: and H: parameters standardized within WiFi QR codes. This leaves the SAE-PK related options unimplemented. A future UI will likely handle SAE-PK options differently than in hostapd_bss_options. Current parameters are based on: https://www.wi-fi.org/system/files/WPA3%20Specification%20v3.5.pdf#page=33 Signed-off-by: Paul Donald --- modules/luci-mod-network/Makefile | 2 +- .../resources/view/network/wireless.js | 214 ++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/modules/luci-mod-network/Makefile b/modules/luci-mod-network/Makefile index b382d800d9..eee08272f6 100644 --- a/modules/luci-mod-network/Makefile +++ b/modules/luci-mod-network/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI Network Administration -LUCI_DEPENDS:=+luci-base +rpcd-mod-iwinfo +LUCI_DEPENDS:=+luci-base +rpcd-mod-iwinfo +luci-lib-uqr PKG_LICENSE:=Apache-2.0 diff --git a/modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js b/modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js index 9bcc5447b7..68c2708a1a 100644 --- a/modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js +++ b/modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js @@ -10,6 +10,7 @@ 'require network'; 'require firewall'; 'require tools.widgets as widgets'; +'require uqr'; const isReadonlyView = !L.hasViewPermission(); @@ -28,6 +29,23 @@ function render_radio_badge(radioDev) { ]); } +function buildSVGQRCode(data, code, options, dummy=false) { + const opts = { + pixelSize: 4, + whiteColor: 'white', + blackColor: 'black', + ecc: 'M', + ...options + }; + const svg = uqr.renderSVG(data, opts); + if (dummy) + return svg; + else { + code.style.opacity = ''; + dom.content(code, Object.assign(E(svg), { style: 'width:100%;height:auto' })); + } +} + function render_signal_badge(signalPercent, signalValue, noiseValue, wrap, mode) { let icon = L.resource('icons/signal-075-100.svg'), title, value; @@ -1464,6 +1482,202 @@ return view.extend({ encr.value(crypto_mode[0], '%s (%s)'.format(crypto_mode[1], security_level)); }); + // QR Code + o = ss.taboption('encryption', form.DummyValue, '_qrops', _('QR Code'), + _('SSID and passwords with URIencoded sequences (e.g. %20) may not work.')); + o.modalonly = true; + + o.createWiFiPassword = function(section_id) { + // https://www.wi-fi.org/system/files/WPA3%20Specification%20v3.5.pdf#page=33 + /* + WIFI:T:WPA;S:mynetwork;P:mypass;; + + WIFI-qr = "WIFI:" [type ";"] [trdisable ";"] ssid ";" [hidden ";"] [id ";"] [password ";"] [public-key ";"] ";" + + Param Description + type "T:" *(unreserved) ; security type + trdisable "R:" *(HEXDIG) ; Transition Disable value + ssid "S:" *(printable / pct-encoded) ; SSID of the network + hidden "H:true" ; when present, indicates a hidden (stealth) SSID is used + id "I:" *(printable / pct-encoded) ; UTF-8 encoded password identifier, present if the password has an SAE password identifier + password "P:" *(printable / pct-encoded) ; password, present for password-based authentication + public-key "K:" *PKCHAR ; DER of ASN.1 SubjectPublicKeyInfo in compressed form and encoded in "base64" as per [6], present when the network supports SAE-PK, else absent + + printable = %x20-3a / %x3c-7e ; semi-colon excluded + PKCHAR = ALPHA / DIGIT / %x2b / %x2f / %x3d + */ + + function pctEncode(str) { + const bytes = new TextEncoder().encode(str); + let out = ""; + for (const b of bytes) { + // printable = 0x20–0x3A and 0x3C–0x7E, but semicolon (0x3B) excluded + // anything *within* this range %encoded should be treated as printable literal(?) + // There seems to be a glaring bug in this WiFi spec. Ofc there are bugs. + // By not encoding the "%" character, a string literal % with two successive + // digits is ambiguous. If the password contains "%20" which + // should be interpreted literally ['%', '2', '0'] and not " ", some + // clients interpret this as " ". YMMV. + const printable = (b >= 0x20 && b <= 0x3A && b !== 0x3B) + || (b >= 0x3C && b <= 0x7E); + + if (printable) { + out += String.fromCharCode(b); + } else { + out += "%" + b.toString(16).toUpperCase().padStart(2, "0"); + } + } + return out; + } + + const wifiSSID = this.section.formvalue(section_id, 'ssid'); // S + const wifiEncr = this.section.formvalue(section_id, 'encryption'); // T + const wifiKey = this.section.formvalue(section_id, '_wpa_key'); // P + const wifiHide = this.section.formvalue(section_id, 'hidden') === '1'; // H + + /* trdisable: + 0 WPA3-Personal + 1 SAE-PK + 2 WPA3-Enterprise + 3 WiFi-Enhanced Open */ + let trdisable = ''; // R + switch (true) { + case (wifiEncr === 'sae'): trdisable = 0; break; // 'sae' i.e. WPA3-Personal + // case (???): trdisable = 1; break; // SAE-PK + case (wifiEncr.startsWith('wpa3')): trdisable = 2; break; // 'wpa3*' i.e. WPA3-Enterprise + case (wifiEncr === 'owe'): trdisable = 3; break; // 'open' i.e. WiFi-Enhanced Open + default: trdisable = ''; break; + } + + return [ + `WIFI:`, + (wifiKey) ? `T:WPA;`: null, // absent indicates [open || Wi-Fi Enhanced Open ] + (trdisable !== '') ? `R:${trdisable};` : null, + `S:${wifiSSID};`, + (wifiHide) ? `H:${wifiHide};` : null, + (wifiKey) ? `P:${pctEncode(wifiKey)};`: null, + ].filter(Boolean).join('') + ';'; + }; + + o.handleGenerateQR = function(section_id, ev) { + const parent = s.map; + const mapNode = document.querySelector('body.modal-overlay-active > #modal_overlay > .modal.cbi-modal > .cbi-map:not(.hidden)'); + const headNode = mapNode.parentNode.querySelector('h4'); + const wifiQRGenerator = this.createWiFiPassword.bind(this, section_id); + + return Promise.all([ + parent.save(null, true) + ]).then(function(data) { + let qrm, qrs, qro; + + qrm = new form.JSONMap({ qrcode: { } }, + null, _('Scan this QR code with the client device.')); + qrm.parent = parent; + + qrs = qrm.section(form.NamedSection, 'qrcode'); + + function handleQRParamChange(ev, section_id, value) { + const code = this.map.findElement('.qr-code'); + const conf = this.map.findElement('.wifi-qr-code-content'); + const ecc = this.section.getUIElement(section_id, 'ecc'); + + if (this.isValid(section_id)) { + conf.firstChild.data = wifiQRGenerator(section_id); + code.style.opacity = '.5'; + + buildSVGQRCode(conf.firstChild.data, code, {ecc: ecc.getValue()}); + } + }; + + qro = qrs.option(form.ListValue, 'ecc', _('QR Error Correction Code Level')); + qro.value('L', _('Low')); + qro.value('M', _('Medium')); + qro.value('Q', _('Quartile')); + qro.value('H', _('High')); + qro.onchange = handleQRParamChange; + + + qro = qrs.option(form.DummyValue, 'output'); + qro.renderWidget = function() { + const wifi_qr = wifiQRGenerator(section_id); + const ecc = this.section.formvalue(section_id, 'ecc'); + + return E('div', { + 'class': 'qr-code-display', + 'style': 'display:flex; flex-wrap:wrap; align-items:center; gap:.5em', + }, [ + E('div', { + 'class': 'qr-code', + // any width and height should be ~360: enough for QR with K: field and High ECC. + }, [ + // fill initial QR code + E(buildSVGQRCode(wifi_qr, null, {ecc: ecc || undefined}, true)) + ]), + E('pre', { + 'class': 'wifi-qr-code-content', + 'style': 'flex:1; overflow:auto; word-break:break-all; ', + 'click': function(ev) { + const sel = window.getSelection(); + const range = document.createRange(); + + range.selectNodeContents(ev.currentTarget); + + sel.removeAllRanges(); + sel.addRange(range); + } + }, [ wifi_qr ]) + ]); + }; + + return qrm.render().then(function(nodes) { + // stash the current dialogue style (visible) + const dStyle = mapNode.style; + // hide the current modal window + mapNode.style.display = 'none'; + // stash the current button row style (visible) + const bRowStyle = mapNode.nextElementSibling.style; + // hide the [ Dismiss | Save ] button row + mapNode.nextElementSibling.style.display = 'none'; + + headNode.appendChild(E('span', [ ' » ', _('Generate WiFi QR…') ])); + mapNode.parentNode.appendChild(E([], [ + nodes, + E('div', { + 'class': 'right' + }, [ + E('button', { + 'class': 'btn', + 'click': function() { + // Remove QR code button (row) + nodes.parentNode.removeChild(nodes.nextSibling); + // Remove QR code form + nodes.parentNode.removeChild(nodes); + // unhide the WiFi modal dialogue + mapNode.style = dStyle; + // Revert button row style to visible again + mapNode.nextSibling.style = bRowStyle; + // Remove the H4 span (») title + headNode.removeChild(headNode.lastChild); + } + }, [ _('Back to settings') ]) + ]) + ])); + }); + }); + }; + + o.cfgvalue = function(section_id, value) { + return E('button', { + 'class': 'btn qr-code', + 'style': 'display:inline-flex;align-items:center;gap:.5em', + 'click': ui.createHandlerFn(this, 'handleGenerateQR', section_id), + }, [ + // inject dummy QR code + E(buildSVGQRCode('openwrt.org', null, {pixelSize: 1, ecc: 'L'}, true)), + _('Generate QR…') + ]); + }; + // End QR Code o = ss.taboption('encryption', form.Flag, 'ppsk', _('Enable Private PSK (PPSK)'), _('Private Pre-Shared Key (PPSK) allows the use of different Pre-Shared Key for each STA MAC address. Private MAC PSKs are stored on the RADIUS server.')); add_dependency_permutations(o, { mode: ['ap', 'ap-wds'], encryption: ['psk', 'psk2', 'psk+psk2', 'psk-mixed'] }); -- 2.30.2