Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/db/sqlite/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,9 @@ function createSchema(d: DatabaseSync): void {
data BLOB,
last_resort INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
claimed_at TEXT
claimed_at TEXT,
not_before INTEGER NOT NULL DEFAULT 0,
not_after INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_mls_key_packages_device
ON mls_key_packages(server_user_id, device_id, last_resort, claimed_at);
Expand Down Expand Up @@ -770,6 +772,13 @@ function runMigrations(d: DatabaseSync): void {
d.exec("ALTER TABLE users ADD COLUMN nickname_changed_at TEXT");
}

// A KeyPackage's lifetime, in seconds (GRYT-1510). Rows from before it read as
// expired, so they're never handed out and the next sweep retires them.
if (!hasColumn(d, "mls_key_packages", "not_after")) {
d.exec("ALTER TABLE mls_key_packages ADD COLUMN not_before INTEGER NOT NULL DEFAULT 0");
d.exec("ALTER TABLE mls_key_packages ADD COLUMN not_after INTEGER NOT NULL DEFAULT 0");
}

if (!hasColumn(d, "server_config", "join_policy")) {
d.exec("ALTER TABLE server_config ADD COLUMN join_policy TEXT NOT NULL DEFAULT 'invite'");
}
Expand Down
58 changes: 50 additions & 8 deletions src/db/sqlite/mls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
listMlsLog,
listMlsWelcomes,
MLS_MAX_DEVICES,
MLS_KEY_PACKAGE_CLAIM_MARGIN,
MLS_MAX_KEY_PACKAGES,
mlsKeyPackageOwner,
oldestMlsSeq,
Expand All @@ -47,6 +48,8 @@ after(() => {

const bytes = (...b: number[]) => new Uint8Array(b);
const DAY = 24 * 60 * 60 * 1000;
/** A lifetime nothing here gets near either end of. The expiry tests set their own. */
const LIVE = { notBefore: 0, notAfter: 2 ** 40 };

let uniq = 0;
async function member(): Promise<string> {
Expand All @@ -71,7 +74,7 @@ describe("devices", () => {
it("frees the slot, its packages and its Welcomes when removed", async () => {
const me = await member();
touchMlsDevice(me, "phone");
addMlsKeyPackages(me, "phone", [{ ref: "ref-removed", data: bytes(1), lastResort: false }]);
addMlsKeyPackages(me, "phone", [{ ref: "ref-removed", data: bytes(1), lastResort: false, ...LIVE }]);

assert.equal(removeMlsDevice(me, "phone"), true);
assert.deepEqual(listMlsDevices([me]), []);
Expand All @@ -86,6 +89,7 @@ describe("KeyPackages", () => {
ref: `kp-${me}-${i}`,
data: bytes(i),
lastResort: false,
...LIVE,
}));
const t0 = new Date("2026-09-25T10:00:00Z");
const { stored, unclaimed } = addMlsKeyPackages(me, "laptop", packages, t0);
Expand All @@ -109,8 +113,8 @@ describe("KeyPackages", () => {
it("falls back to the last-resort package, again and again", async () => {
const me = await member();
addMlsKeyPackages(me, "tablet", [
{ ref: `once-${me}`, data: bytes(1), lastResort: false },
{ ref: `last-${me}`, data: bytes(9), lastResort: true },
{ ref: `once-${me}`, data: bytes(1), lastResort: false, ...LIVE },
{ ref: `last-${me}`, data: bytes(9), lastResort: true, ...LIVE },
]);

assert.equal(claimMlsKeyPackage(me, "tablet")?.ref, `once-${me}`);
Expand All @@ -123,8 +127,8 @@ describe("KeyPackages", () => {

it("retires the old last-resort package when a new one comes, and still routes to it", async () => {
const me = await member();
addMlsKeyPackages(me, "desk", [{ ref: `old-${me}`, data: bytes(1), lastResort: true }]);
addMlsKeyPackages(me, "desk", [{ ref: `new-${me}`, data: bytes(2), lastResort: true }]);
addMlsKeyPackages(me, "desk", [{ ref: `old-${me}`, data: bytes(1), lastResort: true, ...LIVE }]);
addMlsKeyPackages(me, "desk", [{ ref: `new-${me}`, data: bytes(2), lastResort: true, ...LIVE }]);

assert.equal(claimMlsKeyPackage(me, "desk")?.ref, `new-${me}`);
assert.deepEqual(mlsKeyPackageOwner(`old-${me}`), { serverUserId: me, deviceId: "desk" });
Expand All @@ -133,6 +137,44 @@ describe("KeyPackages", () => {
it("gives nothing for a device with nothing", async () => {
assert.equal(claimMlsKeyPackage(await member(), "nothing"), null);
});

it("hands out only packages inside their lifetime, with an hour to spare", async () => {
const me = await member();
const now = new Date("2026-09-25T10:00:00Z");
const t = Math.floor(now.getTime() / 1000);
addMlsKeyPackages(me, "phone", [
{ ref: `gone-${me}`, data: bytes(1), lastResort: false, notBefore: t - 31 * 86400, notAfter: t - 86400 },
{ ref: `ending-${me}`, data: bytes(2), lastResort: false, notBefore: t - 86400, notAfter: t + MLS_KEY_PACKAGE_CLAIM_MARGIN - 1 },
{ ref: `early-${me}`, data: bytes(3), lastResort: false, notBefore: t + 60, notAfter: t + 30 * 86400 },
{ ref: `good-${me}`, data: bytes(4), lastResort: false, notBefore: t - 3600, notAfter: t + 30 * 86400 },
{ ref: `last-${me}`, data: bytes(5), lastResort: true, notBefore: t - 86400, notAfter: t + 60 },
], now);

assert.deepEqual(countMlsKeyPackages(me, "phone", now), { unclaimed: 2, lastResort: false },
"what can't be handed out isn't counted, so the device tops up");
assert.equal(claimMlsKeyPackage(me, "phone", now)?.ref, `good-${me}`);
assert.equal(claimMlsKeyPackage(me, "phone", now), null, "the last-resort one is too close to its end too");
assert.equal(claimMlsKeyPackage(me, "phone", new Date((t + 120) * 1000))?.ref, `early-${me}`);
});

it("retires expired packages in the sweep, and still routes a Welcome to one", async () => {
const me = await member();
const now = new Date();
const t = Math.floor(now.getTime() / 1000);
addMlsKeyPackages(me, "old", [
{ ref: `expired-${me}`, data: bytes(1), lastResort: false, notBefore: t - 31 * 86400, notAfter: t - 60 },
{ ref: `fine-${me}`, data: bytes(2), lastResort: false, notBefore: t - 3600, notAfter: t + 29 * 86400 },
], now);

assert.ok(sweepMls(new Date(now.getTime() - 30 * DAY), now).expired >= 1);
const row = (ref: string) =>
getSqliteDb().prepare(`SELECT data, claimed_at FROM mls_key_packages WHERE key_package_ref = ?`).get(ref) as
{ data: Uint8Array | null; claimed_at: string | null };
assert.equal(row(`expired-${me}`).data, null);
assert.ok(row(`expired-${me}`).claimed_at, "its ref goes with the claimed ones, after the retention window");
assert.deepEqual(mlsKeyPackageOwner(`expired-${me}`), { serverUserId: me, deviceId: "old" });
assert.ok(row(`fine-${me}`).data, "one still inside its lifetime keeps its bytes");
});
});

describe("groups and commit ordering", () => {
Expand Down Expand Up @@ -271,8 +313,8 @@ describe("retention", () => {

const me = await member();
addMlsKeyPackages(me, "d", [
{ ref: `swept-${me}`, data: bytes(1), lastResort: false },
{ ref: `waiting-${me}`, data: bytes(2), lastResort: false },
{ ref: `swept-${me}`, data: bytes(1), lastResort: false, ...LIVE },
{ ref: `waiting-${me}`, data: bytes(2), lastResort: false, ...LIVE },
], old);
claimMlsKeyPackage(me, "d", old);

Expand Down Expand Up @@ -322,7 +364,7 @@ describe("lifecycle", () => {
touchMlsDevice(guest.server_user_id, "shared");
touchMlsDevice(guest.server_user_id, "only-guest");
touchMlsDevice(account.server_user_id, "shared");
addMlsKeyPackages(guest.server_user_id, "only-guest", [{ ref: "guest-kp", data: bytes(1), lastResort: false }]);
addMlsKeyPackages(guest.server_user_id, "only-guest", [{ ref: "guest-kp", data: bytes(1), lastResort: false, ...LIVE }]);

assert.ok(mergeGuestIntoAccount("mls-guest", "mls-account"));
assert.deepEqual(listMlsDevices([account.server_user_id]).map((d) => d.deviceId).sort(), ["only-guest", "shared"]);
Expand Down
58 changes: 44 additions & 14 deletions src/db/sqlite/mls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export const MLS_MAX_DEVICES = 5;
/** Unclaimed KeyPackages a device may hold, not counting its last-resort one. */
export const MLS_MAX_KEY_PACKAGES = 20;

/** In seconds. A package this close to its notAfter isn't handed out, so the adder
doesn't get one that expires before its commit lands. */
export const MLS_KEY_PACKAGE_CLAIM_MARGIN = 60 * 60;

const seconds = (d: Date) => Math.floor(d.getTime() / 1000);

export type MlsLogKind = "commit" | "proposal" | "application";

export interface MlsDevice {
Expand Down Expand Up @@ -174,6 +180,9 @@ export interface NewKeyPackage {
ref: string;
data: Uint8Array;
lastResort: boolean;
/** The leaf node's lifetime, seconds since the epoch. */
notBefore: number;
notAfter: number;
}

/**
Expand All @@ -191,43 +200,49 @@ export function addMlsKeyPackages(
const at = toIso(now);
const insert = db.prepare(
`INSERT OR IGNORE INTO mls_key_packages
(key_package_ref, server_user_id, device_id, data, last_resort, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
(key_package_ref, server_user_id, device_id, data, last_resort, created_at, not_before, not_after)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
const retire = db.prepare(
`UPDATE mls_key_packages SET data = NULL, claimed_at = ?
WHERE server_user_id = ? AND device_id = ? AND last_resort = 1
AND claimed_at IS NULL AND key_package_ref != ?`,
);
let unclaimed = countMlsKeyPackages(serverUserId, deviceId).unclaimed;
let unclaimed = countMlsKeyPackages(serverUserId, deviceId, now).unclaimed;

let stored = 0;
for (const p of packages) {
if (!p.lastResort && unclaimed >= MLS_MAX_KEY_PACKAGES) continue;
const r = insert.run(p.ref, serverUserId, deviceId, p.data, p.lastResort ? 1 : 0, at);
const r = insert.run(p.ref, serverUserId, deviceId, p.data, p.lastResort ? 1 : 0, at, p.notBefore, p.notAfter);
if (Number(r.changes) === 0) continue;
if (p.lastResort) retire.run(at, serverUserId, deviceId, p.ref);
stored += 1;
if (!p.lastResort) unclaimed += 1;
}
return { stored, ...countMlsKeyPackages(serverUserId, deviceId) };
return { stored, ...countMlsKeyPackages(serverUserId, deviceId, now) };
});
}

export function countMlsKeyPackages(serverUserId: string, deviceId: string): { unclaimed: number; lastResort: boolean } {
/** Only what can still be handed out, so a device tops up before its packages run out. */
export function countMlsKeyPackages(
serverUserId: string,
deviceId: string,
now = new Date(),
): { unclaimed: number; lastResort: boolean } {
const db = getSqliteDb();
const until = seconds(now) + MLS_KEY_PACKAGE_CLAIM_MARGIN;
const { n } = db
.prepare(
`SELECT COUNT(*) AS n FROM mls_key_packages
WHERE server_user_id = ? AND device_id = ? AND last_resort = 0 AND claimed_at IS NULL`,
WHERE server_user_id = ? AND device_id = ? AND last_resort = 0 AND claimed_at IS NULL AND not_after >= ?`,
)
.get(serverUserId, deviceId) as { n: number };
.get(serverUserId, deviceId, until) as { n: number };
const last = db
.prepare(
`SELECT 1 FROM mls_key_packages
WHERE server_user_id = ? AND device_id = ? AND last_resort = 1 AND claimed_at IS NULL`,
WHERE server_user_id = ? AND device_id = ? AND last_resort = 1 AND claimed_at IS NULL AND not_after >= ?`,
)
.get(serverUserId, deviceId);
.get(serverUserId, deviceId, until);
return { unclaimed: n, lastResort: !!last };
}

Expand All @@ -242,13 +257,16 @@ export function claimMlsKeyPackage(
): { ref: string; data: Uint8Array; lastResort: boolean } | null {
return inTransaction(() => {
const db = getSqliteDb();
const live = [seconds(now), seconds(now) + MLS_KEY_PACKAGE_CLAIM_MARGIN] as const;
// Only inside the package's lifetime, with the margin to spare.
const fresh = db
.prepare(
`SELECT key_package_ref, data FROM mls_key_packages
WHERE server_user_id = ? AND device_id = ? AND last_resort = 0 AND claimed_at IS NULL
AND not_before <= ? AND not_after >= ?
ORDER BY created_at, rowid LIMIT 1`,
)
.get(serverUserId, deviceId) as { key_package_ref: string; data: Uint8Array } | undefined;
.get(serverUserId, deviceId, ...live) as { key_package_ref: string; data: Uint8Array } | undefined;
if (fresh) {
db.prepare(`UPDATE mls_key_packages SET data = NULL, claimed_at = ? WHERE key_package_ref = ?`).run(
toIso(now),
Expand All @@ -260,9 +278,10 @@ export function claimMlsKeyPackage(
const last = db
.prepare(
`SELECT key_package_ref, data FROM mls_key_packages
WHERE server_user_id = ? AND device_id = ? AND last_resort = 1 AND claimed_at IS NULL`,
WHERE server_user_id = ? AND device_id = ? AND last_resort = 1 AND claimed_at IS NULL
AND not_before <= ? AND not_after >= ?`,
)
.get(serverUserId, deviceId) as { key_package_ref: string; data: Uint8Array } | undefined;
.get(serverUserId, deviceId, ...live) as { key_package_ref: string; data: Uint8Array } | undefined;
return last ? { ref: last.key_package_ref, data: last.data, lastResort: true } : null;
});
}
Expand Down Expand Up @@ -490,10 +509,20 @@ export function deleteMlsWelcomes(serverUserId: string, deviceId: string, welcom
* Drops ciphertext, Welcomes and claimed package refs older than the cutoff, and every
* group whose conversation is gone. A kept group's epoch and head_seq never go back.
*/
export function sweepMls(cutoff: Date): { log: number; welcomes: number; keyPackages: number; groups: number } {
export function sweepMls(
cutoff: Date,
now = new Date(),
): { log: number; welcomes: number; keyPackages: number; expired: number; groups: number } {
return inTransaction(() => {
const db = getSqliteDb();
const before = toIso(cutoff);
// Expired ones lose their bytes like a replaced last-resort one, so a Welcome still routes.
const expired = db
.prepare(
`UPDATE mls_key_packages SET data = NULL, claimed_at = ?
WHERE claimed_at IS NULL AND not_after < ?`,
)
.run(toIso(now), seconds(now) + MLS_KEY_PACKAGE_CLAIM_MARGIN);
const orphans = db
.prepare(
`SELECT conversation_id FROM mls_groups g
Expand All @@ -511,6 +540,7 @@ export function sweepMls(cutoff: Date): { log: number; welcomes: number; keyPack
log: Number(log.changes),
welcomes: Number(welcomes.changes),
keyPackages: Number(keyPackages.changes),
expired: Number(expired.changes),
groups: orphans.length,
};
});
Expand Down
4 changes: 2 additions & 2 deletions src/jobs/mlsRetention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export function mlsRetentionDays(env: NodeJS.ProcessEnv = process.env): number {
}

export function runMlsRetention(now = new Date()): ReturnType<typeof sweepMls> {
const swept = sweepMls(new Date(now.getTime() - mlsRetentionDays() * DAY_MS));
if (swept.log || swept.welcomes || swept.keyPackages || swept.groups) {
const swept = sweepMls(new Date(now.getTime() - mlsRetentionDays() * DAY_MS), now);
if (swept.log || swept.welcomes || swept.keyPackages || swept.expired || swept.groups) {
consola.info("[mls-retention] swept", swept);
}
return swept;
Expand Down
45 changes: 42 additions & 3 deletions src/services/mlsWire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ const { makeKeyPackageRef } = require("ts-mls/keyPackage.js") as typeof import("
export const MLS_CIPHERSUITE = "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519";
export const MLS_CIPHERSUITE_ID = 1;

export type WireRefusal = { error: string; message: string };
/** In seconds. @gryt/crypto writes exactly this, starting an hour back (GRYT-1510). */
export const MLS_KEY_PACKAGE_LIFETIME = 30 * 24 * 60 * 60;

/* ts-mls's default, 0 to 2^63-1, which @gryt/crypto 0.6.0 and older write. Taken for now
and kept 30 days from upload, since nothing makes the new ones yet. */
const LEGACY_NOT_AFTER = BigInt("9223372036854775807");

/** `serverTime` (seconds) comes with a lifetime refusal, so a device with a fast clock can retry. */
export type WireRefusal = { error: string; message: string; serverTime?: number };

const refuse = (error: string, message: string): { ok: false } & WireRefusal => ({ ok: false, error, message });

Expand Down Expand Up @@ -56,15 +64,46 @@ export async function keyPackageRef(kp: KeyPackage): Promise<string> {
return Buffer.from(await makeKeyPackageRef(kp, sha256)).toString("hex");
}

/** Seconds since the epoch, from the leaf node's lifetime. Both ends are inside it. */
export interface KeyPackageLifetime {
notBefore: number;
notAfter: number;
}

/** Checked against the server's own clock, which is what claims are checked against too. */
export function checkKeyPackageLifetime(
lifetime: { notBefore: bigint; notAfter: bigint },
now = new Date(),
): ({ ok: true } & KeyPackageLifetime) | ({ ok: false } & WireRefusal) {
const serverTime = Math.floor(now.getTime() / 1000);
const { notBefore, notAfter } = lifetime;
if (notBefore === BigInt(0) && notAfter === LEGACY_NOT_AFTER) {
return { ok: true, notBefore: serverTime, notAfter: serverTime + MLS_KEY_PACKAGE_LIFETIME };
}
if (notAfter < notBefore || notAfter - notBefore > BigInt(MLS_KEY_PACKAGE_LIFETIME)) {
return { ok: false, serverTime, error: "key_package_lifetime", message: "A KeyPackage can last 30 days at most." };
}
if (notAfter < BigInt(serverTime)) {
return { ok: false, serverTime, error: "key_package_expired", message: "That KeyPackage has expired." };
}
if (notBefore > BigInt(serverTime)) {
return { ok: false, serverTime, error: "key_package_not_yet_valid", message: "That KeyPackage isn't valid yet by this server's clock." };
}
return { ok: true, notBefore: Number(notBefore), notAfter: Number(notAfter) };
}

export async function parseKeyPackage(
bytes: Uint8Array,
): Promise<{ ok: true; ref: string } | ({ ok: false } & WireRefusal)> {
now = new Date(),
): Promise<({ ok: true; ref: string } & KeyPackageLifetime) | ({ ok: false } & WireRefusal)> {
const msg = decodeWhole(bytes);
if (!msg || msg.wireformat !== "mls_key_package") return refuse("invalid_key_package", "That isn't an MLS KeyPackage.");
if (msg.keyPackage.cipherSuite !== MLS_CIPHERSUITE) {
return refuse("unsupported_ciphersuite", `Only ${MLS_CIPHERSUITE} is accepted here.`);
}
return { ok: true, ref: await keyPackageRef(msg.keyPackage) };
const lifetime = checkKeyPackageLifetime(msg.keyPackage.leafNode.lifetime, now);
if (!lifetime.ok) return lifetime;
return { ok: true, ref: await keyPackageRef(msg.keyPackage), notBefore: lifetime.notBefore, notAfter: lifetime.notAfter };
}

export interface ParsedHandshake {
Expand Down
Loading
Loading