diff --git a/main.js b/main.js index 7cfeff0..c3d1c01 100644 --- a/main.js +++ b/main.js @@ -67,9 +67,153 @@ console.log('[Oasis] Launching Oasis Social Network Utopia...'); console.log('[Oasis] Backend path:', backendPath); console.log('[Oasis] HOME:', process.env.HOME); -try { +const http = require('http'); +const keylock = require('./src/models/keylock'); +const configPath = path.join(process.env.HOME, '.ssb'); + +let backendStarted = false; +function startBackend() { + if (backendStarted) return; + backendStarted = true; + try { require(backendPath); console.log('[Oasis] Backend started on port 3000'); -} catch (error) { + } catch (error) { console.error('[Oasis] Error starting backend:', error); + } } + +function unlockAndStart(keys) { + global.__OASIS_KEYS__ = keys; + for (const s of sockets) { try { s.destroy(); } catch (_) {} } + try { lockServer.close(() => startBackend()); } catch (_) { startBackend(); } +} + +const MODE = keylock.isConfigured(configPath) ? 'unlock' : 'setup'; + +const LOCK_LANGS = ['en','es','fr','eu','de','it','pt','zh','ar','hi','ru']; +const LOCK_I18N = { + en: { name:"English", t_setup:"Create your password", t_unlock:"Unlock Oasis", s_setup:"It encrypts your identity and keys on this device. If you forget it, you lose the account (no recovery).", s_unlock:"Enter your password to decrypt your keys and start Oasis.", ph:"Password", ph2:"Repeat password", b_setup:"Create and enter", b_unlock:"Enter", e_min:"Minimum 6 characters", e_mism:"Passwords do not match", e_wrong:"Incorrect password", e_pre:"Error: ", m_unlock:"Unlocking…" }, + es: { name:"Español", t_setup:"Crea tu contraseña", t_unlock:"Desbloquea Oasis", s_setup:"Cifrará tu identidad y tus claves en este dispositivo. Si la olvidas, pierdes la cuenta (no hay recuperación).", s_unlock:"Introduce tu contraseña para descifrar tus claves y arrancar Oasis.", ph:"Contraseña", ph2:"Repite la contraseña", b_setup:"Crear y entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"Las contraseñas no coinciden", e_wrong:"Contraseña incorrecta", e_pre:"Error: ", m_unlock:"Desbloqueando…" }, + fr: { name:"Français", t_setup:"Créez votre mot de passe", t_unlock:"Déverrouiller Oasis", s_setup:"Il chiffre votre identité et vos clés sur cet appareil. Si vous l'oubliez, vous perdez le compte (aucune récupération).", s_unlock:"Saisissez votre mot de passe pour déchiffrer vos clés et démarrer Oasis.", ph:"Mot de passe", ph2:"Répétez le mot de passe", b_setup:"Créer et entrer", b_unlock:"Entrer", e_min:"6 caractères minimum", e_mism:"Les mots de passe ne correspondent pas", e_wrong:"Mot de passe incorrect", e_pre:"Erreur : ", m_unlock:"Déverrouillage…" }, + eu: { name:"Euskara", t_setup:"Sortu zure pasahitza", t_unlock:"Desblokeatu Oasis", s_setup:"Zure identitatea eta gakoak gailu honetan zifratzen ditu. Ahazten baduzu, kontua galtzen duzu (ez dago berreskuratzerik).", s_unlock:"Sartu zure pasahitza gakoak deszifratu eta Oasis abiarazteko.", ph:"Pasahitza", ph2:"Errepikatu pasahitza", b_setup:"Sortu eta sartu", b_unlock:"Sartu", e_min:"Gutxienez 6 karaktere", e_mism:"Pasahitzak ez datoz bat", e_wrong:"Pasahitz okerra", e_pre:"Errorea: ", m_unlock:"Desblokeatzen…" }, + de: { name:"Deutsch", t_setup:"Erstelle dein Passwort", t_unlock:"Oasis entsperren", s_setup:"Es verschlüsselt deine Identität und Schlüssel auf diesem Gerät. Wenn du es vergisst, verlierst du das Konto (keine Wiederherstellung).", s_unlock:"Gib dein Passwort ein, um deine Schlüssel zu entschlüsseln und Oasis zu starten.", ph:"Passwort", ph2:"Passwort wiederholen", b_setup:"Erstellen und eintreten", b_unlock:"Eintreten", e_min:"Mindestens 6 Zeichen", e_mism:"Passwörter stimmen nicht überein", e_wrong:"Falsches Passwort", e_pre:"Fehler: ", m_unlock:"Entsperren…" }, + it: { name:"Italiano", t_setup:"Crea la tua password", t_unlock:"Sblocca Oasis", s_setup:"Cifra la tua identità e le tue chiavi su questo dispositivo. Se la dimentichi, perdi l'account (nessun recupero).", s_unlock:"Inserisci la password per decifrare le chiavi e avviare Oasis.", ph:"Password", ph2:"Ripeti la password", b_setup:"Crea ed entra", b_unlock:"Entra", e_min:"Minimo 6 caratteri", e_mism:"Le password non coincidono", e_wrong:"Password errata", e_pre:"Errore: ", m_unlock:"Sblocco…" }, + pt: { name:"Português", t_setup:"Crie a sua palavra-passe", t_unlock:"Desbloquear o Oasis", s_setup:"Cifra a sua identidade e chaves neste dispositivo. Se a esquecer, perde a conta (sem recuperação).", s_unlock:"Introduza a sua palavra-passe para decifrar as chaves e iniciar o Oasis.", ph:"Palavra-passe", ph2:"Repita a palavra-passe", b_setup:"Criar e entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"As palavras-passe não coincidem", e_wrong:"Palavra-passe incorreta", e_pre:"Erro: ", m_unlock:"A desbloquear…" }, + zh: { name:"中文", t_setup:"创建你的密码", t_unlock:"解锁 Oasis", s_setup:"它会在此设备上加密你的身份和密钥。如果忘记,你将失去账户(无法恢复)。", s_unlock:"输入密码以解密你的密钥并启动 Oasis。", ph:"密码", ph2:"重复密码", b_setup:"创建并进入", b_unlock:"进入", e_min:"至少 6 个字符", e_mism:"密码不一致", e_wrong:"密码错误", e_pre:"错误:", m_unlock:"正在解锁…" }, + ar: { name:"العربية", t_setup:"أنشئ كلمة المرور", t_unlock:"فتح Oasis", s_setup:"يشفّر هويتك ومفاتيحك على هذا الجهاز. إذا نسيتها، تفقد الحساب (لا استعادة).", s_unlock:"أدخل كلمة المرور لفك تشفير مفاتيحك وتشغيل Oasis.", ph:"كلمة المرور", ph2:"أعد كلمة المرور", b_setup:"إنشاء ودخول", b_unlock:"دخول", e_min:"6 أحرف على الأقل", e_mism:"كلمتا المرور غير متطابقتين", e_wrong:"كلمة مرور غير صحيحة", e_pre:"خطأ: ", m_unlock:"جارٍ الفتح…" }, + hi: { name:"हिन्दी", t_setup:"अपना पासवर्ड बनाएं", t_unlock:"Oasis अनलॉक करें", s_setup:"यह इस डिवाइस पर आपकी पहचान और कुंजियाँ एन्क्रिप्ट करता है। भूल गए तो खाता खो देंगे (कोई रिकवरी नहीं)।", s_unlock:"अपनी कुंजियाँ डिक्रिप्ट करने और Oasis शुरू करने के लिए पासवर्ड दर्ज करें।", ph:"पासवर्ड", ph2:"पासवर्ड दोहराएँ", b_setup:"बनाएँ और प्रवेश करें", b_unlock:"प्रवेश करें", e_min:"कम से कम 6 अक्षर", e_mism:"पासवर्ड मेल नहीं खाते", e_wrong:"गलत पासवर्ड", e_pre:"त्रुटि: ", m_unlock:"अनलॉक हो रहा है…" }, + ru: { name:"Русский", t_setup:"Создайте пароль", t_unlock:"Разблокировать Oasis", s_setup:"Он шифрует вашу личность и ключи на этом устройстве. Если забудете, потеряете аккаунт (без восстановления).", s_unlock:"Введите пароль, чтобы расшифровать ключи и запустить Oasis.", ph:"Пароль", ph2:"Повторите пароль", b_setup:"Создать и войти", b_unlock:"Войти", e_min:"Минимум 6 символов", e_mism:"Пароли не совпадают", e_wrong:"Неверный пароль", e_pre:"Ошибка: ", m_unlock:"Разблокировка…" }, +}; + +function lockPage(mode) { + const confirmField = mode === 'setup' + ? '' + : ''; + return '' + + '' + + 'Oasis' + + '' + + '' + + '

' + + '
' + + confirmField + + '' + + '
' + + ''; +} + +function readBody(req) { + return new Promise((resolve) => { + let b = ''; + req.on('data', (d) => { b += d; if (b.length > 1e5) req.destroy(); }); + req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (_) { resolve({}); } }); + req.on('error', () => resolve({})); + }); +} + +const sockets = new Set(); +const lockServer = http.createServer(async (req, res) => { + try { + if (req.method === 'POST' && req.url === '/__keylock/setup') { + const { password } = await readBody(req); + if (!password || String(password).length < 6) { res.writeHead(400, { 'Connection': 'close' }); return res.end('{"ok":false}'); } + keylock.setPassword(configPath, password); + const keys = keylock.loadOrCreateSecret(configPath); + res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' }); + res.end('{"ok":true}'); + return unlockAndStart(keys); + } + if (req.method === 'POST' && req.url === '/__keylock/unlock') { + const { password } = await readBody(req); + const kek = password ? keylock.verifyPassword(configPath, password) : null; + if (!kek) { res.writeHead(401, { 'Connection': 'close' }); return res.end('{"ok":false}'); } + const keys = keylock.loadOrCreateSecret(configPath); + res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' }); + res.end('{"ok":true}'); + return unlockAndStart(keys); + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' }); + res.end(lockPage(MODE)); + } catch (e) { + res.writeHead(500, { 'Connection': 'close' }); + res.end('{"ok":false,"error":"' + String(e && e.message).replace(/"/g, '') + '"}'); + } +}); +lockServer.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); }); +lockServer.listen(3000, '127.0.0.1', () => { + console.log('[Oasis] keylock gate escuchando en 127.0.0.1:3000 (modo: ' + MODE + ')'); +}); diff --git a/src/models/crypto.js b/src/models/crypto.js index bdd9321..10d7085 100644 --- a/src/models/crypto.js +++ b/src/models/crypto.js @@ -1,6 +1,7 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); +const keylock = require('./keylock'); const SENSITIVE_FIELDS = [ 'title', 'description', 'location', 'price', 'salary', 'options', 'votes', @@ -45,7 +46,14 @@ module.exports = (configPath, namespace = 'tribes') => { const loadKeyring = () => { try { - keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8')); + const raw = fs.readFileSync(keyringPath); + if (keylock.isWrapped(raw)) { + if (!keylock.getKEK()) throw new Error('keyring cifrado pero keylock bloqueado'); + keyring = JSON.parse(keylock.unwrap(raw).toString('utf8')); + } else { + keyring = JSON.parse(raw.toString('utf8')); + if (keylock.getKEK()) { try { saveKeyring(); } catch (_) {} } + } try { fs.chmodSync(keyringPath, 0o600); } catch (_) {} } catch (e) { if (e.code !== 'ENOENT') throw e; @@ -55,8 +63,11 @@ module.exports = (configPath, namespace = 'tribes') => { }; const saveKeyring = () => { + const json = JSON.stringify(keyring, null, 2); + const kek = keylock.getKEK(); + const out = kek ? keylock.wrap(Buffer.from(json, 'utf8'), kek) : Buffer.from(json, 'utf8'); const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now(); - fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), { encoding: 'utf8', mode: 0o600 }); + fs.writeFileSync(tmp, out, { mode: 0o600 }); fs.renameSync(tmp, keyringPath); try { fs.chmodSync(keyringPath, 0o600); } catch (_) {} }; diff --git a/src/models/keylock.js b/src/models/keylock.js new file mode 100644 index 0000000..a50d7e8 --- /dev/null +++ b/src/models/keylock.js @@ -0,0 +1,129 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const ssbKeys = require('../server/node_modules/ssb-keys'); + +const MAGIC = Buffer.from('OAES1\n', 'utf8'); +const KEYLEN = 32; +const DEFAULT_SCRYPT = { N: 16384, r: 8, p: 1 }; +const VERIFIER_PLAINTEXT = Buffer.from('oasis-keylock-v1', 'utf8'); + +let KEK = null; // SOLO en memoria + +const maxmemFor = (s) => Math.max(64 * 1024 * 1024, 256 * s.N * s.r); + +const deriveKEK = (password, salt, scrypt = DEFAULT_SCRYPT) => + crypto.scryptSync(Buffer.from(String(password), 'utf8'), salt, KEYLEN, + { N: scrypt.N, r: scrypt.r, p: scrypt.p, maxmem: maxmemFor(scrypt) }); + +const isWrapped = (buf) => + Buffer.isBuffer(buf) && buf.length >= MAGIC.length && buf.subarray(0, MAGIC.length).equals(MAGIC); + +const wrap = (buf, kek = KEK) => { + if (!kek) throw new Error('keylock: locked (no KEK)'); + const iv = crypto.randomBytes(12); + const c = crypto.createCipheriv('aes-256-gcm', kek, iv); + const ct = Buffer.concat([c.update(buf), c.final()]); + const tag = c.getAuthTag(); + return Buffer.concat([MAGIC, iv, tag, ct]); +}; + +const unwrap = (blob, kek = KEK) => { + if (!kek) throw new Error('keylock: locked (no KEK)'); + if (!isWrapped(blob)) throw new Error('keylock: not a wrapped blob'); + let o = MAGIC.length; + const iv = blob.subarray(o, o += 12); + const tag = blob.subarray(o, o += 16); + const ct = blob.subarray(o); + const d = crypto.createDecipheriv('aes-256-gcm', kek, iv); + d.setAuthTag(tag); + return Buffer.concat([d.update(ct), d.final()]); +}; + +const lockPath = (configPath) => path.join(configPath, 'keylock.json'); + +const isConfigured = (configPath) => { + try { return fs.existsSync(lockPath(configPath)); } catch (_) { return false; } +}; + +const setPassword = (configPath, password) => { + const salt = crypto.randomBytes(16); + const scrypt = DEFAULT_SCRYPT; + const kek = deriveKEK(password, salt, scrypt); + const verifier = wrap(VERIFIER_PLAINTEXT, kek); + const data = { + v: 1, kdf: 'scrypt', N: scrypt.N, r: scrypt.r, p: scrypt.p, + salt: salt.toString('hex'), verifier: verifier.toString('base64'), + }; + fs.mkdirSync(configPath, { recursive: true, mode: 0o700 }); + const tmp = lockPath(configPath) + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); + fs.renameSync(tmp, lockPath(configPath)); + KEK = kek; + return kek; +}; + +const verifyPassword = (configPath, password) => { + let data; + try { data = JSON.parse(fs.readFileSync(lockPath(configPath), 'utf8')); } catch (_) { return null; } + const salt = Buffer.from(data.salt, 'hex'); + const scrypt = { N: data.N || DEFAULT_SCRYPT.N, r: data.r || DEFAULT_SCRYPT.r, p: data.p || DEFAULT_SCRYPT.p }; + const kek = deriveKEK(password, salt, scrypt); + try { + if (unwrap(Buffer.from(data.verifier, 'base64'), kek).equals(VERIFIER_PLAINTEXT)) { + KEK = kek; + return kek; + } + } catch (_) {} + return null; +}; + +const getKEK = () => KEK; +const setKEK = (k) => { KEK = k; }; +const isUnlocked = () => KEK != null; +const lock = () => { try { if (KEK) KEK.fill(0); } catch (_) {} KEK = null; }; + +const secretEncPath = (configPath) => path.join(configPath, 'secret.enc'); +const legacySecretPath = (configPath) => path.join(configPath, 'secret'); + +const shred = (p) => { + try { fs.writeFileSync(p, crypto.randomBytes(Math.max(1, fs.statSync(p).size))); } catch (_) {} + try { fs.unlinkSync(p); } catch (_) {} +}; + +const writeSecret = (configPath, keys) => { + const blob = wrap(Buffer.from(JSON.stringify(keys), 'utf8')); + fs.mkdirSync(configPath, { recursive: true, mode: 0o700 }); + const tmp = secretEncPath(configPath) + '.tmp'; + fs.writeFileSync(tmp, blob, { mode: 0o600 }); + fs.renameSync(tmp, secretEncPath(configPath)); +}; + +const loadOrCreateSecret = (configPath) => { + if (!KEK) throw new Error('keylock: locked'); + fs.mkdirSync(configPath, { recursive: true, mode: 0o700 }); + const encP = secretEncPath(configPath); + if (fs.existsSync(encP)) { + return JSON.parse(unwrap(fs.readFileSync(encP)).toString('utf8')); + } + const legP = legacySecretPath(configPath); + if (fs.existsSync(legP)) { + const keys = ssbKeys.loadSync(legP); + writeSecret(configPath, keys); + shred(legP); + return keys; + } + const keys = ssbKeys.generate('ed25519'); // primer arranque + writeSecret(configPath, keys); + return keys; +}; + +const hasIdentity = (configPath) => + fs.existsSync(secretEncPath(configPath)) || fs.existsSync(legacySecretPath(configPath)); + +module.exports = { + MAGIC, deriveKEK, wrap, unwrap, isWrapped, + isConfigured, setPassword, verifyPassword, + getKEK, setKEK, isUnlocked, lock, + loadOrCreateSecret, writeSecret, hasIdentity, +}; diff --git a/src/server/ssb_config.js b/src/server/ssb_config.js index 6e9e36b..0299f68 100644 --- a/src/server/ssb_config.js +++ b/src/server/ssb_config.js @@ -11,9 +11,14 @@ const i = argv.indexOf('--'); const conf = argv.slice(i + 1); const cliArgs = ~i ? argv.slice(0, i) : argv; -let config = Config('ssb', minimist(conf)); +const override = minimist(conf); +if (global.__OASIS_KEYS__) override.keys = global.__OASIS_KEYS__; + +let config = Config('ssb', override); config = { ...config, ...configData }; +if (global.__OASIS_KEYS__) config.keys = global.__OASIS_KEYS__; + const debug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true'; if (debug) { config.logging = { ...(config.logging || {}), level: 'debug' };