Files
f0ckm/public/s/js/anon_ssh.js
T
2026-09-13 14:19:37 +02:00

1074 lines
38 KiB
JavaScript

/**
* f0ckm Anonymous OpenSSH Ed25519 Identity Manager
* Provides client-side Ed25519 key generation, OpenSSH key formatting,
* signing, and authentication without login.
*/
(function () {
'use strict';
const PKCS8_HEADER = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20
]);
const STORAGE_KEY_PRIV = 'f0ck_anon_ssh_priv';
const STORAGE_KEY_PUB = 'f0ck_anon_ssh_pub';
const STORAGE_KEY_FP = 'f0ck_anon_ssh_fp';
function bytesToBase64(bytes) {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
function base64ToBytes(base64) {
const binary = window.atob(base64.replace(/\s+/g, ''));
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
function hexToBytes(hex) {
const clean = hex.replace(/[^0-9a-fA-F]/g, '');
const bytes = new Uint8Array(clean.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
}
return bytes;
}
function writeUInt32BE(buf, value, offset) {
buf[offset] = (value >>> 24) & 0xff;
buf[offset + 1] = (value >>> 16) & 0xff;
buf[offset + 2] = (value >>> 8) & 0xff;
buf[offset + 3] = value & 0xff;
}
function readUInt32BE(buf, offset) {
return (
(buf[offset] << 24) |
(buf[offset + 1] << 16) |
(buf[offset + 2] << 8) |
buf[offset + 3]
) >>> 0;
}
function concatUint8Arrays(arrays) {
const totalLen = arrays.reduce((acc, a) => acc + a.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
class AnonSSH {
constructor() {
this.cryptoKey = null;
this.pubkey = null;
this.fingerprint = null;
this.shortFingerprint = null;
this.rawPub = null;
this.rawSeed = null;
this.isSessionReady = false;
this.hwFingerprint = null;
try {
this.hwFingerprint = localStorage.getItem('f0ck_anon_hw_fp') || null;
} catch (e) {}
}
/**
* Compute a hardware-level fingerprint based on GPU, CPU, RAM, screen, and Web Audio DSP.
* Survives across private browsing windows and different browsers on the same physical device.
*/
async getHardwareFingerprint() {
if (this.hwFingerprint) return this.hwFingerprint;
try {
const cached = localStorage.getItem('f0ck_anon_hw_fp');
if (cached) {
this.hwFingerprint = cached;
return this.hwFingerprint;
}
} catch (e) {}
try {
let glVendor = '';
let glRenderer = '';
let 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) {}
// WebGPU adapter architecture (modern GPUs)
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) {}
// CPU & Memory
const concurrency = navigator.hardwareConcurrency || 0;
const memory = navigator.deviceMemory || 0;
const platform = navigator.platform || '';
// Display, Gamut & Dynamic Range
const screenInfo = [
window.screen ? window.screen.width : 0,
window.screen ? window.screen.height : 0,
window.screen ? window.screen.availWidth : 0,
window.screen ? window.screen.availHeight : 0,
window.screen ? window.screen.colorDepth : 0,
window.devicePixelRatio || 1,
window.matchMedia && window.matchMedia('(color-gamut: p3)').matches ? 'p3' : (window.matchMedia && window.matchMedia('(color-gamut: rec2020)').matches ? 'rec2020' : 'srgb'),
window.matchMedia && window.matchMedia('(dynamic-range: high)').matches ? 'hdr' : 'sdr'
].join('x');
// Input & Peripheral Hardware (touch, mouse, stylus)
const touchPoints = (typeof navigator !== 'undefined' && 'maxTouchPoints' in navigator) ? navigator.maxTouchPoints : 0;
const pointerType = (window.matchMedia && window.matchMedia('(pointer: fine)').matches) ? 'fine' : ((window.matchMedia && window.matchMedia('(pointer: coarse)').matches) ? 'coarse' : 'none');
const hoverType = (window.matchMedia && window.matchMedia('(hover: hover)').matches) ? 'hover' : 'none';
const inputHardware = `${touchPoints}:${pointerType}:${hoverType}`;
// 2D Canvas Font Rasterizer & Subpixel Geometry
let canvasFp = '';
try {
const c2d = document.createElement('canvas');
c2d.width = 240;
c2d.height = 60;
const ctx = c2d.getContext('2d');
if (ctx) {
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#f60';
ctx.fillRect(10, 5, 60, 20);
ctx.fillStyle = '#069';
ctx.font = '14pt Arial, sans-serif';
ctx.fillText('f0ck.dev <canvas> 😃', 4, 35);
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
ctx.font = '16pt Times, serif';
ctx.fillText('f0ck.dev <canvas> 😃', 20, 48);
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) {}
// Web Audio DSP Floating-Point Math
let audioFp = '';
try {
const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
if (AudioContext) {
const actx = new AudioContext(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.reduction.setValueAtTime(-20, 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 renderedBuffer = await actx.startRendering();
const channel = renderedBuffer.getChannelData(0);
let sum = 0;
const end = Math.min(channel.length, 5000);
for (let i = 4500; i < end; i++) {
sum += Math.abs(channel[i] || 0);
}
audioFp = sum.toFixed(7);
}
} catch (e) {}
// FPU Math Microarchitecture Precision
const mathPrecision = [
Math.tan(-1e300).toString().substring(0, 10),
Math.sinh(1).toString().substring(0, 10),
Math.acos(0.123456789).toString().substring(0, 10)
].join(';');
const rawHardware = [
glVendor,
glRenderer,
glLimits,
gpuArch,
concurrency,
memory,
platform,
screenInfo,
inputHardware,
canvasFp,
audioFp,
mathPrecision
].join('~~~');
const encoder = new TextEncoder();
const data = encoder.encode(rawHardware);
const hashBuf = await window.crypto.subtle.digest('SHA-256', data);
const hashHex = bytesToHex(new Uint8Array(hashBuf));
this.hwFingerprint = `HW:${hashHex}`;
try {
localStorage.setItem('f0ck_anon_hw_fp', this.hwFingerprint);
} catch (e) {}
return this.hwFingerprint;
} catch (err) {
console.warn('[ANON_SSH] Failed to compute hardware fingerprint:', err);
return null;
}
}
getDomain() {
return window.f0ckDomain || window.location.hostname || 'f0ck.dev';
}
/**
* Encode raw 32-byte Ed25519 public key into OpenSSH wire format
*/
encodeOpenSSHPublicKey(rawPub, comment) {
const comm = comment || `anonymous@${this.getDomain()}`;
const keyType = new TextEncoder().encode('ssh-ed25519');
const wirePub = new Uint8Array(4 + keyType.length + 4 + rawPub.length);
writeUInt32BE(wirePub, keyType.length, 0);
wirePub.set(keyType, 4);
writeUInt32BE(wirePub, rawPub.length, 4 + keyType.length);
wirePub.set(rawPub, 4 + keyType.length + 4);
const b64 = bytesToBase64(wirePub);
return `ssh-ed25519 ${b64} ${comm}`;
}
/**
* Compute standard OpenSSH SHA256 fingerprint from raw public key
*/
async computeFingerprint(rawPub) {
const keyType = new TextEncoder().encode('ssh-ed25519');
const wirePub = new Uint8Array(4 + keyType.length + 4 + rawPub.length);
writeUInt32BE(wirePub, keyType.length, 0);
wirePub.set(keyType, 4);
writeUInt32BE(wirePub, rawPub.length, 4 + keyType.length);
wirePub.set(rawPub, 4 + keyType.length + 4);
const hashBuffer = await window.crypto.subtle.digest('SHA-256', wirePub);
const hashBytes = new Uint8Array(hashBuffer);
const b64 = bytesToBase64(hashBytes).replace(/=+$/, '');
return `SHA256:${b64}`;
}
/**
* Encode raw seed + pubkey into a valid OpenSSH private key PEM file
*/
encodeOpenSSHPrivateKey(rawSeed, rawPub, comment) {
const comm = new TextEncoder().encode(comment || `anonymous@${this.getDomain()}`);
const keyType = new TextEncoder().encode('ssh-ed25519');
const none = new TextEncoder().encode('none');
const magic = new TextEncoder().encode('openssh-key-v1\0');
// 64-byte private key is seed + pubkey
const privKeyRaw = concatUint8Arrays([rawSeed, rawPub]);
// Random 32-bit checkInt
const checkArray = new Uint8Array(4);
window.crypto.getRandomValues(checkArray);
const checkInt = readUInt32BE(checkArray, 0);
// Construct private key block parts
const parts = [
new Uint8Array(8), // check1 + check2
new Uint8Array(4), keyType,
new Uint8Array(4), rawPub,
new Uint8Array(4), privKeyRaw,
new Uint8Array(4), comm
];
writeUInt32BE(parts[0], checkInt, 0);
writeUInt32BE(parts[0], checkInt, 4);
writeUInt32BE(parts[1], keyType.length, 0);
writeUInt32BE(parts[3], rawPub.length, 0);
writeUInt32BE(parts[5], privKeyRaw.length, 0);
writeUInt32BE(parts[7], comm.length, 0);
let privBlock = concatUint8Arrays(parts);
// Pad to 8-byte boundary
const padLen = (8 - (privBlock.length % 8)) % 8;
if (padLen > 0) {
const padding = new Uint8Array(padLen);
for (let i = 0; i < padLen; i++) padding[i] = i + 1;
privBlock = concatUint8Arrays([privBlock, padding]);
}
// Public key wire block
const wirePub = new Uint8Array(4 + keyType.length + 4 + rawPub.length);
writeUInt32BE(wirePub, keyType.length, 0);
wirePub.set(keyType, 4);
writeUInt32BE(wirePub, rawPub.length, 4 + keyType.length);
wirePub.set(rawPub, 4 + keyType.length + 4);
// Header block
const headerParts = [
magic,
new Uint8Array(4), none,
new Uint8Array(4), none,
new Uint8Array(4), // empty kdf options
new Uint8Array(4), // num keys = 1
new Uint8Array(4), wirePub,
new Uint8Array(4), privBlock
];
writeUInt32BE(headerParts[1], none.length, 0);
writeUInt32BE(headerParts[3], none.length, 0);
writeUInt32BE(headerParts[5], 0, 0);
writeUInt32BE(headerParts[6], 1, 0);
writeUInt32BE(headerParts[7], wirePub.length, 0);
writeUInt32BE(headerParts[9], privBlock.length, 0);
const bodyBytes = concatUint8Arrays(headerParts);
const b64 = bytesToBase64(bodyBytes);
const lines = b64.match(/.{1,70}/g) || [b64];
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${lines.join('\n')}\n-----END OPENSSH PRIVATE KEY-----\n`;
}
/**
* Decode OpenSSH private key PEM or raw seed string into 32-byte seed
*/
extractSeedFromInput(input) {
if (!input || typeof input !== 'string') {
throw new Error('Key input is required');
}
const trimmed = input.trim();
// Check if OpenSSH PEM format
if (trimmed.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) {
const cleanB64 = trimmed
.replace(/-----[^\n]+-----/g, '')
.replace(/\s+/g, '');
const buf = base64ToBytes(cleanB64);
// Find "ssh-ed25519" strings in wire format
const needle = new TextEncoder().encode('ssh-ed25519');
let firstIdx = -1;
for (let i = 0; i <= buf.length - needle.length; i++) {
let match = true;
for (let j = 0; j < needle.length; j++) {
if (buf[i + j] !== needle[j]) { match = false; break; }
}
if (match) { firstIdx = i; break; }
}
if (firstIdx === -1) throw new Error('Not an Ed25519 OpenSSH private key');
// Second occurrence is in private block
let secondIdx = -1;
for (let i = firstIdx + needle.length; i <= buf.length - needle.length; i++) {
let match = true;
for (let j = 0; j < needle.length; j++) {
if (buf[i + j] !== needle[j]) { match = false; break; }
}
if (match) { secondIdx = i; break; }
}
if (secondIdx === -1) throw new Error('Could not parse OpenSSH private key block');
const offset = secondIdx + needle.length;
const pubLen = readUInt32BE(buf, offset);
const pubOffset = offset + 4;
const privLenOffset = pubOffset + pubLen;
const privLen = readUInt32BE(buf, privLenOffset);
const privOffset = privLenOffset + 4;
if (privLen < 32 || privOffset + 32 > buf.length) {
throw new Error('Malformed OpenSSH private key length');
}
return buf.slice(privOffset, privOffset + 32);
}
// Check if 64-char hex string (32 bytes)
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
return hexToBytes(trimmed);
}
// Check if 44-char base64 string (32 bytes)
try {
const bytes = base64ToBytes(trimmed);
if (bytes.length === 32) return bytes;
if (bytes.length === 64) return bytes.slice(0, 32);
} catch (_) {}
throw new Error('Unrecognized key format. Paste an OpenSSH private key (id_ed25519) or 32-byte hex/base64 seed.');
}
/**
* Import raw 32-byte seed into Web Crypto SubtleCrypto
*/
async importSeed(seedBytes) {
if (seedBytes.length !== 32) throw new Error('Seed must be exactly 32 bytes');
const pkcs8 = concatUint8Arrays([PKCS8_HEADER, seedBytes]);
const cryptoKey = await window.crypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: 'Ed25519' },
true,
['sign']
);
// Derive raw public key
// Since WebCrypto doesn't directly export public key from private key in all browsers,
// we can also generate or sign/verify or use the derived public key
// But in modern Chromium/Firefox/Safari, Web Crypto can export or derive.
// If needed, we can extract from OpenSSH PEM if available, or generate a sign verify test
let rawPub = null;
try {
// Many browsers support exporting JWK or SPKI
const spkiBuffer = await window.crypto.subtle.exportKey?.('spki', cryptoKey).catch(() => null);
if (spkiBuffer) {
rawPub = new Uint8Array(spkiBuffer).slice(12);
}
} catch (_) {}
return { cryptoKey, rawSeed: seedBytes, rawPub };
}
/**
* Generate fresh Ed25519 identity in the browser
*/
async generateIdentity() {
const keyPair = await window.crypto.subtle.generateKey(
{ name: 'Ed25519' },
true,
['sign', 'verify']
);
const rawPubBuf = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
const rawPub = new Uint8Array(rawPubBuf);
const pkcs8Buf = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const pkcs8 = new Uint8Array(pkcs8Buf);
const rawSeed = pkcs8.slice(16);
const pubkey = this.encodeOpenSSHPublicKey(rawPub);
const fingerprint = await this.computeFingerprint(rawPub);
this.cryptoKey = keyPair.privateKey;
this.rawPub = rawPub;
this.rawSeed = rawSeed;
this.pubkey = pubkey;
this.fingerprint = fingerprint;
this.shortFingerprint = fingerprint.slice(7, 15);
localStorage.setItem(STORAGE_KEY_PRIV, bytesToHex(rawSeed));
localStorage.setItem(STORAGE_KEY_PUB, pubkey);
localStorage.setItem(STORAGE_KEY_FP, fingerprint);
return this.getIdentity();
}
/**
* Import custom key from user text input
*/
async importKey(input) {
const seedBytes = this.extractSeedFromInput(input);
const pkcs8 = concatUint8Arrays([PKCS8_HEADER, seedBytes]);
const privateKey = await window.crypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: 'Ed25519' },
true,
['sign']
);
// In Ed25519, the public key is deterministically derived from the seed
// To get the public key in Web Crypto, we sign a test challenge or compute it
// Let's create an identity check
this.cryptoKey = privateKey;
this.rawSeed = seedBytes;
// Extract rawPub if input was OpenSSH PEM
let rawPub = null;
if (input.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) {
try {
const cleanB64 = input.replace(/-----[^\n]+-----/g, '').replace(/\s+/g, '');
const buf = base64ToBytes(cleanB64);
const needle = new TextEncoder().encode('ssh-ed25519');
let idx = -1;
for (let i = 0; i <= buf.length - needle.length; i++) {
let match = true;
for (let j = 0; j < needle.length; j++) {
if (buf[i + j] !== needle[j]) { match = false; break; }
}
if (match) { idx = i; break; }
}
if (idx !== -1) {
const pubLen = readUInt32BE(buf, idx + needle.length);
const pubBytes = buf.slice(idx + needle.length + 4, idx + needle.length + 4 + pubLen);
if (pubBytes.length === 32) rawPub = pubBytes;
}
} catch (_) {}
}
if (!rawPub) {
// Fallback: generate keypair or re-derive
// If browser allows generateKey, generate identity and replace seed
rawPub = new Uint8Array(32); // fallback
}
this.rawPub = rawPub;
this.pubkey = this.encodeOpenSSHPublicKey(rawPub);
this.fingerprint = await this.computeFingerprint(rawPub);
this.shortFingerprint = this.fingerprint.slice(7, 15);
localStorage.setItem(STORAGE_KEY_PRIV, bytesToHex(seedBytes));
localStorage.setItem(STORAGE_KEY_PUB, this.pubkey);
localStorage.setItem(STORAGE_KEY_FP, this.fingerprint);
await this.ensureSession(true, isExplicitLogin);
return this.getIdentity();
}
getIdentity() {
return {
pubkey: this.pubkey,
fingerprint: this.fingerprint,
shortFingerprint: this.shortFingerprint,
rawPub: this.rawPub,
rawSeed: this.rawSeed
};
}
/**
* Sign an arbitrary UTF-8 string with the Ed25519 private key
*/
async sign(message) {
if (!this.cryptoKey) throw new Error('No active SSH private key');
const msgBytes = new TextEncoder().encode(message);
const sigBuffer = await window.crypto.subtle.sign(
{ name: 'Ed25519' },
this.cryptoKey,
msgBytes
);
return bytesToBase64(new Uint8Array(sigBuffer));
}
/**
* Authenticate to backend and establish/refresh anonymous session
*/
async ensureSession(force = false, isExplicitLogin = false) {
if (window.location.pathname === '/banned') {
return;
}
// If user is already logged in as a real registered user, don't overwrite session!
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) {
return;
}
if (this.isSessionReady && !force) return;
try {
const timestamp = Date.now();
const message = `anon-auth:${timestamp}:${this.pubkey}`;
const signature = await this.sign(message);
const tombstone = this.getTombstone();
const hwFingerprint = await this.getHardwareFingerprint();
const res = await fetch('/api/v2/anon/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pubkey: this.pubkey,
timestamp: timestamp,
signature: signature,
tombstone: tombstone,
hw_fingerprint: hwFingerprint
})
});
const data = await res.json();
if (data.banned) {
this.setTombstone({
banned: true,
fingerprint: data.fingerprint || this.fingerprint,
hw_fingerprint: data.hw_fingerprint || hwFingerprint,
reason: data.reason,
expires: data.expires
});
this.clearStoredIdentity();
this.updateNavUI(false);
// ONLY redirect to /banned if user was explicitly trying to login!
if (isExplicitLogin) {
const anonModal = document.getElementById('anon-ssh-modal');
if (anonModal) anonModal.style.display = 'none';
if (window.location.pathname !== '/banned') {
window.location.href = data.redirect || '/banned';
}
}
return;
}
if (data.success) {
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
};
window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity }));
if (typeof window.syncRatingButtonUI === 'function') {
window.syncRatingButtonUI();
}
}
} catch (err) {
console.warn('[ANON_SSH] Session handshake failed:', err);
}
}
/**
* Get client ban tombstone if previously banned.
*/
getTombstone() {
try {
const raw = localStorage.getItem('f0ck_anon_tombstone');
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
/**
* Save client ban tombstone.
*/
setTombstone(tombstone) {
try {
localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(tombstone));
if (tombstone && tombstone.banned) {
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(tombstone))}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
} catch (e) {}
}
/**
* Trigger browser download of id_ed25519 private key file
*/
downloadPrivateKey() {
if (!this.rawSeed || !this.rawPub) return;
const pem = this.encodeOpenSSHPrivateKey(this.rawSeed, this.rawPub);
const blob = new Blob([pem], { type: 'application/octet-stream' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'id_ed25519';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}
/**
* Trigger browser download of id_ed25519.pub public key file
*/
downloadPublicKey() {
if (!this.pubkey) return;
const content = `${this.pubkey}\n`;
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'id_ed25519.pub';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}
/**
* Copy public key string to clipboard
*/
async copyPublicKey() {
if (!this.pubkey) return;
try {
await navigator.clipboard.writeText(this.pubkey);
if (typeof window.showToastNotification === 'function') {
window.showToastNotification('OpenSSH Public Key copied to clipboard!');
} else {
alert('Public key copied to clipboard!');
}
} catch (err) {
prompt('Copy your OpenSSH Public Key:', this.pubkey);
}
}
/**
* Clear stored cryptographic identity from localStorage and memory
*/
clearStoredIdentity() {
localStorage.removeItem(STORAGE_KEY_PRIV);
localStorage.removeItem(STORAGE_KEY_PUB);
localStorage.removeItem(STORAGE_KEY_FP);
document.cookie = 'f0ck_banned=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
this.cryptoKey = null;
this.rawSeed = null;
this.rawPub = null;
this.pubkey = null;
this.fingerprint = null;
this.shortFingerprint = null;
this.isSessionReady = false;
}
/**
* Explicitly log in as anonymous (generates identity if none, establishes session, syncs guest favs)
*/
async loginAsAnonymous() {
if (this._loginInProgress) return;
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
return;
}
// If client has a ban tombstone, redirect to /banned directly on login attempt
const tombstone = this.getTombstone();
if (tombstone && tombstone.banned) {
const anonModal = document.getElementById('anon-ssh-modal');
if (anonModal) anonModal.style.display = 'none';
if (window.location.pathname !== '/banned') {
window.location.href = '/banned';
}
return;
}
this._loginInProgress = true;
// Close the login modal immediately for visual feedback
const loginModal = document.getElementById('login-modal');
if (loginModal) loginModal.style.display = 'none';
try {
if (!this.pubkey) {
await this.generateIdentity();
}
await this.ensureSession(true, true);
if (!this.isSessionReady) {
this._loginInProgress = false;
return;
}
// Sync any guest favorites saved in localStorage to this anon account
if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') {
await window.f0ckGuestFavs.importToAccount();
}
this.updateNavUI(true);
if (typeof window.showToastNotification === 'function') {
window.showToastNotification('Logged in as anonymous');
}
// Reload page to reflect authenticated anonymous session across navbar, comments, settings
window.location.reload();
} catch (err) {
this._loginInProgress = false;
console.error('[ANON_SSH] Login as anonymous failed:', err);
alert('Failed to log in as anonymous: ' + (err.message || err));
}
}
/**
* Log out of anonymous session, returning to clean guest state
*/
async logoutAnonymous() {
try {
this.clearStoredIdentity();
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();
}
}
/**
* Update navigation bar elements based on anonymous authentication state
*/
updateNavUI(isAnon) {
const icon = document.getElementById('nav-visitor-icon');
const label = document.getElementById('nav-anon-label');
if (icon) {
if (isAnon) {
icon.classList.remove('fa-user');
icon.classList.add('fa-user-secret');
} else {
icon.classList.remove('fa-user-secret');
icon.classList.add('fa-user');
}
}
if (label) {
label.textContent = isAnon ? 'anonymous' : 'guest';
}
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
if (label) label.textContent = 'guest';
const loginAnonBtn = document.getElementById('nav-login-anon-btn');
const modalAnonBtn = document.getElementById('modal-login-as-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');
if (loginAnonBtn) loginAnonBtn.style.display = 'none';
if (modalAnonBtn) {
const wrapper = modalAnonBtn.closest('div');
if (wrapper) wrapper.style.display = 'none';
else modalAnonBtn.style.display = 'none';
}
if (anonIdentityBtn) anonIdentityBtn.style.display = 'none';
if (anonSettingsBtn) anonSettingsBtn.style.display = 'none';
if (anonLogoutBtn) anonLogoutBtn.style.display = 'none';
if (anonDivider) anonDivider.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');
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';
const guestFavsNav = document.getElementById('nav-guest-favs');
const guestFavsLink = document.getElementById('nav-guest-favs-link');
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();
}
}
/**
* Initialize on page startup
*/
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;
}
// If registered user, do not override
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) {
return;
}
// If client has a ban tombstone, do NOT attempt background session handshake on page load/reload!
const tombstone = this.getTombstone();
if (tombstone && tombstone.banned) {
this.clearStoredIdentity();
this.updateNavUI(false);
this.attachUIListeners();
return;
}
const storedPriv = localStorage.getItem(STORAGE_KEY_PRIV);
const storedPub = localStorage.getItem(STORAGE_KEY_PUB);
const storedFp = localStorage.getItem(STORAGE_KEY_FP);
if (storedPriv && storedPub && storedFp) {
try {
const rawSeed = hexToBytes(storedPriv);
const pkcs8 = concatUint8Arrays([PKCS8_HEADER, rawSeed]);
this.cryptoKey = await window.crypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: 'Ed25519' },
true,
['sign']
);
this.rawSeed = rawSeed;
this.pubkey = storedPub;
this.fingerprint = storedFp;
this.shortFingerprint = storedFp.slice(7, 15);
// Extract rawPub from storedPub wire format
const parts = storedPub.trim().split(/\s+/);
const wirePub = base64ToBytes(parts[1]);
const typeLen = readUInt32BE(wirePub, 0);
this.rawPub = wirePub.slice(4 + typeLen + 4, 4 + typeLen + 4 + 32);
// User previously logged in as anonymous with this key
// If backend session is already active, avoid redundant session churn
if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
this.isSessionReady = true;
this.updateNavUI(true);
} else {
// Unauthenticated guest state: do NOT auto-login as anonymous on page load!
this.updateNavUI(false);
}
} catch (err) {
console.warn('[ANON_SSH] Failed to restore stored key:', err);
this.clearStoredIdentity();
this.updateNavUI(false);
}
} else {
// Clean guest state! Do NOT auto-generate or establish session!
if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
// Stale backend anon session cookie without corresponding local key: clear cookie
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
window.location.reload();
return;
}
this.updateNavUI(false);
}
// Hook identity modal triggers and action buttons
this.attachUIListeners();
}
attachUIListeners() {
// Direct click handler for the 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();
});
}
// Global click handler for Login as anonymous, Identity modal, & Logout
document.addEventListener('click', (e) => {
const identityTarget = e.target.closest('#nav-anon-identity-btn');
if (identityTarget) {
e.preventDefault();
this.openModal();
return;
}
const loginTarget = e.target.closest('#nav-login-anon-btn, #modal-login-as-anon-btn, .trigger-login-anon');
if (loginTarget) {
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) || this.pubkey)) {
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-ssh-modal-close');
if (modalClose) {
modalClose.addEventListener('click', () => this.closeModal());
}
const modalOverlay = document.getElementById('anon-ssh-modal');
if (modalOverlay) {
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) this.closeModal();
});
}
}
openModal() {
const modal = document.getElementById('anon-ssh-modal');
if (!modal) return;
const fp = this.fingerprint || localStorage.getItem(STORAGE_KEY_FP);
const pk = this.pubkey || localStorage.getItem(STORAGE_KEY_PUB);
const fpEl = document.getElementById('anon-ssh-fp-display');
const pubEl = document.getElementById('anon-ssh-pub-display');
if (fpEl) fpEl.textContent = fp || 'Generating...';
if (pubEl) pubEl.textContent = pk || '';
modal.style.display = 'flex';
}
closeModal() {
const modal = document.getElementById('anon-ssh-modal');
if (modal) modal.style.display = 'none';
}
}
window.f0ckAnonSSH = new AnonSSH();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => window.f0ckAnonSSH.init());
} else {
window.f0ckAnonSSH.init();
}
})();