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);