fix: ask before a sign-in link from another app signs in

Any web page (one tap) or installed app (no tap) could fire a
`bulwarkmail://pair` link at the app, which then silently added the
sender's mailbox and switched to it, so the user might send or save mail
into an attacker's account. A `connect` link likewise opened any
server's sign-in page.

- Links that arrive from outside the app now ask first, naming the
  webmail's host and whether the link adds an account. Cancel discards
  the link for good; a link delivered again while the question is open
  is dropped. Codes scanned or pasted inside the app still go straight
  through.
- With the account registry full, a link shows the limit instead of
  opening "Add account".
- A pairing link for a plain-http webmail says the connection would be
  unencrypted instead of "That code isn't a Bulwark sign-in code", or
  nothing at all for a tapped link.
This commit is contained in:
Linus Rath
2026-09-27 05:11:07 +02:00
parent 26bb947940
commit a27d014c59
37 changed files with 742 additions and 47 deletions
+29 -9
View File
@@ -73,8 +73,16 @@ import { AppIconBadge } from './src/components/AppIconBadge';
import { getEmails } from './src/api/email';
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
import { draftContextFromEmail } from './src/lib/draft-context';
import { acceptSignInLink, handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
import {
acceptSignInLink,
handleDeepLink,
parseDeepLink,
routeParkedSignInLink,
shareToDeepLink,
type DeepLink,
} from './src/navigation/linking';
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
import { MAX_ACCOUNTS } from './src/lib/account-utils';
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
@@ -573,8 +581,10 @@ export default function App() {
// Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App,
// `bulwarkmail://connect?…`) work signed out too, so unlike the links above
// they are listened for from launch. They are parked for the login screen:
// the signed-out one takes them as it mounts.
// they are listened for from launch. They are parked for the login screen
// (the signed-out one takes them as it mounts), marked as coming from
// outside the app: any page or app can fire one, so the login screen asks
// before it redeems or opens anything.
React.useEffect(() => {
if (!initialSignInLinkRead) {
initialSignInLinkRead = true;
@@ -585,9 +595,10 @@ export default function App() {
}, []);
// Signed in, a sign-in link adds an account: open Add account, whose login
// screen takes the parked link. On a cold start the navigator mounts right
// after the auth gate flips, so wait for it like a reminder tap does.
const signInLinkPending = usePendingSignInLinkStore((s) => s.payload !== null);
// screen takes the parked link (or says why a refused one won't be used).
// On a cold start the navigator mounts right after the auth gate flips, so
// wait for it like a reminder tap does.
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null);
const addAccountForLink = isAuthenticated && signInLinkPending;
React.useEffect(() => {
if (!addAccountForLink) return;
@@ -598,9 +609,18 @@ export default function App() {
if (cancelled) return;
}
if (cancelled || !navigationRef.isReady()) return;
// Taken meanwhile (Add account was already open).
if (!usePendingSignInLinkStore.getState().payload) return;
navigationRef.navigate('AddAccount');
const route = routeParkedSignInLink(useAccountStore.getState().accounts.length);
if (route === 'account-limit') {
// Said like the Add account buttons say it, instead of opening a
// screen whose sign-in can only fail.
const t = useLocaleStore.getState().t;
toast.error(
t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }),
t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
);
return;
}
if (route === 'add-account') navigationRef.navigate('AddAccount');
})();
return () => { cancelled = true; };
}, [addAccountForLink]);
+6
View File
@@ -505,6 +505,12 @@
"know_server": "أعرف عنوان خادمي",
"known_server_desc": "نعرف هذا الخادم بالفعل، لذا لا حاجة إلى إعداد",
"known_server_title": "حساب آخر على {host}",
"link_confirm_connect": "يطلب رابط تسجيل الدخول في هذه الصفحة. لا تتابع إلا إذا كنت تستخدم {host} لبريدك.",
"link_confirm_connect_add": "يطلب رابط إضافة حساب بتسجيل الدخول في هذه الصفحة. لا تتابع إلا إذا كنت تستخدم {host} لبريدك.",
"link_confirm_connect_title": "فتح صفحة تسجيل الدخول في {host}؟",
"link_confirm_pair": "يسجّل هذا الرابط الدخول إلى حساب من {host}. لا تتابع إلا إذا كنت قد عرضت للتو رمز تسجيل دخول على حاسوبك.",
"link_confirm_pair_add": "يضيف هذا الرابط حسابًا من {host} وينقل التطبيق إليه. لا تتابع إلا إذا كنت قد عرضت للتو رمز تسجيل دخول على حاسوبك.",
"link_confirm_title": "تسجيل الدخول برابط؟",
"looking_up": "جارٍ البحث عن خادمك…",
"manual": "إدخال تفاصيل الخادم يدويًا",
"notice_bad_qr": "هذا الرمز ليس رمز تسجيل دخول Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Conec l'adreça del meu servidor",
"known_server_desc": "Ja coneixem aquest servidor, així que no cal configurar res",
"known_server_title": "Un altre compte a {host}",
"link_confirm_connect": "Un enllaç demana iniciar la sessió en aquesta pàgina. Continueu només si utilitzeu {host} per al correu.",
"link_confirm_connect_add": "Un enllaç demana afegir un compte iniciant la sessió en aquesta pàgina. Continueu només si utilitzeu {host} per al correu.",
"link_confirm_connect_title": "Voleu obrir la pàgina d'inici de sessió de {host}?",
"link_confirm_pair": "Aquest enllaç inicia la sessió en un compte de {host}. Continueu només si acabeu de mostrar un codi d'inici de sessió a l'ordinador.",
"link_confirm_pair_add": "Aquest enllaç afegeix un compte de {host} i hi canvia l'aplicació. Continueu només si acabeu de mostrar un codi d'inici de sessió a l'ordinador.",
"link_confirm_title": "Voleu iniciar la sessió amb un enllaç?",
"looking_up": "S'està cercant el vostre servidor…",
"manual": "Introdueix les dades del servidor manualment",
"notice_bad_qr": "Aquest codi no és un codi d'inici de sessió del Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Znám adresu svého serveru",
"known_server_desc": "Tento server už známe, takže není třeba nic nastavovat",
"known_server_title": "Další účet na {host}",
"link_confirm_connect": "Odkaz žádá o přihlášení na této stránce. Pokračujte, jen pokud {host} používáte pro svůj e-mail.",
"link_confirm_connect_add": "Odkaz žádá o přidání účtu přihlášením na této stránce. Pokračujte, jen pokud {host} používáte pro svůj e-mail.",
"link_confirm_connect_title": "Otevřít přihlašovací stránku {host}?",
"link_confirm_pair": "Tento odkaz vás přihlásí k účtu z {host}. Pokračujte, jen pokud jste právě na počítači zobrazili přihlašovací kód.",
"link_confirm_pair_add": "Tento odkaz přidá účet z {host} a přepne na něj aplikaci. Pokračujte, jen pokud jste právě na počítači zobrazili přihlašovací kód.",
"link_confirm_title": "Přihlásit se pomocí odkazu?",
"looking_up": "Hledání vašeho serveru…",
"manual": "Zadat údaje serveru ručně",
"notice_bad_qr": "Toto není přihlašovací kód Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Jeg kender min serveradresse",
"known_server_desc": "Vi kender allerede denne server, så der skal ikke konfigureres noget",
"known_server_title": "En anden konto på {host}",
"link_confirm_connect": "Et link beder om at logge ind på denne side. Fortsæt kun, hvis du bruger {host} til din mail.",
"link_confirm_connect_add": "Et link beder om at tilføje en konto ved at logge ind på denne side. Fortsæt kun, hvis du bruger {host} til din mail.",
"link_confirm_connect_title": "Åbn login-siden for {host}?",
"link_confirm_pair": "Dette link logger ind på en konto fra {host}. Fortsæt kun, hvis du lige har vist en loginkode på din computer.",
"link_confirm_pair_add": "Dette link tilføjer en konto fra {host} og skifter appen til den. Fortsæt kun, hvis du lige har vist en loginkode på din computer.",
"link_confirm_title": "Log ind med et link?",
"looking_up": "Slår din server op…",
"manual": "Indtast serveroplysninger manuelt",
"notice_bad_qr": "Den kode er ikke en Bulwark-loginkode",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Ich kenne meine Serveradresse",
"known_server_desc": "Diesen Server kennen wir bereits, daher ist keine Einrichtung nötig",
"known_server_title": "Weiteres Konto auf {host}",
"link_confirm_connect": "Ein Link möchte Sie auf dieser Seite anmelden. Fahren Sie nur fort, wenn Sie {host} für Ihre E-Mails verwenden.",
"link_confirm_connect_add": "Ein Link möchte über die Anmeldung auf dieser Seite ein Konto hinzufügen. Fahren Sie nur fort, wenn Sie {host} für Ihre E-Mails verwenden.",
"link_confirm_connect_title": "Anmeldeseite von {host} öffnen?",
"link_confirm_pair": "Dieser Link meldet Sie bei einem Konto von {host} an. Fahren Sie nur fort, wenn Sie gerade auf Ihrem Computer einen Anmeldecode angezeigt haben.",
"link_confirm_pair_add": "Dieser Link fügt ein Konto von {host} hinzu und wechselt die App zu diesem Konto. Fahren Sie nur fort, wenn Sie gerade auf Ihrem Computer einen Anmeldecode angezeigt haben.",
"link_confirm_title": "Mit einem Link anmelden?",
"looking_up": "Ihr Server wird gesucht…",
"manual": "Serverdaten manuell eingeben",
"notice_bad_qr": "Das ist kein Bulwark-Anmeldecode",
+6
View File
@@ -515,6 +515,12 @@
"know_server": "I know my server address",
"known_server_desc": "We already know this server, so there's no setup",
"known_server_title": "Another account on {host}",
"link_confirm_connect": "A link asks to sign in on this page. Only continue if you use {host} for your mail.",
"link_confirm_connect_add": "A link asks to add an account by signing in on this page. Only continue if you use {host} for your mail.",
"link_confirm_connect_title": "Open the sign-in page of {host}?",
"link_confirm_pair": "This link signs in to an account from {host}. Only continue if you just showed a sign-in code on your computer.",
"link_confirm_pair_add": "This link adds an account from {host} and switches the app to it. Only continue if you just showed a sign-in code on your computer.",
"link_confirm_title": "Sign in with a link?",
"looking_up": "Looking up your server…",
"manual": "Enter server details manually",
"notice_bad_qr": "That code isn't a Bulwark sign-in code",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Conozco la dirección de mi servidor",
"known_server_desc": "Ya conocemos este servidor, así que no hay nada que configurar",
"known_server_title": "Otra cuenta en {host}",
"link_confirm_connect": "Un enlace pide iniciar sesión en esta página. Continúa solo si usas {host} para tu correo.",
"link_confirm_connect_add": "Un enlace pide añadir una cuenta iniciando sesión en esta página. Continúa solo si usas {host} para tu correo.",
"link_confirm_connect_title": "¿Abrir la página de inicio de sesión de {host}?",
"link_confirm_pair": "Este enlace inicia sesión en una cuenta de {host}. Continúa solo si acabas de mostrar un código de inicio de sesión en tu ordenador.",
"link_confirm_pair_add": "Este enlace añade una cuenta de {host} y cambia la aplicación a ella. Continúa solo si acabas de mostrar un código de inicio de sesión en tu ordenador.",
"link_confirm_title": "¿Iniciar sesión con un enlace?",
"looking_up": "Buscando tu servidor…",
"manual": "Introducir los datos del servidor manualmente",
"notice_bad_qr": "Ese código no es un código de inicio de sesión de Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "نشانی سرورم را می‌دانم",
"known_server_desc": "این سرور را از قبل می‌شناسیم، پس نیازی به راه‌اندازی نیست",
"known_server_title": "حسابی دیگر در {host}",
"link_confirm_connect": "یک پیوند می‌خواهد در این صفحه وارد شوید. فقط اگر برای ایمیل‌تان از {host} استفاده می‌کنید ادامه دهید.",
"link_confirm_connect_add": "یک پیوند می‌خواهد با ورود در این صفحه حسابی اضافه کنید. فقط اگر برای ایمیل‌تان از {host} استفاده می‌کنید ادامه دهید.",
"link_confirm_connect_title": "صفحه ورود {host} باز شود؟",
"link_confirm_pair": "این پیوند شما را به حسابی از {host} وارد می‌کند. فقط اگر همین حالا روی رایانه‌تان کد ورود نمایش داده‌اید ادامه دهید.",
"link_confirm_pair_add": "این پیوند حسابی از {host} را اضافه می‌کند و برنامه را به آن حساب می‌برد. فقط اگر همین حالا روی رایانه‌تان کد ورود نمایش داده‌اید ادامه دهید.",
"link_confirm_title": "با پیوند وارد می‌شوید؟",
"looking_up": "در حال جستجوی سرور شما…",
"manual": "وارد کردن دستی مشخصات سرور",
"notice_bad_qr": "این کد، کد ورود Bulwark نیست",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Je connais l'adresse de mon serveur",
"known_server_desc": "Nous connaissons déjà ce serveur, aucune configuration n'est donc nécessaire",
"known_server_title": "Un autre compte sur {host}",
"link_confirm_connect": "Un lien demande à vous connecter sur cette page. Ne continuez que si vous utilisez {host} pour vos e-mails.",
"link_confirm_connect_add": "Un lien demande à ajouter un compte en vous connectant sur cette page. Ne continuez que si vous utilisez {host} pour vos e-mails.",
"link_confirm_connect_title": "Ouvrir la page de connexion de {host} ?",
"link_confirm_pair": "Ce lien vous connecte à un compte de {host}. Ne continuez que si vous venez d'afficher un code de connexion sur votre ordinateur.",
"link_confirm_pair_add": "Ce lien ajoute un compte de {host} et fait basculer l'application sur ce compte. Ne continuez que si vous venez d'afficher un code de connexion sur votre ordinateur.",
"link_confirm_title": "Se connecter avec un lien ?",
"looking_up": "Recherche de votre serveur…",
"manual": "Saisir les informations du serveur manuellement",
"notice_bad_qr": "Ce code n'est pas un code de connexion Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "אני יודע את כתובת השרת שלי",
"known_server_desc": "השרת הזה כבר מוכר לנו, כך שאין צורך בהגדרה",
"known_server_title": "חשבון נוסף ב-{host}",
"link_confirm_connect": "קישור מבקש להתחבר בדף הזה. המשך רק אם אתה משתמש ב-{host} לדואר שלך.",
"link_confirm_connect_add": "קישור מבקש להוסיף חשבון באמצעות התחברות בדף הזה. המשך רק אם אתה משתמש ב-{host} לדואר שלך.",
"link_confirm_connect_title": "לפתוח את דף ההתחברות של {host}?",
"link_confirm_pair": "הקישור הזה מחבר לחשבון מ-{host}. המשך רק אם הצגת עכשיו קוד התחברות במחשב.",
"link_confirm_pair_add": "הקישור הזה מוסיף חשבון מ-{host} ומעביר אליו את היישום. המשך רק אם הצגת עכשיו קוד התחברות במחשב.",
"link_confirm_title": "להתחבר עם קישור?",
"looking_up": "מחפש את השרת שלך…",
"manual": "הזן את פרטי השרת ידנית",
"notice_bad_qr": "הקוד הזה אינו קוד התחברות של Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Ismerem a szerverem címét",
"known_server_desc": "Ezt a szervert már ismerjük, így nincs mit beállítani",
"known_server_title": "Másik fiók itt: {host}",
"link_confirm_connect": "Egy hivatkozás bejelentkezést kér ezen az oldalon. Csak akkor folytasd, ha a(z) {host} címet használod a leveleidhez.",
"link_confirm_connect_add": "Egy hivatkozás egy fiók hozzáadását kéri bejelentkezéssel ezen az oldalon. Csak akkor folytasd, ha a(z) {host} címet használod a leveleidhez.",
"link_confirm_connect_title": "Megnyitod a(z) {host} bejelentkezési oldalát?",
"link_confirm_pair": "Ez a hivatkozás bejelentkeztet egy fiókba itt: {host}. Csak akkor folytasd, ha az imént jelenítettél meg bejelentkezési kódot a számítógépeden.",
"link_confirm_pair_add": "Ez a hivatkozás hozzáad egy fiókot innen: {host}, és átváltja rá az alkalmazást. Csak akkor folytasd, ha az imént jelenítettél meg bejelentkezési kódot a számítógépeden.",
"link_confirm_title": "Bejelentkezel egy hivatkozással?",
"looking_up": "Szerver keresése…",
"manual": "Szerveradatok kézi megadása",
"notice_bad_qr": "Ez nem Bulwark bejelentkezési kód",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Conosco l'indirizzo del mio server",
"known_server_desc": "Conosciamo già questo server, quindi non c'è nulla da configurare",
"known_server_title": "Un altro account su {host}",
"link_confirm_connect": "Un link chiede di accedere su questa pagina. Continua solo se usi {host} per la tua posta.",
"link_confirm_connect_add": "Un link chiede di aggiungere un account accedendo su questa pagina. Continua solo se usi {host} per la tua posta.",
"link_confirm_connect_title": "Aprire la pagina di accesso di {host}?",
"link_confirm_pair": "Questo link ti fa accedere a un account di {host}. Continua solo se hai appena mostrato un codice di accesso sul computer.",
"link_confirm_pair_add": "Questo link aggiunge un account di {host} e passa l'app a quell'account. Continua solo se hai appena mostrato un codice di accesso sul computer.",
"link_confirm_title": "Accedere con un link?",
"looking_up": "Ricerca del tuo server…",
"manual": "Inserisci i dati del server manualmente",
"notice_bad_qr": "Questo codice non è un codice di accesso Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "サーバーアドレスがわかる",
"known_server_desc": "このサーバーは登録済みのため、設定は不要です",
"known_server_title": "{host}の別のアカウント",
"link_confirm_connect": "リンクがこのページでのサインインを求めています。メールに {host} を使っている場合にのみ続行してください。",
"link_confirm_connect_add": "リンクがこのページでサインインしてアカウントを追加するよう求めています。メールに {host} を使っている場合にのみ続行してください。",
"link_confirm_connect_title": "{host} のサインインページを開きますか?",
"link_confirm_pair": "このリンクは {host} のアカウントにサインインします。パソコンでサインインコードを表示したばかりの場合にのみ続行してください。",
"link_confirm_pair_add": "このリンクは {host} のアカウントを追加し、アプリをそのアカウントに切り替えます。パソコンでサインインコードを表示したばかりの場合にのみ続行してください。",
"link_confirm_title": "リンクでサインインしますか?",
"looking_up": "サーバーを検索しています…",
"manual": "サーバー情報を手動で入力",
"notice_bad_qr": "このコードは Bulwark のサインインコードではありません",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "서버 주소를 알고 있어요",
"known_server_desc": "이미 알고 있는 서버라서 설정이 필요 없어요",
"known_server_title": "{host}의 다른 계정",
"link_confirm_connect": "링크가 이 페이지에서 로그인하도록 요청하고 있어요. 메일에 {host}을(를) 사용하는 경우에만 계속하세요.",
"link_confirm_connect_add": "링크가 이 페이지에서 로그인해 계정을 추가하도록 요청하고 있어요. 메일에 {host}을(를) 사용하는 경우에만 계속하세요.",
"link_confirm_connect_title": "{host}의 로그인 페이지를 열까요?",
"link_confirm_pair": "이 링크는 {host}의 계정으로 로그인해요. 방금 컴퓨터에서 로그인 코드를 표시한 경우에만 계속하세요.",
"link_confirm_pair_add": "이 링크는 {host}의 계정을 추가하고 앱을 해당 계정으로 전환해요. 방금 컴퓨터에서 로그인 코드를 표시한 경우에만 계속하세요.",
"link_confirm_title": "링크로 로그인할까요?",
"looking_up": "서버를 찾는 중…",
"manual": "서버 정보 직접 입력",
"notice_bad_qr": "Bulwark 로그인 코드가 아니에요",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Es zinu sava servera adresi",
"known_server_desc": "Šis serveris mums jau ir zināms, tāpēc nekas nav jāiestata",
"known_server_title": "Vēl viens konts: {host}",
"link_confirm_connect": "Saite aicina pierakstīties šajā lapā. Turpiniet tikai tad, ja izmantojat {host} savam e-pastam.",
"link_confirm_connect_add": "Saite aicina pievienot kontu, pierakstoties šajā lapā. Turpiniet tikai tad, ja izmantojat {host} savam e-pastam.",
"link_confirm_connect_title": "Atvērt {host} pierakstīšanās lapu?",
"link_confirm_pair": "Šī saite pierakstās kontā no {host}. Turpiniet tikai tad, ja tikko datorā parādījāt pierakstīšanās kodu.",
"link_confirm_pair_add": "Šī saite pievieno kontu no {host} un pārslēdz lietotni uz to. Turpiniet tikai tad, ja tikko datorā parādījāt pierakstīšanās kodu.",
"link_confirm_title": "Pierakstīties ar saiti?",
"looking_up": "Meklē jūsu serveri…",
"manual": "Ievadīt servera datus manuāli",
"notice_bad_qr": "Šis nav Bulwark pierakstīšanās kods",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Би серверийн хаягаа мэднэ",
"known_server_desc": "Бид энэ серверийг аль хэдийн мэддэг тул тохируулах шаардлагагүй",
"known_server_title": "{host} дээрх өөр бүртгэл",
"link_confirm_connect": "Холбоос энэ хуудсанд нэвтрэхийг хүсэж байна. Та имэйлдээ {host}-ийг ашигладаг бол л үргэлжлүүлнэ үү.",
"link_confirm_connect_add": "Холбоос энэ хуудсанд нэвтэрч бүртгэл нэмэхийг хүсэж байна. Та имэйлдээ {host}-ийг ашигладаг бол л үргэлжлүүлнэ үү.",
"link_confirm_connect_title": "{host}-ийн нэвтрэх хуудсыг нээх үү?",
"link_confirm_pair": "Энэ холбоос {host} дээрх бүртгэлд нэвтрүүлнэ. Та саяхан компьютер дээрээ нэвтрэх код харуулсан бол л үргэлжлүүлнэ үү.",
"link_confirm_pair_add": "Энэ холбоос {host} дээрх бүртгэлийг нэмээд програмыг түүн рүү шилжүүлнэ. Та саяхан компьютер дээрээ нэвтрэх код харуулсан бол л үргэлжлүүлнэ үү.",
"link_confirm_title": "Холбоосоор нэвтрэх үү?",
"looking_up": "Таны серверийг хайж байна…",
"manual": "Серверийн мэдээллийг гараар оруулах",
"notice_bad_qr": "Энэ код Bulwark-ийн нэвтрэх код биш байна",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Jeg kjenner serveradressen min",
"known_server_desc": "Vi kjenner allerede denne serveren, så du trenger ikke å sette opp noe",
"known_server_title": "En annen konto på {host}",
"link_confirm_connect": "En lenke ber om å logge på på denne siden. Fortsett bare hvis du bruker {host} til e-posten din.",
"link_confirm_connect_add": "En lenke ber om å legge til en konto ved å logge på på denne siden. Fortsett bare hvis du bruker {host} til e-posten din.",
"link_confirm_connect_title": "Åpne påloggingssiden til {host}?",
"link_confirm_pair": "Denne lenken logger på en konto fra {host}. Fortsett bare hvis du nettopp viste en påloggingskode på datamaskinen.",
"link_confirm_pair_add": "Denne lenken legger til en konto fra {host} og bytter appen til den. Fortsett bare hvis du nettopp viste en påloggingskode på datamaskinen.",
"link_confirm_title": "Logge på med en lenke?",
"looking_up": "Slår opp serveren din…",
"manual": "Skriv inn serverdetaljer manuelt",
"notice_bad_qr": "Koden er ikke en påloggingskode for Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Ik ken mijn serveradres",
"known_server_desc": "We kennen deze server al, dus er hoeft niets te worden ingesteld",
"known_server_title": "Nog een account op {host}",
"link_confirm_connect": "Een link vraagt om op deze pagina in te loggen. Ga alleen door als u {host} voor uw e-mail gebruikt.",
"link_confirm_connect_add": "Een link vraagt om een account toe te voegen door op deze pagina in te loggen. Ga alleen door als u {host} voor uw e-mail gebruikt.",
"link_confirm_connect_title": "Inlogpagina van {host} openen?",
"link_confirm_pair": "Deze link logt u in op een account van {host}. Ga alleen door als u zojuist een inlogcode op uw computer hebt getoond.",
"link_confirm_pair_add": "Deze link voegt een account van {host} toe en schakelt de app daarnaar over. Ga alleen door als u zojuist een inlogcode op uw computer hebt getoond.",
"link_confirm_title": "Inloggen met een link?",
"looking_up": "Uw server opzoeken…",
"manual": "Servergegevens handmatig invoeren",
"notice_bad_qr": "Die code is geen Bulwark-inlogcode",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Znam adres swojego serwera",
"known_server_desc": "Ten serwer jest już znany, więc nie trzeba niczego konfigurować",
"known_server_title": "Kolejne konto na {host}",
"link_confirm_connect": "Link prosi o zalogowanie się na tej stronie. Kontynuuj tylko, jeśli używasz {host} do poczty.",
"link_confirm_connect_add": "Link prosi o dodanie konta przez zalogowanie się na tej stronie. Kontynuuj tylko, jeśli używasz {host} do poczty.",
"link_confirm_connect_title": "Otworzyć stronę logowania {host}?",
"link_confirm_pair": "Ten link loguje do konta z {host}. Kontynuuj tylko, jeśli kod logowania został właśnie wyświetlony na Twoim komputerze.",
"link_confirm_pair_add": "Ten link dodaje konto z {host} i przełącza na nie aplikację. Kontynuuj tylko, jeśli kod logowania został właśnie wyświetlony na Twoim komputerze.",
"link_confirm_title": "Zalogować się za pomocą linku?",
"looking_up": "Wyszukiwanie serwera…",
"manual": "Wprowadź dane serwera ręcznie",
"notice_bad_qr": "To nie jest kod logowania Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Sei o endereço do meu servidor",
"known_server_desc": "Já conhecemos este servidor, então não há nada para configurar",
"known_server_title": "Outra conta em {host}",
"link_confirm_connect": "Um link pede para fazer login nesta página. Só continue se você usa {host} para o seu e-mail.",
"link_confirm_connect_add": "Um link pede para adicionar uma conta fazendo login nesta página. Só continue se você usa {host} para o seu e-mail.",
"link_confirm_connect_title": "Abrir a página de login de {host}?",
"link_confirm_pair": "Este link faz login em uma conta de {host}. Só continue se você acabou de mostrar um código de login no computador.",
"link_confirm_pair_add": "Este link adiciona uma conta de {host} e muda o aplicativo para ela. Só continue se você acabou de mostrar um código de login no computador.",
"link_confirm_title": "Entrar com um link?",
"looking_up": "Procurando seu servidor…",
"manual": "Inserir os dados do servidor manualmente",
"notice_bad_qr": "Esse código não é um código de login do Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Cunosc adresa serverului meu",
"known_server_desc": "Cunoaștem deja acest server, deci nu este nimic de configurat",
"known_server_title": "Alt cont pe {host}",
"link_confirm_connect": "Un link cere autentificarea pe această pagină. Continuați doar dacă folosiți {host} pentru e-mail.",
"link_confirm_connect_add": "Un link cere adăugarea unui cont prin autentificare pe această pagină. Continuați doar dacă folosiți {host} pentru e-mail.",
"link_confirm_connect_title": "Deschideți pagina de autentificare a {host}?",
"link_confirm_pair": "Acest link vă autentifică într-un cont de la {host}. Continuați doar dacă tocmai ați afișat un cod de autentificare pe computer.",
"link_confirm_pair_add": "Acest link adaugă un cont de la {host} și comută aplicația pe el. Continuați doar dacă tocmai ați afișat un cod de autentificare pe computer.",
"link_confirm_title": "Vă autentificați cu un link?",
"looking_up": "Se caută serverul dvs.…",
"manual": "Introduceți manual detaliile serverului",
"notice_bad_qr": "Acesta nu este un cod de autentificare Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Я знаю адрес своего сервера",
"known_server_desc": "Этот сервер уже известен, настройка не нужна",
"known_server_title": "Ещё один аккаунт на {host}",
"link_confirm_connect": "Ссылка предлагает войти на этой странице. Продолжайте, только если вы пользуетесь почтой на {host}.",
"link_confirm_connect_add": "Ссылка предлагает добавить аккаунт, войдя на этой странице. Продолжайте, только если вы пользуетесь почтой на {host}.",
"link_confirm_connect_title": "Открыть страницу входа {host}?",
"link_confirm_pair": "Эта ссылка выполняет вход в аккаунт на {host}. Продолжайте, только если вы только что показали код входа на своём компьютере.",
"link_confirm_pair_add": "Эта ссылка добавляет аккаунт с {host} и переключает на него приложение. Продолжайте, только если вы только что показали код входа на своём компьютере.",
"link_confirm_title": "Войти по ссылке?",
"looking_up": "Ищем ваш сервер…",
"manual": "Ввести данные сервера вручную",
"notice_bad_qr": "Это не код входа Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Poznám adresu svojho servera",
"known_server_desc": "Tento server už poznáme, takže nie je potrebné nič nastavovať",
"known_server_title": "Ďalší účet na {host}",
"link_confirm_connect": "Odkaz žiada o prihlásenie na tejto stránke. Pokračujte, iba ak {host} používate na svoj e-mail.",
"link_confirm_connect_add": "Odkaz žiada o pridanie účtu prihlásením na tejto stránke. Pokračujte, iba ak {host} používate na svoj e-mail.",
"link_confirm_connect_title": "Otvoriť prihlasovaciu stránku {host}?",
"link_confirm_pair": "Tento odkaz vás prihlási do účtu z {host}. Pokračujte, iba ak ste práve na počítači zobrazili prihlasovací kód.",
"link_confirm_pair_add": "Tento odkaz pridá účet z {host} a prepne naň aplikáciu. Pokračujte, iba ak ste práve na počítači zobrazili prihlasovací kód.",
"link_confirm_title": "Prihlásiť sa pomocou odkazu?",
"looking_up": "Hľadá sa váš server…",
"manual": "Zadať údaje servera ručne",
"notice_bad_qr": "Toto nie je prihlasovací kód Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Sunucu adresimi biliyorum",
"known_server_desc": "Bu sunucuyu zaten tanıyoruz, kurulum gerekmiyor",
"known_server_title": "{host} üzerinde başka bir hesap",
"link_confirm_connect": "Bir bağlantı bu sayfada giriş yapmanızı istiyor. Yalnızca e-postanız için {host} kullanıyorsanız devam edin.",
"link_confirm_connect_add": "Bir bağlantı bu sayfada giriş yaparak hesap eklemenizi istiyor. Yalnızca e-postanız için {host} kullanıyorsanız devam edin.",
"link_confirm_connect_title": "{host} giriş sayfası açılsın mı?",
"link_confirm_pair": "Bu bağlantı {host} üzerindeki bir hesapta oturum açar. Yalnızca bilgisayarınızda az önce bir giriş kodu gösterdiyseniz devam edin.",
"link_confirm_pair_add": "Bu bağlantı {host} üzerindeki bir hesabı ekler ve uygulamayı bu hesaba geçirir. Yalnızca bilgisayarınızda az önce bir giriş kodu gösterdiyseniz devam edin.",
"link_confirm_title": "Bağlantıyla giriş yapılsın mı?",
"looking_up": "Sunucunuz aranıyor…",
"manual": "Sunucu bilgilerini elle girin",
"notice_bad_qr": "Bu bir Bulwark giriş kodu değil",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "Я знаю адресу свого сервера",
"known_server_desc": "Цей сервер уже відомий, налаштування не потрібне",
"known_server_title": "Ще один обліковий запис на {host}",
"link_confirm_connect": "Посилання пропонує увійти на цій сторінці. Продовжуйте, лише якщо ви користуєтеся поштою на {host}.",
"link_confirm_connect_add": "Посилання пропонує додати обліковий запис, увійшовши на цій сторінці. Продовжуйте, лише якщо ви користуєтеся поштою на {host}.",
"link_confirm_connect_title": "Відкрити сторінку входу {host}?",
"link_confirm_pair": "Це посилання виконує вхід в обліковий запис на {host}. Продовжуйте, лише якщо ви щойно показали код входу на своєму комп'ютері.",
"link_confirm_pair_add": "Це посилання додає обліковий запис з {host} і перемикає на нього застосунок. Продовжуйте, лише якщо ви щойно показали код входу на своєму комп'ютері.",
"link_confirm_title": "Увійти за посиланням?",
"looking_up": "Шукаємо ваш сервер…",
"manual": "Ввести дані сервера вручну",
"notice_bad_qr": "Це не код входу Bulwark",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "我知道伺服器地址",
"known_server_desc": "我們已知道此伺服器,不需要設定",
"known_server_title": "{host} 上的其他帳號",
"link_confirm_connect": "有連結要求在此頁面登入。只有在您的郵件使用 {host} 時才繼續。",
"link_confirm_connect_add": "有連結要求透過在此頁面登入來新增帳號。只有在您的郵件使用 {host} 時才繼續。",
"link_confirm_connect_title": "要開啟 {host} 的登入頁面嗎?",
"link_confirm_pair": "此連結會登入 {host} 上的帳號。只有在您剛在電腦上顯示登入碼時才繼續。",
"link_confirm_pair_add": "此連結會新增 {host} 上的帳號,並將應用程式切換到該帳號。只有在您剛在電腦上顯示登入碼時才繼續。",
"link_confirm_title": "要使用連結登入嗎?",
"looking_up": "正在尋找您的伺服器…",
"manual": "手動輸入伺服器資訊",
"notice_bad_qr": "此代碼不是 Bulwark 登入碼",
+6
View File
@@ -505,6 +505,12 @@
"know_server": "我知道服务器地址",
"known_server_desc": "我们已经知道此服务器,无需设置",
"known_server_title": "{host} 上的其他账户",
"link_confirm_connect": "有链接请求在此页面登录。仅当您的邮箱使用 {host} 时才继续。",
"link_confirm_connect_add": "有链接请求通过在此页面登录来添加账户。仅当您的邮箱使用 {host} 时才继续。",
"link_confirm_connect_title": "打开 {host} 的登录页面?",
"link_confirm_pair": "此链接将登录 {host} 上的账户。仅当您刚刚在电脑上显示了登录码时才继续。",
"link_confirm_pair_add": "此链接将添加 {host} 上的账户,并将应用切换到该账户。仅当您刚刚在电脑上显示了登录码时才继续。",
"link_confirm_title": "使用链接登录?",
"looking_up": "正在查找您的服务器…",
"manual": "手动输入服务器信息",
"notice_bad_qr": "该代码不是 Bulwark 登录码",
+9 -1
View File
@@ -17,7 +17,8 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { describeLoginError } from '../lib/login-errors';
interface PasteSignInLinkModalProps {
visible: boolean;
@@ -63,6 +64,13 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
const submit = React.useCallback(() => {
const payload = parsePastedSignInLink(text);
if (!payload) {
// A pairing link for a plain-http webmail: say why it won't be used.
const insecure = insecurePairingLinkError(text);
if (insecure) {
const copy = describeLoginError(insecure, { t });
setError(copy.detail ?? copy.title);
return;
}
setError(t('login.mobile.paste_invalid', "That isn't a Bulwark sign-in link"));
return;
}
+9 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { describeLoginError } from '../login-errors';
import { PairingError, type PairingErrorReason } from '../oauth';
import { PairingError, insecurePairingLinkError, type PairingErrorReason } from '../oauth';
import { AccountLimitError } from '../account-utils';
function named(name: string, message: string): Error {
@@ -31,6 +31,14 @@ describe('describeLoginError', () => {
expect(describeLoginError('Maximum of 10 accounts reached')).toEqual(want);
});
it('explains a scanned, pasted or tapped pairing link for a plain-http webmail', () => {
const link = `bulwarkmail://pair?server=${encodeURIComponent('http://webmail.example.org/mail')}&code=${'a1'.repeat(32)}`;
expect(describeLoginError(insecurePairingLinkError(link))).toEqual({
title: "This sign-in can't be trusted",
detail: 'webmail.example.org wants to use an unencrypted connection.',
});
});
it('names the host it could not reach', () => {
const copy = describeLoginError(named('NetworkError', 'Network request failed'), {
serverUrl: 'https://mail.example.com',
+67
View File
@@ -12,10 +12,13 @@ import {
PAIRING_REDEEM_TIMEOUT_MS,
PairingError,
TransientRefreshError,
insecurePairingLinkError,
isInsecurePairingUrl,
parsePastedSignInLink,
parseQrLoginPayload,
redeemPairingCode,
refreshOAuthAccessToken,
signInLinkHost,
} from '../oauth';
const CODE = 'a1'.repeat(32);
@@ -110,6 +113,64 @@ describe('parsePastedSignInLink', () => {
});
});
describe('insecurePairingLinkError', () => {
const httpLink = `bulwarkmail://pair?server=${encodeURIComponent('http://Mail.Example.com:8080/webmail')}&code=${CODE}`;
it('says why a pairing link for a plain-http webmail is refused', () => {
// Still refused by the parser: nothing can redeem it.
expect(parseQrLoginPayload(httpLink)).toBeNull();
expect(parsePastedSignInLink(`Open ${httpLink} on your phone.`)).toBeNull();
for (const text of [httpLink, `Open ${httpLink}.`, `bulwarkmail://pair/?code=${CODE}&server=http://mail.example.com:8080`]) {
const err = insecurePairingLinkError(text);
expect(err).toBeInstanceOf(PairingError);
expect(err).toMatchObject({ reason: 'insecure', host: 'mail.example.com:8080' });
}
});
it('names the host the code would really go to', () => {
const spoof = `bulwarkmail://pair?server=${encodeURIComponent('http://webmail.example.org@attacker.example')}&code=${CODE}`;
expect(insecurePairingLinkError(spoof)).toMatchObject({ host: 'attacker.example' });
});
it('is null for links that are fine or broken for another reason', () => {
expect(insecurePairingLinkError(`bulwarkmail://pair?server=${encodeURIComponent(WEBMAIL)}&code=${CODE}`)).toBeNull();
expect(insecurePairingLinkError(`bulwarkmail://pair?server=http%3A%2F%2Flocalhost%3A3000&code=${CODE}`)).toBeNull();
expect(insecurePairingLinkError('bulwarkmail://pair?server=http://mail.example.com&code=has%20space')).toBeNull();
expect(insecurePairingLinkError('bulwarkmail://pair?server=http://mail.example.com')).toBeNull();
expect(insecurePairingLinkError('bulwarkmail://connect?server=http://mail.example.com')).toBeNull();
expect(insecurePairingLinkError(`bulwarkmail://pair?server=ftp://mail.example.com&code=${CODE}`)).toBeNull();
expect(insecurePairingLinkError('http://mail.example.com')).toBeNull();
expect(insecurePairingLinkError('')).toBeNull();
});
});
describe('isInsecurePairingUrl', () => {
it('allows https and loopback http only', () => {
expect(isInsecurePairingUrl('https://mail.example.com')).toBe(false);
expect(isInsecurePairingUrl('http://localhost:3000')).toBe(false);
expect(isInsecurePairingUrl('http://10.0.2.2:3000')).toBe(false);
expect(isInsecurePairingUrl('http://mail.example.com')).toBe(true);
expect(isInsecurePairingUrl('http://192.168.1.5:3000')).toBe(true);
});
});
describe('signInLinkHost', () => {
it('names the host the link talks to', () => {
expect(signInLinkHost('https://mail.example.com/webmail')).toBe('mail.example.com');
expect(signInLinkHost('https://Mail.Example.com:8443')).toBe('mail.example.com:8443');
expect(signInLinkHost('http://127.0.0.1:3000/?x=1')).toBe('127.0.0.1:3000');
});
it('is not fooled by user info or backslashes', () => {
expect(signInLinkHost('https://mail.example.com@attacker.example/webmail')).toBe('attacker.example');
expect(signInLinkHost('https://a@mail.example.com@attacker.example')).toBe('attacker.example');
expect(signInLinkHost('https://attacker.example\\@mail.example.com')).toBe('attacker.example');
expect(signInLinkHost('https://attacker.example#@mail.example.com')).toBe('attacker.example');
expect(signInLinkHost('https://attacker.example?@mail.example.com')).toBe('attacker.example');
});
});
describe('redeemPairingCode', () => {
it('posts the code to the webmail and returns an OAuth bundle for its token proxy', async () => {
mockSecureFetch.mockResolvedValueOnce(json(200, {
@@ -184,6 +245,12 @@ describe('redeemPairingCode', () => {
await expect(redeemPairingCode('http://10.0.2.2:3000', CODE)).resolves.toMatchObject({ serverUrl: 'http://10.0.2.2:8080' });
});
it('never sends a code to a plain-http webmail', async () => {
await expect(redeemPairingCode('http://webmail.example.org/mail', CODE))
.rejects.toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'webmail.example.org' });
expect(mockSecureFetch).not.toHaveBeenCalled();
});
it('tells an unknown code from an expired or a used one', async () => {
expect((await redeemFailure(json(400, { error: 'invalid_code' }))).reason).toBe('invalid');
expect((await redeemFailure(json(410, { error: 'expired_code' }))).reason).toBe('expired');
+45
View File
@@ -275,6 +275,47 @@ export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
return null;
}
/**
* The code is redeemed at the webmail, and the answer carries credentials:
* only over TLS, or plain http on loopback for development.
*/
export function isInsecurePairingUrl(webmailUrl: string): boolean {
return !isHttpsUrl(webmailUrl) && !isLoopbackHttp(webmailUrl);
}
/**
* The host a sign-in link talks to, for the user to check. Read the way the
* network stack reads the address: `https://mail.example.com@evil.example`
* and `https://evil.example\@mail.example.com` both go to evil.example, so
* that is the host named.
*/
export function signInLinkHost(url: string): string {
const authority = url.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/\\?#]/)[0];
return authority.slice(authority.lastIndexOf('@') + 1).toLowerCase();
}
/**
* Why parseQrLoginPayload refused a pairing link, when the reason is its
* plain-http webmail (the code, and the credentials it buys, would cross the
* network in clear), so a scanned, pasted or tapped link can say so instead
* of "not a Bulwark sign-in code". Only ever an error to show: the parser
* stays the one gate and never yields such a link. Takes the link alone or
* pasted text around it; null for anything else.
*/
export function insecurePairingLinkError(text: string): PairingError | null {
const link = (/bulwarkmail:\/\/\S+/i.exec(text)?.[0] ?? '').replace(/[)\]>.,;'"]+$/, '');
const match = SIGN_IN_LINK_RE.exec(link);
if (!match || match[1].toLowerCase() !== 'pair') return null;
const params = new URLSearchParams(match[2]);
const server = params.get('server')?.trim();
const code = params.get('code')?.trim();
if (!server || !/^http:\/\/[^/?#\s]+/i.test(server) || !isInsecurePairingUrl(server)) return null;
if (!code || !PAIRING_CODE_RE.test(code)) return null;
return new PairingError('insecure', 'Pairing needs an https webmail address', {
host: signInLinkHost(server),
});
}
/**
* Pasted text: the link on its own, or a message that contains it ("Open
* bulwarkmail://pair?… on your phone"). Falls back to the whole text so a
@@ -340,6 +381,10 @@ function redeemFailure(status: number, body: Record<string, unknown> | null, hos
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
const base = webmailUrl.replace(/\/+$/, '');
const host = displayHost(base);
// The login screen refuses these links already; never send a code in clear.
if (isInsecurePairingUrl(base)) {
throw new PairingError('insecure', 'Pairing needs an https webmail address', { host });
}
// secureFetch's native (client-certificate) path ignores `signal`, so the
// deadline is also passed as `timeoutMs` and enforced here with a race; the
+24 -7
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking';
import { setPendingSignInLink, usePendingSignInLinkStore } from '../pending-sign-in-link';
import { usePendingSignInLinkStore } from '../pending-sign-in-link';
import { usePendingSettingsTab } from '../pending-settings-tab';
import { usePendingCalendarOpen } from '../pending-calendar-open';
import { usePendingMailSearch } from '../pending-mail-search';
@@ -284,18 +284,35 @@ describe('sign-in links', () => {
expect(parseSignInLink(null)).toBeNull();
});
it('parks a sign-in link for the login screen, which takes it once', () => {
setPendingSignInLink(null);
it('parks a sign-in link from the OS as external, for the login screen to take once', () => {
usePendingSignInLinkStore.getState().clear();
expect(acceptSignInLink('bulwarkmobile://mail')).toBe(false);
expect(acceptSignInLink('mailto:a@b.co')).toBe(false);
expect(usePendingSignInLinkStore.getState().payload).toBeNull();
expect(usePendingSignInLinkStore.getState().pending).toBeNull();
expect(acceptSignInLink(pair)).toBe(true);
expect(usePendingSignInLinkStore.getState().take()).toEqual({
kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code,
expect(usePendingSignInLinkStore.getState().pending).toMatchObject({
source: 'external',
payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code },
});
// Anyone can fire the link, so it is not run until the user confirms.
expect(usePendingSignInLinkStore.getState().take()).toMatchObject({
needsConfirmation: true,
payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code },
});
// Taken means cleared: a second delivery of the same state signs in nothing.
expect(usePendingSignInLinkStore.getState().payload).toBeNull();
expect(usePendingSignInLinkStore.getState().pending).toBeNull();
expect(usePendingSignInLinkStore.getState().take()).toBeNull();
usePendingSignInLinkStore.getState().clear();
});
it('parks connect links as external too', () => {
usePendingSignInLinkStore.getState().clear();
expect(acceptSignInLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')).toBe(true);
expect(usePendingSignInLinkStore.getState().take()).toMatchObject({
needsConfirmation: true,
payload: { kind: 'connect', webmailUrl: 'https://mail.example.com' },
});
usePendingSignInLinkStore.getState().clear();
});
});
@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { acceptSignInLink, routeParkedSignInLink } from '../linking';
import {
setPendingSignInLink,
signInLinkNeedsConfirmation,
usePendingSignInLinkStore,
} from '../pending-sign-in-link';
import type { QrLoginPayload } from '../../lib/oauth';
import { MAX_ACCOUNTS } from '../../lib/account-utils';
// A `bulwarkmail://` link from outside the app (any web page, one tap; any
// installed app, no tap) names a webmail and a code of the sender's choosing.
// It must not be redeemed or opened until the user says Continue; a scanned
// or pasted one is the user's own doing in the app and runs straight away.
const code = 'c0ffee'.repeat(10) + 'beef';
const link = (server: string) =>
`bulwarkmail://pair?server=${encodeURIComponent(server)}&code=${code}`;
const ATTACKER = link('https://attacker.example');
const OTHER = link('https://other.example');
const store = () => usePendingSignInLinkStore.getState();
beforeEach(() => store().clear());
describe('sign-in link confirmation', () => {
it('asks for links from outside the app, not for scanned or pasted ones', () => {
expect(signInLinkNeedsConfirmation('external')).toBe(true);
expect(signInLinkNeedsConfirmation('in-app')).toBe(false);
});
it('holds an external link until the user continues, then runs it once', () => {
acceptSignInLink(ATTACKER);
const taken = store().take();
expect(taken).toMatchObject({ needsConfirmation: true, payload: { kind: 'pair', webmailUrl: 'https://attacker.example', code } });
// Taken for the dialog, not handed out to run.
expect(store().pending).toBeNull();
expect(store().awaitingConfirmation?.id).toBe(taken!.id);
expect(store().take()).toBeNull();
expect(store().confirm(taken!.id)).toEqual({ kind: 'pair', webmailUrl: 'https://attacker.example', code });
expect(store().awaitingConfirmation).toBeNull();
// A second Continue (or a stale dialog) runs nothing.
expect(store().confirm(taken!.id)).toBeNull();
});
it('forgets a cancelled link, so nothing redeems it later', () => {
acceptSignInLink(ATTACKER);
const taken = store().take()!;
expect(store().discard(taken.id)).toBe(true);
expect(store().confirm(taken.id)).toBeNull();
expect(store().take()).toBeNull();
expect(store().pending).toBeNull();
expect(store().awaitingConfirmation).toBeNull();
expect(store().discard(taken.id)).toBe(false);
});
it('drops links delivered while the dialog is open instead of swapping them in', () => {
acceptSignInLink(ATTACKER);
const shown = store().take()!;
// A second 'url' event, or getInitialURL delivering again.
expect(acceptSignInLink(OTHER)).toBe(true);
expect(acceptSignInLink(ATTACKER)).toBe(true);
expect(store().pending).toBeNull();
expect(store().take()).toBeNull();
// Continue runs the link the dialog named, nothing else.
expect(store().confirm(shown.id)).toMatchObject({ webmailUrl: 'https://attacker.example' });
expect(store().take()).toBeNull();
});
it('asks again when a link is delivered again after the dialog closed', () => {
acceptSignInLink(ATTACKER);
const first = store().take()!;
store().discard(first.id);
acceptSignInLink(ATTACKER);
const again = store().take()!;
expect(again.needsConfirmation).toBe(true);
expect(again.id).not.toBe(first.id);
// The cancelled dialog's id cannot confirm the new delivery.
expect(store().confirm(first.id)).toBeNull();
expect(store().awaitingConfirmation?.id).toBe(again.id);
});
it('keeps the newest of two links parked before the login screen takes one', () => {
acceptSignInLink(ATTACKER);
acceptSignInLink(OTHER);
expect(store().take()).toMatchObject({ needsConfirmation: true, payload: { webmailUrl: 'https://other.example' } });
});
it('runs an in-app link without a dialog', () => {
const payload: QrLoginPayload = { kind: 'connect', webmailUrl: 'https://mail.example.com' };
expect(setPendingSignInLink(payload, 'in-app')).toBe(true);
expect(store().take()).toMatchObject({ needsConfirmation: false, payload });
expect(store().awaitingConfirmation).toBeNull();
expect(store().pending).toBeNull();
});
});
describe('refused sign-in links', () => {
it('parks a pairing link for a plain-http webmail as a refusal, with nothing to run', () => {
expect(acceptSignInLink(link('http://mail.example.com'))).toBe(true);
expect(store().pending).toBeNull();
expect(store().take()).toBeNull();
const refusal = store().takeRefusal();
expect(refusal).toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'mail.example.com' });
expect(store().takeRefusal()).toBeNull();
});
it('ignores other broken sign-in links, as before', () => {
expect(acceptSignInLink(`bulwarkmail://pair?server=${encodeURIComponent('https://mail.example.com')}`)).toBe(false);
expect(acceptSignInLink('bulwarkmail://unlock?server=https://mail.example.com')).toBe(false);
expect(store().refusal).toBeNull();
expect(store().pending).toBeNull();
});
});
describe('routeParkedSignInLink (signed in)', () => {
it('opens Add account while there is room', () => {
acceptSignInLink(ATTACKER);
expect(routeParkedSignInLink(MAX_ACCOUNTS - 1)).toBe('add-account');
// Still parked for Add account's login screen.
expect(store().pending).not.toBeNull();
});
it('drops the link and reports the limit when the registry is full, so no code is spent', () => {
acceptSignInLink(ATTACKER);
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit');
expect(store().pending).toBeNull();
expect(store().take()).toBeNull();
acceptSignInLink(link('http://mail.example.com'));
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit');
expect(store().refusal).toBeNull();
});
it('does nothing when no link is parked', () => {
expect(routeParkedSignInLink(0)).toBe('none');
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none');
});
it('leaves a link the dialog is asking about alone', () => {
acceptSignInLink(ATTACKER);
const shown = store().take()!;
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none');
expect(store().confirm(shown.id)).not.toBeNull();
});
});
+32 -7
View File
@@ -9,8 +9,9 @@ import type { RootStackParamList } from './types';
import { setPendingSettingsTab } from './pending-settings-tab';
import { setPendingCalendarOpen } from './pending-calendar-open';
import { setPendingMailSearch } from './pending-mail-search';
import { setPendingSignInLink } from './pending-sign-in-link';
import { parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
import { MAX_ACCOUNTS } from '../lib/account-utils';
export const APP_SCHEME = 'bulwarkmobile';
// The webmail's "Link Mobile App" links (`bulwarkmail://pair?server=…&code=…`)
@@ -184,17 +185,41 @@ export function parseSignInLink(url: string | null | undefined): QrLoginPayload
}
/**
* Park a tapped sign-in link for the login screen, which takes it (the
* signed-out one, or Add account when someone is signed in). Returns whether
* `url` was one.
* Park a sign-in link the OS delivered (a tapped link, or another app firing
* the intent) for the login screen, which takes it (the signed-out one, or
* Add account when someone is signed in) and asks the user before running
* it: whoever sent it chose the webmail and the code. A pairing link for a
* plain-http webmail is parked as a refusal instead, for the login screen to
* say why nothing happens. Returns whether `url` was a sign-in link.
*/
export function acceptSignInLink(url: string | null | undefined): boolean {
const payload = parseSignInLink(url);
if (!payload) return false;
setPendingSignInLink(payload);
if (payload) {
setPendingSignInLink(payload, 'external');
return true;
}
const refusal = url && isSignInSchemeUrl(url) ? insecurePairingLinkError(url) : null;
if (!refusal) return false;
usePendingSignInLinkStore.getState().refuse(refusal);
return true;
}
/**
* Signed in, where a parked sign-in link goes: Add account, whose login
* screen takes it, or, with no room for another account, nowhere: the link
* is dropped before a code is spent and the caller shows the limit. 'none'
* when nothing is parked (taken meanwhile by an Add account already open).
*/
export function routeParkedSignInLink(accountCount: number): 'add-account' | 'account-limit' | 'none' {
const links = usePendingSignInLinkStore.getState();
if (!links.pending && !links.refusal) return 'none';
if (accountCount >= MAX_ACCOUNTS) {
links.dropParked();
return 'account-limit';
}
return 'add-account';
}
export interface DeepLinkNavigator {
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
// Resolve a message id to its thread (EmailThread needs both). Returns
+102 -13
View File
@@ -1,26 +1,115 @@
import { create } from 'zustand';
import type { QrLoginPayload } from '../lib/oauth';
import type { PairingError, QrLoginPayload } from '../lib/oauth';
// A tapped `bulwarkmail://` sign-in link arrives before the login screen is
// up (cold start, or signed in with Add account not open yet). It is parked
// here and taken by LoginScreen, which runs it like a scanned code. Taking
// clears it, so one link signs in once however often it is delivered.
interface PendingSignInLinkState {
payload: QrLoginPayload | null;
set: (payload: QrLoginPayload | null) => void;
take: () => QrLoginPayload | null;
//
// Where a link came from decides whether the user is asked first:
// - `external`: the OS handed it to the app. Any web page (one tap) or any
// installed app (no tap at all) can fire one, with a webmail and a code of
// its own choosing, so the user confirms before it is redeemed or opened.
// - `in-app`: scanned with the app's scanner or pasted into its paste box.
// The user just did that in the app, so it runs straight away.
export type SignInLinkSource = 'external' | 'in-app';
export interface PendingSignInLink {
// Tells one delivery from the next, so a dialog can only ever confirm the
// link it named.
id: number;
payload: QrLoginPayload;
source: SignInLinkSource;
}
export interface TakenSignInLink {
id: number;
payload: QrLoginPayload;
// Show the confirmation and run the link only on Continue (`confirm`).
needsConfirmation: boolean;
}
export function signInLinkNeedsConfirmation(source: SignInLinkSource): boolean {
return source !== 'in-app';
}
interface PendingSignInLinkState {
// Parked, not taken by a login screen yet.
pending: PendingSignInLink | null;
// Taken and shown to the user, waiting for Continue or Cancel.
awaitingConfirmation: PendingSignInLink | null;
// A tapped link that is refused outright (a pairing code for a plain-http
// webmail): nothing to run or confirm, the login screen says why.
refusal: PairingError | null;
/** Park a link. Returns false when it was dropped (a dialog is open). */
park: (payload: QrLoginPayload, source: SignInLinkSource) => boolean;
/**
* Take the parked link. One that needs confirmation moves to
* `awaitingConfirmation`; run it only with what `confirm` returns.
*/
take: () => TakenSignInLink | null;
/** Continue: the payload to run, or null when that link is gone. */
confirm: (id: number) => QrLoginPayload | null;
/**
* Cancel: forget the link, so nothing redeems it later. Returns whether it
* was still waiting.
*/
discard: (id: number) => boolean;
refuse: (refusal: PairingError) => void;
takeRefusal: () => PairingError | null;
/** Drop what is parked (not a link a dialog is asking about). */
dropParked: () => boolean;
clear: () => void;
}
let nextId = 1;
export const usePendingSignInLinkStore = create<PendingSignInLinkState>((set, get) => ({
payload: null,
set: (payload) => set({ payload }),
take: () => {
const payload = get().payload;
if (payload) set({ payload: null });
return payload;
pending: null,
awaitingConfirmation: null,
refusal: null,
park: (payload, source) => {
// While the user is asked about one link, another is dropped rather
// than queued behind it or swapped in: Continue must run the link the
// dialog names and nothing else, and a link fired while the dialog is
// open must not pop up right after Cancel.
if (get().awaitingConfirmation) return false;
set({ pending: { id: nextId++, payload, source } });
return true;
},
take: () => {
const { pending, awaitingConfirmation } = get();
if (!pending || awaitingConfirmation) return null;
const needsConfirmation = signInLinkNeedsConfirmation(pending.source);
set({ pending: null, awaitingConfirmation: needsConfirmation ? pending : null });
return { id: pending.id, payload: pending.payload, needsConfirmation };
},
confirm: (id) => {
const link = get().awaitingConfirmation;
if (!link || link.id !== id) return null;
set({ awaitingConfirmation: null });
return link.payload;
},
discard: (id) => {
if (get().awaitingConfirmation?.id !== id) return false;
set({ awaitingConfirmation: null });
return true;
},
refuse: (refusal) => set({ refusal }),
takeRefusal: () => {
const refusal = get().refusal;
if (refusal) set({ refusal: null });
return refusal;
},
dropParked: () => {
const { pending, refusal } = get();
if (!pending && !refusal) return false;
set({ pending: null, refusal: null });
return true;
},
clear: () => set({ pending: null, awaitingConfirmation: null, refusal: null }),
}));
export function setPendingSignInLink(payload: QrLoginPayload | null): void {
usePendingSignInLinkStore.getState().set(payload);
export function setPendingSignInLink(payload: QrLoginPayload, source: SignInLinkSource): boolean {
return usePendingSignInLinkStore.getState().park(payload, source);
}
+115 -9
View File
@@ -1,12 +1,17 @@
import React from 'react';
import { BackHandler } from 'react-native';
import { Alert, BackHandler } from 'react-native';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
import { useLocaleStore } from '../stores/locale-store';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { QrScanModal } from '../components/QrScanModal';
import { PasteSignInLinkModal } from '../components/PasteSignInLinkModal';
import { parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
import {
insecurePairingLinkError,
parseQrLoginPayload,
signInLinkHost,
type QrLoginPayload,
} from '../lib/oauth';
import { usePendingSignInLinkStore } from '../navigation/pending-sign-in-link';
import {
discoverServerForEmail,
@@ -33,6 +38,31 @@ type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
const RECENT_EMAILS_KEY = 'login:recentEmails:v1';
// The question asked before running a sign-in link from outside the app:
// which webmail it talks to, and whether it adds an account (and switches to
// it) or signs in.
function signInLinkConfirmation(
payload: QrLoginPayload,
isAddMode: boolean,
t: TranslateFn,
): { title: string; message: string } {
const host = signInLinkHost(payload.webmailUrl);
if (payload.kind === 'connect') {
return {
title: t('login.mobile.link_confirm_connect_title', 'Open the sign-in page of {host}?', { host }),
message: isAddMode
? t('login.mobile.link_confirm_connect_add', 'A link asks to add an account by signing in on this page. Only continue if you use {host} for your mail.', { host })
: t('login.mobile.link_confirm_connect', 'A link asks to sign in on this page. Only continue if you use {host} for your mail.', { host }),
};
}
return {
title: t('login.mobile.link_confirm_title', 'Sign in with a link?'),
message: isAddMode
? t('login.mobile.link_confirm_pair_add', 'This link adds an account from {host} and switches the app to it. Only continue if you just showed a sign-in code on your computer.', { host })
: t('login.mobile.link_confirm_pair', 'This link signs in to an account from {host}. Only continue if you just showed a sign-in code on your computer.', { host }),
};
}
/**
* Sign-in, one question per screen, cheapest question first.
*
@@ -334,6 +364,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
// A pairing code for a plain-http webmail: say why it won't be used.
const insecure = insecurePairingLinkError(data);
if (insecure) {
setNotice(describeLoginError(insecure, { t }));
return;
}
setNotice({
title: t('login.mobile.notice_bad_qr', "That code isn't a Bulwark sign-in code"),
detail: t('login.mobile.notice_bad_qr_detail', 'Open Bulwark on the web, then Settings → Security → Link Mobile App to show one.'),
@@ -345,14 +381,84 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
[handleSignInLink, t],
);
// A tapped sign-in link is parked by App until a login screen is free to
// run it; taking it clears it, so one link signs in once.
const signInLinkPending = usePendingSignInLinkStore((s) => s.payload !== null);
// A sign-in link from outside the app (a tapped link, or one another app
// fired) is parked by App until a login screen is free to take it; taking
// clears it, so one link runs once. Whoever sent it chose the webmail and
// the code, so the user confirms first: nothing is redeemed or opened
// before Continue, and Cancel (or dismissing the dialog) forgets the link.
// Scanned and pasted links skip this: they call handleSignInLink directly.
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null);
// The dialog answers after later renders; run the current callbacks.
const latestRef = React.useRef({ handleSignInLink, onCancel });
React.useEffect(() => {
latestRef.current = { handleSignInLink, onCancel };
}, [handleSignInLink, onCancel]);
const mountedRef = React.useRef(false);
const confirmingLinkIdRef = React.useRef<number | null>(null);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
// Gone while the dialog is up: forget its link, so a late Continue runs
// nothing and later links are not dropped as "dialog open". Checked a
// tick later, as a StrictMode rehearsal remounts straight away.
setTimeout(() => {
const id = confirmingLinkIdRef.current;
if (!mountedRef.current && id !== null) usePendingSignInLinkStore.getState().discard(id);
}, 0);
};
}, []);
React.useEffect(() => {
if (!signInLinkPending || busy || searching) return;
const payload = usePendingSignInLinkStore.getState().take();
if (payload) void handleSignInLink(payload);
}, [busy, handleSignInLink, searching, signInLinkPending]);
const taken = usePendingSignInLinkStore.getState().take();
if (!taken) return;
if (!taken.needsConfirmation) {
void handleSignInLink(taken.payload);
return;
}
// Add account opened for this link closes again on Cancel; one the user
// was already working through stays where it was.
const leaveOnCancel = isAddMode && history.length === 0;
confirmingLinkIdRef.current = taken.id;
let answered = false;
const answer = (proceed: boolean) => {
if (answered) return;
answered = true;
confirmingLinkIdRef.current = null;
const store = usePendingSignInLinkStore.getState();
if (!mountedRef.current) {
store.discard(taken.id);
return;
}
if (proceed) {
// Only the link this dialog named, and only if it is still waiting.
const payload = store.confirm(taken.id);
if (payload) void latestRef.current.handleSignInLink(payload);
return;
}
if (store.discard(taken.id) && leaveOnCancel) latestRef.current.onCancel?.();
};
const { title, message } = signInLinkConfirmation(taken.payload, isAddMode, t);
Alert.alert(
title,
message,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(false) },
{ text: t('login.mobile.continue', 'Continue'), onPress: () => answer(true) },
],
// Android back or a tap outside the dialog counts as Cancel.
{ cancelable: true, onDismiss: () => answer(false) },
);
}, [busy, handleSignInLink, history.length, isAddMode, searching, signInLinkPending, t]);
// A tapped link that is refused outright (a pairing code for a plain-http
// webmail) ran nothing; say why, as for a scanned one.
const signInLinkRefused = usePendingSignInLinkStore((s) => s.refusal !== null);
React.useEffect(() => {
if (!signInLinkRefused || busy || searching) return;
const refusal = usePendingSignInLinkStore.getState().takeRefusal();
if (refusal) setNotice(describeLoginError(refusal, { t }));
}, [busy, searching, signInLinkRefused, t]);
// ── render ─────────────────────────────────────────────