From 70d74c6b79b245bb50920c04421d7b1a1468db8d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 22:39:51 +0000 Subject: [PATCH] Seal copilot own API keys under the owner's key passphrase copilot_settings.anthropic_key / openai_key / elevenlabs_key were AES-GCM under the server env key COPILOT_KEYS_SECRET, so anyone with the box could open every user's BYOK keys. They now use the seal v2 pieces from #165 (same key passphrase and user_key_seals row as the YouTube token): - Saving seals each key to the owner's public key (v2:), 409 before a key passphrase is set; the settings page can set one (recovery key shown once). - Keys open only for a meeting the owner unlocked: Start sends the data key derived in the browser/desktop (never the passphrase); the private key is held in memory under copilot:: with a TTL and dropped when the copilot stops, the meeting ends, on Lock, or on restart. - Locked keys mean free models only, never our paid provider in their place; the panel shows an unlock box (also mid-meeting after a restart). - Legacy v1 values are never used for provider calls; they are re-sealed once in the owner's own request (key setup/unlock, copilot settings, start) and overwritten in place. LEGACY_KEY_MIGRATION=off is the cutoff. - Migration adds copilot_settings.seal_version. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/main/agent-cli/api.ts | 2 + apps/desktop/src/main/agent-cli/copilot.ts | 2 + .../components/copilot/MeetingCopilot.tsx | 3 + apps/web/messages/ar.json | 23 +- apps/web/messages/de.json | 23 +- apps/web/messages/en.json | 23 +- apps/web/messages/es.json | 23 +- apps/web/messages/fr.json | 23 +- apps/web/messages/hi.json | 23 +- apps/web/messages/id.json | 23 +- apps/web/messages/it.json | 23 +- apps/web/messages/ja.json | 23 +- apps/web/messages/ko.json | 23 +- apps/web/messages/nl.json | 23 +- apps/web/messages/pl.json | 23 +- apps/web/messages/pt.json | 23 +- apps/web/messages/ru.json | 23 +- apps/web/messages/sv.json | 23 +- apps/web/messages/tr.json | 23 +- apps/web/messages/uk.json | 23 +- apps/web/messages/vi.json | 23 +- apps/web/messages/zh.json | 23 +- .../web/src/app/api/copilot/settings/route.ts | 9 +- apps/web/src/app/api/keys/lock/route.ts | 4 +- apps/web/src/app/api/keys/setup/route.ts | 16 +- apps/web/src/app/api/keys/unlock/route.ts | 8 + .../api/sessions/[sessionId]/copilot/route.ts | 55 +++- .../app/settings/copilot/copilot-settings.tsx | 96 ++++++- .../src/components/copilot/MeetingCopilot.tsx | 7 +- apps/web/src/lib/copilot/account.ts | 133 +++++---- apps/web/src/lib/copilot/copilot.test.ts | 19 +- apps/web/src/lib/copilot/http.ts | 2 + apps/web/src/lib/copilot/keys.test.ts | 256 ++++++++++++++++++ apps/web/src/lib/copilot/keys.ts | 184 +++++++++++++ apps/web/src/lib/copilot/llm.ts | 2 +- apps/web/src/lib/copilot/providers.ts | 5 + apps/web/src/lib/copilot/runtime.ts | 47 +++- apps/web/src/lib/copilot/voice.ts | 4 +- apps/web/src/lib/sealed/unlock-secret.ts | 38 +++ apps/web/src/lib/sealed/unlockStore.ts | 12 + packages/meeting-copilot/src/CopilotPanel.tsx | 105 ++++++- packages/meeting-copilot/src/strings.ts | 11 + packages/meeting-copilot/src/types.ts | 15 + .../meeting-copilot/src/useMeetingCopilot.ts | 19 +- plans/MEETING-COPILOT.md | 15 +- .../20261028230000_copilot_keys_sealed.sql | 24 ++ 46 files changed, 1391 insertions(+), 139 deletions(-) create mode 100644 apps/web/src/lib/copilot/keys.test.ts create mode 100644 apps/web/src/lib/copilot/keys.ts create mode 100644 apps/web/src/lib/sealed/unlock-secret.ts create mode 100644 supabase/migrations/20261028230000_copilot_keys_sealed.sql diff --git a/apps/desktop/src/main/agent-cli/api.ts b/apps/desktop/src/main/agent-cli/api.ts index 554e4f46..3c0e5596 100644 --- a/apps/desktop/src/main/agent-cli/api.ts +++ b/apps/desktop/src/main/agent-cli/api.ts @@ -281,6 +281,8 @@ export interface CopilotSettingsView { /** GET only. */ eligible?: boolean; byokAvailable?: boolean; + /** Own keys are sealed under a key passphrase set at pairux.com/settings/copilot. */ + passphraseSet?: boolean; balance?: string; balanceMicros?: number; } diff --git a/apps/desktop/src/main/agent-cli/copilot.ts b/apps/desktop/src/main/agent-cli/copilot.ts index 9442856d..ea4f9f8d 100644 --- a/apps/desktop/src/main/agent-cli/copilot.ts +++ b/apps/desktop/src/main/agent-cli/copilot.ts @@ -291,6 +291,8 @@ export function formatSettings(s: CopilotSettingsView): string[] { ); } if (s.byokAvailable === false) lines.push(' (own keys are not enabled on this server)'); + if (s.passphraseSet === false) + lines.push(' (set a key passphrase at https://pairux.com/settings/copilot to save own keys)'); if (s.eligible === false) lines.push('The copilot is part of PairUX Pro and Team.'); if (s.balance !== undefined) lines.push(`Pay-as-you-go balance: ${s.balance}`); return lines; diff --git a/apps/desktop/src/renderer/components/copilot/MeetingCopilot.tsx b/apps/desktop/src/renderer/components/copilot/MeetingCopilot.tsx index 29564e6b..0cd4292d 100644 --- a/apps/desktop/src/renderer/components/copilot/MeetingCopilot.tsx +++ b/apps/desktop/src/renderer/components/copilot/MeetingCopilot.tsx @@ -7,6 +7,8 @@ import { } from '@pairux/meeting-copilot'; import { getElectronAPI } from '@/lib/ipc'; import { API_BASE_URL } from '../../../shared/config'; +// The web app's seal v2 client code (WebCrypto only): own keys unlock here too. +import { dataKeyFromSecret } from '../../../../../web/src/lib/sealed/unlock-secret'; // Ask the main process for the token once a minute. let cached: { token: string | null; until: number } = { token: null, until: 0 }; @@ -25,6 +27,7 @@ const DESKTOP: MeetingCopilotEnvironment = { openUrl: (url) => { void getElectronAPI().invoke('auth:openExternal', url); }, + unlockKeys: dataKeyFromSecret, }; // The desktop app is not localized: the panel's English strings. diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index 72a78bd2..dda1cfe6 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -2401,6 +2401,15 @@ "unclear": "رسائل المتابعة غير واضحة" }, "thin": "النص قصير جدًا لمراجعة هذه المحادثة بإنصاف.", + "ownKeys": { + "locked": "مفاتيح API الخاصة بك مقفلة. أدخل عبارة مرور المفاتيح لاستخدامها في هذا الاجتماع.", + "lockedLive": "مفاتيح API الخاصة بك مقفلة، لذا يعمل المساعد على النماذج المجانية. افتحها للعودة إليها.", + "secret": "عبارة المرور أو مفتاح الاسترداد", + "unlock": "فتح المفاتيح", + "note": "يحتفظ PairUX بالمفتاح المفتوح في الذاكرة لهذا الاجتماع فقط ويحذفه عند توقف المساعد.", + "legacy": "تحتاج مفاتيحك المحفوظة إلى عبارة مرور قبل استخدامها. عيّنها في المفاتيح والرصيد.", + "webOnly": "مفاتيح API الخاصة بك مقفلة. ابدأ المساعد على pairux.com لفتحها." + }, "fullReport": "فتح الملاحظات كاملة", "restart": "البدء من جديد", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "الدفع غير متاح حاليًا. حاول مرة أخرى بعد قليل.", "defaultRole": "الدور الافتراضي", "keysTitle": "مفاتيح API الخاصة بك", - "keysIntro": "استخدم مفاتيحك الخاصة ولن نحاسبك أبدًا على الذكاء الاصطناعي. تُشفَّر المفاتيح، ولا تُعرض مرة أخرى أبدًا، ولا يستخدمها إلا مساعدك.", - "keysDisabled": "المفاتيح الخاصة غير مفعّلة على هذا الخادم بعد.", + "keysIntro": "مع مفاتيحك الخاصة لا نحاسبك أبدًا على الذكاء الاصطناعي. المفاتيح مختومة بعبارة مرور لا يعرفها سواك، لذا لا يستطيع PairUX فتحها؛ تفتحها لاجتماع واحد في كل مرة. إن فقدت العبارة ومفتاح الاسترداد فأعد إدخال مفاتيحك.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC هو الأمر الذي يجيب: تصل المطالبة عبر stdin والرد هو ما يطبعه.", "connectApi": "أو استدعِ واجهة API بنفسك", "connectApiHint": "يرسل كل استدعاء المفتاح كرمز Bearer. استطلع النص بالمؤشر الذي يُعاد." - } + }, + "passphraseFirst": "عيّن عبارة مرور للمفاتيح قبل حفظها. هي نفس عبارة البث التلقائي على YouTube.", + "passphraseLegacy": "مفاتيحك المحفوظة بانتظار نقلها تحت عبارة المرور الخاصة بك. عيّن واحدة لمواصلة استخدامها.", + "passphrase": "عبارة المرور ({min}+ أحرف)", + "passphraseRepeat": "كرر عبارة المرور", + "passphraseShort": "استخدم {min} أحرف على الأقل.", + "passphraseMismatch": "العبارتان مختلفتان.", + "passphraseSet": "تعيين عبارة المرور", + "recoveryTitle": "مفتاح الاسترداد (يُعرض مرة واحدة). احفظه في مكان آمن:", + "recoverySaved": "لقد حفظته" } } diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 3d40b236..e6cdc8f2 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -2401,6 +2401,15 @@ "unclear": "Follow-ups sind unklar" }, "thin": "Das Transkript ist zu kurz, um dieses Gespräch fair zu bewerten.", + "ownKeys": { + "locked": "Deine eigenen API-Schlüssel sind gesperrt. Gib deine Schlüssel-Passphrase ein, um sie in diesem Meeting zu nutzen.", + "lockedLive": "Deine eigenen API-Schlüssel sind gesperrt, daher nutzt der Copilot kostenlose Modelle. Entsperre sie, um zurückzuwechseln.", + "secret": "Schlüssel-Passphrase oder Wiederherstellungsschlüssel", + "unlock": "Schlüssel entsperren", + "note": "PairUX hält den entsperrten Schlüssel nur für dieses Meeting im Arbeitsspeicher und verwirft ihn, wenn der Copilot stoppt.", + "legacy": "Deine gespeicherten Schlüssel brauchen eine Schlüssel-Passphrase, bevor sie genutzt werden können. Lege sie unter Schlüssel und Guthaben fest.", + "webOnly": "Deine eigenen API-Schlüssel sind gesperrt. Starte den Copilot auf pairux.com, um sie zu entsperren." + }, "fullReport": "Vollständige Notizen öffnen", "restart": "Neu starten", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Der Checkout ist gerade nicht verfügbar. Versuch es gleich noch einmal.", "defaultRole": "Standardrolle", "keysTitle": "Deine eigenen API-Schlüssel", - "keysIntro": "Mit deinen eigenen Schlüsseln berechnen wir dir nie etwas für KI. Schlüssel werden verschlüsselt, nie wieder angezeigt und nur von deinem Copilot verwendet.", - "keysDisabled": "Eigene Schlüssel sind auf diesem Server noch nicht aktiviert.", + "keysIntro": "Mit deinen eigenen Schlüsseln berechnen wir dir nie etwas für KI. Die Schlüssel sind mit einer Passphrase versiegelt, die nur du kennst, daher kann PairUX sie nicht öffnen; du entsperrst sie jeweils für ein Meeting. Verlierst du Passphrase und Wiederherstellungsschlüssel, gibst du deine Schlüssel neu ein.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC ist der Befehl, der antwortet: Der Prompt kommt über stdin, die Antwort ist seine Ausgabe.", "connectApi": "Oder ruf die API selbst auf", "connectApiHint": "Jeder Aufruf sendet den Schlüssel als Bearer-Token. Frag das Transkript mit dem zurückgegebenen Cursor ab." - } + }, + "passphraseFirst": "Lege eine Schlüssel-Passphrase fest, bevor du Schlüssel speicherst. Es ist dieselbe Passphrase wie für YouTube Auto-Go-Live.", + "passphraseLegacy": "Deine gespeicherten Schlüssel warten darauf, unter deine eigene Schlüssel-Passphrase zu wechseln. Lege eine fest, um sie weiter zu nutzen.", + "passphrase": "Schlüssel-Passphrase ({min}+ Zeichen)", + "passphraseRepeat": "Passphrase wiederholen", + "passphraseShort": "Verwende mindestens {min} Zeichen.", + "passphraseMismatch": "Die beiden Passphrasen unterscheiden sich.", + "passphraseSet": "Passphrase festlegen", + "recoveryTitle": "Wiederherstellungsschlüssel (nur einmal angezeigt). Bewahre ihn sicher auf:", + "recoverySaved": "Ich habe ihn gespeichert" } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index ec673a65..3a44f468 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2401,6 +2401,15 @@ "unclear": "Follow-ups are unclear" }, "thin": "The transcript is too short to review this conversation fairly.", + "ownKeys": { + "locked": "Your own API keys are locked. Enter your key passphrase to use them in this meeting.", + "lockedLive": "Your own API keys are locked, so the copilot is on free models. Unlock them to switch back.", + "secret": "Key passphrase or recovery key", + "unlock": "Unlock keys", + "note": "PairUX keeps the unlocked key in memory for this meeting only and drops it when the copilot stops.", + "legacy": "Your saved keys need a key passphrase before they can be used. Set one in Keys and credit.", + "webOnly": "Your own API keys are locked. Start the copilot on pairux.com to unlock them." + }, "fullReport": "Open full notes", "restart": "Start again", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Checkout is unavailable right now. Try again shortly.", "defaultRole": "Default role", "keysTitle": "Your own API keys", - "keysIntro": "Use your own keys and we never charge you for AI. Keys are encrypted, never shown again, and used only by your copilot.", - "keysDisabled": "Own keys are not enabled on this server yet.", + "keysIntro": "Use your own keys and we never charge you for AI. Keys are sealed under a key passphrase only you know, so PairUX cannot open them; you unlock them one meeting at a time. Lose the passphrase and the recovery key and you re-enter your keys.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC is the command that answers: the prompt arrives on stdin, the reply is what it prints.", "connectApi": "Or call the API yourself", "connectApiHint": "Every call sends the key as a Bearer token. Poll the transcript with the cursor it returns." - } + }, + "passphraseFirst": "Set a key passphrase before saving keys. It is the same passphrase as YouTube auto go-live.", + "passphraseLegacy": "Your saved keys are waiting to move under your own key passphrase. Set one to keep using them.", + "passphrase": "Key passphrase ({min}+ characters)", + "passphraseRepeat": "Repeat passphrase", + "passphraseShort": "Use at least {min} characters.", + "passphraseMismatch": "The two passphrases differ.", + "passphraseSet": "Set passphrase", + "recoveryTitle": "Recovery key (shown once). Keep it somewhere safe:", + "recoverySaved": "I saved it" } } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index d7280983..e3fe1ad0 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -2401,6 +2401,15 @@ "unclear": "Los seguimientos no están claros" }, "thin": "La transcripción es demasiado corta para analizar esta conversación de forma justa.", + "ownKeys": { + "locked": "Tus claves de API están bloqueadas. Introduce tu frase de contraseña para usarlas en esta reunión.", + "lockedLive": "Tus claves de API están bloqueadas, así que el copiloto usa modelos gratuitos. Desbloquéalas para volver a ellas.", + "secret": "Frase de contraseña o clave de recuperación", + "unlock": "Desbloquear claves", + "note": "PairUX guarda la clave desbloqueada en memoria solo para esta reunión y la descarta cuando el copiloto se detiene.", + "legacy": "Tus claves guardadas necesitan una frase de contraseña antes de poder usarse. Configúrala en Claves y crédito.", + "webOnly": "Tus claves de API están bloqueadas. Inicia el copiloto en pairux.com para desbloquearlas." + }, "fullReport": "Abrir notas completas", "restart": "Empezar de nuevo", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "El pago no está disponible ahora mismo. Inténtalo de nuevo en breve.", "defaultRole": "Rol predeterminado", "keysTitle": "Tus propias claves de API", - "keysIntro": "Usa tus propias claves y nunca te cobraremos por la IA. Las claves se cifran, no se vuelven a mostrar y solo las usa tu copiloto.", - "keysDisabled": "Las claves propias aún no están habilitadas en este servidor.", + "keysIntro": "Con tus propias claves nunca te cobramos la IA. Las claves están selladas con una frase de contraseña que solo tú conoces, así que PairUX no puede abrirlas; las desbloqueas reunión a reunión. Si pierdes la frase y la clave de recuperación, vuelves a introducir tus claves.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC es el comando que responde: el prompt llega por stdin y la respuesta es lo que imprime.", "connectApi": "O llama a la API tú mismo", "connectApiHint": "Cada llamada envía la clave como token Bearer. Consulta la transcripción con el cursor que devuelve." - } + }, + "passphraseFirst": "Configura una frase de contraseña antes de guardar claves. Es la misma frase que la emisión automática en YouTube.", + "passphraseLegacy": "Tus claves guardadas esperan pasar a tu propia frase de contraseña. Configura una para seguir usándolas.", + "passphrase": "Frase de contraseña ({min}+ caracteres)", + "passphraseRepeat": "Repite la frase", + "passphraseShort": "Usa al menos {min} caracteres.", + "passphraseMismatch": "Las dos frases no coinciden.", + "passphraseSet": "Configurar frase", + "recoveryTitle": "Clave de recuperación (se muestra una vez). Guárdala en un lugar seguro:", + "recoverySaved": "Ya la guardé" } } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index c47d2070..281d2610 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -2401,6 +2401,15 @@ "unclear": "Les suivis ne sont pas clairs" }, "thin": "La transcription est trop courte pour analyser équitablement cette conversation.", + "ownKeys": { + "locked": "Vos clés API sont verrouillées. Saisissez votre phrase secrète pour les utiliser dans cette réunion.", + "lockedLive": "Vos clés API sont verrouillées, le copilote utilise donc les modèles gratuits. Déverrouillez-les pour revenir à vos clés.", + "secret": "Phrase secrète ou clé de récupération", + "unlock": "Déverrouiller les clés", + "note": "PairUX garde la clé déverrouillée en mémoire pour cette réunion uniquement et l’efface quand le copilote s’arrête.", + "legacy": "Vos clés enregistrées ont besoin d’une phrase secrète avant de pouvoir servir. Définissez-la dans Clés et crédit.", + "webOnly": "Vos clés API sont verrouillées. Démarrez le copilote sur pairux.com pour les déverrouiller." + }, "fullReport": "Ouvrir les notes complètes", "restart": "Recommencer", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Le paiement est indisponible pour le moment. Réessayez dans un instant.", "defaultRole": "Rôle par défaut", "keysTitle": "Vos propres clés API", - "keysIntro": "Utilisez vos propres clés et nous ne vous facturerons jamais l'IA. Les clés sont chiffrées, ne sont plus jamais affichées et ne servent qu'à votre copilote.", - "keysDisabled": "Les clés personnelles ne sont pas encore activées sur ce serveur.", + "keysIntro": "Avec vos propres clés, nous ne vous facturons jamais l’IA. Les clés sont scellées par une phrase secrète que vous seul connaissez, PairUX ne peut donc pas les ouvrir ; vous les déverrouillez réunion par réunion. Si vous perdez la phrase et la clé de récupération, vous saisissez à nouveau vos clés.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC est la commande qui répond : le prompt arrive sur stdin, la réponse est ce qu’elle affiche.", "connectApi": "Ou appelez l’API vous-même", "connectApiHint": "Chaque appel envoie la clé comme jeton Bearer. Interrogez la transcription avec le curseur renvoyé." - } + }, + "passphraseFirst": "Définissez une phrase secrète avant d’enregistrer des clés. C’est la même que pour le direct automatique YouTube.", + "passphraseLegacy": "Vos clés enregistrées attendent de passer sous votre propre phrase secrète. Définissez-en une pour continuer à les utiliser.", + "passphrase": "Phrase secrète ({min}+ caractères)", + "passphraseRepeat": "Répétez la phrase", + "passphraseShort": "Utilisez au moins {min} caractères.", + "passphraseMismatch": "Les deux phrases diffèrent.", + "passphraseSet": "Définir la phrase", + "recoveryTitle": "Clé de récupération (affichée une seule fois). Conservez-la en lieu sûr :", + "recoverySaved": "Je l’ai enregistrée" } } diff --git a/apps/web/messages/hi.json b/apps/web/messages/hi.json index 2079a322..085a64b9 100644 --- a/apps/web/messages/hi.json +++ b/apps/web/messages/hi.json @@ -2401,6 +2401,15 @@ "unclear": "फ़ॉलो-अप स्पष्ट नहीं हैं" }, "thin": "इस बातचीत की निष्पक्ष समीक्षा के लिए ट्रांसक्रिप्ट बहुत छोटा है।", + "ownKeys": { + "locked": "आपकी अपनी API कुंजियाँ लॉक हैं। इस मीटिंग में उनका उपयोग करने के लिए अपना कुंजी पासफ़्रेज़ दर्ज करें।", + "lockedLive": "आपकी अपनी API कुंजियाँ लॉक हैं, इसलिए कोपायलट मुफ़्त मॉडल पर है। वापस जाने के लिए उन्हें अनलॉक करें।", + "secret": "कुंजी पासफ़्रेज़ या रिकवरी कुंजी", + "unlock": "कुंजियाँ अनलॉक करें", + "note": "PairUX अनलॉक की गई कुंजी को सिर्फ़ इस मीटिंग के लिए मेमोरी में रखता है और कोपायलट रुकने पर हटा देता है।", + "legacy": "आपकी सहेजी गई कुंजियों को उपयोग से पहले एक कुंजी पासफ़्रेज़ चाहिए। इसे कुंजियाँ और क्रेडिट में सेट करें।", + "webOnly": "आपकी अपनी API कुंजियाँ लॉक हैं। उन्हें अनलॉक करने के लिए pairux.com पर कोपायलट शुरू करें।" + }, "fullReport": "पूरे नोट्स खोलें", "restart": "फिर से शुरू करें", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "चेकआउट अभी उपलब्ध नहीं है। थोड़ी देर में फिर कोशिश करें।", "defaultRole": "डिफ़ॉल्ट भूमिका", "keysTitle": "आपकी अपनी API कुंजियाँ", - "keysIntro": "अपनी कुंजियाँ इस्तेमाल करें, तो हम AI के लिए आपसे कभी शुल्क नहीं लेंगे। कुंजियाँ एन्क्रिप्ट की जाती हैं, दोबारा कभी नहीं दिखाई जातीं और केवल आपका कोपायलट उनका इस्तेमाल करता है।", - "keysDisabled": "इस सर्वर पर अपनी कुंजियाँ अभी चालू नहीं हैं।", + "keysIntro": "अपनी कुंजियों के साथ हम AI के लिए कभी शुल्क नहीं लेते। कुंजियाँ एक ऐसे पासफ़्रेज़ से सील हैं जिसे सिर्फ़ आप जानते हैं, इसलिए PairUX उन्हें नहीं खोल सकता; आप उन्हें एक बार में एक मीटिंग के लिए अनलॉक करते हैं। पासफ़्रेज़ और रिकवरी कुंजी खो जाए तो कुंजियाँ फिर से दर्ज करें।", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC वह कमांड है जो जवाब देती है: प्रॉम्प्ट stdin पर आता है, जवाब वही है जो वह प्रिंट करती है।", "connectApi": "या API को ख़ुद कॉल करें", "connectApiHint": "हर कॉल कुंजी को Bearer टोकन के रूप में भेजती है। लौटाए गए कर्सर से ट्रांसक्रिप्ट जाँचें।" - } + }, + "passphraseFirst": "कुंजियाँ सहेजने से पहले एक कुंजी पासफ़्रेज़ सेट करें। यह YouTube ऑटो गो-लाइव वाला ही पासफ़्रेज़ है।", + "passphraseLegacy": "आपकी सहेजी गई कुंजियाँ आपके अपने पासफ़्रेज़ के तहत जाने का इंतज़ार कर रही हैं। उपयोग जारी रखने के लिए एक सेट करें।", + "passphrase": "कुंजी पासफ़्रेज़ ({min}+ अक्षर)", + "passphraseRepeat": "पासफ़्रेज़ दोहराएँ", + "passphraseShort": "कम से कम {min} अक्षर इस्तेमाल करें।", + "passphraseMismatch": "दोनों पासफ़्रेज़ अलग हैं।", + "passphraseSet": "पासफ़्रेज़ सेट करें", + "recoveryTitle": "रिकवरी कुंजी (एक बार दिखाई जाती है)। इसे सुरक्षित जगह रखें:", + "recoverySaved": "मैंने सहेज ली" } } diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index 1ef4d055..7351147b 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -2401,6 +2401,15 @@ "unclear": "Tindak lanjut belum jelas" }, "thin": "Transkrip terlalu pendek untuk meninjau percakapan ini secara adil.", + "ownKeys": { + "locked": "Kunci API Anda terkunci. Masukkan frasa sandi kunci untuk memakainya di rapat ini.", + "lockedLive": "Kunci API Anda terkunci, jadi kopilot memakai model gratis. Buka kuncinya untuk kembali.", + "secret": "Frasa sandi atau kunci pemulihan", + "unlock": "Buka kunci", + "note": "PairUX menyimpan kunci yang terbuka di memori hanya untuk rapat ini dan membuangnya saat kopilot berhenti.", + "legacy": "Kunci yang tersimpan perlu frasa sandi sebelum bisa dipakai. Atur di Kunci dan kredit.", + "webOnly": "Kunci API Anda terkunci. Mulai kopilot di pairux.com untuk membukanya." + }, "fullReport": "Buka catatan lengkap", "restart": "Mulai lagi", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Checkout sedang tidak tersedia. Coba lagi sebentar lagi.", "defaultRole": "Peran default", "keysTitle": "Kunci API Anda sendiri", - "keysIntro": "Gunakan kunci Anda sendiri dan kami tidak akan pernah menagih Anda untuk AI. Kunci dienkripsi, tidak pernah ditampilkan lagi, dan hanya digunakan oleh kopilot Anda.", - "keysDisabled": "Kunci sendiri belum diaktifkan di server ini.", + "keysIntro": "Dengan kunci sendiri kami tidak pernah menagih AI. Kunci disegel dengan frasa sandi yang hanya Anda ketahui, jadi PairUX tidak bisa membukanya; Anda membukanya per rapat. Jika frasa sandi dan kunci pemulihan hilang, masukkan ulang kunci Anda.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC adalah perintah yang menjawab: prompt masuk lewat stdin, jawabannya adalah apa yang dicetak.", "connectApi": "Atau panggil API sendiri", "connectApiHint": "Setiap panggilan mengirim kunci sebagai token Bearer. Periksa transkrip dengan kursor yang dikembalikan." - } + }, + "passphraseFirst": "Atur frasa sandi sebelum menyimpan kunci. Sama dengan frasa sandi siaran otomatis YouTube.", + "passphraseLegacy": "Kunci tersimpan Anda menunggu dipindahkan ke frasa sandi Anda sendiri. Atur satu agar tetap bisa dipakai.", + "passphrase": "Frasa sandi ({min}+ karakter)", + "passphraseRepeat": "Ulangi frasa sandi", + "passphraseShort": "Gunakan minimal {min} karakter.", + "passphraseMismatch": "Kedua frasa sandi berbeda.", + "passphraseSet": "Atur frasa sandi", + "recoveryTitle": "Kunci pemulihan (ditampilkan sekali). Simpan di tempat aman:", + "recoverySaved": "Sudah saya simpan" } } diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 66a00d92..b42f50b6 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -2401,6 +2401,15 @@ "unclear": "I follow-up non sono chiari" }, "thin": "La trascrizione è troppo breve per valutare questa conversazione in modo equo.", + "ownKeys": { + "locked": "Le tue chiavi API sono bloccate. Inserisci la passphrase per usarle in questa riunione.", + "lockedLive": "Le tue chiavi API sono bloccate, quindi il copilota usa i modelli gratuiti. Sbloccale per tornare alle tue chiavi.", + "secret": "Passphrase o chiave di recupero", + "unlock": "Sblocca chiavi", + "note": "PairUX tiene la chiave sbloccata in memoria solo per questa riunione e la elimina quando il copilota si ferma.", + "legacy": "Le chiavi salvate richiedono una passphrase prima di poter essere usate. Impostala in Chiavi e credito.", + "webOnly": "Le tue chiavi API sono bloccate. Avvia il copilota su pairux.com per sbloccarle." + }, "fullReport": "Apri le note complete", "restart": "Ricomincia", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Il checkout non è disponibile al momento. Riprova tra poco.", "defaultRole": "Ruolo predefinito", "keysTitle": "Le tue chiavi API", - "keysIntro": "Usa le tue chiavi e non ti addebiteremo mai nulla per l'IA. Le chiavi sono cifrate, non vengono più mostrate e sono usate solo dal tuo copilota.", - "keysDisabled": "Le chiavi personali non sono ancora attive su questo server.", + "keysIntro": "Con le tue chiavi non ti addebitiamo mai l’IA. Le chiavi sono sigillate con una passphrase che conosci solo tu, quindi PairUX non può aprirle; le sblocchi una riunione alla volta. Se perdi la passphrase e la chiave di recupero, reinserisci le chiavi.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC è il comando che risponde: il prompt arriva su stdin, la risposta è ciò che stampa.", "connectApi": "Oppure chiama tu l’API", "connectApiHint": "Ogni chiamata invia la chiave come token Bearer. Interroga la trascrizione con il cursore restituito." - } + }, + "passphraseFirst": "Imposta una passphrase prima di salvare le chiavi. È la stessa della diretta automatica su YouTube.", + "passphraseLegacy": "Le chiavi salvate aspettano di passare sotto la tua passphrase. Impostane una per continuare a usarle.", + "passphrase": "Passphrase ({min}+ caratteri)", + "passphraseRepeat": "Ripeti la passphrase", + "passphraseShort": "Usa almeno {min} caratteri.", + "passphraseMismatch": "Le due passphrase sono diverse.", + "passphraseSet": "Imposta passphrase", + "recoveryTitle": "Chiave di recupero (mostrata una sola volta). Conservala al sicuro:", + "recoverySaved": "L’ho salvata" } } diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 099dff4d..52eb8cf6 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -2401,6 +2401,15 @@ "unclear": "フォローアップが不明確です" }, "thin": "文字起こしが短すぎるため、この会話を公平にレビューできません。", + "ownKeys": { + "locked": "自分の API キーはロックされています。この会議で使うにはキーのパスフレーズを入力してください。", + "lockedLive": "自分の API キーがロックされているため、コパイロットは無料モデルで動いています。戻すにはロックを解除してください。", + "secret": "パスフレーズまたはリカバリーキー", + "unlock": "キーのロックを解除", + "note": "PairUX は解除したキーをこの会議の間だけメモリに保持し、コパイロット停止時に破棄します。", + "legacy": "保存したキーを使うにはキーのパスフレーズが必要です。「キーとクレジット」で設定してください。", + "webOnly": "自分の API キーはロックされています。解除するには pairux.com でコパイロットを開始してください。" + }, "fullReport": "メモ全文を開く", "restart": "最初からやり直す", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "現在チェックアウトをご利用いただけません。しばらくしてからもう一度お試しください。", "defaultRole": "デフォルトの役割", "keysTitle": "自分の API キー", - "keysIntro": "自分のキーを使えば、AI の料金は一切かかりません。キーは暗号化され、二度と表示されず、あなたのコパイロットだけが使用します。", - "keysDisabled": "このサーバーでは自分のキーはまだ有効になっていません。", + "keysIntro": "自分のキーを使えば AI の料金は一切かかりません。キーはあなただけが知るパスフレーズで封印され、PairUX には開けません。会議ごとにロックを解除します。パスフレーズとリカバリーキーの両方を失った場合はキーを再入力してください。", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC は応答するコマンドです。プロンプトは stdin で届き、出力したものが返信になります。", "connectApi": "または API を直接呼び出す", "connectApiHint": "各呼び出しはキーを Bearer トークンとして送信します。返されたカーソルで文字起こしを取得します。" - } + }, + "passphraseFirst": "キーを保存する前にパスフレーズを設定してください。YouTube 自動配信と同じパスフレーズです。", + "passphraseLegacy": "保存済みのキーはあなたのパスフレーズへの移行を待っています。使い続けるには設定してください。", + "passphrase": "パスフレーズ({min} 文字以上)", + "passphraseRepeat": "パスフレーズを再入力", + "passphraseShort": "{min} 文字以上にしてください。", + "passphraseMismatch": "2 つのパスフレーズが一致しません。", + "passphraseSet": "パスフレーズを設定", + "recoveryTitle": "リカバリーキー(一度だけ表示)。安全な場所に保管してください:", + "recoverySaved": "保存しました" } } diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index aace8d69..ba5fa53a 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -2401,6 +2401,15 @@ "unclear": "후속 메시지가 명확하지 않습니다" }, "thin": "대화록이 너무 짧아 이 대화를 공정하게 리뷰할 수 없습니다.", + "ownKeys": { + "locked": "내 API 키가 잠겨 있습니다. 이 회의에서 사용하려면 키 암호 문구를 입력하세요.", + "lockedLive": "내 API 키가 잠겨 있어 코파일럿이 무료 모델을 사용 중입니다. 다시 전환하려면 잠금을 해제하세요.", + "secret": "암호 문구 또는 복구 키", + "unlock": "키 잠금 해제", + "note": "PairUX는 잠금 해제된 키를 이 회의 동안만 메모리에 보관하고 코파일럿이 멈추면 삭제합니다.", + "legacy": "저장된 키를 사용하려면 먼저 키 암호 문구가 필요합니다. 키 및 크레딧에서 설정하세요.", + "webOnly": "내 API 키가 잠겨 있습니다. 잠금을 해제하려면 pairux.com에서 코파일럿을 시작하세요." + }, "fullReport": "전체 메모 열기", "restart": "다시 시작", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "지금은 결제를 이용할 수 없습니다. 잠시 후 다시 시도하세요.", "defaultRole": "기본 역할", "keysTitle": "내 API 키", - "keysIntro": "내 키를 사용하면 AI 비용이 청구되지 않습니다. 키는 암호화되며 다시 표시되지 않고 내 코파일럿만 사용합니다.", - "keysDisabled": "이 서버에서는 아직 내 키 사용이 활성화되지 않았습니다.", + "keysIntro": "내 키를 사용하면 AI 요금이 청구되지 않습니다. 키는 나만 아는 암호 문구로 봉인되어 PairUX가 열 수 없으며, 회의마다 잠금을 해제합니다. 암호 문구와 복구 키를 모두 잃으면 키를 다시 입력하세요.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC는 응답하는 명령입니다. 프롬프트는 stdin으로 들어오고, 출력한 내용이 답변이 됩니다.", "connectApi": "또는 API를 직접 호출하세요", "connectApiHint": "모든 호출은 키를 Bearer 토큰으로 보냅니다. 반환된 커서로 전사를 조회하세요." - } + }, + "passphraseFirst": "키를 저장하기 전에 키 암호 문구를 설정하세요. YouTube 자동 라이브와 같은 암호 문구입니다.", + "passphraseLegacy": "저장된 키가 내 암호 문구로 옮겨지기를 기다리고 있습니다. 계속 사용하려면 설정하세요.", + "passphrase": "암호 문구({min}자 이상)", + "passphraseRepeat": "암호 문구 다시 입력", + "passphraseShort": "{min}자 이상 입력하세요.", + "passphraseMismatch": "두 암호 문구가 다릅니다.", + "passphraseSet": "암호 문구 설정", + "recoveryTitle": "복구 키(한 번만 표시). 안전한 곳에 보관하세요:", + "recoverySaved": "저장했습니다" } } diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index cdaca9ec..7f765a9b 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -2401,6 +2401,15 @@ "unclear": "Follow-ups zijn onduidelijk" }, "thin": "Het transcript is te kort om dit gesprek eerlijk te beoordelen.", + "ownKeys": { + "locked": "Je eigen API-sleutels zijn vergrendeld. Voer je sleutelwachtwoordzin in om ze in deze vergadering te gebruiken.", + "lockedLive": "Je eigen API-sleutels zijn vergrendeld, dus de copilot gebruikt gratis modellen. Ontgrendel ze om terug te schakelen.", + "secret": "Wachtwoordzin of herstelsleutel", + "unlock": "Sleutels ontgrendelen", + "note": "PairUX houdt de ontgrendelde sleutel alleen voor deze vergadering in het geheugen en wist hem als de copilot stopt.", + "legacy": "Je opgeslagen sleutels hebben een wachtwoordzin nodig voordat ze gebruikt kunnen worden. Stel er een in bij Sleutels en tegoed.", + "webOnly": "Je eigen API-sleutels zijn vergrendeld. Start de copilot op pairux.com om ze te ontgrendelen." + }, "fullReport": "Volledige notities openen", "restart": "Opnieuw beginnen", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Afrekenen is nu niet beschikbaar. Probeer het zo opnieuw.", "defaultRole": "Standaardrol", "keysTitle": "Je eigen API-sleutels", - "keysIntro": "Gebruik je eigen sleutels en we rekenen nooit iets voor AI. Sleutels worden versleuteld, nooit meer getoond en alleen door je copilot gebruikt.", - "keysDisabled": "Eigen sleutels zijn op deze server nog niet ingeschakeld.", + "keysIntro": "Met je eigen sleutels rekenen we nooit iets voor AI. Sleutels zijn verzegeld met een wachtwoordzin die alleen jij kent, dus PairUX kan ze niet openen; je ontgrendelt ze per vergadering. Verlies je de wachtwoordzin en de herstelsleutel, dan voer je je sleutels opnieuw in.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC is de opdracht die antwoordt: de prompt komt via stdin, het antwoord is wat hij print.", "connectApi": "Of roep de API zelf aan", "connectApiHint": "Elke aanroep stuurt de sleutel als Bearer-token. Vraag het transcript op met de cursor die terugkomt." - } + }, + "passphraseFirst": "Stel een wachtwoordzin in voordat je sleutels opslaat. Het is dezelfde als voor automatisch live gaan op YouTube.", + "passphraseLegacy": "Je opgeslagen sleutels wachten om onder je eigen wachtwoordzin te komen. Stel er een in om ze te blijven gebruiken.", + "passphrase": "Wachtwoordzin ({min}+ tekens)", + "passphraseRepeat": "Herhaal wachtwoordzin", + "passphraseShort": "Gebruik minstens {min} tekens.", + "passphraseMismatch": "De twee wachtwoordzinnen verschillen.", + "passphraseSet": "Wachtwoordzin instellen", + "recoveryTitle": "Herstelsleutel (eenmalig getoond). Bewaar hem op een veilige plek:", + "recoverySaved": "Ik heb hem bewaard" } } diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index 62c509d6..757a1fae 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -2401,6 +2401,15 @@ "unclear": "Follow-upy są niejasne" }, "thin": "Transkrypcja jest za krótka, aby rzetelnie ocenić tę rozmowę.", + "ownKeys": { + "locked": "Twoje klucze API są zablokowane. Wpisz hasło kluczy, aby użyć ich na tym spotkaniu.", + "lockedLive": "Twoje klucze API są zablokowane, więc copilot korzysta z darmowych modeli. Odblokuj je, aby wrócić do swoich kluczy.", + "secret": "Hasło kluczy lub klucz odzyskiwania", + "unlock": "Odblokuj klucze", + "note": "PairUX trzyma odblokowany klucz w pamięci tylko na czas tego spotkania i usuwa go, gdy copilot się zatrzyma.", + "legacy": "Zapisane klucze wymagają hasła, zanim będzie można ich użyć. Ustaw je w sekcji Klucze i środki.", + "webOnly": "Twoje klucze API są zablokowane. Uruchom copilota na pairux.com, aby je odblokować." + }, "fullReport": "Otwórz pełne notatki", "restart": "Zacznij od nowa", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Płatność jest teraz niedostępna. Spróbuj ponownie za chwilę.", "defaultRole": "Domyślna rola", "keysTitle": "Twoje własne klucze API", - "keysIntro": "Używaj własnych kluczy, a nigdy nie obciążymy Cię za AI. Klucze są szyfrowane, nigdy więcej nie są wyświetlane i używa ich tylko Twój copilot.", - "keysDisabled": "Własne klucze nie są jeszcze włączone na tym serwerze.", + "keysIntro": "Z własnymi kluczami nigdy nie pobieramy opłat za AI. Klucze są zapieczętowane hasłem, które znasz tylko Ty, więc PairUX nie może ich otworzyć; odblokowujesz je na jedno spotkanie. Jeśli zgubisz hasło i klucz odzyskiwania, wpiszesz klucze ponownie.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC to polecenie, które odpowiada: prompt trafia na stdin, a odpowiedź to to, co wypisze.", "connectApi": "Albo wywołaj API samodzielnie", "connectApiHint": "Każde wywołanie wysyła klucz jako token Bearer. Pobieraj transkrypcję ze zwróconym kursorem." - } + }, + "passphraseFirst": "Ustaw hasło kluczy przed zapisaniem kluczy. To samo hasło co dla automatycznej transmisji na YouTube.", + "passphraseLegacy": "Zapisane klucze czekają na przeniesienie pod Twoje hasło. Ustaw je, aby dalej z nich korzystać.", + "passphrase": "Hasło kluczy ({min}+ znaków)", + "passphraseRepeat": "Powtórz hasło", + "passphraseShort": "Użyj co najmniej {min} znaków.", + "passphraseMismatch": "Hasła się różnią.", + "passphraseSet": "Ustaw hasło", + "recoveryTitle": "Klucz odzyskiwania (pokazywany raz). Przechowuj go w bezpiecznym miejscu:", + "recoverySaved": "Zapisałem go" } } diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index 2eedac80..ff2d3e66 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -2401,6 +2401,15 @@ "unclear": "Os follow-ups não estão claros" }, "thin": "A transcrição é curta demais para analisar esta conversa de forma justa.", + "ownKeys": { + "locked": "Suas chaves de API estão bloqueadas. Digite sua frase-senha para usá-las nesta reunião.", + "lockedLive": "Suas chaves de API estão bloqueadas, então o copiloto usa modelos gratuitos. Desbloqueie-as para voltar a elas.", + "secret": "Frase-senha ou chave de recuperação", + "unlock": "Desbloquear chaves", + "note": "O PairUX mantém a chave desbloqueada na memória só para esta reunião e a descarta quando o copiloto para.", + "legacy": "Suas chaves salvas precisam de uma frase-senha antes de serem usadas. Defina uma em Chaves e crédito.", + "webOnly": "Suas chaves de API estão bloqueadas. Inicie o copiloto em pairux.com para desbloqueá-las." + }, "fullReport": "Abrir notas completas", "restart": "Recomeçar", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "O checkout está indisponível no momento. Tente novamente em instantes.", "defaultRole": "Papel padrão", "keysTitle": "Suas próprias chaves de API", - "keysIntro": "Use suas próprias chaves e nunca cobraremos pela IA. As chaves são criptografadas, nunca mais exibidas e usadas apenas pelo seu copiloto.", - "keysDisabled": "Chaves próprias ainda não estão ativadas neste servidor.", + "keysIntro": "Com suas próprias chaves nunca cobramos pela IA. As chaves são seladas com uma frase-senha que só você conhece, então o PairUX não consegue abri-las; você as desbloqueia uma reunião por vez. Se perder a frase e a chave de recuperação, digite suas chaves de novo.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC é o comando que responde: o prompt chega pelo stdin e a resposta é o que ele imprime.", "connectApi": "Ou chame a API você mesmo", "connectApiHint": "Cada chamada envia a chave como token Bearer. Consulte a transcrição com o cursor retornado." - } + }, + "passphraseFirst": "Defina uma frase-senha antes de salvar chaves. É a mesma da transmissão automática no YouTube.", + "passphraseLegacy": "Suas chaves salvas aguardam a mudança para sua própria frase-senha. Defina uma para continuar usando-as.", + "passphrase": "Frase-senha ({min}+ caracteres)", + "passphraseRepeat": "Repita a frase-senha", + "passphraseShort": "Use pelo menos {min} caracteres.", + "passphraseMismatch": "As duas frases são diferentes.", + "passphraseSet": "Definir frase-senha", + "recoveryTitle": "Chave de recuperação (mostrada uma vez). Guarde-a em local seguro:", + "recoverySaved": "Já guardei" } } diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 6d35add6..713db320 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -2401,6 +2401,15 @@ "unclear": "Итоги непонятны" }, "thin": "Расшифровка слишком короткая, чтобы объективно оценить этот разговор.", + "ownKeys": { + "locked": "Ваши API-ключи заблокированы. Введите парольную фразу, чтобы использовать их на этой встрече.", + "lockedLive": "Ваши API-ключи заблокированы, поэтому копилот работает на бесплатных моделях. Разблокируйте их, чтобы вернуться.", + "secret": "Парольная фраза или ключ восстановления", + "unlock": "Разблокировать ключи", + "note": "PairUX держит разблокированный ключ в памяти только для этой встречи и удаляет его, когда копилот останавливается.", + "legacy": "Сохранённым ключам нужна парольная фраза, прежде чем их можно использовать. Задайте её в разделе «Ключи и баланс».", + "webOnly": "Ваши API-ключи заблокированы. Запустите копилот на pairux.com, чтобы разблокировать их." + }, "fullReport": "Открыть полные заметки", "restart": "Начать заново", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Оплата сейчас недоступна. Попробуйте чуть позже.", "defaultRole": "Роль по умолчанию", "keysTitle": "Ваши собственные API-ключи", - "keysIntro": "С собственными ключами мы никогда не берём с вас плату за ИИ. Ключи шифруются, больше никогда не показываются и используются только вашим копилотом.", - "keysDisabled": "Собственные ключи на этом сервере пока не включены.", + "keysIntro": "Со своими ключами мы никогда не берём плату за ИИ. Ключи запечатаны парольной фразой, которую знаете только вы, поэтому PairUX не может их открыть; вы разблокируете их на одну встречу. Если потеряете фразу и ключ восстановления, введите ключи заново.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC — команда, которая отвечает: промпт приходит на stdin, ответ — то, что она выводит.", "connectApi": "Или вызывайте API сами", "connectApiHint": "Каждый запрос передаёт ключ как Bearer-токен. Запрашивайте расшифровку с возвращённым курсором." - } + }, + "passphraseFirst": "Задайте парольную фразу перед сохранением ключей. Это та же фраза, что и для автоэфира на YouTube.", + "passphraseLegacy": "Сохранённые ключи ждут переноса под вашу парольную фразу. Задайте её, чтобы продолжить ими пользоваться.", + "passphrase": "Парольная фраза ({min}+ символов)", + "passphraseRepeat": "Повторите фразу", + "passphraseShort": "Используйте не менее {min} символов.", + "passphraseMismatch": "Фразы не совпадают.", + "passphraseSet": "Задать фразу", + "recoveryTitle": "Ключ восстановления (показывается один раз). Сохраните его в надёжном месте:", + "recoverySaved": "Я сохранил его" } } diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index 3890a1d2..4fba802b 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -2401,6 +2401,15 @@ "unclear": "Uppföljningarna är otydliga" }, "thin": "Transkriptionen är för kort för att bedöma samtalet rättvist.", + "ownKeys": { + "locked": "Dina egna API-nycklar är låsta. Ange din nyckellösenfras för att använda dem i det här mötet.", + "lockedLive": "Dina egna API-nycklar är låsta, så copiloten använder gratismodeller. Lås upp dem för att byta tillbaka.", + "secret": "Lösenfras eller återställningsnyckel", + "unlock": "Lås upp nycklar", + "note": "PairUX håller den upplåsta nyckeln i minnet bara för det här mötet och släpper den när copiloten stoppas.", + "legacy": "Dina sparade nycklar behöver en lösenfras innan de kan användas. Ange en under Nycklar och kredit.", + "webOnly": "Dina egna API-nycklar är låsta. Starta copiloten på pairux.com för att låsa upp dem." + }, "fullReport": "Öppna alla anteckningar", "restart": "Börja om", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Kassan är inte tillgänglig just nu. Försök igen om en stund.", "defaultRole": "Standardroll", "keysTitle": "Dina egna API-nycklar", - "keysIntro": "Använd dina egna nycklar så debiterar vi dig aldrig för AI. Nycklarna krypteras, visas aldrig igen och används bara av din copilot.", - "keysDisabled": "Egna nycklar är inte aktiverade på den här servern än.", + "keysIntro": "Med egna nycklar tar vi aldrig betalt för AI. Nycklarna är förseglade med en lösenfras som bara du känner till, så PairUX kan inte öppna dem; du låser upp dem ett möte i taget. Tappar du lösenfrasen och återställningsnyckeln anger du nycklarna igen.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC är kommandot som svarar: prompten kommer på stdin och svaret är det som skrivs ut.", "connectApi": "Eller anropa API:t själv", "connectApiHint": "Varje anrop skickar nyckeln som Bearer-token. Hämta transkriptionen med markören som returneras." - } + }, + "passphraseFirst": "Ange en lösenfras innan du sparar nycklar. Det är samma lösenfras som för automatisk YouTube-sändning.", + "passphraseLegacy": "Dina sparade nycklar väntar på att flyttas under din egen lösenfras. Ange en för att fortsätta använda dem.", + "passphrase": "Lösenfras ({min}+ tecken)", + "passphraseRepeat": "Upprepa lösenfrasen", + "passphraseShort": "Använd minst {min} tecken.", + "passphraseMismatch": "Lösenfraserna skiljer sig åt.", + "passphraseSet": "Ange lösenfras", + "recoveryTitle": "Återställningsnyckel (visas en gång). Förvara den säkert:", + "recoverySaved": "Jag har sparat den" } } diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index 2955b622..83cb9e97 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -2401,6 +2401,15 @@ "unclear": "Takip mesajları net değil" }, "thin": "Döküm, bu görüşmeyi adil biçimde değerlendirmek için çok kısa.", + "ownKeys": { + "locked": "Kendi API anahtarlarınız kilitli. Bu toplantıda kullanmak için anahtar parolanızı girin.", + "lockedLive": "Kendi API anahtarlarınız kilitli, bu yüzden yardımcı ücretsiz modelleri kullanıyor. Geri dönmek için kilidi açın.", + "secret": "Anahtar parolası veya kurtarma anahtarı", + "unlock": "Anahtarların kilidini aç", + "note": "PairUX açılan anahtarı yalnızca bu toplantı için bellekte tutar ve yardımcı durunca siler.", + "legacy": "Kayıtlı anahtarlarınızın kullanılabilmesi için bir anahtar parolası gerekiyor. Anahtarlar ve kredi bölümünden belirleyin.", + "webOnly": "Kendi API anahtarlarınız kilitli. Kilidi açmak için yardımcıyı pairux.com üzerinde başlatın." + }, "fullReport": "Tüm notları aç", "restart": "Baştan başla", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Ödeme şu anda kullanılamıyor. Birazdan tekrar dene.", "defaultRole": "Varsayılan rol", "keysTitle": "Kendi API anahtarların", - "keysIntro": "Kendi anahtarlarını kullan, yapay zekâ için senden asla ücret almayalım. Anahtarlar şifrelenir, bir daha asla gösterilmez ve yalnızca yardımcı pilotun tarafından kullanılır.", - "keysDisabled": "Kendi anahtarların bu sunucuda henüz etkin değil.", + "keysIntro": "Kendi anahtarlarınızla yapay zekâ için asla ücret almayız. Anahtarlar yalnızca sizin bildiğiniz bir parolayla mühürlenir, bu yüzden PairUX onları açamaz; her toplantı için ayrı açarsınız. Parolayı ve kurtarma anahtarını kaybederseniz anahtarlarınızı yeniden girersiniz.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC yanıt veren komuttur: istem stdin üzerinden gelir, yanıt komutun yazdırdığıdır.", "connectApi": "Ya da API’yi kendiniz çağırın", "connectApiHint": "Her çağrı anahtarı Bearer belirteci olarak gönderir. Transkripti dönen imleçle yoklayın." - } + }, + "passphraseFirst": "Anahtar kaydetmeden önce bir anahtar parolası belirleyin. YouTube otomatik canlı yayın ile aynı paroladır.", + "passphraseLegacy": "Kayıtlı anahtarlarınız kendi parolanıza taşınmayı bekliyor. Kullanmaya devam etmek için bir parola belirleyin.", + "passphrase": "Anahtar parolası ({min}+ karakter)", + "passphraseRepeat": "Parolayı tekrarlayın", + "passphraseShort": "En az {min} karakter kullanın.", + "passphraseMismatch": "İki parola farklı.", + "passphraseSet": "Parolayı belirle", + "recoveryTitle": "Kurtarma anahtarı (bir kez gösterilir). Güvenli bir yerde saklayın:", + "recoverySaved": "Kaydettim" } } diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index b0f291b5..92bca5fc 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -2401,6 +2401,15 @@ "unclear": "Підсумки незрозумілі" }, "thin": "Розшифровка надто коротка, щоб об'єктивно оцінити цю розмову.", + "ownKeys": { + "locked": "Ваші API-ключі заблоковано. Введіть парольну фразу, щоб використати їх на цій зустрічі.", + "lockedLive": "Ваші API-ключі заблоковано, тому копілот працює на безкоштовних моделях. Розблокуйте їх, щоб повернутися.", + "secret": "Парольна фраза або ключ відновлення", + "unlock": "Розблокувати ключі", + "note": "PairUX тримає розблокований ключ у пам’яті лише для цієї зустрічі й видаляє його, коли копілот зупиняється.", + "legacy": "Збереженим ключам потрібна парольна фраза, перш ніж їх можна використати. Задайте її в розділі «Ключі та баланс».", + "webOnly": "Ваші API-ключі заблоковано. Запустіть копілот на pairux.com, щоб розблокувати їх." + }, "fullReport": "Відкрити повні нотатки", "restart": "Почати знову", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Оплата зараз недоступна. Спробуйте трохи згодом.", "defaultRole": "Роль за замовчуванням", "keysTitle": "Ваші власні API-ключі", - "keysIntro": "З власними ключами ми ніколи не стягуємо з вас плату за ШІ. Ключі шифруються, більше ніколи не показуються й використовуються лише вашим копілотом.", - "keysDisabled": "Власні ключі на цьому сервері ще не ввімкнено.", + "keysIntro": "Зі своїми ключами ми ніколи не беремо плату за ШІ. Ключі запечатано парольною фразою, яку знаєте лише ви, тож PairUX не може їх відкрити; ви розблоковуєте їх на одну зустріч. Якщо втратите фразу й ключ відновлення, введіть ключі знову.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC — команда, що відповідає: промпт надходить на stdin, відповідь — те, що вона виводить.", "connectApi": "Або викликайте API самі", "connectApiHint": "Кожен запит передає ключ як Bearer-токен. Запитуйте транскрипт із поверненим курсором." - } + }, + "passphraseFirst": "Задайте парольну фразу перед збереженням ключів. Це та сама фраза, що й для автоефіру на YouTube.", + "passphraseLegacy": "Збережені ключі чекають на перенесення під вашу парольну фразу. Задайте її, щоб і далі ними користуватися.", + "passphrase": "Парольна фраза ({min}+ символів)", + "passphraseRepeat": "Повторіть фразу", + "passphraseShort": "Використайте щонайменше {min} символів.", + "passphraseMismatch": "Фрази не збігаються.", + "passphraseSet": "Задати фразу", + "recoveryTitle": "Ключ відновлення (показується один раз). Збережіть його в надійному місці:", + "recoverySaved": "Я зберіг його" } } diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 4f5abb9c..49f10ae5 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -2401,6 +2401,15 @@ "unclear": "Nội dung theo dõi chưa rõ ràng" }, "thin": "Bản ghi quá ngắn để đánh giá cuộc trò chuyện này một cách công bằng.", + "ownKeys": { + "locked": "Khóa API của bạn đang bị khóa. Nhập cụm mật khẩu khóa để dùng chúng trong cuộc họp này.", + "lockedLive": "Khóa API của bạn đang bị khóa nên trợ lý dùng mô hình miễn phí. Mở khóa để chuyển lại.", + "secret": "Cụm mật khẩu hoặc khóa khôi phục", + "unlock": "Mở khóa", + "note": "PairUX giữ khóa đã mở trong bộ nhớ chỉ cho cuộc họp này và xóa khi trợ lý dừng.", + "legacy": "Các khóa đã lưu cần cụm mật khẩu trước khi dùng được. Đặt trong Khóa và tín dụng.", + "webOnly": "Khóa API của bạn đang bị khóa. Hãy bắt đầu trợ lý trên pairux.com để mở khóa." + }, "fullReport": "Mở toàn bộ ghi chú", "restart": "Bắt đầu lại", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "Hiện không thể thanh toán. Vui lòng thử lại sau ít phút.", "defaultRole": "Vai trò mặc định", "keysTitle": "Khóa API của riêng bạn", - "keysIntro": "Dùng khóa của riêng bạn thì chúng tôi không bao giờ tính phí AI. Khóa được mã hóa, không bao giờ hiển thị lại và chỉ trợ lý của bạn sử dụng.", - "keysDisabled": "Máy chủ này chưa bật tính năng dùng khóa riêng.", + "keysIntro": "Với khóa riêng, chúng tôi không bao giờ tính phí AI. Khóa được niêm phong bằng cụm mật khẩu chỉ bạn biết nên PairUX không thể mở; bạn mở khóa cho từng cuộc họp. Mất cụm mật khẩu và khóa khôi phục thì bạn nhập lại khóa.", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC là lệnh trả lời: prompt được đưa vào qua stdin, câu trả lời là những gì lệnh in ra.", "connectApi": "Hoặc tự gọi API", "connectApiHint": "Mỗi lệnh gọi gửi khóa dưới dạng token Bearer. Lấy bản chép lời bằng con trỏ được trả về." - } + }, + "passphraseFirst": "Đặt cụm mật khẩu trước khi lưu khóa. Đây là cùng cụm mật khẩu với tự động phát trực tiếp YouTube.", + "passphraseLegacy": "Các khóa đã lưu đang chờ chuyển sang cụm mật khẩu của bạn. Hãy đặt một cụm để tiếp tục dùng.", + "passphrase": "Cụm mật khẩu ({min}+ ký tự)", + "passphraseRepeat": "Nhập lại cụm mật khẩu", + "passphraseShort": "Dùng ít nhất {min} ký tự.", + "passphraseMismatch": "Hai cụm mật khẩu khác nhau.", + "passphraseSet": "Đặt cụm mật khẩu", + "recoveryTitle": "Khóa khôi phục (chỉ hiện một lần). Hãy cất ở nơi an toàn:", + "recoverySaved": "Tôi đã lưu" } } diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 3456384f..e9745cd8 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -2401,6 +2401,15 @@ "unclear": "跟进内容不清晰" }, "thin": "文字记录太短,无法公正地复盘这次对话。", + "ownKeys": { + "locked": "你自己的 API 密钥已锁定。输入密钥口令即可在本次会议中使用。", + "lockedLive": "你自己的 API 密钥已锁定,因此助手正在使用免费模型。解锁后即可切换回来。", + "secret": "密钥口令或恢复密钥", + "unlock": "解锁密钥", + "note": "PairUX 仅在本次会议期间把解锁的密钥保存在内存中,助手停止时即丢弃。", + "legacy": "已保存的密钥需要先设置密钥口令才能使用。请在“密钥和额度”中设置。", + "webOnly": "你自己的 API 密钥已锁定。请在 pairux.com 上启动助手以解锁。" + }, "fullReport": "打开完整笔记", "restart": "重新开始", "roles": { @@ -2454,8 +2463,7 @@ "checkoutUnavailable": "结账暂时不可用,请稍后再试。", "defaultRole": "默认角色", "keysTitle": "你自己的 API 密钥", - "keysIntro": "使用你自己的密钥,我们绝不会向你收取 AI 费用。密钥经过加密,不会再次显示,且仅供你的副驾驶使用。", - "keysDisabled": "此服务器尚未启用自有密钥。", + "keysIntro": "使用你自己的密钥,我们绝不收取 AI 费用。密钥用只有你知道的口令封存,PairUX 无法打开;你每次只为一场会议解锁。如果口令和恢复密钥都丢失,请重新输入密钥。", "providers": { "anthropic": "Anthropic", "openai": "OpenAI", @@ -2507,6 +2515,15 @@ "connectRunnerHint": "AGENT_EXEC 是负责回答的命令:提示词通过 stdin 传入,它打印的内容就是回复。", "connectApi": "或者自己调用 API", "connectApiHint": "每次调用都以 Bearer 令牌发送密钥。用返回的游标轮询转录。" - } + }, + "passphraseFirst": "保存密钥前请先设置密钥口令。它与 YouTube 自动直播使用同一口令。", + "passphraseLegacy": "你已保存的密钥正等待迁移到你自己的口令下。设置口令即可继续使用。", + "passphrase": "密钥口令(至少 {min} 个字符)", + "passphraseRepeat": "再次输入口令", + "passphraseShort": "请至少使用 {min} 个字符。", + "passphraseMismatch": "两次输入的口令不一致。", + "passphraseSet": "设置口令", + "recoveryTitle": "恢复密钥(仅显示一次)。请妥善保管:", + "recoverySaved": "我已保存" } } diff --git a/apps/web/src/app/api/copilot/settings/route.ts b/apps/web/src/app/api/copilot/settings/route.ts index d21e56e7..5d445bb9 100644 --- a/apps/web/src/app/api/copilot/settings/route.ts +++ b/apps/web/src/app/api/copilot/settings/route.ts @@ -7,6 +7,8 @@ import { type KeyProvider, } from '@/lib/copilot/account'; import { body, copilotError, runtimeOrThrow } from '@/lib/copilot/http'; +import { loadSealWith, migrateLegacyCopilotKeys } from '@/lib/copilot/keys'; +import { serviceClient } from '@/lib/supabase/service'; import { formatUsd } from '@/lib/copilot/pricing'; import { requestUser } from '@/lib/request-user'; @@ -20,6 +22,9 @@ export async function GET(request: Request) { const user = await requestUser(request); if (!user) return errorResponse('Sign in first.', 401); const runtime = runtimeOrThrow(); + // One-shot: keys still under the old server key move to the owner's seal. + const seal = await loadSealWith(serviceClient(), user.id); + if (seal) await migrateLegacyCopilotKeys(serviceClient(), user.id, seal.public_key); const [settings, eligible, balance] = await Promise.all([ runtime.accounts.publicSettings(user.id), runtime.accounts.eligible(user.id), @@ -38,7 +43,8 @@ export async function GET(request: Request) { } // PUT /api/copilot/settings — { mode?, role?, keys?: { anthropic?: string|null, ... } }. -// A key string replaces the stored key; null or "" deletes it. +// A key string replaces the stored key (sealed at once to the caller's key +// passphrase, 409 before one is set); null or "" deletes it. export async function PUT(request: Request) { try { const user = await requestUser(request); @@ -61,7 +67,6 @@ export async function PUT(request: Request) { } if (typeof value !== 'string' || !validKeyShape(provider, value.trim())) return errorResponse(`That does not look like a ${provider} API key.`, 400); - if (!byokAvailable()) return errorResponse('Own keys are not enabled on this server.', 503); keys[provider] = value.trim(); } const saved = await runtime.accounts.saveSettings(user.id, { diff --git a/apps/web/src/app/api/keys/lock/route.ts b/apps/web/src/app/api/keys/lock/route.ts index e6a7c686..85d9f702 100644 --- a/apps/web/src/app/api/keys/lock/route.ts +++ b/apps/web/src/app/api/keys/lock/route.ts @@ -1,11 +1,13 @@ import { NextResponse } from 'next/server'; import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; import { lockOwner } from '@/lib/sealed/unlockStore'; +import { lockAllCopilotKeys } from '@/lib/copilot/keys'; -/** POST /api/keys/lock: drop the caller's key from memory. */ +/** POST /api/keys/lock: drop the caller's key from memory (YouTube and every meeting copilot unlock). */ export async function POST() { const { user } = await getAuthenticatedUser(await createClient()); if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); lockOwner(user.id); + lockAllCopilotKeys(user.id); return NextResponse.json({ data: { ok: true } }); } diff --git a/apps/web/src/app/api/keys/setup/route.ts b/apps/web/src/app/api/keys/setup/route.ts index c56bfe95..d73a91f3 100644 --- a/apps/web/src/app/api/keys/setup/route.ts +++ b/apps/web/src/app/api/keys/setup/route.ts @@ -13,6 +13,11 @@ import { } from '@/lib/sealed/crypto'; import { unlockOwner } from '@/lib/sealed/unlockStore'; import { loadSeal } from '@/lib/sealed/vault'; +import { + dropSealedCopilotKeys, + lockAllCopilotKeys, + migrateLegacyCopilotKeys, +} from '@/lib/copilot/keys'; /** * POST /api/keys/setup { seal, dek, ttl_seconds?, reset? } @@ -34,10 +39,16 @@ export async function POST(request: Request) { try { if (!(await verifyDataKey(dek, body.seal, user.id))) return NextResponse.json({ error: 'seal does not match the key' }, { status: 400 }); - if ((await loadSeal(user.id)) && body.reset !== true) { + const existing = await loadSeal(user.id); + if (existing && body.reset !== true) { return NextResponse.json({ error: 'a key passphrase is already set' }, { status: 409 }); } const sb = serviceClient(); + if (existing) { + // A reset: copilot keys sealed to the old key can never open again. + lockAllCopilotKeys(user.id); + await dropSealedCopilotKeys(sb, user.id); + } await sb .from('user_key_seals' as never) .upsert({ user_id: user.id, seal: body.seal, updated_at: new Date().toISOString() } as never); @@ -65,6 +76,9 @@ export async function POST(request: Request) { .eq('user_id', user.id); } + // One-shot: copilot keys still under the old server key move to the new seal. + await migrateLegacyCopilotKeys(sb, user.id, body.seal.public_key); + unlockOwner( user.id, await openPrivateKey(dek, body.seal, user.id), diff --git a/apps/web/src/app/api/keys/unlock/route.ts b/apps/web/src/app/api/keys/unlock/route.ts index 8729a6be..4936d029 100644 --- a/apps/web/src/app/api/keys/unlock/route.ts +++ b/apps/web/src/app/api/keys/unlock/route.ts @@ -3,6 +3,8 @@ import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; import { base64ToBytes, clampUnlockTtlSeconds, openPrivateKey, wipe } from '@/lib/sealed/crypto'; import { unlockOwner } from '@/lib/sealed/unlockStore'; import { loadSeal } from '@/lib/sealed/vault'; +import { migrateLegacyCopilotKeys } from '@/lib/copilot/keys'; +import { serviceClient } from '@/lib/supabase/service'; /** POST /api/keys/unlock { dek, ttl_seconds? }: the data key, never the passphrase. Memory only. */ export async function POST(request: Request) { @@ -17,6 +19,12 @@ export async function POST(request: Request) { try { const key = await openPrivateKey(dek, seal, user.id); const { expiresAt } = unlockOwner(user.id, key, clampUnlockTtlSeconds(body.ttl_seconds)); + // One-shot: copilot keys still under the old server key move to this seal. + await migrateLegacyCopilotKeys(serviceClient(), user.id, seal.public_key).catch( + (error: unknown) => { + console.error('[keys] copilot key migration failed:', error); + } + ); return NextResponse.json({ data: { ok: true, expiresAt: new Date(expiresAt).toISOString() } }); } catch { return NextResponse.json({ error: 'wrong key' }, { status: 403 }); diff --git a/apps/web/src/app/api/sessions/[sessionId]/copilot/route.ts b/apps/web/src/app/api/sessions/[sessionId]/copilot/route.ts index 8ff24bbb..1453b275 100644 --- a/apps/web/src/app/api/sessions/[sessionId]/copilot/route.ts +++ b/apps/web/src/app/api/sessions/[sessionId]/copilot/route.ts @@ -2,6 +2,9 @@ import { isCopilotMode, isCopilotRole, type CopilotStatus } from '@pairux/meetin import { errorResponse, successResponse } from '@/lib/api'; import { authorizeCopilotCaller } from '@/lib/copilot/access'; import { COPILOT_REQUIRES_PRO } from '@/lib/copilot/account'; +import { loadSealWith, migrateLegacyCopilotKeys } from '@/lib/copilot/keys'; +import { serviceClient } from '@/lib/supabase/service'; +import { base64ToBytes, openPrivateKey, wipe } from '@/lib/sealed/crypto'; import { body, copilotError, runtimeOrThrow } from '@/lib/copilot/http'; import { copilotRuntime } from '@/lib/copilot/runtime'; import { upgradePath } from '@/lib/plans'; @@ -39,9 +42,10 @@ export async function GET(request: Request, { params }: RouteParams) { }; return successResponse(status); } - const [eligible, settings] = await Promise.all([ + const [eligible, settings, seal] = await Promise.all([ runtime.accounts.eligible(caller.userId), - runtime.accounts.settingsFor(caller.userId), + runtime.accounts.settingsFor(caller.userId, sessionId), + loadSealWith(serviceClient(), caller.userId), ]); // A copilot left live by a redeploy resumes as soon as its owner looks. const mine = @@ -55,6 +59,7 @@ export async function GET(request: Request, { params }: RouteParams) { mine, mode: settings.mode, role: settings.role, + ownKeys: { state: settings.keysState, userId: caller.userId, seal }, }; return successResponse(status); } catch (error) { @@ -62,6 +67,36 @@ export async function GET(request: Request, { params }: RouteParams) { } } +/** + * Unlock the caller's own keys for this meeting with the data key their + * browser derived from the key passphrase (never the passphrase itself). The + * opened private key stays in this process's memory until the copilot stops. + */ +async function unlockForMeeting( + runtime: NonNullable>, + sessionId: string, + userId: string, + dekB64: string, + ttlSeconds: unknown +): Promise { + const db = serviceClient(); + const seal = await loadSealWith(db, userId); + if (!seal) return errorResponse('Set a key passphrase in Settings first.', 409); + let dek: Uint8Array | null = null; + let privateKey: CryptoKey; + try { + dek = base64ToBytes(dekB64); + privateKey = await openPrivateKey(dek, seal, userId); + } catch { + return errorResponse('Wrong key passphrase.', 403); + } finally { + wipe(dek); + } + await migrateLegacyCopilotKeys(db, userId, seal.public_key); + await runtime.unlockKeys(sessionId, userId, privateKey, ttlSeconds); + return null; +} + async function recoverIfLive( runtime: NonNullable>, sessionId: string, @@ -75,7 +110,9 @@ async function recoverIfLive( } // POST /api/sessions/:id/copilot — start (or resume) the caller's copilot. -// Body: { participantId, role, mode }. +// Body: { participantId, role, mode, dek? }. `dek` (base64, derived in the +// browser from the key passphrase) unlocks the caller's own keys for this +// meeting only; without it a caller with sealed keys runs on free models. export async function POST(request: Request, { params }: RouteParams) { try { const { sessionId } = await params; @@ -89,7 +126,17 @@ export async function POST(request: Request, { params }: RouteParams) { if (caller.sessionStatus === 'ended') return errorResponse('This meeting has ended.', 400); if (!(await runtime.accounts.eligible(caller.userId))) return errorResponse(COPILOT_REQUIRES_PRO, 403); - const settings = await runtime.accounts.settingsFor(caller.userId); + if (typeof input.dek === 'string' && input.dek) { + const refused = await unlockForMeeting( + runtime, + sessionId, + caller.userId, + input.dek, + input.ttl_seconds + ); + if (refused) return refused; + } + const settings = await runtime.accounts.settingsFor(caller.userId, sessionId); const role = isCopilotRole(input.role) ? input.role : settings.role; const mode = isCopilotMode(input.mode) ? input.mode : settings.mode; // Remember the choice for next time (and for the CLI). diff --git a/apps/web/src/app/settings/copilot/copilot-settings.tsx b/apps/web/src/app/settings/copilot/copilot-settings.tsx index 900baaf6..eb63c039 100644 --- a/apps/web/src/app/settings/copilot/copilot-settings.tsx +++ b/apps/web/src/app/settings/copilot/copilot-settings.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react'; import Link from 'next/link'; import { useLocale, useTranslations } from 'next-intl'; import { Bot, KeyRound, Loader2, Users, Wallet } from 'lucide-react'; +import { bytesToBase64, createSeal, wipe, MIN_PASSPHRASE_LENGTH } from '@/lib/sealed/crypto'; import { COPILOT_MODES, COPILOT_ROLES, @@ -19,7 +20,8 @@ interface Settings { role: CopilotRole; keys: Record; eligible: boolean; - byokAvailable: boolean; + passphraseSet: boolean; + legacyKeys: number; balance: string; } interface WalletInfo { @@ -91,6 +93,43 @@ export function CopilotSettings({ header }: { header: ReactNode }) { } }; + // The key passphrase: everything here runs in the browser; the server gets + // the seal (ciphertext + public key) and the derived data key, never the passphrase. + const [pass, setPass] = useState(''); + const [pass2, setPass2] = useState(''); + const [recoveryKey, setRecoveryKey] = useState(''); + const setPassphrase = async () => { + setBusy(true); + setError(''); + setMessage(''); + try { + if (pass.length < MIN_PASSPHRASE_LENGTH) + throw new Error(t('passphraseShort', { min: MIN_PASSPHRASE_LENGTH })); + if (pass !== pass2) throw new Error(t('passphraseMismatch')); + const me = await fetch('/api/keys'); + const { data } = (await me.json()) as { data?: { userId: string } }; + if (!me.ok || !data) throw new Error(`Request failed (${String(me.status)})`); + const made = await createSeal(pass, data.userId); + const dek = bytesToBase64(made.dek); + wipe(made.dek); + const res = await fetch('/api/keys/setup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ seal: made.seal, dek, ttl_seconds: 3600 }), + }); + const body = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) throw new Error(body.error ?? `Request failed (${String(res.status)})`); + setRecoveryKey(made.recoveryKey); + setPass(''); + setPass2(''); + await load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setBusy(false); + } + }; + const topup = async (id: string) => { setBusy(true); setError(''); @@ -189,8 +228,57 @@ export function CopilotSettings({ header }: { header: ReactNode }) { {t('keysTitle')}

{t('keysIntro')}

- {!settings.byokAvailable && ( -

{t('keysDisabled')}

+ {recoveryKey && ( +
+

{t('recoveryTitle')}

+ {recoveryKey} + +
+ )} + {!settings.passphraseSet && ( +
+

+ {settings.legacyKeys > 0 ? t('passphraseLegacy') : t('passphraseFirst')} +

+ { + setPass(event.target.value); + }} + className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm" + /> + { + setPass2(event.target.value); + }} + className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm" + /> + +
)}
{PROVIDERS.map((provider) => ( @@ -207,7 +295,7 @@ export function CopilotSettings({ header }: { header: ReactNode }) { type="password" autoComplete="off" value={drafts[provider]} - disabled={busy || !settings.byokAvailable} + disabled={busy || !settings.passphraseSet} onChange={(event) => { setDrafts((all) => ({ ...all, [provider]: event.target.value })); }} diff --git a/apps/web/src/components/copilot/MeetingCopilot.tsx b/apps/web/src/components/copilot/MeetingCopilot.tsx index bba40571..cff2cee8 100644 --- a/apps/web/src/components/copilot/MeetingCopilot.tsx +++ b/apps/web/src/components/copilot/MeetingCopilot.tsx @@ -7,11 +7,16 @@ import { useMeetingAgents, useMeetingCopilot, type CopilotT, + type MeetingCopilotEnvironment, } from '@pairux/meeting-copilot'; +import { dataKeyFromSecret } from '@/lib/sealed/unlock-secret'; + +/** Same origin, cookie auth; own keys unlock in this browser (only the data key is sent). */ +const WEB: MeetingCopilotEnvironment = { unlockKeys: dataKeyFromSecret }; /** The AI copilot and your agents for a meeting page (same origin, cookie auth). */ export function MeetingCopilot({ sessionId, selfId }: { sessionId: string; selfId: string }) { - const copilot = useMeetingCopilot({ sessionId, selfId, env: {} }); + const copilot = useMeetingCopilot({ sessionId, selfId, env: WEB }); const agents = useMeetingAgents({ sessionId, selfId, env: {} }); const intl = useTranslations('copilot'); const t = useCallback((key, values) => intl(key, values), [intl]); diff --git a/apps/web/src/lib/copilot/account.ts b/apps/web/src/lib/copilot/account.ts index b222c72c..9bf1a6d8 100644 --- a/apps/web/src/lib/copilot/account.ts +++ b/apps/web/src/lib/copilot/account.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import { isCopilotMode, isCopilotRole, @@ -9,6 +8,14 @@ import { import { serviceClient } from '@/lib/supabase/service'; import { resolveUserPlan, type Db } from '@/lib/orgs'; import { PLANS, upgradePath } from '@/lib/plans'; +import { isSealedValue, openWithPrivateKey, sealToPublicKey } from '@/lib/sealed/crypto'; +import { + COPILOT_SEAL_VERSION, + copilotPrivateKey, + isLegacyValue, + keyField, + loadSealWith, +} from './keys'; /** Plans that include the copilot (own plan or an org's). */ const COPILOT_PLANS = new Set(['pro', 'team']); @@ -20,56 +27,45 @@ export type KeyProvider = (typeof KEY_PROVIDERS)[number]; export const isKeyProvider = (value: unknown): value is KeyProvider => typeof value === 'string' && (KEY_PROVIDERS as readonly string[]).includes(value); -/** The owner's own keys, decrypted, for this process only. */ +/** The owner's own keys, opened for one meeting while it is unlocked. */ export type CopilotKeys = Partial>; +/** + * Where the owner's own keys stand for one meeting: + * none no own keys saved + * unlocked sealed keys, unlocked for this meeting (opened in `keys`) + * locked sealed keys, not unlocked: free models only until they unlock + * legacy keys still under the old server key: set a key passphrase to move them + */ +export type OwnKeysState = 'none' | 'unlocked' | 'locked' | 'legacy'; + export interface CopilotSettings { mode: CopilotMode; role: CopilotRole; keys: CopilotKeys; + keysState: OwnKeysState; } /** What the API returns: never key material, only whether a key is set and its last 4. */ export interface PublicCopilotSettings { mode: CopilotMode; role: CopilotRole; keys: Record; + /** Has the owner set a key passphrase (needed to save or use own keys)? */ + passphraseSet: boolean; + /** Keys still under the old server key, waiting for the owner's passphrase. */ + legacyKeys: number; } -// --- BYOK encryption (AES-256-GCM, key derived from COPILOT_KEYS_SECRET) --- +// --- BYOK: sealed to the owner (lib/copilot/keys.ts); no server key opens them --- -function secretKey(): Buffer { - const secret = process.env.COPILOT_KEYS_SECRET ?? ''; - if (secret.length < 32) - throw new Error('COPILOT_KEYS_SECRET is not set (32+ characters); own keys are disabled.'); - return createHash('sha256').update(`pairux-copilot-keys:${secret}`).digest(); -} -export const byokAvailable = (): boolean => (process.env.COPILOT_KEYS_SECRET ?? '').length >= 32; +/** Own keys need no server secret any more: they are sealed to the owner's key passphrase. */ +export const byokAvailable = (): boolean => true; -/** The full 128-bit GCM tag; a shorter one is refused (truncated tags enable forgeries). */ -const TAG_BYTES = 16; - -/** v1..., base64url. */ -export function sealKey(plain: string, key: Buffer = secretKey()): string { - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES }); - const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); - return ['v1', iv, cipher.getAuthTag(), body] - .map((part) => (typeof part === 'string' ? part : part.toString('base64url'))) - .join('.'); -} -export function openKey(sealed: string, key: Buffer = secretKey()): string { - const [version, iv, tag, body] = sealed.split('.'); - if (version !== 'v1' || !iv || !tag || !body) throw new Error('Unreadable stored key.'); - const authTag = Buffer.from(tag, 'base64url'); - if (authTag.length !== TAG_BYTES) throw new Error('Unreadable stored key.'); - const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'), { - authTagLength: TAG_BYTES, - }); - decipher.setAuthTag(authTag); - return Buffer.concat([ - decipher.update(Buffer.from(body, 'base64url')), - decipher.final(), - ]).toString('utf8'); +/** Saving a key before the owner has set a key passphrase. */ +export class KeyPassphraseRequiredError extends Error { + constructor() { + super('Set a key passphrase first: your keys are sealed under it and PairUX cannot open them.'); + } } /** A plausible key for each provider: rejects pasted junk before it is stored. */ @@ -118,34 +114,58 @@ export class CopilotAccounts { return pro; } - async settingsFor(userId: string): Promise { - const cached = this.settings.get(userId); + /** + * Mode, role and, for a meeting the owner unlocked, their opened own keys. + * Without `sessionId` (or while locked) no key is opened. + */ + async settingsFor(userId: string, sessionId = ''): Promise { + const cacheKey = `${userId}:${sessionId}`; + const cached = this.settings.get(cacheKey); if (cached && cached.until > Date.now()) return cached.value; const { data } = await this.db .from('copilot_settings') .select('mode, role, anthropic_key, openai_key, elevenlabs_key') .eq('user_id', userId) .maybeSingle(); + const privateKey = copilotPrivateKey(userId, sessionId); const keys: CopilotKeys = {}; + let sealed = 0; + let legacy = 0; for (const provider of KEY_PROVIDERS) { - const sealed = data?.[`${provider}_key`]; - if (typeof sealed === 'string' && sealed && byokAvailable()) { - try { - keys[provider] = openKey(sealed); - } catch (error) { - console.error(`[copilot] stored ${provider} key could not be opened:`, error); - } + const stored: unknown = data?.[`${provider}_key`]; + if (isLegacyValue(stored)) legacy++; + if (!isSealedValue(stored)) continue; + sealed++; + if (!privateKey) continue; + try { + keys[provider] = await openWithPrivateKey(privateKey, stored, userId, keyField(provider)); + } catch { + console.error(`[copilot] stored ${provider} key could not be opened`); } } + const keysState: OwnKeysState = Object.keys(keys).length + ? 'unlocked' + : sealed + ? 'locked' + : legacy + ? 'legacy' + : 'none'; const value: CopilotSettings = { mode: isCopilotMode(data?.mode) ? data.mode : 'auto', role: isCopilotRole(data?.role) ? data.role : 'general', keys, + keysState, }; - this.settings.set(userId, { value, until: Date.now() + 30_000 }); + this.settings.set(cacheKey, { value, until: Date.now() + 30_000 }); return value; } + /** Drop cached settings (and any opened keys in them) for an owner. */ + forget(userId: string): void { + for (const key of [...this.settings.keys()]) + if (key.startsWith(`${userId}:`)) this.settings.delete(key); + } + async publicSettings(userId: string): Promise { const { data } = await this.db .from('copilot_settings') @@ -155,15 +175,20 @@ export class CopilotAccounts { .eq('user_id', userId) .maybeSingle(); const keys = {} as PublicCopilotSettings['keys']; - for (const provider of KEY_PROVIDERS) + let legacyKeys = 0; + for (const provider of KEY_PROVIDERS) { keys[provider] = { set: Boolean(data?.[`${provider}_key`]), last4: data?.[`${provider}_last4`] ?? null, }; + if (isLegacyValue(data?.[`${provider}_key`])) legacyKeys++; + } return { mode: isCopilotMode(data?.mode) ? data.mode : 'auto', role: isCopilotRole(data?.role) ? data.role : 'general', keys, + passphraseSet: Boolean(await loadSealWith(this.db, userId)), + legacyKeys, }; } @@ -179,20 +204,30 @@ export class CopilotAccounts { const row: Record = { user_id: userId, updated_at: new Date().toISOString() }; if (patch.mode) row.mode = patch.mode; if (patch.role) row.role = patch.role; + const adding = KEY_PROVIDERS.some((provider) => Boolean(patch.keys?.[provider])); + // A new key is sealed to the owner's public key right here; we keep no way to open it. + const seal = adding ? await loadSealWith(this.db, userId) : null; + if (adding && !seal) throw new KeyPassphraseRequiredError(); for (const provider of KEY_PROVIDERS) { const value = patch.keys?.[provider]; if (value === undefined) continue; if (value === null || value === '') { row[`${provider}_key`] = null; row[`${provider}_last4`] = null; - } else { - row[`${provider}_key`] = sealKey(value); + } else if (seal) { + row[`${provider}_key`] = await sealToPublicKey( + seal.public_key, + value, + userId, + keyField(provider) + ); row[`${provider}_last4`] = value.slice(-4); + row.seal_version = COPILOT_SEAL_VERSION; } } const { error } = await this.db.from('copilot_settings').upsert(row, { onConflict: 'user_id' }); if (error) throw new Error(`Saving copilot settings failed: ${String(error.message)}`); - this.settings.delete(userId); + this.forget(userId); return this.publicSettings(userId); } diff --git a/apps/web/src/lib/copilot/copilot.test.ts b/apps/web/src/lib/copilot/copilot.test.ts index 51bc1bab..502a8606 100644 --- a/apps/web/src/lib/copilot/copilot.test.ts +++ b/apps/web/src/lib/copilot/copilot.test.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'node:crypto'; +import { createCipheriv, randomBytes } from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod/v4'; import { encodeWav } from '@profullstack/live-translate'; @@ -6,7 +6,8 @@ import { COPILOT_STRINGS_EN } from '@pairux/meeting-copilot/strings'; import type { TranscriptLine } from '@pairux/meeting-copilot/types'; import en from '../../../messages/en.json'; import { isThin, lastSeconds, mergeUpdate, placeholder, talkListen, THIN_WORDS } from './analysis'; -import { openKey, sealKey, validKeyShape, type CopilotAccounts } from './account'; +import { validKeyShape, type CopilotAccounts } from './account'; +import { openLegacyKey } from './keys'; import { CopilotLlm, CopilotLlmError, boundSchema, extractJson } from './llm'; import { chargeMicros, claudeCostMicros, scribeCostMicros } from './pricing'; import { Cooldowns, providerChain, type ProviderContext } from './providers'; @@ -263,8 +264,20 @@ describe('talk/listen and thin transcripts', () => { }); }); -describe('BYOK key storage', () => { +/** A v1 blob as the old server-key code wrote it (only the migration reads these now). */ +function legacySeal(plain: string, key: Buffer): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }); + const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return ['v1', ...[iv, cipher.getAuthTag(), body].map((part) => part.toString('base64url'))].join( + '.' + ); +} + +describe('BYOK legacy (v1) blobs', () => { const key = randomBytes(32); + const sealKey = legacySeal; + const openKey = openLegacyKey; it('round-trips and refuses a tampered ciphertext', () => { const sealed = sealKey('sk-ant-secret-value-1234', key); expect(sealed).not.toContain('secret'); diff --git a/apps/web/src/lib/copilot/http.ts b/apps/web/src/lib/copilot/http.ts index e38ef7ce..c1251e8a 100644 --- a/apps/web/src/lib/copilot/http.ts +++ b/apps/web/src/lib/copilot/http.ts @@ -1,3 +1,4 @@ +import { KeyPassphraseRequiredError } from './account'; import { errorResponse, handleApiError } from '@/lib/api'; import { CopilotAccessError, authorizeCopilotCaller } from './access'; import { CopilotAgent } from './agent'; @@ -7,6 +8,7 @@ import { CopilotBusyError, copilotRuntime, type CopilotRuntime } from './runtime /** Every copilot route answers its own errors the same way. */ export function copilotError(error: unknown) { if (error instanceof CopilotAccessError) return errorResponse(error.message, error.status); + if (error instanceof KeyPassphraseRequiredError) return errorResponse(error.message, 409); if (error instanceof CopilotBusyError) return errorResponse(error.message, 503); if (error instanceof CopilotLlmError) return errorResponse(error.message, 503); return handleApiError(error); diff --git a/apps/web/src/lib/copilot/keys.test.ts b/apps/web/src/lib/copilot/keys.test.ts new file mode 100644 index 00000000..c764b6f1 --- /dev/null +++ b/apps/web/src/lib/copilot/keys.test.ts @@ -0,0 +1,256 @@ +import { createCipheriv, createHash, randomBytes } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSeal, isSealedValue, openPrivateKey } from '@/lib/sealed/crypto'; +import { clearAllUnlocks, isUnlocked, lockOwner } from '@/lib/sealed/unlockStore'; +import { CopilotAccounts, KeyPassphraseRequiredError } from './account'; +import { + copilotPrivateKey, + dropSealedCopilotKeys, + lockAllCopilotKeys, + lockCopilotKeys, + migrateLegacyCopilotKeys, + unlockCopilotKeys, +} from './keys'; +import { CopilotLlm } from './llm'; + +vi.mock('@/lib/supabase/service', () => ({ serviceClient: () => ({}) })); + +/** Just enough of the Supabase client for copilot_settings + user_key_seals. */ +function fakeDb() { + type Table = Map>; + const tables = { + copilot_settings: new Map() as Table, + user_key_seals: new Map() as Table, + }; + const other = new Map(); + const from = (name: string) => { + const table: Table = + name === 'copilot_settings' || name === 'user_key_seals' + ? tables[name] + : (other.get(name) ?? other.set(name, new Map()).get(name) ?? new Map()); + let filterUser = ''; + let patch: Record | null = null; + const api = { + select: () => api, + eq: (_col: string, value: string) => { + filterUser = value; + if (patch) { + const row = table.get(filterUser); + if (row) Object.assign(row, patch); + return Promise.resolve({ error: null }); + } + return api; + }, + maybeSingle: () => Promise.resolve({ data: table.get(filterUser) ?? null }), + upsert: (row: Record) => { + const id = row.user_id as string; + table.set(id, { ...(table.get(id) ?? {}), ...row }); + return Promise.resolve({ error: null }); + }, + update: (next: Record) => { + patch = next; + return api; + }, + }; + return api; + }; + return { from, tables }; +} + +const USER = '11111111-1111-4111-8111-111111111111'; +const SESSION = 'sess-1'; +const ANTHROPIC = 'sk-ant-api03-abcdefghijklmnopqrstuv'; + +function legacyKey(secret: string): Buffer { + return createHash('sha256').update(`pairux-copilot-keys:${secret}`).digest(); +} +function legacySeal(plain: string, key: Buffer): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }); + const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return ['v1', ...[iv, cipher.getAuthTag(), body].map((part) => part.toString('base64url'))].join( + '.' + ); +} + +async function withSeal(db: ReturnType) { + const made = await createSeal('correct horse battery', USER); + db.tables.user_key_seals.set(USER, { user_id: USER, seal: made.seal }); + const privateKey = await openPrivateKey(made.dek, made.seal, USER); + return { seal: made.seal, privateKey }; +} + +describe('copilot own keys, sealed to the owner', () => { + const env = { ...process.env }; + beforeEach(() => { + clearAllUnlocks(); + }); + afterEach(() => { + process.env = { ...env }; + clearAllUnlocks(); + }); + + it('refuses to save a key before a key passphrase is set', async () => { + const db = fakeDb(); + const accounts = new CopilotAccounts(db); + await expect( + accounts.saveSettings(USER, { keys: { anthropic: ANTHROPIC } }) + ).rejects.toBeInstanceOf(KeyPassphraseRequiredError); + // Mode and role still save without one. + await expect(accounts.saveSettings(USER, { mode: 'byok' })).resolves.toBeTruthy(); + }); + + it('stores only a v2 value and opens it only for the unlocked meeting', async () => { + const db = fakeDb(); + const { privateKey } = await withSeal(db); + const accounts = new CopilotAccounts(db); + const pub = await accounts.saveSettings(USER, { keys: { anthropic: ANTHROPIC } }); + expect(pub.keys.anthropic).toEqual({ set: true, last4: 'stuv' }); + expect(pub.passphraseSet).toBe(true); + const row = db.tables.copilot_settings.get(USER) ?? {}; + expect(isSealedValue(row.anthropic_key)).toBe(true); + expect(JSON.stringify(row)).not.toContain(ANTHROPIC); + expect(row.seal_version).toBe(2); + + // Locked: nothing opens, and it says so. + const locked = await accounts.settingsFor(USER, SESSION); + expect(locked.keys).toEqual({}); + expect(locked.keysState).toBe('locked'); + + unlockCopilotKeys(USER, SESSION, privateKey); + accounts.forget(USER); + const open = await accounts.settingsFor(USER, SESSION); + expect(open.keys.anthropic).toBe(ANTHROPIC); + expect(open.keysState).toBe('unlocked'); + + // Another meeting, or no meeting, stays locked. + expect((await accounts.settingsFor(USER, 'other')).keys).toEqual({}); + expect((await accounts.settingsFor(USER)).keys).toEqual({}); + }); + + it('a value copied to another user or field does not open', async () => { + const db = fakeDb(); + const { privateKey } = await withSeal(db); + const accounts = new CopilotAccounts(db); + await accounts.saveSettings(USER, { keys: { anthropic: ANTHROPIC } }); + const row = db.tables.copilot_settings.get(USER) ?? {}; + row.openai_key = row.anthropic_key; // wrong field (AAD) + unlockCopilotKeys(USER, SESSION, privateKey); + const settings = await accounts.settingsFor(USER, SESSION); + expect(settings.keys.anthropic).toBe(ANTHROPIC); + expect(settings.keys.openai).toBeUndefined(); + }); + + it('meeting unlocks expire, lock, and vanish on restart', async () => { + const db = fakeDb(); + const { privateKey } = await withSeal(db); + const { expiresAt } = unlockCopilotKeys(USER, SESSION, privateKey, 60); + expect(expiresAt - Date.now()).toBeLessThanOrEqual(60_000); + expect(copilotPrivateKey(USER, SESSION)).not.toBeNull(); + lockCopilotKeys(USER, SESSION); + expect(copilotPrivateKey(USER, SESSION)).toBeNull(); + + unlockCopilotKeys(USER, 'a', privateKey); + unlockCopilotKeys(USER, 'b', privateKey); + unlockCopilotKeys('someone-else', 'a', privateKey); + expect(lockAllCopilotKeys(USER)).toBe(2); + expect(copilotPrivateKey('someone-else', 'a')).not.toBeNull(); + + clearAllUnlocks(); // a restart + expect(copilotPrivateKey('someone-else', 'a')).toBeNull(); + + vi.useFakeTimers(); + try { + unlockCopilotKeys(USER, SESSION, privateKey, 60); + vi.advanceTimersByTime(61_000); + expect(copilotPrivateKey(USER, SESSION)).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('a meeting unlock is separate from the YouTube (owner) unlock', async () => { + const db = fakeDb(); + const { privateKey } = await withSeal(db); + unlockCopilotKeys(USER, SESSION, privateKey); + expect(isUnlocked(USER)).toBe(false); + lockOwner(USER); + expect(copilotPrivateKey(USER, SESSION)).not.toBeNull(); + }); + + it('never uses a legacy server-key value, even with COPILOT_KEYS_SECRET set', async () => { + const secret = 'x'.repeat(40); + process.env.COPILOT_KEYS_SECRET = secret; + const db = fakeDb(); + db.tables.copilot_settings.set(USER, { + user_id: USER, + mode: 'cloud', + role: 'general', + anthropic_key: legacySeal(ANTHROPIC, legacyKey(secret)), + }); + const accounts = new CopilotAccounts(db); + const settings = await accounts.settingsFor(USER, SESSION); + expect(settings.keys).toEqual({}); + expect(settings.keysState).toBe('legacy'); + expect((await accounts.publicSettings(USER)).legacyKeys).toBe(1); + }); + + it('migrates legacy values once to the owner seal, in place', async () => { + const secret = 'y'.repeat(40); + const db = fakeDb(); + const { seal, privateKey } = await withSeal(db); + db.tables.copilot_settings.set(USER, { + user_id: USER, + anthropic_key: legacySeal(ANTHROPIC, legacyKey(secret)), + openai_key: 'v1.garbage.garbage.garbage', + }); + const moved = await migrateLegacyCopilotKeys(db, USER, seal.public_key, legacyKey(secret)); + expect(moved).toBe(1); + const row = db.tables.copilot_settings.get(USER) ?? {}; + expect(isSealedValue(row.anthropic_key)).toBe(true); + // Unreadable legacy data is kept for review, never deleted; the row is not marked v2. + expect(row.openai_key).toBe('v1.garbage.garbage.garbage'); + expect(row.seal_version).toBeUndefined(); + // Running again moves nothing. + expect(await migrateLegacyCopilotKeys(db, USER, seal.public_key, legacyKey(secret))).toBe(0); + + unlockCopilotKeys(USER, SESSION, privateKey); + const accounts = new CopilotAccounts(db); + expect((await accounts.settingsFor(USER, SESSION)).keys.anthropic).toBe(ANTHROPIC); + }); + + it('LEGACY_KEY_MIGRATION=off is the cutoff', async () => { + process.env.LEGACY_KEY_MIGRATION = 'off'; + const secret = 'z'.repeat(40); + const db = fakeDb(); + const { seal } = await withSeal(db); + const legacy = legacySeal(ANTHROPIC, legacyKey(secret)); + db.tables.copilot_settings.set(USER, { user_id: USER, anthropic_key: legacy }); + expect(await migrateLegacyCopilotKeys(db, USER, seal.public_key, legacyKey(secret))).toBe(0); + expect(db.tables.copilot_settings.get(USER)?.anthropic_key).toBe(legacy); + }); + + it('a passphrase reset clears keys sealed to the old key', async () => { + const db = fakeDb(); + await withSeal(db); + const accounts = new CopilotAccounts(db); + await accounts.saveSettings(USER, { keys: { anthropic: ANTHROPIC } }); + await dropSealedCopilotKeys(db, USER); + const row = db.tables.copilot_settings.get(USER) ?? {}; + expect(row.anthropic_key).toBeNull(); + expect(row.anthropic_last4).toBeNull(); + }); + + it('locked own keys never fall through to our paid provider', () => { + process.env.ANTHROPIC_API_KEY = 'ours'; + const llm = new CopilotLlm({} as CopilotAccounts); + const ctx = { userId: USER, sessionId: SESSION, keys: {}, admin: false }; + expect(llm.steps({ ...ctx, mode: 'cloud' }).some((s) => s.kind === 'ours')).toBe(true); + expect( + llm.steps({ ...ctx, mode: 'cloud', keysLocked: true }).some((s) => s.kind === 'ours') + ).toBe(false); + expect( + llm.steps({ ...ctx, mode: 'auto', keysLocked: true }).some((s) => s.kind === 'ours') + ).toBe(false); + }); +}); diff --git a/apps/web/src/lib/copilot/keys.ts b/apps/web/src/lib/copilot/keys.ts new file mode 100644 index 00000000..7631c4e6 --- /dev/null +++ b/apps/web/src/lib/copilot/keys.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ +/** + * The copilot's own-key (BYOK) sealing, on the seal v2 scheme in lib/sealed + * (the same key passphrase and seal as the YouTube token). + * + * Each stored key is sealed to the owner's PUBLIC key, so saving needs no + * unlock. Opening needs the owner's private key, which the server holds only + * while a meeting's copilot runs: the browser derives the data key from the + * passphrase, sends it with Start, and the opened private key is kept in + * process memory under `copilot::` with a TTL. Stopping the + * copilot, the meeting ending, Lock, or a restart drops it; the copilot then + * runs on the free models ("locked: unlock to resume") and never charges the + * wallet in place of the owner's keys. + * + * Legacy rows (v1, AES-GCM under the server env key COPILOT_KEYS_SECRET) are + * never used for provider calls. They are read once, by the logged-in owner's + * own request, re-sealed to their public key and overwritten in the same + * update (migrateLegacyCopilotKeys). LEGACY_KEY_MIGRATION=off is the cutoff. + */ +import { createDecipheriv, createHash } from 'node:crypto'; +import { + clampUnlockTtlSeconds, + isSealRecord, + isSealedValue, + sealToPublicKey, + type SealRecord, +} from '@/lib/sealed/crypto'; +import { + lockOwner, + lockOwnersWithPrefix, + privateKeyFor, + unlockOwner, +} from '@/lib/sealed/unlockStore'; + +export const COPILOT_PROVIDERS = ['anthropic', 'openai', 'elevenlabs'] as const; +export type CopilotKeyProvider = (typeof COPILOT_PROVIDERS)[number]; + +/** seal_version of a copilot_settings row whose keys are sealed to the owner (v2). */ +export const COPILOT_SEAL_VERSION = 2; + +/** The AAD field each key is bound to. */ +export const keyField = (provider: CopilotKeyProvider): string => `copilot_${provider}_key`; + +/** Is a stored value an old server-key blob (v1...)? */ +export const isLegacyValue = (value: unknown): value is string => + typeof value === 'string' && value.startsWith('v1.'); + +export const legacyMigrationOn = (): boolean => + (process.env.LEGACY_KEY_MIGRATION ?? '').trim().toLowerCase() !== 'off'; + +// --- The per-meeting unlock ------------------------------------------------- + +const unlockId = (userId: string, sessionId: string) => `copilot:${userId}:${sessionId}`; + +/** Hold the owner's private key for one meeting (memory only, TTL default 8h, max 7d). */ +export function unlockCopilotKeys( + userId: string, + sessionId: string, + privateKey: CryptoKey, + ttlSeconds?: unknown +): { expiresAt: number } { + return unlockOwner(unlockId(userId, sessionId), privateKey, clampUnlockTtlSeconds(ttlSeconds)); +} + +export function copilotPrivateKey(userId: string, sessionId: string): CryptoKey | null { + if (!sessionId) return null; + return privateKeyFor(unlockId(userId, sessionId)); +} + +/** Drop one meeting's unlock (copilot stopped, meeting ended). */ +export function lockCopilotKeys(userId: string, sessionId: string): void { + lockOwner(unlockId(userId, sessionId)); +} + +/** Drop every meeting unlock of one owner (Lock, passphrase reset). */ +export function lockAllCopilotKeys(userId: string): number { + return lockOwnersWithPrefix(`copilot:${userId}:`); +} + +// --- Seal record ------------------------------------------------------------ + +export async function loadSealWith(db: any, userId: string): Promise { + const { data } = await db + .from('user_key_seals') + .select('seal') + .eq('user_id', userId) + .maybeSingle(); + const seal = data?.seal; + return isSealRecord(seal) ? seal : null; +} + +// --- Legacy v1 (read-only, migration only) --------------------------------- + +const TAG_BYTES = 16; + +function legacySecretKey(): Buffer | null { + const secret = process.env.COPILOT_KEYS_SECRET ?? ''; + if (secret.length < 32) return null; + return createHash('sha256').update(`pairux-copilot-keys:${secret}`).digest(); +} + +/** Open a v1 blob. Only migrateLegacyCopilotKeys calls this; it never feeds a provider. */ +export function openLegacyKey(sealed: string, key: Buffer): string { + const [version, iv, tag, body] = sealed.split('.'); + if (version !== 'v1' || !iv || !tag || !body) throw new Error('Unreadable stored key.'); + const authTag = Buffer.from(tag, 'base64url'); + if (authTag.length !== TAG_BYTES) throw new Error('Unreadable stored key.'); + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'), { + authTagLength: TAG_BYTES, + }); + decipher.setAuthTag(authTag); + return Buffer.concat([ + decipher.update(Buffer.from(body, 'base64url')), + decipher.final(), + ]).toString('utf8'); +} + +/** + * One-shot: re-seal this owner's v1 keys to their public key and overwrite the + * legacy ciphertext in the same update. Runs only in the owner's own request + * (key passphrase setup / unlock, copilot settings, copilot start). Returns the + * number of keys migrated; 0 when the switch is off or nothing is legacy. + * A v1 value that cannot be opened is left as is (reported, never deleted). + */ +export async function migrateLegacyCopilotKeys( + db: any, + userId: string, + publicKey: string, + legacyKey: Buffer | null = legacySecretKey() +): Promise { + if (!legacyMigrationOn() || !legacyKey) return 0; + const { data } = await db + .from('copilot_settings') + .select('anthropic_key, openai_key, elevenlabs_key') + .eq('user_id', userId) + .maybeSingle(); + if (!data) return 0; + const patch: Record = {}; + let moved = 0; + for (const provider of COPILOT_PROVIDERS) { + const stored = data[`${provider}_key`]; + if (!isLegacyValue(stored)) continue; + let plain: string; + try { + plain = openLegacyKey(stored, legacyKey); + } catch { + console.error(`[copilot] legacy ${provider} key could not be opened; left for review`); + continue; + } + patch[`${provider}_key`] = await sealToPublicKey(publicKey, plain, userId, keyField(provider)); + moved++; + } + if (!moved) return 0; + const left = COPILOT_PROVIDERS.some( + (provider) => isLegacyValue(data[`${provider}_key`]) && !patch[`${provider}_key`] + ); + if (!left) patch.seal_version = COPILOT_SEAL_VERSION; + patch.updated_at = new Date().toISOString(); + const { error } = await db.from('copilot_settings').update(patch).eq('user_id', userId); + if (error) throw new Error(`Migrating copilot keys failed: ${String(error.message)}`); + return moved; +} + +/** + * After a passphrase reset the old private key is gone, so keys sealed to it + * can never open: clear them (the owner re-enters them). + */ +export async function dropSealedCopilotKeys(db: any, userId: string): Promise { + const { data } = await db + .from('copilot_settings') + .select('anthropic_key, openai_key, elevenlabs_key') + .eq('user_id', userId) + .maybeSingle(); + if (!data) return; + const patch: Record = {}; + for (const provider of COPILOT_PROVIDERS) + if (isSealedValue(data[`${provider}_key`])) { + patch[`${provider}_key`] = null; + patch[`${provider}_last4`] = null; + } + if (!Object.keys(patch).length) return; + patch.updated_at = new Date().toISOString(); + await db.from('copilot_settings').update(patch).eq('user_id', userId); +} diff --git a/apps/web/src/lib/copilot/llm.ts b/apps/web/src/lib/copilot/llm.ts index 9ac8b2d5..e62fd3ff 100644 --- a/apps/web/src/lib/copilot/llm.ts +++ b/apps/web/src/lib/copilot/llm.ts @@ -90,7 +90,7 @@ export class CopilotLlm { return providerChain(ctx.mode, { local: Boolean(LOCAL_LLM_URL()), own, - ours: process.env.ANTHROPIC_API_KEY ? 'anthropic' : null, + ours: process.env.ANTHROPIC_API_KEY && !ctx.keysLocked ? 'anthropic' : null, }); } diff --git a/apps/web/src/lib/copilot/providers.ts b/apps/web/src/lib/copilot/providers.ts index 751a1a5d..ad9f108f 100644 --- a/apps/web/src/lib/copilot/providers.ts +++ b/apps/web/src/lib/copilot/providers.ts @@ -19,6 +19,11 @@ export interface ProviderContext { sessionId: string; mode: CopilotMode; keys: CopilotKeys; + /** + * The owner has own keys saved but they are not unlocked for this meeting: + * free models only, never our paid provider in their place. + */ + keysLocked?: boolean; admin: boolean; } diff --git a/apps/web/src/lib/copilot/runtime.ts b/apps/web/src/lib/copilot/runtime.ts index 0ab2a4cb..b4fef20f 100644 --- a/apps/web/src/lib/copilot/runtime.ts +++ b/apps/web/src/lib/copilot/runtime.ts @@ -5,6 +5,7 @@ import { serviceClient } from '@/lib/supabase/service'; import { translationRuntime } from '@/lib/translation/server'; import { CopilotAccounts } from './account'; import { CopilotAgent, writeFollowUps, writeReview } from './agent'; +import { lockCopilotKeys, unlockCopilotKeys } from './keys'; import { CopilotLlm } from './llm'; import { CopilotMeeting, type MeetingParticipant } from './meeting'; import type { ProviderContext } from './providers'; @@ -45,10 +46,17 @@ export class CopilotRuntime { async context(sessionId: string, userId: string, mode: CopilotMode): Promise { const [settings, admin] = await Promise.all([ - this.accounts.settingsFor(userId), + this.accounts.settingsFor(userId, sessionId), this.accounts.isAdmin(userId), ]); - const ctx: ProviderContext = { userId, sessionId, mode, keys: settings.keys, admin }; + const ctx: ProviderContext = { + userId, + sessionId, + mode, + keys: settings.keys, + keysLocked: settings.keysState === 'locked' || settings.keysState === 'legacy', + admin, + }; this.contexts.set(`${sessionId}:${userId}`, ctx); return ctx; } @@ -217,14 +225,41 @@ export class CopilotRuntime { return agent; } + /** + * The owner unlocked their own keys for this meeting: hold the private key + * in memory (TTL) until the copilot stops, and refresh a running copilot. + */ + async unlockKeys( + sessionId: string, + userId: string, + privateKey: CryptoKey, + ttlSeconds?: unknown + ): Promise { + unlockCopilotKeys(userId, sessionId, privateKey, ttlSeconds); + this.accounts.forget(userId); + const agent = this.get(sessionId, userId); + if (agent) await this.context(sessionId, userId, agent.state.mode); + } + + /** Drop the meeting's unlock and every opened key of this owner for it. */ + private dropKeys(sessionId: string, userId: string): void { + lockCopilotKeys(userId, sessionId); + this.contexts.delete(`${sessionId}:${userId}`); + this.accounts.forget(userId); + } + /** Stop the owner's copilot: follow-ups and review are written, then it is saved. */ async stop(sessionId: string, userId: string): Promise { const meeting = this.meetings.get(sessionId); const agent = meeting?.agents.get(userId); if (!meeting || !agent) return null; - const state = await agent.finish(); - meeting.agents.delete(userId); - this.contexts.delete(`${sessionId}:${userId}`); + let state: CopilotState; + try { + state = await agent.finish(); + } finally { + meeting.agents.delete(userId); + this.dropKeys(sessionId, userId); + } await this.closeIfIdle(sessionId); return state; } @@ -262,7 +297,7 @@ export class CopilotRuntime { writeFollowUps(this.llm, ctx, state, stored.participantId).catch(() => null), writeReview(this.llm, ctx, state, stored.participantId).catch(() => null), ]); - this.contexts.delete(`${state.sessionId}:${stored.userId}`); + this.dropKeys(state.sessionId, stored.userId); await this.store.save(state); return state; } diff --git a/apps/web/src/lib/copilot/voice.ts b/apps/web/src/lib/copilot/voice.ts index 54568147..c5298b4b 100644 --- a/apps/web/src/lib/copilot/voice.ts +++ b/apps/web/src/lib/copilot/voice.ts @@ -218,14 +218,14 @@ export class CopilotVoice { } steps(): ProviderStep[] { - const { mode, keys } = this.ctx(); + const { mode, keys, keysLocked } = this.ctx(); const own: ('elevenlabs' | 'openai')[] = []; if (keys.elevenlabs) own.push('elevenlabs'); if (keys.openai) own.push('openai'); return providerChain(mode, { local: Boolean(LOCAL_STT_URL()), own, - ours: this.ours()?.available() ? 'elevenlabs' : null, + ours: !keysLocked && this.ours()?.available() ? 'elevenlabs' : null, }); } diff --git a/apps/web/src/lib/sealed/unlock-secret.ts b/apps/web/src/lib/sealed/unlock-secret.ts new file mode 100644 index 00000000..e09a4e7a --- /dev/null +++ b/apps/web/src/lib/sealed/unlock-secret.ts @@ -0,0 +1,38 @@ +/** + * Client side: turn what the owner typed (key passphrase or recovery key) into + * the base64 data key for an unlock request. Runs in the browser or the + * desktop renderer (WebCrypto); the typed secret never leaves it. Relative + * imports only, so the desktop app can bundle this file too. + */ +import { + bytesToBase64, + isSealRecord, + unlockSeal, + unlockSealWithRecovery, + wipe, + WrongPassphraseError, +} from './crypto'; + +/** A recovery key as encodeRecoveryKey prints it: groups of 4, dash separated. */ +const RECOVERY_KEY = /^[A-Z0-9]{4}(?:-[A-Z0-9]{1,4}){7,}$/i; + +export async function dataKeyFromSecret( + seal: unknown, + secret: string, + userId: string +): Promise { + if (!isSealRecord(seal)) throw new Error('No key passphrase is set up yet.'); + const typed = secret.trim(); + let dek: Uint8Array | null = null; + try { + dek = RECOVERY_KEY.test(typed) + ? await unlockSealWithRecovery(seal, typed, userId) + : await unlockSeal(seal, secret, userId); + return bytesToBase64(dek); + } catch (error) { + if (error instanceof WrongPassphraseError) throw new Error('Wrong key passphrase.'); + throw error; + } finally { + wipe(dek); + } +} diff --git a/apps/web/src/lib/sealed/unlockStore.ts b/apps/web/src/lib/sealed/unlockStore.ts index 6ee70412..b533366e 100644 --- a/apps/web/src/lib/sealed/unlockStore.ts +++ b/apps/web/src/lib/sealed/unlockStore.ts @@ -43,6 +43,18 @@ export function lockOwner(ownerId: string): boolean { return store().delete(ownerId); } +/** Drop every unlock whose owner id starts with `prefix` (a user's per-meeting copilot unlocks). */ +export function lockOwnersWithPrefix(prefix: string): number { + let n = 0; + for (const id of [...store().keys()]) { + if (id.startsWith(prefix)) { + store().delete(id); + n++; + } + } + return n; +} + /** The owner's private key, or null when locked or expired (an expired entry is dropped). */ export function privateKeyFor(ownerId: string, now = Date.now()): CryptoKey | null { const e = store().get(ownerId); diff --git a/packages/meeting-copilot/src/CopilotPanel.tsx b/packages/meeting-copilot/src/CopilotPanel.tsx index b644eb86..98f7c973 100644 --- a/packages/meeting-copilot/src/CopilotPanel.tsx +++ b/packages/meeting-copilot/src/CopilotPanel.tsx @@ -194,6 +194,95 @@ function StartView({ ); } +/** + * The caller's own keys for this meeting: a passphrase box while they are + * locked, a pointer to Settings while they still need a passphrase. Shown only + * when the chosen models would use own keys. + */ +function OwnKeysBox({ + copilot, + mode, + t, + live, + secret, + setSecret, + onUnlock, +}: { + copilot: MeetingCopilot; + mode: CopilotMode; + t: CopilotT; + live: boolean; + secret: string; + setSecret: (value: string) => void; + onUnlock?: () => void; +}) { + const own = copilot.status?.ownKeys; + if (!own || mode === 'free') return null; + if (own.state === 'legacy') + return

{t('ownKeys.legacy')}

; + if (own.state !== 'locked') return null; + if (!copilot.canUnlockKeys || !own.seal) + return

{t('ownKeys.webOnly')}

; + return ( +
+

+ {t(live ? 'ownKeys.lockedLive' : 'ownKeys.locked')} +

+
+ { + setSecret(event.target.value); + }} + className="min-w-0 flex-1 rounded bg-gray-800 px-2 py-1 text-xs text-gray-100" + /> + {onUnlock && ( + + )} +
+

{t('ownKeys.note')}

+
+ ); +} + +/** A running copilot whose own keys are locked (e.g. after a restart): unlock in place. */ +function LiveKeysUnlock({ + copilot, + state, + t, +}: { + copilot: MeetingCopilot; + state: CopilotState; + t: CopilotT; +}) { + const [secret, setSecret] = useState(''); + return ( + { + void copilot.start(state.role, state.mode, secret).then((started) => { + if (started) setSecret(''); + }); + }} + /> + ); +} + /** Role and model pickers and the Start button (or the upgrade note). */ function CopilotStart({ copilot, @@ -207,6 +296,7 @@ function CopilotStart({ const status = copilot.status; const [role, setRole] = useState(status?.role ?? 'general'); const [mode, setMode] = useState(status?.mode ?? 'auto'); + const [secret, setSecret] = useState(''); // The saved defaults arrive with the first status poll. const savedRole = status?.role; const savedMode = status?.mode; @@ -267,13 +357,25 @@ function CopilotStart({ {t('pricingNote')} +

{t('consent')}

@@ -662,6 +764,7 @@ export function CopilotPanel({ })}

{state.notice &&

{state.notice}

} + {suggestion && suggestion.id !== dismissed && (
{t('suggested')}
diff --git a/packages/meeting-copilot/src/strings.ts b/packages/meeting-copilot/src/strings.ts index d8a20bd6..04cabe6a 100644 --- a/packages/meeting-copilot/src/strings.ts +++ b/packages/meeting-copilot/src/strings.ts @@ -91,6 +91,17 @@ export const COPILOT_STRINGS_EN = { unclear: 'Follow-ups are unclear', }, thin: 'The transcript is too short to review this conversation fairly.', + ownKeys: { + locked: 'Your own API keys are locked. Enter your key passphrase to use them in this meeting.', + lockedLive: + 'Your own API keys are locked, so the copilot is on free models. Unlock them to switch back.', + secret: 'Key passphrase or recovery key', + unlock: 'Unlock keys', + note: 'PairUX keeps the unlocked key in memory for this meeting only and drops it when the copilot stops.', + legacy: + 'Your saved keys need a key passphrase before they can be used. Set one in Keys and credit.', + webOnly: 'Your own API keys are locked. Start the copilot on pairux.com to unlock them.', + }, fullReport: 'Open full notes', restart: 'Start again', roles: { diff --git a/packages/meeting-copilot/src/types.ts b/packages/meeting-copilot/src/types.ts index 69405bf4..05990932 100644 --- a/packages/meeting-copilot/src/types.ts +++ b/packages/meeting-copilot/src/types.ts @@ -152,6 +152,21 @@ export interface CopilotStatus { mine: CopilotState | null; mode: CopilotMode; role: CopilotRole; + /** + * The caller's own API keys for this meeting (absent for guests). Keys are + * sealed under the owner's key passphrase; to use them the client derives the + * data key from it with `seal` and sends it with Start. `legacy` keys need a + * key passphrase set in Settings first. + */ + ownKeys?: CopilotOwnKeys; +} + +export interface CopilotOwnKeys { + state: 'none' | 'unlocked' | 'locked' | 'legacy'; + /** The caller's user id (bound into the seal). */ + userId: string; + /** The caller's seal record (ciphertext only), or null before a passphrase is set. */ + seal: unknown; } /** Where an invite to one of your agents stands. */ diff --git a/packages/meeting-copilot/src/useMeetingCopilot.ts b/packages/meeting-copilot/src/useMeetingCopilot.ts index 129e9c96..033baf10 100644 --- a/packages/meeting-copilot/src/useMeetingCopilot.ts +++ b/packages/meeting-copilot/src/useMeetingCopilot.ts @@ -16,6 +16,12 @@ export interface MeetingCopilotEnvironment { headers?: () => HeadersInit | Promise; /** Open a link (Electron sends it to the system browser). */ openUrl?: (url: string) => void; + /** + * Derive the data key (base64) from the key passphrase or recovery key and + * the caller's seal, locally (WebCrypto). Only the data key is sent, with + * Start, to unlock the caller's own API keys for this meeting. + */ + unlockKeys?: (seal: unknown, secret: string, userId: string) => Promise; } /** @@ -130,20 +136,25 @@ export function useMeetingCopilot({ } }, []); + // Start (or resume) the copilot; `secret` (key passphrase or recovery key) + // unlocks the caller's own keys for this meeting. It never leaves the client. const start = useCallback( - (role: CopilotRole, mode: CopilotMode) => + (role: CopilotRole, mode: CopilotMode, secret = '') => act(async () => { + const own = status?.ownKeys; + const unlock = envRef.current.unlockKeys; + const dek = secret && own?.seal && unlock ? await unlock(own.seal, secret, own.userId) : ''; const started = await json( await api(base, { method: 'POST', - body: JSON.stringify({ participantId: selfId, role, mode }), + body: JSON.stringify({ participantId: selfId, role, mode, ...(dek ? { dek } : {}) }), }) ); setState(started); void refresh(); return started; }), - [act, api, base, json, refresh, selfId] + [act, api, base, json, refresh, selfId, status?.ownKeys] ); const stop = useCallback( @@ -230,6 +241,8 @@ export function useMeetingCopilot({ setQuestion, open, refresh, + /** Can this app unlock own keys here (it supplied `unlockKeys`)? */ + canUnlockKeys: Boolean(env.unlockKeys), }; } export type MeetingCopilot = ReturnType; diff --git a/plans/MEETING-COPILOT.md b/plans/MEETING-COPILOT.md index 00fa2604..dcbf2163 100644 --- a/plans/MEETING-COPILOT.md +++ b/plans/MEETING-COPILOT.md @@ -79,9 +79,16 @@ Rates: Claude per MTok as published (Opus 5.5 $4/$20, Sonnet 5.5 $2/$10, Haiku 5.5 $0.10/$0.50); Scribe $0.22 per submitted hour (live-translate `translationCost`). Admins are never charged. -**BYOK.** Keys are encrypted at rest (AES-256-GCM, server secret -`COPILOT_KEYS_SECRET`), never returned (only the last 4 characters), usable only -by their owner's copilot. Deleting a key deletes the ciphertext. +**BYOK.** Keys are sealed to the owner (seal v2, `lib/copilot/keys.ts`): each key +is sealed to the public key of the owner's key passphrase (`user_key_seals`, the +same passphrase as YouTube auto go-live), so nothing on the server opens them. +Starting a copilot, the browser derives the data key from the passphrase and sends +it (never the passphrase); the opened private key stays in process memory for that +meeting only and is dropped when the copilot stops, on Lock, or on a restart. +While locked the copilot runs on free models and never bills our paid provider in +place of the owner's key. Never returned (only the last 4 characters). Legacy v1 +values under `COPILOT_KEYS_SECRET` are re-sealed once in the owner's own request; +`LEGACY_KEY_MIGRATION=off` ends that. ## 5. Architecture @@ -155,7 +162,7 @@ diarizing several people on one mic, mobile capture. 2. Free stack on dev2: compose project `pairux-copilot` (`~/www/pairux.com/docker-compose.copilot.yml`) on the app's network: `pairux-copilot-whisper` (speaches, faster-whisper-small int8, 8 CPUs/4 GB) and `pairux-copilot-llm` (Ollama, model `pairux-copilot` = qwen2.5:7b with num_ctx 8192 and num_thread 6, 12 CPUs/8 GB). -3. Env: `COPILOT_STT_URL`, `COPILOT_LLM_URL`, `COPILOT_LLM_MODEL`, `COPILOT_KEYS_SECRET` in vault `pairux-com--prod` and app.env. +3. Env: `COPILOT_STT_URL`, `COPILOT_LLM_URL`, `COPILOT_LLM_MODEL` (and, until the legacy cutoff, `COPILOT_KEYS_SECRET`) in vault `pairux-com--prod` and app.env. 4. Web deploy on merge; desktop on the next `v*` tag. ## 10a. Measured on dev2 (2026-10-28, CPU only, Ryzen 9 7950X3D) diff --git a/supabase/migrations/20261028230000_copilot_keys_sealed.sql b/supabase/migrations/20261028230000_copilot_keys_sealed.sql new file mode 100644 index 00000000..c0dc8cb2 --- /dev/null +++ b/supabase/migrations/20261028230000_copilot_keys_sealed.sql @@ -0,0 +1,24 @@ +-- Copilot own keys (BYOK) sealed to the owner (seal v2), no longer under the +-- server env key COPILOT_KEYS_SECRET. +-- +-- copilot_settings.{anthropic,openai,elevenlabs}_key now hold "v2:" values +-- sealed to the owner's public key in user_key_seals (the same key passphrase +-- as the YouTube token). They open only while the owner has unlocked a +-- meeting's copilot (private key in server memory, TTL, dropped when the +-- copilot stops). Legacy "v1." values are re-sealed once in the owner's own +-- request and overwritten in place; LEGACY_KEY_MIGRATION=off ends that. +-- +-- Unmigrated rows: +-- SELECT count(*) FROM copilot_settings +-- WHERE anthropic_key LIKE 'v1.%' OR openai_key LIKE 'v1.%' OR elevenlabs_key LIKE 'v1.%'; +ALTER TABLE public.copilot_settings ADD COLUMN IF NOT EXISTS seal_version SMALLINT; + +UPDATE public.copilot_settings SET seal_version = 1 +WHERE seal_version IS NULL + AND (anthropic_key LIKE 'v1.%' OR openai_key LIKE 'v1.%' OR elevenlabs_key LIKE 'v1.%'); + +COMMENT ON COLUMN public.copilot_settings.seal_version IS + '2 = keys sealed to the owner (user_key_seals); 1 = legacy server-key ciphertext awaiting the owner''s passphrase.'; +COMMENT ON COLUMN public.copilot_settings.anthropic_key IS 'Sealed (v2:) to the owner''s key; legacy v1. = old server key.'; +COMMENT ON COLUMN public.copilot_settings.openai_key IS 'Sealed (v2:) to the owner''s key; legacy v1. = old server key.'; +COMMENT ON COLUMN public.copilot_settings.elevenlabs_key IS 'Sealed (v2:) to the owner''s key; legacy v1. = old server key.';