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
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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user