Created
July 26, 2026 22:25
-
-
Save gingerbeardman/1f98c4e1aa875a6371bc461f9e7fc316 to your computer and use it in GitHub Desktop.
Automatic login to your Nokia FastMile 5G Gateway 3.2 (don't forget to add your password)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Nokia FastMile Auto Login | |
| // @match http://192.168.1.1/* | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| // ---- YOUR CREDENTIALS ---------------------------------------------------- | |
| const USERNAME = 'admin'; | |
| const PASSWORD = 'PUT_YOUR_PASSWORD_HERE'; | |
| // Auto-open the collapsed <mat-expansion-panel> sections on the status pages. | |
| const AUTO_EXPAND = true; | |
| // -------------------------------------------------------------------------- | |
| // Safety: the router reports result -2 = LOCKEDDOWN. If a login is ever | |
| // rejected we set this flag and refuse to try again, so a wrong password can | |
| // never turn into a lockout loop. Clear it from the console with: | |
| // localStorage.removeItem('nokiaAutoLoginBlocked') | |
| const BLOCK_KEY = 'nokiaAutoLoginBlocked'; | |
| /* ------------------------------------------------------------------ sha256 */ | |
| const K = [ | |
| 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, | |
| 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, | |
| 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, | |
| 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, | |
| 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, | |
| 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, | |
| 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, | |
| 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]; | |
| const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0; | |
| function sha256(bytes) { | |
| const H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a, | |
| 0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; | |
| const len = bytes.length; | |
| const total = ((len + 9 + 63) >> 6) << 6; // pad to a multiple of 64 bytes | |
| const m = new Uint8Array(total); | |
| m.set(bytes); | |
| m[len] = 0x80; | |
| const dv = new DataView(m.buffer); | |
| const bits = len * 8; | |
| dv.setUint32(total - 8, Math.floor(bits / 0x100000000)); | |
| dv.setUint32(total - 4, bits >>> 0); | |
| const w = new Uint32Array(64); | |
| for (let off = 0; off < total; off += 64) { | |
| for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4); | |
| for (let i = 16; i < 64; i++) { | |
| const s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >>> 3); | |
| const s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >>> 10); | |
| w[i] = (w[i-16] + s0 + w[i-7] + s1) >>> 0; | |
| } | |
| let [a,b,c,d,e,f,g,h] = H; | |
| for (let i = 0; i < 64; i++) { | |
| const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25); | |
| const ch = (e & f) ^ (~e & g); | |
| const t1 = (h + S1 + ch + K[i] + w[i]) >>> 0; | |
| const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22); | |
| const maj = (a & b) ^ (a & c) ^ (b & c); | |
| const t2 = (S0 + maj) >>> 0; | |
| h = g; g = f; f = e; e = (d + t1) >>> 0; | |
| d = c; c = b; b = a; a = (t1 + t2) >>> 0; | |
| } | |
| const upd = [a,b,c,d,e,f,g,h]; | |
| for (let i = 0; i < 8; i++) H[i] = (H[i] + upd[i]) >>> 0; | |
| } | |
| const out = new Uint8Array(32); | |
| const odv = new DataView(out.buffer); | |
| for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]); | |
| return out; | |
| } | |
| /* ------------------------------------------------------------- conversions */ | |
| const utf8 = s => new TextEncoder().encode(s); | |
| const toHex = b => [...b].map(x => x.toString(16).padStart(2, '0')).join(''); | |
| const fromHex = h => new Uint8Array(h.match(/../g).map(x => parseInt(x, 16))); | |
| const toB64 = b => btoa(String.fromCharCode(...b)); | |
| // Mirrors crypto_page.js base64url_escape(): + -> -, / -> _, = -> . | |
| const esc = s => s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '.'); | |
| // Mirrors cryptoJS.sha256(a, b) === base64(sha256(a + ":" + b)) | |
| const sha256b64 = (a, b) => toB64(sha256(utf8(a + ':' + b))); | |
| const sha256url = (a, b) => esc(sha256b64(a, b)); | |
| /* -------------------------------------------------------------------- login */ | |
| async function login() { | |
| const nonceRes = await fetch('/login_web_app.cgi?nonce', { | |
| credentials: 'include', cache: 'no-store', | |
| }); | |
| const n = await nonceRes.json(); | |
| if (!n.nonce) throw new Error('no nonce in response'); | |
| // Newer GW3 firmware (1.2304.00.0075+) adds a salt step: POST ?salt returns | |
| // {"alati":"..."} which is prepended to the password. This firmware answers | |
| // that endpoint with HTTP 200 and an empty body, so salt stays "" and the | |
| // unsalted form is used. Auto-detecting keeps this working across a firmware | |
| // update rather than silently failing. | |
| let salt = ''; | |
| try { | |
| const sres = await fetch('/login_web_app.cgi?salt', { | |
| method: 'POST', | |
| credentials: 'include', | |
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| body: `userhash=${sha256url(USERNAME, n.nonce)}&nonce=${esc(n.nonce)}`, | |
| }); | |
| const stext = (await sres.text()).trim(); | |
| if (stext) salt = JSON.parse(stext).alati || ''; | |
| } catch (_) { /* no salt endpoint on this firmware */ } | |
| // Password stretching, exactly as the Angular bundle does it: | |
| // l = sha256_hex(salt + password), then re-hashed over RAW BYTES | |
| // (iterations - 1) more times -- the bundle does CryptoJS.enc.Hex.parse(l) | |
| // before each re-hash, so it hashes bytes, not the hex string. | |
| let l = n.iterations >= 1 ? toHex(sha256(utf8(salt + PASSWORD))) : salt + PASSWORD; | |
| for (let i = 1; i < n.iterations; i++) l = toHex(sha256(fromHex(l))); | |
| const credHash = sha256b64(USERNAME, l.toLowerCase()); | |
| // enckey/enciv are 128-bit randoms. They are sent base64url-escaped, but | |
| // stored in sessionStorage as plain base64 separated by a space. | |
| const rand16 = () => toB64(crypto.getRandomValues(new Uint8Array(16))); | |
| const enckey = rand16(); | |
| const enciv = rand16(); | |
| const body = | |
| `userhash=${sha256url(USERNAME, n.nonce)}` + | |
| `&RandomKeyhash=${sha256url(n.randomKey, n.nonce)}` + | |
| `&response=${sha256url(credHash, n.nonce)}` + | |
| `&nonce=${esc(n.nonce)}` + | |
| `&enckey=${esc(enckey)}` + | |
| `&enciv=${esc(enciv)}`; | |
| const res = await fetch('/login_web_app.cgi', { | |
| method: 'POST', | |
| credentials: 'include', | |
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| body, | |
| }); | |
| const out = await res.json(); | |
| if (out.result !== 0) { | |
| // -1 = LOGINFAILED, -2 = LOCKEDDOWN | |
| localStorage.setItem(BLOCK_KEY, `result=${out.result}`); | |
| throw new Error(`login rejected (result=${out.result}); not retrying`); | |
| } | |
| // Seed exactly what the app's own login() writes. | |
| sessionStorage.setItem(out.sid, `${enckey} ${enciv}`); | |
| sessionStorage.setItem('token', out.token); | |
| sessionStorage.setItem('sid', out.sid); | |
| sessionStorage.setItem('currentUser', USERNAME); | |
| return out; | |
| } | |
| /* --------------------------------------------------------------------- boot */ | |
| // Unconditional, so the console tells you whether the script was injected at | |
| // all. No "[nokia] script active" line => Tampermonkey never ran it, and | |
| // nothing below is the problem. | |
| console.log('[nokia] script active on', location.href); | |
| // Internals exposed so the crypto can be cross-checked against the page's own | |
| // sjcl/cryptoJS without spending a login attempt. See nokiaCompare() below. | |
| window.nokiaDebug = { sha256, toHex, fromHex, utf8, sha256b64, sha256url, esc }; | |
| // Zero-risk check: computes the login `response` two ways -- with this script's | |
| // bundled SHA-256, and with the page's own sjcl + cryptoJS -- for the same | |
| // nonce. Only does a harmless GET for the nonce; never POSTs a login. | |
| // nokiaCompare('admin', 'yourpassword') | |
| window.nokiaCompare = async function (user, pass) { | |
| const n = await (await fetch('/login_web_app.cgi?nonce', { cache: 'no-store' })).json(); | |
| const mine = sha256url(sha256b64(user, toHex(sha256(utf8(pass))).toLowerCase()), n.nonce); | |
| let theirs = '(page crypto unavailable)'; | |
| if (typeof sjcl !== 'undefined' && typeof cryptoJS !== 'undefined') { | |
| const l = sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(pass)); | |
| theirs = cryptoJS.sha256url(cryptoJS.sha256(user, l.toLowerCase()), n.nonce); | |
| } | |
| console.log('mine :', mine); | |
| console.log('page :', theirs); | |
| console.log(mine === theirs ? '=> CRYPTO MATCHES (so it is the credentials)' | |
| : '=> MISMATCH (crypto bug)'); | |
| return { mine, theirs, match: mine === theirs }; | |
| }; | |
| /* ------------------------------------------------------- session UI state */ | |
| // The header's logged-in markup is *ngIf="authenticationService.isSessionActive", | |
| // backed by a BehaviorSubject that starts false on EVERY page load -- nothing | |
| // reads the token into it at boot. It only flips true inside | |
| // checkSessionExpiration(), which the route guard calls when a *guarded* route | |
| // activates with a token present. #/overview has no guard, so simply having a | |
| // valid token there still renders the logged-out header. Navigating through a | |
| // guarded route makes the app set the flag itself -- no reload needed. | |
| const GUARDED_ROUTE = '#/status/general'; | |
| const isGuarded = h => !!h && !/^#\/overview/.test(h); | |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); | |
| // The hash must not be changed until Angular has bootstrapped and hooked the | |
| // router, otherwise the navigation happens before anything is listening. | |
| async function whenAppReady() { | |
| if (document.readyState !== 'complete') { | |
| await new Promise(r => addEventListener('load', r, { once: true })); | |
| } | |
| for (let i = 0; i < 60; i++) { | |
| const root = document.querySelector('app-root'); | |
| if (root && root.children.length) return true; | |
| await sleep(50); | |
| } | |
| return false; | |
| } | |
| async function activateSessionUI(finalHash) { | |
| await whenAppReady(); | |
| if (isGuarded(finalHash)) { | |
| location.hash = finalHash; // guard fires -> sessionActive = true | |
| } else { | |
| location.hash = GUARDED_ROUTE; // bounce to flip the flag... | |
| await sleep(300); | |
| location.hash = finalHash; // ...then land where we wanted | |
| } | |
| } | |
| /* -------------------------------------------------- auto-expand mat panels */ | |
| // The status pages wrap their content in <mat-expansion-panel>, collapsed by | |
| // default. Material renders the header with aria-expanded, so a collapsed | |
| // panel is exactly mat-expansion-panel-header[aria-expanded="false"] and a | |
| // click toggles it. | |
| // | |
| // Each header is only ever auto-expanded ONCE (tracked in a WeakSet). Panels | |
| // are re-created on route changes, so new pages still get expanded, but if you | |
| // deliberately collapse one it stays collapsed instead of fighting you. | |
| const expanded = new WeakSet(); | |
| function expandPanels() { | |
| let n = 0; | |
| for (const h of document.querySelectorAll( | |
| 'mat-expansion-panel-header[aria-expanded="false"]')) { | |
| if (expanded.has(h)) continue; // already auto-opened; user re-collapsed it | |
| expanded.add(h); | |
| h.click(); | |
| n++; | |
| } | |
| return n; | |
| } | |
| window.nokiaExpand = expandPanels; // manual re-run from the console | |
| function watchForPanels() { | |
| if (!AUTO_EXPAND) return; | |
| let t = null; | |
| const kick = () => { clearTimeout(t); t = setTimeout(expandPanels, 120); }; | |
| new MutationObserver(kick).observe(document.body, { childList: true, subtree: true }); | |
| addEventListener('hashchange', kick); | |
| kick(); | |
| } | |
| // Manual trigger for debugging: run nokiaLogin() in the console to see the | |
| // real error (and the full response) instead of guessing. | |
| window.nokiaLogin = async function () { | |
| localStorage.removeItem(BLOCK_KEY); | |
| const out = await login(); | |
| console.log('[nokia] manual login result:', out); | |
| return out; | |
| }; | |
| // Panel watching is independent of auth -- start it once the app exists, on | |
| // every path through the logic below (including the early returns). | |
| whenAppReady().then(watchForPanels); | |
| (async () => { | |
| if (sessionStorage.getItem('token')) { | |
| // Still need to flip sessionActive: it resets to false on every page load, | |
| // so a refresh with a valid token otherwise renders the logged-out header. | |
| console.log('[nokia] token already present, activating session UI'); | |
| await activateSessionUI(location.hash || '#/overview'); | |
| return; | |
| } | |
| if (localStorage.getItem(BLOCK_KEY)) { | |
| console.warn('[nokia] BLOCKED after an earlier failure:', | |
| localStorage.getItem(BLOCK_KEY), | |
| '\n clear it with: localStorage.removeItem("' + BLOCK_KEY + '")'); | |
| return; | |
| } | |
| if (PASSWORD === 'PUT_YOUR_PASSWORD_HERE') { | |
| console.error('[nokia] PASSWORD is still the placeholder - edit the script'); | |
| return; | |
| } | |
| // #/overview is the one route with no canActivate guard. Park there while we | |
| // authenticate so the login dialog never gets a chance to open, then restore | |
| // the route the user actually asked for. | |
| let wanted = null; | |
| if (location.hash && !location.hash.startsWith('#/overview')) { | |
| wanted = location.hash; | |
| location.hash = '#/overview'; | |
| } | |
| console.log('[nokia] logging in...', wanted ? '(parked, will return to ' + wanted + ')' : ''); | |
| try { | |
| const out = await login(); | |
| console.log('[nokia] logged in, sid =', out.sid); | |
| await activateSessionUI(wanted || '#/overview'); | |
| console.log('[nokia] session UI active'); | |
| } catch (e) { | |
| console.error('[nokia] auto-login FAILED:', e.message); | |
| if (wanted) location.hash = wanted; // fall back to the popup | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment