From 489d1c20c843e761d7767031572b1b44c1e80ba2 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 28 Sep 2026 09:36:41 +0200 Subject: [PATCH] Refuse and prune expired KeyPackages (GRYT-1510) A KeyPackage lasts 30 days now, from crypto#21. On upload the server reads the lifetime out of the leaf node and refuses one that's already expired, isn't valid yet, or claims more than 30 days. Each refusal carries serverTime, so a device with a fast clock can retry against it. A claim only hands out a package inside its lifetime with an hour to spare, and the count a device sees leaves the rest out, so it tops up in time. The hourly sweep takes the bytes off expired packages and keeps the ref, the way a replaced last-resort package is kept, so a Welcome built on one still routes. ts-mls's default lifetime, 0 to 2^63-1, is what @gryt/crypto 0.6.0 and older write. Those are still taken, and kept 30 days from upload (GRYT-1518 turns that off). Rows from before this change read as expired. Co-Authored-By: Claude Opus 5.5 --- src/db/sqlite/connection.ts | 11 +++++- src/db/sqlite/mls.test.ts | 58 +++++++++++++++++++++++++----- src/db/sqlite/mls.ts | 58 ++++++++++++++++++++++-------- src/jobs/mlsRetention.ts | 4 +-- src/services/mlsWire.ts | 45 +++++++++++++++++++++-- src/socket/handlers/mls.test.ts | 64 +++++++++++++++++++++++++++++++-- src/socket/handlers/mls.ts | 5 +-- 7 files changed, 212 insertions(+), 33 deletions(-) diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 559a736e..cc3e4e5f 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -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); @@ -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'"); } diff --git a/src/db/sqlite/mls.test.ts b/src/db/sqlite/mls.test.ts index 811db135..d9dd11dd 100644 --- a/src/db/sqlite/mls.test.ts +++ b/src/db/sqlite/mls.test.ts @@ -21,6 +21,7 @@ import { listMlsLog, listMlsWelcomes, MLS_MAX_DEVICES, + MLS_KEY_PACKAGE_CLAIM_MARGIN, MLS_MAX_KEY_PACKAGES, mlsKeyPackageOwner, oldestMlsSeq, @@ -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 { @@ -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]), []); @@ -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); @@ -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}`); @@ -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" }); @@ -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", () => { @@ -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); @@ -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"]); diff --git a/src/db/sqlite/mls.ts b/src/db/sqlite/mls.ts index b33b653e..e87d1be2 100644 --- a/src/db/sqlite/mls.ts +++ b/src/db/sqlite/mls.ts @@ -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 { @@ -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; } /** @@ -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 }; } @@ -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), @@ -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; }); } @@ -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 @@ -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, }; }); diff --git a/src/jobs/mlsRetention.ts b/src/jobs/mlsRetention.ts index e18a4459..9f027e9a 100644 --- a/src/jobs/mlsRetention.ts +++ b/src/jobs/mlsRetention.ts @@ -19,8 +19,8 @@ export function mlsRetentionDays(env: NodeJS.ProcessEnv = process.env): number { } export function runMlsRetention(now = new Date()): ReturnType { - 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; diff --git a/src/services/mlsWire.ts b/src/services/mlsWire.ts index ec7920c3..a3045bd4 100644 --- a/src/services/mlsWire.ts +++ b/src/services/mlsWire.ts @@ -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 }); @@ -56,15 +64,46 @@ export async function keyPackageRef(kp: KeyPackage): Promise { 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 { diff --git a/src/socket/handlers/mls.test.ts b/src/socket/handlers/mls.test.ts index 340c2fdf..f069e631 100644 --- a/src/socket/handlers/mls.test.ts +++ b/src/socket/handlers/mls.test.ts @@ -16,7 +16,7 @@ import { getMlsGroupForConversation } from "../../db/sqlite/mls"; import { createServerConfigIfNotExists, setServerRole, updateServerConfig } from "../../db/sqlite/servers"; import { setUserModerationState, upsertUser } from "../../db/sqlite/users"; import { mlsRetentionDays, runMlsRetention } from "../../jobs/mlsRetention"; -import { parseGroupMessage, parseKeyPackage, parseWelcome } from "../../services/mlsWire"; +import { checkKeyPackageLifetime, parseGroupMessage, parseKeyPackage, parseWelcome } from "../../services/mlsWire"; import type { Clients } from "../../types"; import { generateAccessToken } from "../../utils/jwt"; import { resetChannelIdCache } from "../utils/conversationAccess"; @@ -129,14 +129,19 @@ interface Device { const encodeKp = (kp: KeyPackage) => mls.encodeMlsMessage({ version: "mls10", wireformat: "mls_key_package", keyPackage: kp }); -async function makeDevice(id: string, count: number): Promise { +/** What the crypto engine writes: 30 days, starting an hour back. */ +function lifetimeFrom(start = Math.floor(Date.now() / 1000) - 3600, days = 30) { + return { notBefore: BigInt(start), notAfter: BigInt(start + days * 24 * 60 * 60) }; +} + +async function makeDevice(id: string, count: number, lifetime = lifetimeFrom()): Promise { const packages = []; for (let i = 0; i < count; i++) { packages.push( await mls.generateKeyPackage( { credentialType: "basic", identity: new TextEncoder().encode(id) }, mls.defaultCapabilities(), - mls.defaultLifetime, + lifetime, [], impl, ), @@ -281,6 +286,59 @@ describe("KeyPackages", () => { assert.equal(reply.error, "invalid_key_package"); }); + it("are refused when they'd last past 30 days, or already have", async () => { + const now = Math.floor(Date.now() / 1000); + const cases: [ReturnType, string][] = [ + [lifetimeFrom(now - 3600, 31), "key_package_lifetime"], + [{ notBefore: BigInt(0), notAfter: BigInt("18446744073709551615") }, "key_package_lifetime"], + [lifetimeFrom(now - 31 * 86400), "key_package_expired"], + [lifetimeFrom(now + 60), "key_package_not_yet_valid"], + ]; + for (const [lifetime, error] of cases) { + const d = await makeDevice("lifetimes", 1, lifetime); + const r = await publish(alice, d); + assert.equal(r.error, error); + assert.ok(Math.abs((r.serverTime as number) - now) < 5, "the server's clock comes back, to retry against"); + } + }); + + it("take both ends of the 30 days, and ts-mls's default for now", async () => { + const now = Math.floor(Date.now() / 1000); + const at = new Date(now * 1000); + const edge = (lt: ReturnType) => checkKeyPackageLifetime(lt, at).ok; + assert.equal(edge(lifetimeFrom(now)), true, "valid from its first second"); + assert.equal(edge(lifetimeFrom(now - 30 * 86400)), true, "and through its last"); + assert.equal(edge(lifetimeFrom(now + 1)), false); + assert.equal(edge(lifetimeFrom(now - 30 * 86400 - 1)), false); + + const lena = await connect("Lena"); + const legacy = await makeDevice("legacy", 1, mls.defaultLifetime); + assert.equal((await publish(lena, legacy)).ok, true, "@gryt/crypto 0.6.0 and older write this"); + const row = getSqliteDb().prepare(`SELECT not_before, not_after FROM mls_key_packages WHERE device_id = ?`) + .get(legacy.id) as { not_before: number; not_after: number }; + assert.ok(Math.abs(row.not_before - now) < 5); + assert.equal(row.not_after - row.not_before, 30 * 86400, "kept 30 days from upload"); + }); + + it("aren't handed out once expired", async () => { + const gina = await connect("Gina"); + const hans = await connect("Hans"); + const conv = (await openDirectConversation(gina.serverUserId, hans.serverUserId)).conversation_id; + const hansPhone = await makeDevice("hans-phone", 1); + await publish(hans, hansPhone); + const ginaLaptop = await makeDevice("gina-laptop", 1); + await publish(gina, ginaLaptop); + + getSqliteDb().prepare(`UPDATE mls_key_packages SET not_after = ? WHERE device_id = ?`) + .run(Math.floor(Date.now() / 1000) - 1, hansPhone.id); + const claimed = await gina.call("mls:keypackages:claim", { conversationId: conv, deviceId: ginaLaptop.id }); + assert.deepEqual(claimed.keyPackages, []); + assert.deepEqual(claimed.missing, [{ serverUserId: hans.serverUserId, deviceId: hansPhone.id }]); + + const sync = await hans.call("mls:sync", { deviceId: hansPhone.id }); + assert.equal((sync.keyPackages as { unclaimed: number }).unclaimed, 0, "so the phone knows to upload more"); + }); + it("stop at five devices per member", async () => { const carol = await connect("Carol"); for (let i = 0; i < 5; i++) { diff --git a/src/socket/handlers/mls.ts b/src/socket/handlers/mls.ts index f8de0cc5..e07274dc 100644 --- a/src/socket/handlers/mls.ts +++ b/src/socket/handlers/mls.ts @@ -277,10 +277,11 @@ export function registerMlsHandlers(ctx: HandlerContext): EventHandlerMap { } const parsed = await parseKeyPackage(bytes); if (!parsed.ok) { - ack(fail(parsed.error, parsed.message)); + const { error, message, serverTime } = parsed; + ack(fail(error, message, serverTime === undefined ? {} : { serverTime })); return; } - packages.push({ ref: parsed.ref, data: bytes, lastResort }); + packages.push({ ref: parsed.ref, data: bytes, lastResort, notBefore: parsed.notBefore, notAfter: parsed.notAfter }); } const isNew = !isMlsDevice(self, payload.deviceId);