Skip to content

Instantly share code, notes, and snippets.

@gingerbeardman
Created July 26, 2026 22:25
Show Gist options
  • Select an option

  • Save gingerbeardman/1f98c4e1aa875a6371bc461f9e7fc316 to your computer and use it in GitHub Desktop.

Select an option

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)
// ==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