feat(sirius-x/signin): PIN escrow — encrypted-at-rest quick unlock, 3-strike password fallback
Removes the sessionStorage plaintext-mnemonic cache (fixed under the
same commit) and replaces it with a PIN-encrypted blob in localStorage.
No plaintext secret ever touches disk or memory outside the live
BuiltInWallet object.
New js/pin-escrow.js — WebCrypto PBKDF2(50k) + AES-GCM(256). Public
API on window.siriusPin: savePinBlob(mnemonic, pin), tryUnlock(pin),
hasPin(), attemptsUsed(), attemptsRemaining(), clear(), MAX_ATTEMPTS.
Iteration count is lighter than BuiltInWallet's 250k because a 4-6
digit PIN's key space is small anyway; the point is 'not plaintext at
rest,' not brute-force resistance — the durable secret is the full
password.
register-flow.js:
- After a fresh password unlock (Import / Create / Unlock), stepSetPin
offers a 4-6 digit PIN with confirm — skippable with 'Not now'.
Never overwrites an existing PIN blob.
- stepUnlock now shows a numeric PIN pad when a PIN blob is present;
the password field only appears when the user opts to 'Use password
instead' or after the blob was wiped.
- Wrong PIN → increment counter, surface 'N attempts left'. Third
wrong PIN → wipe blob and route to a 'PIN reset' screen that hands
off to the password form.
- Correct PIN → decrypt the mnemonic in-browser, rebuild the
BuiltInWallet, finishSignIn(). Attempt counter resets to 0.
profile-menu.js:
- Sign-out clears the PIN blob (via siriusPin.clear()) alongside the
siriusProfile so the device isn't quick-unlockable with a stale PIN.
All pages that host the sign-in flow now include pin-escrow.js. Same
tabbed layout in stepUnlock — the PIN pad and the password field both
live under 🔓 Unlock, transparent tab-switch works exactly as before.
Verified live:
- Fresh Import → 'Set a PIN' step → 4242 confirmed → blob written
- Reload → PIN pad, 3 attempts remaining
- Correct PIN 4242 → signed in, counter resets to 0
- 3 wrong PIN attempts → 'Wrong PIN — N attempts left' per attempt,
then 'PIN locked — enter your full password to continue', blob
wiped, next reload shows the password form
- localStorage contains only ciphertext + salt + iv + counter; no
plaintext mnemonic anywhere on disk or in sessionStorage.
This commit is contained in:
parent
0a86b012d6
commit
495054ca8a
10 changed files with 229 additions and 48 deletions
|
|
@ -372,6 +372,8 @@ $("mint-btn").addEventListener("click", async () => {
|
|||
}
|
||||
});
|
||||
</script>
|
||||
<script src="../js/pin-escrow.js?v=20260909pin"></script>
|
||||
<script src="../js/pricing.js?v=20260909tiers"></script>
|
||||
<script defer src="../js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -218,6 +218,8 @@
|
|||
|
||||
<footer id="site-footer"></footer>
|
||||
|
||||
<script src="../js/pin-escrow.js?v=20260909pin"></script>
|
||||
<script src="../js/pricing.js?v=20260909tiers"></script>
|
||||
<script defer src="../js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -424,6 +424,8 @@ process.exit(0);"</pre>
|
|||
|
||||
<footer id="site-footer"></footer>
|
||||
|
||||
<script src="../js/pin-escrow.js?v=20260909pin"></script>
|
||||
<script src="../js/pricing.js?v=20260909tiers"></script>
|
||||
<script defer src="../js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -449,6 +449,7 @@ process.exit(0);"</pre>
|
|||
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
|
||||
|
||||
<script src="./js/pricing.js?v=20260909tiers"></script>
|
||||
<script src="./js/pin-escrow.js?v=20260909pin"></script>
|
||||
<script defer src="./js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
|
|
|
|||
118
js/pin-escrow.js
Normal file
118
js/pin-escrow.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// PIN-escrow for the Sirius.X built-in wallet.
|
||||
//
|
||||
// After a user signs in with their full password, they may set a 4–6 digit
|
||||
// PIN. The wallet's mnemonic is encrypted with the PIN via PBKDF2 → AES-GCM
|
||||
// and stored under localStorage."siriusPinBlob" (survives browser close, so
|
||||
// next visit shows the PIN pad instead of the password field).
|
||||
//
|
||||
// Wrong PIN increments localStorage."siriusPinAttempts". After 3 wrong tries
|
||||
// the PIN blob is destroyed and the user falls back to their full password —
|
||||
// same UX pattern the Digibyte.x/web mobile wallet uses.
|
||||
//
|
||||
// Iteration counts are lower than the BuiltInWallet's 250k because a numeric
|
||||
// PIN's key space is tiny anyway. The point of PIN encryption is "not
|
||||
// plaintext in localStorage", not resistance to serious brute-force. Users
|
||||
// keep the real password as their durable secret.
|
||||
|
||||
(() => {
|
||||
const PIN_BLOB_KEY = "siriusPinBlob";
|
||||
const PIN_ATTEMPTS_KEY = "siriusPinAttempts";
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const PBKDF2_ITERATIONS = 50_000;
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
const b64 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
||||
const unb64 = (str) => Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
|
||||
|
||||
async function deriveKey(pin, salt) {
|
||||
const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
|
||||
material,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
function validPin(pin) {
|
||||
return typeof pin === "string" && /^\d{4,6}$/.test(pin);
|
||||
}
|
||||
|
||||
// Encrypt the mnemonic (or any string secret) with the PIN and stash it.
|
||||
// Called right after a successful password unlock, when the user opts in
|
||||
// to the PIN shortcut.
|
||||
async function savePinBlob(secret, pin) {
|
||||
if (!validPin(pin)) throw new Error("PIN must be 4-6 digits");
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(pin, salt);
|
||||
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc.encode(secret));
|
||||
const blob = {
|
||||
v: 1,
|
||||
it: PBKDF2_ITERATIONS,
|
||||
s: b64(salt),
|
||||
iv: b64(iv),
|
||||
ct: b64(ct),
|
||||
};
|
||||
localStorage.setItem(PIN_BLOB_KEY, JSON.stringify(blob));
|
||||
localStorage.setItem(PIN_ATTEMPTS_KEY, "0");
|
||||
}
|
||||
|
||||
// Try decrypting with the supplied PIN. On success returns the mnemonic
|
||||
// and resets the attempt counter. On failure increments attempts and
|
||||
// wipes the blob when the ceiling is hit (forcing password fallback).
|
||||
async function tryUnlock(pin) {
|
||||
const raw = localStorage.getItem(PIN_BLOB_KEY);
|
||||
if (!raw) throw new Error("no PIN set on this device");
|
||||
if (!validPin(pin)) throw new Error("PIN must be 4-6 digits");
|
||||
const blob = JSON.parse(raw);
|
||||
try {
|
||||
const key = await deriveKey(pin, unb64(blob.s));
|
||||
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(blob.iv) }, key, unb64(blob.ct));
|
||||
const secret = dec.decode(pt);
|
||||
localStorage.setItem(PIN_ATTEMPTS_KEY, "0");
|
||||
return secret;
|
||||
} catch {
|
||||
const now = (parseInt(localStorage.getItem(PIN_ATTEMPTS_KEY) || "0", 10) || 0) + 1;
|
||||
if (now >= MAX_ATTEMPTS) {
|
||||
localStorage.removeItem(PIN_BLOB_KEY);
|
||||
localStorage.removeItem(PIN_ATTEMPTS_KEY);
|
||||
const err = new Error("PIN locked — enter your full password to continue");
|
||||
err.locked = true;
|
||||
throw err;
|
||||
}
|
||||
localStorage.setItem(PIN_ATTEMPTS_KEY, String(now));
|
||||
const err = new Error(`Wrong PIN — ${MAX_ATTEMPTS - now} attempt${MAX_ATTEMPTS - now === 1 ? "" : "s"} left`);
|
||||
err.attemptsRemaining = MAX_ATTEMPTS - now;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPin() {
|
||||
try { return !!localStorage.getItem(PIN_BLOB_KEY); } catch { return false; }
|
||||
}
|
||||
|
||||
function attemptsUsed() {
|
||||
try { return parseInt(localStorage.getItem(PIN_ATTEMPTS_KEY) || "0", 10) || 0; } catch { return 0; }
|
||||
}
|
||||
function attemptsRemaining() {
|
||||
return Math.max(0, MAX_ATTEMPTS - attemptsUsed());
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try { localStorage.removeItem(PIN_BLOB_KEY); localStorage.removeItem(PIN_ATTEMPTS_KEY); } catch {}
|
||||
}
|
||||
|
||||
window.siriusPin = {
|
||||
savePinBlob,
|
||||
tryUnlock,
|
||||
hasPin,
|
||||
attemptsUsed,
|
||||
attemptsRemaining,
|
||||
clear,
|
||||
MAX_ATTEMPTS,
|
||||
};
|
||||
})();
|
||||
|
|
@ -155,10 +155,12 @@
|
|||
const action = t.dataset.action;
|
||||
if (action === "signout") {
|
||||
try { localStorage.removeItem("siriusProfile"); } catch {}
|
||||
// Also drop the session-cached seed so a reload after sign-out really
|
||||
// signs the user out (else the next page load would silently rebuild
|
||||
// the same wallet from sessionStorage).
|
||||
try { sessionStorage.removeItem("siriusSessionMnemonic"); sessionStorage.removeItem("siriusSessionSource"); } catch {}
|
||||
// Also wipe the PIN blob so a signed-out device can't be quick-
|
||||
// unlocked with a stale PIN. The password-encrypted BuiltInWallet
|
||||
// blob (bns.wallet.v1) stays — the user's device, the user's data;
|
||||
// they'd remove it explicitly via 'forget wallet' in a future
|
||||
// settings screen.
|
||||
try { window.siriusPin?.clear(); } catch {}
|
||||
try { delete window.siriusWallet; } catch { window.siriusWallet = null; }
|
||||
window.dispatchEvent(new Event("siriusProfileChanged"));
|
||||
// Reload so any signed-in section on the current page (portal, tld) resets.
|
||||
|
|
|
|||
|
|
@ -242,19 +242,13 @@ function writeProfile(w) {
|
|||
// mode, mark siriusProfile and close the modal here; otherwise return
|
||||
// false so the caller keeps going to Fund → Confirm → Register.
|
||||
function finishSignIn(w) {
|
||||
// Expose the live wallet globally BEFORE writeProfile so any listener of
|
||||
// siriusProfileChanged (portal.html adoptWalletFromModal) sees the wallet
|
||||
// and can switch to the signed-in view synchronously.
|
||||
// Expose the live wallet in memory only, BEFORE writeProfile so any
|
||||
// listener of siriusProfileChanged (portal.html adoptWalletFromModal)
|
||||
// sees it and can switch to the signed-in view synchronously. The
|
||||
// mnemonic is NEVER written to sessionStorage / localStorage in
|
||||
// plaintext — the only at-rest copies are the password-encrypted
|
||||
// BuiltInWallet blob and, optionally, the PIN-encrypted escrow blob.
|
||||
window.siriusWallet = w;
|
||||
// Session-cache the seed so a page reload restores the wallet without
|
||||
// asking for the password again. sessionStorage is per-tab and clears on
|
||||
// browser close — same lifetime as a "keep me signed in for this session"
|
||||
// toggle in a normal app. Chipnet-only convenience; mainnet gets the PIN
|
||||
// escrow pattern (see finishSignIn comment history).
|
||||
try {
|
||||
if (w?.mnemonic) sessionStorage.setItem("siriusSessionMnemonic", w.mnemonic);
|
||||
if (w?.source === "wc") sessionStorage.setItem("siriusSessionSource", "wc");
|
||||
} catch {}
|
||||
writeProfile({ address: w.address, tokenAddress: w.tokenAddress, source: w.source ?? "seed" });
|
||||
if (state.signInOnly) {
|
||||
render(`<h3><span class="ok">✓</span> Signed in</h3>
|
||||
|
|
@ -346,21 +340,13 @@ function adoptSignedInWallet() {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Best-effort silent rebuild from sessionStorage — same session ⇒ same
|
||||
// wallet, no password prompt. Returns true when the wallet is ready.
|
||||
// In-memory only: return the live wallet if it was set earlier this
|
||||
// session, else false. NO plaintext-mnemonic cache — a reload drops the
|
||||
// user back to the Unlock tab (PIN pad if a PIN blob exists, password
|
||||
// field otherwise), which decrypts locally without any secret ever
|
||||
// touching disk in plaintext.
|
||||
async function adoptSessionWalletAsync() {
|
||||
if (state.wallet) return true;
|
||||
if (adoptSignedInWallet()) return true;
|
||||
try {
|
||||
const cached = typeof sessionStorage !== "undefined"
|
||||
? sessionStorage.getItem("siriusSessionMnemonic")
|
||||
: null;
|
||||
if (!cached) return false;
|
||||
const w = await BNS.BuiltInWallet.fromMnemonic(cached);
|
||||
window.siriusWallet = w;
|
||||
state.wallet = w;
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return adoptSignedInWallet();
|
||||
}
|
||||
|
||||
async function startFlow(name, opts = {}) {
|
||||
|
|
@ -653,7 +639,7 @@ function stepPhrase() {
|
|||
`);
|
||||
$("copy").onclick = () => navigator.clipboard?.writeText(state.wallet.mnemonic);
|
||||
$("ack").onchange = (e) => { $("next").disabled = !e.target.checked; };
|
||||
$("next").onclick = () => { if (!finishSignIn(state.wallet)) stepFund(); };
|
||||
$("next").onclick = () => stepSetPin(state.wallet, () => { if (!finishSignIn(state.wallet)) stepFund(); });
|
||||
}
|
||||
|
||||
// ---------- built-in: import / unlock ----------
|
||||
|
|
@ -677,7 +663,7 @@ function stepImport() {
|
|||
const wallet = await BNS.BuiltInWallet.fromMnemonic($("mn").value);
|
||||
await wallet.save($("pw").value);
|
||||
state.wallet = wallet;
|
||||
if (!finishSignIn(wallet)) stepFund();
|
||||
stepSetPin(wallet, () => { if (!finishSignIn(wallet)) stepFund(); });
|
||||
} catch (e) { err(e); }
|
||||
};
|
||||
}
|
||||
|
|
@ -703,10 +689,49 @@ function stepUnlock() {
|
|||
$("go-new").onclick = stepCreate;
|
||||
return;
|
||||
}
|
||||
// PIN escrow — if the user set a PIN on a previous session, prefer that.
|
||||
// 3 wrong PINs wipe the blob and this branch falls back to the password
|
||||
// form on next render.
|
||||
if (window.siriusPin?.hasPin?.()) {
|
||||
const remaining = window.siriusPin.attemptsRemaining();
|
||||
render(`
|
||||
${renderTabs("unlock")}
|
||||
<h3>Enter your PIN</h3>
|
||||
<p class="sub">4–6 digits. Set on this device to skip typing your full password.
|
||||
${remaining < 3 ? `<b class="err" style="color:var(--taken,#f6768a)">${esc(String(remaining))} attempt${remaining === 1 ? "" : "s"} left</b> before the PIN resets and you must use your password.` : ""}</p>
|
||||
<label for="pin">PIN</label>
|
||||
<input type="password" id="pin" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="off">
|
||||
<div class="row end">
|
||||
<button class="btn ghost" id="use-password" type="button">Use password instead</button>
|
||||
<button class="btn acid" id="next" type="button">Unlock →</button>
|
||||
</div>
|
||||
`);
|
||||
$("use-password").onclick = () => { window.siriusPin.clear(); stepUnlock(); };
|
||||
$("pin").focus();
|
||||
$("pin").onkeydown = (e) => { if (e.key === "Enter") $("next").click(); };
|
||||
$("next").onclick = async () => {
|
||||
try {
|
||||
const mnemonic = await window.siriusPin.tryUnlock($("pin").value);
|
||||
state.wallet = await BNS.BuiltInWallet.fromMnemonic(mnemonic);
|
||||
if (!finishSignIn(state.wallet)) stepFund();
|
||||
} catch (e) {
|
||||
if (e.locked) { render(`
|
||||
${renderTabs("unlock")}
|
||||
<h3>PIN reset</h3>
|
||||
<p class="sub">3 wrong attempts — the PIN has been cleared. Enter your password to unlock and set a new PIN if you like.</p>
|
||||
<div class="row end"><button class="btn acid" id="go-pw">Continue with password →</button></div>
|
||||
`); $("go-pw").onclick = stepUnlock; return; }
|
||||
err(e);
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
render(`
|
||||
${renderTabs("unlock")}
|
||||
<h3>Unlock your wallet</h3>
|
||||
<p class="sub">Decrypts the wallet stored in this browser.</p>
|
||||
<p class="sub">Decrypts the wallet stored in this browser. After unlocking you can set a
|
||||
PIN so future sessions ask for 4–6 digits instead of the full password.</p>
|
||||
<label for="pw">Password</label>
|
||||
<input type="password" id="pw" autocomplete="current-password">
|
||||
<div class="row end"><button class="btn ghost" id="back">Back</button>
|
||||
|
|
@ -717,11 +742,45 @@ function stepUnlock() {
|
|||
$("next").onclick = async () => {
|
||||
try {
|
||||
state.wallet = await BNS.BuiltInWallet.load($("pw").value);
|
||||
if (!finishSignIn(state.wallet)) stepFund();
|
||||
// Fresh password unlock — offer the PIN shortcut before finishing.
|
||||
stepSetPin(state.wallet, () => { if (!finishSignIn(state.wallet)) stepFund(); });
|
||||
} catch (e) { err(e); }
|
||||
};
|
||||
}
|
||||
|
||||
// After a successful password sign-in (Import / Create / Unlock), offer to
|
||||
// stash the mnemonic under a PIN so next session opens with a numeric pad.
|
||||
// User can decline ("Not now"); either way we call `next` afterwards.
|
||||
function stepSetPin(w, next) {
|
||||
if (!window.siriusPin || !w?.mnemonic) return next();
|
||||
// Already have a PIN blob? Don't overwrite silently — skip.
|
||||
if (window.siriusPin.hasPin()) return next();
|
||||
render(`
|
||||
${renderTabs("unlock")}
|
||||
<h3>Set a PIN <span class="pill">optional</span></h3>
|
||||
<p class="sub">4–6 digits. Encrypts your recovery phrase under this PIN so future sessions on
|
||||
this device unlock with just the PIN. Three wrong PINs and the PIN is cleared — you'd fall
|
||||
back to your full password.</p>
|
||||
<label for="pin1">PIN</label>
|
||||
<input type="password" id="pin1" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="off">
|
||||
<label for="pin2" style="margin-top:12px">Repeat PIN</label>
|
||||
<input type="password" id="pin2" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="off">
|
||||
<div class="row end">
|
||||
<button class="btn ghost" id="skip" type="button">Not now</button>
|
||||
<button class="btn acid" id="save-pin" type="button">Save PIN →</button>
|
||||
</div>
|
||||
`);
|
||||
$("skip").onclick = next;
|
||||
$("pin2").onkeydown = (e) => { if (e.key === "Enter") $("save-pin").click(); };
|
||||
$("save-pin").onclick = async () => {
|
||||
const a = $("pin1").value, b = $("pin2").value;
|
||||
if (!/^\d{4,6}$/.test(a)) return err(new Error("PIN must be 4–6 digits"));
|
||||
if (a !== b) return err(new Error("PINs do not match"));
|
||||
try { await window.siriusPin.savePinBlob(w.mnemonic, a); next(); }
|
||||
catch (e) { err(e); }
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- fund ----------
|
||||
let fundTimer = null;
|
||||
async function stepFund() {
|
||||
|
|
|
|||
20
portal.html
20
portal.html
|
|
@ -278,20 +278,10 @@ async function adoptWalletFromModal() {
|
|||
await enterPortal();
|
||||
return true;
|
||||
}
|
||||
// No live wallet in memory — but if a session mnemonic is cached from an
|
||||
// earlier sign-in in this browser session, rebuild the wallet silently.
|
||||
// Same tab (or a reload) keeps the user signed in; a full browser close
|
||||
// clears sessionStorage and drops the user back to Unlock.
|
||||
try {
|
||||
const cached = sessionStorage.getItem("siriusSessionMnemonic");
|
||||
if (cached) {
|
||||
const w = await BNS.BuiltInWallet.fromMnemonic(cached);
|
||||
wallet = w;
|
||||
window.siriusWallet = w;
|
||||
await enterPortal();
|
||||
return true;
|
||||
}
|
||||
} catch { /* fall through to sign-in prompt */ }
|
||||
// No live wallet in memory — the mnemonic is NEVER cached in plaintext.
|
||||
// Fall through so the caller shows the Unlock tab; PIN-encrypted blob
|
||||
// (if the user set a PIN earlier) or the full password decrypts locally
|
||||
// in-browser.
|
||||
return false;
|
||||
}
|
||||
function readProfile() {
|
||||
|
|
@ -794,6 +784,8 @@ $("tld-submit").addEventListener("click", async () => {
|
|||
// Initial paint on page load so the card shows the price even before sign-in.
|
||||
updateTldPrice();
|
||||
</script>
|
||||
<script src="./js/pin-escrow.js?v=20260909pin"></script>
|
||||
<script src="./js/pricing.js?v=20260909tiers"></script>
|
||||
<script defer src="./js/site-footer.js?v=20260907rel"></script>
|
||||
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ sha256sum <downloaded-file>.exe</pre>
|
|||
|
||||
<footer id="site-footer"></footer>
|
||||
|
||||
<script src="../js/pin-escrow.js?v=20260909pin"></script>
|
||||
<script src="../js/pricing.js?v=20260909tiers"></script>
|
||||
<script defer src="../js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
|
|
|
|||
1
tld.html
1
tld.html
|
|
@ -283,6 +283,7 @@
|
|||
|
||||
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
|
||||
<script src="./js/pricing.js?v=20260909tiers"></script>
|
||||
<script src="./js/pin-escrow.js?v=20260909pin"></script>
|
||||
<script defer src="./js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue