alotta good shit

This commit is contained in:
2026-09-19 06:20:37 +02:00
parent 74f7884525
commit 1477d56658
45 changed files with 4363 additions and 2069 deletions
+369 -155
View File
@@ -1,17 +1,26 @@
import crypto from 'node:crypto';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import security from '../../security.mjs';
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.mjs';
import {
getOrCreateAnonUserByCredential,
createAnonSession,
resolveAuditIP
} from '../../anon_auth.mjs';
import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, fromBase64url, getRpIdFromHost
} from '../../webauthn.mjs';
import { getEnableAnonymousAccess } from '../../settings.mjs';
export default router => {
router.group(/^\/api\/v2\/anon/, group => {
/**
* POST /api/v2/anon/session
* Authenticate via OpenSSH Ed25519 signature and establish an anonymous session.
*/
// ─── Helpers ─────────────────────────────────────────────────────────────
const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => {
if (!sourceReason) return prefix;
let clean = sourceReason;
@@ -30,123 +39,60 @@ export default router => {
res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`);
};
group.post(/\/session$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({
success: false,
banned: true,
msg: 'YOU ARE BANNED!',
reason: ipBan.reason || 'IP address is banned',
expires: ipBan.expires ? new Date(ipBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
const body = req.post || req.body || {};
const pubkey = (body.pubkey || '').trim();
const timestamp = parseInt(body.timestamp, 10);
const signature = (body.signature || '').trim();
if (!pubkey || !timestamp || !signature) {
return res.json({ success: false, msg: 'Missing pubkey, timestamp, or signature' }, 400);
}
// Freshness check (5-minute window for clock skew)
const now = Date.now();
if (Math.abs(now - timestamp) > 300000) {
return res.json({ success: false, msg: 'Timestamp expired or out of bounds' }, 401);
}
const message = `anon-auth:${timestamp}:${pubkey}`;
const isValid = verifySignature(pubkey, message, signature);
if (!isValid) {
return res.json({ success: false, msg: 'Invalid Ed25519 signature' }, 401);
}
const parsed = parseOpenSshPubkey(pubkey);
const hwFingerprint = (body.hw_fingerprint || '').trim() || null;
// Check tombstone token sent from client (fingerprint or hardware ID)
if (body.tombstone && body.tombstone.banned) {
const tombstoneFp = body.tombstone.fingerprint;
const tombstoneHw = body.tombstone.hw_fingerprint;
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
if (activeTombstoneBan) {
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, activeTombstoneBan.reason || 'Device is banned', activeTombstoneBan.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint || tombstoneHw,
msg: 'YOU ARE BANNED!',
reason: activeTombstoneBan.reason || 'Device is banned',
expires: activeTombstoneBan.expires ? new Date(activeTombstoneBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
const checkAndCascadeBans = async (res, fingerprint, hwFingerprint, credentialId, tombstone) => {
// Tombstone cascade
if (tombstone && tombstone.banned) {
const tombstoneFp = tombstone.fingerprint;
const tombstoneHw = tombstone.hw_fingerprint;
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
if (activeTombstoneBan) {
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
if (!alreadyFpBanned && fingerprint) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
return activeTombstoneBan;
}
}
// Check hardware fingerprint ban
if (hwFingerprint) {
const hwBan = await security.isHardwareBanned(hwFingerprint);
if (hwBan) {
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, hwBan.reason || 'Hardware ID is banned', hwBan.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: hwBan.reason || 'Hardware ID is banned',
expires: hwBan.expires ? new Date(hwBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
// Hardware fingerprint ban
if (hwFingerprint) {
const hwBan = await security.isHardwareBanned(hwFingerprint);
if (hwBan) {
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
if (!alreadyFpBanned && fingerprint) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
return hwBan;
}
}
// Check fingerprint ban
const fpBan = await security.isFingerprintBanned(parsed.fingerprint);
// Fingerprint ban
if (fingerprint) {
const fpBan = await security.isFingerprintBanned(fingerprint);
if (fpBan) {
if (hwFingerprint) {
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
if (!alreadyHwBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
fingerprint,
hwFingerprint,
bannedBy: fpBan.banned_by,
reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'),
@@ -156,69 +102,332 @@ export default router => {
});
}
}
setBanCookie(res, fpBan.reason || 'Key fingerprint is banned', fpBan.expires);
return fpBan;
}
}
return null;
};
// ─── Deprecated SSH endpoint — hard cut ───────────────────────────────────
group.post(/\/session$/, async (req, res) => {
return res.json({
success: false,
msg: 'SSH-key anonymous authentication has been replaced by passkeys. Please refresh the page.'
}, 410);
});
// ─── Passkey Registration ─────────────────────────────────────────────────
/**
* POST /api/v2/anon/passkey/register/begin
* Returns WebAuthn registration options (challenge + rp + user config).
* The client does NOT need to be logged in.
*/
group.post(/\/passkey\/register\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const challenge = generateChallenge({ type: 'anon-register' });
// Generate a temporary opaque user handle (32 random bytes, base64url)
// This will be replaced by the real user_id after finish, but WebAuthn requires
// a user.id at registration time. We store it in the challenge.
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
// Store the user handle in the challenge so finish can retrieve it
// (The challenge entry is keyed by challenge string)
// We re-issue the challenge with the user handle attached
const challengeWithHandle = generateChallenge({ type: 'anon-register', userHandle });
// Temporary display name for the registration prompt
const tmpName = `anon_new@${cfg.main?.url?.domain || 'f0ck.dev'}`;
const options = buildRegistrationOptions({
challenge: challengeWithHandle,
userId: userHandle,
userName: tmpName,
displayName: 'Anonymous',
rpId: getRpIdFromHost(req.headers.host)
});
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] register/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/register/finish
* Verify attestation, create shadow user + credential, establish session.
*/
group.post(/\/passkey\/register\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume and verify challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-register') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Verify the attestation
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[ANON_PASSKEY] Registration verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
// Get fingerprint before ban checks (derived from credentialId)
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
// Ban checks
const ban = await checkAndCascadeBans(res, fingerprint, hwFingerprint || null, credentialId, tombstone || null);
if (ban) {
setBanCookie(res, ban.reason || 'Banned', ban.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: fpBan.reason || 'Key fingerprint is banned',
expires: fpBan.expires ? new Date(fpBan.expires).toLocaleString() : 'Permanent',
success: false, banned: true,
fingerprint, hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!', reason: ban.reason,
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint, req, hwFingerprint);
// Get or create shadow user
const { userId, isNew, fingerprint: fp } = await getOrCreateAnonUserByCredential(
credentialId, req, hwFingerprint || null
);
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
userId,
fingerprint: parsed.fingerprint,
hwFingerprint,
reason: u.ban_reason || 'Banned',
expires: u.ban_expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: u.ban_reason || 'Banned',
expires: u.ban_expires ? new Date(u.ban_expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
}
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint);
// Store / update passkey credential in passkey_credentials
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'})
ON CONFLICT (credential_id) DO UPDATE
SET sign_count = ${regResult.signCount}, last_used = NOW()
`;
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
success: true,
is_new: isNew,
user_id: userId,
fingerprint: parsed.fingerprint,
short_fingerprint: parsed.shortFingerprint,
hw_fingerprint: hwFingerprint,
csrf_token: csrf_token
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
credential_id: credentialId,
hw_fingerprint: hwFingerprint || null,
csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Session establishment error:', err);
console.error('[ANON_PASSKEY] register/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Passkey Authentication ───────────────────────────────────────────────
/**
* POST /api/v2/anon/passkey/auth/begin
* Returns authentication options. allowCredentials is empty (discoverable credential flow).
*/
group.post(/\/passkey\/auth\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const challenge = generateChallenge({ type: 'anon-auth' });
const options = buildAuthenticationOptions({
challenge,
allowCredentials: [], // discoverable — let the browser/Bitwarden pick
rpId: getRpIdFromHost(req.headers.host)
});
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] auth/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/auth/finish
* Verify assertion, establish anonymous session.
*/
group.post(/\/passkey\/auth\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-auth') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Look up stored credential
const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count, ai.fingerprint
FROM passkey_credentials pc
LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = ${credentialId}
WHERE pc.credential_id = ${credentialId}
LIMIT 1
`;
if (credRows.length === 0) {
return res.json({ success: false, msg: 'Passkey not registered. Please register first.' }, 401);
}
const { user_id: userId, public_key_spki: spki, sign_count: storedSignCount, fingerprint } = credRows[0];
// Verify the assertion
let authResult;
try {
authResult = await verifyAuthentication({
challenge,
clientDataJSON,
authenticatorData,
signature,
spki,
storedSignCount,
rpId: getRpIdFromHost(req.headers.host)
});
} catch (e) {
console.warn('[ANON_PASSKEY] Auth verification failed:', e.message);
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
}
// Derive fingerprint if not stored yet (legacy or first-time)
const fpForBan = fingerprint || (() => {
return 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
})();
// Ban checks
const ban = await checkAndCascadeBans(res, fpForBan, hwFingerprint || null, credentialId, tombstone || null);
if (ban) {
setBanCookie(res, ban.reason || 'Banned', ban.expires);
return res.json({
success: false, banned: true,
fingerprint: fpForBan, hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!', reason: ban.reason,
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
}
// Update sign count and last_used
await db`
UPDATE passkey_credentials
SET sign_count = ${authResult.newSignCount}, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
// Update anon_identities (hw_fingerprint, last_seen)
await db`
UPDATE anon_identities
SET last_seen = NOW()
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
WHERE user_id = ${userId} AND credential_id = ${credentialId}
`.catch(() => {});
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
success: true,
user_id: userId,
fingerprint: fpForBan,
short_fingerprint: fpForBan.slice(7, 15),
credential_id: credentialId,
hw_fingerprint: hwFingerprint || null,
csrf_token
});
} catch (err) {
console.error('[ANON_PASSKEY] auth/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Identity ─────────────────────────────────────────────────────────────
/**
* GET /api/v2/anon/identity
* Get the current anonymous identity or registered user state.
@@ -234,9 +443,11 @@ export default router => {
}
const rows = await db`
SELECT pubkey, fingerprint, hw_fingerprint, created_at, last_seen
FROM anon_identities
WHERE user_id = ${req.session.id}
SELECT ai.credential_id, ai.fingerprint, ai.hw_fingerprint, ai.created_at, ai.last_seen,
pc.name AS passkey_name, pc.aaguid
FROM anon_identities ai
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
WHERE ai.user_id = ${req.session.id}
LIMIT 1
`;
@@ -247,9 +458,10 @@ export default router => {
is_anon: true,
user_id: req.session.id,
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
short_fingerprint: fp ? fp.slice(7, 15) : null,
hw_fingerprint: rows[0].hw_fingerprint,
pubkey: rows[0].pubkey,
credential_id: rows[0].credential_id,
passkey_name: rows[0].passkey_name,
csrf_token: req.session.csrf_token
});
}
@@ -262,11 +474,13 @@ export default router => {
csrf_token: req.session.csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Identity lookup error:', err);
console.error('[ANON_PASSKEY] Identity lookup error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
// ─── Logout ───────────────────────────────────────────────────────────────
/**
* POST /api/v2/anon/logout
* Clear anonymous session cookie and remove active session from database.
@@ -282,7 +496,7 @@ export default router => {
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
return res.json({ success: true });
} catch (err) {
console.error('[ANON_AUTH] Logout error:', err);
console.error('[ANON_PASSKEY] Logout error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});