From b85fd35cb01fa58dff991517740c20c45a9bfd29 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:19:25 -0300 Subject: [PATCH 1/8] docs(spec): mobile PWA to approve and answer (section 28) Design for the phone: sealed end-to-end relay through the cloud server, QR pairing, restricted phone token, approval/question bridge rules, Web Push without content, milestones CE1-CE7. Stays local until the implementation lands (no docs-only PRs). --- docs/spec.md | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 1b2f5b1c..cba6b543 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -780,6 +780,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time - Nome de anexo vira só o nome do arquivo (sem `..`, sem pasta, sem caracteres proibidos no Windows) antes de ir para o disco. - Ferramentas conectadas (25): só o dono cadastra, pelo app. Um servidor `stdio` roda com os poderes do dono, fora das cercas de 7.5, e um `http` recebe o que o bot mandar; o cadastro diz isso e pede confirmação. Segredos dos servidores ficam em `secrets\mcp` e no ambiente dos bots ligados, nunca no banco, no `mcp.json` ou no log. - Conta e cópias na nuvem (27): opcionais e só por ação do dono. O token do aparelho fica em `secrets\cloud.json` (ACL do usuário), o servidor guarda dele só o SHA-256, e a cópia sai selada com a senha do dono, que não vai ao servidor. O cliente só fala `https` (fora `127.0.0.1`). Não é telemetria: nada é enviado sem o clique do dono. +- Celular (28): só aprova e responde, com selo de ponta a ponta entre o computador e o celular (ECDH P-256, HKDF, AES-256-GCM); as chaves vêm de um QR cujo segredo fica no fragmento do link e nunca chega ao servidor. O token de um celular só alcança o relay e o push, nunca as cópias. As chaves ficam em `secrets\mobile.json` (ACL do usuário). Nada de conversa, arquivo ou comando passa em claro, e o aviso de push nunca leva conteúdo. - Isolamento entre bots é cooperativo (mesmo usuário do Windows). Documentar isso no README sem prometer sandbox. - Navegador dos bots (21): perfil próprio por bot, sem os logins do navegador do dono. A porta do DevTools escuta só em 127.0.0.1 e não tem senha: enquanto o navegador está aberto, outro programa do computador pode controlá-lo, como pode fazer com o resto do que roda com o usuário. Páginas não chegam a ela: o Chromium recusa WebSocket com `Origin` de site sem `--remote-allow-origins`. Trocar a porta por um pipe fica para depois. O texto das páginas pode trazer instruções para o bot; as tools dizem a ele que não valem como pedido do dono, e nos modos que perguntam, cada site novo passa pelo dono (21.5). - Pasta de trabalho escolhida (5): todos os bots da crew a editam como a própria pasta. Por isso ela não pode tocar a pasta de dados do Botloft (segredos, banco), conter os workspaces de todos os bots, encostar nas pastas de outra crew nem ser um disco inteiro. @@ -2102,7 +2103,7 @@ Finanças, jurídico e saúde, que pedem aviso de que não substituem um profiss ### 27.1 O que é -A cópia de segurança de 14.2 já protege contra formatar o computador, mas só se o dono guardar o arquivo em outro lugar. Esta seção traz uma **conta** opcional e um lugar onde as cópias ficam guardadas: o dono envia a cópia selada pelo app e a baixa de volta em qualquer computador. É o primeiro passo para o celular (aprovar e responder de longe), que fica para depois (27.9). +A cópia de segurança de 14.2 já protege contra formatar o computador, mas só se o dono guardar o arquivo em outro lugar. Esta seção traz uma **conta** opcional e um lugar onde as cópias ficam guardadas: o dono envia a cópia selada pelo app e a baixa de volta em qualquer computador. É o primeiro passo para o celular (aprovar e responder de longe, seção 28). Três promessas valem para tudo aqui: @@ -2200,7 +2201,7 @@ Configurações, "Cópia de segurança" (14.2) ganha o bloco **Conta**, acima de ### 27.9 Fora desta etapa -Conversas, anexos e pastas de trabalho na nuvem, envio automático no Linux e no macOS (pede um cofre de senhas de cada sistema), retomar um envio, cópias só do que mudou, plano pago e cotas por plano, login por Google ou GitHub, recuperar uma senha de cópia perdida, trocar o servidor sem perder as cópias, e o celular: o relay pelo servidor, o pareamento por QR e o app com aprovações e perguntas, que usam esta conta e vêm em seguida. +Conversas, anexos e pastas de trabalho na nuvem, envio automático no Linux e no macOS (pede um cofre de senhas de cada sistema), retomar um envio, cópias só do que mudou, plano pago e cotas por plano, login por Google ou GitHub, recuperar uma senha de cópia perdida, e trocar o servidor sem perder as cópias. O celular (o relay pelo servidor, a conexão por QR e o PWA com aprovações e perguntas) usa esta conta e está na seção 28. ### 27.10 Envio automático @@ -2227,3 +2228,147 @@ Notificação: `autobackup.changed`, com o status, depois de cada olhada e de ca No app, o bloco **Cópias automáticas** fica na conta, entre "Enviar uma cópia agora" e "Cópias na nuvem". Desligado: uma frase do que acontece, a senha duas vezes, "Com que frequência" ("Todo dia" ou "Toda semana") e "Ligar". Ligado: "Ligado: uma cópia por dia, se algo mudou", "Última cópia enviada: …" (ou "Nenhuma cópia enviada ainda"), "Próxima vez que olha: …", a última falha em palavras (os `reasons` de 27.6, mais `no_keystore` e `no_passphrase`), o seletor e "Desligar". Sem cofre: "As cópias automáticas só funcionam no Windows por enquanto." Textos nos três idiomas. Testes: `crates/botloft-store` (a impressão digital muda com o que o dono faz e não com o que muda sozinho), `crates/botloftd/tests/autobackup.rs` (o servidor de 27.2 no processo e o relógio na mão: diário, semanal, sem mudança, cópia à mão, falha de rede, senha sumida, sair da conta, `no_keystore`), o cofre do Windows de verdade (`platform::windows::credentials`), e o bloco do app com o `FakeBotloft`. + +## 28. Celular + +Status: **CE1 (spec).** Vem depois de 27 e usa a conta dela. É o "celular" que 27.9 deixou para depois. + +### 28.1 O que é + +Uma página que o dono instala na tela inicial do celular (um PWA) para **aprovar pedidos dos bots e responder às perguntas deles** (10.1, 23) quando está longe do computador. O celular é um controle remoto: os bots continuam rodando no computador, e o `botloftd` continua só em `127.0.0.1` (13). Nada de código nativo, de loja ou de conta de desenvolvedor. + +O que o celular **faz**: mostra os pedidos de aprovação abertos, permite ou nega um de cada vez (com nota), mostra as perguntas abertas, responde ou descarta, e avisa quando chega algo novo. + +O que o celular **não faz** nesta etapa: ver conversas, mandar mensagem a um bot, ver arquivos, criar bot, crew, tarefa ou rotina, mexer em configurações. Quanto menos o celular sabe, menos há para vazar de um celular perdido. + +Quatro promessas valem para tudo aqui: + +- **É opcional e é do dono.** Exige a conta de 27. Sem nenhum celular conectado, o daemon não abre conexão nenhuma além das de 27. Conectar e desconectar é sempre um clique no computador. +- **O servidor não lê nada.** Pedidos, perguntas e respostas passam **selados de ponta a ponta** entre o computador e o celular, com chaves que só os dois têm (28.3). O servidor repassa bytes e sabe só que existe tráfego, de que tamanho e quando. +- **Quem entra no e-mail não vira celular.** O link do e-mail (27.3) abre a conta, mas um celular só entra por um QR que o computador mostra, e só fala com os bots quem tem as chaves do QR. +- **O celular perdido se corta pelo computador:** "Desconectar" tira o aparelho do servidor e do daemon na hora (28.6). + +### 28.2 Quem é quem + +| Peça | Papel | +|---|---| +| computador | o `botloftd` com os bots; abre **uma** conexão de saída para o servidor (`wss`), nunca recebe | +| servidor (`botloft-cloud`, 27.2) | autentica os aparelhos, liga o computador ao celular pareado com ele e entrega o PWA em `/m`; não abre o conteúdo | +| celular | o PWA; guarda as chaves e o token no navegador (IndexedDB, chaves não extraíveis) | + +A conta de 27 ganha o **tipo** do aparelho (`computer` ou `phone`) e, no celular, o **par** (`peer`): o computador com que ele foi conectado. Um celular conectado a dois computadores são duas conexões, cada uma com o seu token e as suas chaves. + +**O token de um celular só alcança o relay, o push e `GET /v1/me`.** As cópias (27.4), o login e a lista de aparelhos dão `403` `wrong_device`. Um celular nunca baixa uma cópia. + +### 28.3 Conectar um celular e selar as mensagens + +Criptografia que existe em todo navegador (WebCrypto) e em crates do RustCrypto, para o PWA não carregar biblioteca de criptografia: **ECDH P-256**, **HKDF-SHA-256**, **HMAC-SHA-256** e **AES-256-GCM**. + +1. No app (28.7), "Conectar um celular" chama `mobile.pair_start`. O daemon sorteia `pair_id` (16 bytes) e `secret` (32 bytes), a chave efêmera dele (`D`), e avisa o servidor (`POST /v1/pairings {id}`, com o token do computador; vale 5 minutos e uma vez). O app mostra um **QR** (gerado no app, sem serviço externo) com `/m#p=&s=`. O que vem depois do `#` o navegador nunca envia: o servidor não vê o `secret`. +2. O celular abre o link, lê o fragmento e gera a chave dele (`P`). Manda `POST /v1/pairings//join {phone_pub, device_name, proof}`, com `proof = HMAC(secret, "botloft-pair-1" ‖ id ‖ phone_pub)`. O servidor guarda e avisa o computador pelo relay. +3. O daemon confere a `proof`; errada, recusa. Certa, mostra no app o **nome do celular e um código de 6 dígitos** que sai das duas chaves públicas (`HKDF(secret, D ‖ P)`, 20 bits), e o celular mostra o mesmo código. O dono compara e clica **Conectar** (`mobile.pair_confirm`): é isso que impede quem fotografou o QR de entrar no lugar do dono. +4. O daemon manda `POST /v1/pairings//accept {daemon_pub, proof2}` (`proof2 = HMAC(secret, "botloft-pair-1-accept" ‖ id ‖ D ‖ P)`). O servidor cria o aparelho `phone` com o `peer` e, na primeira consulta do celular (`GET /v1/pairings/`, uma vez), entrega `{token, device, daemon_pub, proof2}`. O celular confere `proof2`: um servidor que trocasse a chave `D` não tem o `secret` e não consegue forjá-la. +5. Os dois derivam `shared = ECDH(D, P)` e, com `HKDF(shared, salt = secret, info = "botloft-mobile-1")`, duas chaves AES-256: `k_c2p` (computador para celular) e `k_p2c`. A chave de cada direção só sela naquela direção. O `secret` é apagado dos dois lados. + +**Mensagem selada** (`seal`): `{v: 1, seq, ct}`. `ct` é AES-256-GCM com nonce de 12 bytes (`seq` em 8 bytes big-endian, 4 bytes zero) e dados autenticados `v ‖ direção ‖ device_id ‖ seq`. Quem recebe guarda o maior `seq` visto por direção e **recusa** qualquer um que não seja maior (repetição); o celular guarda o dele no IndexedDB. Trocar de chave é conectar de novo. Vetores de teste (chaves, nonces e textos de verdade) ficam num arquivo único (`docs/test-vectors/mobile.json`), gerado pelo Rust e lido pelo teste do PWA: os dois lados têm que concordar byte a byte. + +**Dentro do selo,** JSON de uma linha, no máximo 16 KiB: + +| Sentido | Mensagem | +|---|---| +| celular → computador | `sync` (pede o retrato de agora); `approval.answer {approvalId, allow, note?}`; `question.answer {questionId, answer}`; `question.dismiss {questionId}` | +| computador → celular | `snapshot {approvals, questions}` (resposta de `sync`); `approval.open {card}`; `approval.closed {approvalId, status}`; `question.open {card}`; `question.closed {questionId, status}` | + +O `card` é o que o celular mostra (28.5), não o `Approval` nem a `Question` inteiros. + +### 28.4 O relay (servidor) + +- **Aparelhos.** A migration do crate acrescenta `devices.kind` (`computer` por padrão, para o que já existe) e `devices.peer`, mais as tabelas `pairings` (`id`, `account`, `computer`, `phone_pub`, `proof`, `daemon_pub`, `proof2`, `token_once`, `expires_at`) e `relay_queue` (`device`, `seq`, `body`, `expires_at`). `DELETE /v1/devices/` num computador leva os celulares dele, e num celular fecha o socket dele na hora. +- **`GET /v1/relay`** (WebSocket, `Authorization: Bearer`). O computador e cada celular abrem um. O servidor liga um celular só ao `peer` dele; um segundo socket do mesmo aparelho substitui o primeiro. Quadros em JSON de até 64 KiB: `{t: "msg", seq, body}` (o `body` é o selo, em base64, que o servidor **não abre**), `{t: "ack", seq}`, `{t: "presence", peer: "online" | "offline"}` (para o celular saber se o computador está no ar), `{t: "wake"}` (28.8) e `{t: "ping"}` a cada 25 s (os proxies cortam conexões quietas, e a Cloudflare corta em 100 s). Limite de 60 quadros por minuto por aparelho (`429`). +- **Fila só no sentido do computador.** Uma resposta do celular enquanto o computador está sem conexão (uma queda de rede) espera até 1 hora, no máximo 50 quadros, em ordem de `seq`, e sai da fila no `ack` do daemon. **Do computador para o celular não há fila:** quando o celular abre, ele manda `sync` e recebe o retrato de agora. O celular não guarda nenhum conteúdo no aparelho entre uma abertura e outra, só a lista em memória. +- **O daemon decide, o servidor só leva.** Uma resposta que o servidor entregou mas o daemon recusa (pedido que já expirou, `seq` repetido, selo que não abre) é descartada no daemon e o celular recebe `approval.closed` ou `question.closed` com o estado verdadeiro. +- **Reconexão.** O daemon reconecta com espera crescente (1 s até 60 s); só para de tentar quando o dono tira o último celular ou sai da conta. +- **Logs do servidor:** nunca o `body`, o token nem o nome do aparelho em `info`. + +### 28.5 No daemon (`mobile`) e o que o celular pode + +Métodos novos do protocolo (11), todos só do dono: + +| Método | O que faz | +|---|---| +| `mobile.status` | `{relay: "off" \| "connecting" \| "connected", phones: [{id, name, pairedAt, lastSeenAt?, online}], pending?}` | +| `mobile.pair_start` | começa a conexão (28.3); devolve `{pairId, url, expiresIn}`, e `url` é o que o QR leva. Exige a conta (`not_signed_in`) | +| `mobile.pair_cancel {pairId}` | desiste | +| `mobile.pair_confirm {pairId, accept}` | o dono compara o código e aceita ou recusa | +| `mobile.revoke {phoneId}` | `DELETE /v1/devices/`, apaga as chaves e para de aceitar selos desse celular. Se o servidor não responde, apaga as chaves do mesmo jeito e tenta o servidor depois | + +Notificações: `mobile.changed {status}` e `mobile.pair_request {pairId, name, code}` (o celular entrou no link; o app mostra o nome e o código). Erros com `reason` (20.8): os de 27.5 mais `pair_expired`, `bad_proof` e `unknown_phone`. As chaves, o token e o `seq` de cada celular ficam em `secrets\mobile.json` (ACL do usuário, 13), nunca no banco; a cópia de segurança (14.2) não leva segredos, então **restaurar num computador novo pede conectar o celular de novo**. O relay só está aberto com pelo menos um celular conectado ou uma conexão em andamento. + +**O que vai ao celular.** Cada pedido aberto (10.1) e cada pergunta aberta (23.3) vira um `card`: o bot (nome e cor), a crew, a hora, e para o pedido, o tipo, o `summary`, a `explanation` e o texto do comando ou do plano; para a pergunta, o texto e as opções. Nada de `Bot`, de conversa, de caminho de pasta do dono além do que o próprio pedido mostra. + +**Regras de segurança do celular** (o daemon as aplica; o PWA só as mostra): + +1. **Só permite o que mostra inteiro.** Um pedido cujo texto passa de 8 KiB (comando, plano, diff) vai ao celular **cortado e marcado**, com só "Negar" e "Abra no computador". Ninguém aprova no escuro. +2. **Só "uma vez".** O celular não grava "permitir sempre" (`always`) e não edita a entrada (`input`). A regra durável é decisão de quem está no computador. +3. **O que muda o computador de forma durável fica no computador.** Sugestão de bot (10.2), rotina pedida por bot (20.12), acesso a outra crew (10.4), permissão de desktop (24.2), ferramentas conectadas novas (25.4) e tudo do navegador em mãos (21.10) aparecem no celular só como "Precisa de você no computador", com "Negar" quando couber. O celular nunca recebe `browser_ask_owner` nem nada que peça senha ou código (23.2). +4. **O mesmo caminho do app.** `approval.answer` chama a mesma lógica de `approvals.answer` (10.1), e `question.answer` e `question.dismiss` as de `questions.*` (23.3): `conflict` se já foi respondido, `expired` se venceu. Responder no celular fecha o cartão no computador e vice-versa, nos dois sentidos, na hora (`approval.closed`, `question.closed`). +5. **Rate limit** no daemon: 30 respostas por minuto por celular. +6. **Nunca se loga** o selo, o `card` nem a resposta em `info` (13). + +### 28.6 Celular perdido, roubado ou trocado + +- **Desconectar** (app, 28.7) chama `mobile.revoke`: o servidor apaga o token e fecha o socket, o daemon esquece as chaves e descarta qualquer selo daquele celular. O celular fica com chaves que não servem para nada. +- **Sair da conta** (27.3) apaga todos os celulares do computador. **Apagar a conta** leva os aparelhos e as filas. +- Um celular roubado **e desbloqueado** consegue permitir pedidos de uma vez só e dentro das regras de 28.5 até o dono desconectá-lo. É o risco que a pessoa aceita ao receber pedidos no bolso. O PWA mostra o que está permitindo, e a tela do app diz isso na hora de conectar. +- O servidor, comprometido, não consegue entrar numa conversa com os bots nem aprovar nada: não tem as chaves, e uma chave `D` trocada na conexão não passa em `proof2` (28.3). Consegue apagar mensagens e fechar sockets (o dono vê "sem conexão" e resolve no computador), e ver quando e quanto passa (28.9). + +### 28.7 Telas + +**App do computador:** Configurações, bloco **Celular** (abaixo de Conta, 27.6). Sem conta: "Entre na conta para usar o celular", com o botão que leva ao bloco Conta. Com conta e sem celular: uma frase do que o celular faz e não faz ("Aprove pedidos e responda perguntas dos bots de qualquer lugar. As conversas ficam só no computador."), e "Conectar um celular". Conectando: o QR, "Abra a câmera do celular e aponte para o código", o tempo que falta e "Cancelar"; quando o celular entra, "O celular de Ana quer entrar. Confira que o código é 482 913 nos dois", com "Conectar" e "Recusar". Com celulares: cada um com o nome, quando entrou, "online agora" ou "visto há 2 h", e **"Desconectar"** com a confirmação que diz que o celular perde o acesso na hora. Aviso fixo, em palavras do dia a dia, de que quem tiver o celular desbloqueado pode aprovar pedidos (28.6). Textos nos três idiomas, sem jargão (15.6): "celular", "conectar", "código"; nunca "relay", "pareamento", "token", "chave" nem "daemon". + +**PWA (`/m`):** a mesma identidade visual do app (cores, fonte, mascote, tema do aparelho), uma coluna, feito para o polegar, em `pt-BR`, `en` e `es` pelo idioma do aparelho. +- **Caixa:** a lista do que espera o dono, pedidos e perguntas misturados, o mais velho primeiro (o que vence antes), cada linha com o rosto do bot, o nome, a crew e a hora; vazio mostra o mascote e "Nada esperando você". Se o computador está fora do ar: "O computador está desligado ou sem internet. Os pedidos continuam esperando lá." +- **Pedido:** o cartão de 15.1 simplificado: o bot, o que ele quer fazer em uma frase (a `explanation`, marcada como escrita pelo bot, ou o aviso de que ele não disse para que serve), o comando inteiro a um toque, **Permitir** e **Negar** (com campo de nota). Cartão cortado ou do tipo que fica no computador: só "Negar" e a explicação. +- **Pergunta:** o texto em markdown, as opções como botões e um campo para outra resposta, com **Responder** e **Descartar**. +- **Este celular:** nome, "Desconectar daqui" (apaga as chaves e avisa o computador) e, no iOS fora da tela inicial, a instrução de "Adicionar à Tela de Início" (o aviso por push só chega depois disso). +- O PWA é uma entrada do `app/` (`app/mobile`) que reusa os componentes, o tema e o `i18n`, atrás de uma interface própria, `MobileApi`, com um `FakeMobile` para os testes. É compilado para dentro do `botloft-cloud` (um binário só). `Content-Security-Policy`: `default-src 'self'`, `connect-src 'self' wss:`, sem script de terceiros. O service worker guarda só o casco da página (nunca um `card`). + +### 28.8 Avisos (Web Push) + +Quando chega um pedido ou uma pergunta, o dono precisa saber sem abrir a página. O daemon manda ao servidor o quadro `{t: "wake"}`, **sem conteúdo**, no máximo um a cada 30 s por computador (os avisos que chegam no meio se juntam num só). O servidor manda um Web Push (RFC 8030, VAPID) a cada celular conectado a esse computador. + +- `POST /v1/push/subscribe {endpoint, p256dh, auth, locale}` (só do celular) e `DELETE /v1/push/subscribe`. O servidor guarda isso por celular. O `cloud.toml` ganha `[push]` com `subject` (um `mailto:` ou `https:`) e `vapid_key_file` (como o `password_file` do `smtp`, 27.2); sem `[push]`, o servidor não oferece push e o PWA mostra só o que há ao abrir. +- O texto do aviso é **sempre o mesmo** e escrito no idioma do celular: "O Botloft precisa de você". Não leva o nome do bot, o comando nem a pergunta: os serviços de push (Apple, Google, Mozilla) veem o que passa por eles. Tocar abre a caixa, e o conteúdo vem pelo relay, selado. +- Endereço de push recusado pelo serviço (`410`) é apagado. + +### 28.9 Privacidade (13) + +- Nada vai à rede sem um celular conectado pelo dono (28.1). Desconectar o último celular fecha a conexão. +- O servidor vê: o e-mail, os nomes dos aparelhos que entraram, o IP de quem conecta (no proxy, por 7 dias, 27.7), quando cada um está online, o **tamanho e a hora** de cada mensagem selada, e os endereços de push. Não vê: nome de bot ou de equipe, comando, plano, pergunta, resposta, nota, nem as chaves. +- Quem controla o servidor pode ver **que** o dono aprovou algo às 14h, e quanto tempo levou, mas não o quê. +- Os serviços de push veem que "um aviso do Botloft" chegou a um aparelho, e nada além. +- Sem telemetria: nenhuma contagem de uso do celular sai do aparelho. + +### 28.10 Marcos + +| Marco | O que entra | Teste | +|---|---|---| +| **CE1** Spec | esta seção | revisão do dono | +| **CE2** Servidor | migration (`devices.kind`, `peer`, `pairings`, `relay_queue`), `/v1/pairings`, `/v1/relay`, fila no sentido do computador, token de celular restrito, `DELETE /v1/devices` com celulares | dois pares de clientes de teste: o QR de ponta a ponta no processo; `403` `wrong_device` nas cópias; a fila entrega em ordem e sai no `ack`; um selo adulterado chega adulterado (o servidor não abre); revogar fecha o socket; pareamento vence e vale uma vez | +| **CE3** Daemon | cliente do relay com reconexão, `seal` (P-256, HKDF, AES-GCM) com os vetores de teste, conexão, `secrets\mobile.json`, métodos e notificações, a ponte de aprovações e perguntas com as regras de 28.5, tipos gerados | o servidor de CE2 no processo e um "celular" de teste em Rust: conectar com o código, receber o retrato, permitir e negar, responder uma pergunta, `seq` repetido, selo adulterado, pedido já respondido no computador, cartão grande, tipo que fica no computador, sem `always`, revogar | +| **CE4** App do computador | bloco Celular com o QR e o código, `FakeBotloft`, três idiomas | `pnpm check`; a tela no preview | +| **CE5** PWA | a entrada `app/mobile`, `MobileApi` e `FakeMobile`, a caixa, os cartões, WebCrypto contra os vetores, três idiomas, servido pelo `botloft-cloud` | `pnpm check`; os vetores do Rust abrem no PWA e os do PWA abrem no Rust; Chromium real no Docker (`linux-docker-testing`) com o servidor e um daemon de teste: conectar e aprovar ponta a ponta | +| **CE6** Avisos | `[push]`, VAPID, `/v1/push/*`, `wake` com limite, service worker | teste com um serviço de push falso: um `wake` vira um push sem conteúdo; `410` apaga o endereço; vários pedidos seguidos viram um aviso | +| **CE7** Teste real | um iPhone e um Android de verdade, a VPS de verdade, o Claude Code real: um bot pede para rodar um comando e o dono permite pelo celular; aviso com a tela bloqueada; celular desconectado pelo computador | registrado aqui, em "A verificar" | + +**A verificar (CE7).** Nada disso é certo até ser visto: + +- o leitor de QR da câmera do iOS e do Android preserva o `#` do link (os dois abrem o fragmento no navegador); +- ECDH P-256 e HKDF no WebCrypto do Safari (iOS 16.4 ou mais novo) e do Chrome do Android dão os mesmos bytes que o RustCrypto (os vetores cobrem; falta o aparelho); +- IndexedDB guarda chave `CryptoKey` não extraível no PWA instalado do iOS e não é apagada por falta de uso (o iOS apaga dados de sites que não são abertos por semanas, mas um PWA instalado fica fora dessa regra: confirmar); +- Web Push chega no iOS com o PWA instalado e a tela bloqueada, e no Android com o Chrome fechado; +- o proxy (Caddy; Cloudflare, se houver) mantém o WebSocket de `/v1/relay` aberto por horas com o ping de 25 s. + +### 28.11 Fora desta etapa + +Conversa, mensagem aos bots, arquivos, tarefas e rotinas no celular; app nativo e lojas; trocar as chaves sem conectar de novo; trava com biometria ou PIN dentro do PWA; mais de um dono; ligação direta entre celular e computador sem o servidor (WebRTC); aviso com conteúdo; permitir "sempre" pelo celular. From 949c9c55448ea3bf5b665954b5e8172f24f3aa67 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:54:24 -0300 Subject: [PATCH 2/8] feat(cloud): relay and phone pairing for the mobile app (spec 28, CE2) devices get a kind (computer/phone) and a peer; a phone token reaches only the relay, /v1/me and logout. Pairings (open, join, accept, collect once), a WebSocket relay that routes sealed bodies without opening them, a queue per phone acknowledged by the computer, presence, revocation that closes sockets, and rate limits. Spec 28 updated to match. --- Cargo.lock | 1 + crates/botloft-cloud/Cargo.toml | 3 +- .../botloft-cloud/migrations/0003_mobile.sql | 38 ++ crates/botloft-cloud/src/accounts.rs | 71 +-- crates/botloft-cloud/src/app.rs | 13 +- crates/botloft-cloud/src/copies.rs | 10 +- crates/botloft-cloud/src/db.rs | 3 + crates/botloft-cloud/src/devices.rs | 142 ++++++ crates/botloft-cloud/src/error.rs | 24 + crates/botloft-cloud/src/hub.rs | 112 +++++ crates/botloft-cloud/src/lib.rs | 7 + crates/botloft-cloud/src/limits.rs | 6 + crates/botloft-cloud/src/login.rs | 4 +- crates/botloft-cloud/src/main.rs | 5 +- crates/botloft-cloud/src/pairing.rs | 245 +++++++++++ crates/botloft-cloud/src/pairing_rows.rs | 222 ++++++++++ crates/botloft-cloud/src/relay.rs | 272 ++++++++++++ crates/botloft-cloud/src/relay_rows.rs | 89 ++++ crates/botloft-cloud/src/removal.rs | 13 +- crates/botloft-cloud/src/session.rs | 81 +++- crates/botloft-cloud/tests/common/mobile.rs | 205 +++++++++ crates/botloft-cloud/tests/common/mod.rs | 5 +- crates/botloft-cloud/tests/pairing.rs | 416 ++++++++++++++++++ crates/botloft-cloud/tests/relay.rs | 341 ++++++++++++++ crates/botloftd/tests/common/cloud_server.rs | 3 +- docs/spec.md | 16 +- 26 files changed, 2244 insertions(+), 103 deletions(-) create mode 100644 crates/botloft-cloud/migrations/0003_mobile.sql create mode 100644 crates/botloft-cloud/src/devices.rs create mode 100644 crates/botloft-cloud/src/hub.rs create mode 100644 crates/botloft-cloud/src/pairing.rs create mode 100644 crates/botloft-cloud/src/pairing_rows.rs create mode 100644 crates/botloft-cloud/src/relay.rs create mode 100644 crates/botloft-cloud/src/relay_rows.rs create mode 100644 crates/botloft-cloud/tests/common/mobile.rs create mode 100644 crates/botloft-cloud/tests/pairing.rs create mode 100644 crates/botloft-cloud/tests/relay.rs diff --git a/Cargo.lock b/Cargo.lock index abb5aeb4..e6d54482 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,6 +513,7 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "tokio", + "tokio-tungstenite 0.30.0", "toml 1.1.6+spec-1.1.0", "tower", "tracing", diff --git a/crates/botloft-cloud/Cargo.toml b/crates/botloft-cloud/Cargo.toml index 004e3062..8bc7ea02 100644 --- a/crates/botloft-cloud/Cargo.toml +++ b/crates/botloft-cloud/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [dependencies] anyhow = "1.0.104" -axum = "0.8.9" +axum = { version = "0.8.9", features = ["ws"] } base64 = "0.23.1" bytes = "1.12.1" clap = { version = "4.6.7", features = ["derive"] } @@ -33,4 +33,5 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } [dev-dependencies] tempfile = "3.27.0" +tokio-tungstenite = "0.30.0" tower = { version = "0.5.3", features = ["util"] } diff --git a/crates/botloft-cloud/migrations/0003_mobile.sql b/crates/botloft-cloud/migrations/0003_mobile.sql new file mode 100644 index 00000000..2bcb4748 --- /dev/null +++ b/crates/botloft-cloud/migrations/0003_mobile.sql @@ -0,0 +1,38 @@ +-- The phone (spec 28). A device is a `computer` (as before) or a `phone`; a +-- phone belongs to the computer it was connected with (`peer`), and goes with +-- it when that computer is removed. +ALTER TABLE devices ADD COLUMN kind TEXT NOT NULL DEFAULT 'computer' + CHECK (kind IN ('computer', 'phone')); +ALTER TABLE devices ADD COLUMN peer TEXT REFERENCES devices(id) ON DELETE CASCADE; +CREATE INDEX devices_by_peer ON devices(peer); + +-- A phone being connected (spec 28.3). The computer opens the row; the phone +-- joins with its public key and a proof; the computer accepts with its own; +-- the phone then collects its token once and the row goes away. The server +-- never sees the QR's secret, so it can only carry these values, not forge them. +CREATE TABLE pairings ( + id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + computer TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + phone_pub TEXT, + phone_name TEXT, + proof TEXT, + poll_hash TEXT, + daemon_pub TEXT, + proof2 TEXT +); +CREATE INDEX pairings_by_computer ON pairings(computer); + +-- Sealed messages from a phone that the computer has not acknowledged (spec +-- 28.4). The server never reads `body`. Nothing is queued the other way. +CREATE TABLE relay_queue ( + from_device TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + to_device TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + body TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (from_device, seq) +); +CREATE INDEX relay_queue_by_to ON relay_queue(to_device, from_device, seq); diff --git a/crates/botloft-cloud/src/accounts.rs b/crates/botloft-cloud/src/accounts.rs index f5cb9724..817730fe 100644 --- a/crates/botloft-cloud/src/accounts.rs +++ b/crates/botloft-cloud/src/accounts.rs @@ -1,7 +1,6 @@ //! Queries for logins, accounts and devices (spec 27.3). use rusqlite::{Connection, OptionalExtension, params}; -use serde::Serialize; /// How long a sign-in link and its request live. pub const LOGIN_TTL_MS: i64 = 600_000; @@ -169,75 +168,13 @@ pub fn finish_login( Ok(true) } -/// Who a token belongs to. -pub struct Who { - pub account_id: i64, - pub device_id: String, - pub email: String, -} - -/// Looks the token up and notes that the device was used, at most once a minute. -pub fn who_is(conn: &Connection, token_hash: &str, now: i64) -> rusqlite::Result> { - let who = conn - .query_row( - "SELECT devices.account_id, devices.id, accounts.email FROM devices \ - JOIN accounts ON accounts.id = devices.account_id WHERE devices.token_hash = ?1", - [token_hash], - |row| { - Ok(Who { - account_id: row.get(0)?, - device_id: row.get(1)?, - email: row.get(2)?, - }) - }, - ) - .optional()?; - if let Some(who) = &who { - conn.execute( - "UPDATE devices SET last_used_at = ?1 WHERE id = ?2 AND last_used_at < ?1 - 60000", - params![now, who.device_id], - )?; - } - Ok(who) -} - -#[derive(Serialize)] -pub struct Device { - pub id: String, - pub name: String, - pub created_at: i64, - pub last_used_at: i64, -} - -pub fn devices(conn: &Connection, account_id: i64) -> rusqlite::Result> { - let mut stmt = conn.prepare( - "SELECT id, name, created_at, last_used_at FROM devices \ - WHERE account_id = ?1 ORDER BY created_at", - )?; - stmt.query_map([account_id], |row| { - Ok(Device { - id: row.get(0)?, - name: row.get(1)?, - created_at: row.get(2)?, - last_used_at: row.get(3)?, - }) - })? - .collect() -} - -/// False when the account has no such device. -pub fn delete_device(conn: &Connection, account_id: i64, id: &str) -> rusqlite::Result { - let removed = conn.execute( - "DELETE FROM devices WHERE id = ?1 AND account_id = ?2", - params![id, account_id], - )?; - Ok(removed > 0) -} - -/// Drops what ran out: links and the old counts of the rate limits. +/// Drops what ran out: links, phones being connected, messages nobody took, +/// and the old counts of the rate limits. pub fn purge(conn: &Connection, now: i64) -> rusqlite::Result<()> { conn.execute("DELETE FROM logins WHERE expires_at <= ?1", [now])?; conn.execute("DELETE FROM attempts WHERE at <= ?1", [now - 3_600_000])?; conn.execute("DELETE FROM deletions WHERE expires_at <= ?1", [now])?; + conn.execute("DELETE FROM pairings WHERE expires_at <= ?1", [now])?; + conn.execute("DELETE FROM relay_queue WHERE expires_at <= ?1", [now])?; Ok(()) } diff --git a/crates/botloft-cloud/src/app.rs b/crates/botloft-cloud/src/app.rs index 104dc8f2..2d6de738 100644 --- a/crates/botloft-cloud/src/app.rs +++ b/crates/botloft-cloud/src/app.rs @@ -8,9 +8,10 @@ use axum::routing::{delete, get, post}; use crate::clock::Clock; use crate::config::Config; use crate::db::Db; +use crate::hub::Hub; use crate::mailer::Mailer; use crate::store::CopyStore; -use crate::{copies, login, removal, session}; +use crate::{copies, login, pairing, relay, removal, session}; #[derive(Clone)] pub struct AppState { @@ -19,6 +20,7 @@ pub struct AppState { pub clock: Clock, pub config: Arc, pub store: CopyStore, + pub hub: Hub, } pub fn router(state: AppState) -> Router { @@ -34,6 +36,15 @@ pub fn router(state: AppState) -> Router { .route("/v1/me", get(session::me)) .route("/v1/logout", post(session::logout)) .route("/v1/devices/{id}", delete(session::remove_device)) + .route("/v1/relay", get(relay::relay)) + .route("/v1/pairings", post(pairing::open)) + .route( + "/v1/pairings/{id}", + get(pairing::status).delete(pairing::cancel), + ) + .route("/v1/pairings/{id}/join", post(pairing::join)) + .route("/v1/pairings/{id}/accept", post(pairing::accept)) + .route("/v1/pairings/{id}/result", get(pairing::result)) .route("/v1/copies", get(copies::list).put(copies::put)) .route("/v1/copies/{id}", get(copies::get).delete(copies::remove)) .route("/v1/account/delete", post(removal::ask)) diff --git a/crates/botloft-cloud/src/copies.rs b/crates/botloft-cloud/src/copies.rs index c530b724..b18d1e35 100644 --- a/crates/botloft-cloud/src/copies.rs +++ b/crates/botloft-cloud/src/copies.rs @@ -17,7 +17,7 @@ use tokio::io::AsyncWriteExt; use crate::app::AppState; use crate::copy_rows::{self, Copy, blob_key}; use crate::error::ApiError; -use crate::session::Authed; +use crate::session::Computer; use crate::tokens; /// The upload's temp file goes away however the request ends. @@ -32,7 +32,7 @@ impl Drop for Temp { /// `PUT /v1/copies` pub async fn put( State(state): State, - Authed(who): Authed, + Computer(who): Computer, headers: HeaderMap, body: Body, ) -> Result<(StatusCode, Json), ApiError> { @@ -115,7 +115,7 @@ pub async fn put( /// `GET /v1/copies`, newest first. pub async fn list( State(state): State, - Authed(who): Authed, + Computer(who): Computer, ) -> Result, ApiError> { let copies = state.db.run(|conn| copy_rows::list(conn, who.account_id))?; let copies: Vec = copies @@ -128,7 +128,7 @@ pub async fn list( /// `GET /v1/copies/`, all of it or one `Range: bytes=`. pub async fn get( State(state): State, - Authed(who): Authed, + Computer(who): Computer, Path(id): Path, headers: HeaderMap, ) -> Result { @@ -171,7 +171,7 @@ pub async fn get( /// `DELETE /v1/copies/` pub async fn remove( State(state): State, - Authed(who): Authed, + Computer(who): Computer, Path(id): Path, ) -> Result { let removed = state diff --git a/crates/botloft-cloud/src/db.rs b/crates/botloft-cloud/src/db.rs index 74cf4a08..9e94d93b 100644 --- a/crates/botloft-cloud/src/db.rs +++ b/crates/botloft-cloud/src/db.rs @@ -13,6 +13,7 @@ use crate::error::ApiError; const MIGRATIONS: &[(u32, &str)] = &[ (1, include_str!("../migrations/0001_accounts.sql")), (2, include_str!("../migrations/0002_copies.sql")), + (3, include_str!("../migrations/0003_mobile.sql")), ]; #[derive(Clone)] @@ -118,6 +119,8 @@ mod tests { "deletions", "devices", "logins", + "pairings", + "relay_queue", ] { assert!(tables.iter().any(|table| table == name), "{name}"); } diff --git a/crates/botloft-cloud/src/devices.rs b/crates/botloft-cloud/src/devices.rs new file mode 100644 index 00000000..4954c499 --- /dev/null +++ b/crates/botloft-cloud/src/devices.rs @@ -0,0 +1,142 @@ +//! Devices behind the tokens (spec 27.3, 28.2): who a token is, the list of an +//! account's devices, and removing one. + +use rusqlite::{Connection, OptionalExtension, params}; +use serde::Serialize; + +/// What a device is (spec 28.2): a phone only reaches the relay. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Kind { + Computer, + Phone, +} + +impl Kind { + pub fn tag(self) -> &'static str { + match self { + Self::Computer => "computer", + Self::Phone => "phone", + } + } + + fn parse(tag: &str) -> Self { + if tag == "phone" { + Self::Phone + } else { + Self::Computer + } + } +} + +/// Who a token belongs to. +pub struct Who { + pub account_id: i64, + pub device_id: String, + pub email: String, + pub kind: Kind, + /// The computer a phone was connected with. + pub peer: Option, +} + +/// Looks the token up and notes that the device was used, at most once a minute. +pub fn who_is(conn: &Connection, token_hash: &str, now: i64) -> rusqlite::Result> { + let who = conn + .query_row( + "SELECT devices.account_id, devices.id, accounts.email, devices.kind, devices.peer \ + FROM devices JOIN accounts ON accounts.id = devices.account_id \ + WHERE devices.token_hash = ?1", + [token_hash], + |row| { + Ok(Who { + account_id: row.get(0)?, + device_id: row.get(1)?, + email: row.get(2)?, + kind: Kind::parse(&row.get::<_, String>(3)?), + peer: row.get(4)?, + }) + }, + ) + .optional()?; + if let Some(who) = &who { + conn.execute( + "UPDATE devices SET last_used_at = ?1 WHERE id = ?2 AND last_used_at < ?1 - 60000", + params![now, who.device_id], + )?; + } + Ok(who) +} + +#[derive(Serialize)] +pub struct Device { + pub id: String, + pub name: String, + pub kind: String, + pub peer: Option, + pub created_at: i64, + pub last_used_at: i64, +} + +pub fn devices(conn: &Connection, account_id: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, name, kind, peer, created_at, last_used_at FROM devices \ + WHERE account_id = ?1 ORDER BY created_at", + )?; + stmt.query_map([account_id], |row| { + Ok(Device { + id: row.get(0)?, + name: row.get(1)?, + kind: row.get(2)?, + peer: row.get(3)?, + created_at: row.get(4)?, + last_used_at: row.get(5)?, + }) + })? + .collect() +} + +/// A device that left, and the computer that must hear of it if it was a phone. +pub struct Removed { + pub id: String, + pub peer: Option, +} + +/// Removes a device and, with a computer, the phones connected to it (the +/// foreign key does it). Says who left; empty when the account has no such +/// device. +pub fn delete_device( + conn: &mut Connection, + account_id: i64, + id: &str, +) -> rusqlite::Result> { + let tx = conn.transaction()?; + let mut gone = Vec::new(); + { + let mut stmt = tx.prepare( + "SELECT id, peer FROM devices WHERE account_id = ?2 AND (id = ?1 OR peer = ?1)", + )?; + let rows = stmt.query_map(params![id, account_id], |row| { + Ok(Removed { + id: row.get(0)?, + peer: row.get(1)?, + }) + })?; + for row in rows { + gone.push(row?); + } + } + if !gone.iter().any(|device| device.id == id) { + return Ok(Vec::new()); + } + tx.execute( + "DELETE FROM devices WHERE id = ?1 AND account_id = ?2", + params![id, account_id], + )?; + tx.commit()?; + Ok(gone) +} + +/// Every device of an account, for closing their sockets before it goes. +pub fn device_ids(conn: &Connection, account_id: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT id FROM devices WHERE account_id = ?1")?; + stmt.query_map([account_id], |row| row.get(0))?.collect() +} diff --git a/crates/botloft-cloud/src/error.rs b/crates/botloft-cloud/src/error.rs index a623e021..a358a130 100644 --- a/crates/botloft-cloud/src/error.rs +++ b/crates/botloft-cloud/src/error.rs @@ -35,6 +35,30 @@ impl ApiError { Self::new(StatusCode::NOT_FOUND, "not_found", "There is nothing here.") } + pub fn wrong_device() -> Self { + Self::new( + StatusCode::FORBIDDEN, + "wrong_device", + "This device is not allowed to do that.", + ) + } + + pub fn bad_pairing() -> Self { + Self::new( + StatusCode::BAD_REQUEST, + "bad_pairing", + "That is not a valid pairing request.", + ) + } + + pub fn pair_expired() -> Self { + Self::new( + StatusCode::GONE, + "pair_expired", + "This code was used or has run out. Ask for a new one.", + ) + } + pub fn bad_email() -> Self { Self::new( StatusCode::BAD_REQUEST, diff --git a/crates/botloft-cloud/src/hub.rs b/crates/botloft-cloud/src/hub.rs new file mode 100644 index 00000000..e8c93463 --- /dev/null +++ b/crates/botloft-cloud/src/hub.rs @@ -0,0 +1,112 @@ +//! Who is connected to the relay right now (spec 28.4): one socket per +//! device, found by its id. Nothing here is stored; it is the live pipes. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use tokio::sync::mpsc; + +/// What a socket's writer is asked to do. +pub enum Out { + Text(String), + Close, +} + +struct Conn { + id: u64, + tx: mpsc::Sender, +} + +#[derive(Default)] +struct Inner { + next: u64, + conns: HashMap, +} + +#[derive(Clone, Default)] +pub struct Hub(Arc>); + +impl Hub { + fn inner(&self) -> std::sync::MutexGuard<'_, Inner> { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Registers the socket of `device`; a second socket of the same device + /// replaces the first, which is told to close. The number goes back to + /// `detach`. + pub fn attach(&self, device: &str, tx: mpsc::Sender) -> u64 { + let mut inner = self.inner(); + inner.next += 1; + let id = inner.next; + if let Some(old) = inner.conns.insert(device.to_owned(), Conn { id, tx }) { + let _ = old.tx.try_send(Out::Close); + } + id + } + + /// Drops the registration, unless a newer socket took the place. + pub fn detach(&self, device: &str, id: u64) { + let mut inner = self.inner(); + if inner.conns.get(device).is_some_and(|conn| conn.id == id) { + inner.conns.remove(device); + } + } + + pub fn online(&self, device: &str) -> bool { + self.inner().conns.contains_key(device) + } + + /// Hands `text` to the device's socket. False when it is not connected or + /// cannot keep up (a slow reader loses the frame, the queue keeps what + /// matters). + pub fn send(&self, device: &str, text: String) -> bool { + let inner = self.inner(); + inner + .conns + .get(device) + .is_some_and(|conn| conn.tx.try_send(Out::Text(text)).is_ok()) + } + + /// Closes the device's socket: its token is gone. + pub fn kick(&self, device: &str) { + if let Some(conn) = self.inner().conns.remove(device) { + let _ = conn.tx.try_send(Out::Close); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_newer_socket_replaces_the_older_and_the_older_cannot_detach_it() { + let hub = Hub::default(); + let (first, mut first_rx) = mpsc::channel(4); + let (second, mut second_rx) = mpsc::channel(4); + let a = hub.attach("dev", first); + let b = hub.attach("dev", second); + assert!(matches!(first_rx.try_recv(), Ok(Out::Close))); + hub.detach("dev", a); + assert!(hub.online("dev")); + assert!(hub.send("dev", "hi".into())); + assert!(matches!(second_rx.try_recv(), Ok(Out::Text(text)) if text == "hi")); + hub.detach("dev", b); + assert!(!hub.online("dev")); + assert!(!hub.send("dev", "lost".into())); + } + + #[test] + fn a_full_socket_loses_the_frame_and_a_kick_closes() { + let hub = Hub::default(); + let (tx, mut rx) = mpsc::channel(1); + hub.attach("dev", tx); + assert!(hub.send("dev", "one".into())); + assert!(!hub.send("dev", "two".into())); + hub.kick("dev"); + assert!(!hub.online("dev")); + assert!(matches!(rx.try_recv(), Ok(Out::Text(_)))); + } +} diff --git a/crates/botloft-cloud/src/lib.rs b/crates/botloft-cloud/src/lib.rs index 84529ae1..6d970cfd 100644 --- a/crates/botloft-cloud/src/lib.rs +++ b/crates/botloft-cloud/src/lib.rs @@ -8,14 +8,20 @@ mod config; mod copies; mod copy_rows; mod db; +mod devices; mod error; mod html; +mod hub; mod limits; mod login; mod mail_html; mod mail_text; mod mailer; mod pages; +mod pairing; +mod pairing_rows; +mod relay; +mod relay_rows; mod removal; mod session; mod store; @@ -26,5 +32,6 @@ pub use clock::Clock; pub use config::{Bucket, Config, Smtp, Storage}; pub use db::Db; pub use error::ApiError; +pub use hub::Hub; pub use mailer::{Mail, Mailer, Outbox, SmtpMailer}; pub use store::CopyStore; diff --git a/crates/botloft-cloud/src/limits.rs b/crates/botloft-cloud/src/limits.rs index 543d7642..b030d3c5 100644 --- a/crates/botloft-cloud/src/limits.rs +++ b/crates/botloft-cloud/src/limits.rs @@ -19,6 +19,12 @@ pub const PER_ADDRESS: Rule = Rule { limit: 20, window_ms: 3_600_000, }; +/// Phones joining a pairing (spec 28.3), per address. +pub const PAIR_PER_ADDRESS: Rule = Rule { + kind: "pair", + limit: 30, + window_ms: 3_600_000, +}; pub const GAP: Rule = Rule { kind: "gap", limit: 1, diff --git a/crates/botloft-cloud/src/login.rs b/crates/botloft-cloud/src/login.rs index 31e90097..dcd051d5 100644 --- a/crates/botloft-cloud/src/login.rs +++ b/crates/botloft-cloud/src/login.rs @@ -176,7 +176,7 @@ pub async fn confirm( /// Where the request really comes from: the header the config names, if it /// holds an address; else, behind a proxy, the last `X-Forwarded-For`; else /// the connection. -fn client_address( +pub(crate) fn client_address( state: &AppState, headers: &HeaderMap, connection: Option>, @@ -222,7 +222,7 @@ fn clean_email(raw: &str) -> Option { } /// The name of the computer: printable, at most 60 characters. -fn clean_device(raw: &str) -> String { +pub(crate) fn clean_device(raw: &str) -> String { let name: String = raw .chars() .filter(|c| !c.is_control()) diff --git a/crates/botloft-cloud/src/main.rs b/crates/botloft-cloud/src/main.rs index eb4ec683..cc513795 100644 --- a/crates/botloft-cloud/src/main.rs +++ b/crates/botloft-cloud/src/main.rs @@ -6,7 +6,9 @@ use std::path::PathBuf; use std::sync::Arc; use anyhow::Context; -use botloft_cloud::{AppState, Clock, Config, CopyStore, Db, Mailer, SmtpMailer, Storage, router}; +use botloft_cloud::{ + AppState, Clock, Config, CopyStore, Db, Hub, Mailer, SmtpMailer, Storage, router, +}; use clap::{Parser, Subcommand}; #[derive(Parser)] @@ -87,6 +89,7 @@ async fn serve(config: Config) -> anyhow::Result<()> { mailer: Arc::new(Mailer::Smtp(Box::new(SmtpMailer::new(&config.smtp)?))), clock: Clock::default(), config: Arc::new(config.clone()), + hub: Hub::default(), }; let listener = tokio::net::TcpListener::bind(config.listen).await?; tracing::info!("listening on {}", config.listen); diff --git a/crates/botloft-cloud/src/pairing.rs b/crates/botloft-cloud/src/pairing.rs new file mode 100644 index 00000000..e89f7e71 --- /dev/null +++ b/crates/botloft-cloud/src/pairing.rs @@ -0,0 +1,245 @@ +//! Connecting a phone (spec 28.3): the computer opens a pairing, the phone +//! joins with its public key and a proof, the computer accepts, and the phone +//! collects its token once. The server carries these values and checks their +//! shape; it cannot forge a proof, because it never sees the QR's secret. + +use std::net::SocketAddr; + +use axum::Json; +use axum::extract::{ConnectInfo, Extension, Path, State}; +use axum::http::header::AUTHORIZATION; +use axum::http::{HeaderMap, StatusCode}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::app::AppState; +use crate::error::ApiError; +use crate::limits::{self, PAIR_PER_ADDRESS}; +use crate::login::{clean_device, client_address}; +use crate::pairing_rows::{self as rows, Collected, Join, PAIR_TTL_MS, Seen}; +use crate::session::Computer; +use crate::tokens; + +/// A base64url value without padding of exactly `len` characters, or at most +/// `max` when `len` is 0. +fn b64url(text: &str, len: usize, max: usize) -> bool { + let shaped = text + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + shaped + && if len > 0 { + text.len() == len + } else { + !text.is_empty() && text.len() <= max + } +} + +/// 16 bytes, which is 22 characters. +const ID_LEN: usize = 22; +/// A 32-byte HMAC. +const PROOF_LEN: usize = 43; +/// An uncompressed P-256 point is 65 bytes, 87 characters; leave room. +const KEY_MAX: usize = 100; + +fn tell_computer(state: &AppState, computer: &str, id: &str) { + state + .hub + .send(computer, json!({ "t": "pairing", "id": id }).to_string()); +} + +#[derive(Deserialize)] +pub struct Open { + id: String, +} + +/// `POST /v1/pairings`: the computer opens a pairing for its QR code. +pub async fn open( + State(state): State, + Computer(who): Computer, + Json(Open { id }): Json, +) -> Result<(StatusCode, Json), ApiError> { + if !b64url(&id, ID_LEN, 0) { + return Err(ApiError::bad_pairing()); + } + let now = state.clock.now(); + let opened = state + .db + .run(|conn| rows::open(conn, &id, who.account_id, &who.device_id, now))?; + if !opened { + return Err(ApiError::rate_limited(60)); + } + Ok(( + StatusCode::CREATED, + Json(json!({ "expires_in": PAIR_TTL_MS / 1000 })), + )) +} + +/// `GET /v1/pairings/`: the computer looks at whether a phone joined. +pub async fn status( + State(state): State, + Computer(who): Computer, + Path(id): Path, +) -> Result, ApiError> { + let now = state.clock.now(); + let seen = state + .db + .run(|conn| rows::seen(conn, &id, &who.device_id, now))?; + Ok(Json(match seen { + None => json!({ "status": "expired" }), + Some(Seen::Waiting) => json!({ "status": "waiting" }), + Some(Seen::Joined { + phone_name, + phone_pub, + proof, + }) => json!({ + "status": "joined", + "device_name": phone_name, + "phone_pub": phone_pub, + "proof": proof, + }), + })) +} + +#[derive(Deserialize)] +pub struct JoinBody { + phone_pub: String, + #[serde(default)] + device_name: String, + proof: String, +} + +/// `POST /v1/pairings//join`: the phone, which has no token yet. The +/// answer holds a secret only this phone knows, for collecting its token. +pub async fn join( + State(state): State, + headers: HeaderMap, + connection: Option>>, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + if !b64url(&id, ID_LEN, 0) + || !b64url(&body.phone_pub, 0, KEY_MAX) + || !b64url(&body.proof, PROOF_LEN, 0) + { + return Err(ApiError::bad_pairing()); + } + let address = client_address(&state, &headers, connection.map(|Extension(info)| info)); + let (key, now) = (tokens::hash(&address), state.clock.now()); + let taken = state + .db + .run(|conn| limits::take(conn, now, &[(&PAIR_PER_ADDRESS, &key)]))?; + taken.map_err(ApiError::rate_limited)?; + + let poll = tokens::random()?; + let name = clean_device(&body.device_name); + let joined = state.db.run(|conn| { + rows::join( + conn, + &id, + &Join { + phone_pub: &body.phone_pub, + phone_name: &name, + proof: &body.proof, + poll_hash: &tokens::hash(&poll), + }, + now, + ) + })?; + let computer = joined.ok_or_else(ApiError::pair_expired)?; + tell_computer(&state, &computer, &id); + Ok((StatusCode::ACCEPTED, Json(json!({ "poll": poll })))) +} + +#[derive(Deserialize)] +pub struct Accept { + daemon_pub: String, + proof2: String, +} + +/// `POST /v1/pairings//accept`: the computer accepts the phone that joined. +pub async fn accept( + State(state): State, + Computer(who): Computer, + Path(id): Path, + Json(body): Json, +) -> Result { + if !b64url(&body.daemon_pub, 0, KEY_MAX) || !b64url(&body.proof2, PROOF_LEN, 0) { + return Err(ApiError::bad_pairing()); + } + let now = state.clock.now(); + let accepted = state.db.run(|conn| { + rows::accept( + conn, + &id, + &who.device_id, + &body.daemon_pub, + &body.proof2, + now, + ) + })?; + if accepted { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found()) + } +} + +/// `DELETE /v1/pairings/`: the computer cancels, or refuses the phone. +pub async fn cancel( + State(state): State, + Computer(who): Computer, + Path(id): Path, +) -> Result { + let removed = state + .db + .run(|conn| rows::remove(conn, &id, &who.device_id))?; + if removed { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found()) + } +} + +/// `GET /v1/pairings//result`: the phone polls with its secret as the +/// bearer. Once the computer accepted, the first poll takes the token. +pub async fn result( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, ApiError> { + let poll = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or_else(ApiError::unauthorized)?; + let (poll_hash, now) = (tokens::hash(poll.trim()), state.clock.now()); + let found = state + .db + .run(|conn| rows::collect(conn, &id, &poll_hash, now))?; + match found { + Collected::Gone => Ok(Json(json!({ "status": "expired" }))), + Collected::Pending => Ok(Json(json!({ "status": "pending" }))), + Collected::Ready(ready) => { + let (token, device) = ( + tokens::random()?, + format!("dev_{}", &tokens::random()?[..16]), + ); + let token_hash = tokens::hash(&token); + let finished = state + .db + .run(|conn| rows::finish(conn, &id, &ready, &device, &token_hash, now))?; + if !finished { + // Two polls at once: the other one took it. + return Ok(Json(json!({ "status": "expired" }))); + } + Ok(Json(json!({ + "status": "approved", + "token": token, + "device": device, + "peer": ready.computer, + "daemon_pub": ready.daemon_pub, + "proof2": ready.proof2, + }))) + } + } +} diff --git a/crates/botloft-cloud/src/pairing_rows.rs b/crates/botloft-cloud/src/pairing_rows.rs new file mode 100644 index 00000000..237b041f --- /dev/null +++ b/crates/botloft-cloud/src/pairing_rows.rs @@ -0,0 +1,222 @@ +//! Queries for connecting a phone (spec 28.3). + +use rusqlite::{Connection, OptionalExtension, params}; + +/// How long a QR code stays good. +pub const PAIR_TTL_MS: i64 = 300_000; +/// How many QR codes one computer may have open at once. +pub const OPEN_MAX: i64 = 3; + +/// What the computer finds when it looks at its pairing. +pub enum Seen { + Waiting, + Joined { + phone_name: String, + phone_pub: String, + proof: String, + }, +} + +/// False when the computer already has too many open, or the id is taken. +pub fn open( + conn: &Connection, + id: &str, + account_id: i64, + computer: &str, + now: i64, +) -> rusqlite::Result { + conn.execute("DELETE FROM pairings WHERE expires_at <= ?1", [now])?; + let open: i64 = conn.query_row( + "SELECT COUNT(*) FROM pairings WHERE computer = ?1", + [computer], + |row| row.get(0), + )?; + if open >= OPEN_MAX { + return Ok(false); + } + let added = conn.execute( + "INSERT OR IGNORE INTO pairings (id, account_id, computer, created_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, account_id, computer, now, now + PAIR_TTL_MS], + )?; + Ok(added > 0) +} + +pub struct Join<'a> { + pub phone_pub: &'a str, + pub phone_name: &'a str, + pub proof: &'a str, + pub poll_hash: &'a str, +} + +/// The phone joins an open, unjoined pairing. Says which computer to tell, or +/// nothing when the code is unknown, expired or already used. +pub fn join( + conn: &Connection, + id: &str, + join: &Join, + now: i64, +) -> rusqlite::Result> { + let computer: Option = conn + .query_row( + "SELECT computer FROM pairings \ + WHERE id = ?1 AND expires_at > ?2 AND phone_pub IS NULL", + params![id, now], + |row| row.get(0), + ) + .optional()?; + let Some(computer) = computer else { + return Ok(None); + }; + conn.execute( + "UPDATE pairings SET phone_pub = ?2, phone_name = ?3, proof = ?4, poll_hash = ?5 \ + WHERE id = ?1", + params![ + id, + join.phone_pub, + join.phone_name, + join.proof, + join.poll_hash + ], + )?; + Ok(Some(computer)) +} + +/// What the computer's own pairing looks like. `None`: unknown or expired. +pub fn seen( + conn: &Connection, + id: &str, + computer: &str, + now: i64, +) -> rusqlite::Result> { + conn.query_row( + "SELECT phone_name, phone_pub, proof FROM pairings \ + WHERE id = ?1 AND computer = ?2 AND expires_at > ?3", + params![id, computer, now], + |row| { + Ok(match row.get::<_, Option>(1)? { + None => Seen::Waiting, + Some(phone_pub) => Seen::Joined { + phone_name: row.get::<_, Option>(0)?.unwrap_or_default(), + phone_pub, + proof: row.get::<_, Option>(2)?.unwrap_or_default(), + }, + }) + }, + ) + .optional() +} + +/// The computer accepts the phone that joined. False when there is nothing to +/// accept (no such pairing, nobody joined, or already accepted). +pub fn accept( + conn: &Connection, + id: &str, + computer: &str, + daemon_pub: &str, + proof2: &str, + now: i64, +) -> rusqlite::Result { + let changed = conn.execute( + "UPDATE pairings SET daemon_pub = ?3, proof2 = ?4 \ + WHERE id = ?1 AND computer = ?2 AND expires_at > ?5 \ + AND phone_pub IS NOT NULL AND daemon_pub IS NULL", + params![id, computer, daemon_pub, proof2, now], + )?; + Ok(changed > 0) +} + +/// The computer cancels, or refuses the phone that joined. +pub fn remove(conn: &Connection, id: &str, computer: &str) -> rusqlite::Result { + let removed = conn.execute( + "DELETE FROM pairings WHERE id = ?1 AND computer = ?2", + params![id, computer], + )?; + Ok(removed > 0) +} + +/// What the phone's poll finds. +pub enum Collected { + /// Unknown, wrong poll secret, expired, cancelled or already collected. + Gone, + Pending, + Ready(Ready), +} + +pub struct Ready { + pub account_id: i64, + pub computer: String, + pub phone_name: String, + pub daemon_pub: String, + pub proof2: String, +} + +/// Looks at the pairing by its id and the hash of the phone's poll secret. +pub fn collect( + conn: &Connection, + id: &str, + poll_hash: &str, + now: i64, +) -> rusqlite::Result { + let row = conn + .query_row( + "SELECT account_id, computer, phone_name, daemon_pub, proof2 FROM pairings \ + WHERE id = ?1 AND poll_hash = ?2 AND expires_at > ?3", + params![id, poll_hash, now], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + )) + }, + ) + .optional()?; + Ok(match row { + None => Collected::Gone, + Some((account_id, computer, name, Some(daemon_pub), Some(proof2))) => { + Collected::Ready(Ready { + account_id, + computer, + phone_name: name.unwrap_or_default(), + daemon_pub, + proof2, + }) + } + Some(_) => Collected::Pending, + }) +} + +/// The phone collected its answer: a device with the hash of its new token, +/// and the pairing is gone, so the token is handed out once. False if someone +/// else collected it first. +pub fn finish( + conn: &mut Connection, + id: &str, + ready: &Ready, + device_id: &str, + token_hash: &str, + now: i64, +) -> rusqlite::Result { + let tx = conn.transaction()?; + let removed = tx.execute("DELETE FROM pairings WHERE id = ?1", [id])?; + if removed == 0 { + return Ok(false); + } + tx.execute( + "INSERT INTO devices (id, account_id, name, token_hash, created_at, last_used_at, \ + kind, peer) VALUES (?1, ?2, ?3, ?4, ?5, ?5, 'phone', ?6)", + params![ + device_id, + ready.account_id, + ready.phone_name, + token_hash, + now, + ready.computer + ], + )?; + tx.commit()?; + Ok(true) +} diff --git a/crates/botloft-cloud/src/relay.rs b/crates/botloft-cloud/src/relay.rs new file mode 100644 index 00000000..44a0d36b --- /dev/null +++ b/crates/botloft-cloud/src/relay.rs @@ -0,0 +1,272 @@ +//! The relay (spec 28.4): `GET /v1/relay` is a WebSocket where the computer +//! and its phones talk. The server routes sealed `body` strings between a +//! phone and the computer it was connected with, and never opens them. +//! +//! Browsers cannot put a header on a WebSocket, so the token goes in the first +//! frame (`{t: "hello", token}`), never in the address. + +use std::collections::VecDeque; +use std::time::Duration; + +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::Response; +use serde_json::{Value, json}; +use tokio::sync::mpsc; +use tokio::time::{MissedTickBehavior, interval, timeout}; + +use crate::app::AppState; +use crate::devices::{self, Kind, Who}; +use crate::hub::Out; +use crate::relay_rows::{self as rows, Pushed}; +use crate::tokens; + +/// The biggest frame, in either direction. +const MAX_FRAME: usize = 64 * 1024; +/// How long a new socket has to say who it is. +const HELLO_WAIT: Duration = Duration::from_secs(10); +/// How often the server pings, so proxies keep the socket open. +const PING_EVERY: Duration = Duration::from_secs(25); +/// Frames a device may send in a minute. +const FRAMES_PER_MINUTE: usize = 60; + +/// `GET /v1/relay` +pub async fn relay(State(state): State, upgrade: WebSocketUpgrade) -> Response { + upgrade + .max_message_size(MAX_FRAME) + .max_frame_size(MAX_FRAME) + .on_upgrade(move |socket| serve(state, socket)) +} + +async fn put(socket: &mut WebSocket, frame: Value) -> bool { + socket + .send(Message::Text(frame.to_string().into())) + .await + .is_ok() +} + +/// The first frame, checked against the devices. +async fn hello(state: &AppState, socket: &mut WebSocket) -> Option { + let first = timeout(HELLO_WAIT, socket.recv()).await.ok()??.ok()?; + let Message::Text(text) = first else { + return None; + }; + let frame: Value = serde_json::from_str(&text).ok()?; + let token = frame + .get("t") + .filter(|kind| *kind == "hello") + .and(frame.get("token")) + .and_then(Value::as_str)?; + let (hash, now) = (tokens::hash(token.trim()), state.clock.now()); + let who = state + .db + .run(|conn| devices::who_is(conn, &hash, now)) + .ok() + .flatten(); + if who.is_none() { + let _ = put(socket, json!({ "t": "error", "reason": "unauthorized" })).await; + } + who +} + +async fn serve(state: AppState, mut socket: WebSocket) { + let Some(who) = hello(&state, &mut socket).await else { + return; + }; + let (tx, mut outgoing) = mpsc::channel::(256); + let me = state.hub.attach(&who.device_id, tx); + if !ready(&state, &who, &mut socket).await { + state.hub.detach(&who.device_id, me); + return; + } + presence(&state, &who, true); + + let mut ping = interval(PING_EVERY); + ping.set_missed_tick_behavior(MissedTickBehavior::Delay); + ping.tick().await; + let mut recent: VecDeque = VecDeque::new(); + loop { + tokio::select! { + incoming = socket.recv() => match incoming { + Some(Ok(Message::Text(text))) => { + if !within_limit(&state, &mut recent) { + let _ = put(&mut socket, json!({"t": "error", "reason": "rate_limited"})).await; + continue; + } + if let Some(reply) = handle(&state, &who, &text) { + let _ = put(&mut socket, reply).await; + } + } + Some(Ok(Message::Close(_)) | Err(_)) | None => break, + Some(Ok(_)) => {} + }, + out = outgoing.recv() => match out { + Some(Out::Text(text)) => { + if socket.send(Message::Text(text.into())).await.is_err() { + break; + } + } + Some(Out::Close) | None => { + let _ = socket.send(Message::Close(None)).await; + break; + } + }, + _ = ping.tick() => { + if !put(&mut socket, json!({"t": "ping"})).await { + break; + } + } + } + } + state.hub.detach(&who.device_id, me); + // A newer socket of the same device may have taken over: then it is still there. + if !state.hub.online(&who.device_id) { + presence(&state, &who, false); + } +} + +/// At most `FRAMES_PER_MINUTE` frames in any minute (the server's clock). +fn within_limit(state: &AppState, recent: &mut VecDeque) -> bool { + let now = state.clock.now(); + while recent.front().is_some_and(|at| *at <= now - 60_000) { + recent.pop_front(); + } + if recent.len() >= FRAMES_PER_MINUTE { + return false; + } + recent.push_back(now); + true +} + +/// `{t: "ready"}`, then (for a computer) what its phones sent while it was away. +async fn ready(state: &AppState, who: &Who, socket: &mut WebSocket) -> bool { + match who.kind { + Kind::Phone => { + let peer = who.peer.clone().unwrap_or_default(); + let online = state.hub.online(&peer); + put( + socket, + json!({ + "t": "ready", "kind": "phone", "device": who.device_id, + "peer": peer, "online": online, + }), + ) + .await + } + Kind::Computer => { + let now = state.clock.now(); + let loaded = state.db.run(|conn| { + Ok(( + rows::phones_of(conn, &who.device_id)?, + rows::waiting(conn, &who.device_id, now)?, + )) + }); + let Ok((phones, waiting)) = loaded else { + return false; + }; + let phones: Vec = phones + .into_iter() + .map(|id| json!({ "online": state.hub.online(&id), "id": id })) + .collect(); + let first = json!({ + "t": "ready", "kind": "computer", "device": who.device_id, "phones": phones, + }); + if !put(socket, first).await { + return false; + } + for (from, seq, body) in waiting { + let frame = json!({ "t": "msg", "from": from, "seq": seq, "body": body }); + if !put(socket, frame).await { + return false; + } + } + true + } + } +} + +/// Tells the other side that this device came or went. +fn presence(state: &AppState, who: &Who, online: bool) { + let frame = |device: &str| json!({ "t": "presence", "device": device, "online": online }); + match who.kind { + Kind::Phone => { + if let Some(peer) = &who.peer { + state.hub.send(peer, frame(&who.device_id).to_string()); + } + } + Kind::Computer => { + let phones = state + .db + .run(|conn| rows::phones_of(conn, &who.device_id)) + .unwrap_or_default(); + for phone in phones { + state.hub.send(&phone, frame(&who.device_id).to_string()); + } + } + } +} + +/// One frame from a device. Gives back an error frame for the sender, if any. +fn handle(state: &AppState, who: &Who, text: &str) -> Option { + let Ok(frame) = serde_json::from_str::(text) else { + return Some(json!({ "t": "error", "reason": "bad_frame" })); + }; + let kind = frame.get("t").and_then(Value::as_str).unwrap_or_default(); + let seq = frame.get("seq").and_then(Value::as_i64).filter(|s| *s >= 0); + let body = frame.get("body").and_then(Value::as_str); + match (who.kind, kind) { + (Kind::Phone, "msg") => { + let (Some(seq), Some(body), Some(peer)) = (seq, body, &who.peer) else { + return Some(json!({ "t": "error", "reason": "bad_frame" })); + }; + let now = state.clock.now(); + match state + .db + .run(|conn| rows::push(conn, &who.device_id, peer, seq, body, now)) + { + Ok(Pushed::Queued) => { + let out = + json!({ "t": "msg", "from": who.device_id, "seq": seq, "body": body }); + state.hub.send(peer, out.to_string()); + None + } + Ok(Pushed::Again) => None, + Ok(Pushed::Full) => { + Some(json!({ "t": "error", "reason": "queue_full", "seq": seq })) + } + Err(_) => Some(json!({ "t": "error", "reason": "internal" })), + } + } + (Kind::Computer, "msg") => { + let (Some(seq), Some(body), Some(to)) = + (seq, body, frame.get("to").and_then(Value::as_str)) + else { + return Some(json!({ "t": "error", "reason": "bad_frame" })); + }; + let known = state + .db + .run(|conn| rows::is_phone_of(conn, to, &who.device_id)) + .unwrap_or(false); + if !known { + return Some(json!({ "t": "error", "reason": "unknown_device", "seq": seq })); + } + let out = json!({ "t": "msg", "seq": seq, "body": body }); + state.hub.send(to, out.to_string()); + None + } + (Kind::Computer, "ack") => { + let (Some(upto), Some(from)) = ( + frame.get("upto").and_then(Value::as_i64), + frame.get("from").and_then(Value::as_str), + ) else { + return Some(json!({ "t": "error", "reason": "bad_frame" })); + }; + let _ = state + .db + .run(|conn| rows::ack(conn, &who.device_id, from, upto)); + None + } + // `wake` is for the push of CE6; pings and pongs need no answer. + _ => None, + } +} diff --git a/crates/botloft-cloud/src/relay_rows.rs b/crates/botloft-cloud/src/relay_rows.rs new file mode 100644 index 00000000..8fa46dc9 --- /dev/null +++ b/crates/botloft-cloud/src/relay_rows.rs @@ -0,0 +1,89 @@ +//! The queue of the relay (spec 28.4): sealed messages a phone sent that its +//! computer has not acknowledged. The server never reads a `body`. + +use rusqlite::{Connection, params}; + +/// How long a message waits for a computer that is offline. +pub const QUEUE_TTL_MS: i64 = 3_600_000; +/// How many unacknowledged messages one phone may have waiting. +pub const QUEUE_MAX: i64 = 50; + +pub enum Pushed { + Queued, + /// The same `seq` was already queued: nothing new, and not an error. + Again, + Full, +} + +pub fn push( + conn: &Connection, + from: &str, + to: &str, + seq: i64, + body: &str, + now: i64, +) -> rusqlite::Result { + conn.execute( + "DELETE FROM relay_queue WHERE from_device = ?1 AND expires_at <= ?2", + params![from, now], + )?; + let waiting: i64 = conn.query_row( + "SELECT COUNT(*) FROM relay_queue WHERE from_device = ?1", + [from], + |row| row.get(0), + )?; + if waiting >= QUEUE_MAX { + return Ok(Pushed::Full); + } + let added = conn.execute( + "INSERT OR IGNORE INTO relay_queue (from_device, to_device, seq, body, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![from, to, seq, body, now + QUEUE_TTL_MS], + )?; + Ok(if added == 0 { + Pushed::Again + } else { + Pushed::Queued + }) +} + +/// What the computer has not acknowledged, oldest first per phone. +pub fn waiting( + conn: &Connection, + to: &str, + now: i64, +) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT from_device, seq, body FROM relay_queue \ + WHERE to_device = ?1 AND expires_at > ?2 ORDER BY from_device, seq", + )?; + stmt.query_map(params![to, now], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })? + .collect() +} + +/// The computer took everything from `from` up to and including `upto`. +pub fn ack(conn: &Connection, to: &str, from: &str, upto: i64) -> rusqlite::Result<()> { + conn.execute( + "DELETE FROM relay_queue WHERE to_device = ?1 AND from_device = ?2 AND seq <= ?3", + params![to, from, upto], + )?; + Ok(()) +} + +/// The phones connected to a computer, for presence and for where it may send. +pub fn phones_of(conn: &Connection, computer: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT id FROM devices WHERE peer = ?1 ORDER BY created_at")?; + stmt.query_map([computer], |row| row.get(0))?.collect() +} + +/// True when `phone` is a phone connected with `computer`. +pub fn is_phone_of(conn: &Connection, phone: &str, computer: &str) -> rusqlite::Result { + conn.query_row( + "SELECT COUNT(*) FROM devices WHERE id = ?1 AND peer = ?2 AND kind = 'phone'", + params![phone, computer], + |row| row.get::<_, i64>(0), + ) + .map(|count| count > 0) +} diff --git a/crates/botloft-cloud/src/removal.rs b/crates/botloft-cloud/src/removal.rs index 718b6400..d20476a2 100644 --- a/crates/botloft-cloud/src/removal.rs +++ b/crates/botloft-cloud/src/removal.rs @@ -11,11 +11,12 @@ use serde::Deserialize; use crate::accounts::LOGIN_TTL_MS; use crate::app::AppState; use crate::copy_rows; +use crate::devices; use crate::error::ApiError; use crate::limits::{self, GAP, PER_EMAIL}; use crate::mail_text::{Locale, delete_mail}; use crate::mailer::Mail; -use crate::session::Authed; +use crate::session::Computer; use crate::{pages, tokens}; #[derive(Deserialize, Default)] @@ -27,7 +28,7 @@ pub struct Ask { /// `POST /v1/account/delete`: mails a link; nothing is deleted yet. pub async fn ask( State(state): State, - Authed(who): Authed, + Computer(who): Computer, body: Option>, ) -> Result { let locale = Locale::parse(&body.map(|Json(ask)| ask.locale).unwrap_or_default()); @@ -126,12 +127,16 @@ pub async fn confirm( return Ok(None); }; let keys = copy_rows::keys(conn, account_id)?; + let devices = devices::device_ids(conn, account_id)?; conn.execute("DELETE FROM accounts WHERE id = ?1", [account_id])?; - Ok(Some((Locale::parse(&locale), keys))) + Ok(Some((Locale::parse(&locale), keys, devices))) })?; - let Some((locale, keys)) = gone else { + let Some((locale, keys, devices)) = gone else { return Ok(pages::gone(Locale::of_request(&headers))); }; + for device in devices { + state.hub.kick(&device); + } // The rows are gone; now the bytes. for key in keys { state.store.delete(&key).await; diff --git a/crates/botloft-cloud/src/session.rs b/crates/botloft-cloud/src/session.rs index 71431cc9..9dec135f 100644 --- a/crates/botloft-cloud/src/session.rs +++ b/crates/botloft-cloud/src/session.rs @@ -1,4 +1,5 @@ //! Who is asking, once signed in (spec 27.3): the bearer token of a device. +//! A phone's token reaches the relay and little else (spec 28.2). use axum::Json; use axum::extract::{FromRequestParts, Path, State}; @@ -7,13 +8,13 @@ use axum::http::header::AUTHORIZATION; use axum::http::request::Parts; use serde_json::{Value, json}; -use crate::accounts::{self, Who}; use crate::app::AppState; use crate::copy_rows; +use crate::devices::{self, Kind, Removed, Who}; use crate::error::ApiError; use crate::tokens; -/// The device behind a request. +/// The device behind a request, whatever it is. pub struct Authed(pub Who); impl FromRequestParts for Authed { @@ -30,19 +31,46 @@ impl FromRequestParts for Authed { .and_then(|value| value.strip_prefix("Bearer ")) .ok_or_else(ApiError::unauthorized)?; let (hash, now) = (tokens::hash(token.trim()), state.clock.now()); - let who = state.db.run(|conn| accounts::who_is(conn, &hash, now))?; + let who = state.db.run(|conn| devices::who_is(conn, &hash, now))?; who.map(Self).ok_or_else(ApiError::unauthorized) } } -/// `GET /v1/me` +/// A computer: the copies, the other devices and the account are only its. +pub struct Computer(pub Who); + +impl FromRequestParts for Computer { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let Authed(who) = Authed::from_request_parts(parts, state).await?; + if who.kind == Kind::Computer { + Ok(Self(who)) + } else { + Err(ApiError::wrong_device()) + } + } +} + +/// `GET /v1/me`. A phone sees only whose it is and which computer it serves. pub async fn me( State(state): State, Authed(who): Authed, ) -> Result, ApiError> { + if who.kind == Kind::Phone { + return Ok(Json(json!({ + "email": who.email, + "kind": who.kind.tag(), + "device": who.device_id, + "peer": who.peer, + }))); + } let (devices, used) = state.db.run(|conn| { Ok(( - accounts::devices(conn, who.account_id)?, + devices::devices(conn, who.account_id)?, copy_rows::used(conn, who.account_id)?, )) })?; @@ -52,6 +80,8 @@ pub async fn me( json!({ "id": device.id, "name": device.name, + "kind": device.kind, + "peer": device.peer, "created_at": device.created_at, "last_used_at": device.last_used_at, "current": device.id == who.device_id, @@ -66,29 +96,48 @@ pub async fn me( }))) } -/// `POST /v1/logout`: this device's token stops working. +/// Closes the sockets of devices that are gone, and tells the computer of +/// each phone that left (spec 28.4), so it forgets that phone's keys. +pub fn farewell(state: &AppState, gone: &[Removed]) { + for device in gone { + state.hub.kick(&device.id); + } + for device in gone { + if let Some(peer) = &device.peer { + state.hub.send( + peer, + json!({ "t": "revoked", "device": device.id }).to_string(), + ); + } + } +} + +/// `POST /v1/logout`: this device's token stops working (a phone's too, which +/// is how it disconnects from its side). pub async fn logout( State(state): State, Authed(who): Authed, ) -> Result { - state + let gone = state .db - .run(|conn| accounts::delete_device(conn, who.account_id, &who.device_id))?; + .run(|conn| devices::delete_device(conn, who.account_id, &who.device_id))?; + farewell(&state, &gone); Ok(StatusCode::NO_CONTENT) } -/// `DELETE /v1/devices/`: another device of the same account. +/// `DELETE /v1/devices/`: another device of the same account, with the +/// phones that belong to it. pub async fn remove_device( State(state): State, - Authed(who): Authed, + Computer(who): Computer, Path(id): Path, ) -> Result { - let removed = state + let gone = state .db - .run(|conn| accounts::delete_device(conn, who.account_id, &id))?; - if removed { - Ok(StatusCode::NO_CONTENT) - } else { - Err(ApiError::not_found()) + .run(|conn| devices::delete_device(conn, who.account_id, &id))?; + if gone.is_empty() { + return Err(ApiError::not_found()); } + farewell(&state, &gone); + Ok(StatusCode::NO_CONTENT) } diff --git a/crates/botloft-cloud/tests/common/mobile.rs b/crates/botloft-cloud/tests/common/mobile.rs new file mode 100644 index 00000000..fb65e6a6 --- /dev/null +++ b/crates/botloft-cloud/tests/common/mobile.rs @@ -0,0 +1,205 @@ +//! Helpers for the phone's tests (spec 28): a real listener for the relay, a +//! WebSocket client, and the pairing flow with made-up (but well-shaped) keys. + +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, header}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +use super::{Reply, Server}; + +/// 22 characters, as a pairing id is (16 bytes in base64url). +pub fn pair_id(n: u32) -> String { + format!("{n:0>22}") +} + +/// 43 characters, as a proof is (an HMAC-SHA-256 in base64url). +pub fn proof(letter: char) -> String { + letter.to_string().repeat(43) +} + +/// A key as the server sees it: only its shape matters to the server. +pub fn key(letter: char) -> String { + letter.to_string().repeat(87) +} + +/// A phone that finished connecting. +pub struct Phone { + pub token: String, + pub device: String, + pub pairing: String, +} + +impl Server { + /// A request with a JSON body, and a bearer token if there is one. + pub async fn call( + &self, + method: &str, + path: &str, + token: Option<&str>, + body: Option, + ) -> Reply { + let mut request = Request::builder().method(method).uri(path); + if let Some(token) = token { + request = request.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + let body = match body { + Some(body) => { + request = request.header(header::CONTENT_TYPE, "application/json"); + Body::from(body.to_string()) + } + None => Body::empty(), + }; + self.send(request.body(body).expect("request")).await + } + + /// Serves the router on a free port, for the relay's sockets. + pub async fn listen(&self) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + let app = self.app.clone(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + address + } + + /// The whole flow of 28.3 with a computer's token: the pairing id is + /// `pair_id(n)`, and the phone ends up with its own token. + pub async fn pair(&self, computer: &str, n: u32) -> Phone { + let id = pair_id(n); + let opened = self + .call( + "POST", + "/v1/pairings", + Some(computer), + Some(json!({ "id": id })), + ) + .await; + assert_eq!(opened.status, 201, "{}", opened.body); + let joined = self + .call( + "POST", + &format!("/v1/pairings/{id}/join"), + None, + Some(json!({ + "phone_pub": key('P'), "device_name": "Celular da Ana", "proof": proof('p'), + })), + ) + .await; + assert_eq!(joined.status, 202, "{}", joined.body); + let poll = joined.json()["poll"].as_str().expect("poll").to_owned(); + let accepted = self + .call( + "POST", + &format!("/v1/pairings/{id}/accept"), + Some(computer), + Some(json!({ "daemon_pub": key('D'), "proof2": proof('d') })), + ) + .await; + assert_eq!(accepted.status, 204, "{}", accepted.body); + let got = self + .call( + "GET", + &format!("/v1/pairings/{id}/result"), + Some(&poll), + None, + ) + .await + .json(); + assert_eq!(got["status"], "approved", "{got}"); + Phone { + token: got["token"].as_str().expect("token").to_owned(), + device: got["device"].as_str().expect("device").to_owned(), + pairing: id, + } + } +} + +/// A client of the relay. +pub struct Sock(WebSocketStream>); + +const WAIT: Duration = Duration::from_secs(3); + +impl Sock { + /// Connects and says hello with `token`; the next frame is the server's answer. + pub async fn open(address: SocketAddr, token: &str) -> Self { + let mut sock = Self::connect(address).await; + sock.send(json!({ "t": "hello", "token": token })).await; + sock + } + + pub async fn connect(address: SocketAddr) -> Self { + let (socket, _) = tokio_tungstenite::connect_async(format!("ws://{address}/v1/relay")) + .await + .expect("connect"); + Self(socket) + } + + pub async fn send(&mut self, frame: Value) { + self.send_text(&frame.to_string()).await; + } + + pub async fn send_text(&mut self, text: &str) { + self.0 + .send(Message::Text(text.to_owned().into())) + .await + .expect("send"); + } + + /// The next frame from the server. + pub async fn next(&mut self) -> Value { + let frame = tokio::time::timeout(WAIT, self.0.next()) + .await + .expect("a frame in time"); + match frame { + Some(Ok(Message::Text(text))) => serde_json::from_str(&text).expect("json"), + other => panic!("not a frame: {other:?}"), + } + } + + /// Opens as `open` does and checks the `ready` frame. + pub async fn ready(address: SocketAddr, token: &str) -> (Self, Value) { + let mut sock = Self::open(address, token).await; + let ready = sock.next().await; + assert_eq!(ready["t"], "ready", "{ready}"); + (sock, ready) + } + + /// No frame arrives for a while. + pub async fn quiet(&mut self) { + let frame = tokio::time::timeout(Duration::from_millis(300), self.0.next()).await; + assert!(frame.is_err(), "unexpected: {frame:?}"); + } + + /// The server closes this socket. + pub async fn closed(&mut self) { + loop { + let frame = tokio::time::timeout(WAIT, self.0.next()) + .await + .expect("closed in time"); + match frame { + None | Some(Err(_) | Ok(Message::Close(_))) => return, + Some(Ok(_)) => {} + } + } + } + + /// Once this returns, the server has handled every frame sent before it: + /// a frame that is not JSON is answered, and frames are handled in order. + pub async fn sync(&mut self) { + self.send_text("not json").await; + let reply = self.next().await; + assert_eq!(reply["reason"], "bad_frame", "{reply}"); + } +} diff --git a/crates/botloft-cloud/tests/common/mod.rs b/crates/botloft-cloud/tests/common/mod.rs index 5d2db796..539bf712 100644 --- a/crates/botloft-cloud/tests/common/mod.rs +++ b/crates/botloft-cloud/tests/common/mod.rs @@ -8,11 +8,13 @@ use std::sync::Arc; use axum::Router; use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode, header}; -use botloft_cloud::{AppState, Clock, Config, CopyStore, Db, Mailer, Outbox, router}; +use botloft_cloud::{AppState, Clock, Config, CopyStore, Db, Hub, Mailer, Outbox, router}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tower::ServiceExt; +pub mod mobile; + pub struct Server { app: Router, pub store: CopyStore, @@ -57,6 +59,7 @@ fn build(outbox: Outbox, config: Config) -> Server { clock: clock.clone(), config: Arc::new(config), store: store.clone(), + hub: Hub::default(), }; Server { app: router(state), diff --git a/crates/botloft-cloud/tests/pairing.rs b/crates/botloft-cloud/tests/pairing.rs new file mode 100644 index 00000000..dc2cbc85 --- /dev/null +++ b/crates/botloft-cloud/tests/pairing.rs @@ -0,0 +1,416 @@ +//! Connecting a phone (spec 28.3) and what a phone's token may reach (28.2). + +mod common; + +use axum::http::StatusCode; +use common::mobile::{key, pair_id, proof}; +use common::server; +use serde_json::json; + +#[tokio::test] +async fn a_phone_joins_the_computer_accepts_and_the_phone_collects_its_token_once() { + let s = server(); + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let id = pair_id(1); + let path = format!("/v1/pairings/{id}"); + + let opened = s + .call( + "POST", + "/v1/pairings", + Some(&computer), + Some(json!({ "id": id })), + ) + .await; + assert_eq!(opened.status, StatusCode::CREATED); + assert_eq!(opened.json()["expires_in"], 300); + assert_eq!( + s.call("GET", &path, Some(&computer), None).await.json()["status"], + "waiting" + ); + + // The phone has no token: it joins with its key and proof, and keeps the + // secret that lets it collect the answer. + let joined = s + .call( + "POST", + &format!("{path}/join"), + None, + Some(json!({ + "phone_pub": key('P'), "device_name": "Celular da Ana", "proof": proof('p'), + })), + ) + .await; + assert_eq!(joined.status, StatusCode::ACCEPTED); + let poll = joined.json()["poll"].as_str().expect("poll").to_owned(); + + // The computer sees who joined, exactly as sent. + let seen = s.call("GET", &path, Some(&computer), None).await.json(); + assert_eq!(seen["status"], "joined"); + assert_eq!(seen["device_name"], "Celular da Ana"); + assert_eq!(seen["phone_pub"], key('P')); + assert_eq!(seen["proof"], proof('p')); + + // Nothing for the phone until the computer accepts. + let result = format!("{path}/result"); + assert_eq!( + s.call("GET", &result, Some(&poll), None).await.json()["status"], + "pending" + ); + let accepted = s + .call( + "POST", + &format!("{path}/accept"), + Some(&computer), + Some(json!({ "daemon_pub": key('D'), "proof2": proof('d') })), + ) + .await; + assert_eq!(accepted.status, StatusCode::NO_CONTENT); + + let got = s.call("GET", &result, Some(&poll), None).await.json(); + assert_eq!(got["status"], "approved"); + assert_eq!(got["daemon_pub"], key('D')); + assert_eq!(got["proof2"], proof('d')); + let token = got["token"].as_str().expect("token").to_owned(); + let device = got["device"].as_str().expect("device").to_owned(); + // Once: the pairing is gone and the token is not handed out again. + assert_eq!( + s.call("GET", &result, Some(&poll), None).await.json()["status"], + "expired" + ); + + // The phone is a device of the account, tied to the computer that opened it. + let me = s.authed("GET", "/v1/me", &token).await.json(); + assert_eq!(me["kind"], "phone"); + assert_eq!(me["device"], device); + assert_eq!(me["email"], "ana@exemplo.com"); + assert!(me.get("devices").is_none() && me.get("used").is_none()); + let mine = s.authed("GET", "/v1/me", &computer).await.json(); + let devices = mine["devices"].as_array().expect("devices"); + assert_eq!(devices.len(), 2); + let phone = devices.iter().find(|d| d["id"] == device).expect("phone"); + assert_eq!(phone["kind"], "phone"); + assert_eq!(phone["name"], "Celular da Ana"); + assert_eq!(me["peer"], phone["peer"]); + assert!( + devices + .iter() + .any(|d| d["kind"] == "computer" && d["peer"].is_null()) + ); +} + +#[tokio::test] +async fn a_phone_token_reaches_the_relay_and_nothing_that_belongs_to_a_computer() { + let s = server(); + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&computer, 1).await; + + for (method, path) in [ + ("GET", "/v1/copies"), + ("GET", "/v1/copies/abc"), + ("DELETE", "/v1/copies/abc"), + ("POST", "/v1/account/delete"), + ("DELETE", "/v1/devices/anything"), + ("GET", "/v1/pairings/anything"), + ] { + let reply = s.authed(method, path, &phone.token).await; + assert_eq!(reply.status, StatusCode::FORBIDDEN, "{method} {path}"); + assert_eq!(reply.json()["reason"], "wrong_device"); + } + let opened = s + .call( + "POST", + "/v1/pairings", + Some(&phone.token), + Some(json!({ "id": pair_id(9) })), + ) + .await; + assert_eq!(opened.status, StatusCode::FORBIDDEN); + let put = s.put_copy(&phone.token, b"sealed").await; + assert_eq!(put.status, StatusCode::FORBIDDEN); + assert_eq!( + s.authed("GET", "/v1/me", &phone.token).await.status, + StatusCode::OK + ); + + // It can disconnect itself, and then its token is dead. + assert_eq!( + s.authed("POST", "/v1/logout", &phone.token).await.status, + StatusCode::NO_CONTENT + ); + assert_eq!( + s.authed("GET", "/v1/me", &phone.token).await.status, + StatusCode::UNAUTHORIZED + ); + // The computer is still signed in. + assert_eq!( + s.authed("GET", "/v1/me", &computer).await.status, + StatusCode::OK + ); +} + +#[tokio::test] +async fn a_pairing_is_used_once_runs_out_and_checks_what_it_is_given() { + let s = server(); + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let id = pair_id(1); + let join = format!("/v1/pairings/{id}/join"); + let body = json!({ "phone_pub": key('P'), "device_name": "X", "proof": proof('p') }); + s.call( + "POST", + "/v1/pairings", + Some(&computer), + Some(json!({ "id": id })), + ) + .await; + + // Bad shapes are refused before anything is stored. + for bad in [ + json!({ "phone_pub": "has space", "device_name": "X", "proof": proof('p') }), + json!({ "phone_pub": key('P'), "device_name": "X", "proof": "short" }), + json!({ "phone_pub": "", "device_name": "X", "proof": proof('p') }), + ] { + let reply = s.call("POST", &join, None, Some(bad)).await; + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!(reply.json()["reason"], "bad_pairing"); + } + let short = s + .call( + "POST", + "/v1/pairings", + Some(&computer), + Some(json!({ "id": "short" })), + ) + .await; + assert_eq!(short.status, StatusCode::BAD_REQUEST); + + // The first phone to join gets it; a second one finds it used. + assert_eq!( + s.call("POST", &join, None, Some(body.clone())).await.status, + StatusCode::ACCEPTED + ); + let again = s.call("POST", &join, None, Some(body.clone())).await; + assert_eq!(again.status, StatusCode::GONE); + assert_eq!(again.json()["reason"], "pair_expired"); + + // Without the poll secret of the phone that joined, there is no token, + // even after the computer accepted. + s.call( + "POST", + &format!("/v1/pairings/{id}/accept"), + Some(&computer), + Some(json!({ "daemon_pub": key('D'), "proof2": proof('d') })), + ) + .await; + let stolen = s + .call( + "GET", + &format!("/v1/pairings/{id}/result"), + Some("not-the-secret"), + None, + ) + .await; + assert_eq!(stolen.json()["status"], "expired"); + assert!(stolen.json().get("token").is_none()); + let nobody = s + .call("GET", &format!("/v1/pairings/{id}/result"), None, None) + .await; + assert_eq!(nobody.status, StatusCode::UNAUTHORIZED); + + // Five minutes later the code is dead, and the computer sees it so. + let late = pair_id(2); + s.call( + "POST", + "/v1/pairings", + Some(&computer), + Some(json!({ "id": late })), + ) + .await; + s.clock.advance(301_000); + let expired = s + .call( + "POST", + &format!("/v1/pairings/{late}/join"), + None, + Some(body), + ) + .await; + assert_eq!(expired.status, StatusCode::GONE); + let seen = s + .call( + "GET", + &format!("/v1/pairings/{late}"), + Some(&computer), + None, + ) + .await; + assert_eq!(seen.json()["status"], "expired"); +} + +#[tokio::test] +async fn a_computer_holds_three_open_codes_cancels_and_cannot_accept_what_nobody_joined() { + let s = server(); + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + for n in 1..=3 { + let opened = s + .call( + "POST", + "/v1/pairings", + Some(&computer), + Some(json!({ "id": pair_id(n) })), + ) + .await; + assert_eq!(opened.status, StatusCode::CREATED); + } + let fourth = s + .call( + "POST", + "/v1/pairings", + Some(&computer), + Some(json!({ "id": pair_id(4) })), + ) + .await; + assert_eq!(fourth.status, StatusCode::TOO_MANY_REQUESTS); + + // Accepting before anyone joined has nothing to accept. + let early = s + .call( + "POST", + &format!("/v1/pairings/{}/accept", pair_id(1)), + Some(&computer), + Some(json!({ "daemon_pub": key('D'), "proof2": proof('d') })), + ) + .await; + assert_eq!(early.status, StatusCode::NOT_FOUND); + + // Cancelling frees the slot and kills the code. + let path = format!("/v1/pairings/{}", pair_id(1)); + assert_eq!( + s.call("DELETE", &path, Some(&computer), None).await.status, + StatusCode::NO_CONTENT + ); + assert_eq!( + s.call("DELETE", &path, Some(&computer), None).await.status, + StatusCode::NOT_FOUND + ); + let join = s + .call( + "POST", + &format!("{path}/join"), + None, + Some(json!({ "phone_pub": key('P'), "device_name": "X", "proof": proof('p') })), + ) + .await; + assert_eq!(join.status, StatusCode::GONE); + let fourth = s + .call( + "POST", + "/v1/pairings", + Some(&computer), + Some(json!({ "id": pair_id(4) })), + ) + .await; + assert_eq!(fourth.status, StatusCode::CREATED); +} + +#[tokio::test] +async fn another_computer_cannot_see_or_accept_someone_elses_pairing() { + let s = server(); + let a = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + s.clock.advance(61_000); + let b = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let id = pair_id(1); + s.call("POST", "/v1/pairings", Some(&a), Some(json!({ "id": id }))) + .await; + s.call( + "POST", + &format!("/v1/pairings/{id}/join"), + None, + Some(json!({ "phone_pub": key('P'), "device_name": "X", "proof": proof('p') })), + ) + .await; + let path = format!("/v1/pairings/{id}"); + assert_eq!( + s.call("GET", &path, Some(&b), None).await.json()["status"], + "expired" + ); + let accept = s + .call( + "POST", + &format!("{path}/accept"), + Some(&b), + Some(json!({ "daemon_pub": key('D'), "proof2": proof('d') })), + ) + .await; + assert_eq!(accept.status, StatusCode::NOT_FOUND); + assert_eq!( + s.call("DELETE", &path, Some(&b), None).await.status, + StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn removing_a_phone_or_its_computer_ends_the_tokens() { + let s = server(); + let a = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + s.clock.advance(61_000); + let b = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let first = s.pair(&a, 1).await; + let second = s.pair(&a, 2).await; + + // Disconnecting one phone from a computer leaves the other. + let gone = s + .authed("DELETE", &format!("/v1/devices/{}", first.device), &a) + .await; + assert_eq!(gone.status, StatusCode::NO_CONTENT); + assert_eq!( + s.authed("GET", "/v1/me", &first.token).await.status, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + s.authed("GET", "/v1/me", &second.token).await.status, + StatusCode::OK + ); + + // Removing the computer takes the phones that belong to it, not another computer. + let me = s.authed("GET", "/v1/me", &a).await.json(); + let a_id = me["devices"] + .as_array() + .expect("devices") + .iter() + .find(|d| d["current"] == true) + .expect("this device")["id"] + .as_str() + .expect("id") + .to_owned(); + let removed = s.authed("DELETE", &format!("/v1/devices/{a_id}"), &b).await; + assert_eq!(removed.status, StatusCode::NO_CONTENT); + assert_eq!( + s.authed("GET", "/v1/me", &a).await.status, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + s.authed("GET", "/v1/me", &second.token).await.status, + StatusCode::UNAUTHORIZED + ); + assert_eq!(s.authed("GET", "/v1/me", &b).await.status, StatusCode::OK); +} + +#[tokio::test] +async fn deleting_the_account_takes_the_phones_too() { + let s = server(); + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&computer, 1).await; + assert_eq!( + s.authed("POST", "/v1/account/delete", &computer) + .await + .status, + StatusCode::ACCEPTED + ); + let (_, code) = s.last_link(); + assert_eq!(s.post_delete(&code).await.status, StatusCode::OK); + assert_eq!( + s.authed("GET", "/v1/me", &phone.token).await.status, + StatusCode::UNAUTHORIZED + ); +} diff --git a/crates/botloft-cloud/tests/relay.rs b/crates/botloft-cloud/tests/relay.rs new file mode 100644 index 00000000..24baf542 --- /dev/null +++ b/crates/botloft-cloud/tests/relay.rs @@ -0,0 +1,341 @@ +//! The relay (spec 28.4): a real server on `127.0.0.1` and real sockets. + +mod common; + +use common::mobile::{Sock, key, pair_id, proof}; +use common::server; +use serde_json::json; + +/// What a sealed message looks like to the server: text it must not touch. +const SEALED: &str = "AAECAwQF+/8=?not-base64 {\"even\":\"json\"} \u{1f525}"; + +#[tokio::test] +async fn the_first_frame_must_be_a_valid_hello() { + let s = server(); + let address = s.listen().await; + + let mut wrong = Sock::open(address, "not-a-token").await; + assert_eq!(wrong.next().await["reason"], "unauthorized"); + wrong.closed().await; + + let mut silent = Sock::connect(address).await; + silent + .send(json!({ "t": "msg", "seq": 1, "body": "x" })) + .await; + silent.closed().await; + + // A phone that disconnected has no way back in with the old token. + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&computer, 1).await; + s.authed("POST", "/v1/logout", &phone.token).await; + let mut gone = Sock::open(address, &phone.token).await; + assert_eq!(gone.next().await["reason"], "unauthorized"); +} + +#[tokio::test] +async fn a_phone_and_its_computer_talk_and_see_each_other_come_and_go() { + let s = server(); + let address = s.listen().await; + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&computer, 1).await; + + let (mut pc, ready) = Sock::ready(address, &computer).await; + assert_eq!(ready["kind"], "computer"); + assert_eq!( + ready["phones"], + json!([{ "id": phone.device, "online": false }]) + ); + + let (mut mobile, ready) = Sock::ready(address, &phone.token).await; + assert_eq!(ready["kind"], "phone"); + assert_eq!(ready["online"], true); + assert_eq!( + pc.next().await, + json!({ "t": "presence", "device": phone.device, "online": true }) + ); + + // Each body arrives exactly as sent. + mobile + .send(json!({ "t": "msg", "seq": 1, "body": SEALED })) + .await; + assert_eq!( + pc.next().await, + json!({ "t": "msg", "from": phone.device, "seq": 1, "body": SEALED }) + ); + pc.send(json!({ "t": "msg", "to": phone.device, "seq": 1, "body": SEALED })) + .await; + assert_eq!( + mobile.next().await, + json!({ "t": "msg", "seq": 1, "body": SEALED }) + ); + + // A computer cannot write to a device that is not one of its phones. + pc.send(json!({ "t": "msg", "to": "dev_nobody", "seq": 2, "body": "x" })) + .await; + let refused = pc.next().await; + assert_eq!(refused["reason"], "unknown_device"); + assert_eq!(refused["seq"], 2); + + drop(mobile); + assert_eq!( + pc.next().await, + json!({ "t": "presence", "device": phone.device, "online": false }) + ); + // And the phone sees the computer leave. + let (mut mobile, _) = Sock::ready(address, &phone.token).await; + pc.next().await; + drop(pc); + assert_eq!( + mobile.next().await, + json!({ "t": "presence", "device": ready_peer(&s, &computer).await, "online": false }) + ); +} + +/// The id of the computer behind `token`. +async fn ready_peer(s: &common::Server, token: &str) -> String { + let me = s.authed("GET", "/v1/me", token).await.json(); + me["devices"] + .as_array() + .expect("devices") + .iter() + .find(|d| d["current"] == true) + .expect("this device")["id"] + .as_str() + .expect("id") + .to_owned() +} + +#[tokio::test] +async fn answers_wait_for_a_computer_that_is_away_until_it_acknowledges_them() { + let s = server(); + let address = s.listen().await; + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&computer, 1).await; + + let (mut mobile, ready) = Sock::ready(address, &phone.token).await; + assert_eq!(ready["online"], false); + for seq in [1, 2, 3] { + mobile + .send(json!({ "t": "msg", "seq": seq, "body": format!("answer {seq}") })) + .await; + } + // The same seq again is not queued twice. + mobile + .send(json!({ "t": "msg", "seq": 2, "body": "answer 2" })) + .await; + mobile.sync().await; + + // The computer comes back: ready, then everything, in order. + let (mut pc, _) = Sock::ready(address, &computer).await; + for seq in [1, 2, 3] { + let frame = pc.next().await; + assert_eq!( + (frame["seq"].as_i64(), frame["from"].as_str()), + (Some(seq), Some(phone.device.as_str())) + ); + assert_eq!(frame["body"], format!("answer {seq}")); + } + pc.quiet().await; + + // It takes up to 2; the next time only 3 is left. + pc.send(json!({ "t": "ack", "from": phone.device, "upto": 2 })) + .await; + pc.sync().await; + drop(pc); + let (mut pc, _) = Sock::ready(address, &computer).await; + let frame = pc.next().await; + assert_eq!( + (frame["seq"].as_i64(), frame["body"].as_str()), + (Some(3), Some("answer 3")) + ); + pc.quiet().await; + pc.send(json!({ "t": "ack", "from": phone.device, "upto": 3 })) + .await; + pc.sync().await; + drop(pc); + let (mut pc, _) = Sock::ready(address, &computer).await; + pc.quiet().await; +} + +#[tokio::test] +async fn a_message_nobody_took_in_an_hour_is_dropped_and_the_queue_has_a_limit() { + let s = server(); + let address = s.listen().await; + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&computer, 1).await; + let (mut mobile, _) = Sock::ready(address, &phone.token).await; + + mobile + .send(json!({ "t": "msg", "seq": 1, "body": "old" })) + .await; + mobile.sync().await; + s.clock.advance(3_600_001); + let (mut pc, _) = Sock::ready(address, &computer).await; + pc.quiet().await; + drop(pc); + + // Fifty wait, the fifty-first is told so, and an acknowledgement makes room. + for seq in 10..60 { + mobile + .send(json!({ "t": "msg", "seq": seq, "body": "x" })) + .await; + } + mobile + .send(json!({ "t": "msg", "seq": 60, "body": "x" })) + .await; + // (The computer coming and going above is told to the phone as presence.) + let mut full = mobile.next().await; + while full["t"] == "presence" { + full = mobile.next().await; + } + assert_eq!( + (full["reason"].as_str(), full["seq"].as_i64()), + (Some("queue_full"), Some(60)) + ); + s.clock.advance(61_000); + let (mut pc, _) = Sock::ready(address, &computer).await; + for _ in 10..60 { + pc.next().await; + } + pc.send(json!({ "t": "ack", "from": phone.device, "upto": 59 })) + .await; + pc.sync().await; + mobile + .send(json!({ "t": "msg", "seq": 61, "body": "room" })) + .await; + assert_eq!(pc.next().await["body"], "room"); +} + +#[tokio::test] +async fn disconnecting_a_phone_closes_its_socket_and_tells_the_computer() { + let s = server(); + let address = s.listen().await; + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&computer, 1).await; + let (mut pc, _) = Sock::ready(address, &computer).await; + let (mut mobile, _) = Sock::ready(address, &phone.token).await; + pc.next().await; + + s.authed( + "DELETE", + &format!("/v1/devices/{}", phone.device), + &computer, + ) + .await; + mobile.closed().await; + // The computer hears that the phone is revoked (and may see it go offline). + let mut frames = [pc.next().await, pc.next().await]; + frames.sort_by_key(|frame| frame["t"].as_str().unwrap_or_default().to_owned()); + assert_eq!( + frames[0], + json!({ "t": "presence", "device": phone.device, "online": false }) + ); + assert_eq!(frames[1], json!({ "t": "revoked", "device": phone.device })); + // The phone's own logout does the same. + let again = s.pair(&computer, 2).await; + let (mut second, _) = Sock::ready(address, &again.token).await; + pc.next().await; + s.authed("POST", "/v1/logout", &again.token).await; + second.closed().await; +} + +#[tokio::test] +async fn removing_a_computer_closes_it_and_its_phones() { + let s = server(); + let address = s.listen().await; + let a = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + s.clock.advance(61_000); + let b = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&a, 1).await; + let (mut pc, _) = Sock::ready(address, &a).await; + let (mut mobile, _) = Sock::ready(address, &phone.token).await; + pc.next().await; + + let a_id = ready_peer(&s, &a).await; + s.authed("DELETE", &format!("/v1/devices/{a_id}"), &b).await; + pc.closed().await; + mobile.closed().await; +} + +#[tokio::test] +async fn one_computers_phones_never_reach_another_computer() { + let s = server(); + let address = s.listen().await; + let a = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + s.clock.advance(61_000); + let b = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone_a = s.pair(&a, 1).await; + let phone_b = s.pair(&b, 2).await; + let (mut pc_a, _) = Sock::ready(address, &a).await; + let (mut pc_b, _) = Sock::ready(address, &b).await; + let (mut mobile_a, _) = Sock::ready(address, &phone_a.token).await; + let (mut mobile_b, _) = Sock::ready(address, &phone_b.token).await; + pc_a.next().await; + pc_b.next().await; + + mobile_b + .send(json!({ "t": "msg", "seq": 1, "body": "for b" })) + .await; + assert_eq!(pc_b.next().await["body"], "for b"); + pc_a.quiet().await; + + // Computer A cannot write to computer B's phone, even knowing its id. + pc_a.send(json!({ "t": "msg", "to": phone_b.device, "seq": 1, "body": "intruder" })) + .await; + assert_eq!(pc_a.next().await["reason"], "unknown_device"); + mobile_b.quiet().await; + mobile_a.quiet().await; +} + +#[tokio::test] +async fn a_device_sends_sixty_frames_a_minute_and_a_second_socket_replaces_the_first() { + let s = server(); + let address = s.listen().await; + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let phone = s.pair(&computer, 1).await; + let (mut mobile, _) = Sock::ready(address, &phone.token).await; + for seq in 1..=60 { + mobile + .send(json!({ "t": "msg", "seq": seq, "body": "x" })) + .await; + } + mobile + .send(json!({ "t": "msg", "seq": 61, "body": "x" })) + .await; + // (Fifty queue; frames 51-60 are told the queue is full; the 61st the rate.) + let mut reasons = Vec::new(); + for _ in 0..11 { + reasons.push(mobile.next().await["reason"].as_str().map(str::to_owned)); + } + assert_eq!(reasons.last(), Some(&Some("rate_limited".to_owned()))); + s.clock.advance(61_000); + mobile.sync().await; + + let (mut again, _) = Sock::ready(address, &phone.token).await; + mobile.closed().await; + again.sync().await; +} + +#[tokio::test] +async fn the_computer_hears_when_a_phone_joins() { + let s = server(); + let address = s.listen().await; + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let (mut pc, _) = Sock::ready(address, &computer).await; + let id = pair_id(7); + s.call( + "POST", + "/v1/pairings", + Some(&computer), + Some(json!({ "id": id })), + ) + .await; + s.call( + "POST", + &format!("/v1/pairings/{id}/join"), + None, + Some(json!({ "phone_pub": key('P'), "device_name": "X", "proof": proof('p') })), + ) + .await; + assert_eq!(pc.next().await, json!({ "t": "pairing", "id": id })); +} diff --git a/crates/botloftd/tests/common/cloud_server.rs b/crates/botloftd/tests/common/cloud_server.rs index f05eebd9..2dcdc15d 100644 --- a/crates/botloftd/tests/common/cloud_server.rs +++ b/crates/botloftd/tests/common/cloud_server.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use botloft_cloud::{AppState, Clock, Config, CopyStore, Db, Mailer, Outbox, router}; +use botloft_cloud::{AppState, Clock, Config, CopyStore, Db, Hub, Mailer, Outbox, router}; use serde_json::json; use tokio::net::TcpListener; @@ -28,6 +28,7 @@ pub async fn cloud_server() -> CloudServer { clock: clock.clone(), config: Arc::new(config), store: CopyStore::memory(), + hub: Hub::default(), }; tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve"); diff --git a/docs/spec.md b/docs/spec.md index cba6b543..6bcd14d5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2258,7 +2258,7 @@ Quatro promessas valem para tudo aqui: A conta de 27 ganha o **tipo** do aparelho (`computer` ou `phone`) e, no celular, o **par** (`peer`): o computador com que ele foi conectado. Um celular conectado a dois computadores são duas conexões, cada uma com o seu token e as suas chaves. -**O token de um celular só alcança o relay, o push e `GET /v1/me`.** As cópias (27.4), o login e a lista de aparelhos dão `403` `wrong_device`. Um celular nunca baixa uma cópia. +**O token de um celular só alcança o relay, o push, `GET /v1/me` (que diz só de quem é o celular e a que computador serve, sem a lista de aparelhos nem o espaço usado) e `POST /v1/logout` (desconectar pelo próprio celular).** As cópias (27.4), a conta, os pareamentos e os outros aparelhos dão `403` `wrong_device`. Um celular nunca baixa uma cópia. Tirar um computador (`DELETE /v1/devices/`, ou apagar a conta) leva os celulares dele e fecha os sockets de todos. ### 28.3 Conectar um celular e selar as mensagens @@ -2267,7 +2267,7 @@ Criptografia que existe em todo navegador (WebCrypto) e em crates do RustCrypto, 1. No app (28.7), "Conectar um celular" chama `mobile.pair_start`. O daemon sorteia `pair_id` (16 bytes) e `secret` (32 bytes), a chave efêmera dele (`D`), e avisa o servidor (`POST /v1/pairings {id}`, com o token do computador; vale 5 minutos e uma vez). O app mostra um **QR** (gerado no app, sem serviço externo) com `/m#p=&s=`. O que vem depois do `#` o navegador nunca envia: o servidor não vê o `secret`. 2. O celular abre o link, lê o fragmento e gera a chave dele (`P`). Manda `POST /v1/pairings//join {phone_pub, device_name, proof}`, com `proof = HMAC(secret, "botloft-pair-1" ‖ id ‖ phone_pub)`. O servidor guarda e avisa o computador pelo relay. 3. O daemon confere a `proof`; errada, recusa. Certa, mostra no app o **nome do celular e um código de 6 dígitos** que sai das duas chaves públicas (`HKDF(secret, D ‖ P)`, 20 bits), e o celular mostra o mesmo código. O dono compara e clica **Conectar** (`mobile.pair_confirm`): é isso que impede quem fotografou o QR de entrar no lugar do dono. -4. O daemon manda `POST /v1/pairings//accept {daemon_pub, proof2}` (`proof2 = HMAC(secret, "botloft-pair-1-accept" ‖ id ‖ D ‖ P)`). O servidor cria o aparelho `phone` com o `peer` e, na primeira consulta do celular (`GET /v1/pairings/`, uma vez), entrega `{token, device, daemon_pub, proof2}`. O celular confere `proof2`: um servidor que trocasse a chave `D` não tem o `secret` e não consegue forjá-la. +4. O daemon manda `POST /v1/pairings//accept {daemon_pub, proof2}` (`proof2 = HMAC(secret, "botloft-pair-1-accept" ‖ id ‖ D ‖ P)`). O servidor cria o aparelho `phone` com o `peer` e, na primeira consulta do celular (`GET /v1/pairings//result`, com o `poll` do passo 2 como `Bearer`, uma vez), entrega `{token, device, peer, daemon_pub, proof2}`; quem só tem o `id` do QR não tem o `poll` e não leva o token. O computador vê o pareamento em `GET /v1/pairings/` (`waiting`, `joined` com `device_name`, `phone_pub` e `proof`, ou `expired`) e desiste ou recusa com `DELETE /v1/pairings/`. No máximo 3 códigos abertos por computador e 30 entradas por hora por endereço; os erros novos são `bad_pairing` (`400`), `pair_expired` (`410`, vale também para código já usado) e `wrong_device` (`403`). O celular confere `proof2`: um servidor que trocasse a chave `D` não tem o `secret` e não consegue forjá-la. 5. Os dois derivam `shared = ECDH(D, P)` e, com `HKDF(shared, salt = secret, info = "botloft-mobile-1")`, duas chaves AES-256: `k_c2p` (computador para celular) e `k_p2c`. A chave de cada direção só sela naquela direção. O `secret` é apagado dos dois lados. **Mensagem selada** (`seal`): `{v: 1, seq, ct}`. `ct` é AES-256-GCM com nonce de 12 bytes (`seq` em 8 bytes big-endian, 4 bytes zero) e dados autenticados `v ‖ direção ‖ device_id ‖ seq`. Quem recebe guarda o maior `seq` visto por direção e **recusa** qualquer um que não seja maior (repetição); o celular guarda o dele no IndexedDB. Trocar de chave é conectar de novo. Vetores de teste (chaves, nonces e textos de verdade) ficam num arquivo único (`docs/test-vectors/mobile.json`), gerado pelo Rust e lido pelo teste do PWA: os dois lados têm que concordar byte a byte. @@ -2284,8 +2284,16 @@ O `card` é o que o celular mostra (28.5), não o `Approval` nem a `Question` in ### 28.4 O relay (servidor) - **Aparelhos.** A migration do crate acrescenta `devices.kind` (`computer` por padrão, para o que já existe) e `devices.peer`, mais as tabelas `pairings` (`id`, `account`, `computer`, `phone_pub`, `proof`, `daemon_pub`, `proof2`, `token_once`, `expires_at`) e `relay_queue` (`device`, `seq`, `body`, `expires_at`). `DELETE /v1/devices/` num computador leva os celulares dele, e num celular fecha o socket dele na hora. -- **`GET /v1/relay`** (WebSocket, `Authorization: Bearer`). O computador e cada celular abrem um. O servidor liga um celular só ao `peer` dele; um segundo socket do mesmo aparelho substitui o primeiro. Quadros em JSON de até 64 KiB: `{t: "msg", seq, body}` (o `body` é o selo, em base64, que o servidor **não abre**), `{t: "ack", seq}`, `{t: "presence", peer: "online" | "offline"}` (para o celular saber se o computador está no ar), `{t: "wake"}` (28.8) e `{t: "ping"}` a cada 25 s (os proxies cortam conexões quietas, e a Cloudflare corta em 100 s). Limite de 60 quadros por minuto por aparelho (`429`). -- **Fila só no sentido do computador.** Uma resposta do celular enquanto o computador está sem conexão (uma queda de rede) espera até 1 hora, no máximo 50 quadros, em ordem de `seq`, e sai da fila no `ack` do daemon. **Do computador para o celular não há fila:** quando o celular abre, ele manda `sync` e recebe o retrato de agora. O celular não guarda nenhum conteúdo no aparelho entre uma abertura e outra, só a lista em memória. +- **`GET /v1/relay`** (WebSocket). O navegador não põe cabeçalho num WebSocket, então o token vai no **primeiro quadro**, `{t: "hello", token}` (nunca no endereço), em até 10 s; sem ele, ou com um token que não vale, o servidor responde `{t: "error", reason: "unauthorized"}` e fecha. O computador e cada celular abrem um. O servidor liga um celular só ao `peer` dele; um segundo socket do mesmo aparelho substitui o primeiro. Quadros em JSON de até 64 KiB (`bad_frame` quando não é JSON ou falta campo; o socket continua aberto): + - do servidor, ao entrar: `{t: "ready", kind, device, ...}`, que num computador traz `phones: [{id, online}]` e num celular `peer` e `online`; + - `{t: "msg", seq, body}`: o `body` é o selo, em texto, que o servidor **não abre**. O celular manda sem destino (vai ao `peer`) e o computador chega com `from` (o celular); o computador manda com `to`, e um `to` que não é celular dele volta como `{t: "error", reason: "unknown_device", seq}`; + - `{t: "ack", from, upto}` (só do computador): leva da fila tudo de `from` até `upto`; + - `{t: "presence", device, online}`: o outro lado entrou ou saiu (o celular sabe se o computador está no ar, e o computador, de cada celular); + - `{t: "pairing", id}` (para o computador): um celular entrou no código `id` (28.3); + - `{t: "revoked", device}` (para o computador): um celular foi desconectado, pelo computador ou por ele mesmo; + - `{t: "wake"}` (28.8, ignorado até o push existir) e `{t: "ping"}` do servidor a cada 25 s (os proxies cortam conexões quietas, e a Cloudflare corta em 100 s). + - Limite de 60 quadros por minuto por aparelho: o que passa disso volta como `{t: "error", reason: "rate_limited"}` e não é processado. +- **Fila só no sentido do computador.** Toda mensagem do celular entra na fila e é entregue ao computador na hora, se ele está conectado, ou quando ele volta (uma queda de rede), em ordem de `seq`; sai da fila no `ack` do daemon, e até lá volta a cada reconexão (o daemon descarta o `seq` que já viu, 28.3). Espera até 1 hora, e no máximo 50 por celular: a 51ª volta como `{t: "error", reason: "queue_full", seq}`. O mesmo `seq` duas vezes entra uma só. **Do computador para o celular não há fila:** quando o celular abre, ele manda `sync` e recebe o retrato de agora. O celular não guarda nenhum conteúdo no aparelho entre uma abertura e outra, só a lista em memória. - **O daemon decide, o servidor só leva.** Uma resposta que o servidor entregou mas o daemon recusa (pedido que já expirou, `seq` repetido, selo que não abre) é descartada no daemon e o celular recebe `approval.closed` ou `question.closed` com o estado verdadeiro. - **Reconexão.** O daemon reconecta com espera crescente (1 s até 60 s); só para de tentar quando o dono tira o último celular ou sai da conta. - **Logs do servidor:** nunca o `body`, o token nem o nome do aparelho em `info`. From 5735a4c409717860f35ca0001f7753f0d59208fb Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:26:59 -0300 Subject: [PATCH 3/8] feat(mobile): phone pairing, sealed relay client and approval bridge (spec 28, CE3) The daemon opens one relay connection while a phone is connected or being connected. P-256/HKDF/AES-GCM sealing (vectors in docs/test-vectors, checked against WebCrypto), QR pairing with a code to compare, keys in secrets/mobile.json, cards for approvals and questions, and the phone's answers through the same code as the app's, allowed only for plain tools shown whole. mobile.* methods and notifications, bindings, FakeBotloft handlers. The server sends a paired frame so the computer learns the phone's id. --- Cargo.lock | 357 ++++++++++++- app/src/lib/fake.ts | 2 + app/src/lib/fakeCloud.ts | 7 + app/src/lib/fakeMobile.test.ts | 52 ++ app/src/lib/fakeMobile.ts | 121 +++++ app/src/lib/protocol.gen.ts | 129 +++++ crates/botloft-cloud/src/pairing.rs | 5 + crates/botloft-cloud/tests/relay.rs | 18 + crates/botloft-core/src/protocol/export.rs | 5 + .../src/protocol/export_mobile.rs | 67 +++ crates/botloft-core/src/protocol/mobile.rs | 262 ++++++++++ crates/botloft-core/src/protocol/mod.rs | 9 + crates/botloftd/Cargo.toml | 4 +- crates/botloftd/src/cloud/client.rs | 4 +- crates/botloftd/src/cloud/mod.rs | 11 +- crates/botloftd/src/cloud/pairing.rs | 123 +++++ crates/botloftd/src/lib.rs | 1 + crates/botloftd/src/main.rs | 1 + crates/botloftd/src/mobile/bridge.rs | 301 +++++++++++ crates/botloftd/src/mobile/cards.rs | 207 ++++++++ crates/botloftd/src/mobile/kdf.rs | 107 ++++ crates/botloftd/src/mobile/mod.rs | 300 +++++++++++ crates/botloftd/src/mobile/pair.rs | 301 +++++++++++ crates/botloftd/src/mobile/phones.rs | 241 +++++++++ crates/botloftd/src/mobile/relay.rs | 195 +++++++ crates/botloftd/src/mobile/seal.rs | 359 +++++++++++++ crates/botloftd/src/rpc/dispatch.rs | 13 +- crates/botloftd/src/rpc/mod.rs | 5 + crates/botloftd/src/rpc/notify.rs | 2 + crates/botloftd/src/service/cloud.rs | 5 +- crates/botloftd/src/service/mobile.rs | 57 +++ crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/src/state.rs | 9 +- crates/botloftd/tests/common/cloud_server.rs | 12 +- crates/botloftd/tests/common/mobile.rs | 249 +++++++++ crates/botloftd/tests/common/mod.rs | 23 + crates/botloftd/tests/mobile.rs | 474 ++++++++++++++++++ docs/spec.md | 17 +- docs/test-vectors/mobile.json | 34 ++ 39 files changed, 4064 insertions(+), 26 deletions(-) create mode 100644 app/src/lib/fakeMobile.test.ts create mode 100644 app/src/lib/fakeMobile.ts create mode 100644 crates/botloft-core/src/protocol/export_mobile.rs create mode 100644 crates/botloft-core/src/protocol/mobile.rs create mode 100644 crates/botloftd/src/cloud/pairing.rs create mode 100644 crates/botloftd/src/mobile/bridge.rs create mode 100644 crates/botloftd/src/mobile/cards.rs create mode 100644 crates/botloftd/src/mobile/kdf.rs create mode 100644 crates/botloftd/src/mobile/mod.rs create mode 100644 crates/botloftd/src/mobile/pair.rs create mode 100644 crates/botloftd/src/mobile/phones.rs create mode 100644 crates/botloftd/src/mobile/relay.rs create mode 100644 crates/botloftd/src/mobile/seal.rs create mode 100644 crates/botloftd/src/service/mobile.rs create mode 100644 crates/botloftd/tests/common/mobile.rs create mode 100644 crates/botloftd/tests/mobile.rs create mode 100644 docs/test-vectors/mobile.json diff --git a/Cargo.lock b/Cargo.lock index e6d54482..d48e3c00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,41 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes", + "cipher 0.5.2", + "ctr", + "ctutils", + "ghash", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -372,6 +407,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -560,6 +601,7 @@ dependencies = [ name = "botloftd" version = "0.13.0" dependencies = [ + "aes-gcm", "anyhow", "argon2", "axum", @@ -578,6 +620,7 @@ dependencies = [ "jiff", "jpeg-encoder", "libc", + "p256", "reqwest", "serde", "serde_json", @@ -790,7 +833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -811,9 +854,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -837,10 +880,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.7" @@ -890,6 +944,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -981,6 +1041,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1033,6 +1099,21 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1050,7 +1131,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1082,6 +1165,25 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", + "subtle", +] + [[package]] name = "darling" version = "0.24.1" @@ -1164,6 +1266,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1231,6 +1343,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1360,12 +1473,46 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "elliptic-curve", + "signature", + "spki", + "zeroize", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.2" @@ -1503,6 +1650,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1827,6 +1984,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1923,6 +2089,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -2051,6 +2228,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -2118,7 +2313,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -2378,6 +2575,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2645,7 +2851,7 @@ dependencies = [ "tokio", "tokio-rustls", "url", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -3301,6 +3507,18 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", +] + [[package]] name = "pango" version = "0.18.3" @@ -3442,6 +3660,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3509,7 +3737,18 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", ] [[package]] @@ -3557,6 +3796,32 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4156,6 +4421,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4359,6 +4638,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4459,6 +4748,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4563,6 +4861,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -5321,8 +5629,12 @@ checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.30.0", + "webpki-roots 0.26.11", ] [[package]] @@ -5635,6 +5947,8 @@ dependencies = [ "httparse", "log", "rand 0.10.3", + "rustls", + "rustls-pki-types", "sha1 0.11.0", "thiserror 2.0.21", ] @@ -5700,6 +6014,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5987,6 +6311,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -6472,6 +6805,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 5e41da56..8d200dba 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -18,6 +18,7 @@ import { FakeDesktop } from "./fakeDesktop"; import { FakeFiles } from "./fakeFiles"; import { FakeLesson } from "./fakeLesson"; import { FakeMcp } from "./fakeMcp"; +import { mobileHandlers } from "./fakeMobile"; import { FakeQuestions } from "./fakeQuestions"; import { FakeReactions } from "./fakeReactions"; import { FakeRoutines } from "./fakeRoutines"; @@ -293,6 +294,7 @@ export class FakeBotloft implements BotloftApi { ...backupHandlers(this), ...cloudHandlers(this), ...autoBackupHandlers(this), + ...mobileHandlers(this), ...searchHandlers(this), }; } diff --git a/app/src/lib/fakeCloud.ts b/app/src/lib/fakeCloud.ts index 0db3d9b6..fa4d5771 100644 --- a/app/src/lib/fakeCloud.ts +++ b/app/src/lib/fakeCloud.ts @@ -4,6 +4,7 @@ import type { FakeBotloft, Handlers } from "./fake"; import { turnOffFakeAutoBackup } from "./fakeAutoBackup"; +import { forgetFakePhones } from "./fakeMobile"; import { type CloudCopy, type CloudStatus, RpcErrorCode } from "./protocol.gen"; import { RpcError } from "./rpc"; @@ -41,6 +42,11 @@ function signedIn(state: CloudState): void { if (!state.signedIn) throw refused("not_signed_in", "sign in to the account first"); } +/** Whether the fake is signed in to the account. */ +export function fakeSignedIn(fake: FakeBotloft): boolean { + return stateOf(fake).signedIn; +} + /** The owner opens the link in the e-mail: the waiting sign-in finishes. */ export function openFakeCloudLink(fake: FakeBotloft): void { const state = stateOf(fake); @@ -87,6 +93,7 @@ export function cloudHandlers(fake: FakeBotloft): Pick { }, "cloud.signout": () => { turnOffFakeAutoBackup(fake); + forgetFakePhones(fake); state.signedIn = false; state.email = null; state.pending = null; diff --git a/app/src/lib/fakeMobile.test.ts b/app/src/lib/fakeMobile.test.ts new file mode 100644 index 00000000..ff124c26 --- /dev/null +++ b/app/src/lib/fakeMobile.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { FakeBotloft } from "./fake"; +import { openFakeCloudLink } from "./fakeCloud"; +import { joinFakePhone } from "./fakeMobile"; + +describe("the fake's phones", () => { + it("needs the account, shows the code a phone brings, connects it and cuts it off", async () => { + const fake = new FakeBotloft(); + const seen: string[] = []; + fake.subscribe((event) => seen.push(event.name)); + + await expect(fake.call("mobile.pair_start")).rejects.toMatchObject({ reason: "not_signed_in" }); + await fake.call("cloud.signin", { email: "ana@exemplo.com" }); + openFakeCloudLink(fake); + + const started = await fake.call("mobile.pair_start"); + expect(started.url).toContain("#p="); + expect((await fake.call("mobile.status")).pending?.pairId).toBe(started.pairId); + await expect( + fake.call("mobile.pair_confirm", { pairId: started.pairId, accept: true }), + ).rejects.toMatchObject({ reason: "conflict" }); + + joinFakePhone(fake, "Celular da Ana", "482913"); + expect(seen).toContain("mobile.pair_request"); + expect((await fake.call("mobile.status")).pending?.joined).toEqual({ + name: "Celular da Ana", + code: "482913", + }); + await fake.call("mobile.pair_confirm", { pairId: started.pairId, accept: true }); + const status = await fake.call("mobile.status"); + expect(status.pending).toBeUndefined(); + expect(status.phones).toHaveLength(1); + expect(status.phones[0]?.name).toBe("Celular da Ana"); + + const gone = await fake.call("mobile.revoke", { phoneId: status.phones[0]?.id ?? "" }); + expect(gone.phones).toEqual([]); + await expect(fake.call("mobile.revoke", { phoneId: "dev_none" })).rejects.toMatchObject({ + reason: "not_found", + }); + }); + + it("is cleared when the account is left", async () => { + const fake = new FakeBotloft(); + await fake.call("cloud.signin", { email: "ana@exemplo.com" }); + openFakeCloudLink(fake); + const started = await fake.call("mobile.pair_start"); + joinFakePhone(fake); + await fake.call("mobile.pair_confirm", { pairId: started.pairId, accept: true }); + await fake.call("cloud.signout"); + expect((await fake.call("mobile.status")).phones).toEqual([]); + }); +}); diff --git a/app/src/lib/fakeMobile.ts b/app/src/lib/fakeMobile.ts new file mode 100644 index 00000000..0e8987bd --- /dev/null +++ b/app/src/lib/fakeMobile.ts @@ -0,0 +1,121 @@ +// The fake daemon's phones (spec 28), in memory. A code on screen waits until +// `joinFakePhone` plays a phone scanning it; nothing leaves the page. + +import type { FakeBotloft, Handlers } from "./fake"; +import { fakeSignedIn } from "./fakeCloud"; +import { + type MobilePairing, + type MobilePhone, + type MobileStatus, + RpcErrorCode, +} from "./protocol.gen"; +import { RpcError } from "./rpc"; + +type MobileMethods = Extract; + +const EXPIRES_IN = 300; + +interface MobileState { + phones: MobilePhone[]; + pending: MobilePairing | null; + next: number; +} + +const states = new WeakMap(); + +function stateOf(fake: FakeBotloft): MobileState { + let state = states.get(fake); + if (!state) { + state = { phones: [], pending: null, next: 1 }; + states.set(fake, state); + } + return state; +} + +function refused(code: number, reason: string, message: string): RpcError { + return new RpcError(code, message, reason); +} + +function statusOf(fake: FakeBotloft): MobileStatus { + const state = stateOf(fake); + const status: MobileStatus = { + relay: state.phones.length > 0 || state.pending ? "connected" : "off", + phones: state.phones.map((phone) => ({ ...phone })), + }; + if (state.pending) status.pending = { ...state.pending }; + return status; +} + +function told(fake: FakeBotloft): MobileStatus { + const status = statusOf(fake); + fake.emit({ name: "mobile.changed", params: status }); + return status; +} + +/** A phone scans the code on screen and shows `code` to be compared. */ +export function joinFakePhone(fake: FakeBotloft, name = "Celular da Ana", code = "482913"): void { + const { pending } = stateOf(fake); + if (!pending) return; + pending.joined = { name, code }; + fake.emit({ name: "mobile.pair_request", params: { pairId: pending.pairId, name, code } }); + told(fake); +} + +/** Signing out of the account takes every phone with it. */ +export function forgetFakePhones(fake: FakeBotloft): void { + const state = stateOf(fake); + state.phones = []; + state.pending = null; +} + +export function mobileHandlers(fake: FakeBotloft): Pick { + const state = stateOf(fake); + return { + "mobile.status": () => statusOf(fake), + "mobile.pair_start": () => { + if (!fakeSignedIn(fake)) { + throw refused(RpcErrorCode.validation, "not_signed_in", "sign in to the account first"); + } + const pairId = `pair${state.next++}`; + state.pending = { pairId, expiresAt: fake.now + EXPIRES_IN * 1000 }; + told(fake); + return { + pairId, + url: `https://cloud.botloft.example/m#p=${pairId}&s=secret&d=key`, + expiresIn: EXPIRES_IN, + }; + }, + "mobile.pair_cancel": ({ pairId }) => { + if (state.pending?.pairId === pairId) { + state.pending = null; + told(fake); + } + return null; + }, + "mobile.pair_confirm": ({ pairId, accept }) => { + const pending = state.pending; + if (!pending || pending.pairId !== pairId || !pending.joined) { + throw refused(RpcErrorCode.conflict, "conflict", "there is no phone waiting on that code"); + } + state.pending = null; + if (accept) { + state.phones.push({ + id: `dev_phone${state.next++}`, + name: pending.joined.name, + pairedAt: fake.now, + online: true, + }); + } + told(fake); + return null; + }, + "mobile.revoke": ({ phoneId }) => { + const before = state.phones.length; + state.phones = state.phones.filter((phone) => phone.id !== phoneId); + if (state.phones.length === before) { + throw refused(RpcErrorCode.notFound, "not_found", `phone ${phoneId}`); + } + return told(fake); + }, + }; +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index b1742d49..218e2f5a 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1341,6 +1341,128 @@ export type AutoBackupEnableParams = { passphrase: string, every: AutoBackupEver */ export type AutoBackupEveryParams = { every: AutoBackupEvery, }; +/** + * Whether the computer's connection to the relay is up. + */ +export type MobileRelay = "off" | "connecting" | "connected"; + +/** + * A phone the owner connected. + */ +export type MobilePhone = { id: string, name: string, +/** + * Unix time in milliseconds. + */ +pairedAt: number, +/** + * When the phone last sent anything, in Unix milliseconds. + */ +lastSeenAt?: number, online: boolean, }; + +/** + * A phone that opened the QR code and waits for the owner to compare the + * code and accept it. + */ +export type MobileJoined = { name: string, +/** + * Six digits, the same on the phone's screen. + */ +code: string, }; + +/** + * A QR code on screen. + */ +export type MobilePairing = { pairId: string, +/** + * Unix time in milliseconds the code stops working. + */ +expiresAt: number, joined?: MobileJoined, }; + +/** + * `mobile.status`, and the `mobile.changed` notification. + */ +export type MobileStatus = { relay: MobileRelay, phones: Array, pending?: MobilePairing, }; + +/** + * `mobile.pair_start`: what the QR code carries. + */ +export type MobilePairStarted = { pairId: string, +/** + * The address the QR code holds, `#` and all. + */ +url: string, +/** + * Seconds the code works. + */ +expiresIn: number, }; + +/** + * `mobile.pair_cancel`. + */ +export type MobilePairIdParams = { pairId: string, }; + +/** + * `mobile.pair_confirm`: the owner compared the codes. + */ +export type MobilePairConfirmParams = { pairId: string, accept: boolean, }; + +/** + * `mobile.revoke`. + */ +export type MobileRevokeParams = { phoneId: string, }; + +/** + * The `mobile.pair_request` notification: a phone opened the code. + */ +export type MobilePairRequest = { pairId: string, name: string, code: string, }; + +/** + * The bot that asks, as the phone shows it. + */ +export type CardBot = { name: string, color: string, }; + +/** + * A request waiting for the owner, as the phone shows it (spec 28.5). It + * carries what the owner needs to decide, and nothing else of the bot. + */ +export type ApprovalCard = { approvalId: ApprovalId, bot: CardBot, crew: string, +/** + * Unix time in milliseconds. + */ +createdAt: number, toolName: string, summary: string, +/** + * What the bot says the command is for; the bot wrote it. + */ +explanation?: string, +/** + * The command, the plan or the request, whole unless `cut`. + */ +text: string, +/** + * `text` is not the whole request: the phone may only deny it. + */ +cut: boolean, +/** + * The request is of a kind that is answered at the computer: the phone + * may only deny it. + */ +atComputer: boolean, }; + +/** + * A question waiting for the owner, as the phone shows it. + */ +export type QuestionCard = { questionId: QuestionId, bot: CardBot, crew: string, createdAt: number, text: string, options: Array, }; + +/** + * What the computer sends the phone, sealed (spec 28.3). + */ +export type ToPhone = { "t": "snapshot", approvals: Array, questions: Array, } | { "t": "approval.open", card: ApprovalCard, } | { "t": "approval.closed", approvalId: ApprovalId, status: ApprovalStatus, } | { "t": "question.open", card: QuestionCard, } | { "t": "question.closed", questionId: QuestionId, status: QuestionStatus, }; + +/** + * What the phone sends the computer, sealed (spec 28.3). + */ +export type FromPhone = { "t": "sync" } | { "t": "approval.answer", approvalId: ApprovalId, allow: boolean, note?: string, } | { "t": "question.answer", questionId: QuestionId, answer: string, } | { "t": "question.dismiss", questionId: QuestionId, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -2064,6 +2186,11 @@ export interface RpcMethods { "autobackup.enable": { params: AutoBackupEnableParams; result: AutoBackupStatus }; "autobackup.set_every": { params: AutoBackupEveryParams; result: AutoBackupStatus }; "autobackup.disable": { params: undefined; result: AutoBackupStatus }; + "mobile.status": { params: undefined; result: MobileStatus }; + "mobile.pair_start": { params: undefined; result: MobilePairStarted }; + "mobile.pair_cancel": { params: MobilePairIdParams; result: null }; + "mobile.pair_confirm": { params: MobilePairConfirmParams; result: null }; + "mobile.revoke": { params: MobileRevokeParams; result: MobileStatus }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; @@ -2123,6 +2250,8 @@ export interface RpcNotifications { "cloud.progress": CloudProgress; "cloud.signin_expired": null; "autobackup.changed": AutoBackupStatus; + "mobile.changed": MobileStatus; + "mobile.pair_request": MobilePairRequest; "browser.changed": BrowserState; "browser.action": BrowserAction; "browser.frame": BrowserFrame; diff --git a/crates/botloft-cloud/src/pairing.rs b/crates/botloft-cloud/src/pairing.rs index e89f7e71..24a83671 100644 --- a/crates/botloft-cloud/src/pairing.rs +++ b/crates/botloft-cloud/src/pairing.rs @@ -232,6 +232,11 @@ pub async fn result( // Two polls at once: the other one took it. return Ok(Json(json!({ "status": "expired" }))); } + // The computer learns the phone's id, which only the phone had. + state.hub.send( + &ready.computer, + json!({ "t": "paired", "pairing": id, "device": device }).to_string(), + ); Ok(Json(json!({ "status": "approved", "token": token, diff --git a/crates/botloft-cloud/tests/relay.rs b/crates/botloft-cloud/tests/relay.rs index 24baf542..82f29a54 100644 --- a/crates/botloft-cloud/tests/relay.rs +++ b/crates/botloft-cloud/tests/relay.rs @@ -316,6 +316,24 @@ async fn a_device_sends_sixty_frames_a_minute_and_a_second_socket_replaces_the_f again.sync().await; } +#[tokio::test] +async fn the_computer_hears_the_phones_id_when_it_collects_its_token() { + let s = server(); + let address = s.listen().await; + let computer = s.sign_in("ana@exemplo.com", "1.1.1.1").await; + let (mut pc, _) = Sock::ready(address, &computer).await; + let phone = s.pair(&computer, 3).await; + // The join came first, then the phone collected its token. + assert_eq!( + pc.next().await, + json!({ "t": "pairing", "id": phone.pairing }) + ); + assert_eq!( + pc.next().await, + json!({ "t": "paired", "pairing": phone.pairing, "device": phone.device }) + ); +} + #[tokio::test] async fn the_computer_hears_when_a_phone_joins() { let s = server(); diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 79c48c7c..2b75a830 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -28,6 +28,8 @@ mod crew_access; mod desktop; #[path = "export_mcp.rs"] mod mcp; +#[path = "export_mobile.rs"] +mod mobile; #[path = "export_questions.rs"] mod questions; #[path = "export_reactions.rs"] @@ -197,6 +199,7 @@ fn export_bindings() { catalog::decls(&mut out); backup::decls(&mut out); cloud::decls(&mut out); + mobile::decls(&mut out); out.decl::(); usage::decls(&mut out); questions::decls(&mut out); @@ -371,6 +374,7 @@ fn export_bindings() { catalog::methods(&mut out); backup::methods(&mut out); cloud::methods(&mut out); + mobile::methods(&mut out); routines::methods(&mut out); questions::methods(&mut out); reactions::methods(&mut out); @@ -425,6 +429,7 @@ fn export_bindings() { desktop::notifications(&mut out); mcp::notifications(&mut out); cloud::notifications(&mut out); + mobile::notifications(&mut out); browser::notifications(&mut out); out.text.push_str("}\n\n"); diff --git a/crates/botloft-core/src/protocol/export_mobile.rs b/crates/botloft-core/src/protocol/export_mobile.rs new file mode 100644 index 00000000..3a2660cf --- /dev/null +++ b/crates/botloft-core/src/protocol/export_mobile.rs @@ -0,0 +1,67 @@ +//! The phone part of the TypeScript bindings (spec 28). + +use std::fmt::Write as _; + +use super::super::*; +use super::Out; + +pub(super) fn decls(out: &mut Out) { + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method( + method::MOBILE_STATUS, + "undefined", + &out.name::(), + ); + out.method( + method::MOBILE_PAIR_START, + "undefined", + &out.name::(), + ); + out.method( + method::MOBILE_PAIR_CANCEL, + &out.name::(), + "null", + ); + out.method( + method::MOBILE_PAIR_CONFIRM, + &out.name::(), + "null", + ); + out.method( + method::MOBILE_REVOKE, + &out.name::(), + &out.name::(), + ); +} + +pub(super) fn notifications(out: &mut Out) { + let changed = out.name::(); + let _ = writeln!( + out.text, + " \"{}\": {changed};", + notification::MOBILE_CHANGED + ); + let request = out.name::(); + let _ = writeln!( + out.text, + " \"{}\": {request};", + notification::MOBILE_PAIR_REQUEST + ); +} diff --git a/crates/botloft-core/src/protocol/mobile.rs b/crates/botloft-core/src/protocol/mobile.rs new file mode 100644 index 00000000..7357c07f --- /dev/null +++ b/crates/botloft-core/src/protocol/mobile.rs @@ -0,0 +1,262 @@ +//! The phone (spec 28): the `mobile.*` methods the app calls, and the sealed +//! messages that cross the relay between the computer and the phone (28.3). + +use serde::{Deserialize, Serialize}; + +use crate::ids::{ApprovalId, QuestionId}; +use crate::protocol::{ApprovalStatus, QuestionStatus}; + +/// Whether the computer's connection to the relay is up. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub enum MobileRelay { + /// No phone is connected and none is being: nothing is open. + Off, + Connecting, + Connected, +} + +/// A phone the owner connected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobilePhone { + pub id: String, + pub name: String, + /// Unix time in milliseconds. + pub paired_at: i64, + /// When the phone last sent anything, in Unix milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub last_seen_at: Option, + pub online: bool, +} + +/// A phone that opened the QR code and waits for the owner to compare the +/// code and accept it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobileJoined { + pub name: String, + /// Six digits, the same on the phone's screen. + pub code: String, +} + +/// A QR code on screen. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobilePairing { + pub pair_id: String, + /// Unix time in milliseconds the code stops working. + pub expires_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub joined: Option, +} + +/// `mobile.status`, and the `mobile.changed` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobileStatus { + pub relay: MobileRelay, + pub phones: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub pending: Option, +} + +/// `mobile.pair_start`: what the QR code carries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobilePairStarted { + pub pair_id: String, + /// The address the QR code holds, `#` and all. + pub url: String, + /// Seconds the code works. + pub expires_in: u32, +} + +/// `mobile.pair_cancel`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobilePairIdParams { + pub pair_id: String, +} + +/// `mobile.pair_confirm`: the owner compared the codes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobilePairConfirmParams { + pub pair_id: String, + pub accept: bool, +} + +/// `mobile.revoke`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobileRevokeParams { + pub phone_id: String, +} + +/// The `mobile.pair_request` notification: a phone opened the code. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MobilePairRequest { + pub pair_id: String, + pub name: String, + pub code: String, +} + +/// The bot that asks, as the phone shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CardBot { + pub name: String, + pub color: String, +} + +/// A request waiting for the owner, as the phone shows it (spec 28.5). It +/// carries what the owner needs to decide, and nothing else of the bot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ApprovalCard { + pub approval_id: ApprovalId, + pub bot: CardBot, + pub crew: String, + /// Unix time in milliseconds. + pub created_at: i64, + pub tool_name: String, + pub summary: String, + /// What the bot says the command is for; the bot wrote it. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub explanation: Option, + /// The command, the plan or the request, whole unless `cut`. + pub text: String, + /// `text` is not the whole request: the phone may only deny it. + pub cut: bool, + /// The request is of a kind that is answered at the computer: the phone + /// may only deny it. + pub at_computer: bool, +} + +/// A question waiting for the owner, as the phone shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct QuestionCard { + pub question_id: QuestionId, + pub bot: CardBot, + pub crew: String, + pub created_at: i64, + pub text: String, + pub options: Vec, +} + +/// What the computer sends the phone, sealed (spec 28.3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "t", rename_all_fields = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub enum ToPhone { + /// What is waiting right now: the answer to `sync`. + #[serde(rename = "snapshot")] + Snapshot { + approvals: Vec, + questions: Vec, + }, + #[serde(rename = "approval.open")] + ApprovalOpen { card: ApprovalCard }, + /// It was answered (at the computer or here) or ran out. + #[serde(rename = "approval.closed")] + ApprovalClosed { + approval_id: ApprovalId, + status: ApprovalStatus, + }, + #[serde(rename = "question.open")] + QuestionOpen { card: QuestionCard }, + #[serde(rename = "question.closed")] + QuestionClosed { + question_id: QuestionId, + status: QuestionStatus, + }, +} + +/// What the phone sends the computer, sealed (spec 28.3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "t", rename_all_fields = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub enum FromPhone { + /// Asks for the `snapshot`. + #[serde(rename = "sync")] + Sync, + #[serde(rename = "approval.answer")] + ApprovalAnswer { + approval_id: ApprovalId, + allow: bool, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + note: Option, + }, + #[serde(rename = "question.answer")] + QuestionAnswer { + question_id: QuestionId, + answer: String, + }, + #[serde(rename = "question.dismiss")] + QuestionDismiss { question_id: QuestionId }, +} + +/// The biggest `text` a card carries, in bytes (spec 28.5). +pub const CARD_TEXT_MAX: usize = 8 * 1024; +/// The biggest sealed message either way, in bytes of JSON. +pub const SEALED_MAX: usize = 16 * 1024; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_sealed_messages_have_the_shape_the_phone_reads() { + let sync = serde_json::to_value(FromPhone::Sync).expect("json"); + assert_eq!(sync, serde_json::json!({ "t": "sync" })); + let id = QuestionId::generate(); + let dismiss = serde_json::to_value(FromPhone::QuestionDismiss { + question_id: id.clone(), + }) + .expect("json"); + assert_eq!( + dismiss, + serde_json::json!({ "t": "question.dismiss", "questionId": id }) + ); + let back: FromPhone = serde_json::from_value(serde_json::json!({ + "t": "approval.answer", "approvalId": ApprovalId::generate(), "allow": true, + })) + .expect("an answer without a note"); + assert!(matches!( + back, + FromPhone::ApprovalAnswer { + allow: true, + note: None, + .. + } + )); + let closed = serde_json::to_value(ToPhone::QuestionClosed { + question_id: id, + status: QuestionStatus::Answered, + }) + .expect("json"); + assert_eq!(closed["t"], "question.closed"); + assert_eq!(closed["status"], "answered"); + } +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 725f6cf1..07b581ac 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -64,6 +64,7 @@ mod lesson; mod mcp; mod messaging; mod methods; +mod mobile; mod model; mod questions; mod reactions; @@ -88,6 +89,7 @@ pub use lesson::*; pub use mcp::*; pub use messaging::*; pub use methods::*; +pub use mobile::*; pub use model::*; pub use questions::*; pub use reactions::*; @@ -154,6 +156,11 @@ pub mod method { pub const AUTOBACKUP_ENABLE: &str = "autobackup.enable"; pub const AUTOBACKUP_SET_EVERY: &str = "autobackup.set_every"; pub const AUTOBACKUP_DISABLE: &str = "autobackup.disable"; + pub const MOBILE_STATUS: &str = "mobile.status"; + pub const MOBILE_PAIR_START: &str = "mobile.pair_start"; + pub const MOBILE_PAIR_CANCEL: &str = "mobile.pair_cancel"; + pub const MOBILE_PAIR_CONFIRM: &str = "mobile.pair_confirm"; + pub const MOBILE_REVOKE: &str = "mobile.revoke"; pub const CREW_ACCESS_LIST: &str = "crewAccess.list"; pub const CREW_ACCESS_REVOKE: &str = "crewAccess.revoke"; pub const MCP_SERVERS: &str = "mcp.servers"; @@ -247,6 +254,8 @@ pub mod notification { pub const CLOUD_PROGRESS: &str = "cloud.progress"; pub const CLOUD_SIGNIN_EXPIRED: &str = "cloud.signin_expired"; pub const AUTOBACKUP_CHANGED: &str = "autobackup.changed"; + pub const MOBILE_CHANGED: &str = "mobile.changed"; + pub const MOBILE_PAIR_REQUEST: &str = "mobile.pair_request"; } /// Error codes: the standard JSON-RPC ones plus the daemon's (spec 11.4). diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 3ecf518b..822c7d1c 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -9,6 +9,7 @@ authors.workspace = true [dependencies] anyhow = "1.0.104" +aes-gcm = "0.11.1" argon2 = "0.5" axum = { version = "0.8.9", features = ["ws"] } base64 = "0.23.1" @@ -23,13 +24,14 @@ getrandom = "0.4.3" hex = "0.4.3" jiff = "0.2.37" jpeg-encoder = "0.7.1" +p256 = { version = "0.14.0", default-features = false, features = ["ecdh", "arithmetic", "alloc"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json", "stream"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sha2 = "0.11.0" thiserror = "2.0.21" tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "signal", "time"] } -tokio-tungstenite = "0.30.0" +tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] } toml = "1.1.6" toml_edit = "0.25.15" tracing = "0.1.44" diff --git a/crates/botloftd/src/cloud/client.rs b/crates/botloftd/src/cloud/client.rs index 022f85ed..9ade5f11 100644 --- a/crates/botloftd/src/cloud/client.rs +++ b/crates/botloftd/src/cloud/client.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use super::CloudError; /// Small calls give up after this; transfers only when they go quiet. -const CALL: Duration = Duration::from_secs(30); +pub(super) const CALL: Duration = Duration::from_secs(30); const QUIET: Duration = Duration::from_secs(60); pub struct Server { @@ -90,7 +90,7 @@ impl Server { }) } - async fn json(&self, request: RequestBuilder) -> Result { + pub(super) async fn json(&self, request: RequestBuilder) -> Result { let response = self.send(request.timeout(CALL)).await?; response .json() diff --git a/crates/botloftd/src/cloud/mod.rs b/crates/botloftd/src/cloud/mod.rs index 2b7c01a0..98216747 100644 --- a/crates/botloftd/src/cloud/mod.rs +++ b/crates/botloftd/src/cloud/mod.rs @@ -4,6 +4,7 @@ mod client; mod creds; +mod pairing; pub(crate) mod signin; mod transfer; @@ -15,6 +16,7 @@ use tokio::task::AbortHandle; pub use client::{Me, Server}; pub use creds::Credentials; +pub use pairing::Seen; pub use transfer::{Progress, download, upload}; use crate::config::Config; @@ -28,6 +30,8 @@ pub struct CloudSettings { pub poll: Duration, /// How often the automatic backup looks whether its time has come. pub auto_tick: Duration, + /// How soon the phone relay tries again after losing the server. + pub relay_retry: crate::mobile::Retry, } impl Default for CloudSettings { @@ -36,6 +40,7 @@ impl Default for CloudSettings { url: String::new(), poll: Duration::from_secs(2), auto_tick: crate::autobackup::DEFAULT_TICK, + relay_retry: crate::mobile::Retry::default(), } } } @@ -46,6 +51,7 @@ impl CloudSettings { url: config.cloud.url.trim().to_owned(), poll: Duration::from_millis(config.cloud.poll_ms), auto_tick: crate::autobackup::DEFAULT_TICK, + relay_retry: crate::mobile::Retry::default(), } } } @@ -88,7 +94,7 @@ impl CloudError { /// The server's own `reason`; one this build does not know stays generic. pub(crate) fn from_server(reason: &str, message: String) -> Self { - const KNOWN: [&str; 11] = [ + const KNOWN: [&str; 14] = [ "unauthorized", "not_found", "bad_email", @@ -100,6 +106,9 @@ impl CloudError { "too_big", "quota", "bad_range", + "wrong_device", + "bad_pairing", + "pair_expired", ]; Self { reason: KNOWN diff --git a/crates/botloftd/src/cloud/pairing.rs b/crates/botloftd/src/cloud/pairing.rs new file mode 100644 index 00000000..acfe0065 --- /dev/null +++ b/crates/botloftd/src/cloud/pairing.rs @@ -0,0 +1,123 @@ +//! The server's calls for connecting a phone (spec 28.3) and the address of +//! its relay (28.4). One call each; the pairing's meaning is in `mobile`. + +use serde_json::json; + +use super::CloudError; +use super::client::{CALL, Server}; + +/// What the computer finds when it looks at its pairing. +pub enum Seen { + Waiting, + Joined { + name: String, + phone_pub: String, + proof: String, + }, + /// Unknown, expired or cancelled. + Gone, +} + +impl Server { + /// The relay's WebSocket address: `https` becomes `wss`. + pub fn relay_url(&self) -> String { + let url = self.url("/v1/relay"); + match url.strip_prefix("https://") { + Some(rest) => format!("wss://{rest}"), + None => url.replacen("http://", "ws://", 1), + } + } + + /// The address a phone's QR code opens (the part after `#` is added by + /// the caller). + pub fn phone_page(&self) -> String { + self.url("/m") + } + + /// Opens a pairing for the QR code. The server's `expires_in` is seconds. + pub async fn open_pairing(&self, token: &str, id: &str) -> Result { + let answer = self + .json( + self.http + .post(self.url("/v1/pairings")) + .bearer_auth(token) + .json(&json!({ "id": id })), + ) + .await?; + Ok(answer["expires_in"].as_u64().unwrap_or(300) as u32) + } + + pub async fn pairing(&self, token: &str, id: &str) -> Result { + let answer = self + .json( + self.http + .get(self.url(&format!("/v1/pairings/{id}"))) + .bearer_auth(token), + ) + .await?; + let field = |key: &str| answer[key].as_str().map(str::to_owned); + Ok(match answer["status"].as_str() { + Some("waiting") => Seen::Waiting, + Some("joined") => match (field("phone_pub"), field("proof")) { + (Some(phone_pub), Some(proof)) => Seen::Joined { + name: field("device_name").unwrap_or_default(), + phone_pub, + proof, + }, + _ => Seen::Gone, + }, + _ => Seen::Gone, + }) + } + + pub async fn accept_pairing( + &self, + token: &str, + id: &str, + daemon_pub: &str, + proof2: &str, + ) -> Result<(), CloudError> { + self.send( + self.http + .post(self.url(&format!("/v1/pairings/{id}/accept"))) + .bearer_auth(token) + .json(&json!({ "daemon_pub": daemon_pub, "proof2": proof2 })) + .timeout(CALL), + ) + .await + .map(|_| ()) + } + + /// Cancels, or refuses the phone that joined. A pairing that is already + /// gone is not a problem. + pub async fn cancel_pairing(&self, token: &str, id: &str) -> Result<(), CloudError> { + let result = self + .send( + self.http + .delete(self.url(&format!("/v1/pairings/{id}"))) + .bearer_auth(token) + .timeout(CALL), + ) + .await; + match result { + Err(err) if err.reason == "not_found" => Ok(()), + other => other.map(|_| ()), + } + } + + /// Takes a device (a phone, here) off the account. Already gone is fine. + pub async fn remove_device(&self, token: &str, id: &str) -> Result<(), CloudError> { + let result = self + .send( + self.http + .delete(self.url(&format!("/v1/devices/{id}"))) + .bearer_auth(token) + .timeout(CALL), + ) + .await; + match result { + Err(err) if err.reason == "not_found" => Ok(()), + other => other.map(|_| ()), + } + } +} diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index a18a9061..863cc9d1 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -18,6 +18,7 @@ pub mod desktop; pub mod keep_awake; pub mod logging; pub mod mcp_secrets; +pub mod mobile; pub mod paths; pub mod platform; pub mod routines; diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index 7f6318f0..20f835dd 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -172,6 +172,7 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a tokio::spawn(courier::run(Arc::clone(&daemon))); tokio::spawn(routines::run(Arc::clone(&daemon))); tokio::spawn(botloftd::autobackup::run(Arc::clone(&daemon))); + tokio::spawn(botloftd::mobile::run(Arc::clone(&daemon))); tokio::spawn(browser::run(Arc::clone(&daemon))); // The outline around the window a bot uses (spec 24.9). tokio::spawn(botloftd::desktop::outline::run(Arc::clone(&daemon))); diff --git a/crates/botloftd/src/mobile/bridge.rs b/crates/botloftd/src/mobile/bridge.rs new file mode 100644 index 00000000..523763f9 --- /dev/null +++ b/crates/botloftd/src/mobile/bridge.rs @@ -0,0 +1,301 @@ +//! Between the phone and the owner's own flows (spec 28.5): what happens in +//! the chat and the question box reaches the phone as a card, and what the +//! phone answers goes through the same code as the app's answer, with a few +//! more rules. + +use std::sync::Arc; + +use botloft_core::ids::{ApprovalId, QuestionId}; +use botloft_core::protocol::{ + ApprovalStatus, ApprovalsAnswerParams, ChatBody, FromPhone, QuestionIdParams, QuestionStatus, + QuestionsAnswerParams, SEALED_MAX, ToPhone, +}; +use serde_json::json; +use tracing::{debug, warn}; + +use super::cards; +use super::seal::{self, Dir}; +use super::{Mobile, identity}; +use crate::approvals; +use crate::service::questions; +use crate::state::{Daemon, Event}; + +/// How much of a snapshot's cards go in one message: the rest follow as +/// cards of their own. +const SNAPSHOT_ROOM: usize = SEALED_MAX - 1024; + +impl Mobile { + /// Something happened in the owner's chat or question box: the phones + /// that are on hear of it. Phones that are not ask when they open. + pub(crate) fn on_event(&self, daemon: &Daemon, event: &Event) { + let message = match event { + Event::ChatItem(change) => match &change.item.body { + ChatBody::Approval(shown) if shown.status == ApprovalStatus::Pending => { + cards::approval_card(daemon, &change.item, shown) + .map(|card| ToPhone::ApprovalOpen { card }) + } + ChatBody::Approval(shown) => Some(ToPhone::ApprovalClosed { + approval_id: shown.approval_id.clone(), + status: shown.status, + }), + _ => None, + }, + Event::QuestionChanged(question) if question.status == QuestionStatus::Open => { + cards::question_card(daemon, question).map(|card| ToPhone::QuestionOpen { card }) + } + Event::QuestionChanged(question) => Some(ToPhone::QuestionClosed { + question_id: question.id.clone(), + status: question.status, + }), + _ => None, + }; + let Some(message) = message else { + return; + }; + for phone in self.online_phones(daemon) { + self.send_to(daemon, &phone.id, &message); + } + // The server may wake phones that are not on (spec 28.8). + if matches!( + message, + ToPhone::ApprovalOpen { .. } | ToPhone::QuestionOpen { .. } + ) { + self.send_frame(&json!({ "t": "wake" })); + } + } + + /// Sends `phone` what is waiting right now. + fn snapshot(&self, daemon: &Daemon, phone: &str) { + let (approvals, questions) = cards::waiting(daemon); + let mut room = SNAPSHOT_ROOM; + let mut fits = |size: usize| { + if size <= room { + room -= size; + true + } else { + false + } + }; + let (mut first_approvals, mut later_approvals) = (Vec::new(), Vec::new()); + for card in approvals { + let size = serde_json::to_vec(&card).map_or(usize::MAX, |bytes| bytes.len()); + if later_approvals.is_empty() && fits(size) { + first_approvals.push(card); + } else { + later_approvals.push(card); + } + } + let (mut first_questions, mut later_questions) = (Vec::new(), Vec::new()); + for card in questions { + let size = serde_json::to_vec(&card).map_or(usize::MAX, |bytes| bytes.len()); + if later_questions.is_empty() && fits(size) { + first_questions.push(card); + } else { + later_questions.push(card); + } + } + self.send_to( + daemon, + phone, + &ToPhone::Snapshot { + approvals: first_approvals, + questions: first_questions, + }, + ); + for card in later_approvals { + self.send_to(daemon, phone, &ToPhone::ApprovalOpen { card }); + } + for card in later_questions { + self.send_to(daemon, phone, &ToPhone::QuestionOpen { card }); + } + } + + /// A message from a phone came through the relay: opens it, and acts on + /// it if it is new. The relay is told it was taken either way, so what + /// does not open is not sent again. + pub(crate) async fn receive( + &self, + daemon: &Arc, + from: &str, + frame_seq: u64, + body: &str, + ) { + let ack = || self.send_frame(&json!({ "t": "ack", "from": from, "upto": frame_seq })); + let Ok((_, credentials)) = identity(daemon) else { + return; + }; + let known = self.phones.get(&credentials.url, &credentials.device, from); + let Some((phone, keys)) = known.and_then(|phone| phone.keys().map(|keys| (phone, keys))) + else { + ack(); + return; + }; + let Ok((seq, plain)) = seal::open(&keys.p2c, Dir::PhoneToComputer, from, body) else { + warn!("a message from a phone did not open and was dropped"); + ack(); + return; + }; + // The same message again (the relay repeats what it was not told + // about), or an old one sent back. + if seq != frame_seq || seq <= phone.received { + ack(); + return; + } + let now = daemon.clock.now_ms(); + let saved = self + .phones + .update(&credentials.url, &credentials.device, from, |phone| { + phone.received = seq; + phone.last_seen_at = Some(now); + }); + if !matches!(saved, Ok(true)) { + return; + } + ack(); + let Ok(message) = serde_json::from_slice::(&plain) else { + debug!("a phone sent something this version does not know"); + return; + }; + self.apply(daemon, from, message).await; + } + + async fn apply(&self, daemon: &Arc, phone: &str, message: FromPhone) { + match message { + FromPhone::Sync => self.snapshot(daemon, phone), + FromPhone::ApprovalAnswer { + approval_id, + allow, + note, + } => { + if self.may_answer(daemon, phone) { + self.answer_approval(daemon, phone, approval_id, allow, note) + .await; + } + } + FromPhone::QuestionAnswer { + question_id, + answer, + } => { + if self.may_answer(daemon, phone) { + let params = QuestionsAnswerParams { + question_id: question_id.clone(), + answer, + }; + let done = blocking(daemon, move |daemon| { + questions::answer(daemon, params).is_ok() + }) + .await; + if !done { + self.say_question_is_closed(daemon, phone, &question_id); + } + } + } + FromPhone::QuestionDismiss { question_id } => { + if self.may_answer(daemon, phone) { + let params = QuestionIdParams { + question_id: question_id.clone(), + }; + let done = blocking(daemon, move |daemon| { + questions::dismiss(daemon, params).is_ok() + }) + .await; + if !done { + self.say_question_is_closed(daemon, phone, &question_id); + } + } + } + } + } + + async fn answer_approval( + &self, + daemon: &Arc, + phone: &str, + id: ApprovalId, + allow: bool, + note: Option, + ) { + let Some((item, shown)) = cards::approval_item(daemon, &id) else { + return; + }; + if shown.status != ApprovalStatus::Pending { + self.send_to( + daemon, + phone, + &ToPhone::ApprovalClosed { + approval_id: id, + status: shown.status, + }, + ); + return; + } + // The phone allows only what it showed whole and plain (28.5). + if allow { + let card = cards::approval_card(daemon, &item, &shown); + if card + .as_ref() + .is_none_or(|card| card.cut || card.at_computer) + { + warn!("a phone tried to allow a request that is answered at the computer"); + if let Some(card) = card { + self.send_to(daemon, phone, &ToPhone::ApprovalOpen { card }); + } + return; + } + } + let params = ApprovalsAnswerParams { + approval_id: id.clone(), + allow, + note: note.map(|note| note.chars().take(500).collect()), + input: None, + always: None, + }; + let done = blocking(daemon, move |daemon| { + approvals::answer(daemon, params).is_ok() + }) + .await; + if !done { + let status = cards::approval_item(daemon, &id).map(|(_, shown)| shown.status); + if let Some(status) = status.filter(|status| *status != ApprovalStatus::Pending) { + self.send_to( + daemon, + phone, + &ToPhone::ApprovalClosed { + approval_id: id, + status, + }, + ); + } + } + } + + fn say_question_is_closed(&self, daemon: &Daemon, phone: &str, id: &QuestionId) { + let status = daemon + .store() + .question(id) + .ok() + .flatten() + .map(|record| record.question.status); + if let Some(status) = status.filter(|status| *status != QuestionStatus::Open) { + self.send_to( + daemon, + phone, + &ToPhone::QuestionClosed { + question_id: id.clone(), + status, + }, + ); + } + } +} + +/// Runs a service call where the store may be waited for. +async fn blocking( + daemon: &Arc, + work: impl FnOnce(&Daemon) -> bool + Send + 'static, +) -> bool { + let daemon = Arc::clone(daemon); + tokio::task::spawn_blocking(move || work(&daemon)) + .await + .unwrap_or(false) +} diff --git a/crates/botloftd/src/mobile/cards.rs b/crates/botloftd/src/mobile/cards.rs new file mode 100644 index 00000000..397476f4 --- /dev/null +++ b/crates/botloftd/src/mobile/cards.rs @@ -0,0 +1,207 @@ +//! What the phone is shown (spec 28.5): a card for each request and question +//! that waits, with only what the owner needs to decide. + +use botloft_core::chat::PLAN_TOOL; +use botloft_core::ids::ApprovalId; +use botloft_core::protocol::{ + ApprovalCard, ApprovalItem, ApprovalStatus, CARD_TEXT_MAX, CardBot, ChatBody, ChatItem, + Question, QuestionCard, QuestionStatus, +}; +use serde_json::Value; +use tracing::warn; + +use crate::state::Daemon; + +/// The tools the phone may allow: the ones Claude Code asks about in a +/// plain way, which change nothing that lasts. Everything else (a bot or +/// routine suggestion, reaching another crew, the desktop, the browser in +/// hand, connected tools) is answered at the computer, and the phone may +/// only deny it. +pub fn may_allow(tool: &str) -> bool { + matches!( + tool, + "Bash" + | "PowerShell" + | "WebFetch" + | "WebSearch" + | "Read" + | "Write" + | "Edit" + | "MultiEdit" + | "NotebookEdit" + ) || tool == PLAN_TOOL +} + +/// The text to read before allowing, and whether it is not all of the +/// request (cut by the daemon when saved, or too long for a phone). +fn text_of(tool: &str, input: &str) -> (String, bool) { + let parsed: Option = serde_json::from_str(input).ok(); + let field = |name: &str| { + parsed + .as_ref() + .and_then(|value| value.get(name)) + .and_then(Value::as_str) + .map(str::to_owned) + }; + let (text, whole) = match tool { + "Bash" | "PowerShell" => (field("command"), parsed.is_some()), + PLAN_TOOL => (field("plan"), parsed.is_some()), + _ => (None, parsed.is_some()), + }; + // A request the daemon cut short (it does not parse) is shown as it is + // and marked, and so is one the field of which is missing. + let text = text.unwrap_or_else(|| input.to_owned()); + if text.len() <= CARD_TEXT_MAX { + return (text, !whole); + } + let mut end = CARD_TEXT_MAX; + while !text.is_char_boundary(end) { + end -= 1; + } + (text[..end].to_owned(), true) +} + +fn bot_and_crew(daemon: &Daemon, bot: &botloft_core::ids::BotId) -> Option<(CardBot, String)> { + let store = daemon.store(); + let record = match store.bot(bot) { + Ok(Some(record)) if record.archived_at.is_none() => record, + Ok(_) => return None, + Err(err) => { + warn!(bot = %bot, "could not read a bot for a card: {err}"); + return None; + } + }; + let crew = store.crew(&record.crew_id).ok().flatten()?; + Some(( + CardBot { + name: record.name, + color: record.color, + }, + crew.name, + )) +} + +/// The card of a request in the chat; `None` for a bot that is gone. +pub fn approval_card( + daemon: &Daemon, + item: &ChatItem, + shown: &ApprovalItem, +) -> Option { + let (bot, crew) = bot_and_crew(daemon, &item.bot_id)?; + let (text, cut) = text_of(&shown.tool_name, &shown.input); + Some(ApprovalCard { + approval_id: shown.approval_id.clone(), + bot, + crew, + created_at: item.created_at, + tool_name: shown.tool_name.clone(), + summary: shown.summary.clone(), + explanation: shown.explanation.clone(), + text, + cut, + at_computer: !may_allow(&shown.tool_name), + }) +} + +pub fn question_card(daemon: &Daemon, question: &Question) -> Option { + let (bot, crew) = bot_and_crew(daemon, &question.bot_id)?; + Some(QuestionCard { + question_id: question.id.clone(), + bot, + crew, + created_at: question.created_at, + text: question.text.clone(), + options: question.options.clone(), + }) +} + +/// The request in the chat, whatever its state: `(item, what it shows)`. +pub fn approval_item(daemon: &Daemon, id: &ApprovalId) -> Option<(ChatItem, ApprovalItem)> { + let store = daemon.store(); + let record = store.approval(id).ok().flatten()?; + let item = store.chat_item(&record.chat_item_id).ok().flatten()?; + match item.body.clone() { + ChatBody::Approval(shown) => Some((item, shown)), + _ => None, + } +} + +/// Every request and question waiting right now. +pub fn waiting(daemon: &Daemon) -> (Vec, Vec) { + let mut approvals = Vec::new(); + let pending: Vec = { + let store = daemon.store(); + let bots = store.bots(None, false).unwrap_or_default(); + bots.iter() + .flat_map(|bot| store.pending_approvals(&bot.id).unwrap_or_default()) + .collect() + }; + for id in pending { + if let Some((item, shown)) = approval_item(daemon, &id) + && shown.status == ApprovalStatus::Pending + && let Some(card) = approval_card(daemon, &item, &shown) + { + approvals.push(card); + } + } + approvals.sort_by_key(|card| card.created_at); + let open = daemon + .store() + .questions(QuestionStatus::Open) + .unwrap_or_default(); + let mut questions: Vec = open + .iter() + .filter_map(|question| question_card(daemon, question)) + .collect(); + questions.sort_by_key(|card| card.created_at); + (approvals, questions) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn the_phone_allows_plain_tools_and_leaves_the_rest_to_the_computer() { + for tool in ["Bash", "PowerShell", "WebFetch", "Read", "Edit", PLAN_TOOL] { + assert!(may_allow(tool), "{tool}"); + } + for tool in [ + "mcp__botloft__suggest_bot", + "mcp__botloft__ask_crew_access", + "mcp__botloft__desktop", + "mcp__botloft__browser", + "mcp__github__create_issue", + "Agent", + "", + ] { + assert!(!may_allow(tool), "{tool}"); + } + } + + #[test] + fn a_command_is_shown_whole_and_a_cut_or_long_one_is_marked() { + let input = json!({ "command": "ls -la", "description": "list" }).to_string(); + assert_eq!(text_of("Bash", &input), ("ls -la".to_owned(), false)); + let plan = json!({ "plan": "# Plan\n1. one" }).to_string(); + assert_eq!( + text_of(PLAN_TOOL, &plan), + ("# Plan\n1. one".to_owned(), false) + ); + // Another tool shows its input as it is. + let read = json!({ "file_path": "a.txt" }).to_string(); + assert_eq!(text_of("Read", &read), (read.clone(), false)); + + // What the daemon cut when it saved the request does not parse. + let cut = format!("{{\"command\": \"{}…", "x".repeat(50)); + let (text, marked) = text_of("Bash", &cut); + assert_eq!(text, cut); + assert!(marked); + + // A command over the phone's limit is cut on a character boundary. + let long = json!({ "command": "é".repeat(CARD_TEXT_MAX) }).to_string(); + let (text, marked) = text_of("Bash", &long); + assert!(marked && text.len() <= CARD_TEXT_MAX && text.chars().all(|c| c == 'é')); + } +} diff --git a/crates/botloftd/src/mobile/kdf.rs b/crates/botloftd/src/mobile/kdf.rs new file mode 100644 index 00000000..5e32b2f7 --- /dev/null +++ b/crates/botloftd/src/mobile/kdf.rs @@ -0,0 +1,107 @@ +//! HMAC-SHA-256 and HKDF-SHA-256 (RFC 2104, RFC 5869) over the `sha2` the +//! daemon already uses. The phone has both in its browser (WebCrypto), so the +//! two sides get the same bytes (spec 28.3). + +use sha2::{Digest, Sha256}; + +const BLOCK: usize = 64; + +/// `HMAC(key, parts[0] ‖ parts[1] ‖ ...)`. +pub fn hmac(key: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut padded = [0u8; BLOCK]; + if key.len() > BLOCK { + padded[..32].copy_from_slice(&Sha256::digest(key)); + } else { + padded[..key.len()].copy_from_slice(key); + } + let mut inner = Sha256::new(); + inner.update(padded.map(|byte| byte ^ 0x36)); + for part in parts { + inner.update(part); + } + let inner = inner.finalize(); + let mut outer = Sha256::new(); + outer.update(padded.map(|byte| byte ^ 0x5c)); + outer.update(inner); + outer.finalize().into() +} + +/// `len` bytes (at most 255 × 32) from `ikm`, with the extract-then-expand of +/// HKDF. An empty `salt` is 32 zero bytes, as the RFC says. +pub fn hkdf(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec { + let zeros = [0u8; 32]; + let salt = if salt.is_empty() { &zeros[..] } else { salt }; + let prk = hmac(salt, &[ikm]); + let mut okm = Vec::with_capacity(len); + let mut block: Vec = Vec::new(); + let mut counter = 1u8; + while okm.len() < len { + block = hmac(&prk, &[&block, info, &[counter]]).to_vec(); + okm.extend_from_slice(&block); + counter += 1; + } + okm.truncate(len); + okm +} + +/// Whether two byte strings are equal, without stopping at the first +/// difference. +pub fn same(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unhex(text: &str) -> Vec { + hex::decode(text).expect("hex") + } + + #[test] + fn hmac_matches_rfc_4231() { + // Test case 1, and case 6 (a key longer than a block). + let key = [0x0b; 20]; + assert_eq!( + hex::encode(hmac(&key, &[b"Hi There"])), + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" + ); + let long = [0xaa; 131]; + assert_eq!( + hex::encode(hmac( + &long, + &[b"Test Using Larger Than Block-Size Key - Hash Key First"] + )), + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" + ); + // Parts are just one message cut in pieces. + assert_eq!(hmac(&key, &[b"Hi ", b"There"]), hmac(&key, &[b"Hi There"])); + } + + #[test] + fn hkdf_matches_rfc_5869() { + let okm = hkdf( + &[0x0b; 22], + &unhex("000102030405060708090a0b0c"), + &unhex("f0f1f2f3f4f5f6f7f8f9"), + 42, + ); + assert_eq!( + hex::encode(okm), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + ); + // Case 3: no salt and no info. + let okm = hkdf(&[0x0b; 22], &[], &[], 42); + assert_eq!( + hex::encode(okm), + "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8" + ); + } + + #[test] + fn same_tells_equal_from_different() { + assert!(same(b"abc", b"abc")); + assert!(!same(b"abc", b"abd")); + assert!(!same(b"abc", b"ab")); + } +} diff --git a/crates/botloftd/src/mobile/mod.rs b/crates/botloftd/src/mobile/mod.rs new file mode 100644 index 00000000..c795dbfd --- /dev/null +++ b/crates/botloftd/src/mobile/mod.rs @@ -0,0 +1,300 @@ +//! The phone (spec 28): connecting one, and the relay that carries sealed +//! approvals and questions to it. Nothing opens until a phone is connected +//! or being connected, and nothing of what passes goes in the log. + +mod bridge; +mod cards; +mod kdf; +mod pair; +mod phones; +mod relay; +pub mod seal; + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +use botloft_core::protocol::{ + MobileJoined, MobilePairing, MobilePhone, MobileRelay, MobileStatus, SEALED_MAX, ToPhone, +}; +use tokio::sync::{Notify, mpsc}; +use tracing::warn; + +pub use self::pair::Confirm; +use self::phones::{Phone, Phones}; +pub use self::relay::run; +use self::seal::{Dir, Keypair, Keys}; +use crate::cloud::{CloudError, Credentials, Server}; +use crate::state::{Daemon, Event}; + +/// How soon the relay tries again after losing the server, and how long it +/// waits at most between tries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Retry { + pub min: Duration, + pub max: Duration, +} + +impl Default for Retry { + fn default() -> Self { + Self { + min: Duration::from_secs(1), + max: Duration::from_secs(60), + } + } +} + +/// A QR code on screen, with the secret only the phone that scans it shares. +pub(crate) struct Pairing { + pub(crate) id: String, + pub(crate) secret: [u8; 32], + pub(crate) keys: Keypair, + pub(crate) expires_at: i64, + pub(crate) joined: Option, +} + +/// A phone that opened the code and waits for the owner. +pub(crate) struct Joined { + pub(crate) name: String, + pub(crate) phone_pub: Vec, + pub(crate) code: String, + /// The owner accepted: the keys, until the phone collects its token and + /// the server says which device it is. + pub(crate) accepted: Option, +} + +#[derive(Default)] +struct State { + relay: Option, + online: HashSet, + pairing: Option, + /// When each phone answered lately, for the limit. + answers: HashMap>, + /// The connection's way out, while there is one. + live: Option>, +} + +pub struct Mobile { + phones: Phones, + state: Mutex, + wake: Notify, + retry: Retry, +} + +/// The server and the token of this sign-in. +pub(crate) fn identity(daemon: &Daemon) -> Result<(Server, Credentials), CloudError> { + let server = daemon.cloud.server()?; + let credentials = daemon + .cloud + .credentials(&daemon.paths.secrets()) + .ok_or_else(CloudError::not_signed_in)?; + Ok((server, credentials)) +} + +impl Mobile { + pub fn new(secrets_dir: &Path, retry: Retry) -> Self { + Self { + phones: Phones::open(secrets_dir), + state: Mutex::new(State::default()), + wake: Notify::new(), + retry, + } + } + + fn lock(&self) -> MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Asks the relay to look at what is wanted again. + pub(crate) fn wake(&self) { + self.wake.notify_one(); + } + + /// The phones of this sign-in; none when signed out. + pub(crate) fn phones(&self, daemon: &Daemon) -> Vec { + match daemon.cloud.credentials(&daemon.paths.secrets()) { + Some(credentials) => self.phones.list(&credentials.url, &credentials.device), + None => Vec::new(), + } + } + + /// Whether the relay should be open: signed in, and a phone connected or + /// being connected. + pub(crate) fn wanted(&self, daemon: &Daemon) -> bool { + daemon.cloud.credentials(&daemon.paths.secrets()).is_some() + && (!self.phones(daemon).is_empty() || self.lock().pairing.is_some()) + } + + pub fn status(&self, daemon: &Daemon) -> MobileStatus { + let now = daemon.clock.now_ms(); + let mut state = self.lock(); + if state + .pairing + .as_ref() + .is_some_and(|pairing| pairing.expires_at <= now) + { + state.pairing = None; + } + let phones = self + .phones(daemon) + .into_iter() + .map(|phone| MobilePhone { + online: state.online.contains(&phone.id), + id: phone.id, + name: phone.name, + paired_at: phone.paired_at, + last_seen_at: phone.last_seen_at, + }) + .collect(); + let pending = state.pairing.as_ref().map(|pairing| MobilePairing { + pair_id: pairing.id.clone(), + expires_at: pairing.expires_at, + joined: pairing.joined.as_ref().map(|joined| MobileJoined { + name: joined.name.clone(), + code: joined.code.clone(), + }), + }); + MobileStatus { + relay: state.relay.unwrap_or(MobileRelay::Off), + phones, + pending, + } + } + + /// Tells the apps how things stand now. + pub(crate) fn changed(&self, daemon: &Daemon) { + daemon.emit(Event::MobileChanged(self.status(daemon))); + } + + pub(crate) fn set_relay(&self, daemon: &Daemon, relay: MobileRelay) { + let changed = { + let mut state = self.lock(); + let changed = state.relay.unwrap_or(MobileRelay::Off) != relay; + state.relay = Some(relay); + if relay != MobileRelay::Connected { + state.live = None; + state.online.clear(); + } + changed + }; + if changed { + self.changed(daemon); + } + } + + pub(crate) fn set_live(&self, live: Option>) { + self.lock().live = live; + } + + /// Hands a frame to the relay connection, if there is one. + pub(crate) fn send_frame(&self, frame: &serde_json::Value) -> bool { + self.lock() + .live + .as_ref() + .is_some_and(|live| live.send(frame.to_string()).is_ok()) + } + + pub(crate) fn set_online(&self, online: HashSet) { + self.lock().online = online; + } + + /// A phone came or went. False if it is not one of ours. + pub(crate) fn presence(&self, daemon: &Daemon, device: &str, online: bool) -> bool { + if !self.phones(daemon).iter().any(|phone| phone.id == device) { + return false; + } + { + let mut state = self.lock(); + if online { + state.online.insert(device.to_owned()); + } else { + state.online.remove(device); + } + } + self.changed(daemon); + true + } + + pub(crate) fn online_phones(&self, daemon: &Daemon) -> Vec { + let state = self.lock(); + self.phones(daemon) + .into_iter() + .filter(|phone| state.online.contains(&phone.id)) + .collect() + } + + /// Seals `message` for the phone and hands it to the relay. The counter + /// is saved before the message goes. Nothing happens if the relay is not + /// connected: the phone asks for what it missed when it opens. + pub(crate) fn send_to(&self, daemon: &Daemon, phone: &str, message: &ToPhone) { + let Some((_, credentials)) = identity(daemon).ok() else { + return; + }; + let Some(known) = self + .phones + .get(&credentials.url, &credentials.device, phone) + else { + return; + }; + let Some(keys) = known.keys() else { + return; + }; + let Ok(plain) = serde_json::to_vec(message) else { + return; + }; + if plain.len() > SEALED_MAX { + warn!("a message for a phone is too big and was not sent"); + return; + } + let seq = known.sent + 1; + let saved = self + .phones + .update(&credentials.url, &credentials.device, phone, |phone| { + phone.sent = seq; + }); + if !matches!(saved, Ok(true)) { + warn!("could not save a phone's counter; nothing was sent"); + return; + } + match seal::seal(&keys.c2p, Dir::ComputerToPhone, phone, seq, &plain) { + Ok(body) => { + self.send_frame(&serde_json::json!({ + "t": "msg", "to": phone, "seq": seq, "body": body, + })); + } + Err(_) => warn!("a message for a phone did not seal"), + } + } + + /// The computer left the account: every phone goes with it. + pub fn forget(&self, daemon: &Daemon) { + self.phones.clear(); + { + let mut state = self.lock(); + state.pairing = None; + state.online.clear(); + state.answers.clear(); + } + self.wake(); + self.changed(daemon); + } + + /// Whether the phone may answer now: at most 30 a minute. + pub(crate) fn may_answer(&self, daemon: &Daemon, phone: &str) -> bool { + const PER_MINUTE: usize = 30; + let now = daemon.clock.now_ms(); + let mut state = self.lock(); + let recent = state.answers.entry(phone.to_owned()).or_default(); + while recent.front().is_some_and(|at| *at <= now - 60_000) { + recent.pop_front(); + } + if recent.len() >= PER_MINUTE { + return false; + } + recent.push_back(now); + true + } +} diff --git a/crates/botloftd/src/mobile/pair.rs b/crates/botloftd/src/mobile/pair.rs new file mode 100644 index 00000000..4d78d338 --- /dev/null +++ b/crates/botloftd/src/mobile/pair.rs @@ -0,0 +1,301 @@ +//! Connecting a phone (spec 28.3): the QR code, the phone that joins, the +//! owner who compares the code, and the phone that collects its token. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use botloft_core::protocol::{MobilePairRequest, MobilePairStarted}; +use tracing::warn; + +use super::kdf::same; +use super::phones::Phone; +use super::seal::{self, Keypair}; +use super::{Joined, Mobile, Pairing, identity}; +use crate::cloud::{CloudError, Seen}; +use crate::state::{Daemon, Event}; + +/// Why a confirmation could not be taken. +#[derive(Debug, PartialEq, Eq)] +pub enum Confirm { + /// There is no such code, or no phone has joined it. + NothingToConfirm, + Cloud(CloudError), +} + +impl From for Confirm { + fn from(err: CloudError) -> Self { + Self::Cloud(err) + } +} + +fn random() -> Result<[u8; N], CloudError> { + let mut bytes = [0u8; N]; + getrandom::fill(&mut bytes) + .map_err(|_| CloudError::other("the system has no random bytes".to_owned()))?; + Ok(bytes) +} + +impl Mobile { + /// `mobile.pair_start`: a code for a phone to scan. Another code on + /// screen is dropped. + pub async fn pair_start(&self, daemon: &Daemon) -> Result { + let (server, credentials) = identity(daemon)?; + let id = B64.encode(random::<16>()?); + let secret = random::<32>()?; + let keys = Keypair::generate() + .map_err(|_| CloudError::other("the system has no random bytes".to_owned()))?; + let daemon_pub = B64.encode(keys.public()); + let old = self.lock().pairing.take(); + if let Some(old) = old { + let _ = server.cancel_pairing(&credentials.token, &old.id).await; + } + let expires_in = server.open_pairing(&credentials.token, &id).await?; + let expires_at = daemon.clock.now_ms() + i64::from(expires_in) * 1000; + // The part after `#` never reaches the server. The computer's public + // key is there so the phone can show the code before it is accepted. + let url = format!( + "{}#p={id}&s={}&d={}", + server.phone_page(), + B64.encode(secret), + daemon_pub + ); + self.lock().pairing = Some(Pairing { + id: id.clone(), + secret, + keys, + expires_at, + joined: None, + }); + self.wake(); + self.changed(daemon); + Ok(MobilePairStarted { + pair_id: id, + url, + expires_in, + }) + } + + /// `mobile.pair_cancel`. + pub async fn pair_cancel(&self, daemon: &Daemon, id: &str) -> Result<(), CloudError> { + let ours = { + let mut state = self.lock(); + if state + .pairing + .as_ref() + .is_some_and(|pairing| pairing.id == id) + { + state.pairing = None; + true + } else { + false + } + }; + if ours { + if let Ok((server, credentials)) = identity(daemon) { + let _ = server.cancel_pairing(&credentials.token, id).await; + } + self.wake(); + self.changed(daemon); + } + Ok(()) + } + + /// Looks whether a phone opened the code, and if its proof is right, + /// shows the owner its name and the code to compare. + pub(crate) async fn check_joined(&self, daemon: &Daemon) { + let Some((id, secret, daemon_pub)) = ({ + let state = self.lock(); + state + .pairing + .as_ref() + .filter(|pairing| pairing.joined.is_none()) + .map(|pairing| { + ( + pairing.id.clone(), + pairing.secret, + pairing.keys.public().to_vec(), + ) + }) + }) else { + return; + }; + let Ok((server, credentials)) = identity(daemon) else { + return; + }; + let seen = server.pairing(&credentials.token, &id).await; + let (name, phone_pub, proof) = match seen { + Ok(Seen::Joined { + name, + phone_pub, + proof, + }) => (name, phone_pub, proof), + Ok(Seen::Gone) => { + let mut state = self.lock(); + if state.pairing.as_ref().is_some_and(|p| p.id == id) { + state.pairing = None; + drop(state); + self.changed(daemon); + } + return; + } + Ok(Seen::Waiting) | Err(_) => return, + }; + let phone_pub = B64.decode(&phone_pub).unwrap_or_default(); + let proof = B64.decode(&proof).unwrap_or_default(); + let right = same(&seal::join_proof(&secret, &id, &phone_pub), &proof); + if !right || phone_pub.len() != 65 { + warn!("a phone joined the code with a wrong proof and was refused"); + let _ = server.cancel_pairing(&credentials.token, &id).await; + self.lock().pairing = None; + self.changed(daemon); + return; + } + let code = seal::code(&secret, &daemon_pub, &phone_pub); + { + let mut state = self.lock(); + let Some(pairing) = state.pairing.as_mut().filter(|pairing| pairing.id == id) else { + return; + }; + pairing.joined = Some(Joined { + name: name.clone(), + phone_pub, + code: code.clone(), + accepted: None, + }); + } + self.changed(daemon); + daemon.emit(Event::MobilePairRequest(MobilePairRequest { + pair_id: id, + name, + code, + })); + } + + /// `mobile.pair_confirm`: the owner compared the codes. + pub async fn pair_confirm( + &self, + daemon: &Daemon, + id: &str, + accept: bool, + ) -> Result<(), Confirm> { + let (server, credentials) = identity(daemon)?; + let work = { + let state = self.lock(); + state + .pairing + .as_ref() + .filter(|pairing| pairing.id == id) + .and_then(|pairing| { + let joined = pairing.joined.as_ref().filter(|j| j.accepted.is_none())?; + Some(( + pairing.secret, + pairing.keys.public().to_vec(), + joined.phone_pub.clone(), + seal::derive(&pairing.keys, &joined.phone_pub, &pairing.secret).ok()?, + )) + }) + }; + let Some((secret, daemon_pub, phone_pub, keys)) = work else { + return Err(Confirm::NothingToConfirm); + }; + if !accept { + server.cancel_pairing(&credentials.token, id).await?; + self.lock().pairing = None; + self.wake(); + self.changed(daemon); + return Ok(()); + } + let proof = seal::accept_proof(&secret, id, &daemon_pub, &phone_pub); + server + .accept_pairing( + &credentials.token, + id, + &B64.encode(&daemon_pub), + &B64.encode(proof), + ) + .await?; + if let Some(joined) = self + .lock() + .pairing + .as_mut() + .and_then(|pairing| pairing.joined.as_mut()) + { + joined.accepted = Some(keys); + } + Ok(()) + } + + /// The server says the phone collected its token and which device it is: + /// from now on the keys are kept for that device. + pub(crate) fn paired(&self, daemon: &Daemon, pairing_id: &str, device: &str) { + let Ok((_, credentials)) = identity(daemon) else { + return; + }; + let taken = { + let mut state = self.lock(); + let ours = state + .pairing + .as_ref() + .is_some_and(|pairing| pairing.id == pairing_id); + if ours { state.pairing.take() } else { None } + }; + let Some(Pairing { + joined: + Some(Joined { + name, + accepted: Some(keys), + .. + }), + .. + }) = taken + else { + return; + }; + let phone = Phone::new(device.to_owned(), name, daemon.clock.now_ms(), &keys); + if let Err(err) = self + .phones + .add(&credentials.url, &credentials.device, phone) + { + warn!("could not keep the new phone's keys: {err}"); + return; + } + self.changed(daemon); + } + + /// Whether a code was accepted and waits for its phone to collect the + /// token: a device the server lists then is not a stranger. + pub(crate) fn awaiting_phone(&self) -> bool { + self.lock() + .pairing + .as_ref() + .and_then(|pairing| pairing.joined.as_ref()) + .is_some_and(|joined| joined.accepted.is_some()) + } + + /// `mobile.revoke`: the server is told if it can be; the keys go either + /// way, and a phone the server still lists is taken off at the next + /// connection. + pub async fn revoke(&self, daemon: &Daemon, phone: &str) -> Result { + let (server, credentials) = identity(daemon)?; + let removed = self + .phones + .remove(&credentials.url, &credentials.device, phone) + .unwrap_or(false); + let _ = server.remove_device(&credentials.token, phone).await; + self.lock().online.remove(phone); + self.wake(); + self.changed(daemon); + Ok(removed) + } + + /// The phone left from its side: the server tells us, and the keys go. + pub(crate) fn revoked(&self, daemon: &Daemon, phone: &str) { + if let Ok((_, credentials)) = identity(daemon) { + let _ = self + .phones + .remove(&credentials.url, &credentials.device, phone); + } + self.lock().online.remove(phone); + self.wake(); + self.changed(daemon); + } +} diff --git a/crates/botloftd/src/mobile/phones.rs b/crates/botloftd/src/mobile/phones.rs new file mode 100644 index 00000000..2558032f --- /dev/null +++ b/crates/botloftd/src/mobile/phones.rs @@ -0,0 +1,241 @@ +//! The phones this computer connected, and the keys it shares with each: in +//! `secrets\mobile.json`, readable only by the current user (spec 28.5, 13). +//! Nothing here goes in the log, and nothing of it is in the backup. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use serde::{Deserialize, Serialize}; + +use super::seal::Keys; +use crate::platform; + +/// One connected phone. +#[derive(Clone, Serialize, Deserialize)] +pub struct Phone { + /// The id the server gave the phone's device. + pub id: String, + pub name: String, + pub paired_at: i64, + pub last_seen_at: Option, + c2p: String, + p2c: String, + /// The last number used to seal a message to the phone. Saved before the + /// message goes, so a crash never reuses a number under the same key. + pub sent: u64, + /// The highest number taken from the phone; the next must be greater. + pub received: u64, +} + +impl Phone { + pub fn new(id: String, name: String, paired_at: i64, keys: &Keys) -> Self { + Self { + id, + name, + paired_at, + last_seen_at: None, + c2p: B64.encode(keys.c2p), + p2c: B64.encode(keys.p2c), + sent: 0, + received: 0, + } + } + + pub fn keys(&self) -> Option { + let decode = |text: &str| -> Option<[u8; 32]> { B64.decode(text).ok()?.try_into().ok() }; + Some(Keys { + c2p: decode(&self.c2p)?, + p2c: decode(&self.p2c)?, + }) + } +} + +/// What the file holds: the phones, and whose they are. Another server or +/// another sign-in is another account, and these phones do not carry over. +#[derive(Default, Serialize, Deserialize)] +struct File { + url: String, + computer: String, + phones: Vec, +} + +pub struct Phones { + path: PathBuf, + file: Mutex, +} + +impl Phones { + pub fn open(secrets_dir: &Path) -> Self { + let path = secrets_dir.join("mobile.json"); + let file = std::fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + Self { + path, + file: Mutex::new(file), + } + } + + fn lock(&self) -> MutexGuard<'_, File> { + self.file + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// The phones that belong to this sign-in (`url` and the computer's + /// device id); none if the file is of another. + pub fn list(&self, url: &str, computer: &str) -> Vec { + let file = self.lock(); + if file.url == url && file.computer == computer { + file.phones.clone() + } else { + Vec::new() + } + } + + pub fn get(&self, url: &str, computer: &str, id: &str) -> Option { + self.list(url, computer) + .into_iter() + .find(|phone| phone.id == id) + } + + /// Changes one phone and saves. False when there is no such phone. + pub fn update( + &self, + url: &str, + computer: &str, + id: &str, + change: impl FnOnce(&mut Phone), + ) -> io::Result { + let mut file = self.lock(); + if file.url != url || file.computer != computer { + return Ok(false); + } + let Some(phone) = file.phones.iter_mut().find(|phone| phone.id == id) else { + return Ok(false); + }; + change(phone); + self.save(&file)?; + Ok(true) + } + + /// Adds a phone, replacing one with the same id. A file of another + /// sign-in is started over. + pub fn add(&self, url: &str, computer: &str, phone: Phone) -> io::Result<()> { + let mut file = self.lock(); + if file.url != url || file.computer != computer { + *file = File { + url: url.to_owned(), + computer: computer.to_owned(), + phones: Vec::new(), + }; + } + file.phones.retain(|other| other.id != phone.id); + file.phones.push(phone); + self.save(&file) + } + + /// Forgets a phone and its keys. False when there was none. + pub fn remove(&self, url: &str, computer: &str, id: &str) -> io::Result { + let mut file = self.lock(); + if file.url != url || file.computer != computer { + return Ok(false); + } + let before = file.phones.len(); + file.phones.retain(|phone| phone.id != id); + let removed = file.phones.len() != before; + if removed { + self.save(&file)?; + } + Ok(removed) + } + + /// Forgets every phone: the computer left the account. + pub fn clear(&self) { + let mut file = self.lock(); + *file = File::default(); + let _ = std::fs::remove_file(&self.path); + } + + fn save(&self, file: &File) -> io::Result<()> { + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir)?; + platform::restrict_to_current_user(dir)?; + } + let tmp = self.path.with_extension("tmp"); + std::fs::write(&tmp, serde_json::to_vec(file).map_err(io::Error::other)?)?; + platform::restrict_to_current_user(&tmp)?; + std::fs::rename(&tmp, &self.path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys() -> Keys { + Keys { + c2p: [1; 32], + p2c: [2; 32], + } + } + + #[test] + fn phones_are_kept_per_sign_in_and_survive_a_restart() { + let dir = tempfile::tempdir().expect("dir"); + let phones = Phones::open(dir.path()); + assert!(phones.list("https://a", "dev_1").is_empty()); + phones + .add( + "https://a", + "dev_1", + Phone::new("dev_p".into(), "Celular".into(), 5, &keys()), + ) + .expect("add"); + assert_eq!(phones.list("https://a", "dev_1").len(), 1); + // Another server, or another sign-in of this computer, has none. + assert!(phones.list("https://b", "dev_1").is_empty()); + assert!(phones.list("https://a", "dev_2").is_empty()); + + let changed = phones + .update("https://a", "dev_1", "dev_p", |phone| { + phone.sent = 9; + phone.received = 4; + }) + .expect("update"); + assert!(changed); + let again = Phones::open(dir.path()); + let phone = again.get("https://a", "dev_1", "dev_p").expect("kept"); + assert_eq!( + (phone.sent, phone.received, phone.name.as_str()), + (9, 4, "Celular") + ); + assert!(phone.keys().expect("keys") == keys()); + + assert!(again.remove("https://a", "dev_1", "dev_p").expect("remove")); + assert!(!again.remove("https://a", "dev_1", "dev_p").expect("again")); + assert!( + Phones::open(dir.path()) + .list("https://a", "dev_1") + .is_empty() + ); + } + + #[test] + fn a_broken_file_reads_as_no_phones_and_clear_removes_the_file() { + let dir = tempfile::tempdir().expect("dir"); + std::fs::write(dir.path().join("mobile.json"), "{not json").expect("write"); + let phones = Phones::open(dir.path()); + assert!(phones.list("u", "c").is_empty()); + phones + .add("u", "c", Phone::new("p".into(), "x".into(), 1, &keys())) + .expect("add"); + phones.clear(); + assert!(!dir.path().join("mobile.json").exists()); + assert!(phones.list("u", "c").is_empty()); + } +} diff --git a/crates/botloftd/src/mobile/relay.rs b/crates/botloftd/src/mobile/relay.rs new file mode 100644 index 00000000..c95c4964 --- /dev/null +++ b/crates/botloftd/src/mobile/relay.rs @@ -0,0 +1,195 @@ +//! The computer's side of the relay (spec 28.4): one WebSocket to the server +//! while a phone is connected or being connected, back again when it drops. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use botloft_core::protocol::MobileRelay; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::mpsc; +use tokio::time::{MissedTickBehavior, interval, timeout}; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; + +use super::identity; +use crate::state::Daemon; + +/// How long the server has to answer the hello. +const READY_WAIT: Duration = Duration::from_secs(10); + +/// Keeps the relay open for as long as it is wanted. Started once, with the +/// daemon. +pub async fn run(daemon: Arc) { + let mobile = &daemon.mobile; + let mut delay = mobile.retry.min; + loop { + if !mobile.wanted(&daemon) { + mobile.set_relay(&daemon, MobileRelay::Off); + mobile.wake.notified().await; + delay = mobile.retry.min; + continue; + } + mobile.set_relay(&daemon, MobileRelay::Connecting); + if session(&daemon).await { + delay = mobile.retry.min; + } + mobile.set_live(None); + if mobile.wanted(&daemon) { + mobile.set_relay(&daemon, MobileRelay::Connecting); + tokio::select! { + () = tokio::time::sleep(delay) => {} + () = mobile.wake.notified() => {} + } + delay = (delay * 2).min(mobile.retry.max); + } + } +} + +/// One connection, until it ends. True if the server accepted it. +async fn session(daemon: &Arc) -> bool { + let mobile = &daemon.mobile; + let Ok((server, credentials)) = identity(daemon) else { + return false; + }; + let Ok((socket, _)) = tokio_tungstenite::connect_async(server.relay_url()).await else { + return false; + }; + let (mut sink, mut stream) = socket.split(); + let hello = json!({ "t": "hello", "token": credentials.token }).to_string(); + if sink.send(Message::Text(hello.into())).await.is_err() { + return false; + } + let ready = timeout(READY_WAIT, async { + while let Some(Ok(message)) = stream.next().await { + if let Message::Text(text) = message { + return serde_json::from_str::(&text).ok(); + } + } + None + }) + .await + .ok() + .flatten(); + let Some(ready) = ready.filter(|ready| ready["t"] == "ready" && ready["kind"] == "computer") + else { + return false; + }; + + let (out, mut outgoing) = mpsc::unbounded_channel::(); + let listed: Vec<(String, bool)> = ready["phones"] + .as_array() + .map(|phones| { + phones + .iter() + .filter_map(|phone| { + Some((phone["id"].as_str()?.to_owned(), phone["online"] == true)) + }) + .collect() + }) + .unwrap_or_default(); + let ours: HashSet = mobile.phones(daemon).into_iter().map(|p| p.id).collect(); + mobile.set_online( + listed + .iter() + .filter(|(id, online)| *online && ours.contains(id)) + .map(|(id, _)| id.clone()) + .collect(), + ); + mobile.set_live(Some(out)); + mobile.set_relay(daemon, MobileRelay::Connected); + + // A phone the server lists and this computer has no keys for was taken + // off here while the server was out of reach: it goes off there too. (Not + // while one is being accepted: its device is new, and the keys wait.) + if !mobile.awaiting_phone() { + let strangers: Vec = listed + .into_iter() + .map(|(id, _)| id) + .filter(|id| !ours.contains(id)) + .collect(); + for id in strangers { + let (server, token) = ( + identity(daemon).map(|(server, _)| server), + credentials.token.clone(), + ); + tokio::spawn(async move { + if let Ok(server) = server { + let _ = server.remove_device(&token, &id).await; + } + }); + } + } + + let mut events = daemon.subscribe(); + let mut tick = interval(daemon.cloud.settings.poll); + tick.set_missed_tick_behavior(MissedTickBehavior::Delay); + loop { + tokio::select! { + frame = stream.next() => match frame { + Some(Ok(Message::Text(text))) => handle(daemon, &text).await, + Some(Ok(Message::Close(_)) | Err(_)) | None => break, + Some(Ok(_)) => {} + }, + Some(text) = outgoing.recv() => { + if sink.send(Message::Text(text.into())).await.is_err() { + break; + } + } + event = events.recv() => match event { + Ok(event) => mobile.on_event(daemon, &event), + Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => break, + }, + _ = tick.tick() => mobile.check_joined(daemon).await, + () = mobile.wake.notified() => { + if !mobile.wanted(daemon) { + let _ = sink.send(Message::Close(None)).await; + break; + } + } + } + } + true +} + +/// A frame from the server. +async fn handle(daemon: &Arc, text: &str) { + let mobile = &daemon.mobile; + let Ok(frame) = serde_json::from_str::(text) else { + return; + }; + let field = |name: &str| frame[name].as_str(); + match field("t") { + Some("msg") => { + if let (Some(from), Some(seq), Some(body)) = + (field("from"), frame["seq"].as_u64(), field("body")) + { + mobile.receive(daemon, from, seq, body).await; + } + } + Some("presence") => { + if let Some(device) = field("device") { + mobile.presence(daemon, device, frame["online"] == true); + } + } + Some("pairing") => mobile.check_joined(daemon).await, + Some("paired") => { + if let (Some(pairing), Some(device)) = (field("pairing"), field("device")) { + mobile.paired(daemon, pairing, device); + } + } + Some("revoked") => { + if let Some(device) = field("device") { + mobile.revoked(daemon, device); + } + } + Some("error") => debug!( + reason = field("reason").unwrap_or_default(), + "the relay refused a frame" + ), + _ => {} + } +} diff --git a/crates/botloftd/src/mobile/seal.rs b/crates/botloftd/src/mobile/seal.rs new file mode 100644 index 00000000..c7250474 --- /dev/null +++ b/crates/botloftd/src/mobile/seal.rs @@ -0,0 +1,359 @@ +//! Sealing what crosses the relay (spec 28.3): ECDH P-256 for the keys, +//! HKDF-SHA-256 to turn them into one AES-256-GCM key per direction, and +//! HMAC-SHA-256 for the proofs of the QR code. All of it exists in the +//! phone's browser, and `docs/test-vectors/mobile.json` holds bytes both +//! sides must agree on. + +use aes_gcm::aead::{Aead, Payload}; +use aes_gcm::{Aes256Gcm, KeyInit}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use p256::{PublicKey, SecretKey}; +use serde::{Deserialize, Serialize}; + +use super::kdf::{hkdf, hmac}; + +/// The version of the sealed message (`v`). +pub const VERSION: u8 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SealError { + #[error("not a valid key")] + Key, + #[error("not a sealed message")] + Format, + #[error("the message does not open")] + Auth, + #[error("no random bytes")] + Random, +} + +/// One side's key for the exchange. +pub struct Keypair { + secret: SecretKey, + public: Vec, +} + +impl Keypair { + pub fn generate() -> Result { + loop { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| SealError::Random)?; + // Not every 32 bytes are a scalar below the curve's order; try again. + if let Ok(pair) = Self::from_bytes(&bytes) { + return Ok(pair); + } + } + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + let secret = SecretKey::from_slice(bytes).map_err(|_| SealError::Key)?; + let public = secret.public_key().to_sec1_bytes().to_vec(); + Ok(Self { secret, public }) + } + + /// The point, uncompressed (65 bytes), as WebCrypto exports it. + pub fn public(&self) -> &[u8] { + &self.public + } + + fn shared(&self, theirs: &[u8]) -> Result, SealError> { + let theirs = PublicKey::from_sec1_bytes(theirs).map_err(|_| SealError::Key)?; + let shared = + p256::ecdh::diffie_hellman(self.secret.to_nonzero_scalar(), theirs.as_affine()); + Ok(shared.raw_secret_bytes().to_vec()) + } +} + +/// The two keys of a connection, one per direction. +#[derive(Clone, PartialEq, Eq)] +pub struct Keys { + pub c2p: [u8; 32], + pub p2c: [u8; 32], +} + +/// Both sides derive the same pair: the shared point, salted with the QR's +/// secret, which the server never saw. +pub fn derive(mine: &Keypair, theirs: &[u8], secret: &[u8]) -> Result { + let okm = hkdf(&mine.shared(theirs)?, secret, b"botloft-mobile-1", 64); + let mut keys = Keys { + c2p: [0; 32], + p2c: [0; 32], + }; + keys.c2p.copy_from_slice(&okm[..32]); + keys.p2c.copy_from_slice(&okm[32..]); + Ok(keys) +} + +/// What the phone proves when it joins: that it holds the QR's secret. +pub fn join_proof(secret: &[u8], id: &str, phone_pub: &[u8]) -> [u8; 32] { + hmac(secret, &[b"botloft-pair-1", id.as_bytes(), phone_pub]) +} + +/// What the computer proves when it accepts. +pub fn accept_proof(secret: &[u8], id: &str, daemon_pub: &[u8], phone_pub: &[u8]) -> [u8; 32] { + hmac( + secret, + &[ + b"botloft-pair-1-accept", + id.as_bytes(), + daemon_pub, + phone_pub, + ], + ) +} + +/// Six digits both screens show, so the owner sees it is the same pairing. +pub fn code(secret: &[u8], daemon_pub: &[u8], phone_pub: &[u8]) -> String { + let ikm = [daemon_pub, phone_pub].concat(); + let bytes = hkdf(&ikm, secret, b"botloft-pair-1-code", 3); + let bits = (u32::from(bytes[0]) << 12) | (u32::from(bytes[1]) << 4) | u32::from(bytes[2] >> 4); + format!("{:06}", bits % 1_000_000) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dir { + ComputerToPhone = 1, + PhoneToComputer = 2, +} + +#[derive(Serialize, Deserialize)] +struct Sealed { + v: u8, + seq: u64, + ct: String, +} + +fn aad(dir: Dir, device: &str, seq: u64) -> Vec { + let mut aad = vec![VERSION, dir as u8]; + aad.extend_from_slice(device.as_bytes()); + aad.extend_from_slice(&seq.to_be_bytes()); + aad +} + +fn nonce(seq: u64) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[4..].copy_from_slice(&seq.to_be_bytes()); + nonce +} + +fn cipher(key: &[u8; 32]) -> Result { + Aes256Gcm::new_from_slice(key).map_err(|_| SealError::Key) +} + +/// The text the relay carries: `{"v":1,"seq":N,"ct":"..."}`. The `seq` must +/// never repeat under a key, so it comes from a counter that is saved first. +pub fn seal( + key: &[u8; 32], + dir: Dir, + device: &str, + seq: u64, + plain: &[u8], +) -> Result { + let nonce = nonce(seq); + let ct = cipher(key)? + .encrypt( + &nonce.into(), + Payload { + msg: plain, + aad: &aad(dir, device, seq), + }, + ) + .map_err(|_| SealError::Auth)?; + serde_json::to_string(&Sealed { + v: VERSION, + seq, + ct: B64.encode(ct), + }) + .map_err(|_| SealError::Format) +} + +/// The `seq` and the plain text of a sealed message, if it opens. +pub fn open( + key: &[u8; 32], + dir: Dir, + device: &str, + body: &str, +) -> Result<(u64, Vec), SealError> { + let sealed: Sealed = serde_json::from_str(body).map_err(|_| SealError::Format)?; + if sealed.v != VERSION { + return Err(SealError::Format); + } + let ct = B64.decode(&sealed.ct).map_err(|_| SealError::Format)?; + let plain = cipher(key)? + .decrypt( + &nonce(sealed.seq).into(), + Payload { + msg: &ct, + aad: &aad(dir, device, sealed.seq), + }, + ) + .map_err(|_| SealError::Auth)?; + Ok((sealed.seq, plain)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + const SECRET: [u8; 32] = [0x33; 32]; + const ID: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + + fn pair() -> (Keypair, Keypair) { + ( + Keypair::from_bytes(&[0x11; 32]).expect("daemon key"), + Keypair::from_bytes(&[0x22; 32]).expect("phone key"), + ) + } + + #[test] + fn both_sides_derive_the_same_keys_and_the_secret_matters() { + let (daemon, phone) = pair(); + let a = derive(&daemon, phone.public(), &SECRET).expect("daemon side"); + let b = derive(&phone, daemon.public(), &SECRET).expect("phone side"); + assert!(a == b); + assert_ne!(a.c2p, a.p2c); + // The server knows both public keys but not the secret: not the keys. + let guess = derive(&daemon, phone.public(), &[0x34; 32]).expect("wrong secret"); + assert!(guess != a); + assert_eq!(daemon.public().len(), 65); + assert_eq!(daemon.public()[0], 4); + assert_eq!( + derive(&daemon, &[1, 2, 3], &SECRET).err(), + Some(SealError::Key) + ); + } + + #[test] + fn a_sealed_message_opens_only_for_its_key_direction_device_and_seq() { + let key = [7u8; 32]; + let body = seal(&key, Dir::ComputerToPhone, "dev_a", 5, b"hello").expect("seal"); + assert_eq!( + open(&key, Dir::ComputerToPhone, "dev_a", &body).expect("open"), + (5, b"hello".to_vec()) + ); + assert_eq!( + open(&[8u8; 32], Dir::ComputerToPhone, "dev_a", &body).err(), + Some(SealError::Auth) + ); + assert_eq!( + open(&key, Dir::PhoneToComputer, "dev_a", &body).err(), + Some(SealError::Auth) + ); + assert_eq!( + open(&key, Dir::ComputerToPhone, "dev_b", &body).err(), + Some(SealError::Auth) + ); + // Changing the counter, or a byte of the text, breaks it. + let moved = body.replace("\"seq\":5", "\"seq\":6"); + assert_eq!( + open(&key, Dir::ComputerToPhone, "dev_a", &moved).err(), + Some(SealError::Auth) + ); + let mut value: Value = serde_json::from_str(&body).expect("json"); + let ct = value["ct"].as_str().expect("ct").to_owned(); + let flipped = format!( + "{}{}", + &ct[..ct.len() - 1], + if ct.ends_with('A') { 'B' } else { 'A' } + ); + value["ct"] = json!(flipped); + assert_eq!( + open(&key, Dir::ComputerToPhone, "dev_a", &value.to_string()).err(), + Some(SealError::Auth) + ); + for bad in [ + "", + "not json", + "{\"v\":2,\"seq\":1,\"ct\":\"AA\"}", + "{\"v\":1,\"seq\":1,\"ct\":\"***\"}", + ] { + assert_eq!( + open(&key, Dir::ComputerToPhone, "dev_a", bad).err(), + Some(SealError::Format), + "{bad}" + ); + } + } + + #[test] + fn the_code_is_six_digits_and_follows_the_keys() { + let (daemon, phone) = pair(); + let code_a = code(&SECRET, daemon.public(), phone.public()); + assert_eq!(code_a.len(), 6); + assert!(code_a.bytes().all(|b| b.is_ascii_digit())); + assert_ne!(code_a, code(&SECRET, phone.public(), daemon.public())); + assert_ne!( + join_proof(&SECRET, ID, phone.public()), + join_proof(&[0x34; 32], ID, phone.public()) + ); + assert_ne!( + join_proof(&SECRET, ID, phone.public()), + accept_proof(&SECRET, ID, daemon.public(), phone.public()) + ); + } + + /// The bytes the phone's code must reproduce. Run with + /// `BOTLOFT_WRITE_VECTORS=1` to write the file again. + #[test] + fn the_test_vectors_are_what_the_code_makes() { + let (daemon, phone) = pair(); + let keys = derive(&daemon, phone.public(), &SECRET).expect("keys"); + let b64 = |bytes: &[u8]| B64.encode(bytes); + let c2p = seal( + &keys.c2p, + Dir::ComputerToPhone, + "dev_phone", + 1, + br#"{"t":"sync"}"#, + ) + .expect("c2p"); + let p2c = seal( + &keys.p2c, + Dir::PhoneToComputer, + "dev_phone", + 7, + "olá 🔥".as_bytes(), + ) + .expect("p2c"); + let vectors = json!({ + "about": "Spec 28.3. Generated by botloftd (mobile::seal tests); the PWA must reproduce every value.", + "pair": { + "id": ID, + "secret": b64(&SECRET), + "daemonPrivateHex": hex::encode([0x11u8; 32]), + "phonePrivateHex": hex::encode([0x22u8; 32]), + "daemonPublic": b64(daemon.public()), + "phonePublic": b64(phone.public()), + "joinProof": b64(&join_proof(&SECRET, ID, phone.public())), + "acceptProof": b64(&accept_proof(&SECRET, ID, daemon.public(), phone.public())), + "code": code(&SECRET, daemon.public(), phone.public()), + "computerToPhoneKeyHex": hex::encode(keys.c2p), + "phoneToComputerKeyHex": hex::encode(keys.p2c), + }, + "seal": [ + { + "direction": "computerToPhone", "device": "dev_phone", "seq": 1, + "keyHex": hex::encode(keys.c2p), "plain": r#"{"t":"sync"}"#, "body": c2p, + }, + { + "direction": "phoneToComputer", "device": "dev_phone", "seq": 7, + "keyHex": hex::encode(keys.p2c), "plain": "olá 🔥", "body": p2c, + }, + ], + }); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../docs/test-vectors/mobile.json"); + let text = serde_json::to_string_pretty(&vectors).expect("json") + "\n"; + if std::env::var_os("BOTLOFT_WRITE_VECTORS").is_some() { + std::fs::write(&path, &text).expect("write the vectors"); + } + let on_disk = std::fs::read_to_string(&path).expect("docs/test-vectors/mobile.json"); + assert_eq!( + on_disk.replace("\r\n", "\n"), + text, + "run with BOTLOFT_WRITE_VECTORS=1" + ); + } +} diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index d2820426..c689819d 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -9,8 +9,8 @@ use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ self, ApiError, ApiResult, archive, attachments, autobackup, backup, bots, catalog, chat, - cloud, crew_access, crews, delete, deliveries, desktop, files, lead, mcp, messages, models, - modes, questions, reactions, routines, rules, screens, settings, tasks, usage, + cloud, crew_access, crews, delete, deliveries, desktop, files, lead, mcp, messages, mobile, + models, modes, questions, reactions, routines, rules, screens, settings, tasks, usage, }; use crate::state::Daemon; @@ -80,6 +80,15 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(autobackup::enable(daemon, parse(params)?)), method::AUTOBACKUP_SET_EVERY => reply(autobackup::set_every(daemon, parse(params)?)), method::AUTOBACKUP_DISABLE => reply(Ok::<_, ApiError>(autobackup::disable(daemon))), + method::MOBILE_STATUS => reply(Ok::<_, ApiError>(mobile::status(daemon))), + method::MOBILE_PAIR_START => reply(mobile::pair_start(daemon)), + method::MOBILE_PAIR_CANCEL => { + reply(mobile::pair_cancel(daemon, parse(params)?).map(|()| Value::Null)) + } + method::MOBILE_PAIR_CONFIRM => { + reply(mobile::pair_confirm(daemon, parse(params)?).map(|()| Value::Null)) + } + method::MOBILE_REVOKE => reply(mobile::revoke(daemon, parse(params)?)), method::CREW_ACCESS_LIST => reply(crew_access::list(daemon, parse(params)?)), method::CREW_ACCESS_REVOKE => reply(crew_access::revoke(daemon, parse(params)?)), method::MCP_SERVERS => reply(mcp::overview(daemon)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index a3ee4345..c192e383 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -205,6 +205,11 @@ const ASIDE: &[&str] = &[ method::CLOUD_DOWNLOAD, method::CLOUD_DELETE, method::CLOUD_DELETE_ACCOUNT, + // The phone: they ask the server (spec 28.5). + method::MOBILE_PAIR_START, + method::MOBILE_PAIR_CANCEL, + method::MOBILE_PAIR_CONFIRM, + method::MOBILE_REVOKE, ]; enum Routed { diff --git a/crates/botloftd/src/rpc/notify.rs b/crates/botloftd/src/rpc/notify.rs index 473a5c53..a4cbb820 100644 --- a/crates/botloftd/src/rpc/notify.rs +++ b/crates/botloftd/src/rpc/notify.rs @@ -37,5 +37,7 @@ pub(super) fn to_notification(event: &Event) -> String { Event::CloudProgress(progress) => note(notification::CLOUD_PROGRESS, progress), Event::CloudSigninExpired => note(notification::CLOUD_SIGNIN_EXPIRED, &()), Event::AutoBackupChanged(status) => note(notification::AUTOBACKUP_CHANGED, status), + Event::MobileChanged(status) => note(notification::MOBILE_CHANGED, status), + Event::MobilePairRequest(request) => note(notification::MOBILE_PAIR_REQUEST, request), } } diff --git a/crates/botloftd/src/service/cloud.rs b/crates/botloftd/src/service/cloud.rs index 63ddf009..15ef6982 100644 --- a/crates/botloftd/src/service/cloud.rs +++ b/crates/botloftd/src/service/cloud.rs @@ -27,9 +27,10 @@ fn run(work: impl Future) -> T { } /// A refused token ends the sign-in on this computer: it was revoked. -fn fail(daemon: &Daemon, err: CloudError) -> ApiError { +pub(super) fn fail(daemon: &Daemon, err: CloudError) -> ApiError { if err.reason == "unauthorized" { daemon.cloud.forget(&daemon.paths.secrets()); + daemon.mobile.forget(daemon); return ApiError::Rule { reason: "signed_out", message: "sign in again to use the account".to_owned(), @@ -132,6 +133,8 @@ pub fn signout(daemon: &Daemon) { let _ = run(server.logout(&credentials.token)); } daemon.cloud.forget(&daemon.paths.secrets()); + // The phones go with the account (spec 28.6). + daemon.mobile.forget(daemon); // Without the account there is nowhere to send the copies. daemon.autobackup.turn_off(); daemon.emit(Event::AutoBackupChanged(daemon.autobackup.status())); diff --git a/crates/botloftd/src/service/mobile.rs b/crates/botloftd/src/service/mobile.rs new file mode 100644 index 00000000..992e0c00 --- /dev/null +++ b/crates/botloftd/src/service/mobile.rs @@ -0,0 +1,57 @@ +//! `mobile.*` (spec 28.5). Those that ask the server wait for the network, so +//! the connection answers them aside (`rpc::ASIDE`). + +use std::future::Future; + +use botloft_core::protocol::{ + MobilePairConfirmParams, MobilePairIdParams, MobilePairStarted, MobileRevokeParams, + MobileStatus, +}; + +use super::cloud::fail; +use super::{ApiError, ApiResult}; +use crate::mobile::Confirm; +use crate::state::Daemon; + +/// The service runs on the blocking pool, where the network waits belong. +fn run(work: impl Future) -> T { + tokio::runtime::Handle::current().block_on(work) +} + +/// `mobile.status`: nothing is asked of the server. +pub fn status(daemon: &Daemon) -> MobileStatus { + daemon.mobile.status(daemon) +} + +/// `mobile.pair_start`: a code for a phone to scan. +pub fn pair_start(daemon: &Daemon) -> ApiResult { + run(daemon.mobile.pair_start(daemon)).map_err(|err| fail(daemon, err)) +} + +/// `mobile.pair_cancel` +pub fn pair_cancel(daemon: &Daemon, params: MobilePairIdParams) -> ApiResult<()> { + run(daemon.mobile.pair_cancel(daemon, ¶ms.pair_id)).map_err(|err| fail(daemon, err)) +} + +/// `mobile.pair_confirm`: the owner compared the codes. +pub fn pair_confirm(daemon: &Daemon, params: MobilePairConfirmParams) -> ApiResult<()> { + run(daemon + .mobile + .pair_confirm(daemon, ¶ms.pair_id, params.accept)) + .map_err(|err| match err { + Confirm::NothingToConfirm => ApiError::Conflict( + "there is no phone waiting on that code; it may have run out".to_owned(), + ), + Confirm::Cloud(err) => fail(daemon, err), + }) +} + +/// `mobile.revoke`: the phone loses its access at once. +pub fn revoke(daemon: &Daemon, params: MobileRevokeParams) -> ApiResult { + let removed = + run(daemon.mobile.revoke(daemon, ¶ms.phone_id)).map_err(|err| fail(daemon, err))?; + if !removed { + return Err(ApiError::NotFound(format!("phone {}", params.phone_id))); + } + Ok(daemon.mobile.status(daemon)) +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 237c225e..994e35ab 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -19,6 +19,7 @@ pub mod lead; pub mod mcp; pub mod mcp_state; pub mod messages; +pub mod mobile; pub mod models; pub mod modes; pub mod plan; diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index 5ff4c813..b983113e 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -9,8 +9,8 @@ use botloft_core::protocol::{ AccountUsage, AutoBackupStatus, Bot, BotContextChanged, BotDeleted, BotDesktop, BotMcp, BotRules, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, CloudProgress, CloudSignedIn, Crew, CrewDeleted, Delivery, DesktopAwayUse, DesktopState, - FolderRecycled, McpOverview, Message, Question, ReactionChanged, Routine, RoutineRun, - ScreenDraft, Task, + FolderRecycled, McpOverview, Message, MobilePairRequest, MobileStatus, Question, + ReactionChanged, Routine, RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::runtime::{Handle, RuntimeFlavor}; @@ -65,6 +65,8 @@ pub enum Event { CloudProgress(CloudProgress), CloudSigninExpired, AutoBackupChanged(AutoBackupStatus), + MobileChanged(MobileStatus), + MobilePairRequest(MobilePairRequest), } /// Events buffered per connection before a slow client is dropped. @@ -131,6 +133,8 @@ pub struct Daemon { pub cloud: crate::cloud::Cloud, /// The copies it sends by itself (spec 27.10). pub autobackup: crate::autobackup::AutoBackup, + /// The phone that approves and answers from afar (spec 28). + pub mobile: crate::mobile::Mobile, /// Other crews a bot may reach for its current turn (spec 10.4). pub crew_access: crate::service::crew_access::TurnAccess, /// What each bot's process had cost by its last turn (spec 8.7). @@ -180,6 +184,7 @@ impl Daemon { options.secrets, options.cloud.auto_tick, ), + mobile: crate::mobile::Mobile::new(&options.paths.secrets(), options.cloud.relay_retry), cloud: crate::cloud::Cloud::new(options.cloud), crew_access: Default::default(), costs: Default::default(), diff --git a/crates/botloftd/tests/common/cloud_server.rs b/crates/botloftd/tests/common/cloud_server.rs index 2dcdc15d..a9f5f60a 100644 --- a/crates/botloftd/tests/common/cloud_server.rs +++ b/crates/botloftd/tests/common/cloud_server.rs @@ -14,12 +14,13 @@ pub struct CloudServer { pub url: String, pub outbox: Outbox, pub clock: Clock, + pub hub: Hub, } pub async fn cloud_server() -> CloudServer { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let url = format!("http://{}", listener.local_addr().expect("addr")); - let (outbox, clock) = (Outbox::default(), Clock::default()); + let (outbox, clock, hub) = (Outbox::default(), Clock::default(), Hub::default()); let mut config = Config::for_tests(); config.public_url = url.clone(); let state = AppState { @@ -28,12 +29,17 @@ pub async fn cloud_server() -> CloudServer { clock: clock.clone(), config: Arc::new(config), store: CopyStore::memory(), - hub: Hub::default(), + hub: hub.clone(), }; tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve"); }); - CloudServer { url, outbox, clock } + CloudServer { + url, + outbox, + clock, + hub, + } } impl CloudServer { diff --git a/crates/botloftd/tests/common/mobile.rs b/crates/botloftd/tests/common/mobile.rs new file mode 100644 index 00000000..5d49296b --- /dev/null +++ b/crates/botloftd/tests/common/mobile.rs @@ -0,0 +1,249 @@ +//! A phone for the tests (spec 28): what the PWA does, in Rust, against the +//! real account server and a daemon in this process. + +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use botloft_core::protocol::{FromPhone, ToPhone}; +use botloftd::mobile::seal::{self, Dir, Keypair, Keys}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +const WAIT: Duration = Duration::from_secs(10); + +/// A phone that scanned the code and joined it. +pub struct Joined { + base: String, + pub pair_id: String, + secret: Vec, + daemon_pub: Vec, + keys: Keypair, + poll: String, + /// The code the phone shows the owner to compare. + pub code: String, +} + +/// Reads what the QR holds and joins, with `proof` or the right one. +pub async fn join(base: &str, qr: &str, name: &str, wrong_proof: bool) -> Joined { + let fragment = qr.split_once('#').expect("a fragment").1; + let part = |key: &str| -> String { + fragment + .split('&') + .find_map(|pair| pair.strip_prefix(&format!("{key}="))) + .unwrap_or_else(|| panic!("{key} in the QR")) + .to_owned() + }; + let (pair_id, secret, daemon_pub) = ( + part("p"), + B64.decode(part("s")).expect("secret"), + B64.decode(part("d")).expect("key"), + ); + let keys = Keypair::generate().expect("keys"); + let mut proof = seal::join_proof(&secret, &pair_id, keys.public()); + if wrong_proof { + proof[0] ^= 1; + } + let answer = reqwest::Client::new() + .post(format!("{base}/v1/pairings/{pair_id}/join")) + .json(&json!({ + "phone_pub": B64.encode(keys.public()), "device_name": name, "proof": B64.encode(proof), + })) + .send() + .await + .expect("join"); + assert_eq!( + answer.status(), + 202, + "{}", + answer.text().await.unwrap_or_default() + ); + let poll = answer.json::().await.expect("json")["poll"] + .as_str() + .expect("poll") + .to_owned(); + let code = seal::code(&secret, &daemon_pub, keys.public()); + Joined { + base: base.to_owned(), + pair_id, + secret, + daemon_pub, + keys, + poll, + code, + } +} + +impl Joined { + /// Asks for the answer until the computer accepts. `None` if the code is + /// gone (the owner refused, or it ran out). + pub async fn collect(self) -> Option { + let url = format!("{}/v1/pairings/{}/result", self.base, self.pair_id); + for _ in 0..200 { + let answer: Value = reqwest::Client::new() + .get(&url) + .bearer_auth(&self.poll) + .send() + .await + .expect("result") + .json() + .await + .expect("json"); + match answer["status"].as_str() { + Some("approved") => { + // The computer proves it accepted, with the QR's secret. + let theirs = B64 + .decode(answer["daemon_pub"].as_str().expect("key")) + .expect("b64"); + assert_eq!(theirs, self.daemon_pub, "the key is the one in the QR"); + let proof = B64 + .decode(answer["proof2"].as_str().expect("proof")) + .expect("b64"); + let expected = seal::accept_proof( + &self.secret, + &self.pair_id, + &theirs, + self.keys.public(), + ); + assert_eq!(proof, expected); + return Some(Phone { + base: self.base.clone(), + token: answer["token"].as_str().expect("token").to_owned(), + id: answer["device"].as_str().expect("device").to_owned(), + keys: seal::derive(&self.keys, &theirs, &self.secret).expect("keys"), + sent: 0, + socket: None, + }); + } + Some("expired") => return None, + _ => tokio::time::sleep(Duration::from_millis(50)).await, + } + } + panic!("the computer never accepted"); + } + + /// A word for the owner who compares: the same six digits. + pub fn secret(&self) -> &[u8] { + &self.secret + } +} + +/// A phone that is connected to a computer. +pub struct Phone { + base: String, + pub token: String, + pub id: String, + keys: Keys, + sent: u64, + socket: Option>>, +} + +impl Phone { + /// Opens the relay as the phone does and waits for `ready`. + pub async fn connect(&mut self) -> Value { + let url = self.base.replacen("http://", "ws://", 1) + "/v1/relay"; + let (socket, _) = connect_async(url).await.expect("connect"); + self.socket = Some(socket); + self.send_raw(json!({ "t": "hello", "token": self.token })) + .await; + loop { + let frame = self.frame().await; + if frame["t"] == "ready" { + return frame; + } + } + } + + pub async fn send_raw(&mut self, frame: Value) { + let socket = self.socket.as_mut().expect("connected"); + socket + .send(Message::Text(frame.to_string().into())) + .await + .expect("send"); + } + + /// The text relayed for `message`, with the next counter; the frame is + /// not sent. + pub fn seal(&mut self, message: &FromPhone) -> Value { + self.sent += 1; + let plain = serde_json::to_vec(message).expect("json"); + let body = seal::seal( + &self.keys.p2c, + Dir::PhoneToComputer, + &self.id, + self.sent, + &plain, + ) + .expect("seal"); + json!({ "t": "msg", "seq": self.sent, "body": body }) + } + + /// Seals and sends. Gives the frame back, to send again. + pub async fn send(&mut self, message: &FromPhone) -> Value { + let frame = self.seal(message); + self.send_raw(frame.clone()).await; + frame + } + + /// The next frame, whatever it is. + pub async fn frame(&mut self) -> Value { + let socket = self.socket.as_mut().expect("connected"); + let next = tokio::time::timeout(WAIT, socket.next()) + .await + .expect("a frame in time"); + match next { + Some(Ok(Message::Text(text))) => serde_json::from_str(&text).expect("json"), + other => panic!("not a frame: {other:?}"), + } + } + + /// The next thing the computer says, opened. Frames that are not messages + /// (who is online) are skipped. + pub async fn next(&mut self) -> ToPhone { + loop { + let frame = self.frame().await; + if frame["t"] != "msg" { + continue; + } + let (seq, plain) = seal::open( + &self.keys.c2p, + Dir::ComputerToPhone, + &self.id, + frame["body"].as_str().expect("body"), + ) + .expect("a message that opens"); + assert_eq!(Some(seq), frame["seq"].as_u64()); + return serde_json::from_slice(&plain).expect("a known message"); + } + } + + /// Nothing more from the computer for a while. + pub async fn quiet(&mut self) { + let socket = self.socket.as_mut().expect("connected"); + loop { + match tokio::time::timeout(Duration::from_millis(400), socket.next()).await { + Err(_) => return, + Ok(Some(Ok(Message::Text(text)))) => { + let frame: Value = serde_json::from_str(&text).expect("json"); + assert_ne!(frame["t"], "msg", "unexpected message: {frame}"); + } + Ok(other) => panic!("the socket ended: {other:?}"), + } + } + } + + /// The server closes the socket (the phone was cut off). + pub async fn closed(&mut self) { + let socket = self.socket.as_mut().expect("connected"); + loop { + match tokio::time::timeout(WAIT, socket.next()).await { + Ok(Some(Ok(Message::Text(_)))) => {} + Ok(None | Some(Err(_) | Ok(Message::Close(_)))) => return, + other => panic!("not closed: {other:?}"), + } + } + } +} diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index d07f4927..7e4c62c3 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -10,6 +10,7 @@ pub mod context; #[cfg(windows)] pub mod desktop; pub mod mcp; +pub mod mobile; pub mod routines; pub mod site; pub mod stream; @@ -163,6 +164,14 @@ pub fn new_daemon_with( } } +/// The relay tries again soon, so a test does not wait for it. +fn fast_retry() -> botloftd::mobile::Retry { + botloftd::mobile::Retry { + min: Duration::from_millis(50), + max: Duration::from_millis(200), + } +} + impl TestDaemon { /// Server only: bots are never started and nothing is delivered. pub async fn start() -> Self { @@ -191,11 +200,23 @@ impl TestDaemon { let cloud = botloftd::cloud::CloudSettings { url: url.to_owned(), poll: Duration::from_millis(50), + relay_retry: fast_retry(), ..Default::default() }; Self::launch_with(false, APPROVAL_WAIT, cloud, secrets).await } + /// Server, supervisor and courier, with an account server to talk to. + pub async fn start_supervised_with_cloud(url: &str) -> Self { + let cloud = botloftd::cloud::CloudSettings { + url: url.to_owned(), + poll: Duration::from_millis(50), + relay_retry: fast_retry(), + ..Default::default() + }; + Self::launch_with(true, APPROVAL_WAIT, cloud, Arc::new(MemorySecrets::new())).await + } + async fn launch(supervised: bool, approval_wait: Duration) -> Self { Self::launch_with( supervised, @@ -219,6 +240,8 @@ impl TestDaemon { tokio::spawn(supervisor::run(Arc::clone(&parts.daemon))); tokio::spawn(courier::run(Arc::clone(&parts.daemon))); } + // The phone's relay waits until a phone is wanted (spec 28). + tokio::spawn(botloftd::mobile::run(Arc::clone(&parts.daemon))); tokio::spawn(server::serve( Arc::clone(&parts.daemon), listener, diff --git a/crates/botloftd/tests/mobile.rs b/crates/botloftd/tests/mobile.rs new file mode 100644 index 00000000..b6330df9 --- /dev/null +++ b/crates/botloftd/tests/mobile.rs @@ -0,0 +1,474 @@ +//! The phone (spec 28): connecting one with the QR code, and approving and +//! answering from it through the relay of the real account server. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::{ApprovalStatus, FromPhone, QuestionStatus, ToPhone}; +use common::bots::ready_bot; +use common::cloud_server::{CloudServer, cloud_server, sign_in}; +use common::mcp::Mcp; +use common::mobile::{Phone, join}; +use common::{Client, TestDaemon, stream}; +use serde_json::{Value, json}; + +/// A daemon signed in to its own account server. +async fn setup() -> (CloudServer, TestDaemon, Client) { + let cloud = cloud_server().await; + let t = TestDaemon::start_supervised_with_cloud(&cloud.url).await; + let mut app = t.session().await; + sign_in(&cloud, &mut app).await; + (cloud, t, app) +} + +/// Waits until `mobile.status` satisfies `ok`. +async fn status_where(app: &mut Client, ok: impl Fn(&Value) -> bool) -> Value { + for _ in 0..200 { + let status = app + .call("mobile.status", Value::Null) + .await + .expect("status"); + if ok(&status) { + return status; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("mobile.status never became what the test waits for"); +} + +/// Connects a phone as the owner would, comparing the codes. +async fn connect_phone(cloud: &CloudServer, app: &mut Client) -> Phone { + let started = app + .call("mobile.pair_start", Value::Null) + .await + .expect("start"); + assert_eq!(started["expiresIn"], 300); + let qr = started["url"].as_str().expect("url"); + assert!(qr.starts_with(&format!("{}/m#p=", cloud.url)), "{qr}"); + let joined = join(&cloud.url, qr, "Celular da Ana", false).await; + let request = app.notification("mobile.pair_request").await; + assert_eq!(request["name"], "Celular da Ana"); + assert_eq!( + request["code"], + joined.code.as_str(), + "both screens show the same code" + ); + assert_eq!(request["pairId"], started["pairId"]); + app.call( + "mobile.pair_confirm", + json!({ "pairId": started["pairId"], "accept": true }), + ) + .await + .expect("confirm"); + let mut phone = joined.collect().await.expect("a token"); + status_where(app, |s| s["phones"][0]["id"] == phone.id.as_str()).await; + let ready = phone.connect().await; + assert_eq!(ready["kind"], "phone"); + status_where(app, |s| s["phones"][0]["online"] == true).await; + phone +} + +/// A lead in a turn, ready to ask for permission. +async fn lead( + t: &TestDaemon, + app: &mut Client, +) -> ( + botloftd::runtime::fake::FakeProcess, + std::net::SocketAddr, + String, +) { + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (bot, process, mcp) = ready_bot(t, app, &crew, "Lead").await; + app.call("messages.send", json!({ "botId": bot["id"], "body": "Go" })) + .await + .expect("send"); + let lines = process.wait_lines(1).await; + process + .emit(stream::replay(lines.last().expect("line"))) + .await; + (process, mcp.addr, mcp.token.clone()) +} + +/// Claude Code asks for permission; the decision comes out of the handle. +async fn ask( + process: &botloftd::runtime::fake::FakeProcess, + at: &(std::net::SocketAddr, String), + id: &str, + tool: &str, + input: &Value, +) -> tokio::task::JoinHandle { + process + .emit(stream::tool_use(id, tool, input.clone())) + .await; + let mut mcp = Mcp::new(at.0, at.1.clone()); + let args = json!({ "tool_name": tool, "input": input, "tool_use_id": id }); + tokio::spawn(async move { mcp.tool("permission_prompt", args).await.expect("decision") }) +} + +#[tokio::test] +async fn a_phone_is_connected_with_the_code_and_answers_a_question_once() { + let (cloud, t, mut app) = setup().await; + let mut phone = connect_phone(&cloud, &mut app).await; + + // The keys are on this computer, in the owner's secrets, and nowhere else. + let kept = std::fs::read_to_string(t.paths.secrets().join("mobile.json")).expect("keys kept"); + assert!(kept.contains(&phone.id)); + let status = app + .call("mobile.status", Value::Null) + .await + .expect("status"); + assert_eq!(status["relay"], "connected"); + assert_eq!(status["phones"][0]["name"], "Celular da Ana"); + assert!(status.get("pending").is_none()); + + phone.send(&FromPhone::Sync).await; + match phone.next().await { + ToPhone::Snapshot { + approvals, + questions, + } => assert!(approvals.is_empty() && questions.is_empty()), + other => panic!("{other:?}"), + } + + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (_, process, mut mcp) = ready_bot(&t, &mut app, &crew, "Scout").await; + mcp.tool( + "ask_owner", + json!({ "question": "Which client first?", "options": ["Acme", "Globex"] }), + ) + .await + .expect("asked"); + let ToPhone::QuestionOpen { card } = phone.next().await else { + panic!("the question should reach the phone"); + }; + assert_eq!( + ( + card.text.as_str(), + card.bot.name.as_str(), + card.crew.as_str() + ), + ("Which client first?", "Scout", "Ops") + ); + assert_eq!(card.options, ["Acme", "Globex"]); + + let sent = phone + .send(&FromPhone::QuestionAnswer { + question_id: card.question_id.clone(), + answer: "Acme".into(), + }) + .await; + let ToPhone::QuestionClosed { status, .. } = phone.next().await else { + panic!("the card should close"); + }; + assert_eq!(status, QuestionStatus::Answered); + let lines = process.wait_lines(1).await; + assert!(common::bots::text_of(&lines[0]).contains("Acme")); + // (The app heard the question open first.) + while app.notification("question.changed").await["status"] != "answered" {} + + // The same sealed message again does nothing: not a second answer, not a reply. + phone.send_raw(sent).await; + phone.quiet().await; + assert_eq!(process.input_lines().len(), 1); + + // A message changed on the way does not open and does not count. + let mut tampered = phone.seal(&FromPhone::Sync); + let body = tampered["body"] + .as_str() + .expect("body") + .replace("\"ct\":\"", "\"ct\":\"AAAA"); + tampered["body"] = Value::String(body); + phone.send_raw(tampered).await; + phone.send(&FromPhone::Sync).await; + assert!(matches!(phone.next().await, ToPhone::Snapshot { .. })); +} + +#[tokio::test] +async fn the_phone_allows_what_it_can_read_whole_and_only_denies_the_rest() { + let (cloud, t, mut app) = setup().await; + let mut phone = connect_phone(&cloud, &mut app).await; + let (process, addr, token) = lead(&t, &mut app).await; + let at = (addr, token); + + // A plain command: shown whole, allowed from the phone. + let input = json!({ "command": "git status", "description": "Shows what changed" }); + let decision = ask(&process, &at, "toolu_1", "Bash", &input).await; + let ToPhone::ApprovalOpen { card } = phone.next().await else { + panic!("the request should reach the phone"); + }; + assert_eq!( + ( + card.tool_name.as_str(), + card.text.as_str(), + card.bot.name.as_str() + ), + ("Bash", "git status", "Lead") + ); + assert_eq!(card.explanation.as_deref(), Some("Shows what changed")); + assert!(!card.cut && !card.at_computer); + phone + .send(&FromPhone::ApprovalAnswer { + approval_id: card.approval_id.clone(), + allow: true, + note: None, + }) + .await; + assert_eq!(decision.await.expect("task")["behavior"], "allow"); + assert!(matches!( + phone.next().await, + ToPhone::ApprovalClosed { + status: ApprovalStatus::Allowed, + .. + } + )); + + // A tool that belongs to the computer: the phone cannot allow it, and is shown so. + let input = json!({ "title": "Fix it" }); + let decision = ask( + &process, + &at, + "toolu_2", + "mcp__github__create_issue", + &input, + ) + .await; + let ToPhone::ApprovalOpen { card } = phone.next().await else { + panic!("open") + }; + assert!(card.at_computer); + phone + .send(&FromPhone::ApprovalAnswer { + approval_id: card.approval_id.clone(), + allow: true, + note: None, + }) + .await; + let ToPhone::ApprovalOpen { card: again } = phone.next().await else { + panic!("the card again") + }; + assert_eq!(again.approval_id, card.approval_id); + assert!(!decision.is_finished(), "still waiting for the owner"); + phone + .send(&FromPhone::ApprovalAnswer { + approval_id: card.approval_id.clone(), + allow: false, + note: Some("not now".into()), + }) + .await; + assert_eq!(decision.await.expect("task")["behavior"], "deny"); + assert!(matches!( + phone.next().await, + ToPhone::ApprovalClosed { + status: ApprovalStatus::Denied, + .. + } + )); + + // A command too long for a phone is cut and marked: deny only. + let input = json!({ "command": format!("echo {}", "x".repeat(9000)) }); + let decision = ask(&process, &at, "toolu_3", "Bash", &input).await; + let ToPhone::ApprovalOpen { card } = phone.next().await else { + panic!("open") + }; + assert!(card.cut && card.text.len() <= 8 * 1024); + phone + .send(&FromPhone::ApprovalAnswer { + approval_id: card.approval_id.clone(), + allow: true, + note: None, + }) + .await; + assert!(matches!(phone.next().await, ToPhone::ApprovalOpen { .. })); + assert!(!decision.is_finished()); + phone + .send(&FromPhone::ApprovalAnswer { + approval_id: card.approval_id.clone(), + allow: false, + note: None, + }) + .await; + assert_eq!(decision.await.expect("task")["behavior"], "deny"); + assert!(matches!(phone.next().await, ToPhone::ApprovalClosed { .. })); +} + +#[tokio::test] +async fn an_answer_at_the_computer_closes_the_card_and_a_late_answer_changes_nothing() { + let (cloud, t, mut app) = setup().await; + let mut phone = connect_phone(&cloud, &mut app).await; + let (process, addr, token) = lead(&t, &mut app).await; + let at = (addr, token); + + let decision = ask( + &process, + &at, + "toolu_1", + "Bash", + &json!({ "command": "ls" }), + ) + .await; + let ToPhone::ApprovalOpen { card } = phone.next().await else { + panic!("open") + }; + + // A phone that opens later is told what waits. + phone.send(&FromPhone::Sync).await; + let ToPhone::Snapshot { approvals, .. } = phone.next().await else { + panic!("snapshot") + }; + assert_eq!(approvals.len(), 1); + assert_eq!(approvals[0].approval_id, card.approval_id); + + app.call( + "approvals.answer", + json!({ "approvalId": card.approval_id, "allow": false }), + ) + .await + .expect("answer"); + assert_eq!(decision.await.expect("task")["behavior"], "deny"); + assert!(matches!( + phone.next().await, + ToPhone::ApprovalClosed { + status: ApprovalStatus::Denied, + .. + } + )); + + // The phone allows it anyway, late: it is told how it ended, nothing is allowed. + phone + .send(&FromPhone::ApprovalAnswer { + approval_id: card.approval_id.clone(), + allow: true, + note: None, + }) + .await; + assert!(matches!( + phone.next().await, + ToPhone::ApprovalClosed { + status: ApprovalStatus::Denied, + .. + } + )); +} + +#[tokio::test] +async fn a_wrong_proof_or_a_refusal_ends_the_code_without_a_phone() { + let (cloud, _t, mut app) = setup().await; + let started = app + .call("mobile.pair_start", Value::Null) + .await + .expect("start"); + let qr = started["url"].as_str().expect("url"); + let _ = join(&cloud.url, qr, "Intruder", true).await; + status_where(&mut app, |s| s.get("pending").is_none()).await; + let status = app + .call("mobile.status", Value::Null) + .await + .expect("status"); + assert_eq!(status["phones"], json!([])); + + // The owner sees a phone and says no. + let started = app + .call("mobile.pair_start", Value::Null) + .await + .expect("start"); + let joined = join( + &cloud.url, + started["url"].as_str().expect("url"), + "Celular", + false, + ) + .await; + app.notification("mobile.pair_request").await; + app.call( + "mobile.pair_confirm", + json!({ "pairId": started["pairId"], "accept": false }), + ) + .await + .expect("refuse"); + assert!(joined.collect().await.is_none(), "the phone gets no token"); + status_where(&mut app, |s| s.get("pending").is_none()).await; + let nothing = app + .call( + "mobile.pair_confirm", + json!({ "pairId": started["pairId"], "accept": true }), + ) + .await + .expect_err("nothing to confirm"); + assert_eq!(nothing.code, -32003); +} + +#[tokio::test] +async fn revoking_a_phone_cuts_it_off_and_leaving_the_account_takes_every_phone() { + let (cloud, t, mut app) = setup().await; + let mut phone = connect_phone(&cloud, &mut app).await; + let gone = app + .call("mobile.revoke", json!({ "phoneId": phone.id })) + .await + .expect("revoke"); + assert_eq!(gone["phones"], json!([])); + phone.closed().await; + let me = reqwest::Client::new() + .get(format!("{}/v1/me", cloud.url)) + .bearer_auth(&phone.token) + .send() + .await + .expect("me"); + assert_eq!(me.status(), 401, "the server dropped the phone's token"); + status_where(&mut app, |s| s["relay"] == "off").await; + let unknown = app + .call("mobile.revoke", json!({ "phoneId": phone.id })) + .await + .expect_err("already gone"); + assert_eq!(unknown.code, -32002); + + // Another phone, then the computer leaves the account. + let mut second = connect_phone(&cloud, &mut app).await; + app.call("cloud.signout", Value::Null) + .await + .expect("signout"); + second.closed().await; + assert!(!t.paths.secrets().join("mobile.json").exists()); + let status = app + .call("mobile.status", Value::Null) + .await + .expect("status"); + assert_eq!(status["phones"], json!([])); +} + +#[tokio::test] +async fn the_relay_comes_back_when_the_server_cuts_it_and_nothing_is_lost_for_good() { + let (cloud, t, mut app) = setup().await; + let mut phone = connect_phone(&cloud, &mut app).await; + let credentials: Value = serde_json::from_slice( + &std::fs::read(t.paths.secrets().join("cloud.json")).expect("credentials"), + ) + .expect("json"); + cloud + .hub + .kick(credentials["device"].as_str().expect("device")); + + // It reconnects by itself, and the phone is online again. + status_where(&mut app, |s| { + s["relay"] == "connected" && s["phones"][0]["online"] == true + }) + .await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (_, _process, mut mcp) = ready_bot(&t, &mut app, &crew, "Scout").await; + mcp.tool("ask_owner", json!({ "question": "After the drop?" })) + .await + .expect("asked"); + let ToPhone::QuestionOpen { card } = phone.next().await else { + panic!("open") + }; + assert_eq!(card.text, "After the drop?"); +} diff --git a/docs/spec.md b/docs/spec.md index 6bcd14d5..ce902cce 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2264,13 +2264,13 @@ A conta de 27 ganha o **tipo** do aparelho (`computer` ou `phone`) e, no celular Criptografia que existe em todo navegador (WebCrypto) e em crates do RustCrypto, para o PWA não carregar biblioteca de criptografia: **ECDH P-256**, **HKDF-SHA-256**, **HMAC-SHA-256** e **AES-256-GCM**. -1. No app (28.7), "Conectar um celular" chama `mobile.pair_start`. O daemon sorteia `pair_id` (16 bytes) e `secret` (32 bytes), a chave efêmera dele (`D`), e avisa o servidor (`POST /v1/pairings {id}`, com o token do computador; vale 5 minutos e uma vez). O app mostra um **QR** (gerado no app, sem serviço externo) com `/m#p=&s=`. O que vem depois do `#` o navegador nunca envia: o servidor não vê o `secret`. +1. No app (28.7), "Conectar um celular" chama `mobile.pair_start`. O daemon sorteia `pair_id` (16 bytes) e `secret` (32 bytes), a chave efêmera dele (`D`), e avisa o servidor (`POST /v1/pairings {id}`, com o token do computador; vale 5 minutos e uma vez). O app mostra um **QR** (gerado no app, sem serviço externo) com `/m#p=&s=&d=` (`secret` e `D` em base64url; `D` é a chave pública do computador, 65 bytes). O que vem depois do `#` o navegador nunca envia: o servidor não vê o `secret` nem fica sabendo de `D` por aqui, e um servidor que quisesse trocar `D` não consegue, porque o celular a leu do QR. 2. O celular abre o link, lê o fragmento e gera a chave dele (`P`). Manda `POST /v1/pairings//join {phone_pub, device_name, proof}`, com `proof = HMAC(secret, "botloft-pair-1" ‖ id ‖ phone_pub)`. O servidor guarda e avisa o computador pelo relay. -3. O daemon confere a `proof`; errada, recusa. Certa, mostra no app o **nome do celular e um código de 6 dígitos** que sai das duas chaves públicas (`HKDF(secret, D ‖ P)`, 20 bits), e o celular mostra o mesmo código. O dono compara e clica **Conectar** (`mobile.pair_confirm`): é isso que impede quem fotografou o QR de entrar no lugar do dono. +3. O daemon confere a `proof`; errada, recusa e o código morre. Certa, mostra no app o **nome do celular e um código de 6 dígitos** (`HKDF(ikm = D ‖ P, salt = secret, info = "botloft-pair-1-code", 3 bytes)`; os 20 primeiros bits, como número, módulo 1 000 000, com zeros à esquerda), e o celular mostra o mesmo código na hora, porque já tem `D` do QR. O dono compara e clica **Conectar** (`mobile.pair_confirm`): é isso que impede quem fotografou o QR de entrar no lugar do dono. 4. O daemon manda `POST /v1/pairings//accept {daemon_pub, proof2}` (`proof2 = HMAC(secret, "botloft-pair-1-accept" ‖ id ‖ D ‖ P)`). O servidor cria o aparelho `phone` com o `peer` e, na primeira consulta do celular (`GET /v1/pairings//result`, com o `poll` do passo 2 como `Bearer`, uma vez), entrega `{token, device, peer, daemon_pub, proof2}`; quem só tem o `id` do QR não tem o `poll` e não leva o token. O computador vê o pareamento em `GET /v1/pairings/` (`waiting`, `joined` com `device_name`, `phone_pub` e `proof`, ou `expired`) e desiste ou recusa com `DELETE /v1/pairings/`. No máximo 3 códigos abertos por computador e 30 entradas por hora por endereço; os erros novos são `bad_pairing` (`400`), `pair_expired` (`410`, vale também para código já usado) e `wrong_device` (`403`). O celular confere `proof2`: um servidor que trocasse a chave `D` não tem o `secret` e não consegue forjá-la. 5. Os dois derivam `shared = ECDH(D, P)` e, com `HKDF(shared, salt = secret, info = "botloft-mobile-1")`, duas chaves AES-256: `k_c2p` (computador para celular) e `k_p2c`. A chave de cada direção só sela naquela direção. O `secret` é apagado dos dois lados. -**Mensagem selada** (`seal`): `{v: 1, seq, ct}`. `ct` é AES-256-GCM com nonce de 12 bytes (`seq` em 8 bytes big-endian, 4 bytes zero) e dados autenticados `v ‖ direção ‖ device_id ‖ seq`. Quem recebe guarda o maior `seq` visto por direção e **recusa** qualquer um que não seja maior (repetição); o celular guarda o dele no IndexedDB. Trocar de chave é conectar de novo. Vetores de teste (chaves, nonces e textos de verdade) ficam num arquivo único (`docs/test-vectors/mobile.json`), gerado pelo Rust e lido pelo teste do PWA: os dois lados têm que concordar byte a byte. +**Mensagem selada** (`seal`): `{v: 1, seq, ct}`. `ct` é AES-256-GCM com nonce de 12 bytes (`seq` em 8 bytes big-endian, 4 bytes zero) e dados autenticados `v ‖ direção ‖ device_id ‖ seq`. O texto que o relay leva é `{"v":1,"seq":N,"ct":""}` e o `seq` do quadro tem de ser o mesmo. Quem recebe guarda o maior `seq` visto por direção e **recusa** qualquer um que não seja maior (repetição); o celular guarda o dele no IndexedDB, e o daemon guarda o dele (e o último que usou para selar, gravado antes de mandar) em `secrets\mobile.json`. Chaves públicas viajam em 65 bytes não comprimidos (`raw` do WebCrypto); o segredo do ECDH é a coordenada x (32 bytes); `ct` leva a etiqueta de 16 bytes no fim, como o WebCrypto devolve. Trocar de chave é conectar de novo. Vetores de teste (chaves, nonces e textos de verdade) ficam num arquivo único (`docs/test-vectors/mobile.json`), gerado pelo Rust e lido pelo teste do PWA: os dois lados têm que concordar byte a byte. **Dentro do selo,** JSON de uma linha, no máximo 16 KiB: @@ -2290,6 +2290,7 @@ O `card` é o que o celular mostra (28.5), não o `Approval` nem a `Question` in - `{t: "ack", from, upto}` (só do computador): leva da fila tudo de `from` até `upto`; - `{t: "presence", device, online}`: o outro lado entrou ou saiu (o celular sabe se o computador está no ar, e o computador, de cada celular); - `{t: "pairing", id}` (para o computador): um celular entrou no código `id` (28.3); + - `{t: "paired", pairing, device}` (para o computador): o celular recolheu o token. `device` é o id do celular, que só ele tinha; é daí que o computador passa a guardar as chaves para esse aparelho; - `{t: "revoked", device}` (para o computador): um celular foi desconectado, pelo computador ou por ele mesmo; - `{t: "wake"}` (28.8, ignorado até o push existir) e `{t: "ping"}` do servidor a cada 25 s (os proxies cortam conexões quietas, e a Cloudflare corta em 100 s). - Limite de 60 quadros por minuto por aparelho: o que passa disso volta como `{t: "error", reason: "rate_limited"}` e não é processado. @@ -2304,21 +2305,21 @@ Métodos novos do protocolo (11), todos só do dono: | Método | O que faz | |---|---| -| `mobile.status` | `{relay: "off" \| "connecting" \| "connected", phones: [{id, name, pairedAt, lastSeenAt?, online}], pending?}` | +| `mobile.status` | `{relay: "off" \| "connecting" \| "connected", phones: [{id, name, pairedAt, lastSeenAt?, online}], pending?: {pairId, expiresAt, joined?: {name, code}}}`; o `relay` é `off` sem celular nem código, `connecting` ou `connected` | | `mobile.pair_start` | começa a conexão (28.3); devolve `{pairId, url, expiresIn}`, e `url` é o que o QR leva. Exige a conta (`not_signed_in`) | | `mobile.pair_cancel {pairId}` | desiste | -| `mobile.pair_confirm {pairId, accept}` | o dono compara o código e aceita ou recusa | +| `mobile.pair_confirm {pairId, accept}` | o dono compara o código e aceita ou recusa. `conflict` se nenhum celular entrou nesse código (ou ele venceu) | | `mobile.revoke {phoneId}` | `DELETE /v1/devices/`, apaga as chaves e para de aceitar selos desse celular. Se o servidor não responde, apaga as chaves do mesmo jeito e tenta o servidor depois | -Notificações: `mobile.changed {status}` e `mobile.pair_request {pairId, name, code}` (o celular entrou no link; o app mostra o nome e o código). Erros com `reason` (20.8): os de 27.5 mais `pair_expired`, `bad_proof` e `unknown_phone`. As chaves, o token e o `seq` de cada celular ficam em `secrets\mobile.json` (ACL do usuário, 13), nunca no banco; a cópia de segurança (14.2) não leva segredos, então **restaurar num computador novo pede conectar o celular de novo**. O relay só está aberto com pelo menos um celular conectado ou uma conexão em andamento. +Notificações: `mobile.changed {status}` e `mobile.pair_request {pairId, name, code}` (o celular entrou no link; o app mostra o nome e o código). Erros com `reason` (20.8): os de 27.5 mais `wrong_device`, `bad_pairing` e `pair_expired`; um celular que não existe dá `not_found` em `mobile.revoke`. As chaves, o token e o `seq` de cada celular ficam em `secrets\mobile.json` (ACL do usuário, 13), nunca no banco; a cópia de segurança (14.2) não leva segredos, então **restaurar num computador novo pede conectar o celular de novo**. O relay só está aberto com pelo menos um celular conectado ou uma conexão em andamento. **O que vai ao celular.** Cada pedido aberto (10.1) e cada pergunta aberta (23.3) vira um `card`: o bot (nome e cor), a crew, a hora, e para o pedido, o tipo, o `summary`, a `explanation` e o texto do comando ou do plano; para a pergunta, o texto e as opções. Nada de `Bot`, de conversa, de caminho de pasta do dono além do que o próprio pedido mostra. **Regras de segurança do celular** (o daemon as aplica; o PWA só as mostra): -1. **Só permite o que mostra inteiro.** Um pedido cujo texto passa de 8 KiB (comando, plano, diff) vai ao celular **cortado e marcado**, com só "Negar" e "Abra no computador". Ninguém aprova no escuro. +1. **Só permite o que mostra inteiro.** Um pedido cujo texto passa de 8 KiB, ou que o daemon guardou cortado (a entrada não é um JSON inteiro), vai ao celular **cortado e marcado** (`cut`), com só "Negar" e "Abra no computador". Ninguém aprova no escuro. O texto de um comando é o `command`, o de um plano é o `plan`, o das outras ferramentas é a entrada como o daemon a guardou. 2. **Só "uma vez".** O celular não grava "permitir sempre" (`always`) e não edita a entrada (`input`). A regra durável é decisão de quem está no computador. -3. **O que muda o computador de forma durável fica no computador.** Sugestão de bot (10.2), rotina pedida por bot (20.12), acesso a outra crew (10.4), permissão de desktop (24.2), ferramentas conectadas novas (25.4) e tudo do navegador em mãos (21.10) aparecem no celular só como "Precisa de você no computador", com "Negar" quando couber. O celular nunca recebe `browser_ask_owner` nem nada que peça senha ou código (23.2). +3. **O que muda o computador de forma durável fica no computador.** O celular só permite `Bash`, `PowerShell`, `WebFetch`, `WebSearch`, `Read`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit` e o plano (`ExitPlanMode`); tudo o mais (sugestão de bot, 10.2; rotina pedida por bot, 20.12; acesso a outra crew, 10.4; permissão de desktop, 24.2; ferramentas conectadas, 25.4; o navegador em mãos, 21.10; e o que vier a existir) aparece como "Precisa de você no computador" (`atComputer`), com "Negar" quando couber. O daemon confere de novo quando chega a resposta: um "Permitir" para o que é só do computador, ou cortado, não faz nada e o celular recebe o cartão de novo. O celular nunca recebe `browser_ask_owner` nem nada que peça senha ou código (23.2). 4. **O mesmo caminho do app.** `approval.answer` chama a mesma lógica de `approvals.answer` (10.1), e `question.answer` e `question.dismiss` as de `questions.*` (23.3): `conflict` se já foi respondido, `expired` se venceu. Responder no celular fecha o cartão no computador e vice-versa, nos dois sentidos, na hora (`approval.closed`, `question.closed`). 5. **Rate limit** no daemon: 30 respostas por minuto por celular. 6. **Nunca se loga** o selo, o `card` nem a resposta em `info` (13). diff --git a/docs/test-vectors/mobile.json b/docs/test-vectors/mobile.json new file mode 100644 index 00000000..cf64cb6b --- /dev/null +++ b/docs/test-vectors/mobile.json @@ -0,0 +1,34 @@ +{ + "about": "Spec 28.3. Generated by botloftd (mobile::seal tests); the PWA must reproduce every value.", + "pair": { + "acceptProof": "NdSLo_JbvyUlNdeMbaMhFid9xszH2reUwcBqRVSKm1A", + "code": "191842", + "computerToPhoneKeyHex": "666e2e668f88e444d987d1991bf55cbf39554965e13bbc3109d7a1057e573d0e", + "daemonPrivateHex": "1111111111111111111111111111111111111111111111111111111111111111", + "daemonPublic": "BAIX5hfwtkQ5KCePlpmeaaI6TywVK99tbN9m5bgCgtTtGUp968uXcS0t2jyoWqh2Wlb0X8dYWZZS8ol8ZTBuV5Q", + "id": "AAAAAAAAAAAAAAAAAAAAAA", + "joinProof": "5Femzc3eHkfgzv_h7tgbXbU7Sd2UR-tO1nZanUaC-_Q", + "phonePrivateHex": "2222222222222222222222222222222222222222222222222222222222222222", + "phonePublic": "BNZak5d8qj0bCBhS_1ennkZfFmBXcwS66tUF3TpIWJzzUBheiVNy32Ih6joTdVfkc_3bZ1XwW9UHw8Uz_OnJEoU", + "phoneToComputerKeyHex": "e6c33702dc10953068013d194a333d5d2d856f527e3031490efa73433ff41c9d", + "secret": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM" + }, + "seal": [ + { + "body": "{\"v\":1,\"seq\":1,\"ct\":\"cuLmOforUDGKmBVaNROQi0ZZNZ0xghLcRGOp4Q\"}", + "device": "dev_phone", + "direction": "computerToPhone", + "keyHex": "666e2e668f88e444d987d1991bf55cbf39554965e13bbc3109d7a1057e573d0e", + "plain": "{\"t\":\"sync\"}", + "seq": 1 + }, + { + "body": "{\"v\":1,\"seq\":7,\"ct\":\"JXM35sFUqeFoSPoIZsuR7qO5vEzW-iCLFw\"}", + "device": "dev_phone", + "direction": "phoneToComputer", + "keyHex": "e6c33702dc10953068013d194a333d5d2d856f527e3031490efa73433ff41c9d", + "plain": "olá 🔥", + "seq": 7 + } + ] +} From 0e95908202ed9c033dfccccd4e914bb28314f785 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:38:57 -0300 Subject: [PATCH 4/8] feat(app): phone block in Settings with QR, code comparison and disconnect (spec 28, CE4) Settings > Backup gets a Phone block under the account: connect a phone by showing a QR drawn in the app, compare the six-digit code the phone shows, accept or refuse, list the connected phones and disconnect one. Texts in English, Portuguese and Spanish. --- app/package.json | 1 + app/pnpm-lock.yaml | 8 ++ app/src/features/account/BackupSettings.tsx | 2 + app/src/features/account/MobilePairing.tsx | 102 ++++++++++++++++ app/src/features/account/MobilePhones.tsx | 32 +++++ app/src/features/account/MobileSignedIn.tsx | 127 ++++++++++++++++++++ app/src/features/account/mobile.test.tsx | 86 +++++++++++++ app/src/features/account/useMobile.ts | 50 ++++++++ app/src/i18n/en/account.ts | 29 +++++ app/src/i18n/es/account.ts | 31 +++++ app/src/i18n/pt-BR/account.ts | 29 +++++ app/src/ui/QrCode.tsx | 53 ++++++++ app/src/ui/qrcode.test.tsx | 41 +++++++ docs/spec.md | 2 +- 14 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 app/src/features/account/MobilePairing.tsx create mode 100644 app/src/features/account/MobilePhones.tsx create mode 100644 app/src/features/account/MobileSignedIn.tsx create mode 100644 app/src/features/account/mobile.test.tsx create mode 100644 app/src/features/account/useMobile.ts create mode 100644 app/src/ui/QrCode.tsx create mode 100644 app/src/ui/qrcode.test.tsx diff --git a/app/package.json b/app/package.json index d085b2c6..e4058d00 100644 --- a/app/package.json +++ b/app/package.json @@ -28,6 +28,7 @@ "@tauri-apps/plugin-notification": "^2.5.0", "@tauri-apps/plugin-updater": "^2.13.0", "lucide-react": "^1.48.0", + "qrcode-generator": "2.0.4", "react": "^19.3.0", "react-dom": "^19.3.0", "react-markdown": "^10.1.0", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index bd82fe4a..f50e8c59 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: lucide-react: specifier: ^1.48.0 version: 1.48.0(react@19.3.0) + qrcode-generator: + specifier: 2.0.4 + version: 2.0.4 react: specifier: ^19.3.0 version: 19.3.0 @@ -1423,6 +1426,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode-generator@2.0.4: + resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==} + react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: @@ -2822,6 +2828,8 @@ snapshots: punycode@2.3.1: {} + qrcode-generator@2.0.4: {} + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 diff --git a/app/src/features/account/BackupSettings.tsx b/app/src/features/account/BackupSettings.tsx index 590dfa39..377caa3f 100644 --- a/app/src/features/account/BackupSettings.tsx +++ b/app/src/features/account/BackupSettings.tsx @@ -13,6 +13,7 @@ import { Callout } from "../../ui/Callout"; import { BackupRestore } from "./BackupRestore"; import { backupError } from "./backupError"; import { CloudAccount } from "./CloudAccount"; +import { MobilePhones } from "./MobilePhones"; import { PassphraseFields, usePassphrase } from "./PassphraseFields"; import { Section } from "./settingsParts"; @@ -26,6 +27,7 @@ export function BackupSettings() {
+

{b.exportIntro}

diff --git a/app/src/features/account/MobilePairing.tsx b/app/src/features/account/MobilePairing.tsx new file mode 100644 index 00000000..eb7d8f92 --- /dev/null +++ b/app/src/features/account/MobilePairing.tsx @@ -0,0 +1,102 @@ +// A code waiting to be scanned (spec 28.3, 28.7): the picture, the time left, +// and, once a phone scanned it, the name and the two codes to compare. + +import { useEffect, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import type { MobilePairStarted, MobilePairing as Pending } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { QrCode } from "../../ui/QrCode"; +import { backupError } from "./backupError"; +import { clock, useSecondsLeft } from "./useMobile"; + +/** "482913" as "482 913", easier to compare by eye. */ +const spaced = (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`; + +export function MobilePairing({ + pending, + started, + done, + lapsed, + refresh, +}: { + pending: Pending; + started: MobilePairStarted | null; + /** The owner is done with this code: refused, cancelled or connected. */ + done(): void; + /** It ran out. */ + lapsed(): void; + refresh(): Promise; +}) { + const m = useT().account.mobile; + const reasons = useT().account.cloud.reasons; + const api = useApi(); + const left = useSecondsLeft(pending.expiresAt); + const [accepting, setAccepting] = useState(false); + const [problem, setProblem] = useState(null); + + // The time is up: the daemon forgets the code, and this one goes away. + const over = useRef(false); + useEffect(() => { + if (left === 0 && !accepting && !over.current) { + over.current = true; + lapsed(); + void refresh(); + } + }, [left, accepting, lapsed, refresh]); + + const answer = async (accept: boolean) => { + setProblem(null); + setAccepting(accept); + try { + await api.call("mobile.pair_confirm", { pairId: pending.pairId, accept }); + if (!accept) { + done(); + } + } catch (failure) { + setAccepting(false); + setProblem(backupError(failure, reasons)); + } + }; + + const cancel = async () => { + done(); + await api.call("mobile.pair_cancel", { pairId: pending.pairId }).catch(() => null); + await refresh(); + }; + + return ( +
+ {pending.joined ? ( + <> +

{m.wants(pending.joined.name)}

+

{spaced(pending.joined.code)}

+

{m.compare(spaced(pending.joined.code))}

+
+ + +
+ + ) : ( + <> + {started && } +

{m.scan}

+

{m.expires(clock(left))}

+ + + )} + {problem && ( + + {problem} + + )} +
+ ); +} diff --git a/app/src/features/account/MobilePhones.tsx b/app/src/features/account/MobilePhones.tsx new file mode 100644 index 00000000..423a7967 --- /dev/null +++ b/app/src/features/account/MobilePhones.tsx @@ -0,0 +1,32 @@ +// Settings, "Backup", the phone block (spec 28.7): connect a phone by showing +// a code for it to scan, compare the two codes, and cut a phone off. + +import { Smartphone } from "lucide-react"; +import { useT } from "../../i18n"; +import { MobileSignedIn } from "./MobileSignedIn"; +import { Section } from "./settingsParts"; +import { useCloud } from "./useCloud"; +import { useMobile } from "./useMobile"; + +export function MobilePhones() { + const m = useT().account.mobile; + const { status: cloud } = useCloud(); + const { status, refresh } = useMobile(); + // No cloud set up, nothing to connect a phone to. + if (!cloud?.url) { + return null; + } + return ( +
+

+ + {m.intro} +

+ {!cloud.signedIn ? ( +

{m.needAccount}

+ ) : ( + status && + )} +
+ ); +} diff --git a/app/src/features/account/MobileSignedIn.tsx b/app/src/features/account/MobileSignedIn.tsx new file mode 100644 index 00000000..6e3ee043 --- /dev/null +++ b/app/src/features/account/MobileSignedIn.tsx @@ -0,0 +1,127 @@ +// The phone block once the computer is in the account (spec 28.7): the phones +// that are connected, and the way to connect another. + +import { useState } from "react"; +import { useT } from "../../i18n"; +import { when } from "../../lib/format"; +import type { MobilePairStarted, MobilePhone, MobileStatus } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Confirm } from "../../ui/Confirm"; +import { backupError } from "./backupError"; +import { MobilePairing } from "./MobilePairing"; + +export function MobileSignedIn({ + status, + refresh, +}: { + status: MobileStatus; + refresh(): Promise; +}) { + const m = useT().account.mobile; + const reasons = useT().account.cloud.reasons; + const api = useApi(); + const [started, setStarted] = useState(null); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(null); + const [lapsed, setLapsed] = useState(false); + const [leaving, setLeaving] = useState(null); + + const connect = async () => { + setBusy(true); + setProblem(null); + setLapsed(false); + try { + setStarted(await api.call("mobile.pair_start")); + } catch (failure) { + setProblem(backupError(failure, reasons)); + } + setBusy(false); + }; + + const disconnect = async (phone: MobilePhone) => { + try { + await api.call("mobile.revoke", { phoneId: phone.id }); + } catch (failure) { + setProblem(backupError(failure, reasons)); + } + await refresh(); + }; + + // A code on screen: this window's own, or one left from before it opened. + const pending = status.pending; + const showing = pending && (!started || started.pairId === pending.pairId); + + return ( +
+ {status.phones.length === 0 ? ( +

{m.none}

+ ) : ( +
+

{m.phones}

+
    + {status.phones.map((phone) => ( +
  • +
    +

    {phone.name}

    +

    + {phone.online + ? m.online + : phone.lastSeenAt !== undefined + ? m.seen(when(phone.lastSeenAt)) + : m.neverSeen} +

    +
    + +
  • + ))} +
+

{m.warning}

+
+ )} + + {showing && pending ? ( + setStarted(null)} + lapsed={() => { + setStarted(null); + setLapsed(true); + }} + refresh={refresh} + /> + ) : ( +
+ {lapsed && ( +

+ {m.expired} +

+ )} + +
+ )} + + {problem && ( + + {problem} + + )} + {leaving && ( + disconnect(leaving)} + onClose={() => setLeaving(null)} + > + {m.disconnectText} + + )} +
+ ); +} diff --git a/app/src/features/account/mobile.test.tsx b/app/src/features/account/mobile.test.tsx new file mode 100644 index 00000000..6ada8d87 --- /dev/null +++ b/app/src/features/account/mobile.test.tsx @@ -0,0 +1,86 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { openFakeCloudLink } from "../../lib/fakeCloud"; +import { FakeHost } from "../../lib/fakeHost"; +import { joinFakePhone } from "../../lib/fakeMobile"; +import { crewOpened, renderApp } from "../../test/app"; + +afterEach(cleanup); + +async function openBackup() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + fake.addBot(ops.id, "Scout"); + renderApp(fake, new FakeHost()); + await crewOpened("Ops"); + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" })); + return { fake, dialog }; +} + +/** Signs in through the screen, the way the owner does. */ +async function signIn(fake: FakeBotloft, dialog: HTMLElement) { + fireEvent.change(await within(dialog).findByLabelText("Your e-mail"), { + target: { value: "ana@exemplo.com" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Send me the link" })); + await within(dialog).findByText(/We sent a link to ana@exemplo.com/); + act(() => openFakeCloudLink(fake)); + await within(dialog).findByText("Signed in as ana@exemplo.com"); +} + +describe("the phone block in Settings", () => { + test("waits for the account, then shows a code, compares it and connects the phone", async () => { + const { fake, dialog } = await openBackup(); + await within(dialog).findByText("Sign in to the account above to use the phone."); + expect(within(dialog).queryByRole("button", { name: "Connect a phone" })).toBeNull(); + await signIn(fake, dialog); + + expect(await within(dialog).findByText("No phone connected yet.")).toBeTruthy(); + fireEvent.click(within(dialog).getByRole("button", { name: "Connect a phone" })); + // The code to scan is a picture, with the time it still works. + expect( + await within(dialog).findByRole("img", { name: "Code to scan with your phone" }), + ).toBeTruthy(); + expect(within(dialog).getByText(/The code works for 5:00 more/)).toBeTruthy(); + + // A phone scans it: its name, and the code to compare with the phone's screen. + act(() => joinFakePhone(fake, "Celular da Ana", "482913")); + expect(await within(dialog).findByText("Celular da Ana wants to connect.")).toBeTruthy(); + expect(within(dialog).getByText("482 913", { selector: "p.font-mono" })).toBeTruthy(); + expect(within(dialog).queryByRole("img", { name: "Code to scan with your phone" })).toBeNull(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Connect" })); + const list = await within(dialog).findByRole("list"); + expect(within(list).getByText("Celular da Ana")).toBeTruthy(); + expect(within(list).getByText("Online now")).toBeTruthy(); + expect(within(dialog).getByRole("button", { name: "Connect a phone" })).toBeTruthy(); + + // Disconnecting asks first, and the phone is gone. + fireEvent.click(within(list).getByRole("button", { name: "Disconnect" })); + const confirm = await screen.findByRole("dialog", { name: "Disconnect Celular da Ana?" }); + fireEvent.click(within(confirm).getByRole("button", { name: "Disconnect" })); + await within(dialog).findByText("No phone connected yet."); + expect(fake.calls.some((call) => call.method === "mobile.revoke")).toBe(true); + }); + + test("cancelling or refusing leaves no phone and brings the button back", async () => { + const { fake, dialog } = await openBackup(); + await signIn(fake, dialog); + fireEvent.click(await within(dialog).findByRole("button", { name: "Connect a phone" })); + await within(dialog).findByRole("img", { name: "Code to scan with your phone" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await within(dialog).findByRole("button", { name: "Connect a phone" }); + expect((await fake.call("mobile.status")).pending).toBeUndefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Connect a phone" })); + await within(dialog).findByRole("img", { name: "Code to scan with your phone" }); + act(() => joinFakePhone(fake)); + fireEvent.click(await within(dialog).findByRole("button", { name: "Not this one" })); + await within(dialog).findByRole("button", { name: "Connect a phone" }); + await waitFor(async () => expect((await fake.call("mobile.status")).phones).toEqual([])); + }); +}); diff --git a/app/src/features/account/useMobile.ts b/app/src/features/account/useMobile.ts new file mode 100644 index 00000000..ea513764 --- /dev/null +++ b/app/src/features/account/useMobile.ts @@ -0,0 +1,50 @@ +// What the Settings screen knows of the phones (spec 28.5): their status, +// kept current by the daemon's notifications, and the time left on a code. + +import { useCallback, useEffect, useState } from "react"; +import type { MobileStatus } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; + +export function useMobile() { + const api = useApi(); + const [status, setStatus] = useState(null); + + const refresh = useCallback(async () => { + try { + setStatus(await api.call("mobile.status")); + } catch { + // The screen keeps what it had. + } + }, [api]); + + useEffect(() => { + void refresh(); + return api.subscribe((event) => { + if (event.name === "mobile.changed") { + setStatus(event.params); + } + }); + }, [api, refresh]); + + return { status, refresh }; +} + +/** Seconds left until `expiresAt`, counted down each second; 0 when it is past. */ +export function useSecondsLeft(expiresAt: number | undefined): number { + const [seconds, setSeconds] = useState(() => secondsUntil(expiresAt)); + useEffect(() => { + setSeconds(secondsUntil(expiresAt)); + const timer = setInterval(() => setSeconds(secondsUntil(expiresAt)), 1000); + return () => clearInterval(timer); + }, [expiresAt]); + return seconds; +} + +function secondsUntil(at: number | undefined): number { + return at === undefined ? 0 : Math.max(0, Math.ceil((at - Date.now()) / 1000)); +} + +/** "4:32". */ +export function clock(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 5d8e67f3..26cb9996 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -175,6 +175,35 @@ export const account = { "The passphrase is gone from this computer, so automatic copies stopped. Turn them on again.", } as Record, }, + mobile: { + title: "Phone", + intro: + "Approve your bots' requests and answer their questions from anywhere. Chats and files stay only on this computer.", + needAccount: "Sign in to the account above to use the phone.", + warning: + "Whoever holds a connected phone, unlocked, can allow requests until you disconnect it.", + connect: "Connect a phone", + starting: "Preparing…", + qrLabel: "Code to scan with your phone", + scan: "Open your phone's camera and point it at this code.", + expires: (time: string) => `The code works for ${time} more.`, + expired: "The code ran out. Ask for a new one.", + cancel: "Cancel", + wants: (name: string) => `${name} wants to connect.`, + compare: (code: string) => `Check that the phone shows ${code} too, and only then connect it.`, + accept: "Connect", + refuse: "Not this one", + connecting: "Connecting…", + phones: "Connected phones", + none: "No phone connected yet.", + online: "Online now", + seen: (when: string) => `Seen ${when}`, + neverSeen: "Not seen yet", + disconnect: "Disconnect", + disconnectTitle: (name: string) => `Disconnect ${name}?`, + disconnectText: "The phone loses access right away. To use it again, connect it anew.", + failed: "Something went wrong", + }, settings: { title: "Settings", background: "In the background", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index af957206..7c376c25 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -177,6 +177,37 @@ export const account: Messages["account"] = { "La contraseña ya no está en este equipo, así que las copias automáticas se detuvieron. Actívalas de nuevo.", } as Record, }, + mobile: { + title: "Móvil", + intro: + "Aprueba las solicitudes de tus bots y responde a sus preguntas desde cualquier lugar. Las conversaciones y los archivos se quedan solo en este ordenador.", + needAccount: "Inicia sesión en la cuenta de arriba para usar el móvil.", + warning: + "Quien tenga un móvil conectado y desbloqueado puede permitir solicitudes hasta que lo desconectes.", + connect: "Conectar un móvil", + starting: "Preparando…", + qrLabel: "Código para escanear con el móvil", + scan: "Abre la cámara del móvil y apúntala a este código.", + expires: (time: string) => `El código vale ${time} más.`, + expired: "El código venció. Pide otro.", + cancel: "Cancelar", + wants: (name: string) => `${name} quiere conectarse.`, + compare: (code: string) => + `Comprueba que el móvil muestre ${code} también, y solo entonces conéctalo.`, + accept: "Conectar", + refuse: "No es este", + connecting: "Conectando…", + phones: "Móviles conectados", + none: "Todavía no hay ningún móvil conectado.", + online: "En línea ahora", + seen: (when: string) => `Visto ${when}`, + neverSeen: "Aún no visto", + disconnect: "Desconectar", + disconnectTitle: (name: string) => `¿Desconectar ${name}?`, + disconnectText: + "El móvil pierde el acceso al instante. Para usarlo de nuevo, conéctalo otra vez.", + failed: "Algo salió mal", + }, settings: { title: "Configuración", background: "En segundo plano", diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 66c01026..501b8663 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -177,6 +177,35 @@ export const account: Messages["account"] = { "A senha sumiu deste computador, então as cópias automáticas pararam. Ligue de novo.", } as Record, }, + mobile: { + title: "Celular", + intro: + "Aprove os pedidos dos seus bots e responda às perguntas deles de qualquer lugar. As conversas e os arquivos ficam só neste computador.", + needAccount: "Entre na conta acima para usar o celular.", + warning: + "Quem estiver com um celular conectado e desbloqueado pode permitir pedidos até você desconectá-lo.", + connect: "Conectar um celular", + starting: "Preparando…", + qrLabel: "Código para ler com o celular", + scan: "Abra a câmera do celular e aponte para este código.", + expires: (time: string) => `O código vale por mais ${time}.`, + expired: "O código venceu. Peça outro.", + cancel: "Cancelar", + wants: (name: string) => `${name} quer se conectar.`, + compare: (code: string) => `Confira se o celular mostra ${code} também, e só então conecte.`, + accept: "Conectar", + refuse: "Não é este", + connecting: "Conectando…", + phones: "Celulares conectados", + none: "Nenhum celular conectado ainda.", + online: "Online agora", + seen: (when: string) => `Visto ${when}`, + neverSeen: "Ainda não visto", + disconnect: "Desconectar", + disconnectTitle: (name: string) => `Desconectar ${name}?`, + disconnectText: "O celular perde o acesso na hora. Para usar de novo, conecte outra vez.", + failed: "Algo deu errado", + }, settings: { title: "Configurações", background: "Em segundo plano", diff --git a/app/src/ui/QrCode.tsx b/app/src/ui/QrCode.tsx new file mode 100644 index 00000000..40861f74 --- /dev/null +++ b/app/src/ui/QrCode.tsx @@ -0,0 +1,53 @@ +// A QR code drawn as an image, made here: nothing about what it holds leaves +// the computer. Black on white whatever the theme, because scanners need it. + +import qrcode from "qrcode-generator"; +import { useMemo } from "react"; + +/** One path for every dark square, row by row. */ +function draw(text: string): { path: string; size: number } { + const code = qrcode(0, "M"); + code.addData(text); + code.make(); + const count = code.getModuleCount(); + let path = ""; + for (let row = 0; row < count; row++) { + let column = 0; + while (column < count) { + if (!code.isDark(row, column)) { + column++; + continue; + } + const start = column; + while (column < count && code.isDark(row, column)) { + column++; + } + path += `M${start} ${row}h${column - start}v1h-${column - start}z`; + } + } + return { path, size: count }; +} + +/** `label` says what the code is, for whoever cannot see it. */ +export function QrCode({ text, label }: { text: string; label: string }) { + const { path, size } = useMemo(() => draw(text), [text]); + const margin = 4; + return ( + + + + + ); +} diff --git a/app/src/ui/qrcode.test.tsx b/app/src/ui/qrcode.test.tsx new file mode 100644 index 00000000..e0de42ec --- /dev/null +++ b/app/src/ui/qrcode.test.tsx @@ -0,0 +1,41 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import qrcode from "qrcode-generator"; +import { afterEach, expect, test } from "vitest"; +import { QrCode } from "./QrCode"; + +afterEach(cleanup); + +/** The dark squares a path draws, as "row,column". */ +function squares(path: string): Set { + const dark = new Set(); + for (const [, x, y, width] of path.matchAll(/M(\d+) (\d+)h(\d+)v1h-\d+z/g)) { + for (let column = Number(x); column < Number(x) + Number(width); column++) { + dark.add(`${y},${column}`); + } + } + return dark; +} + +test("the picture holds exactly the squares of the code for the address", () => { + const url = `https://botloft.example/m#p=${"A".repeat(22)}&s=${"b".repeat(43)}&d=${"C".repeat(87)}`; + render(); + const image = screen.getByRole("img", { name: "Code" }); + const drawn = squares(image.querySelector("path")?.getAttribute("d") ?? ""); + + const code = qrcode(0, "M"); + code.addData(url); + code.make(); + const expected = new Set(); + for (let row = 0; row < code.getModuleCount(); row++) { + for (let column = 0; column < code.getModuleCount(); column++) { + if (code.isDark(row, column)) { + expected.add(`${row},${column}`); + } + } + } + expect(drawn.size).toBeGreaterThan(500); + expect(drawn).toEqual(expected); + // Black on white and a quiet margin, whatever the theme. + expect(image.querySelector("rect")?.getAttribute("fill")).toBe("#fff"); + expect(image.getAttribute("viewBox")?.startsWith("-4 -4")).toBe(true); +}); diff --git a/docs/spec.md b/docs/spec.md index ce902cce..e005a208 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2333,7 +2333,7 @@ Notificações: `mobile.changed {status}` e `mobile.pair_request {pairId, name, ### 28.7 Telas -**App do computador:** Configurações, bloco **Celular** (abaixo de Conta, 27.6). Sem conta: "Entre na conta para usar o celular", com o botão que leva ao bloco Conta. Com conta e sem celular: uma frase do que o celular faz e não faz ("Aprove pedidos e responda perguntas dos bots de qualquer lugar. As conversas ficam só no computador."), e "Conectar um celular". Conectando: o QR, "Abra a câmera do celular e aponte para o código", o tempo que falta e "Cancelar"; quando o celular entra, "O celular de Ana quer entrar. Confira que o código é 482 913 nos dois", com "Conectar" e "Recusar". Com celulares: cada um com o nome, quando entrou, "online agora" ou "visto há 2 h", e **"Desconectar"** com a confirmação que diz que o celular perde o acesso na hora. Aviso fixo, em palavras do dia a dia, de que quem tiver o celular desbloqueado pode aprovar pedidos (28.6). Textos nos três idiomas, sem jargão (15.6): "celular", "conectar", "código"; nunca "relay", "pareamento", "token", "chave" nem "daemon". +**App do computador:** Configurações, bloco **Celular** (abaixo de Conta, 27.6). Sem cloud configurada (`[cloud] url` vazio) o bloco não aparece. Sem conta: "Entre na conta acima para usar o celular" (o bloco Conta fica logo acima). Com conta e sem celular: uma frase do que o celular faz e não faz ("Aprove pedidos e responda perguntas dos bots de qualquer lugar. As conversas ficam só no computador."), e "Conectar um celular". Conectando: o QR, "Abra a câmera do celular e aponte para o código", o tempo que falta e "Cancelar"; quando o celular entra, "O celular de Ana quer entrar. Confira que o código é 482 913 nos dois", com "Conectar" e "Recusar". Com celulares: cada um com o nome, quando entrou, "online agora" ou "visto há 2 h", e **"Desconectar"** com a confirmação que diz que o celular perde o acesso na hora. Sob a lista de celulares, um aviso fixo, em palavras do dia a dia, de que quem tiver um celular conectado e desbloqueado pode permitir pedidos até ser desconectado (28.6). O QR é desenhado no app (`qrcode-generator`, MIT), preto sobre branco com margem em qualquer tema, e vale o que o código diz: o tempo que falta conta de 5:00 até 0:00, e depois o app volta ao botão e diz que o código venceu. Textos nos três idiomas, sem jargão (15.6): "celular", "conectar", "código"; nunca "relay", "pareamento", "token", "chave" nem "daemon". **PWA (`/m`):** a mesma identidade visual do app (cores, fonte, mascote, tema do aparelho), uma coluna, feito para o polegar, em `pt-BR`, `en` e `es` pelo idioma do aparelho. - **Caixa:** a lista do que espera o dono, pedidos e perguntas misturados, o mais velho primeiro (o que vence antes), cada linha com o rosto do bot, o nome, a crew e a hora; vazio mostra o mascote e "Nada esperando você". Se o computador está fora do ar: "O computador está desligado ou sem internet. Os pedidos continuam esperando lá." From df7ad289a543ccf43d706ba2a7ee5c53b5b972d8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:23:40 -0300 Subject: [PATCH 5/8] feat(phone): the phone's page, served by the account server (spec 28, CE5) A PWA in app/src/phone: WebCrypto sealing checked against the computer's test vectors, the relay client (counters saved first, replay and tampering refused, reconnection, revocation), QR joining with a code to compare, an inbox of requests and questions with Allow/Deny only for what is shown whole, three languages, manifest and service worker. botloft-cloud serves it from phone_dir at /m under a strict policy; the Docker image builds it. Checked by hand with a real browser against a real daemon and server (cargo test -p botloftd --test mobile_live -- --ignored). --- .gitignore | 1 + app/package.json | 1 + app/public-phone/apple-touch-icon.png | Bin 0 -> 16534 bytes app/public-phone/icon-192.png | Bin 0 -> 18083 bytes app/public-phone/icon-512.png | Bin 0 -> 47113 bytes app/public-phone/manifest.webmanifest | 14 + app/public-phone/sw.js | 53 +++ app/src/features/account/mobile.test.tsx | 2 +- app/src/i18n/en/index.ts | 2 + app/src/i18n/en/phone.ts | 78 ++++ app/src/i18n/es/index.ts | 2 + app/src/i18n/es/phone.ts | 79 ++++ app/src/i18n/pt-BR/index.ts | 2 + app/src/i18n/pt-BR/phone.ts | 78 ++++ app/src/phone/ApprovalView.tsx | 110 ++++++ app/src/phone/PairScreen.tsx | 98 +++++ app/src/phone/PhoneApp.tsx | 150 +++++++ app/src/phone/QuestionView.tsx | 92 +++++ app/src/phone/client.test.ts | 393 +++++++++++++++++++ app/src/phone/client.ts | 358 +++++++++++++++++ app/src/phone/crypto.test.ts | 131 +++++++ app/src/phone/crypto.ts | 207 ++++++++++ app/src/phone/fake.ts | 59 +++ app/src/phone/index.html | 17 + app/src/phone/main.tsx | 113 ++++++ app/src/phone/pair.ts | 154 ++++++++ app/src/phone/parts.tsx | 63 +++ app/src/phone/phone.css | 21 + app/src/phone/phone.test.tsx | 210 ++++++++++ app/src/phone/store.ts | 103 +++++ app/vite.config.ts | 14 + crates/botloft-cloud/src/app.rs | 5 +- crates/botloft-cloud/src/config.rs | 5 + crates/botloft-cloud/src/lib.rs | 1 + crates/botloft-cloud/src/phone.rs | 141 +++++++ crates/botloft-cloud/tests/phone.rs | 128 ++++++ crates/botloftd/tests/common/cloud_server.rs | 10 + crates/botloftd/tests/common/mod.rs | 8 +- crates/botloftd/tests/mobile_live.rs | 170 ++++++++ deploy/cloud/Dockerfile | 11 + deploy/cloud/Dockerfile.dockerignore | 1 + deploy/cloud/cloud.example.toml | 5 + deploy/cloud/docker-compose.coolify.yml | 1 + docs/cloud-deploy.md | 2 +- docs/spec.md | 8 +- 45 files changed, 3094 insertions(+), 7 deletions(-) create mode 100644 app/public-phone/apple-touch-icon.png create mode 100644 app/public-phone/icon-192.png create mode 100644 app/public-phone/icon-512.png create mode 100644 app/public-phone/manifest.webmanifest create mode 100644 app/public-phone/sw.js create mode 100644 app/src/i18n/en/phone.ts create mode 100644 app/src/i18n/es/phone.ts create mode 100644 app/src/i18n/pt-BR/phone.ts create mode 100644 app/src/phone/ApprovalView.tsx create mode 100644 app/src/phone/PairScreen.tsx create mode 100644 app/src/phone/PhoneApp.tsx create mode 100644 app/src/phone/QuestionView.tsx create mode 100644 app/src/phone/client.test.ts create mode 100644 app/src/phone/client.ts create mode 100644 app/src/phone/crypto.test.ts create mode 100644 app/src/phone/crypto.ts create mode 100644 app/src/phone/fake.ts create mode 100644 app/src/phone/index.html create mode 100644 app/src/phone/main.tsx create mode 100644 app/src/phone/pair.ts create mode 100644 app/src/phone/parts.tsx create mode 100644 app/src/phone/phone.css create mode 100644 app/src/phone/phone.test.tsx create mode 100644 app/src/phone/store.ts create mode 100644 crates/botloft-cloud/src/phone.rs create mode 100644 crates/botloft-cloud/tests/phone.rs create mode 100644 crates/botloftd/tests/mobile_live.rs diff --git a/.gitignore b/.gitignore index c9e6b038..b7d65008 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Thumbs.db # Setup window build (spec 15.7) /app/dist-setup/ +/app/dist-phone/ # Design sync (claude.ai/design): staged scripts, build output, machine state /.ds-sync/ diff --git a/app/package.json b/app/package.json index e4058d00..392e4d2f 100644 --- a/app/package.json +++ b/app/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "vite", "build": "pnpm typecheck && vite build", + "build:phone": "vite build --mode phone", "preview": "vite preview", "tauri": "tauri", "bundle": "node scripts/sidecar.mjs && tauri build --config src-tauri/tauri.bundle.conf.json", diff --git a/app/public-phone/apple-touch-icon.png b/app/public-phone/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d392523b85b551ff1f34ebfd9e82dcbf5c5c1503 GIT binary patch literal 16534 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 IM6N<$f}GX>`~Uy| literal 0 HcmV?d00001 diff --git a/app/public-phone/icon-192.png b/app/public-phone/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..178b781abff6f76ee2874df0ea0d7ba270e0318f GIT binary patch literal 18083 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 literal 0 HcmV?d00001 diff --git a/app/public-phone/icon-512.png b/app/public-phone/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0c479f3afd42569da468e28a3fca0566583984bf GIT binary patch literal 47113 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 g{-2$f4zEKTA?$~nWw|JA1pq!Cs_Cg#s#r( self.skipWaiting()); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((names) => + Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name))), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + if ( + request.method !== "GET" || + url.origin !== self.location.origin || + !url.pathname.startsWith("/m/") + ) { + return; + } + // The built files have a hash in their names: once kept, they never change. + if (url.pathname.startsWith("/m/assets/")) { + event.respondWith( + caches.match(request).then( + (kept) => + kept || + fetch(request).then((fresh) => { + const copy = fresh.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + return fresh; + }), + ), + ); + return; + } + // The page itself comes from the server when it can, and from here when not. + event.respondWith( + fetch(request) + .then((fresh) => { + const copy = fresh.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + return fresh; + }) + .catch(() => caches.match(request).then((kept) => kept || caches.match("/m/"))), + ); +}); diff --git a/app/src/features/account/mobile.test.tsx b/app/src/features/account/mobile.test.tsx index 6ada8d87..05a0b0f9 100644 --- a/app/src/features/account/mobile.test.tsx +++ b/app/src/features/account/mobile.test.tsx @@ -45,7 +45,7 @@ describe("the phone block in Settings", () => { expect( await within(dialog).findByRole("img", { name: "Code to scan with your phone" }), ).toBeTruthy(); - expect(within(dialog).getByText(/The code works for 5:00 more/)).toBeTruthy(); + expect(within(dialog).getByText(/The code works for [45]:[0-5][0-9] more/)).toBeTruthy(); // A phone scans it: its name, and the code to compare with the phone's screen. act(() => joinFakePhone(fake, "Celular da Ana", "482913")); diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts index fc900f23..570deaa9 100644 --- a/app/src/i18n/en/index.ts +++ b/app/src/i18n/en/index.ts @@ -13,6 +13,7 @@ import { desktop } from "./desktop"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { phone } from "./phone"; import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; @@ -39,6 +40,7 @@ export const en = { desktop, files, messages, + phone, questions, routines, screens, diff --git a/app/src/i18n/en/phone.ts b/app/src/i18n/en/phone.ts new file mode 100644 index 00000000..0d76eba6 --- /dev/null +++ b/app/src/i18n/en/phone.ts @@ -0,0 +1,78 @@ +// The phone's page (spec 28.7): connecting it, and approving and answering +// from it. What the bots ask is data and never passes through here. + +export const phone = { + pair: { + title: "Connect this phone", + intro: + "This phone will approve your bots' requests and answer their questions, from anywhere. Chats and files stay on your computer.", + nameLabel: "Name of this phone", + connect: "Connect", + connecting: "Connecting…", + compare: (code: string) => + `Check that your computer shows ${code} too. Then press Connect on the computer.`, + waiting: "Waiting for you to accept on the computer…", + notALink: + "To connect a phone, point its camera at the code on your computer: Settings, Backup, Phone.", + expired: "The code ran out or was already used. Ask for a new one on the computer.", + offline: "Could not reach Botloft. Check the connection and try again.", + failed: "Could not connect. Ask for a new code on the computer.", + names: { iphone: "iPhone", ipad: "iPad", android: "Android phone", other: "Phone" }, + }, + inbox: { + title: "Waiting for you", + empty: "Nothing is waiting for you", + emptyBody: "When a bot needs you, it shows up here.", + loading: "Checking what is waiting…", + computerOff: "Your computer is off or has no internet. Requests keep waiting there.", + noConnection: "No connection. Trying again…", + thisPhone: "This phone", + inCrew: (crew: string) => `in ${crew}`, + }, + approval: { + asks: (bot: string) => `${bot} asks to`, + explanationBy: (bot: string) => `${bot} wrote this`, + noExplanation: (bot: string) => + `${bot} did not say what this is for. Consider denying it and asking.`, + showAll: "Show the whole request", + hideAll: "Hide it", + allow: "Allow", + deny: "Deny", + sending: "Sending…", + noteLabel: "Note for the bot (optional)", + cut: "Too long to read on a phone. You can deny it here, or answer it at the computer.", + atComputer: "This one needs you at the computer. Here you can only deny it.", + failed: "Not sent. Check the connection and try again.", + ended: { allowed: "Allowed", denied: "Denied", expired: "Ran out" }, + }, + question: { + asks: (bot: string) => `${bot} asks`, + pick: "Pick an answer", + answerLabel: "Your answer", + placeholder: "Write your answer", + send: "Answer", + sending: "Sending…", + dismiss: "Dismiss", + dismissHint: (bot: string) => + `Closes the question without telling ${bot}. To let it know, answer instead.`, + failed: "Not sent. Check the connection and try again.", + ended: { answered: "You answered", dismissed: "Dismissed" }, + }, + settings: { + title: "This phone", + back: "Back", + name: "Name", + disconnect: "Disconnect this phone", + disconnectTitle: "Disconnect this phone?", + disconnectText: + "It stops receiving requests. To use it again, connect it anew on your computer.", + cancel: "Cancel", + install: "To get notices, add Botloft to your Home Screen: tap Share, then Add to Home Screen.", + }, + cut: { + revokedTitle: "This phone was disconnected", + leftTitle: "Phone disconnected", + body: "To use it again, connect it from Botloft on your computer: Settings, Backup, Phone.", + ok: "OK", + }, +}; diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts index eb5c57b1..f566a000 100644 --- a/app/src/i18n/es/index.ts +++ b/app/src/i18n/es/index.ts @@ -14,6 +14,7 @@ import { desktop } from "./desktop"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { phone } from "./phone"; import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; @@ -40,6 +41,7 @@ export const es: Messages = { desktop, files, messages, + phone, questions, routines, screens, diff --git a/app/src/i18n/es/phone.ts b/app/src/i18n/es/phone.ts new file mode 100644 index 00000000..2a4a4b0e --- /dev/null +++ b/app/src/i18n/es/phone.ts @@ -0,0 +1,79 @@ +// La página del móvil (spec 28.7). + +import type { Messages } from "../en"; + +export const phone: Messages["phone"] = { + pair: { + title: "Conectar este móvil", + intro: + "Este móvil aprobará las solicitudes de tus bots y responderá a sus preguntas, desde cualquier lugar. Las conversaciones y los archivos se quedan en tu ordenador.", + nameLabel: "Nombre de este móvil", + connect: "Conectar", + connecting: "Conectando…", + compare: (code: string) => + `Comprueba que el ordenador muestre ${code} también. Luego pulsa Conectar en el ordenador.`, + waiting: "Esperando a que aceptes en el ordenador…", + notALink: + "Para conectar un móvil, apunta su cámara al código que aparece en el ordenador: Ajustes, Copia de seguridad, Móvil.", + expired: "El código venció o ya se usó. Pide uno nuevo en el ordenador.", + offline: "No se pudo hablar con Botloft. Comprueba la conexión e inténtalo de nuevo.", + failed: "No se pudo conectar. Pide un código nuevo en el ordenador.", + names: { iphone: "iPhone", ipad: "iPad", android: "Móvil Android", other: "Móvil" }, + }, + inbox: { + title: "Esperándote", + empty: "Nada te está esperando", + emptyBody: "Cuando un bot te necesite, aparecerá aquí.", + loading: "Viendo qué está esperando…", + computerOff: "El ordenador está apagado o sin internet. Las solicitudes siguen esperando allí.", + noConnection: "Sin conexión. Intentando de nuevo…", + thisPhone: "Este móvil", + inCrew: (crew: string) => `en ${crew}`, + }, + approval: { + asks: (bot: string) => `${bot} pide`, + explanationBy: (bot: string) => `${bot} escribió esto`, + noExplanation: (bot: string) => `${bot} no dijo para qué sirve. Conviene denegar y preguntar.`, + showAll: "Mostrar la solicitud entera", + hideAll: "Ocultar", + allow: "Permitir", + deny: "Denegar", + sending: "Enviando…", + noteLabel: "Nota para el bot (opcional)", + cut: "Demasiado larga para leerla en el móvil. Aquí solo puedes denegarla, o responderla en el ordenador.", + atComputer: "Esta solicitud te necesita en el ordenador. Aquí solo puedes denegarla.", + failed: "No se envió. Comprueba la conexión e inténtalo de nuevo.", + ended: { allowed: "Permitido", denied: "Denegado", expired: "Venció" }, + }, + question: { + asks: (bot: string) => `${bot} pregunta`, + pick: "Elige una respuesta", + answerLabel: "Tu respuesta", + placeholder: "Escribe tu respuesta", + send: "Responder", + sending: "Enviando…", + dismiss: "Descartar", + dismissHint: (bot: string) => + `Cierra la pregunta sin avisar a ${bot}. Para que lo sepa, responde.`, + failed: "No se envió. Comprueba la conexión e inténtalo de nuevo.", + ended: { answered: "Respondiste", dismissed: "Descartada" }, + }, + settings: { + title: "Este móvil", + back: "Volver", + name: "Nombre", + disconnect: "Desconectar este móvil", + disconnectTitle: "¿Desconectar este móvil?", + disconnectText: + "Deja de recibir solicitudes. Para usarlo de nuevo, conéctalo otra vez en el ordenador.", + cancel: "Cancelar", + install: + "Para recibir avisos, añade Botloft a la pantalla de inicio: toca Compartir y luego Añadir a pantalla de inicio.", + }, + cut: { + revokedTitle: "Este móvil fue desconectado", + leftTitle: "Móvil desconectado", + body: "Para usarlo de nuevo, conéctalo desde Botloft en el ordenador: Ajustes, Copia de seguridad, Móvil.", + ok: "OK", + }, +}; diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts index 3dd9eb77..e3b376db 100644 --- a/app/src/i18n/pt-BR/index.ts +++ b/app/src/i18n/pt-BR/index.ts @@ -14,6 +14,7 @@ import { desktop } from "./desktop"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { phone } from "./phone"; import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; @@ -40,6 +41,7 @@ export const ptBR: Messages = { desktop, files, messages, + phone, questions, routines, screens, diff --git a/app/src/i18n/pt-BR/phone.ts b/app/src/i18n/pt-BR/phone.ts new file mode 100644 index 00000000..eca0b39d --- /dev/null +++ b/app/src/i18n/pt-BR/phone.ts @@ -0,0 +1,78 @@ +// A página do celular (spec 28.7). + +import type { Messages } from "../en"; + +export const phone: Messages["phone"] = { + pair: { + title: "Conectar este celular", + intro: + "Este celular vai aprovar os pedidos dos seus bots e responder às perguntas deles, de qualquer lugar. As conversas e os arquivos ficam no seu computador.", + nameLabel: "Nome deste celular", + connect: "Conectar", + connecting: "Conectando…", + compare: (code: string) => + `Confira se o computador mostra ${code} também. Depois aperte Conectar no computador.`, + waiting: "Esperando você aceitar no computador…", + notALink: + "Para conectar um celular, aponte a câmera dele para o código que aparece no computador: Configurações, Cópia de segurança, Celular.", + expired: "O código venceu ou já foi usado. Peça um novo no computador.", + offline: "Não foi possível falar com o Botloft. Confira a conexão e tente de novo.", + failed: "Não foi possível conectar. Peça um novo código no computador.", + names: { iphone: "iPhone", ipad: "iPad", android: "Celular Android", other: "Celular" }, + }, + inbox: { + title: "Esperando você", + empty: "Nada esperando você", + emptyBody: "Quando um bot precisar de você, aparece aqui.", + loading: "Vendo o que está esperando…", + computerOff: "O computador está desligado ou sem internet. Os pedidos continuam esperando lá.", + noConnection: "Sem conexão. Tentando de novo…", + thisPhone: "Este celular", + inCrew: (crew: string) => `em ${crew}`, + }, + approval: { + asks: (bot: string) => `${bot} pede para`, + explanationBy: (bot: string) => `${bot} escreveu isto`, + noExplanation: (bot: string) => `${bot} não disse para que serve. Vale negar e perguntar.`, + showAll: "Mostrar o pedido inteiro", + hideAll: "Esconder", + allow: "Permitir", + deny: "Negar", + sending: "Enviando…", + noteLabel: "Recado para o bot (opcional)", + cut: "Longo demais para ler no celular. Aqui você só pode negar, ou responder no computador.", + atComputer: "Este pedido precisa de você no computador. Aqui você só pode negar.", + failed: "Não foi enviado. Confira a conexão e tente de novo.", + ended: { allowed: "Permitido", denied: "Negado", expired: "Venceu" }, + }, + question: { + asks: (bot: string) => `${bot} pergunta`, + pick: "Escolha uma resposta", + answerLabel: "Sua resposta", + placeholder: "Escreva sua resposta", + send: "Responder", + sending: "Enviando…", + dismiss: "Descartar", + dismissHint: (bot: string) => `Fecha a pergunta sem avisar ${bot}. Para ele saber, responda.`, + failed: "Não foi enviado. Confira a conexão e tente de novo.", + ended: { answered: "Você respondeu", dismissed: "Descartada" }, + }, + settings: { + title: "Este celular", + back: "Voltar", + name: "Nome", + disconnect: "Desconectar este celular", + disconnectTitle: "Desconectar este celular?", + disconnectText: + "Ele deixa de receber pedidos. Para usar de novo, conecte outra vez no computador.", + cancel: "Cancelar", + install: + "Para receber avisos, ponha o Botloft na tela inicial: toque em Compartilhar e depois em Adicionar à Tela de Início.", + }, + cut: { + revokedTitle: "Este celular foi desconectado", + leftTitle: "Celular desconectado", + body: "Para usar de novo, conecte pelo Botloft no computador: Configurações, Cópia de segurança, Celular.", + ok: "OK", + }, +}; diff --git a/app/src/phone/ApprovalView.tsx b/app/src/phone/ApprovalView.tsx new file mode 100644 index 00000000..10b4aae2 --- /dev/null +++ b/app/src/phone/ApprovalView.tsx @@ -0,0 +1,110 @@ +// A request waiting for the owner, as the phone shows it (spec 28.5, 28.7): +// what the bot wants, what it says it is for, the whole request a tap away, +// and Allow or Deny. Allow is off for what is cut or belongs to the computer. + +import { useState } from "react"; +import { useT } from "../i18n"; +import { when } from "../lib/format"; +import type { ApprovalCard } from "../lib/protocol.gen"; +import { Callout } from "../ui/Callout"; +import { BotDot, PhoneButton } from "./parts"; + +export function ApprovalView({ + card, + sending, + answer, +}: { + card: ApprovalCard; + sending: boolean; + /** False if the answer could not be sent. */ + answer(allow: boolean, note?: string): Promise; +}) { + const t = useT().phone; + const a = t.approval; + const [all, setAll] = useState(false); + const [note, setNote] = useState(""); + const [failed, setFailed] = useState(false); + const [pressed, setPressed] = useState(null); + + const send = async (allow: boolean) => { + setFailed(false); + setPressed(allow); + const sent = await answer(allow, note); + setFailed(!sent); + }; + const canAllow = !card.cut && !card.atComputer; + + return ( +

+
+ +
+

{card.bot.name}

+

+ {t.inbox.inCrew(card.crew)} · {when(card.createdAt)} +

+
+
+ +
+

{a.asks(card.bot.name)}

+

{card.summary || card.toolName}

+
+ + {card.explanation ? ( +
+

{card.explanation}

+

{a.explanationBy(card.bot.name)}

+
+ ) : ( + canAllow && + )} + +
+ + {all && ( +
+            {card.text}
+          
+ )} +
+ + {card.cut && } + {!card.cut && card.atComputer && } + + + +
+ send(true)}> + {sending && pressed === true ? a.sending : a.allow} + + send(false)}> + {sending && pressed === false ? a.sending : a.deny} + +
+ {failed && ( +

+ {a.failed} +

+ )} +
+ ); +} diff --git a/app/src/phone/PairScreen.tsx b/app/src/phone/PairScreen.tsx new file mode 100644 index 00000000..b21359e6 --- /dev/null +++ b/app/src/phone/PairScreen.tsx @@ -0,0 +1,98 @@ +// Connecting this phone (spec 28.3, 28.7): the owner scanned the code on the +// computer. Here the phone gets a name, joins, shows six digits to compare +// with the computer's, and waits for the owner to accept there. + +import { useState } from "react"; +import { useT } from "../i18n"; +import { Callout } from "../ui/Callout"; +import { type Pair, PairError, type PairFailure } from "./pair"; +import { PhoneButton } from "./parts"; +import type { Session } from "./store"; + +/** A name to start from, by the kind of phone this is. */ +export function guessName( + names: { iphone: string; ipad: string; android: string; other: string }, + agent = typeof navigator === "undefined" ? "" : navigator.userAgent, +): string { + if (/iPhone|iPod/.test(agent)) { + return names.iphone; + } + if (/iPad/.test(agent)) { + return names.ipad; + } + return /Android/.test(agent) ? names.android : names.other; +} + +const spaced = (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`; + +export function PairScreen({ + pair, + onSession, +}: { + /** `null` when the page was not opened from a code. */ + pair: Pair | null; + onSession(session: Session): void; +}) { + const t = useT().phone; + const p = t.pair; + const [name, setName] = useState(() => guessName(p.names)); + const [step, setStep] = useState<"form" | "joining" | "waiting">("form"); + const [code, setCode] = useState(""); + const [failure, setFailure] = useState(null); + + const connect = async () => { + if (!pair) { + return; + } + setFailure(null); + setStep("joining"); + try { + const joined = await pair.join(name.trim() || guessName(p.names)); + setCode(joined.code); + setStep("waiting"); + onSession(await joined.collect()); + } catch (error) { + setFailure(error instanceof PairError ? error.reason : "failed"); + setStep("form"); + } + }; + + return ( +
+

{p.title}

+ {!pair ? ( +

{p.notALink}

+ ) : step === "waiting" ? ( + <> +

{spaced(code)}

+

{p.compare(spaced(code))}

+

+ {p.waiting} +

+ + ) : ( + <> +

{p.intro}

+ + + {step === "joining" ? p.connecting : p.connect} + + {failure && } + + )} +
+ ); +} diff --git a/app/src/phone/PhoneApp.tsx b/app/src/phone/PhoneApp.tsx new file mode 100644 index 00000000..fc3c492d --- /dev/null +++ b/app/src/phone/PhoneApp.tsx @@ -0,0 +1,150 @@ +// The phone's page once it is connected (spec 28.7): what waits for the +// owner, oldest first, and a small screen about this phone. + +import { Settings, Smartphone } from "lucide-react"; +import { useState, useSyncExternalStore } from "react"; +import { useT } from "../i18n"; +import { Callout } from "../ui/Callout"; +import { Confirm } from "../ui/Confirm"; +import { ApprovalView } from "./ApprovalView"; +import type { PhoneApi } from "./client"; +import { PhoneButton } from "./parts"; +import { QuestionView } from "./QuestionView"; + +export function usePhone(api: PhoneApi) { + return useSyncExternalStore(api.subscribe, api.getState); +} + +/** Safari on an iPhone gives notices only to a page on the Home Screen. */ +function needsHomeScreen(): boolean { + if (typeof navigator === "undefined") { + return false; + } + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent); + const standalone = (navigator as Navigator & { standalone?: boolean }).standalone === true; + return ios && !standalone && !window.matchMedia?.("(display-mode: standalone)").matches; +} + +export function PhoneApp({ api, onGone }: { api: PhoneApi; onGone?(): void }) { + const t = useT().phone; + const state = usePhone(api); + const [view, setView] = useState<"inbox" | "phone">("inbox"); + + if (state.session !== "ok") { + return ( + +
+

+ {state.session === "revoked" ? t.cut.revokedTitle : t.cut.leftTitle} +

+

{t.cut.body}

+ void api.disconnect().then(() => onGone?.())} + className="flex-none" + > + {t.cut.ok} + +
+
+ ); + } + if (view === "phone") { + return setView("inbox")} />; + } + + const waiting = state.approvals.length + state.questions.length; + return ( + +
+

{t.inbox.title}

+ +
+ + {state.computer === "offline" && } + {state.link !== "online" && } + {needsHomeScreen() && } + +
+ {state.approvals.map((card) => ( + api.answerApproval(card.approvalId, allow, note)} + /> + ))} + {state.questions.map((card) => ( + api.answerQuestion(card.questionId, text)} + dismiss={() => api.dismissQuestion(card.questionId)} + /> + ))} + {waiting === 0 && ( +
+ + {state.loaded || state.computer === "offline" ? ( + <> +

{t.inbox.empty}

+

{t.inbox.emptyBody}

+ + ) : ( +

{t.inbox.loading}

+ )} +
+ )} +
+
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ThisPhone({ api, name, back }: { api: PhoneApi; name: string; back(): void }) { + const t = useT().phone; + const [leaving, setLeaving] = useState(false); + return ( + +
+ + {t.settings.back} + +

{t.settings.title}

+
+

+ {t.settings.name}: + {name} +

+ {needsHomeScreen() && } + setLeaving(true)} className="flex-none"> + {t.settings.disconnect} + + {leaving && ( + api.disconnect()} + onClose={() => setLeaving(false)} + > + {t.settings.disconnectText} + + )} +
+ ); +} diff --git a/app/src/phone/QuestionView.tsx b/app/src/phone/QuestionView.tsx new file mode 100644 index 00000000..9bac2388 --- /dev/null +++ b/app/src/phone/QuestionView.tsx @@ -0,0 +1,92 @@ +// A question waiting for the owner (spec 23, 28.7): the text, the ready +// answers as buttons, a field for another one, and Dismiss. + +import { useState } from "react"; +import { useT } from "../i18n"; +import { when } from "../lib/format"; +import type { QuestionCard } from "../lib/protocol.gen"; +import { BotDot, PhoneButton, PhoneMarkdown } from "./parts"; + +export function QuestionView({ + card, + sending, + answer, + dismiss, +}: { + card: QuestionCard; + sending: boolean; + /** False if the answer could not be sent. */ + answer(text: string): Promise; + dismiss(): Promise; +}) { + const t = useT().phone; + const q = t.question; + const [text, setText] = useState(""); + const [failed, setFailed] = useState(false); + + const run = async (work: () => Promise) => { + setFailed(false); + setFailed(!(await work())); + }; + + return ( +
+
+ +
+

{q.asks(card.bot.name)}

+

+ {t.inbox.inCrew(card.crew)} · {when(card.createdAt)} +

+
+
+ + {card.text} + + {card.options.length > 0 && ( +
+ {q.pick} + {card.options.map((option) => ( + run(() => answer(option))} + className="flex-none" + > + {option} + + ))} +
+ )} + +