Files
Kelvin Amoaba d5afa9f2f9 feat(mobile): add an encrypted on-device store (#38954)
* feat(mobile): add an encrypted on-device store

One AES-256-GCM key in the pairing keychain seals one file per value, bound to its host, table and key. Cache rows are LRU-capped at 128 MiB; drafts are written as tmp, delete, move and refused past 16 MiB. Launch upkeep (orphan hosts, draft TTL, lone tmp recovery, cap totals) runs in idle slices after the splash, and host removal wipes the host's rows. No consumers yet.

* style(mobile): use a ternary for the cache row write

* fix(mobile): keep a lone draft tmp before reuse and settle every row read
2026-11-06 16:51:07 +00:00

121 lines
4.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'
// Alias the native-only crypto module to Node’s secure source for Vitest; explicit per-test mocks still win.
export function getRandomBytes(byteCount: number): Uint8Array {
return new Uint8Array(randomBytes(byteCount))
}
export { randomUUID }
type BinaryInput = string | Uint8Array | ArrayBuffer
export enum AESKeySize {
AES128 = 128,
AES192 = 192,
AES256 = 256
}
// Expo reads string BinaryInput as base64, so the stand-in must too.
function binaryInputBytes(input: BinaryInput): Buffer {
if (typeof input === 'string') {
return Buffer.from(input, 'base64')
}
return Buffer.from(input instanceof ArrayBuffer ? new Uint8Array(input) : input)
}
const IV_BYTES = 12
const TAG_BYTES = 16
export class AESEncryptionKey {
private constructor(private readonly keyBytes: Buffer) {}
get size(): AESKeySize {
const size = this.keyBytes.byteLength * 8
if (size !== 128 && size !== 192 && size !== 256) {
throw new Error('invalid AES key length')
}
return size
}
static async generate(size: AESKeySize = AESKeySize.AES256): Promise<AESEncryptionKey> {
return new AESEncryptionKey(randomBytes(size / 8))
}
static async import(
input: string | Uint8Array,
encoding?: 'hex' | 'base64'
): Promise<AESEncryptionKey> {
const bytes =
typeof input === 'string' ? Buffer.from(input, encoding ?? 'hex') : Buffer.from(input)
if (![16, 24, 32].includes(bytes.byteLength)) {
throw new Error('invalid AES key length')
}
return new AESEncryptionKey(bytes)
}
async bytes(): Promise<Uint8Array> {
return new Uint8Array(this.keyBytes)
}
async encoded(encoding: 'hex' | 'base64'): Promise<string> {
return this.keyBytes.toString(encoding)
}
cipherKey(): Buffer {
return this.keyBytes
}
}
export class AESSealedData {
private constructor(private readonly combinedBytes: Buffer) {}
static fromCombined(combined: BinaryInput): AESSealedData {
return new AESSealedData(binaryInputBytes(combined))
}
async combined(encoding?: 'bytes' | 'base64'): Promise<Uint8Array | string> {
return encoding === 'base64'
? this.combinedBytes.toString('base64')
: new Uint8Array(this.combinedBytes)
}
parts(): { iv: Buffer; ciphertext: Buffer; tag: Buffer } {
const bytes = this.combinedBytes
return {
iv: bytes.subarray(0, IV_BYTES),
ciphertext: bytes.subarray(IV_BYTES, bytes.byteLength - TAG_BYTES),
tag: bytes.subarray(bytes.byteLength - TAG_BYTES)
}
}
}
const CIPHER_BY_KEY_SIZE = {
128: 'aes-128-gcm',
192: 'aes-192-gcm',
256: 'aes-256-gcm'
} as const satisfies Record<AESKeySize, string>
function cipherName(key: AESEncryptionKey): (typeof CIPHER_BY_KEY_SIZE)[AESKeySize] {
return CIPHER_BY_KEY_SIZE[key.size]
}
export async function aesEncryptAsync(
plaintext: BinaryInput,
key: AESEncryptionKey,
options: { additionalData?: BinaryInput } = {}
): Promise<AESSealedData> {
const iv = randomBytes(IV_BYTES)
const cipher = createCipheriv(cipherName(key), key.cipherKey(), iv)
if (options.additionalData !== undefined) {
cipher.setAAD(binaryInputBytes(options.additionalData))
}
const ciphertext = Buffer.concat([cipher.update(binaryInputBytes(plaintext)), cipher.final()])
return AESSealedData.fromCombined(Buffer.concat([iv, ciphertext, cipher.getAuthTag()]))
}
export async function aesDecryptAsync(
sealed: AESSealedData,
key: AESEncryptionKey,
options: { output?: 'bytes' | 'base64'; additionalData?: BinaryInput } = {}
): Promise<Uint8Array | string> {
const { iv, ciphertext, tag } = sealed.parts()
const decipher = createDecipheriv(cipherName(key), key.cipherKey(), iv)
decipher.setAuthTag(tag)
if (options.additionalData !== undefined) {
decipher.setAAD(binaryInputBytes(options.additionalData))
}
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return options.output === 'base64' ? plaintext.toString('base64') : new Uint8Array(plaintext)
}