Files
2026-09-19 06:20:37 +02:00

673 lines
27 KiB
JavaScript

/**
* f0ckm Anonymous Passkey Identity Manager
*
* Replaces the old OpenSSH Ed25519 approach.
* Private keys NEVER touch localStorage — they live in the OS / Bitwarden credential store.
*
* Flow:
* First visit: "Login as Anonymous" → register/begin → browser passkey prompt → register/finish → session
* Return visit: "Login as Anonymous" → auth/begin → browser passkey picker → auth/finish → session
*
* For registered users:
* Settings page calls window.f0ckPasskeyManager.addPasskey() to add a passkey.
*/
(function () {
'use strict';
// ─── base64url helpers (browser) ───────────────────────────────────────────
function b64urlToArr(b64) {
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function arrToB64url(buf) {
const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
let bin = '';
arr.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// ─── Hardware fingerprint (kept for ban enforcement) ──────────────────────
async function getHardwareFingerprint() {
try {
const cached = localStorage.getItem('f0ck_anon_hw_fp');
if (cached) return cached;
} catch (e) {}
try {
let glVendor = '', glRenderer = '', glLimits = '';
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (gl) {
const ext = gl.getExtension('WEBGL_debug_renderer_info');
if (ext) {
glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || '';
glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '';
}
glLimits = [
gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0,
gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0,
gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0,
gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0,
gl.getParameter(gl.MAX_VARYING_VECTORS) || 0,
gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0
].join(',');
}
} catch (e) {}
let gpuArch = '';
try {
if (navigator.gpu) {
const adapter = await navigator.gpu.requestAdapter();
if (adapter && adapter.info) {
gpuArch = [adapter.info.architecture, adapter.info.vendor, adapter.info.description].filter(Boolean).join(':');
}
}
} catch (e) {}
const concurrency = navigator.hardwareConcurrency || 0;
const memory = navigator.deviceMemory || 0;
const platform = navigator.platform || '';
const screenInfo = [
window.screen ? window.screen.width : 0,
window.screen ? window.screen.height : 0,
window.screen ? window.screen.colorDepth : 0,
window.devicePixelRatio || 1
].join('x');
const touchPoints = navigator.maxTouchPoints || 0;
let canvasFp = '';
try {
const c2d = document.createElement('canvas');
c2d.width = 240; c2d.height = 60;
const ctx = c2d.getContext('2d');
if (ctx) {
ctx.fillStyle = '#f60'; ctx.fillRect(10, 5, 60, 20);
ctx.fillStyle = '#069'; ctx.font = '14pt Arial, sans-serif';
ctx.fillText('f0ck.dev 😃', 4, 35);
const imgData = ctx.getImageData(0, 0, 240, 60).data;
let sum = 0;
for (let i = 0; i < imgData.length; i += 4) {
sum = (sum * 31 + imgData[i] + imgData[i+1] + imgData[i+2] + imgData[i+3]) >>> 0;
}
canvasFp = sum.toString(16);
}
} catch (e) {}
let audioFp = '';
try {
const AudioCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
if (AudioCtx) {
const actx = new AudioCtx(1, 44100, 44100);
const osc = actx.createOscillator();
osc.type = 'triangle';
osc.frequency.setValueAtTime(10000, actx.currentTime);
const comp = actx.createDynamicsCompressor();
comp.threshold.setValueAtTime(-50, actx.currentTime);
comp.knee.setValueAtTime(40, actx.currentTime);
comp.ratio.setValueAtTime(12, actx.currentTime);
comp.attack.setValueAtTime(0, actx.currentTime);
comp.release.setValueAtTime(0.25, actx.currentTime);
osc.connect(comp); comp.connect(actx.destination); osc.start(0);
const buf = await actx.startRendering();
const ch = buf.getChannelData(0);
let sum = 0;
for (let i = 4500; i < Math.min(ch.length, 5000); i++) sum += Math.abs(ch[i] || 0);
audioFp = sum.toFixed(7);
}
} catch (e) {}
const raw = [glVendor, glRenderer, glLimits, gpuArch, concurrency, memory, platform, screenInfo, touchPoints, canvasFp, audioFp].join('~~~');
const hashBuf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
const fp = `HW:${hashHex}`;
try { localStorage.setItem('f0ck_anon_hw_fp', fp); } catch (e) {}
return fp;
} catch (err) {
console.warn('[ANON_PASSKEY] Failed to compute hardware fingerprint:', err);
return null;
}
}
// ─── Tombstone (ban state persisted client-side) ───────────────────────────
function getTombstone() {
try { return JSON.parse(localStorage.getItem('f0ck_anon_tombstone') || 'null'); } catch (e) { return null; }
}
function setTombstone(t) {
try {
localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(t));
if (t && t.banned) {
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(t))}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
} catch (e) {}
}
// ─── AnonPasskey class ────────────────────────────────────────────────────
class AnonPasskey {
constructor() {
this.isSessionReady = false;
this._loginInProgress = false;
}
// ── Check WebAuthn support ──────────────────────────────────────────────
get supported() {
return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
}
// ── UI helpers ──────────────────────────────────────────────────────────
updateNavUI(isAnon) {
const icon = document.getElementById('nav-visitor-icon');
const label = document.getElementById('nav-anon-label');
if (icon) {
icon.classList.toggle('fa-user-secret', isAnon);
icon.classList.toggle('fa-user', !isAnon);
}
if (label) label.textContent = isAnon ? 'anonymous' : 'guest';
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
if (label) label.textContent = 'guest';
['nav-login-anon-btn', 'nav-anon-identity-btn', 'nav-anon-settings-btn', 'nav-anon-logout-btn', 'nav-anon-divider'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const modalAnonBtn = document.getElementById('modal-login-as-anon-btn');
if (modalAnonBtn) {
const w = modalAnonBtn.closest('div');
if (w) w.style.display = 'none'; else modalAnonBtn.style.display = 'none';
}
return;
}
const loginAnonBtn = document.getElementById('nav-login-anon-btn');
const anonIdentityBtn = document.getElementById('nav-anon-identity-btn');
const anonSettingsBtn = document.getElementById('nav-anon-settings-btn');
const anonLogoutBtn = document.getElementById('nav-anon-logout-btn');
const anonDivider = document.getElementById('nav-anon-divider');
const guestFavsNav = document.getElementById('nav-guest-favs');
const guestFavsLink = document.getElementById('nav-guest-favs-link');
if (loginAnonBtn) loginAnonBtn.style.display = isAnon ? 'none' : '';
if (anonIdentityBtn) anonIdentityBtn.style.display = isAnon ? '' : 'none';
if (anonSettingsBtn) anonSettingsBtn.style.display = isAnon ? '' : 'none';
if (anonLogoutBtn) anonLogoutBtn.style.display = isAnon ? '' : 'none';
if (anonDivider) anonDivider.style.display = isAnon ? '' : 'none';
if (guestFavsNav) guestFavsNav.style.display = isAnon ? '' : 'none';
if (guestFavsLink) guestFavsLink.style.display = isAnon ? '' : 'none';
if (isAnon && window.f0ckSession) {
window.f0ckSession.user = window.f0ckSession.user || 'anonymous';
window.f0ckSession.is_anon = true;
window.f0ckSession.logged_in = true;
}
if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI();
}
_applySessionData(data, hwFingerprint) {
this.isSessionReady = true;
if (window.f0ckSession) {
window.f0ckSession.user = 'anonymous';
window.f0ckSession.is_anon = true;
window.f0ckSession.logged_in = true;
window.f0ckSession.id = data.user_id;
window.f0ckSession.user_id = data.user_id;
if (data.csrf_token) window.f0ckSession.csrf_token = data.csrf_token;
}
const metaCsrf = document.querySelector('meta[name="csrf-token"]');
if (metaCsrf && data.csrf_token) metaCsrf.content = data.csrf_token;
window.f0ckAnonIdentity = {
userId: data.user_id,
fingerprint: data.fingerprint,
shortFingerprint: data.short_fingerprint,
hwFingerprint: data.hw_fingerprint || hwFingerprint,
credentialId: data.credential_id
};
window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity }));
if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI();
}
// ── Register a new passkey (anonymous user) ────────────────────────────
async register() {
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
// 1. Get registration options from server
const beginRes = await fetch('/api/v2/anon/passkey/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error during registration setup');
const rawChallenge = beginData.options.challenge;
const opts = beginData.options;
// 2. Decode options for the WebAuthn API
const pkOpts = {
rp: opts.rp,
user: {
id: b64urlToArr(opts.user.id),
name: opts.user.name,
displayName: opts.user.displayName
},
challenge: b64urlToArr(rawChallenge),
pubKeyCredParams: opts.pubKeyCredParams,
timeout: opts.timeout || 60000,
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
authenticatorSelection: opts.authenticatorSelection,
attestation: opts.attestation || 'none'
};
// 3. Browser passkey creation prompt
const cred = await navigator.credentials.create({ publicKey: pkOpts });
// 4. Send attestation to server
const hwFp = await getHardwareFingerprint();
const tombstone = getTombstone();
const finishRes = await fetch('/api/v2/anon/passkey/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: rawChallenge,
credentialId: arrToB64url(cred.rawId),
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
attestationObject: arrToB64url(cred.response.attestationObject),
hw_fingerprint: hwFp,
tombstone: tombstone
})
});
const finishData = await finishRes.json();
if (finishData.banned) {
setTombstone({
banned: true,
fingerprint: finishData.fingerprint,
hw_fingerprint: finishData.hw_fingerprint || hwFp,
reason: finishData.reason,
expires: finishData.expires
});
if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned';
throw new Error('Banned: ' + (finishData.reason || ''));
}
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
// Mark this browser as having a registered passkey for this site
this._markLocalPasskey();
return finishData;
}
// ── Authenticate with an existing passkey (anonymous user) ────────────
async authenticate() {
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
// 1. Get auth challenge from server
const beginRes = await fetch('/api/v2/anon/passkey/auth/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error during authentication setup');
const rawChallenge = beginData.options.challenge;
const opts = beginData.options;
// 2. Invoke browser passkey picker
const pkOpts = {
challenge: b64urlToArr(rawChallenge),
rpId: opts.rpId,
userVerification: opts.userVerification || 'preferred',
timeout: opts.timeout || 60000,
allowCredentials: (opts.allowCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) }))
};
const assertion = await navigator.credentials.get({ publicKey: pkOpts });
// 3. Send assertion to server
const hwFp = await getHardwareFingerprint();
const tombstone = getTombstone();
const finishRes = await fetch('/api/v2/anon/passkey/auth/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: rawChallenge,
credentialId: arrToB64url(assertion.rawId),
clientDataJSON: arrToB64url(assertion.response.clientDataJSON),
authenticatorData: arrToB64url(assertion.response.authenticatorData),
signature: arrToB64url(assertion.response.signature),
hw_fingerprint: hwFp,
tombstone: tombstone
})
});
const finishData = await finishRes.json();
if (finishData.banned) {
setTombstone({
banned: true,
fingerprint: finishData.fingerprint,
hw_fingerprint: finishData.hw_fingerprint || hwFp,
reason: finishData.reason,
expires: finishData.expires
});
if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned';
throw new Error('Banned: ' + (finishData.reason || ''));
}
if (!finishData.success) throw new Error(finishData.msg || 'Authentication failed');
return finishData;
}
// ── Helpers ───────────────────────────────────────────────────────────────
_hasLocalPasskey() {
try { return !!localStorage.getItem('f0ck_anon_has_passkey'); } catch (e) { return false; }
}
_markLocalPasskey() {
try { localStorage.setItem('f0ck_anon_has_passkey', '1'); } catch (e) {}
}
async _finishLogin(data) {
const hwFp = await getHardwareFingerprint();
this._applySessionData(data, hwFp);
if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') {
await window.f0ckGuestFavs.importToAccount();
}
this.updateNavUI(true);
// Close both modals
const sm = document.getElementById('anon-setup-modal'); if (sm) sm.style.display = 'none';
const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none';
if (typeof window.showToastNotification === 'function') {
window.showToastNotification('Logged in as anonymous');
}
window.location.reload();
}
// ── Public: called by the setup modal's "Create my passkey" button ────────
async doRegister() {
if (this._loginInProgress) return;
this._loginInProgress = true;
try {
const data = await this.register(); // throws on error/cancel
this._markLocalPasskey();
await this._finishLogin(data);
} catch (err) {
this._loginInProgress = false;
throw err; // modal handles the error display
}
}
// ── Public: called by the setup modal's "Use my passkey" button ───────────
async doAuthenticate() {
if (this._loginInProgress) return;
this._loginInProgress = true;
try {
const data = await this.authenticate();
await this._finishLogin(data);
} catch (err) {
this._loginInProgress = false;
throw err;
}
}
// ── Public: opens the setup modal (new vs returning view) ─────────────────
openSetupModal() {
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) return;
const tombstone = getTombstone();
if (tombstone && tombstone.banned) {
if (window.location.pathname !== '/banned') window.location.href = '/banned';
return;
}
if (!this.supported) {
alert('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.');
return;
}
// Close the login modal first
const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none';
const hasPasskey = this._hasLocalPasskey();
const newView = document.getElementById('anon-setup-new');
const retView = document.getElementById('anon-setup-returning');
if (newView) newView.style.display = hasPasskey ? 'none' : '';
if (retView) retView.style.display = hasPasskey ? '' : 'none';
const modal = document.getElementById('anon-setup-modal');
if (modal) modal.style.display = 'flex';
}
// ── Legacy: clicking "Login as anonymous" anywhere just opens the modal ───
loginAsAnonymous() {
this.openSetupModal();
}
// ── Logout ────────────────────────────────────────────────────────────────
async logoutAnonymous() {
try {
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
} finally {
this.updateNavUI(false);
if (typeof window.showToastNotification === 'function') {
window.showToastNotification('Logged out from anonymous session');
}
window.location.reload();
}
}
// ── Identity modal (shown when already logged in as anon) ─────────────────
openModal() {
const modal = document.getElementById('anon-passkey-modal');
if (!modal) return;
this._refreshModalContent();
modal.style.display = 'flex';
}
closeModal() {
const modal = document.getElementById('anon-passkey-modal');
if (modal) modal.style.display = 'none';
}
async _refreshModalContent() {
try {
const res = await fetch('/api/v2/anon/identity');
const data = await res.json();
const fpEl = document.getElementById('anon-pk-fp-display');
const credEl = document.getElementById('anon-pk-cred-display');
if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none';
if (credEl) credEl.textContent = data.credential_id || '—';
} catch (e) {}
}
// ── Init ──────────────────────────────────────────────────────────────────
async init() {
if (window.location.pathname === '/banned') return;
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
if (window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
window.location.reload();
return;
}
this.updateNavUI(false);
return;
}
// Registered user — nothing to do
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) return;
// Check tombstone — if banned, don't auto-login
const tombstone = getTombstone();
if (tombstone && tombstone.banned) {
this.updateNavUI(false);
this.attachUIListeners();
return;
}
// If backend session already shows is_anon, mark ready
if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
this.isSessionReady = true;
this.updateNavUI(true);
} else {
// Stale backend session without a local key no longer applies — just show guest
if (window.f0ckSession && window.f0ckSession.is_anon && !window.f0ckSession.logged_in) {
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
window.location.reload();
return;
}
this.updateNavUI(false);
}
this.attachUIListeners();
}
attachUIListeners() {
// Modal login-as-anonymous button
const modalAnonBtn = document.getElementById('modal-login-as-anon-btn');
if (modalAnonBtn) {
modalAnonBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); this.loginAsAnonymous(); });
}
document.addEventListener('click', e => {
if (e.target.closest('#nav-anon-identity-btn')) {
e.preventDefault(); this.openModal(); return;
}
if (e.target.closest('#nav-login-anon-btn, #modal-login-as-anon-btn, .trigger-login-anon')) {
e.preventDefault(); this.loginAsAnonymous(); return;
}
const logoutTarget = e.target.closest('#nav-anon-logout-btn, a[href="/logout"]');
if (logoutTarget && window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
e.preventDefault(); this.logoutAnonymous(); return;
}
});
const anonBtn = document.getElementById('nav-anon-identity-btn');
if (anonBtn) anonBtn.addEventListener('click', e => { e.preventDefault(); this.openModal(); });
const modalClose = document.getElementById('anon-passkey-modal-close');
if (modalClose) modalClose.addEventListener('click', () => this.closeModal());
const modalOverlay = document.getElementById('anon-passkey-modal');
if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); });
// Add-passkey button inside modal (for anonymous users to add a second passkey)
const addBtn = document.getElementById('anon-pk-add-btn');
if (addBtn) {
addBtn.addEventListener('click', async () => {
addBtn.disabled = true;
try {
await this.register();
if (typeof window.showToastNotification === 'function') window.showToastNotification('New passkey registered!');
this._refreshModalContent();
} catch (err) {
if (err.name !== 'NotAllowedError') alert('Failed to add passkey: ' + err.message);
} finally {
addBtn.disabled = false;
}
});
}
}
}
// ─── Passkey manager for registered users (used by settings page) ──────────
class PasskeyManager {
async listPasskeys() {
const res = await fetch('/api/v2/settings/passkeys');
return (await res.json()).passkeys || [];
}
async addPasskey(name) {
// 1. Begin
const beginRes = await fetch('/api/v2/settings/passkeys/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name || 'Passkey' })
});
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
const rawChallenge = beginData.options.challenge;
const opts = beginData.options;
const pkOpts = {
rp: opts.rp,
user: {
id: b64urlToArr(opts.user.id),
name: opts.user.name,
displayName: opts.user.displayName
},
challenge: b64urlToArr(rawChallenge),
pubKeyCredParams: opts.pubKeyCredParams,
timeout: opts.timeout || 60000,
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
authenticatorSelection: opts.authenticatorSelection,
attestation: opts.attestation || 'none'
};
const cred = await navigator.credentials.create({ publicKey: pkOpts });
// 2. Finish
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const finishRes = await fetch('/api/v2/settings/passkeys/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify({
challenge: rawChallenge,
credentialId: arrToB64url(cred.rawId),
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
attestationObject: arrToB64url(cred.response.attestationObject),
name: name || 'Passkey'
})
});
const finishData = await finishRes.json();
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
return finishData;
}
async deletePasskey(credentialId) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const res = await fetch('/api/v2/settings/passkeys/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify({ credential_id: credentialId })
});
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'Delete failed');
return data;
}
}
// ─── Bootstrap ────────────────────────────────────────────────────────────
window.f0ckAnonPasskey = new AnonPasskey();
window.f0ckPasskeyManager = new PasskeyManager();
// Backwards compat alias (so any code that checks window.f0ckAnonSSH still works for guards)
window.f0ckAnonSSH = window.f0ckAnonPasskey;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => window.f0ckAnonPasskey.init());
} else {
window.f0ckAnonPasskey.init();
}
})();