/** * On-disk account v2: Ed25519 (bare-crypto); secret key sealed with * PBKDF2-SHA256 + ChaCha20-Poly1305. * v1 used libsodium — use `login --new` to create a v2 account. */ import b4a from 'b4a' import bareCrypto from 'bare-crypto' const { generateKeyPair, pbkdf2Sync, createCipheriv, createDecipheriv, randomFillSync, createHash } = bareCrypto export const ACCOUNT_MAGIC = new Uint8Array([ 0x42, 0x41, 0x52, 0x45, 0x4f, 0x53, 0x30, 0x31 ]) // BAREOS01 export const ACCOUNT_VERSION = 2 export const ACCOUNT_PATH = '/.bare/account' /** Documented crypto profile for v2 on-disk accounts (algorithm agility metadata). */ export const BARE_OS_ACCOUNT_CRYPTO_PROFILE_V2 = Object.freeze({ kdf: 'pbkdf2-sha256', aead: 'chacha20-poly1305', signing: 'ed25519', encodingVersion: ACCOUNT_VERSION, defaultKdfIterations: 210_000 }) const LEGACY_ACCOUNT_VERSION = 1 export const ED25519_PUBLIC_KEY_LENGTH = 32 export const ED25519_SECRET_KEY_LENGTH = 64 const SALT_LENGTH = 16 const NONCE_LENGTH = 12 const TAG_LENGTH = 16 /** OWASP-style iteration count for PBKDF2-SHA256 */ export const PBKDF2_ITERATIONS = 210_000 /** @param {ArrayBuffer | ArrayBufferView} view */ function u8(view) { if (view instanceof Uint8Array) return view if (view instanceof ArrayBuffer) return new Uint8Array(view) return new Uint8Array(view.buffer, view.byteOffset, view.byteLength) } export function secureZero(buf) { if (!buf) return u8(buf).fill(0) } export function hashPublicKeyForUid(publicKey) { const h = createHash('sha256') h.update(u8(publicKey)) const d = u8(h.digest()) const n = (d[0] | (d[1] << 8) | (d[2] << 16) | (d[3] << 24)) >>> 0 return String(10000 + (n % 50000)) } /** 32-byte subkey for vault crypto (SHA-256 of signing secret). */ export function vaultKeyFromSecret(secretKey) { const h = createHash('sha256') h.update(u8(secretKey)) return u8(h.digest()) } /** Unkeyed SHA-256 for vault blob names (path-stable). */ export function hashUtf8Path(pathUtf8) { const h = createHash('sha256') h.update(u8(pathUtf8)) return u8(h.digest()) } /** * @param {ArrayBufferView} key32 * @param {ArrayBufferView} plaintext * @returns {Uint8Array} nonce | ciphertext | tag */ export function sealBytes(key32, plaintext) { const nonce = new Uint8Array(NONCE_LENGTH) randomFillSync(nonce) const key = u8(key32) const pt = u8(plaintext) const cipher = createCipheriv('chacha20-poly1305', key, nonce) cipher.update(pt) const ct = u8(cipher.final()) const tag = u8(cipher.getAuthTag()) const out = new Uint8Array(NONCE_LENGTH + ct.length + TAG_LENGTH) out.set(nonce, 0) out.set(ct, NONCE_LENGTH) out.set(tag, NONCE_LENGTH + ct.length) return out } /** * @param {ArrayBufferView} key32 * @param {ArrayBufferView} boxed * @returns {Uint8Array} */ export function openBytes(key32, boxed) { const key = u8(key32) const b = u8(boxed) if (b.length < NONCE_LENGTH + TAG_LENGTH + 1) { throw new Error('Invalid sealed blob') } const nonce = b.subarray(0, NONCE_LENGTH) const tag = b.subarray(b.length - TAG_LENGTH) const ct = b.subarray(NONCE_LENGTH, b.length - TAG_LENGTH) const decipher = createDecipheriv('chacha20-poly1305', key, nonce) decipher.update(ct) decipher.setAuthTag(tag) return u8(decipher.final()) } /** * @param {string | Uint8Array} passphrase * @param {Uint8Array} salt * @param {number} iterations */ function deriveKey(passphrase, salt, iterations) { if (typeof passphrase === 'string') { const pass = b4a.from(passphrase, 'utf8') const out = u8(pbkdf2Sync(pass, u8(salt), iterations, 32, 'sha256')) secureZero(pass) return out } const pass = u8(passphrase) return u8(pbkdf2Sync(pass, u8(salt), iterations, 32, 'sha256')) } /** Raw bytes from a bare-crypto KeyObject (`export()`; `_key` is gone). */ function rawKeyBytes(key) { if (!key) throw new Error('missing key') if (key instanceof Uint8Array || key instanceof ArrayBuffer) return u8(key) if (key._key) return u8(key._key) if (typeof key.export === 'function') { const ex = key.export() if (ex && (ex.byteLength != null || ex instanceof ArrayBuffer)) return u8(ex) } throw new Error('bare-crypto KeyObject has no exportable key material') } export function generateEd25519Keypair() { const { publicKey, privateKey } = generateKeyPair('ed25519') return { publicKey: rawKeyBytes(publicKey), secretKey: rawKeyBytes(privateKey) } } /** * @param {string} passphrase * @returns {Uint8Array} */ export function encodeNewAccount(passphrase) { const { publicKey, secretKey } = generateEd25519Keypair() const buf = encodeAccount(passphrase, publicKey, secretKey) secureZero(secretKey) return buf } /** * @param {string} passphrase * @param {Uint8Array} pk * @param {Uint8Array} sk */ export function encodeAccount(passphrase, pk, sk) { const salt = new Uint8Array(SALT_LENGTH) randomFillSync(salt) const subkey = deriveKey(passphrase, salt, PBKDF2_ITERATIONS) const sealed = sealBytes(subkey, u8(sk)) secureZero(subkey) const iters = PBKDF2_ITERATIONS const out = new Uint8Array( ACCOUNT_MAGIC.length + 1 + ED25519_PUBLIC_KEY_LENGTH + SALT_LENGTH + 4 + sealed.length ) let o = 0 out.set(ACCOUNT_MAGIC, o) o += ACCOUNT_MAGIC.length out[o++] = ACCOUNT_VERSION out.set(u8(pk), o) o += ED25519_PUBLIC_KEY_LENGTH out.set(salt, o) o += SALT_LENGTH out[o++] = (iters >>> 24) & 255 out[o++] = (iters >>> 16) & 255 out[o++] = (iters >>> 8) & 255 out[o++] = iters & 255 out.set(sealed, o) return out } /** * @param {string} passphrase * @param {Uint8Array} buf * @returns {{ publicKey: Uint8Array, secretKey: Uint8Array }} */ export function decodeAccount(passphrase, buf) { if ( !buf || buf.length < ACCOUNT_MAGIC.length + 1 + ED25519_PUBLIC_KEY_LENGTH + SALT_LENGTH + 4 + NONCE_LENGTH + TAG_LENGTH + 1 ) { throw new Error('Invalid account file') } for (let i = 0; i < ACCOUNT_MAGIC.length; i++) { if (buf[i] !== ACCOUNT_MAGIC[i]) throw new Error('Invalid account magic') } let o = ACCOUNT_MAGIC.length const ver = buf[o++] if (ver === LEGACY_ACCOUNT_VERSION) { throw new Error( 'Account uses legacy crypto (v1). Run: login --new (then enter passphrase at prompt) to create a new account' ) } if (ver !== ACCOUNT_VERSION) throw new Error('Unsupported account version') const pk = buf.subarray(o, o + ED25519_PUBLIC_KEY_LENGTH) o += ED25519_PUBLIC_KEY_LENGTH const salt = buf.subarray(o, o + SALT_LENGTH) o += SALT_LENGTH const iterations = (buf[o] << 24) | (buf[o + 1] << 16) | (buf[o + 2] << 8) | buf[o + 3] o += 4 if (iterations < 10000 || iterations > 10_000_000) { throw new Error('Invalid account parameters') } const sealed = buf.subarray(o) const subkey = deriveKey(passphrase, salt, iterations) let sk try { sk = openBytes(subkey, sealed) } catch { secureZero(subkey) throw new Error('Wrong passphrase') } secureZero(subkey) if (sk.length !== ED25519_SECRET_KEY_LENGTH) { secureZero(sk) throw new Error('Invalid account file') } return { publicKey: Uint8Array.from(pk), secretKey: sk } } /** * Read non-secret crypto metadata from an account blob (no decryption). * @param {Uint8Array | null | undefined} buf * @returns {Record | null} */ export function readAccountCryptoProfile(buf) { if (!buf || buf.length < ACCOUNT_MAGIC.length + 1) return null for (let i = 0; i < ACCOUNT_MAGIC.length; i++) { if (buf[i] !== ACCOUNT_MAGIC[i]) return null } const ver = buf[ACCOUNT_MAGIC.length] if (ver === LEGACY_ACCOUNT_VERSION) { return { encodingVersion: ver, supported: false, note: 'Legacy v1 account; migrate with login --new' } } if (ver !== ACCOUNT_VERSION) { return { encodingVersion: ver, supported: false } } return { encodingVersion: ver, supported: true, kdf: BARE_OS_ACCOUNT_CRYPTO_PROFILE_V2.kdf, aead: BARE_OS_ACCOUNT_CRYPTO_PROFILE_V2.aead, signing: BARE_OS_ACCOUNT_CRYPTO_PROFILE_V2.signing, publicKeyBytes: ED25519_PUBLIC_KEY_LENGTH } }