Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61462516d2 | |||
| d39a0fb6dc | |||
| 9207f473cd | |||
| 5367544b59 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.18.1",
|
||||
"version": "0.18.3",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -416,7 +416,11 @@ export function MessageBubble({
|
||||
</button>
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
<span className="italic text-fg-muted opacity-60">
|
||||
{t('app:chats.unreadable', {
|
||||
defaultValue: 'Nachricht nicht lesbar',
|
||||
})}
|
||||
</span>
|
||||
) : parsed.kind === 'poll' ? (
|
||||
<PollCard
|
||||
question={parsed.question}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { changePin, regenerateRecoveryCode, resetIdentity } from '../lib/userIdentity';
|
||||
import {
|
||||
changePin,
|
||||
type LegacyMigrationReport,
|
||||
regenerateRecoveryCode,
|
||||
resetIdentity,
|
||||
retryLegacyMigration,
|
||||
} from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -12,6 +18,17 @@ export function SecurityCenter({ userId }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [recovery, setRecovery] = useState<string | null>(null);
|
||||
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
|
||||
|
||||
async function handleRetryMigration() {
|
||||
setBusy(true); setMsg(null); setMigration(null);
|
||||
try {
|
||||
const report = await retryLegacyMigration(userId);
|
||||
setMigration(report);
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleChangePin() {
|
||||
setBusy(true); setMsg(null);
|
||||
@@ -76,6 +93,39 @@ export function SecurityCenter({ userId }: Props) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Schlüssel-Migration reparieren</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen.
|
||||
Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst.
|
||||
</p>
|
||||
<button type="button" disabled={busy} onClick={() => void handleRetryMigration()}
|
||||
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}Migration erneut ausführen
|
||||
</button>
|
||||
{migration && (
|
||||
<div className="mt-2 rounded border border-line bg-surface-3 p-3 text-xs text-fg-muted">
|
||||
<div>Geräte (Server): {migration.serverDevices}</div>
|
||||
<div>Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices}
|
||||
{migration.strongholdKeysFromBundleScan > 0 && (
|
||||
<> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan)</>
|
||||
)}
|
||||
</div>
|
||||
<div>Versucht: {migration.attempted}, Erfolgreich: <span className="text-emerald-500">{migration.migrated}</span></div>
|
||||
<div>Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}</div>
|
||||
<div>Entschlüsselung gescheitert: {migration.decryptFailed}</div>
|
||||
<div>Server-Fehler: {migration.rpcFailed}</div>
|
||||
{migration.attempted > 0 && migration.migrated === 0 && (
|
||||
<p className="mt-2 text-rose-300">
|
||||
Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal.
|
||||
Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
|
||||
|
||||
@@ -148,21 +148,84 @@ export async function resetIdentity(params: { userId: string; pin: string }): Pr
|
||||
return setup.recoveryCode ?? '';
|
||||
}
|
||||
|
||||
// Returned by ensureLegacyMigrated and SecurityCenter's manual retry. Lets
|
||||
// the UI surface "X conv-keys re-wrapped, Y stuck because no key in vault."
|
||||
export interface LegacyMigrationReport {
|
||||
serverDevices: number;
|
||||
strongholdKeysFromServerDevices: number;
|
||||
strongholdKeysFromBundleScan: number;
|
||||
attempted: number;
|
||||
migrated: number;
|
||||
noStrongholdKey: number;
|
||||
decryptFailed: number;
|
||||
rpcFailed: number;
|
||||
}
|
||||
|
||||
async function runLegacyMigration(
|
||||
userId: string,
|
||||
ownNewPriv: Uint8Array,
|
||||
ownNewPub: Uint8Array,
|
||||
): Promise<void> {
|
||||
): Promise<LegacyMigrationReport> {
|
||||
const report: LegacyMigrationReport = {
|
||||
serverDevices: 0,
|
||||
strongholdKeysFromServerDevices: 0,
|
||||
strongholdKeysFromBundleScan: 0,
|
||||
attempted: 0,
|
||||
migrated: 0,
|
||||
noStrongholdKey: 0,
|
||||
decryptFailed: 0,
|
||||
rpcFailed: 0,
|
||||
};
|
||||
|
||||
const devices = await listOwnDevices(supabase);
|
||||
if (devices.length === 0) return;
|
||||
report.serverDevices = devices.length;
|
||||
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
|
||||
|
||||
// 1) Try every server-listed device first.
|
||||
for (const d of devices) {
|
||||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`);
|
||||
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
|
||||
}
|
||||
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
|
||||
|
||||
// 2) Scan our visible un-migrated conversation_keys for distinct
|
||||
// recipient_device_ids and probe stronghold for each. This catches the case
|
||||
// where a device row was deleted server-side but its key remains locally,
|
||||
// OR where listOwnDevices missed a device because of an RLS edge.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: scanRowsRaw } = await (supabase as any)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.is('recipient_user_id', null)
|
||||
.not('recipient_device_id', 'is', null);
|
||||
const scanIds = Array.from(new Set(
|
||||
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
|
||||
.map((r) => r.recipient_device_id)
|
||||
.filter((id): id is string => !!id),
|
||||
));
|
||||
for (const id of scanIds) {
|
||||
if (ownLegacyDevicePrivateKeys[id]) continue;
|
||||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${id}`);
|
||||
if (k) {
|
||||
ownLegacyDevicePrivateKeys[id] = k;
|
||||
report.strongholdKeysFromBundleScan += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
'[crypto-migration] vault scan:',
|
||||
'serverDevices=' + report.serverDevices,
|
||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||
'extraKeysFromBundleScan=' + report.strongholdKeysFromBundleScan,
|
||||
);
|
||||
|
||||
const ids = Object.keys(ownLegacyDevicePrivateKeys);
|
||||
if (ids.length === 0) return;
|
||||
await migrateOwnLegacyBundles({
|
||||
if (ids.length === 0) {
|
||||
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
|
||||
return report;
|
||||
}
|
||||
|
||||
const result = await migrateOwnLegacyBundles({
|
||||
client: supabase,
|
||||
ownUserId: userId,
|
||||
ownNewPublicKey: ownNewPub,
|
||||
@@ -170,6 +233,22 @@ async function runLegacyMigration(
|
||||
ownLegacyDeviceIds: ids,
|
||||
ownLegacyDevicePrivateKeys,
|
||||
});
|
||||
|
||||
report.attempted = result.attempted;
|
||||
report.migrated = result.migratedConversations;
|
||||
report.noStrongholdKey = result.noStrongholdKey;
|
||||
report.decryptFailed = result.decryptFailed;
|
||||
report.rpcFailed = result.rpcFailed;
|
||||
return report;
|
||||
}
|
||||
|
||||
// Public wrapper for SecurityCenter's "Migration erneut versuchen" button.
|
||||
// Returns a structured report so the UI can render numbers and reasons.
|
||||
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) throw new Error('user key not cached locally — re-login required');
|
||||
const pub = await derivePublicKey(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('auth/userKey', () => {
|
||||
expect(res.lockedUntil).toBe(lockedUntil);
|
||||
});
|
||||
|
||||
it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => {
|
||||
mock.setRpcResponse('reset_user_key', { data: 0, error: null });
|
||||
it('uploadUserKeyBlob upserts via upsert_user_key RPC (non-destructive)', async () => {
|
||||
mock.setRpcResponse('upsert_user_key', { data: null, error: null });
|
||||
await uploadUserKeyBlob(mock.client, {
|
||||
userId: USER_ID,
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
@@ -64,7 +64,9 @@ describe('auth/userKey', () => {
|
||||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
|
||||
});
|
||||
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
|
||||
expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key');
|
||||
// Crucial: must hit upsert_user_key, NOT reset_user_key — the latter
|
||||
// deletes every conversation_keys row for the user (0.18.0–0.18.2 bug).
|
||||
expect(mock.rpcCalls.at(-1)?.name).toBe('upsert_user_key');
|
||||
expect(params.p_user_id).toBe(USER_ID);
|
||||
expect(params.p_public_key_b64).toBe('AQID');
|
||||
expect(params.p_sealed_private_b64).toBe('BAU=');
|
||||
|
||||
@@ -92,7 +92,13 @@ export async function uploadUserKeyBlob(
|
||||
client: AppSupabaseClient,
|
||||
params: UploadParams,
|
||||
): Promise<void> {
|
||||
const { error } = await rpc(client).rpc('reset_user_key', {
|
||||
// Non-destructive UPSERT — must NOT touch conversation_keys. Used for the
|
||||
// first-time PIN setup, PIN change, and recovery-code regeneration. The
|
||||
// 0.18.0–0.18.2 builds wired this to `reset_user_key` which DELETED every
|
||||
// legacy conv-key bundle for the user before the migration could re-wrap
|
||||
// them, leaving people unable to read or send. `upsert_user_key` writes
|
||||
// only the user_keys row and leaves conversation_keys alone.
|
||||
const { error } = await rpc(client).rpc('upsert_user_key', {
|
||||
p_user_id: params.userId,
|
||||
p_public_key_b64: bytesToB64(params.publicKey),
|
||||
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
|
||||
|
||||
@@ -138,11 +138,105 @@ export async function getOrCreateConvKey(
|
||||
.eq('key_version', version);
|
||||
if (cntErr) throw cntErr;
|
||||
if ((count ?? 0) > 0) {
|
||||
throw new Error('Awaiting conversation key — another user must share it with this user.');
|
||||
// Rows exist for this version, but none for me. Either I lost the device-key
|
||||
// that originally received my bundle, or my own bundle was wiped by the
|
||||
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
|
||||
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
|
||||
// unreadable for me; new ones flow.
|
||||
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
|
||||
return rotateConvKey(client, conversationId, own);
|
||||
}
|
||||
return bootstrapConvKey(client, conversationId, own, version);
|
||||
}
|
||||
|
||||
// Mints a fresh conv-key at active_key_version + 1 and wraps it for every
|
||||
// accepted member. Per-user bundles take priority; for members lacking a
|
||||
// user_keys row we fall back to per-device wrapping (one bundle per device)
|
||||
// so peers still on the legacy 0.17.x client can decrypt with their device
|
||||
// private key. Caller must own a copy of their private key in `own`.
|
||||
export async function rotateConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
own: OwnUserCtx,
|
||||
): Promise<ConvKeyHandle> {
|
||||
const currentVersion = await fetchActiveKeyVersion(client, conversationId);
|
||||
const newVersion = currentVersion + 1;
|
||||
|
||||
const { data: members, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr) throw mErr;
|
||||
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||||
if (memberIds.length === 0) throw new Error('cannot rotate — no accepted members');
|
||||
|
||||
const userKeys = await fetchPeerPublicKeys(client, memberIds);
|
||||
const userKeyByUserId = new Map(userKeys.map((k) => [k.userId, k.publicKey]));
|
||||
const missingUserKeyMembers = memberIds.filter((id) => !userKeyByUserId.has(id));
|
||||
|
||||
let legacyDevices: { userId: string; deviceId: string; publicKey: Uint8Array }[] = [];
|
||||
if (missingUserKeyMembers.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: devs, error: dErr } = await (client as any)
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
.in('user_id', missingUserKeyMembers)
|
||||
.not('public_key', 'is', null);
|
||||
if (dErr) throw dErr;
|
||||
legacyDevices = (devs ?? []).map((d: { id: string; user_id: string; public_key: string }) => ({
|
||||
userId: d.user_id,
|
||||
deviceId: d.id,
|
||||
publicKey: pgHexToBytes(d.public_key),
|
||||
}));
|
||||
}
|
||||
|
||||
const convKey = generateConvKey();
|
||||
const bundles: Array<{
|
||||
recipient_user_id?: string;
|
||||
recipient_device_id?: string;
|
||||
encrypted_key: string;
|
||||
nonce: string;
|
||||
}> = [];
|
||||
for (const k of userKeys) {
|
||||
const wrapped = await wrapConvKeyForRecipient(convKey, k.publicKey, own.privateKey);
|
||||
bundles.push({
|
||||
recipient_user_id: k.userId,
|
||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||
nonce: hexNoPrefix(wrapped.nonce),
|
||||
});
|
||||
}
|
||||
for (const d of legacyDevices) {
|
||||
const wrapped = await wrapConvKeyForRecipient(convKey, d.publicKey, own.privateKey);
|
||||
bundles.push({
|
||||
recipient_device_id: d.deviceId,
|
||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||
nonce: hexNoPrefix(wrapped.nonce),
|
||||
});
|
||||
}
|
||||
if (bundles.length === 0) {
|
||||
throw new Error('cannot rotate — no peers have a public key (no user_keys, no devices)');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||||
const { error } = await rpc.call(client, 'rotate_conv_key', {
|
||||
p_conv_id: conversationId,
|
||||
p_sender_user_id: own.userId,
|
||||
p_new_version: newVersion,
|
||||
p_bundles: bundles,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const handle = { conversationId, keyVersion: newVersion, key: convKey };
|
||||
cache.set(cacheKey(conversationId, newVersion), handle);
|
||||
console.info(
|
||||
'[conv-key] rotated conversation ' + conversationId.slice(0, 8) +
|
||||
' from v' + currentVersion + ' to v' + newVersion +
|
||||
' — wrapped for ' + userKeys.length + ' user-keys + ' + legacyDevices.length + ' legacy devices',
|
||||
);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function tryGetConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
|
||||
@@ -23,10 +23,11 @@ describe('migrateOwnLegacyBundles', () => {
|
||||
const stubClient = {
|
||||
from: (table: string) => {
|
||||
if (table === 'conversation_keys') {
|
||||
// Mirrors the new chain: .select(...).is('recipient_user_id', null).not('recipient_device_id', 'is', null)
|
||||
return {
|
||||
select: () => ({
|
||||
in: () => ({
|
||||
is: () => Promise.resolve({
|
||||
is: () => ({
|
||||
not: () => Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
conversation_id: 'conv-1',
|
||||
|
||||
@@ -14,6 +14,13 @@ export interface MigrateParams {
|
||||
export interface MigrateResult {
|
||||
migratedConversations: number;
|
||||
errors: { conversationId: string; reason: string }[];
|
||||
// Per-pair detail collected even on success so the SecurityCenter "retry"
|
||||
// button can show "X / Y bundles re-wrapped, Z stuck because the device
|
||||
// private key is no longer in the local vault."
|
||||
attempted: number;
|
||||
noStrongholdKey: number;
|
||||
decryptFailed: number;
|
||||
rpcFailed: number;
|
||||
}
|
||||
|
||||
interface LegacyRow {
|
||||
@@ -45,35 +52,50 @@ async function fetchSenderPubKeys(
|
||||
}
|
||||
|
||||
export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<MigrateResult> {
|
||||
const result: MigrateResult = { migratedConversations: 0, errors: [] };
|
||||
if (params.ownLegacyDeviceIds.length === 0) return result;
|
||||
const result: MigrateResult = {
|
||||
migratedConversations: 0, errors: [],
|
||||
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
|
||||
};
|
||||
if (params.ownLegacyDeviceIds.length === 0) {
|
||||
console.info('[crypto-migration] no legacy device-ids to consider — skipping');
|
||||
return result;
|
||||
}
|
||||
|
||||
// PostgREST translates `.eq('col', null)` to `col=eq.null` which evaluates as
|
||||
// `col = NULL` — always false in SQL. `.is('col', null)` produces `col IS NULL`,
|
||||
// which is what we want here. Wrong filter silently returned zero rows so the
|
||||
// entire migration was a no-op (0.18.0 bug; users could set a PIN but no
|
||||
// conv-key bundles got re-wrapped → "Awaiting key" on every send).
|
||||
// RLS already filters this query down to rows where one of the recipient
|
||||
// device-ids belongs to us. We do NOT pre-filter by `ownLegacyDeviceIds`
|
||||
// anymore — historically, users can have device rows server-side whose
|
||||
// private key is no longer in the local vault (fresh OS install, vault
|
||||
// wiped, etc.). Conversely, the vault may hold a key for a device-id the
|
||||
// server forgot. The decisive check is "do we have the matching private
|
||||
// key in stronghold?" — which we evaluate per row below.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: rowsRaw, error } = await (params.client as any)
|
||||
.from('conversation_keys')
|
||||
.select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce')
|
||||
.in('recipient_device_id', params.ownLegacyDeviceIds)
|
||||
.is('recipient_user_id', null);
|
||||
.is('recipient_user_id', null)
|
||||
.not('recipient_device_id', 'is', null);
|
||||
if (error) throw error;
|
||||
const rows = (rowsRaw ?? []) as LegacyRow[];
|
||||
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
|
||||
const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds);
|
||||
|
||||
for (const row of rows) {
|
||||
result.attempted += 1;
|
||||
const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id];
|
||||
if (!ownPriv) {
|
||||
result.errors.push({ conversationId: row.conversation_id, reason: 'no legacy private key in store' });
|
||||
result.noStrongholdKey += 1;
|
||||
result.errors.push({
|
||||
conversationId: row.conversation_id,
|
||||
reason: 'no private key in stronghold for device ' + row.recipient_device_id.slice(0, 8),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sender = senderMap.get(row.sender_device_id);
|
||||
if (!sender) {
|
||||
result.decryptFailed += 1;
|
||||
result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' });
|
||||
continue;
|
||||
}
|
||||
@@ -84,6 +106,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
sender.publicKey, ownPriv,
|
||||
);
|
||||
} catch (err) {
|
||||
result.decryptFailed += 1;
|
||||
result.errors.push({
|
||||
conversationId: row.conversation_id,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
@@ -103,6 +126,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
}],
|
||||
});
|
||||
if (rpcError) {
|
||||
result.rpcFailed += 1;
|
||||
result.errors.push({
|
||||
conversationId: row.conversation_id,
|
||||
reason: (rpcError as Error).message ?? String(rpcError),
|
||||
@@ -112,5 +136,13 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
result.migratedConversations += 1;
|
||||
}
|
||||
|
||||
console.info(
|
||||
'[crypto-migration] result:',
|
||||
'attempted=' + result.attempted,
|
||||
'migrated=' + result.migratedConversations,
|
||||
'noKey=' + result.noStrongholdKey,
|
||||
'decryptFail=' + result.decryptFailed,
|
||||
'rpcFail=' + result.rpcFailed,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
-- 0.18.3 hotfix: split user_keys upload into a non-destructive `upsert_user_key`
|
||||
-- and the existing destructive `reset_user_key`. Add `rotate_conv_key` so the
|
||||
-- client can escape "Awaiting key" deadlocks by minting a fresh per-conversation
|
||||
-- key and wrapping it for every member (per-user where possible, per-device as
|
||||
-- a legacy fallback for peers still on 0.17.x).
|
||||
--
|
||||
-- Why: `reset_user_key` was being called from EVERY upload path
|
||||
-- (setupNewUserIdentity, changePin, regenerateRecoveryCode), wiping every
|
||||
-- legacy `conversation_keys` row for the user before the migration could
|
||||
-- re-wrap them. Users ended up with `user_keys` set, zero un-migrated
|
||||
-- bundles, and no way to send or read.
|
||||
|
||||
-- 1) upsert_user_key — same UPSERT as reset_user_key but WITHOUT the DELETE.
|
||||
-- Safe to call on every PIN-set / PIN-change / recovery-regen.
|
||||
|
||||
create or replace function public.upsert_user_key(
|
||||
p_user_id uuid,
|
||||
p_public_key_b64 text,
|
||||
p_sealed_private_b64 text,
|
||||
p_salt_b64 text,
|
||||
p_kdf_params jsonb,
|
||||
p_recovery_sealed_b64 text default null,
|
||||
p_recovery_salt_b64 text default null
|
||||
) returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
caller uuid := auth.uid();
|
||||
begin
|
||||
if caller is null or caller <> p_user_id then
|
||||
raise exception 'not authenticated as %', p_user_id;
|
||||
end if;
|
||||
|
||||
insert into public.user_keys (
|
||||
user_id, public_key, sealed_private_key, salt, kdf_params,
|
||||
recovery_sealed_private_key, recovery_salt,
|
||||
failed_attempts, locked_until,
|
||||
failed_recovery_attempts, recovery_locked_until,
|
||||
key_version, created_at, updated_at
|
||||
) values (
|
||||
p_user_id,
|
||||
decode(p_public_key_b64, 'base64'),
|
||||
decode(p_sealed_private_b64, 'base64'),
|
||||
decode(p_salt_b64, 'base64'),
|
||||
p_kdf_params,
|
||||
case when p_recovery_sealed_b64 is null then null else decode(p_recovery_sealed_b64, 'base64') end,
|
||||
case when p_recovery_salt_b64 is null then null else decode(p_recovery_salt_b64, 'base64') end,
|
||||
0, null, 0, null,
|
||||
1, now(), now()
|
||||
)
|
||||
on conflict (user_id) do update set
|
||||
public_key = excluded.public_key,
|
||||
sealed_private_key = excluded.sealed_private_key,
|
||||
salt = excluded.salt,
|
||||
kdf_params = excluded.kdf_params,
|
||||
recovery_sealed_private_key = excluded.recovery_sealed_private_key,
|
||||
recovery_salt = excluded.recovery_salt,
|
||||
failed_attempts = 0,
|
||||
locked_until = null,
|
||||
failed_recovery_attempts = 0,
|
||||
recovery_locked_until = null,
|
||||
-- Don't bump key_version here — the public key is unchanged.
|
||||
updated_at = now();
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke execute on function public.upsert_user_key(uuid, text, text, text, jsonb, text, text) from public, anon;
|
||||
grant execute on function public.upsert_user_key(uuid, text, text, text, jsonb, text, text) to authenticated;
|
||||
|
||||
-- 2) rotate_conv_key — atomically bumps active_key_version and inserts a new
|
||||
-- set of bundles. Each bundle may carry recipient_user_id (per-user wrap)
|
||||
-- OR recipient_device_id (per-device fallback for peers on the legacy
|
||||
-- client). Caller must ensure the new version is strictly greater than
|
||||
-- the current one (we lock the row to prevent races).
|
||||
|
||||
create or replace function public.rotate_conv_key(
|
||||
p_conv_id uuid,
|
||||
p_sender_user_id uuid,
|
||||
p_new_version int,
|
||||
p_bundles jsonb
|
||||
) returns int
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
caller uuid := auth.uid();
|
||||
cur_version int;
|
||||
bundle jsonb;
|
||||
inserted int := 0;
|
||||
recipient_uid uuid;
|
||||
recipient_did uuid;
|
||||
member_user_id uuid;
|
||||
enc_key_hex text;
|
||||
nonce_hex text;
|
||||
begin
|
||||
if caller is null or caller <> p_sender_user_id then
|
||||
raise exception 'not authenticated as %', p_sender_user_id;
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1 from public.conversation_members
|
||||
where conversation_id = p_conv_id
|
||||
and user_id = caller
|
||||
and accepted = true
|
||||
) then
|
||||
raise exception 'caller is not an accepted member of %', p_conv_id;
|
||||
end if;
|
||||
|
||||
-- Lock the conversation row so concurrent rotations don't race the version bump.
|
||||
select active_key_version into cur_version
|
||||
from public.conversations
|
||||
where id = p_conv_id
|
||||
for update;
|
||||
if cur_version is null then
|
||||
raise exception 'conversation % not found', p_conv_id;
|
||||
end if;
|
||||
if p_new_version <= cur_version then
|
||||
raise exception 'new key version % must be greater than current %',
|
||||
p_new_version, cur_version;
|
||||
end if;
|
||||
|
||||
update public.conversations
|
||||
set active_key_version = p_new_version
|
||||
where id = p_conv_id;
|
||||
|
||||
-- Insert each bundle. We don't auto-derive recipient_user_id from the
|
||||
-- device anymore — for per-device fallback rows the column stays NULL so
|
||||
-- multiple devices of the same user can each get their own bundle.
|
||||
for bundle in select * from jsonb_array_elements(p_bundles) loop
|
||||
recipient_uid := nullif(bundle->>'recipient_user_id', '')::uuid;
|
||||
recipient_did := nullif(bundle->>'recipient_device_id', '')::uuid;
|
||||
enc_key_hex := bundle->>'encrypted_key';
|
||||
nonce_hex := bundle->>'nonce';
|
||||
|
||||
-- Validate membership regardless of mode.
|
||||
if recipient_uid is not null then
|
||||
member_user_id := recipient_uid;
|
||||
elsif recipient_did is not null then
|
||||
select user_id into member_user_id from public.devices where id = recipient_did;
|
||||
else
|
||||
continue;
|
||||
end if;
|
||||
if member_user_id is null then continue; end if;
|
||||
if not exists (
|
||||
select 1 from public.conversation_members
|
||||
where conversation_id = p_conv_id
|
||||
and user_id = member_user_id
|
||||
and accepted = true
|
||||
) then continue; end if;
|
||||
|
||||
insert into public.conversation_keys
|
||||
(conversation_id, recipient_user_id, recipient_device_id,
|
||||
key_version, sender_user_id, sender_device_id,
|
||||
encrypted_key, nonce)
|
||||
values
|
||||
(p_conv_id, recipient_uid, recipient_did,
|
||||
p_new_version, p_sender_user_id, null,
|
||||
decode(enc_key_hex, 'hex'),
|
||||
decode(nonce_hex, 'hex'))
|
||||
on conflict do nothing;
|
||||
|
||||
if found then inserted := inserted + 1; end if;
|
||||
end loop;
|
||||
|
||||
return inserted;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke execute on function public.rotate_conv_key(uuid, uuid, int, jsonb) from public, anon;
|
||||
grant execute on function public.rotate_conv_key(uuid, uuid, int, jsonb) to authenticated;
|
||||
Reference in New Issue
Block a user