OASIS_MOBILE/src/models/keylock.js
s1to 851042e576 keylock: password-based at-rest encryption
- Pre-boot password gate (main.js) with language selector; backend starts only after unlock.
- keylock.js: scrypt KEK + AES-256-GCM wrap/unwrap of the SSB secret and keyrings.
- crypto.js: keyrings encrypted/decrypted with the KEK.
- ssb_config.js: decrypted keys in memory (no plaintext secret on disk).
2026-07-09 09:56:30 +02:00

129 lines
4.5 KiB
JavaScript

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,
};