SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

@@ -0,0 +1,324 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import {
DATABASE_HEADER_SIZE,
decryptDatabase,
detectAlgorithm,
type SqlcipherAlgorithm,
} from '../src/qq-account/sqlcipher';
import {
FIXTURE_HEADER_SIZE,
FIXTURE_PAGE_SIZE,
FIXTURE_PASSPHRASE,
FIXTURE_WAL_FRAME_HEADER_SIZE,
FIXTURE_WAL_HEADER_SIZE,
encryptImage,
encryptWal,
reserveBytes,
walChecksum,
type FixtureAlgorithm,
} from './qq-account-fixtures';
const SHA1_SHA512: FixtureAlgorithm = { pageHmac: 'sha1', kdfHmac: 'sha512' };
/**
* Reserved space SQLite is told about for an encrypted database: room for the
* IV plus the largest page digest. SQLCipher's own reserve always fits inside
* it, which is why the tail of every page is disposable.
*/
const HEADER_RESERVED_BYTES = 80;
/** Offset of one frame in a `-wal` buffer. */
function frameOffset(index: number): number {
return FIXTURE_WAL_HEADER_SIZE + index * (FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE);
}
/** Last frame index a merge may have applied, or -1 when nothing was committed. */
function lastCommitFrame(wal: Buffer, maxFrameIndex = frameCount(wal) - 1): number {
let last = -1;
for (let index = 0; index <= maxFrameIndex; index++) {
if (wal.readUInt32BE(frameOffset(index) + 4) !== 0) {
last = index;
}
}
return last;
}
function frameCount(wal: Buffer): number {
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
return Math.floor((wal.length - FIXTURE_WAL_HEADER_SIZE) / frameSize);
}
/** Frames up to and including the last commit, i.e. what a merge should apply. */
function committedFrames(wal: Buffer, maxFrameIndex?: number): number {
return lastCommitFrame(wal, maxFrameIndex) + 1;
}
/** Database size the last applicable commit declares. */
function lastCommitSize(wal: Buffer, maxFrameIndex?: number): number {
return wal.readUInt32BE(frameOffset(lastCommitFrame(wal, maxFrameIndex)) + 4);
}
/** Page content as the last applicable commit left it, keyed by page number. */
function committedPages(wal: Buffer, maxFrameIndex?: number): Map<number, Buffer> {
const pages = new Map<number, Buffer>();
const last = lastCommitFrame(wal, maxFrameIndex);
const size = lastCommitSize(wal, maxFrameIndex);
for (let index = 0; index <= last; index++) {
const pageNumber = wal.readUInt32BE(frameOffset(index));
if (pageNumber >= 1 && pageNumber <= size) {
pages.set(
pageNumber,
wal.subarray(
frameOffset(index) + FIXTURE_WAL_FRAME_HEADER_SIZE,
frameOffset(index + 1),
),
);
}
}
return pages;
}
/** Compare one merged page against the page a frame carried. */
function expectPageMatches(
merged: Buffer,
expectedPage: Buffer,
pageNumber: number,
algorithm: FixtureAlgorithm,
): void {
const reserve = reserveBytes(algorithm);
const start = (pageNumber - 1) * FIXTURE_PAGE_SIZE;
// Page 1's SQLite header carries the fields the merge keeps consistent with
// the assembled image (change counter, page count, journal mode), so only the
// page body is compared there.
const bodyStart = pageNumber === 1 ? 100 : 0;
expect(merged.subarray(start + bodyStart, start + FIXTURE_PAGE_SIZE - reserve)).toEqual(
expectedPage.subarray(bodyStart, FIXTURE_PAGE_SIZE - reserve),
);
expect(
merged
.subarray(start + FIXTURE_PAGE_SIZE - reserve, start + FIXTURE_PAGE_SIZE)
.equals(Buffer.alloc(reserve)),
).toBe(true);
}
/** A plaintext image with the header fields the merge keeps consistent. */
function plaintextImage(pageCount: number, marker = 0): Buffer {
const image = Buffer.alloc(pageCount * FIXTURE_PAGE_SIZE);
for (let page = 0; page < pageCount; page++) {
for (let offset = 0; offset < FIXTURE_PAGE_SIZE - HEADER_RESERVED_BYTES; offset++) {
image[page * FIXTURE_PAGE_SIZE + offset] = (page * 31 + offset + marker) % 251;
}
}
image.write('SQLite format 3\0', 0, 'latin1');
image.writeUInt16BE(FIXTURE_PAGE_SIZE, 16);
image[18] = 1;
image[19] = 1;
image[20] = HEADER_RESERVED_BYTES;
image[21] = 64;
image[22] = 32;
image[23] = 32;
image.writeUInt32BE(7, 24);
image.writeUInt32BE(pageCount, 28);
image.writeUInt32BE(7, 92);
return image;
}
/** A real SQLite database in WAL mode, plus the sidecar SQLite wrote for it. */
async function realWalDatabase(
directory: string,
inserts: string[][],
): Promise<{ image: Buffer; wal: Buffer; db: SqliteDatabase }> {
const file = path.join(directory, 'source.db');
const db = new SqliteDatabase(file);
await db.exec('PRAGMA wal_autocheckpoint = 0');
await db.exec('CREATE TABLE t(value TEXT)');
for (const [value] of inserts) {
await db.prepare('INSERT INTO t VALUES (?)').run(value);
}
return { image: await readFile(file), wal: await readFile(`${file}-wal`), db };
}
describe('SQLCipher page decryption', () => {
it('round-trips a page image and normalizes the header', () => {
const plain = plaintextImage(3);
plain[18] = 2; // stored as a WAL-mode database, like QQ writes them
plain[19] = 2;
const encrypted = encryptImage(plain, SHA1_SHA512);
expect(detectAlgorithm(encrypted, FIXTURE_PASSPHRASE)).toEqual(SHA1_SHA512);
const decrypted = decryptDatabase(encrypted, null, FIXTURE_PASSPHRASE, SHA1_SHA512);
expect(decrypted).not.toBeNull();
const expected = Buffer.from(plain);
expected[18] = 1;
expected[19] = 1;
expected.writeUInt32BE(3, 28);
expected.writeUInt32BE(7, 92);
expect(decrypted!.image.equals(expected)).toBe(true);
expect(decrypted!.walFrames).toBe(0);
});
it('probes the algorithm pair instead of assuming one', () => {
const plain = plaintextImage(2);
for (const algorithm of [
{ pageHmac: 'sha256', kdfHmac: 'sha256' },
{ pageHmac: 'sha512', kdfHmac: 'sha1' },
{ pageHmac: 'none', kdfHmac: 'sha512' },
] satisfies SqlcipherAlgorithm[]) {
const encrypted = encryptImage(plain, algorithm);
expect(detectAlgorithm(encrypted, FIXTURE_PASSPHRASE)).toEqual(algorithm);
expect(
decryptDatabase(encrypted, null, FIXTURE_PASSPHRASE, algorithm)!.image.equals(
plain,
),
).toBe(true);
}
});
it('rejects a wrong passphrase and a file that is too short', () => {
const encrypted = encryptImage(plaintextImage(2), SHA1_SHA512);
expect(detectAlgorithm(encrypted, 'wrong-passphrase')).toBeNull();
expect(
detectAlgorithm(Buffer.alloc(DATABASE_HEADER_SIZE + 512), FIXTURE_PASSPHRASE),
).toBeNull();
expect(decryptDatabase(Buffer.alloc(64), null, FIXTURE_PASSPHRASE, SHA1_SHA512)).toBeNull();
});
});
describe('SQLCipher WAL folding', () => {
it('reproduces the frame checksums SQLite itself wrote', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const littleEndian = wal.readUInt32BE(0) === 0x377f0682;
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
expect((wal.length - FIXTURE_WAL_HEADER_SIZE) % frameSize).toBe(0);
const header = walChecksum(
[0, 0],
[wal.subarray(0, FIXTURE_WAL_HEADER_SIZE - 8)],
littleEndian,
);
expect(header).toEqual([wal.readUInt32BE(24), wal.readUInt32BE(28)]);
let running = header;
for (
let offset = FIXTURE_WAL_HEADER_SIZE;
offset + frameSize <= wal.length;
offset += frameSize
) {
const frame = wal.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
running = walChecksum(
running,
[
frame.subarray(0, 8),
wal.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize),
],
littleEndian,
);
expect(running).toEqual([frame.readUInt32BE(16), frame.readUInt32BE(20)]);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('folds every committed frame into the decrypted image', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { image, wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const encryptedImage = encryptImage(image, SHA1_SHA512);
const encryptedWal = encryptWal(wal, encryptedImage, SHA1_SHA512);
const decrypted = decryptDatabase(
encryptedImage,
encryptedWal,
FIXTURE_PASSPHRASE,
SHA1_SHA512,
);
expect(decrypted!.walFrames).toBe(committedFrames(wal));
expect(decrypted!.image.length).toBe(lastCommitSize(wal) * FIXTURE_PAGE_SIZE);
// Every page a committed frame carried has to arrive as it was written,
// apart from the on-disk IV/HMAC region the encryptor owns.
for (const [pageNumber, page] of committedPages(wal)) {
expectPageMatches(decrypted!.image, page, pageNumber, SHA1_SHA512);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('keeps the intact prefix of a WAL torn by a running client', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { image, wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const encryptedImage = encryptImage(image, SHA1_SHA512);
const encryptedWal = encryptWal(wal, encryptedImage, SHA1_SHA512);
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
const frameCount = (wal.length - FIXTURE_WAL_HEADER_SIZE) / frameSize;
// Shred the last frame's page: everything after the previous commit
// must be dropped rather than half-applied.
encryptedWal[encryptedWal.length - 8] ^= 0xff;
const decrypted = decryptDatabase(
encryptedImage,
encryptedWal,
FIXTURE_PASSPHRASE,
SHA1_SHA512,
);
expect(decrypted).not.toBeNull();
expect(decrypted!.walFrames).toBeLessThan(frameCount);
// Frames up to the tear survive; the merge still stops at the last
// commit they contain rather than applying a partial transaction.
expect(decrypted!.walFrames).toBe(committedFrames(wal, frameCount - 2));
// Everything the surviving commits wrote has to be in the image.
for (const [pageNumber, page] of committedPages(wal, frameCount - 2)) {
expectPageMatches(decrypted!.image, page, pageNumber, SHA1_SHA512);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe('SQLCipher header handling', () => {
it('keeps the client header opaque and stores the salt at the start of page 1', () => {
const plain = plaintextImage(2);
const encrypted = encryptImage(plain, SHA1_SHA512);
const header = encrypted.subarray(0, FIXTURE_HEADER_SIZE);
expect(header.equals(Buffer.alloc(FIXTURE_HEADER_SIZE))).toBe(true);
expect(
encrypted
.subarray(FIXTURE_HEADER_SIZE + 16, FIXTURE_HEADER_SIZE + 48)
.equals(plain.subarray(16, 48)),
).toBe(false);
});
});