luci-mod-network: Implement WiFi QR Codes
authorPaul Donald <[email protected]>
Tue, 28 Oct 2025 15:55:28 +0000 (16:55 +0100)
committerPaul Donald <[email protected]>
Mon, 24 Nov 2025 23:43:33 +0000 (00:43 +0100)
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 <[email protected]>
modules/luci-mod-network/Makefile
modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js

index b382d800d99bf83dee756187a80c25a7693eee1b..eee08272f63f58b3fea7a5dabe39e6c1163b1ff3 100644 (file)
@@ -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
 
index 9bcc5447b7e6f1b890221a207b60b1f47edd239d..68c2708a1ad6b5e4391b38d5f1c7a49c77945403 100644 (file)
@@ -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'] });