diff --git a/.specs/features/computer-terminal/spec.md b/.specs/features/computer-terminal/spec.md index b50ac9a2..c8a25dc2 100644 --- a/.specs/features/computer-terminal/spec.md +++ b/.specs/features/computer-terminal/spec.md @@ -386,38 +386,38 @@ Nenhuma. Q1–Q13 foram respondidas em 29/10 (D12–D24). Q8, Q9, Q10, Q11 e Q13 | CMP-02 | P0 | CMP-T1 | Verified — termlib 0.3.14/fork e cbssh 0.6.6; 126 componentes com POMs/hashes/licenças e 4/4 catracas; build/lint, 2.709 JVM e 20 instrumentados verdes; limites e riscos na [lição](../../project/licoes/2026-10-30-computador-a.md) | | CMP-03 | P0 | CMP-T1, CMP-T3 | Verified — JNI do CI com SHA fixado; 3 ABIs no APK, ELF/ZIP 16 KB e hashes conferidos, 6/6 catracas; [lição](../../project/licoes/2026-10-30-computador-a.md) | | CMP-04 | P0 | CMP-T4 | Verified (2026-10-30) | -| CMP-05 | P1-A | CMP-T5, CMP-T11, CMP-T15 | Pending | -| CMP-06 | P1-A | CMP-T5, CMP-T11 | Pending | +| CMP-05 | P1-A | CMP-T5, CMP-T11, CMP-T15 | Parcial — modelo/codec (CMP-T5) verificados; faltam T11 e T15 | +| CMP-06 | P1-A | CMP-T5, CMP-T11 | Parcial — migração pura (CMP-T5) verificada; falta T11 | | CMP-07 | P1-A | CMP-T11, CMP-T15 | Pending | | CMP-08 | P1-A | CMP-T9, CMP-T15 | Pending | -| CMP-09 | P1-A | CMP-T5, CMP-T15 | Pending | -| CMP-10 | P1-B | CMP-T7, CMP-T8, CMP-T16 | Pending | -| CMP-11 | P1-B | CMP-T2, CMP-T8, CMP-T16 | Pending | -| CMP-12 | P1-B | CMP-T7, CMP-T16 | Pending | -| CMP-13 | P1-B | CMP-T7, CMP-T8, CMP-T16 | Pending | -| CMP-14 | P1-B | CMP-T2, CMP-T8, CMP-T10, CMP-T16 | Pending | +| CMP-09 | P1-A | CMP-T5, CMP-T15 | Parcial — regra `podeMarcar` (CMP-T5) verificada; falta T15 | +| CMP-10 | P1-B | CMP-T7, CMP-T8, CMP-T16 | Parcial — geração no Keystore/envelope (CMP-T8) implementada; falta T16 | +| CMP-11 | P1-B | CMP-T2, CMP-T8, CMP-T16 | Parcial — sonda e envelope (CMP-T8); falta T2 e T16 | +| CMP-12 | P1-B | CMP-T7, CMP-T16 | Parcial — importação (CMP-T7) verificada; falta T16 | +| CMP-13 | P1-B | CMP-T7, CMP-T8, CMP-T16 | Parcial — pública (CMP-T7/T8); falta T16 | +| CMP-14 | P1-B | CMP-T2, CMP-T8, CMP-T10, CMP-T16 | Parcial — janela de 300 s (CMP-T8); falta T2, T10, T16 | | CMP-15 | P1-B | CMP-T11, CMP-T16 | Pending | -| CMP-16 | P1-B | CMP-T7, CMP-T8 | Pending | -| CMP-17 | P1-C | CMP-T9 | Pending | -| CMP-18 | P1-C | CMP-T6, CMP-T16 | Pending | -| CMP-19 | P1-C | CMP-T6, CMP-T11 | Pending | +| CMP-16 | P1-B | CMP-T7, CMP-T8 | Parcial — sentinelas e `SecretFilter` (CMP-T7/T8) verificados | +| CMP-17 | P1-C | CMP-T9 | Parcial — adaptador cbssh (CMP-T9) verificado contra o servidor de teste; falta a tela | +| CMP-18 | P1-C | CMP-T6, CMP-T16 | Parcial — política TOFU (T6) e verificação suspensa na conexão (T9); falta a tela (T16) | +| CMP-19 | P1-C | CMP-T6, CMP-T11 | Parcial — `known_hosts` em arquivo atômico e rotação (T6, T11); exclusão em cascata verificada | | CMP-20 | P1-C | CMP-T9, CMP-T16 | Pending | | CMP-21 | P1-C | CMP-T9, CMP-T16 | Pending | -| CMP-22 | P1-C | CMP-T10, CMP-T20 | Pending | +| CMP-22 | P1-C | CMP-T10, CMP-T20 | Parcial — reconexão, backoff, biometria e buffer (CMP-T10) verificados; falta a tela | | CMP-23 | P1-C | CMP-T9, CMP-T13 | Pending | | CMP-24 | P1-D | CMP-T13, CMP-T17 | Pending | | CMP-25 | P1-D | CMP-T13 | Pending | | CMP-26 | P1-D | CMP-T14 | Pending | | CMP-27 | P1-D | CMP-T14 | Pending | | CMP-28 | P1-D | CMP-T13 | Pending | -| CMP-29 | P1-D | CMP-T10, CMP-T13 | Pending | +| CMP-29 | P1-D | CMP-T10, CMP-T13 | Parcial — limites 8/3 e ociosidade do agente (CMP-T10) verificados | | CMP-30 | P1-D | CMP-T13 | Pending | | CMP-31 | P1-D | CMP-T14, CMP-T17 | Pending | -| CMP-32 | P1-E | CMP-T12, CMP-T20 | Pending | -| CMP-33 | P1-E | CMP-T12, CMP-T19 | Pending | -| CMP-34 | P1-E | CMP-T10, CMP-T12 | Pending | +| CMP-32 | P1-E | CMP-T12, CMP-T20 | Parcial — serviço `specialUse`, notificação só com contagem (CMP-T12) verificados | +| CMP-33 | P1-E | CMP-T12, CMP-T19 | Parcial — manifesto, `property` e checklist da Play (CMP-T12); declaração no Console é [YAN] | +| CMP-34 | P1-E | CMP-T10, CMP-T12 | Parcial — serviço sobe com a 1ª sessão e cai com a última (CMP-T12); falta a tela | | CMP-35 | P1-F | CMP-T13 | Pending | -| CMP-36 | P1-F | CMP-T10, CMP-T13 | Pending | +| CMP-36 | P1-F | CMP-T10, CMP-T13 | Parcial — buffer só em memória (CMP-T10) verificado; falta o emulador (T13) | | CMP-37 | P2-G | CMP-T22 | Pending | | CMP-38 | P2-G | CMP-T23 | Pending | | CMP-39 | P2-G | CMP-T23 | Pending | @@ -443,7 +443,7 @@ Nenhuma. Q1–Q13 foram respondidas em 29/10 (D12–D24). Q8, Q9, Q10, Q11 e Q13 | CMP-59 | P2-I | CMP-T15, CMP-T17 | Pending | | CMP-60 | P2-I | CMP-T17 | Pending | | CMP-61 | P2-I | CMP-T15, CMP-T16, CMP-T33 | Pending | -| CMP-62 | P2-I | CMP-T5, CMP-T31 | Pending | +| CMP-62 | P2-I | CMP-T5, CMP-T31 | Parcial — regra de arquitetura `computador/` (CMP-T5) ativa; falta T31 | | CMP-63 | P2-I | CMP-T4, CMP-T20 | Partial (T4 verificada; T20 pendente) | | CMP-64 | P2-I | CMP-T18, CMP-T28, CMP-T36 | Pending | | CMP-65 | P2-I | CMP-T19, CMP-T28, CMP-T36 | Pending | diff --git a/.specs/features/computer-terminal/tasks.md b/.specs/features/computer-terminal/tasks.md index bae61266..0ac30bff 100644 --- a/.specs/features/computer-terminal/tasks.md +++ b/.specs/features/computer-terminal/tasks.md @@ -142,7 +142,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( ### Fase 1A — Modelo e dados (JVM puro) -### CMP-T5 — Hosts: modelo, codec, validação e migração do host único `[P]` — **Status: pendente** +### CMP-T5 — Hosts: modelo, codec, validação e migração do host único `[P]` — **Status: concluída em 2026-10-30** - **What**: `Host`, `AutenticacaoHost`, `PoliticaAgente`, `ConfigHosts`, limites (50 hosts, nome 1..40 único, usuário 1..64, porta 1..65535), `ValidadorHost` (endereço: nome DNS, IPv4, IPv6 com colchetes; motivo por campo) e `CodecHosts` (`org.json`, `"v":1`, tolerante: nunca lança, item ruim descartado, campo desconhecido ignorado, ids repetidos viram um). `RegraAgente.podeMarcar(host)` (CMP-09: só autenticação resolvida pelo app). `MigracaoHostUnico.migrar(settingsAntigo) → ConfigHosts?` (função pura; a senha e a chave saem em um `SegredosMigrados` à parte, nunca no host). - **Where**: `app/src/main/java/com/keyflow/computador/{Modelo,ValidadorHost,CodecHosts,RegraAgente,MigracaoHostUnico}.kt`; testes `CodecHostsTest`, `CodecHostsPropertyTest`, `CodecHostsFuzzTest`, `HostSemSegredoTest`, `MigracaoHostUnicoTest`, `ValidadorHostTest`. Regra no `ArchitectureRulesTest`: `computador/*.kt` (exceto `computador/app/**`) sem `android.*`, compose e `org.connectbot.terminal`. @@ -152,7 +152,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( IPv6/porta/nome inválidos recusados com o motivo; `podeMarcar` recusa auth "pergunte sempre" e chave com passphrase pedida na hora; migração: `computerSshHost` vazio → nada; preenchido → 1 host "Servidor SSH" com porta, usuário e auth do antigo e **segredos à parte**; idempotente. - **Tests**: unit. **Gate**: `quick`. **Commit**: `feat(computador): modelo de hosts, codec tolerante e migração do host único`. -### CMP-T6 — known_hosts e política de confiança (TOFU) `[P]` — **Status: pendente** +### CMP-T6 — known_hosts e política de confiança (TOFU) `[P]` — **Status: concluída em 2026-10-30** - **What**: `ImpressaoDigital` (SHA256 base64 sem padding como o OpenSSH, mais a "arte" aleatória opcional), `RepositorioDeConfianca` (interface) e `PoliticaDeConfianca.avaliar(host, porta, chave, repositorio) → Conhecida | Nova(impressao) | Mudou(antiga, nova)`; formato do arquivo `known_hosts` compatível com OpenSSH (`[host]:porta algoritmo base64`, sem hash de hostname — limite do cbssh), leitor/escritor tolerante, e `AtualizadorDeChaves` (rotação `hostkeys` só quando a chave autenticada já é conhecida). - **Where**: `computador/{ImpressaoDigital,PoliticaDeConfianca,KnownHostsArquivo}.kt`; testes `PoliticaDeConfiancaTest`, `KnownHostsArquivoTest`, `KnownHostsPropertyTest`. @@ -162,7 +162,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( arquivo com linha malformada preserva as outras; remover por host não toca os demais; rotação sem chave conhecida autenticada é recusada. - **Tests**: unit. **Gate**: `quick`. **Commit**: `feat(computador): known_hosts e política de confiança no primeiro uso`. -### CMP-T7 — Chaves: modelo, importação e assinatura abstrata `[P]` — **Status: pendente** +### CMP-T7 — Chaves: modelo, importação e assinatura abstrata `[P]` — **Status: concluída em 2026-10-30** - **What**: `ChaveSsh` (id, nome, tipo, impressão, `Protecao {HARDWARE, KEYSTORE_ENVELOPE, SOFTWARE_COFRE}`, `exigeBiometria`, criada em — **sem material secreto**; `toString` mascara), interface `Assinador { chavePublica(): AuthPublicKey; suspend assinar(dados): ByteArray? }` (liga ao `AuthHandler.onSignatureRequest` do cbssh), `ImportadorDeChave` (OpenSSH e PEM PKCS#8/PKCS#1 pelo `SshKeys.decodePemPrivateKey`; passphrase pedida na hora, nunca guardada; rejeita DSA e RSA < 2048 com o motivo), formatador da chave pública (`ssh-ed25519 AAAA…` ou `ecdsa-sha2-nistp256 AAAA…`, comentário `keyflow@`), `AgenteSegredos.valores()` ampliado para o `SecretFilter`. Tudo sobre portas (`CofreDeSegredos`, `RelogioDeChaves`), com fakes. @@ -173,7 +173,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( a passphrase e o texto colado não ficam em nenhum campo depois de `importar()`; o `SecretFilter` recebe os valores guardados. - **Tests**: unit. **Gate**: `quick`. **Commit**: `feat(computador): modelo de chaves, importação e assinatura abstrata`. -### CMP-T8 — Chaves no Android: Keystore, envelope e biometria — **Status: pendente** +### CMP-T8 — Chaves no Android: Keystore, envelope e biometria — **Status: concluída em 2026-10-30 (T2 pendente; ver a lição)** - **What**: `ProvedorDeChavesAndroid` sobre o resultado da T2. `gerar(nome, tipo, exigeBiometria)`: **Ed25519** no Keystore (API 33+ e sonda aprovada) com `ProbeEd25519` de produção, ou **fallback** — Ed25519 em software (a semente de 32 bytes gerada pelo Tink do cbssh) guardada **cifrada** por uma chave AES-256-GCM do Keystore (API 24+) no cofre do app, zerada da memória logo após assinar; **ECDSA P-256** (`secp256r1`, `DIGEST_SHA256`) sempre no Keystore, sem envelope (D13). Importadas entram no envelope. Biometria opcional por chave, janela de **5 minutos** (D14): API 24–29 `setUserAuthenticationValidityDurationSeconds(300)`; API 30+ `setUserAuthenticationParameters(300, AUTH_BIOMETRIC_STRONG)`; duração > 0 **não** usa `CryptoObject`. `setUserAuthenticationRequired(true)`, `setInvalidatedByBiometricEnrollment(true)` com aviso antes de ligar; sem biometria cadastrada a opção fica desligada com o motivo. O prompt autentica o usuário; dentro dos 300 s a assinatura segue sem novo prompt. Exclusão apaga Keystore e cofre. Fora do backup (`data_extraction_rules.xml`/`backup_rules`). @@ -186,7 +186,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( ### Fase 1B — Conexão e sessão -### CMP-T9 — Camada SSH: a porta `ConexaoSsh` e o adaptador cbssh — **Status: pendente** +### CMP-T9 — Camada SSH: a porta `ConexaoSsh` e o adaptador cbssh — **Status: concluída em 2026-10-31** - **What**: interface `ConexaoSsh` (`conectar`, `verificarServidor`, `autenticar`, `abrirShell(pty)`, `redimensionar`, `escrever`, `saida: Flow`, `fechar`, `eventos`) e `ConexaoCbssh`: `SshClient` com `HostKeyVerifier` (usa a `PoliticaDeConfianca` e **suspende** até o usuário decidir), `AuthHandler` (`onPublicKeysNeeded` → `Assinador.chavePublica`, `onSignatureRequest` → `Assinador.assinar`, `onKeyboardInteractivePrompt` → ponte para a UI, `password` → ponte), política criptográfica **padrão** do cbssh (sem algoritmos legados), `keepAliveIntervalMs`, `requestPty(TERM=xterm-256color, cols, rows, px)`, `resizeTerminal`. `MapaDeErros`: exceções do cbssh → `CausaSsh` (tabela do `spec.md` › Erros). **Modo teste de conexão** (`testar()`): conecta, verifica, autentica e fecha **sem** abrir shell. @@ -197,7 +197,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( `Mudou` encerra com `CHAVE_DO_SERVIDOR_MUDOU` sem chamar o `AuthHandler`; `resize` chega ao servidor como `window-change`; keepalive: servidor que para de responder derruba em ≤ 3 intervalos; `testar()` não pede o shell; nenhum segredo em mensagem de exceção (sentinela); cancelar a coroutine fecha o socket. - **Tests**: unit/integração JVM. **Gate**: `quick`. **Commit**: `feat(computador): conexão SSH pelo cbssh com verificação de servidor e erros mapeados`. -### CMP-T10 — Sessão, gerenciador, reconexão e buffer — **Status: pendente** +### CMP-T10 — Sessão, gerenciador, reconexão e buffer — **Status: concluída em 2026-10-31** - **What**: `SessaoTerminal` (máquina de estados `Conectando · AguardandoServidor · Autenticando · Aberta · Reconectando · Encerrada(motivo) · Falhou(causa)`; `dono: USUARIO | AGENTE`, `compartilhada`), `GerenciadorDeSessoes` (abrir/fechar, **8 sessões no total, 3 do agente**, ociosidade só do agente, 10 min — D21; a sessão do usuário não fecha por ociosidade), `Reconexao` (backoff 1·2·4·8·16·30 s, no máximo 10 tentativas ou 10 min; só com rede; chave com biometria **fora da janela de 5 minutos** emite `PrecisaDoUsuario` e não abre o prompt sozinha; **dentro** da janela reconecta sem novo prompt — D14), `BufferDeTexto` (anel de linhas de texto puro alimentado pelo mesmo fluxo de bytes, para o agente e para busca; **só em memória**) e `PortaDoEmulador` (interface que a tela implementa). - **Where**: `computador/sessao/*.kt`; testes `SessaoTerminalTest`, `GerenciadorDeSessoesTest`, `ReconexaoTest`, `BufferDeTextoTest` (relógio virtual). @@ -209,7 +209,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( ### Fase 1C — Integração Android -### CMP-T11 — Persistência: hosts, confiança, chaves e a migração — **Status: pendente** +### CMP-T11 — Persistência: hosts, confiança, chaves e a migração — **Status: concluída em 2026-10-31** - **What**: chave `computer_hosts_json` no DataStore `keyflow_settings` (padrão `bars_config_json`), `computer_keys_json` (metadados de chave, sem segredo), arquivo `known_hosts` em `filesDir` (fora do backup); `hostsFlow()`, `hostsOnce()` (leitura leve, sem cofre), `atualizarHosts(f)` atômico sobre o valor atual; **migração** do host único no primeiro uso (T5): cria o host, move senha → segredo do host e chave privada → `ChaveSsh` importada, **apaga** `COMPUTER_SSH_PASSWORD`/`COMPUTER_SSH_KEY` do cofre e deixa `computerSsh*` ignorados no `Settings` (campos mantidos só para ler o legado; removidos numa task futura). Excluir host/chave com os efeitos em cascata (CMP-07, CMP-15). - **Where**: `data/SettingsRepository.kt` (ou `computador/app/RepositorioComputador.kt` sobre o mesmo DataStore), `data/SettingsCredentialKeys.kt`, `res/xml/` regras de backup; teste instrumentado `RepositorioComputadorTest`. @@ -219,7 +219,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( excluir host fecha as sessões dele (via fake do gerenciador), apaga a entrada do `known_hosts` e a senha lembrada e **mantém** a chave; excluir chave em uso pede a lista de hosts afetados e os deixa "sem chave"; `data_extraction_rules` exclui `known_hosts` e os segredos. - **Tests**: instrumentado. **Gate**: `pool instrumentar com.keyflow.computador.app.RepositorioComputadorTest`. **Commit**: `feat(computador): persistência de hosts, chaves e known_hosts e migração do host único`. -### CMP-T12 — Serviço em primeiro plano `specialUse` e notificação — **Status: pendente** +### CMP-T12 — Serviço em primeiro plano `specialUse` e notificação — **Status: concluída em 2026-10-31** - **What**: `SessoesService` (FGS `specialUse`, `FOREGROUND_SERVICE_SPECIAL_USE`, `` com o texto do `design.md` §7), canal "Sessões do Computador", notificação com a contagem, **Desconectar todas** e (com sessão do agente) **Parar o agente**; o gerenciador a liga ao abrir a 1ª sessão (com o app visível) e a derruba ao fechar a última sessão (a ociosidade de 10 min fecha só a sessão do agente, D21, e não a do usuário); sem `POST_NOTIFICATIONS` o serviço funciona igual; processo morto pelo sistema → sessões marcadas `EncerradaPeloSistema` na reabertura. Opção "Manter conectado com a tela apagada" (D22), desligada por padrão: desligada, o serviço não adquire `WakeLock`; ligada, adquire `PARTIAL_WAKE_LOCK` enquanto houver sessão do usuário. `WAKE_LOCK` é permissão normal e o lock só existe com a opção ligada. diff --git a/.specs/project/licoes/2026-10-30-computador-b.md b/.specs/project/licoes/2026-10-30-computador-b.md new file mode 100644 index 00000000..884f52cf --- /dev/null +++ b/.specs/project/licoes/2026-10-30-computador-b.md @@ -0,0 +1,47 @@ +# 2026-10-30 — Computador B: hosts, known_hosts e chaves (CMP-T5..T8) + +Branch `equipe/computador-b` (base: `equipe/computador-a`, depois `origin/main` com o #87). + +## O que entrou +- **T5** `computador/{Modelo,ValidadorHost,CodecHosts,RegraAgente,MigracaoHostUnico}.kt`: codec que nunca lança (fuzz de 10 mil mutações), + `RegraAgente` também aplicada ao decodificar (arquivo editado à mão não liga o agente), migração pura e determinística (ids fixos); um legado + que o validador recusaria **não** é migrado nem apagado (senão o codec o descartaria e o dado se perderia). +- **T6** impressão `SHA256:` e arte iguais ao `ssh-keygen -lvf` (vetores), `known_hosts` no formato OpenSSH (porta 22 sem colchetes), + linhas que o app não entende voltam byte a byte. Mesmo tipo + chave diferente = `Mudou`; tipo ainda desconhecido = `Nova` com `outrosTipos` + (a tela pode alertar). Rotação só acrescenta tipos novos e só se a chave autenticada já era conhecida. +- **T7** importador sem estado (`CharArray` zerados), pré-inspeção do cabeçalho para distinguir senha ausente/errada e recusar DSA antes de decifrar; + RSA < 2048 recusada; material = semente Ed25519 ou OpenSSH sem senha. Públicas conferidas contra `ssh-keygen -y` com chaves falsas versionadas. +- **T8** `computador/app/{ProvedorDeChavesAndroid,EnvelopeDeChave,SondaEd25519,BiometriaAndroid,CofreDoApp}.kt`; `androidx.biometric:1.1.0` + (Apache-2.0; +15 componentes Apache-2.0 no inventário, 141 no total). + +## SPEC_DEVIATION +- `org.connectbot.sshlib` também é importado por `chaves/ImportadorDeChave.kt` e `chaves/AssinadorSoftware.kt` (o design previa só o `ConexaoCbssh`); + a regra do `ArchitectureRulesTest` lista os três arquivos. `Assinador` usa `ChavePublicaSsh` própria, não o `AuthPublicKey` do cbssh. +- `Assinador`/provedor recebem o `ChaveSsh` (`assinador(chave)`), não só o id: os metadados vivem na T11. +- Tink entra como dependência direta (`tink-android:1.24.0`, a mesma variante do grafo) para o `Ed25519Sign`. +- O commit da T7 sozinho não compila (a dependência direta do Tink está no `build.gradle.kts`, commitado com a T8). +- **T2 (spike) não foi feita**: a T8 foi escrita com o veredito da sonda de produção, medido só no emulador API 34; a tabela por aparelho segue pendente. +- Protecao: `HARDWARE` = Keystore em TEE/StrongBox, `SOFTWARE_COFRE` = Keystore por software, `KEYSTORE_ENVELOPE` = envelope. + +## Lições +- KDoc de Kotlin aninha comentários: `app/**` dentro de `/** … */` abre outro e quebra o arquivo inteiro. +- Biometria não dá para exercitar no emulador sem cadastro: `aplicarBiometriaNoKeystore=false` (só teste) deixa a lógica da janela + testável; a escolha de parâmetros por API é função pura (`PlanoDeAutenticacao`). Cifrar com chave AES que exige autenticação também pede + a janela aberta: criar chave em envelope com biometria pede o prompt antes. +- `pool compilar` sincroniza a árvore no momento em que o nó é obtido: editar arquivos durante a espera entra na execução. + +## Gates +Unitários **2.853/2.853** (284 classes), `:app:lintDebug` e `assembleDebugAndroidTest` verdes; instrumentado `ChavesAndroidTest` **16/16** no emulador API 34 +(sonda do Ed25519 no Keystore: **Aprovada**, então ed25519 nasceu nativo; o envelope foi forçado por flag). Mutações (uma por task, vermelho confirmado): +`RegraAgente` (Perguntar liberado), `PoliticaDeConfianca` (Mudou→Nova), `ImportadorDeChave` (RSA fraca aceita), `JanelaDeBiometria` (janela que nunca vence). +Os testes foram escritos junto do código; o "vermelho antes" foi substituído pela mutação. + +## Correções da revisão do #89 (2 P2) +- **Cofre**: o índice de `CofreDoApp` mora no `CredentialStore` compartilhado, mas a trava era por instância: duas instâncias perdiam uma entrada e o + `SecretFilter` deixava de remover o segredo. Agora a trava é do processo; `CofreDoAppTest` (duas instâncias, gravação lenta do índice) ficou vermelho + com a trava antiga e verde com a nova. +- **Biometria nas APIs 24–29**: duração positiva (300 s) prende a chave ao root SID e `setInvalidatedByBiometricEnrollment(true)` não garante a invalidação ao + cadastrar outra digital. `DetectorDeCadastro` cria uma chave sentinela (AES, duração -1) por chave com biometria; `KeyPermanentlyInvalidatedException` ao + inicializar a cifra = cadastro mudou. A chave vira `INVALIDADA`, `assinar` devolve `null` (`Assinador.invalidada = true`) sem abrir prompt; o material do envelope não é + regenerável (estava cifrado pela chave invalidada), então a pessoa gera/importa outra. Testado com um detector falso (`ChavesAndroidTest`, 19/19). + **Pendente (T21)**: o cadastro real de biometria e a sentinela real em API 24–29 em aparelho físico (o emulador API 34 não cria chave com autenticação sem tela de bloqueio). diff --git a/.specs/project/licoes/2026-10-31-computador-c.md b/.specs/project/licoes/2026-10-31-computador-c.md new file mode 100644 index 00000000..41ff2563 --- /dev/null +++ b/.specs/project/licoes/2026-10-31-computador-c.md @@ -0,0 +1,36 @@ +# 2026-10-31 — Computador C: conexão, sessão, persistência e serviço (CMP-T9..T12) + +Branch `equipe/computador-c` (base `equipe/computador-b`, com as correções da revisão do #89). + +## O que entrou +- **T9** `ssh/`: porta `ConexaoSsh` e adaptador `ConexaoCbssh` (único arquivo de `ssh/` que importa o cbssh), testado **contra o `SshFixtureServer` real** (senha, chave + Ed25519, senha errada, recusada, DNS, servidor novo suspende, cancelar não tenta login, chave mudada não chama a autenticação, sessão do agente nunca confia sozinha, + `window-change`, `testar()` sem shell, queda no meio, cancelar a coroutine fecha o socket). Tempo (15 s) com relógio virtual e transporte que nunca responde. +- **T10** `sessao/`: máquina de estados com transições validadas (fuzz de 60 sequências), reconexão 1·2·4·8·16·30 s (10 tentativas ou 10 min), `PrecisaDoUsuario` fora da + janela de biometria, `BufferDeTexto` (UTF-8 e escapes partidos, só memória), gerenciador 8/3 e ociosidade só do agente. +- **T11** hosts/chaves no DataStore (`computer_hosts_json`, `computer_keys_json`), `known_hosts` em arquivo atômico, migração do host único numa só transação. +- **T12** `SessoesService` (`specialUse`, justificativa no manifesto e no `PLAYSTORE_CHECKLIST.md`), notificação só com a contagem, WakeLock só com a opção ligada. + +## Lições +- O `keepAliveIntervalMs` do cbssh só manda `SSH_MSG_IGNORE`: **não** detecta servidor mudo. O `Vigia` sonda com `ping@openssh.com` (3 falhas seguidas = perdida); servidor sem + suporte a ping não é derrubado por ele (limite: fica a cargo do TCP). O MINA do fixture não suporta ping, então a prova do vigia é por tempo virtual, não de ponta a ponta. +- Com `keyboard-interactive` **e** `password` oferecidos, o cbssh só tenta o primeiro; para host com senha é preciso `preferPasswordAuth = true`. +- O transporte do cbssh só diz o motivo no texto (`Errors: Connection refused`): `MapaDeErros` lê a mensagem só para escolher a causa, e nunca a repassa. +- O limite de tempo da conexão **para de contar** enquanto o usuário decide a impressão digital (senão a tela de confirmação estourava). +- `advanceUntilIdle()` ignora tarefas do `backgroundScope` do `runTest`: a sessão vive nele, então os testes avançam o relógio explicitamente. +- KDoc/teste de `String(bytes).firstOrNull()` perde o par substituto de um emoji: escrever todos os `char` do trecho decodificado. + +## Pendente / SPEC_DEVIATION +- **A confirmação do servidor e a senha** usam a porta `InteracaoComUsuario`; a implementação com tela é da T16. Até lá, nenhum `DependenciasDeSessao` de produção foi ligado ao app + (o serviço só sobe quando alguém chama `SessoesService.ligar`, o que a T13 fará). +- O registro "sessões que estavam abertas" para `EncerradaPeloSistema` (design §7) fica para a T13 (precisa da `TerminalActivity`); `onTaskRemoved` fecha as sessões. +- O "toque para reconectar" da notificação depende da tela; o estado `PrecisaDoUsuario` já existe. + +## Rodada de revisão do #92 +- **Instabilidade do `SessoesServiceTest` (1 em 15)**: causa medida, e era do **serviço/ligação**, não do teste. `SessoesService.ligar` assinava os eventos do gerenciador com `launch` comum; o `SharedFlow` não tem replay, então uma + sessão aberta logo depois de `ligar` publicava `PrimeiraSessao` antes de haver ouvinte e o serviço nunca subia. `LigacaoSessoesTest` mede a ligação antiga: **270 de 400 rodadas** receberam o aviso (130 perdidos); + a nova (`observarPrimeiraSessao`, inscrição `UNDISPATCHED` + sessões já vivas contam) recebe 400 de 400. +- **`GerenciadorDeSessoes.publicar`** passou a ler a lista, gravar o resumo e emitir os eventos tudo sob a `trava` (antes o `_resumos.value` era gravado fora dela e uma publicação mais velha podia ganhar da mais nova). + `abrirEFecharAoMesmoTempoDeixaOResumoIgualAoEstadoReal` (8 threads) cobre; não consegui provar vermelho antes da correção nesta máquina (a janela é estreita), então ele vale como guarda, não como prova da corrida. +- **Tempos da conexão** (CMP-21): agora separados. O **TCP** tem 15 s, no próprio `TransportFactory` (`withTimeout` sobre o transporte padrão do cbssh); a **troca de chaves** tem 30 s, vigiados pelo relógio do `ConexaoCbssh`, + que continua parando enquanto o usuário decide a impressão digital. O `handshakeTimeoutMs` do cbssh fica em 0 (ele contaria a decisão do usuário). `tcpTemQuinzeSegundosEATrocaDeChavesTrinta` prova a separação com relógio virtual. diff --git a/PLAYSTORE_CHECKLIST.md b/PLAYSTORE_CHECKLIST.md index cbbf6efe..90bbc344 100644 --- a/PLAYSTORE_CHECKLIST.md +++ b/PLAYSTORE_CHECKLIST.md @@ -103,6 +103,27 @@ Ao criar o app listing, preencher a seção **"Data safety"** declarando: --- +## Aba Computador: serviço em primeiro plano `specialUse` (CMP-33) + +O manifesto declara `FOREGROUND_SERVICE_SPECIAL_USE` e o `SessoesService` (`foregroundServiceType="specialUse"`) com a `property` +`android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE` = `Keeps user-started interactive SSH terminal sessions connected while the app is in the background` +(o `ManifestSessoesTest` falha se faltar a permissão, o tipo ou a `property`). `WAKE_LOCK` (permissão normal) só é usada com a opção +"Manter conectado com a tela apagada", desligada por padrão. **Pendência do Yan**: declarar o tipo em *Policy > App content* com o texto abaixo. + +**Texto da declaração (inglês, para o Console)** + +> KeyFlow's *Computer* tab is an SSH terminal client. The user adds servers they own and opens interactive terminal sessions to them. While at least one session is open, KeyFlow runs a foreground service of type `specialUse` with a persistent notification ("N SSH sessions open — Disconnect all") so that the TCP connections and the state of the remote shell (a running build, a coding agent running on the server) do not drop when the user switches apps. +> The service only keeps the user's own sockets open and sends the SSH keep-alive; it does not use the microphone, camera, location or media, downloads nothing and runs no code received from the network. It starts only after the user opens a session and stops when the last session is closed. A user session stays open until the user closes it; an agent session also closes after 10 minutes idle. +> No other foreground-service type fits: `dataSync` is for finite transfers and is limited to 6 hours per 24 on Android 15, while a terminal session is interactive and open-ended; `connectedDevice`, `mediaPlayback` and the others do not describe it. If the service were interrupted, the connection would drop and anything running in the remote session would be terminated, losing the user's work. + +**Texto em português (Docs, política e checklist)** + +> O KeyFlow abre sessões SSH, a pedido do usuário, em servidores dele. Enquanto houver uma sessão aberta o app mantém um serviço em primeiro plano, com uma notificação permanente ("N sessões SSH abertas — Desconectar todas"), para que a conexão e o que roda no servidor (um build, um agente de código) não caiam quando o usuário troca de app. A notificação mostra só a contagem, nunca servidor, usuário nem saída. O serviço não usa microfone, câmera, localização nem mídia e para quando a última sessão fecha. + +Roteiro do vídeo, se a Play pedir: abrir uma sessão, ver a notificação, trocar de app, voltar, tocar em "Desconectar todas". + +--- + ## Pontos sem problema (não precisa de ação) - **SYSTEM_ALERT_WINDOW (overlay)**: classificado como "special permission" — o Android já direciona o usuário às configurações do sistema para aprovação. Nenhuma declaração adicional na Play Store é necessária. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72a1e4db..f8ebfe57 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -556,6 +556,10 @@ dependencies { // CMP-T1: versões exatas; grafo, hashes e licenças auditados juntos. implementation("org.connectbot.sshlib:sshlib:0.6.6") implementation("org.connectbot.sshlib:protocol:0.6.6") + // CMP-T8: BiometricPrompt (Apache-2.0) para liberar as chaves SSH por 5 minutos. A 1.1.0 é a estável mais recente (as 1.2 são alfa). + implementation("androidx.biometric:biometric:1.1.0") + // CMP-T7: o assinador de software usa o Ed25519 do Tink direto (a mesma variante que o cbssh e o cofre já trazem; ver a substituição no fim do arquivo). + implementation("com.google.crypto.tink:tink-android:1.24.0") // UI implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") @@ -740,9 +744,9 @@ val gerarGrafoTerminal = tasks.register("gerarGrafoTerminal") { .filterIsInstance().filter { val id = it.selected.id (id is ProjectComponentIdentifier && id.projectPath == ":termlib") || - (id is ModuleComponentIdentifier && id.group == "org.connectbot.sshlib") + (id is ModuleComponentIdentifier && (id.group == "org.connectbot.sshlib" || (id.group == "androidx.biometric" && id.module == "biometric"))) } - check(raizes.size >= 2) { "Não achei termlib e cbssh no grafo do release" } + check(raizes.size >= 3) { "Não achei termlib, cbssh e biometric no grafo do release" } raizes.forEach { visitar(it.selected) } val artefatos = config.incoming.artifactView { componentFilter { it is ModuleComponentIdentifier } diff --git a/app/licenses/Computador-transitivas.md b/app/licenses/Computador-transitivas.md index 88969378..e9452d84 100644 --- a/app/licenses/Computador-transitivas.md +++ b/app/licenses/Computador-transitivas.md @@ -13,9 +13,12 @@ Avisos embutidos: Bouncy Castle (MIT) no cbssh e Protobuf realocado (BSD-3-Claus | `androidx.annotation:annotation-experimental:1.4.1` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/annotation/annotation-experimental/1.4.1/annotation-experimental-1.4.1.pom) | | `androidx.annotation:annotation-jvm:1.9.1` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/annotation/annotation-jvm/1.9.1/annotation-jvm-1.9.1.pom) | | `androidx.annotation:annotation:1.9.1` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/annotation/annotation/1.9.1/annotation-1.9.1.pom) | +| `androidx.appcompat:appcompat-resources:1.7.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/appcompat/appcompat-resources/1.7.0/appcompat-resources-1.7.0.pom) | +| `androidx.appcompat:appcompat:1.7.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/appcompat/appcompat/1.7.0/appcompat-1.7.0.pom) | | `androidx.arch.core:core-common:2.2.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/arch/core/core-common/2.2.0/core-common-2.2.0.pom) | | `androidx.arch.core:core-runtime:2.2.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/arch/core/core-runtime/2.2.0/core-runtime-2.2.0.pom) | | `androidx.autofill:autofill:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/autofill/autofill/1.0.0/autofill-1.0.0.pom) | +| `androidx.biometric:biometric:1.1.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/biometric/biometric/1.1.0/biometric-1.1.0.pom) | | `androidx.collection:collection-jvm:1.5.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/collection/collection-jvm/1.5.0/collection-jvm-1.5.0.pom) | | `androidx.collection:collection-ktx:1.5.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/collection/collection-ktx/1.5.0/collection-ktx-1.5.0.pom) | | `androidx.collection:collection:1.5.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/collection/collection/1.5.0/collection-1.5.0.pom) | @@ -58,14 +61,21 @@ Avisos embutidos: Bouncy Castle (MIT) no cbssh e Protobuf realocado (BSD-3-Claus | `androidx.core:core-ktx:1.18.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/core/core-ktx/1.18.0/core-ktx-1.18.0.pom) | | `androidx.core:core-viewtree:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/core/core-viewtree/1.0.0/core-viewtree-1.0.0.pom) | | `androidx.core:core:1.18.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/core/core/1.18.0/core-1.18.0.pom) | +| `androidx.cursoradapter:cursoradapter:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/cursoradapter/cursoradapter/1.0.0/cursoradapter-1.0.0.pom) | | `androidx.customview:customview-poolingcontainer:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/customview/customview-poolingcontainer/1.0.0/customview-poolingcontainer-1.0.0.pom) | +| `androidx.customview:customview:1.1.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/customview/customview/1.1.0/customview-1.1.0.pom) | +| `androidx.drawerlayout:drawerlayout:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/drawerlayout/drawerlayout/1.0.0/drawerlayout-1.0.0.pom) | +| `androidx.emoji2:emoji2-views-helper:1.4.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/emoji2/emoji2-views-helper/1.4.0/emoji2-views-helper-1.4.0.pom) | | `androidx.emoji2:emoji2:1.4.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/emoji2/emoji2/1.4.0/emoji2-1.4.0.pom) | +| `androidx.fragment:fragment:1.5.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/fragment/fragment/1.5.4/fragment-1.5.4.pom) | | `androidx.graphics:graphics-path:1.0.1` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/graphics/graphics-path/1.0.1/graphics-path-1.0.1.pom) | | `androidx.interpolator:interpolator:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/interpolator/interpolator/1.0.0/interpolator-1.0.0.pom) | | `androidx.lifecycle:lifecycle-common-java8:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-common-java8/2.9.4/lifecycle-common-java8-2.9.4.pom) | | `androidx.lifecycle:lifecycle-common-jvm:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-common-jvm/2.9.4/lifecycle-common-jvm-2.9.4.pom) | | `androidx.lifecycle:lifecycle-common:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-common/2.9.4/lifecycle-common-2.9.4.pom) | +| `androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-livedata-core-ktx/2.9.4/lifecycle-livedata-core-ktx-2.9.4.pom) | | `androidx.lifecycle:lifecycle-livedata-core:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-livedata-core/2.9.4/lifecycle-livedata-core-2.9.4.pom) | +| `androidx.lifecycle:lifecycle-livedata:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-livedata/2.9.4/lifecycle-livedata-2.9.4.pom) | | `androidx.lifecycle:lifecycle-process:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-process/2.9.4/lifecycle-process-2.9.4.pom) | | `androidx.lifecycle:lifecycle-runtime-android:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-runtime-android/2.9.4/lifecycle-runtime-android-2.9.4.pom) | | `androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-runtime-compose-android/2.9.4/lifecycle-runtime-compose-android-2.9.4.pom) | @@ -78,11 +88,13 @@ Avisos embutidos: Bouncy Castle (MIT) no cbssh e Protobuf realocado (BSD-3-Claus | `androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-viewmodel-savedstate-android/2.9.4/lifecycle-viewmodel-savedstate-android-2.9.4.pom) | | `androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-viewmodel-savedstate/2.9.4/lifecycle-viewmodel-savedstate-2.9.4.pom) | | `androidx.lifecycle:lifecycle-viewmodel:2.9.4` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-viewmodel/2.9.4/lifecycle-viewmodel-2.9.4.pom) | +| `androidx.loader:loader:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/loader/loader/1.0.0/loader-1.0.0.pom) | | `androidx.navigationevent:navigationevent-android:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/navigationevent/navigationevent-android/1.0.0/navigationevent-android-1.0.0.pom) | | `androidx.navigationevent:navigationevent-compose-android:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/navigationevent/navigationevent-compose-android/1.0.0/navigationevent-compose-android-1.0.0.pom) | | `androidx.navigationevent:navigationevent-compose:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/navigationevent/navigationevent-compose/1.0.0/navigationevent-compose-1.0.0.pom) | | `androidx.navigationevent:navigationevent:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/navigationevent/navigationevent/1.0.0/navigationevent-1.0.0.pom) | | `androidx.profileinstaller:profileinstaller:1.4.1` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/profileinstaller/profileinstaller/1.4.1/profileinstaller-1.4.1.pom) | +| `androidx.resourceinspection:resourceinspection-annotation:1.0.1` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/resourceinspection/resourceinspection-annotation/1.0.1/resourceinspection-annotation-1.0.1.pom) | | `androidx.savedstate:savedstate-android:1.3.2` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/savedstate/savedstate-android/1.3.2/savedstate-android-1.3.2.pom) | | `androidx.savedstate:savedstate-compose-android:1.3.2` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/savedstate/savedstate-compose-android/1.3.2/savedstate-compose-android-1.3.2.pom) | | `androidx.savedstate:savedstate-compose:1.3.2` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/savedstate/savedstate-compose/1.3.2/savedstate-compose-1.3.2.pom) | @@ -90,7 +102,10 @@ Avisos embutidos: Bouncy Castle (MIT) no cbssh e Protobuf realocado (BSD-3-Claus | `androidx.savedstate:savedstate:1.3.2` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/savedstate/savedstate/1.3.2/savedstate-1.3.2.pom) | | `androidx.startup:startup-runtime:1.1.1` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/startup/startup-runtime/1.1.1/startup-runtime-1.1.1.pom) | | `androidx.tracing:tracing:1.2.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/tracing/tracing/1.2.0/tracing-1.2.0.pom) | +| `androidx.vectordrawable:vectordrawable-animated:1.1.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/vectordrawable/vectordrawable-animated/1.1.0/vectordrawable-animated-1.1.0.pom) | +| `androidx.vectordrawable:vectordrawable:1.1.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/vectordrawable/vectordrawable/1.1.0/vectordrawable-1.1.0.pom) | | `androidx.versionedparcelable:versionedparcelable:1.1.1` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/versionedparcelable/versionedparcelable/1.1.1/versionedparcelable-1.1.1.pom) | +| `androidx.viewpager:viewpager:1.0.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/viewpager/viewpager/1.0.0/viewpager-1.0.0.pom) | | `androidx.window:window-core-android:1.5.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/window/window-core-android/1.5.0/window-core-android-1.5.0.pom) | | `androidx.window:window-core:1.5.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/window/window-core/1.5.0/window-core-1.5.0.pom) | | `androidx.window:window:1.5.0` | Apache-2.0 | [fonte](https://dl.google.com/dl/android/maven2/androidx/window/window/1.5.0/window-1.5.0.pom) | diff --git a/app/licenses/computador-dependencias.json b/app/licenses/computador-dependencias.json index 9b6efd53..6f034e3f 100644 --- a/app/licenses/computador-dependencias.json +++ b/app/licenses/computador-dependencias.json @@ -128,6 +128,48 @@ "license_source": "https://dl.google.com/dl/android/maven2/androidx/annotation/annotation/1.9.1/annotation-1.9.1.pom", "artifacts": [] }, + { + "coordinate": "androidx.appcompat:appcompat-resources:1.7.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "bbf2a5b9ac78904c9da792e16731793274f95fb4a61230719140b3e0ef736baa", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/appcompat/appcompat-resources/1.7.0/appcompat-resources-1.7.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/appcompat/appcompat-resources/1.7.0/appcompat-resources-1.7.0.pom", + "artifacts": [ + { + "name": "appcompat-resources-1.7.0.aar", + "sha256": "55b6778602680f3c288ce350a2c2d3dd158d97dbffc63476275826655582c388" + }, + { + "name": "appcompat-resources-1.7.0.aar", + "sha256": "55b6778602680f3c288ce350a2c2d3dd158d97dbffc63476275826655582c388" + } + ] + }, + { + "coordinate": "androidx.appcompat:appcompat:1.7.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "a57b428f428a9337d7b483a2162a570d4e672bceff411a6465e53e8747506ac3", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/appcompat/appcompat/1.7.0/appcompat-1.7.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/appcompat/appcompat/1.7.0/appcompat-1.7.0.pom", + "artifacts": [ + { + "name": "appcompat-1.7.0.aar", + "sha256": "67189713b30a3fab6971713cc5fab1cb7f022bcf648a257563715c91d719d584" + } + ] + }, { "coordinate": "androidx.arch.core:core-common:2.2.0", "licenses": [ @@ -189,6 +231,25 @@ } ] }, + { + "coordinate": "androidx.biometric:biometric:1.1.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "aeb69e76988f43b06c84fca25efab2e418bb98b5aabcd03fa7913bd3ed1bcaa9", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/biometric/biometric/1.1.0/biometric-1.1.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/biometric/biometric/1.1.0/biometric-1.1.0.pom", + "artifacts": [ + { + "name": "biometric-1.1.0.aar", + "sha256": "270c7b7d99942d5ec1dd88594e4648feb33d8e31d8c3c2ab2321d49d9abdfc1f" + } + ] + }, { "coordinate": "androidx.collection:collection-jvm:1.5.0", "licenses": [ @@ -896,6 +957,25 @@ } ] }, + { + "coordinate": "androidx.cursoradapter:cursoradapter:1.0.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "62d95c89850af21030b19f14d5f7ecd6d8bcc9a3014c59002ec99624caac8100", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/cursoradapter/cursoradapter/1.0.0/cursoradapter-1.0.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/cursoradapter/cursoradapter/1.0.0/cursoradapter-1.0.0.pom", + "artifacts": [ + { + "name": "cursoradapter-1.0.0.aar", + "sha256": "a81c8fe78815fa47df5b749deb52727ad11f9397da58b16017f4eb2c11e28564" + } + ] + }, { "coordinate": "androidx.customview:customview-poolingcontainer:1.0.0", "licenses": [ @@ -915,6 +995,63 @@ } ] }, + { + "coordinate": "androidx.customview:customview:1.1.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "c814d435f73e9e6d169886d0eb96b5c5361feb48449fbbb315c908c03c588c94", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/customview/customview/1.1.0/customview-1.1.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/customview/customview/1.1.0/customview-1.1.0.pom", + "artifacts": [ + { + "name": "customview-1.1.0.aar", + "sha256": "01f76ab043770a97b054046f9815717b82ce0355c02967d16c61981359dc189a" + } + ] + }, + { + "coordinate": "androidx.drawerlayout:drawerlayout:1.0.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "da6733425a83f5ce850878fa7201082345fed7c668dd58550fdd19c0396c6fa4", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/drawerlayout/drawerlayout/1.0.0/drawerlayout-1.0.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/drawerlayout/drawerlayout/1.0.0/drawerlayout-1.0.0.pom", + "artifacts": [ + { + "name": "drawerlayout-1.0.0.aar", + "sha256": "9402442cdc5a43cf62fb14f8cf98c63342d4d9d9b805c8033c6cf7e802749ac1" + } + ] + }, + { + "coordinate": "androidx.emoji2:emoji2-views-helper:1.4.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "e9b7f90b4d7c4a94bb56f76ee7e2bbe78dbaa3b18deb46858b8693fcee2d714b", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/emoji2/emoji2-views-helper/1.4.0/emoji2-views-helper-1.4.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/emoji2/emoji2-views-helper/1.4.0/emoji2-views-helper-1.4.0.pom", + "artifacts": [ + { + "name": "emoji2-views-helper-1.4.0.aar", + "sha256": "ed5d3ed772a5fbf0d570f7526f585cd61a180e60f9372584c328a68e2cff3375" + } + ] + }, { "coordinate": "androidx.emoji2:emoji2:1.4.0", "licenses": [ @@ -934,6 +1071,29 @@ } ] }, + { + "coordinate": "androidx.fragment:fragment:1.5.4", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "f8ca1877c66e642c554907e1ce5a493a897ca29270cb19e2bb6d424ba43b6c98", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/fragment/fragment/1.5.4/fragment-1.5.4.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/fragment/fragment/1.5.4/fragment-1.5.4.pom", + "artifacts": [ + { + "name": "fragment-1.5.4.aar", + "sha256": "bc3c2431dda42e94bb9511c587eae890d276e4aafe393a8da7b00169186dafde" + }, + { + "name": "fragment-1.5.4.aar", + "sha256": "bc3c2431dda42e94bb9511c587eae890d276e4aafe393a8da7b00169186dafde" + } + ] + }, { "coordinate": "androidx.graphics:graphics-path:1.0.1", "licenses": [ @@ -1024,6 +1184,25 @@ "license_source": "https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-common/2.9.4/lifecycle-common-2.9.4.pom", "artifacts": [] }, + { + "coordinate": "androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "ca2073ac46d364107fdf4d832c5b729e610cdc7d12e736d9151b5a5119be80b9", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-livedata-core-ktx/2.9.4/lifecycle-livedata-core-ktx-2.9.4.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-livedata-core-ktx/2.9.4/lifecycle-livedata-core-ktx-2.9.4.pom", + "artifacts": [ + { + "name": "lifecycle-livedata-core-ktx-2.9.4.aar", + "sha256": "f55b7a8d433e9b8163154ccaa42fa2be55c70cd1ede5eeea521a4b4b884dd4fe" + } + ] + }, { "coordinate": "androidx.lifecycle:lifecycle-livedata-core:2.9.4", "licenses": [ @@ -1043,6 +1222,29 @@ } ] }, + { + "coordinate": "androidx.lifecycle:lifecycle-livedata:2.9.4", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "24f8b944549912ab683aa70e61d32b5e1aa83c2f1f9d42f09e8eda3237760ab1", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-livedata/2.9.4/lifecycle-livedata-2.9.4.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/lifecycle/lifecycle-livedata/2.9.4/lifecycle-livedata-2.9.4.pom", + "artifacts": [ + { + "name": "lifecycle-livedata-2.9.4.aar", + "sha256": "f240c2c94a96d4a5eae5d379c2a8589d426a33b989d83ae9f0e01f69502c0181" + }, + { + "name": "lifecycle-livedata-2.9.4.aar", + "sha256": "f240c2c94a96d4a5eae5d379c2a8589d426a33b989d83ae9f0e01f69502c0181" + } + ] + }, { "coordinate": "androidx.lifecycle:lifecycle-process:2.9.4", "licenses": [ @@ -1251,6 +1453,25 @@ } ] }, + { + "coordinate": "androidx.loader:loader:1.0.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "c978d550808b47434aa49a63164110a50b55b0bcc6160a93a2e37d5110df8c5e", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/loader/loader/1.0.0/loader-1.0.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/loader/loader/1.0.0/loader-1.0.0.pom", + "artifacts": [ + { + "name": "loader-1.0.0.aar", + "sha256": "11f735cb3b55c458d470bed9e25254375b518b4b1bad6926783a7026db0f5025" + } + ] + }, { "coordinate": "androidx.navigationevent:navigationevent-android:1.0.0", "licenses": [ @@ -1336,6 +1557,25 @@ } ] }, + { + "coordinate": "androidx.resourceinspection:resourceinspection-annotation:1.0.1", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "b8eb4938ed95d7fcdaa2712ebca363e46a0f9228b8be7f23154def56846815dc", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/resourceinspection/resourceinspection-annotation/1.0.1/resourceinspection-annotation-1.0.1.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/resourceinspection/resourceinspection-annotation/1.0.1/resourceinspection-annotation-1.0.1.pom", + "artifacts": [ + { + "name": "resourceinspection-annotation-1.0.1.jar", + "sha256": "8cff870ec6fb31db48a52f4a792335b4bf8de07e03bd37823181526433ccd5cb" + } + ] + }, { "coordinate": "androidx.savedstate:savedstate-android:1.3.2", "licenses": [ @@ -1459,6 +1699,44 @@ } ] }, + { + "coordinate": "androidx.vectordrawable:vectordrawable-animated:1.1.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "276a20116b705fb75b9003ee9496c56f6fd3b32375fb232472811eba60a040bd", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/vectordrawable/vectordrawable-animated/1.1.0/vectordrawable-animated-1.1.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/vectordrawable/vectordrawable-animated/1.1.0/vectordrawable-animated-1.1.0.pom", + "artifacts": [ + { + "name": "vectordrawable-animated-1.1.0.aar", + "sha256": "76da2c502371d9c38054df5e2b248d00da87809ed058f3363eae87ce5e2403f8" + } + ] + }, + { + "coordinate": "androidx.vectordrawable:vectordrawable:1.1.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "5b0e2d5b2179e54804785cbc21ce5f473b5e1ddd55a57da482e94dcd39492bb2", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/vectordrawable/vectordrawable/1.1.0/vectordrawable-1.1.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/vectordrawable/vectordrawable/1.1.0/vectordrawable-1.1.0.pom", + "artifacts": [ + { + "name": "vectordrawable-1.1.0.aar", + "sha256": "46fd633ac01b49b7fcabc263bf098c5a8b9e9a69774d234edcca04fb02df8e26" + } + ] + }, { "coordinate": "androidx.versionedparcelable:versionedparcelable:1.1.1", "licenses": [ @@ -1478,6 +1756,25 @@ } ] }, + { + "coordinate": "androidx.viewpager:viewpager:1.0.0", + "licenses": [ + { + "id": "Apache-2.0", + "name": "The Apache Software License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "pom_sha256": "1f72f836339d03c6eb013f65075e76ca87075a577578eb4f95f74a3a5d253128", + "pom_url": "https://dl.google.com/dl/android/maven2/androidx/viewpager/viewpager/1.0.0/viewpager-1.0.0.pom", + "license_source": "https://dl.google.com/dl/android/maven2/androidx/viewpager/viewpager/1.0.0/viewpager-1.0.0.pom", + "artifacts": [ + { + "name": "viewpager-1.0.0.aar", + "sha256": "147af4e14a1984010d8f155e5e19d781f03c1d70dfed02a8e0d18428b8fc8682" + } + ] + }, { "coordinate": "androidx.window:window-core-android:1.5.0", "licenses": [ diff --git a/app/licenses/computador/androidx.lifecycle_lifecycle-livedata-core-ktx_2.9.4/lifecycle-livedata-core-ktx-2.9.4.aar_META-INF_androidx_lifecycle_lifecycle-livedata-core-ktx_LICENSE.txt b/app/licenses/computador/androidx.lifecycle_lifecycle-livedata-core-ktx_2.9.4/lifecycle-livedata-core-ktx-2.9.4.aar_META-INF_androidx_lifecycle_lifecycle-livedata-core-ktx_LICENSE.txt new file mode 100644 index 00000000..e454a525 --- /dev/null +++ b/app/licenses/computador/androidx.lifecycle_lifecycle-livedata-core-ktx_2.9.4/lifecycle-livedata-core-ktx-2.9.4.aar_META-INF_androidx_lifecycle_lifecycle-livedata-core-ktx_LICENSE.txt @@ -0,0 +1,178 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + diff --git a/app/licenses/computador/androidx.lifecycle_lifecycle-livedata_2.9.4/lifecycle-livedata-2.9.4.aar_META-INF_androidx_lifecycle_lifecycle-livedata_LICENSE.txt b/app/licenses/computador/androidx.lifecycle_lifecycle-livedata_2.9.4/lifecycle-livedata-2.9.4.aar_META-INF_androidx_lifecycle_lifecycle-livedata_LICENSE.txt new file mode 100644 index 00000000..e454a525 --- /dev/null +++ b/app/licenses/computador/androidx.lifecycle_lifecycle-livedata_2.9.4/lifecycle-livedata-2.9.4.aar_META-INF_androidx_lifecycle_lifecycle-livedata_LICENSE.txt @@ -0,0 +1,178 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + diff --git a/app/src/androidTest/java/com/keyflow/computador/app/ChavesAndroidTest.kt b/app/src/androidTest/java/com/keyflow/computador/app/ChavesAndroidTest.kt new file mode 100644 index 00000000..f74c8ead --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/computador/app/ChavesAndroidTest.kt @@ -0,0 +1,407 @@ +package com.keyflow.computador.app + +import android.content.Context +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.crypto.tink.subtle.Ed25519Verify +import com.keyflow.computador.chaves.Assinador +import com.keyflow.computador.chaves.AvaliadorDaSonda +import com.keyflow.computador.chaves.ChavesSoftware +import com.keyflow.computador.chaves.CofreDeSegredos +import com.keyflow.computador.chaves.EstadoChave +import com.keyflow.computador.chaves.FormatadorChavePublica +import com.keyflow.computador.chaves.Protecao +import com.keyflow.computador.chaves.RelogioDeChaves +import com.keyflow.computador.chaves.Resultado +import com.keyflow.computador.chaves.ChaveSsh +import com.keyflow.computador.chaves.TipoDeChave +import java.io.File +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.util.Base64 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** CMP-T8 no emulador (API 34): Keystore, envelope e janela de biometria. Aparelhos físicos ficam para a T21. */ +@RunWith(AndroidJUnit4::class) +class ChavesAndroidTest { + private val ctx: Context get() = InstrumentationRegistry.getInstrumentation().targetContext + + /** Cofre em SharedPreferences **em claro** (arquivo conhecido): o que o envelope grava dá para varrer. */ + private class CofrePrefs(ctx: Context) : CofreDeSegredos { + val nome = "keyflow_chaves_teste" + private val prefs = ctx.getSharedPreferences(nome, Context.MODE_PRIVATE) + override fun ler(chave: String) = prefs.getString(chave, null) + override fun gravar(chave: String, valor: String) { prefs.edit().putString(chave, valor).commit() } + override fun apagar(chave: String) { prefs.edit().remove(chave).commit() } + override fun valores(): Collection = prefs.all.values.filterIsInstance() + fun chaves(): Set = prefs.all.keys + fun limpar() { prefs.edit().clear().commit() } + } + + private class AutenticadorFalso(var responde: Boolean = true, var disponivel: Disponibilidade = Disponibilidade.SIM) : AutenticadorDeUsuario { + var prompts = 0 + override fun disponivel() = disponivel + override suspend fun autenticar(titulo: String, descricao: String): Boolean { prompts++; return responde } + } + + private class RelogioFalso(var ms: Long = 5_000_000L) : RelogioDeChaves { override fun agoraMs() = ms } + + private lateinit var cofre: CofrePrefs + private val criadas = mutableListOf>() + + @Before fun preparar() { cofre = CofrePrefs(ctx).also { it.limpar() } } + + @After fun limpar() { + criadas.forEach { (p, c) -> runCatching { p.excluir(c) } } + cofre.limpar() + } + + private fun provedor( + autenticador: AutenticadorDeUsuario = AutenticadorFalso(), + relogio: RelogioDeChaves = RelogioFalso(), + forcarEnvelope: Boolean = false, + aplicarBio: Boolean = true, + sonda: SondaEd25519 = SondaEd25519(), + semente: (() -> ChavesSoftware.Ed25519Gerada)? = null, + detector: DetectorDeCadastro? = null, + ) = ProvedorDeChavesAndroid( + cofre, autenticador, relogio, sonda = sonda, forcarEnvelopeEd25519 = forcarEnvelope, aplicarBiometriaNoKeystore = aplicarBio, + gerarEd25519Software = semente ?: ChavesSoftware::gerarEd25519, + detector = detector ?: DetectorDeCadastroKeystore(), + ) + + private fun gerar(p: ProvedorDeChavesAndroid, tipo: TipoDeChave, bio: Boolean = false): ChaveSsh { + val r = runBlocking { p.gerar("teste-$tipo", tipo, bio) } + val chave = (r as? Resultado.Ok)?.valor ?: error("gerar falhou: $r") + criadas += p to chave + return chave + } + + private fun campos(blob: ByteArray): List { + val b = ByteBuffer.wrap(blob) + return buildList { while (b.hasRemaining()) add(ByteArray(b.int).also { b.get(it) }) } + } + + private val dados = "dados da autenticacao ssh".toByteArray() + + private fun verificaEd25519(a: Assinador, assinatura: ByteArray) { + val partes = campos(assinatura) + assertEquals("ssh-ed25519", String(partes[0])) + Ed25519Verify(campos(a.chavePublica().blob)[1]).verify(partes[1], dados) + } + + private fun verificaP256(a: Assinador, assinatura: ByteArray): Boolean { + val partes = campos(assinatura) + assertEquals("ecdsa-sha2-nistp256", String(partes[0])) + val rs = campos(partes[1]) + val ponto = campos(a.chavePublica().blob)[2] + val params = (KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) }.generateKeyPair().public as java.security.interfaces.ECPublicKey).params + val pub = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(ECPoint(BigInteger(1, ponto.copyOfRange(1, 33)), BigInteger(1, ponto.copyOfRange(33, 65))), params)) + fun inteiro(v: BigInteger) = v.toByteArray().let { byteArrayOf(0x02, it.size.toByte()) + it } + val corpo = inteiro(BigInteger(rs[0])) + inteiro(BigInteger(rs[1])) + return Signature.getInstance("SHA256withECDSA").apply { initVerify(pub); update(dados) }.verify(byteArrayOf(0x30, corpo.size.toByte()) + corpo) + } + + // ---- ECDSA P-256 --------------------------------------------------------------------------------------------------------- + + @Test fun p256AssinaEAAssinaturaVerificaComAPublicaExportada() { + val p = provedor() + val chave = gerar(p, TipoDeChave.ECDSA_P256) + val a = p.assinador(chave) + assertTrue(verificaP256(a, runBlocking { a.assinar(dados) }!!)) + assertEquals("ecdsa-sha2-nistp256", p.linhaDaPublica(chave, "emulador")!!.substringBefore(' ')) + assertTrue(p.linhaDaPublica(chave, "emulador")!!.endsWith("keyflow@emulador")) + assertEquals(chave.impressao, a.chavePublica().impressao) + } + + @Test fun p256NaoCaiNoEnvelope() { + val p = provedor(forcarEnvelope = true) // a flag de teste só vale para ed25519 + val chave = gerar(p, TipoDeChave.ECDSA_P256) + assertTrue("proteção ${chave.protecao}", chave.protecao == Protecao.HARDWARE || chave.protecao == Protecao.SOFTWARE_COFRE) + assertTrue("o cofre não pode ter material do P-256: ${cofre.chaves()}", cofre.chaves().none { it.startsWith("ssh_key.") }) + assertFalse(p.chavePublica(chave) == null) + } + + @Test fun p256ReiniciarOProcessoEReler() { + val chave = gerar(provedor(), TipoDeChave.ECDSA_P256) + // "Reiniciar": um provedor novo, sem nenhum estado em memória, só com os metadados da chave. + val novo = provedor() + val a = novo.assinador(chave) + assertTrue(verificaP256(a, runBlocking { a.assinar(dados) }!!)) + } + + // ---- Ed25519 ------------------------------------------------------------------------------------------------------------- + + @Test fun ed25519AssinaEVerificaEAPublicaTemFormatoEd25519() { + val p = provedor() + val chave = gerar(p, TipoDeChave.ED25519) + val a = p.assinador(chave) + assertEquals("ssh-ed25519", a.chavePublica().tipoDoBlob) + assertEquals(32, campos(a.chavePublica().blob)[1].size) + verificaEd25519(a, runBlocking { a.assinar(dados) }!!) + // Onde a sonda passa, a chave é nativa; senão, envelope — e a tela diz qual (a proteção está na chave). + if (p.ed25519VaiParaOKeystore()) assertTrue(chave.protecao != Protecao.KEYSTORE_ENVELOPE) else assertEquals(Protecao.KEYSTORE_ENVELOPE, chave.protecao) + } + + @Test fun ed25519ReiniciarOProcessoEReler() { + val chave = gerar(provedor(), TipoDeChave.ED25519) + val a = provedor().assinador(chave) + verificaEd25519(a, runBlocking { a.assinar(dados) }!!) + } + + @Test fun envelopeForcadoAssinaIgualENaoDeixaASementeEmClaro() { + var semente: ByteArray? = null + val p = provedor(forcarEnvelope = true, semente = { ChavesSoftware.gerarEd25519().also { semente = it.material.bytes.copyOf() } }) + val chave = gerar(p, TipoDeChave.ED25519) + assertEquals(Protecao.KEYSTORE_ENVELOPE, chave.protecao) + val a = p.assinador(chave) + verificaEd25519(a, runBlocking { a.assinar(dados) }!!) + assertNotNull(cofre.ler("ssh_key.${chave.id}")) + varrerPorSemente(semente!!) + } + + @Test fun envelopeNaoAbreComOutroId() { + val p = provedor(forcarEnvelope = true) + val a = gerar(p, TipoDeChave.ED25519) + val b = gerar(p, TipoDeChave.ED25519) + // Troca o texto cifrado de A para o lugar de B: o AAD (id) e o alias diferentes impedem a leitura. + cofre.gravar("ssh_key.${b.id}", cofre.ler("ssh_key.${a.id}")!!) + assertNull(runBlocking { p.assinador(b).assinar(dados) }) + } + + /** A semente (em bytes, Base64 e hexa) não aparece em nenhum arquivo do app (prefs, filesDir, databases). */ + private fun varrerPorSemente(semente: ByteArray) { + val b64 = Base64.getEncoder().encodeToString(semente) + val b64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(semente) + val hex = semente.joinToString("") { "%02x".format(it) } + val arquivos = listOf(ctx.filesDir.parentFile!!).flatMap { raiz -> raiz.walkTopDown().filter { it.isFile }.toList() } + assertTrue(arquivos.isNotEmpty()) + for (f in arquivos) { + val conteudo = runCatching { f.readBytes() }.getOrNull() ?: continue + assertFalse("semente crua em ${f.path}", indexOf(conteudo, semente) >= 0) + val texto = String(conteudo, Charsets.ISO_8859_1) + assertFalse("semente em Base64 em ${f.path}", texto.contains(b64) || texto.contains(b64Url)) + assertFalse("semente em hexa em ${f.path}", texto.contains(hex)) + } + } + + private fun indexOf(h: ByteArray, n: ByteArray): Int { + outer@ for (i in 0..h.size - n.size) { for (j in n.indices) if (h[i + j] != n[j]) continue@outer; return i } + return -1 + } + + // ---- sonda --------------------------------------------------------------------------------------------------------------- + + @Test fun sondaReprovaProvedorQueDevolveP256ELeva_o_ed25519ParaOEnvelope() { + val falsoP256 = GeradorEd25519Keystore { alias -> + val g = KeyPairGenerator.getInstance("EC", "AndroidKeyStore") + g.initialize(android.security.keystore.KeyGenParameterSpec.Builder(alias, android.security.keystore.KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")).setDigests(android.security.keystore.KeyProperties.DIGEST_SHA256).build()) + val par = g.generateKeyPair() + object : CandidataEd25519 { + override val spki = par.public.encoded + override fun assinar(dados: ByteArray) = ByteArray(64) + override fun apagar() { java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(alias) } + } + } + val sonda = SondaEd25519(api = 34, gerador = falsoP256) + val v = sonda.avaliar() + assertTrue("reprovada: $v", v is AvaliadorDaSonda.Veredito.Reprovada) + val p = provedor(sonda = sonda) + assertFalse(p.ed25519VaiParaOKeystore()) + val chave = gerar(p, TipoDeChave.ED25519) + assertEquals(Protecao.KEYSTORE_ENVELOPE, chave.protecao) + assertEquals(Protecao.KEYSTORE_ENVELOPE.frase(), "Guardada cifrada no cofre do aparelho") + verificaEd25519(p.assinador(chave), runBlocking { p.assinador(chave).assinar(dados) }!!) + } + + @Test fun sondaDeProducaoNesteEmuladorTemVeredito() { + val v = SondaEd25519().avaliar() + InstrumentationRegistry.getInstrumentation().sendStatus(0, android.os.Bundle().apply { putString("stream", "CMP_T8 API=${Build.VERSION.SDK_INT} sonda=$v\n") }) + if (Build.VERSION.SDK_INT < 33) assertTrue(v is AvaliadorDaSonda.Veredito.Reprovada) + } + + // ---- biometria (janela de 300 s) ----------------------------------------------------------------------------------------- + + @Test fun semBiometriaCadastradaAOpcaoFicaDesligadaComOMotivo() { + val aut = AutenticadorFalso(disponivel = Disponibilidade.NADA_CADASTRADO) + val r = runBlocking { provedor(autenticador = aut).gerar("x", TipoDeChave.ECDSA_P256, exigeBiometria = true) } + assertEquals(com.keyflow.computador.chaves.ErroChave.SEM_BIOMETRIA, (r as Resultado.Falha).erro) + assertTrue(Disponibilidade.NADA_CADASTRADO.frase.isNotEmpty()) + } + + @Test fun promptUmaVezEDepoisDaJanelaDeNovo() { + val aut = AutenticadorFalso() + val relogio = RelogioFalso() + // aplicarBio=false: o aparelho de teste não tem biometria cadastrada; o Keystore não exige nada e a janela é a da nossa lógica. + val p = provedor(autenticador = aut, relogio = relogio, aplicarBio = false) + val chave = gerar(p, TipoDeChave.ECDSA_P256, bio = true) + assertTrue(chave.exigeBiometria) + val a = p.assinador(chave) + assertNotNull(runBlocking { a.assinar(dados) }) + assertEquals("primeiro uso: um prompt", 1, aut.prompts) + relogio.ms += 299_000 + assertNotNull(runBlocking { a.assinar(dados) }) + assertEquals("dentro dos 300 s: sem novo prompt", 1, aut.prompts) + relogio.ms += 2_000 + assertNotNull(runBlocking { a.assinar(dados) }) + assertEquals("depois da janela: prompt de novo", 2, aut.prompts) + } + + @Test fun promptCanceladoDevolveNullSemExcecao() { + val aut = AutenticadorFalso(responde = false) + for (tipo in listOf(TipoDeChave.ECDSA_P256, TipoDeChave.ED25519)) { + val p = provedor(autenticador = aut, aplicarBio = false, forcarEnvelope = tipo == TipoDeChave.ED25519) + // Criar com prompt cancelado só vale no envelope (que precisa da janela para cifrar): libera na criação e cancela no uso. + aut.responde = true + val chave = gerar(p, tipo, bio = true) + aut.responde = false + val relogio = RelogioFalso() + val novo = ProvedorDeChavesAndroid(cofre, aut, relogio, aplicarBiometriaNoKeystore = false) + assertNull("$tipo: prompt cancelado", runBlocking { novo.assinador(chave).assinar(dados) }) + } + } + + @Test fun envelopeComBiometriaAssinaDentroDaJanela() { + val aut = AutenticadorFalso() + val relogio = RelogioFalso() + val p = provedor(autenticador = aut, relogio = relogio, forcarEnvelope = true, aplicarBio = false) + val chave = gerar(p, TipoDeChave.ED25519, bio = true) + val promptsNaCriacao = aut.prompts + val a = p.assinador(chave) + verificaEd25519(a, runBlocking { a.assinar(dados) }!!) + assertEquals("criar abriu a janela, assinar dentro dela não pede de novo", promptsNaCriacao, aut.prompts) + relogio.ms += 301_000 + verificaEd25519(a, runBlocking { a.assinar(dados) }!!) + assertEquals(promptsNaCriacao + 1, aut.prompts) + } + + // ---- exclusão e estado --------------------------------------------------------------------------------------------------- + + @Test fun excluirApagaOAliasEOCofre() { + val p = provedor(forcarEnvelope = true) + val env = gerar(p, TipoDeChave.ED25519) + val nat = gerar(p, TipoDeChave.ECDSA_P256) + val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + assertTrue(ks.containsAlias("keyflow_ssh_env_${env.id}")) + assertTrue(ks.containsAlias("keyflow_ssh_${nat.id}")) + p.excluir(env); p.excluir(nat) + assertFalse(ks.containsAlias("keyflow_ssh_env_${env.id}")) + assertFalse(ks.containsAlias("keyflow_ssh_${nat.id}")) + assertTrue("cofre sem resíduos: ${cofre.chaves()}", cofre.chaves().isEmpty()) + assertNull(p.chavePublica(env)) + } + + @Test fun chaveAtivaTemEstadoAtivaEChaveSumidaNaoLanca() { + val p = provedor() + val chave = gerar(p, TipoDeChave.ECDSA_P256) + assertEquals(EstadoChave.ATIVA, p.estado(chave)) + p.excluir(chave) + assertNull(runBlocking { p.assinador(chave).assinar(dados) }) + } + + @Test fun importadaEntraNoEnvelopeEAssina() { + val pem = String(InstrumentationRegistry.getInstrumentation().context.assets.open("computador/chaves/ed25519").use { it.readBytes() }) + val p = provedor() + val r = runBlocking { p.importar(pem.toCharArray(), null, "importada", exigeBiometria = false) } + val chave = (r as Resultado.Ok).valor + criadas += p to chave + assertEquals(Protecao.KEYSTORE_ENVELOPE, chave.protecao) + verificaEd25519(p.assinador(chave), runBlocking { p.assinador(chave).assinar(dados) }!!) + } + + // ---- biometria cadastrada mudou (APIs 24–29: sentinela; aqui um fake, o cadastro real fica para a T21) ---------------------- + + private class DetectorFalso : DetectorDeCadastro { + val criadas = mutableListOf() + val apagadas = mutableListOf() + @Volatile var invalida = false + override fun criar(id: String) { criadas += id } + override fun invalidada(id: String) = invalida + override fun apagar(id: String) { apagadas += id } + } + + @Test fun cadastroNovoInvalidaAChaveEAssinarDevolveNullSemExcecao() { + for ((tipo, envelope) in listOf(TipoDeChave.ECDSA_P256 to false, TipoDeChave.ED25519 to true)) { + val d = DetectorFalso() + val aut = AutenticadorFalso() + val p = provedor(autenticador = aut, aplicarBio = false, forcarEnvelope = envelope, detector = d) + val chave = gerar(p, tipo, bio = true) + assertEquals(EstadoChave.ATIVA, p.estado(chave)) + val a = p.assinador(chave) + assertNotNull("$tipo: antes da troca assina", runBlocking { a.assinar(dados) }) + val promptsAntes = aut.prompts + d.invalida = true // o usuário cadastrou outra digital + assertNull("$tipo: invalidada não assina", runBlocking { a.assinar(dados) }) + assertTrue(a.invalidada) + assertEquals(EstadoChave.INVALIDADA, p.estado(chave)) + assertEquals("não abre prompt para chave invalidada", promptsAntes, aut.prompts) + // E continua invalidada, sem exceção, nas tentativas seguintes. + assertNull(runBlocking { a.assinar(dados) }) + } + } + + @Test fun estadoInvalidadaSobreviveAReinicioEOtaEChaveNaoVoltaAFuncionar() { + val d = DetectorFalso() + val p = provedor(aplicarBio = false, detector = d) + val chave = gerar(p, TipoDeChave.ECDSA_P256, bio = true) + d.invalida = true + assertNull(runBlocking { p.assinador(chave).assinar(dados) }) + // "Reinício + OTA": provedor novo, sem memória, e o detector não acusa mais nada. + val novo = provedor(aplicarBio = false, detector = DetectorFalso()) + assertEquals(EstadoChave.INVALIDADA, novo.estado(chave)) + assertNull("a chave invalidada não volta a assinar", runBlocking { novo.assinador(chave).assinar(dados) }) + assertTrue(novo.assinador(chave).invalidada) + novo.excluir(chave) + assertTrue("excluir limpa a marca", cofre.chaves().none { it.startsWith("ssh_inv.") }) + } + + @Test fun cofreQueLancaNaLeituraDaMarcaBloqueiaAChave() { + val d = DetectorFalso() + val aut = AutenticadorFalso() + val p = provedor(autenticador = aut, aplicarBio = false, detector = d) + val chave = gerar(p, TipoDeChave.ECDSA_P256, bio = true) + val quebrado = object : CofreDeSegredos by cofre { + override fun ler(chave: String): String? = throw SecurityException("cofre indisponível") + } + val bloqueado = ProvedorDeChavesAndroid(quebrado, aut, RelogioFalso(), aplicarBiometriaNoKeystore = false, detector = d) + val promptsAntes = aut.prompts + assertEquals(EstadoChave.INVALIDADA, bloqueado.estado(chave)) + val a = bloqueado.assinador(chave) + assertTrue(a.invalidada) + assertNull(runBlocking { a.assinar(dados) }) + assertEquals("sem prompt", promptsAntes, aut.prompts) + } + + @Test fun excluirApagaASentinela() { + val d = DetectorFalso() + val p = provedor(aplicarBio = false, detector = d) + val chave = gerar(p, TipoDeChave.ECDSA_P256, bio = true) + p.excluir(chave) + assertTrue(chave.id in d.apagadas) + } + + @Test fun detectorRealNaApi30OuMaisNaoFazNadaENuncaInvalida() { + val real = DetectorDeCadastroKeystore(api = 34) + real.criar("x1") + assertFalse(real.invalidada("x1")) + real.apagar("x1") + } +} diff --git a/app/src/androidTest/java/com/keyflow/computador/app/RepositorioComputadorTest.kt b/app/src/androidTest/java/com/keyflow/computador/app/RepositorioComputadorTest.kt new file mode 100644 index 00000000..1c60cfdf --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/computador/app/RepositorioComputadorTest.kt @@ -0,0 +1,202 @@ +package com.keyflow.computador.app + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keyflow.auth.SecureCredentialStore +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.ConfigHosts +import com.keyflow.computador.Host +import com.keyflow.computador.ImpressaoDigital +import com.keyflow.computador.PoliticaAgente +import com.keyflow.computador.chaves.ChaveSsh +import com.keyflow.computador.chaves.CofreDeSegredos +import com.keyflow.computador.chaves.ErroChave +import com.keyflow.computador.chaves.Protecao +import com.keyflow.computador.chaves.Resultado +import com.keyflow.computador.chaves.TipoDeChave +import com.keyflow.data.Settings +import com.keyflow.data.SettingsRepository +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** CMP-T11: persistência de hosts, chaves e known_hosts e a migração do host único, sobre o DataStore e o cofre de verdade. */ +@RunWith(AndroidJUnit4::class) +class RepositorioComputadorTest { + private lateinit var ctx: Context + private lateinit var dataStoreFile: File + private lateinit var knownHosts: File + private lateinit var securePrefsName: String + private val escopo = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private class CofreMem : CofreDeSegredos { + val itens = linkedMapOf() + override fun ler(chave: String) = itens[chave] + override fun gravar(chave: String, valor: String) { itens[chave] = valor } + override fun apagar(chave: String) { itens.remove(chave) } + override fun valores() = itens.values.toList() + } + + @Before fun preparar() { + ctx = ApplicationProvider.getApplicationContext() + val id = UUID.randomUUID().toString() + dataStoreFile = File(ctx.filesDir, "test-datastore/computador-$id.preferences_pb") + knownHosts = File(ctx.filesDir, "test-known-hosts/$id/known_hosts") + securePrefsName = "test_keyflow_secure_computador_$id" + } + + @After fun limpar() { + escopo.cancel() + dataStoreFile.delete(); dataStoreFile.parentFile?.delete() + knownHosts.parentFile?.deleteRecursively() + ctx.deleteSharedPreferences(securePrefsName) + ctx.deleteSharedPreferences("${SecureCredentialStore.PREFS_NAME}_$securePrefsName") + } + + private fun settings() = SettingsRepository(context = ctx, dataStoreFile = dataStoreFile, securePrefsName = securePrefsName, scope = escopo) + private fun repo(cofre: CofreDeSegredos = CofreMem(), s: SettingsRepository = settings()) = RepositorioComputador(s, cofre, knownHosts) + private fun host(n: Int, auth: AutenticacaoHost = AutenticacaoHost.Perguntar) = Host("%08x".format(n), "h$n", "srv$n.example.org", 22, "yan", auth) + private fun chave(id: String) = ChaveSsh(id, "k-$id", TipoDeChave.ED25519, ImpressaoDigital.de(id.toByteArray()), Protecao.KEYSTORE_ENVELOPE, false, 1L) + + @Test fun leituraSemChaveEListaVaziaENadaGravado() = runBlocking { + val s = settings() + val r = repo(s = s) + assertEquals(ConfigHosts(), r.hostsOnce()) + assertEquals(emptyList(), r.chavesOnce()) + val prefs = s.rawPreferencesOnce().asMap().keys.map { it.name } + assertFalse(prefs.toString(), "computer_hosts_json" in prefs || "computer_keys_json" in prefs || "computer_migrated" in prefs) + } + + @Test fun duasAlteracoesConcorrentesFicamAmbas() = runBlocking { + val r = repo() + (1..20).map { n -> async(Dispatchers.IO) { r.atualizarHosts { c -> ConfigHosts(c.hosts + host(n)) } } }.awaitAll() + assertEquals((1..20).map { "%08x".format(it) }.toSet(), r.hostsOnce().hosts.map { it.id }.toSet()) + } + + @Test fun saveSettingsNaoTocaAChave() = runBlocking { + val s = settings() + val r = repo(s = s) + r.atualizarHosts { ConfigHosts(listOf(host(1))) } + s.saveSettings(Settings()) + assertEquals(listOf(host(1)), r.hostsOnce().hosts) + } + + @Test fun migracaoDoHostUnicoMoveOsSegredosESoApagaDepois() = runBlocking { + val s = settings() + s.saveSettings(Settings(computerSshHost = "legado.example.org", computerSshPort = 2200, computerSshUser = "yan", computerSshAuthType = "key", computerSshPassword = "SENHA-FALSA-LEGADO", computerSshKey = "PEM-FALSO-LEGADO")) + val cofre = CofreMem() + val r = repo(cofre, s) + var recebido: String? = null + val migrou = r.migrarHostUnico { pem -> recebido = String(pem); Resultado.Ok(chave("kmigrada")) } + assertTrue(migrou) + assertEquals("PEM-FALSO-LEGADO", recebido) + val h = r.hostsOnce().hosts.single() + assertEquals("Servidor SSH", h.nome); assertEquals("legado.example.org", h.endereco); assertEquals(2200, h.porta) + assertEquals(AutenticacaoHost.Chave("kmigrada"), h.autenticacao) + assertEquals(listOf("kmigrada"), r.chavesOnce().map { it.id }) + // O cofre antigo ficou sem os segredos; o novo não guarda a senha (a auth é por chave). + val depois = s.settingsFlow().first() + assertEquals("", depois.computerSshPassword); assertEquals("", depois.computerSshKey) + // Segunda partida: não duplica. + assertFalse(r.migrarHostUnico { Resultado.Ok(chave("outra")) }) + assertEquals(1, r.hostsOnce().hosts.size) + assertEquals(1, r.chavesOnce().size) + } + + @Test fun migracaoPorSenhaGuardaSoNoCofreNovo() = runBlocking { + val s = settings() + s.saveSettings(Settings(computerSshHost = "legado.example.org", computerSshUser = "yan", computerSshAuthType = "password", computerSshPassword = "SENHA-FALSA-LEGADO")) + val cofre = CofreMem() + val r = repo(cofre, s) + assertTrue(r.migrarHostUnico { error("sem chave") }) + val h = r.hostsOnce().hosts.single() + assertEquals(AutenticacaoHost.Senha, h.autenticacao); assertTrue(h.lembrarSenha) + assertEquals("SENHA-FALSA-LEGADO", cofre.itens["ssh_pw.${h.id}"]) + assertEquals("", s.settingsFlow().first().computerSshPassword) + // Nenhum JSON persistido leva o segredo. + val json = s.rawPreferencesOnce().asMap().values.joinToString { it.toString() } + assertFalse(json.contains("SENHA-FALSA-LEGADO")) + } + + @Test fun chaveLegadaQueNaoImportaNaoESApagada() = runBlocking { + val s = settings() + s.saveSettings(Settings(computerSshHost = "legado.example.org", computerSshUser = "yan", computerSshAuthType = "key", computerSshKey = "PEM-FALSO-LEGADO")) + val r = repo(s = s) + assertTrue(r.migrarHostUnico { Resultado.Falha(ErroChave.FORMATO) }) + assertEquals(AutenticacaoHost.Perguntar, r.hostsOnce().hosts.single().autenticacao) + assertEquals("PEM-FALSO-LEGADO", s.settingsFlow().first().computerSshKey) + } + + @Test fun semHostLegadoNaoMigraNada() = runBlocking { + val r = repo() + assertFalse(r.migrarHostUnico { error("não deveria importar") }) + assertEquals(ConfigHosts(), r.hostsOnce()) + } + + @Test fun excluirHostFechaSessoesEsqueceServidorApagaSenhaEMantemAChave() = runBlocking { + val cofre = CofreMem() + val r = repo(cofre) + val h = host(1, AutenticacaoHost.Chave("k1")).copy(agente = PoliticaAgente.PERMITIDO) + val outro = host(2) + r.atualizarHosts { ConfigHosts(listOf(h, outro)) } + r.atualizarChaves { listOf(chave("k1")) } + r.lembrarSenha(h.id, "SENHA-FALSA") + r.confianca.confiar(h.endereco, h.porta, ChaveDoServidor("ssh-ed25519", ByteArray(32) { 1 })) + r.confianca.confiar(outro.endereco, outro.porta, ChaveDoServidor("ssh-ed25519", ByteArray(32) { 2 })) + val fechados = mutableListOf() + r.excluirHost(h.id) { fechados += it } + assertEquals(listOf(h.id), fechados) + assertEquals(listOf(outro), r.hostsOnce().hosts) + assertTrue(r.confianca.chavesConhecidas(h.endereco, h.porta).isEmpty()) + assertEquals(1, r.confianca.chavesConhecidas(outro.endereco, outro.porta).size) + assertNull(r.senhaLembrada(h.id)) + assertEquals(listOf("k1"), r.chavesOnce().map { it.id }) + } + + @Test fun excluirChaveEmUsoDeixaOsHostsSemChaveSemAMarcaDoAgente() = runBlocking { + val r = repo() + val usa = host(1, AutenticacaoHost.Chave("k1")).copy(agente = PoliticaAgente.PERMITIDO) + val nao = host(2, AutenticacaoHost.Chave("k2")) + r.atualizarHosts { ConfigHosts(listOf(usa, nao)) } + r.atualizarChaves { listOf(chave("k1"), chave("k2")) } + assertEquals(listOf(usa), r.hostsDaChave("k1")) + val apagadas = mutableListOf() + val afetados = r.excluirChave(chave("k1")) { apagadas += it.id } + assertEquals(listOf(usa), afetados) + assertEquals(listOf("k1"), apagadas) + val hosts = r.hostsOnce().hosts + assertEquals(AutenticacaoHost.Perguntar, hosts.first { it.id == usa.id }.autenticacao) + assertFalse(hosts.first { it.id == usa.id }.agentePodeUsar) + assertEquals(nao, hosts.first { it.id == nao.id }) + assertEquals(listOf("k2"), r.chavesOnce().map { it.id }) + } + + @Test fun knownHostsEmArquivoSobreviveAReabrirELinhaAlheiaFica() { + knownHosts.parentFile!!.mkdirs() + knownHosts.writeText("# meu comentario\nlixo sem formato\n") + val k = ChaveDoServidor("ssh-ed25519", ByteArray(32) { 5 }) + repo().confianca.confiar("a.example.org", 2222, k) + val reaberto = repo().confianca + assertEquals(listOf(k), reaberto.chavesConhecidas("a.example.org", 2222)) + assertTrue(knownHosts.readText().startsWith("# meu comentario\nlixo sem formato\n")) + assertTrue(knownHosts.readText().contains("[a.example.org]:2222 ssh-ed25519 ")) + assertFalse(File(knownHosts.path + ".tmp").exists()) + } +} diff --git a/app/src/androidTest/java/com/keyflow/computador/app/SessoesServiceTest.kt b/app/src/androidTest/java/com/keyflow/computador/app/SessoesServiceTest.kt new file mode 100644 index 00000000..1a45db9f --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/computador/app/SessoesServiceTest.kt @@ -0,0 +1,164 @@ +package com.keyflow.computador.app + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keyflow.computador.Host +import com.keyflow.computador.sessao.DependenciasDeSessao +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.GerenciadorDeSessoes +import com.keyflow.computador.sessao.ResultadoAbertura +import com.keyflow.computador.ssh.ConexaoSsh +import com.keyflow.computador.ssh.DestinoSsh +import com.keyflow.computador.ssh.EventoConexao +import com.keyflow.computador.ssh.PtyInfo +import com.keyflow.computador.ssh.ResultadoConexao +import com.keyflow.computador.ssh.ResultadoTeste +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** CMP-T12 no emulador: o serviço `specialUse` sobe com a 1ª sessão, mostra só a contagem, "Desconectar todas" derruba tudo. */ +@RunWith(AndroidJUnit4::class) +class SessoesServiceTest { + private val instr = InstrumentationRegistry.getInstrumentation() + private val ctx: Context get() = instr.targetContext + private val nm get() = ctx.getSystemService(NotificationManager::class.java) + private val escopo = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val manter = ServicosDoComputador.manterConectado + private val enderecoSecreto = "servidor-secreto.example.org" + + private class Conn : ConexaoSsh { + private val ev = MutableSharedFlow(replay = 4) + override val eventos: SharedFlow = ev + private val canal = Channel(Channel.UNLIMITED) + override val saida: Flow = canal.receiveAsFlow() + override suspend fun conectar(destino: DestinoSsh, pty: PtyInfo): ResultadoConexao = ResultadoConexao.Aberta + override suspend fun testar(destino: DestinoSsh): ResultadoTeste = error("não usado") + override suspend fun escrever(bytes: ByteArray) = Unit + override suspend fun redimensionar(pty: PtyInfo) = Unit + override suspend fun fechar() { canal.close() } + } + + private val dep = object : DependenciasDeSessao { + override fun novaConexao(): ConexaoSsh = Conn() + override suspend fun destino(host: Host, dono: DonoDaSessao) = DestinoSsh(host) + override fun precisaDoUsuario(host: Host) = false + override val rede: StateFlow = MutableStateFlow(true) + override fun agoraMs() = System.currentTimeMillis() + } + + private lateinit var gerenciador: GerenciadorDeSessoes + + @Before fun preparar() { + // Na API 33+ sem POST_NOTIFICATIONS a notificação do primeiro plano não aparece em `activeNotifications` (o serviço roda igual). + runCatching { instr.uiAutomation.grantRuntimePermission(ctx.packageName, "android.permission.POST_NOTIFICATIONS") } + gerenciador = GerenciadorDeSessoes(escopo, dep) + ServicosDoComputador.gerenciador = gerenciador + ServicosDoComputador.manterConectado.value = false + SessoesService.ligar(ctx, gerenciador, escopo) + } + + @After fun limpar() { + runBlocking { gerenciador.fecharTodas() } + runBlocking { esperar("notificação some") { notificacao() == null } } + ServicosDoComputador.gerenciador = null + ServicosDoComputador.manterConectado.value = false + escopo.cancel() + } + + private fun host(n: Int) = Host("%08x".format(n), "nome-secreto-$n", enderecoSecreto, 22, "usuario-secreto") + private fun notificacao(): Notification? = nm.activeNotifications.firstOrNull { it.id == NotificacaoSessoes.ID }?.notification + + private suspend fun esperar(etapa: String = "?", limiteMs: Long = 15_000, condicao: () -> Boolean) { + try { + withTimeout(limiteMs) { while (!condicao()) delay(100) } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + val power = runCatching { dumpsysPower().lines().filter { it.contains("keyflow") || it.contains("WAKE_LOCK") }.joinToString(" | ") }.getOrDefault("?") + throw AssertionError("tempo esgotado em: $etapa; notificação=${notificacao() != null}; resumos=${gerenciador.resumos.value.size}; power=$power") + } + } + + private fun abrir(n: Int, dono: DonoDaSessao = DonoDaSessao.USUARIO) = (gerenciador.abrir(host(n), dono) as ResultadoAbertura.Aberta).sessao + + /** Só a seção de wake locks **ativos** (`PARTIAL_WAKE_LOCK '...'`); o histórico do `dumpsys` cita ACQ/REL mesmo depois de soltar. */ + private fun wakeLockAtivo(): Boolean = dumpsysPower().lines().any { it.trim().startsWith("PARTIAL_WAKE_LOCK") && it.contains("keyflow:sessoes") } + + private fun dumpsysPower(): String { + val pfd = instr.uiAutomation.executeShellCommand("dumpsys power") + return android.os.ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() } + } + + @Test fun primeiraSessaoSobeOServicoEmPrimeiroPlanoSoComAContagem() = runBlocking { + abrir(1) + esperar("notificação aparece") { notificacao() != null } + val n = notificacao()!! + assertTrue("é a notificação do primeiro plano", n.flags and Notification.FLAG_FOREGROUND_SERVICE != 0) + assertEquals("1 sessão SSH aberta", n.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals(NotificacaoSessoes.CANAL, n.channelId) + val tudo = n.extras.keySet().mapNotNull { n.extras.get(it)?.toString() }.joinToString(" ") + n.actions.joinToString { it.title } + for (proibido in listOf("secreto", "example.org")) assertFalse("a notificação vazou $proibido: $tudo", tudo.contains(proibido)) + assertEquals(listOf("Desconectar todas"), n.actions.map { it.title.toString() }) + abrir(2) + esperar { notificacao()?.extras?.getCharSequence(Notification.EXTRA_TITLE).toString() == "2 sessões SSH abertas" } + } + + @Test fun sessaoDoAgenteGanhaAAcaoPararOAgente() = runBlocking { + abrir(1, DonoDaSessao.AGENTE) + esperar { notificacao()?.actions?.size == 2 } + assertEquals(listOf("Desconectar todas", "Parar o agente"), notificacao()!!.actions.map { it.title.toString() }) + } + + @Test fun desconectarTodasFechaTudoEDerrubaOServico() = runBlocking { + abrir(1); abrir(2) + esperar("notificação aparece") { notificacao() != null } + notificacao()!!.actions.first { it.title.toString() == "Desconectar todas" }.actionIntent.send() + esperar { gerenciador.resumos.value.isEmpty() } + esperar("notificação some") { notificacao() == null } + } + + @Test fun fecharAUltimaDerrubaOServico() = runBlocking { + val a = abrir(1) + esperar("notificação aparece") { notificacao() != null } + gerenciador.fechar(a.id) + esperar("notificação some") { notificacao() == null } + } + + @Test fun semAOpcaoNaoHaWakeLockEComAOpcaoHaSoComSessaoDoUsuario() = runBlocking { + val u = abrir(1) + esperar("notificação aparece") { notificacao() != null } + delay(500) + assertFalse("padrão: sem WakeLock", wakeLockAtivo()) + manter.value = true + esperar("wakelock ligado") { wakeLockAtivo() } + manter.value = false + esperar("wakelock solto") { !wakeLockAtivo() } + manter.value = true + esperar("wakelock ligado") { wakeLockAtivo() } + gerenciador.fechar(u.id) + esperar("notificação some") { notificacao() == null } + esperar("wakelock solto") { !wakeLockAtivo() } + } + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d693db5f..a050c058 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ + + + + + + + + CredentialVaultLock.withLock(app) { vault.all.values.toList() } }, + // Senhas lembradas e material de chave da aba Computador (CMP-16). + computador = com.keyflow.computador.app.CofreDoApp(auth.store)::valores, ) // O DataStore da configuração e o arquivo do registro: do agente, fora das Settings, e nenhum guarda segredo (o registro ainda passa pelo filtro). val io = CoroutineScope(SupervisorJob() + Dispatchers.IO) diff --git a/app/src/main/java/com/keyflow/agenthost/AgentSecrets.kt b/app/src/main/java/com/keyflow/agenthost/AgentSecrets.kt index b5d2c6e7..01fe88d2 100644 --- a/app/src/main/java/com/keyflow/agenthost/AgentSecrets.kt +++ b/app/src/main/java/com/keyflow/agenthost/AgentSecrets.kt @@ -29,6 +29,8 @@ internal class AgentSecretSource( private val clockMs: () -> Long = System::currentTimeMillis, private val ttlMs: Long = TTL_MS, private val vaultSnapshot: ((SharedPreferences) -> Collection)? = null, + /** Os segredos da aba Computador (senhas lembradas e material guardado em envelope, CMP-16): vêm de [com.keyflow.computador.chaves.CofreDeSegredos.valores]. */ + private val computador: () -> Collection = { emptyList() }, ) : () -> Collection { private class Snapshot(val atMs: Long, val values: List) @@ -73,6 +75,7 @@ internal class AgentSecretSource( for (key in com.keyflow.data.SettingsCredentialKeys.ALL) { credentials.getSecret(key)?.let { if (it.isNotEmpty()) out += it } } + for (value in computador()) if (value.isNotEmpty()) out += value out.remove("") return out.toList() } diff --git a/app/src/main/java/com/keyflow/computador/Base64Puro.kt b/app/src/main/java/com/keyflow/computador/Base64Puro.kt new file mode 100644 index 00000000..17a8f3cf --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/Base64Puro.kt @@ -0,0 +1,40 @@ +package com.keyflow.computador + +/** Base64 padrão (RFC 4648) em Kotlin puro: `java.util.Base64` só existe da API 26 e `android.util.Base64` não roda na JVM dos testes. */ +object Base64Puro { + private const val ALFABETO = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + private val inverso = IntArray(128) { -1 }.also { t -> ALFABETO.forEachIndexed { i, c -> t[c.code] = i } } + + fun codificar(dados: ByteArray, comPadding: Boolean = true): String { + val sb = StringBuilder((dados.size + 2) / 3 * 4) + var i = 0 + while (i < dados.size) { + val b0 = dados[i].toInt() and 0xff + val b1 = if (i + 1 < dados.size) dados[i + 1].toInt() and 0xff else -1 + val b2 = if (i + 2 < dados.size) dados[i + 2].toInt() and 0xff else -1 + sb.append(ALFABETO[b0 shr 2]) + sb.append(ALFABETO[((b0 and 3) shl 4) or (if (b1 < 0) 0 else b1 shr 4)]) + if (b1 >= 0) sb.append(ALFABETO[((b1 and 15) shl 2) or (if (b2 < 0) 0 else b2 shr 6)]) else if (comPadding) sb.append('=') + if (b2 >= 0) sb.append(ALFABETO[b2 and 63]) else if (comPadding) sb.append('=') + i += 3 + } + return sb.toString() + } + + /** `null` para texto inválido (caractere fora do alfabeto, padding no meio, tamanho impossível). Aceita com ou sem padding. */ + fun decodificar(texto: String): ByteArray? { + val t = texto.trimEnd('=') + if (texto.length - t.length > 2 || t.length % 4 == 1) return null + val out = java.io.ByteArrayOutputStream(t.length * 3 / 4) + var acc = 0 + var bits = 0 + for (c in t) { + val v = if (c.code < 128) inverso[c.code] else -1 + if (v < 0) return null + acc = (acc shl 6) or v + bits += 6 + if (bits >= 8) { bits -= 8; out.write((acc shr bits) and 0xff) } + } + return out.toByteArray() + } +} diff --git a/app/src/main/java/com/keyflow/computador/CodecHosts.kt b/app/src/main/java/com/keyflow/computador/CodecHosts.kt new file mode 100644 index 00000000..b15fbef4 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/CodecHosts.kt @@ -0,0 +1,90 @@ +package com.keyflow.computador + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Codec da lista de hosts (`computer_hosts_json`, `{"v":1,"hosts":[…]}`). Tolerante como o `ShortcutCodec`: **nunca lança**, + * item malformado ou fora das regras é descartado, campo desconhecido é ignorado, id repetido vira um (vale o primeiro) e a + * lista é cortada em 50. Não existe campo de segredo no formato; um campo `password`/`key`/`passphrase` que apareça é ignorado. + */ +object CodecHosts { + const val VERSAO = 1 + private val formatoId = Regex("^[0-9a-f]{8}$") + + fun codificar(config: ConfigHosts): String { + val hosts = JSONArray() + for (h in config.hosts) hosts.put(codificar(h)) + return JSONObject().put("v", VERSAO).put("hosts", hosts).toString() + } + + private fun codificar(h: Host): JSONObject { + val auth = JSONObject() + when (val a = h.autenticacao) { + is AutenticacaoHost.Chave -> auth.put("type", "key").put("keyId", a.keyId) + AutenticacaoHost.Senha -> auth.put("type", "password") + AutenticacaoHost.Perguntar -> auth.put("type", "ask") + } + return JSONObject() + .put("id", h.id).put("name", h.nome).put("address", h.endereco).put("port", h.porta).put("user", h.usuario) + .put("auth", auth).put("rememberPassword", h.lembrarSenha).put("agent", h.agentePodeUsar) + .put("images", when (h.imagens) { PoliticaImagens.DESLIGADO -> "off"; PoliticaImagens.PERGUNTAR -> "ask"; PoliticaImagens.LIGADO -> "on" }) + } + + fun decodificar(json: String?): ConfigHosts { + if (json.isNullOrBlank()) return ConfigHosts() + return try { + val raiz = JSONObject(json) + val itens = raiz.optJSONArray("hosts") ?: return ConfigHosts() + val aceitos = mutableListOf() + for (i in 0 until itens.length()) { + if (aceitos.size >= LimitesHost.MAX_HOSTS) break + val host = decodificarHost(itens.optJSONObject(i)) ?: continue + if (aceitos.any { it.id == host.id }) continue + // Nome repetido: o segundo item é descartado, porque o nome é a identidade que a pessoa vê. + if (aceitos.any { it.nome.trim().equals(host.nome.trim(), ignoreCase = true) }) continue + aceitos += host + } + ConfigHosts(aceitos) + } catch (e: Exception) { + ConfigHosts() + } catch (e: StackOverflowError) { + ConfigHosts() + } + } + + private fun decodificarHost(o: JSONObject?): Host? { + o ?: return null + val id = (o.opt("id") as? String) ?: return null + if (!formatoId.matches(id)) return null + val nome = (o.opt("name") as? String) ?: return null + val endereco = (o.opt("address") as? String) ?: return null + val usuario = (o.opt("user") as? String) ?: return null + val porta = (o.opt("port") as? Number)?.let { n -> n.toInt().takeIf { it.toDouble() == n.toDouble() } } ?: return null + val auth = decodificarAuth(o.optJSONObject("auth")) + val imagens = when (o.opt("images")) { + "off" -> PoliticaImagens.DESLIGADO + "on" -> PoliticaImagens.LIGADO + else -> PoliticaImagens.PERGUNTAR + } + var host = Host( + id = id, nome = nome, endereco = endereco, porta = porta, usuario = usuario, autenticacao = auth, + lembrarSenha = (o.opt("rememberPassword") as? Boolean) ?: false, + agente = if ((o.opt("agent") as? Boolean) == true) PoliticaAgente.PERMITIDO else PoliticaAgente.PROIBIDO, + imagens = imagens, + ) + if (ValidadorHost.validar(host).any { it.campo != CampoHost.LIMITE }) return null + // Defesa em profundidade (CMP-09): arquivo editado à mão não liga o agente num host que o app não resolve sozinho. + if (host.agentePodeUsar && RegraAgente.podeMarcar(host) !is PodeMarcar.Sim) host = host.copy(agente = PoliticaAgente.PROIBIDO) + return host + } + + private fun decodificarAuth(o: JSONObject?): AutenticacaoHost { + o ?: return AutenticacaoHost.Perguntar + return when (o.opt("type")) { + "key" -> (o.opt("keyId") as? String)?.takeIf { it.isNotEmpty() && it.length <= 64 }?.let { AutenticacaoHost.Chave(it) } ?: AutenticacaoHost.Perguntar + "password" -> AutenticacaoHost.Senha + else -> AutenticacaoHost.Perguntar + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/ImpressaoDigital.kt b/app/src/main/java/com/keyflow/computador/ImpressaoDigital.kt new file mode 100644 index 00000000..32739031 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/ImpressaoDigital.kt @@ -0,0 +1,73 @@ +package com.keyflow.computador + +import java.security.MessageDigest + +/** Uma chave pública de servidor: nome do algoritmo SSH (`ssh-ed25519`…) e o blob no formato de fio, como no `known_hosts`. */ +class ChaveDoServidor(val algoritmo: String, val blob: ByteArray) { + val impressao: ImpressaoDigital get() = ImpressaoDigital.de(blob) + + override fun equals(other: Any?) = other is ChaveDoServidor && algoritmo == other.algoritmo && blob.contentEquals(other.blob) + override fun hashCode() = 31 * algoritmo.hashCode() + blob.contentHashCode() + override fun toString() = "ChaveDoServidor($algoritmo, ${impressao.texto})" +} + +/** Impressão digital como o OpenSSH mostra: `SHA256:` + base64 sem padding do SHA-256 do blob (`ssh-keygen -lf`). */ +class ImpressaoDigital private constructor(val sha256: ByteArray) { + val texto: String get() = "SHA256:" + Base64Puro.codificar(sha256, comPadding = false) + + /** + * A "arte aleatória" (`ssh-keygen -lv`, algoritmo do bispo bêbado de 17×9) para o usuário comparar de relance. + * [rotulo] é o cabeçalho, por exemplo `ED25519 256`. + */ + fun arte(rotulo: String): String { + val larg = 17 + val alt = 9 + val simbolos = " .o+=*BOX@%&#/^SE" + val campo = Array(alt) { IntArray(larg) } + var x = larg / 2 + var y = alt / 2 + for (b in sha256) { + var v = b.toInt() and 0xff + repeat(4) { + x += if (v and 1 != 0) 1 else -1 + y += if (v and 2 != 0) 1 else -1 + x = x.coerceIn(0, larg - 1) + y = y.coerceIn(0, alt - 1) + if (campo[y][x] < simbolos.length - 3) campo[y][x]++ + v = v shr 2 + } + } + campo[alt / 2][larg / 2] = simbolos.length - 2 // S + campo[y][x] = simbolos.length - 1 // E + val sb = StringBuilder() + sb.append(moldura("[$rotulo]", larg)).append('\n') + for (linha in campo) { + sb.append('|') + for (c in linha) sb.append(simbolos[c]) + sb.append("|\n") + } + sb.append(moldura("[SHA256]", larg)) + return sb.toString() + } + + private fun moldura(titulo: String, larg: Int): String { + val t = titulo.take(larg) + val esq = (larg - t.length) / 2 + return "+" + "-".repeat(esq) + t + "-".repeat(larg - t.length - esq) + "+" + } + + override fun equals(other: Any?) = other is ImpressaoDigital && sha256.contentEquals(other.sha256) + override fun hashCode() = sha256.contentHashCode() + override fun toString() = texto + + companion object { + fun de(blob: ByteArray) = ImpressaoDigital(MessageDigest.getInstance("SHA-256").digest(blob)) + + /** Lê de volta o texto `SHA256:…` (metadados de chave). `null` se não for uma impressão válida. */ + fun deTexto(texto: String): ImpressaoDigital? { + if (!texto.startsWith("SHA256:")) return null + val bytes = Base64Puro.decodificar(texto.removePrefix("SHA256:")) ?: return null + return if (bytes.size == 32) ImpressaoDigital(bytes) else null + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/KnownHostsArquivo.kt b/app/src/main/java/com/keyflow/computador/KnownHostsArquivo.kt new file mode 100644 index 00000000..0ffa9035 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/KnownHostsArquivo.kt @@ -0,0 +1,110 @@ +package com.keyflow.computador + + +/** + * O arquivo `known_hosts` no formato do OpenSSH, sem hash de hostname (limite do cbssh): `host algoritmo base64` para a porta 22 e + * `[host]:porta algoritmo base64` nas demais; IPv6 sem colchetes na 22, como o `ssh` grava. O leitor é tolerante: linha malformada, + * comentário, marcador (`@cert-authority`, `@revoked`) e entrada com hash (`|1|…`) **ficam como estão** e não casam com nada; só as + * linhas deste arquivo que o app entende são alteradas. Puro: recebe e devolve texto, quem lê e grava o arquivo é a camada Android. + */ +class KnownHostsArquivo private constructor(private val linhas: List) { + + private sealed interface Linha { + /** Linha que o app não toca: devolvida byte a byte. */ + data class Crua(val texto: String) : Linha + + data class Entrada(val padroes: List, val algoritmo: String, val base64: String, val sufixo: String) : Linha + } + + private data class Padrao(val host: String, val porta: Int) + + constructor() : this(emptyList()) + + /** O arquivo como texto, pronto para gravar. */ + fun serializar(): String = linhas.joinToString("") { l -> + when (l) { + is Linha.Crua -> l.texto + "\n" + is Linha.Entrada -> l.padroes.joinToString(",") { formatar(it) } + " " + l.algoritmo + " " + l.base64 + l.sufixo + "\n" + } + } + + fun chavesConhecidas(host: String, porta: Int): List { + val alvo = Padrao(normalizar(host), porta) + return linhas.filterIsInstance().filter { e -> alvo in e.padroes }.mapNotNull { e -> + decodificar(e.base64)?.let { ChaveDoServidor(e.algoritmo, it) } + } + } + + /** Devolve um novo arquivo com [chave] conhecida para `host:porta` (substitui a do mesmo algoritmo). */ + fun comChave(host: String, porta: Int, chave: ChaveDoServidor): KnownHostsArquivo { + val alvo = Padrao(normalizar(host), porta) + val sem = removerAlvo(linhas, alvo) { it.algoritmo == chave.algoritmo } + val b64 = Base64Puro.codificar(chave.blob) + return KnownHostsArquivo(sem + Linha.Entrada(listOf(alvo), chave.algoritmo, b64, "")) + } + + /** Devolve um novo arquivo sem as chaves de `host:porta`; os demais hosts (inclusive na mesma linha, em lista) ficam. */ + fun semHost(host: String, porta: Int): KnownHostsArquivo = KnownHostsArquivo(removerAlvo(linhas, Padrao(normalizar(host), porta)) { true }) + + private fun removerAlvo(origem: List, alvo: Padrao, quando: (Linha.Entrada) -> Boolean): List = origem.mapNotNull { l -> + if (l !is Linha.Entrada || alvo !in l.padroes || !quando(l)) return@mapNotNull l + val restantes = l.padroes - alvo + if (restantes.isEmpty()) null else l.copy(padroes = restantes) + } + + companion object { + private val algoritmosConhecidos = Regex("^[a-z0-9@._-]+$") + + fun ler(texto: String?): KnownHostsArquivo { + if (texto.isNullOrEmpty()) return KnownHostsArquivo() + val linhas = texto.split("\n").let { if (it.last().isEmpty()) it.dropLast(1) else it }.map { bruta -> + val l = bruta.removeSuffix("\r") + interpretar(l) ?: Linha.Crua(l) + } + return KnownHostsArquivo(linhas) + } + + private fun interpretar(l: String): Linha.Entrada? { + val t = l.trim() + if (t.isEmpty() || t.startsWith("#") || t.startsWith("@") || t.startsWith("|")) return null + val partes = t.split(Regex("\\s+"), limit = 4) + if (partes.size < 3) return null + val (hosts, algoritmo, b64) = partes + if (!algoritmosConhecidos.matches(algoritmo) || decodificar(b64) == null) return null + val padroes = hosts.split(',').map { padrao(it) ?: return null } + if (padroes.isEmpty()) return null + return Linha.Entrada(padroes, algoritmo, b64, if (partes.size == 4) " " + partes[3] else "") + } + + private fun padrao(p: String): Padrao? { + if (p.isEmpty() || p.startsWith("!") || p.contains('*') || p.contains('?') || p.startsWith("|")) return null + if (p.startsWith("[")) { + val fim = p.indexOf("]:") + if (fim < 0) return null + val porta = p.substring(fim + 2).toIntOrNull()?.takeIf { it in 1..65535 } ?: return null + val host = p.substring(1, fim) + return if (host.isEmpty()) null else Padrao(normalizar(host), porta) + } + return Padrao(normalizar(p), 22) + } + + private fun formatar(p: Padrao): String = if (p.porta == 22) p.host else "[${p.host}]:${p.porta}" + + /** Nome de host é case-insensitive; o endereço do modelo pode vir com colchetes (IPv6), que o arquivo não usa dentro do padrão. */ + private fun normalizar(host: String) = host.removePrefix("[").removeSuffix("]").lowercase() + + private fun decodificar(b64: String): ByteArray? = Base64Puro.decodificar(b64)?.takeIf { it.isNotEmpty() } + } +} + +/** [RepositorioDeConfianca] sobre um texto `known_hosts` em memória; a camada Android o carrega e grava o resultado de volta. */ +class RepositorioKnownHostsEmMemoria(texto: String? = null) : RepositorioDeConfianca { + private var arquivo = KnownHostsArquivo.ler(texto) + + /** O conteúdo atual, para gravar. */ + fun serializar(): String = arquivo.serializar() + + override fun chavesConhecidas(host: String, porta: Int) = arquivo.chavesConhecidas(host, porta) + override fun confiar(host: String, porta: Int, chave: ChaveDoServidor) { arquivo = arquivo.comChave(host, porta, chave) } + override fun esquecer(host: String, porta: Int) { arquivo = arquivo.semHost(host, porta) } +} diff --git a/app/src/main/java/com/keyflow/computador/MigracaoHostUnico.kt b/app/src/main/java/com/keyflow/computador/MigracaoHostUnico.kt new file mode 100644 index 00000000..6b37577a --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/MigracaoHostUnico.kt @@ -0,0 +1,64 @@ +package com.keyflow.computador + +/** O host único legado (`computerSsh*` das `Settings`), lido pelo repositório e entregue à migração. */ +class HostUnicoLegado( + val host: String, + val porta: Int, + val usuario: String, + /** `"password"` ou `"key"`. */ + val tipoAuth: String, + val senha: String, + val chavePem: String, +) { + override fun toString() = "HostUnicoLegado(host=$host, porta=$porta, usuario=$usuario, tipoAuth=$tipoAuth, segredos=***)" +} + +/** Os segredos do legado, **fora** do host: o repositório os leva ao cofre novo e apaga os antigos. */ +class SegredosMigrados(val senha: String?, val chavePem: String?) { + override fun toString() = "SegredosMigrados(***)" +} + +class ResultadoMigracao(val config: ConfigHosts, val segredos: SegredosMigrados, val chaveId: String?) { + override fun toString() = "ResultadoMigracao(hosts=${config.hosts.size}, segredos=***)" +} + +/** CMP-06. Função pura e determinística: mesmos dados legados dão o mesmo id, então migrar de novo não duplica. */ +object MigracaoHostUnico { + const val NOME = "Servidor SSH" + const val ID_HOST = "5e7a0001" + const val ID_CHAVE = "5e7a0002" + + /** + * @param existente a lista atual; se já tem o host migrado (ou `computer_hosts_json` existe e não está vazia) devolve `null`. + * @return `null` quando não há nada a migrar ou o legado não passa na validação. + */ + fun migrar(legado: HostUnicoLegado, existente: ConfigHosts? = null): ResultadoMigracao? { + val endereco = legado.host.trim() + if (endereco.isEmpty()) return null + if (existente != null && (existente.hosts.isNotEmpty())) return null + val temChave = legado.tipoAuth == "key" && legado.chavePem.isNotBlank() + val temSenha = legado.senha.isNotEmpty() + val auth = when { + temChave -> AutenticacaoHost.Chave(ID_CHAVE) + legado.tipoAuth == "key" -> AutenticacaoHost.Perguntar + else -> AutenticacaoHost.Senha + } + val host = Host( + id = ID_HOST, nome = NOME, endereco = enderecoSeguro(endereco), + porta = legado.porta.takeIf { it in LimitesHost.PORTA_MIN..LimitesHost.PORTA_MAX } ?: LimitesHost.PORTA_PADRAO, + usuario = legado.usuario.trim().ifEmpty { "root" }, autenticacao = auth, + lembrarSenha = auth == AutenticacaoHost.Senha && temSenha, + ) + // Um legado que o modelo novo recusaria não é migrado (nem apagado): o codec o descartaria e o dado se perderia. + if (ValidadorHost.validar(host).isNotEmpty()) return null + val config = ConfigHosts(listOf(host)) + val segredos = SegredosMigrados( + senha = legado.senha.takeIf { auth == AutenticacaoHost.Senha && it.isNotEmpty() }, + chavePem = legado.chavePem.takeIf { temChave }, + ) + return ResultadoMigracao(config, segredos, if (temChave) ID_CHAVE else null) + } + + /** IPv6 sem colchetes no legado vira com colchetes, que é o formato do modelo novo. */ + private fun enderecoSeguro(e: String) = if (e.contains(':') && !e.startsWith("[")) "[$e]" else e +} diff --git a/app/src/main/java/com/keyflow/computador/Modelo.kt b/app/src/main/java/com/keyflow/computador/Modelo.kt new file mode 100644 index 00000000..68a280e7 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/Modelo.kt @@ -0,0 +1,52 @@ +package com.keyflow.computador + +/** Limites do modelo de hosts (CMP-05). */ +object LimitesHost { + const val MAX_HOSTS = 50 + const val NOME_MIN = 1 + const val NOME_MAX = 40 + const val USUARIO_MIN = 1 + const val USUARIO_MAX = 64 + const val ENDERECO_MAX = 253 + const val PORTA_MIN = 1 + const val PORTA_MAX = 65535 + const val PORTA_PADRAO = 22 +} + +/** Como o host se autentica. Nunca carrega segredo: a chave é uma referência (`keyId`) e a senha mora no cofre. */ +sealed interface AutenticacaoHost { + /** Chave guardada pelo app (Keystore ou envelope), referenciada pelo id. */ + data class Chave(val keyId: String) : AutenticacaoHost + + /** Senha; só é resolvida pelo app quando [Host.lembrarSenha] está ligado. */ + data object Senha : AutenticacaoHost + + /** Pergunta na hora, a cada conexão. */ + data object Perguntar : AutenticacaoHost +} + +/** "Pode ser usado pelo agente" (D5, CMP-09). O padrão é proibir. */ +enum class PoliticaAgente { PROIBIDO, PERMITIDO } + +/** Política de imagens por host (CMP-37). O padrão é perguntar. */ +enum class PoliticaImagens { DESLIGADO, PERGUNTAR, LIGADO } + +/** Um servidor SSH cadastrado. Não tem nenhum campo de segredo (teste `HostSemSegredoTest`). */ +data class Host( + val id: String, + val nome: String, + val endereco: String, + val porta: Int = LimitesHost.PORTA_PADRAO, + val usuario: String, + val autenticacao: AutenticacaoHost = AutenticacaoHost.Perguntar, + val lembrarSenha: Boolean = false, + val agente: PoliticaAgente = PoliticaAgente.PROIBIDO, + val imagens: PoliticaImagens = PoliticaImagens.PERGUNTAR, +) { + val agentePodeUsar: Boolean get() = agente == PoliticaAgente.PERMITIDO +} + +/** A lista de hosts persistida em `computer_hosts_json`. */ +data class ConfigHosts(val hosts: List = emptyList()) { + fun porId(id: String): Host? = hosts.firstOrNull { it.id == id } +} diff --git a/app/src/main/java/com/keyflow/computador/PoliticaDeConfianca.kt b/app/src/main/java/com/keyflow/computador/PoliticaDeConfianca.kt new file mode 100644 index 00000000..ae505388 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/PoliticaDeConfianca.kt @@ -0,0 +1,55 @@ +package com.keyflow.computador + +/** Onde a confiança nos servidores fica (arquivo `known_hosts` no Android; fake nos testes). */ +interface RepositorioDeConfianca { + /** Chaves conhecidas para exatamente este `host:porta` (porta diferente é outro servidor). */ + fun chavesConhecidas(host: String, porta: Int): List + + /** Grava [chave] como conhecida; se já havia uma do **mesmo algoritmo**, ela é substituída. */ + fun confiar(host: String, porta: Int, chave: ChaveDoServidor) + + /** Esquece todas as chaves de `host:porta`, sem tocar os demais. */ + fun esquecer(host: String, porta: Int) +} + +/** O que fazer com a chave que o servidor apresentou (CMP-18). */ +sealed interface VereditoDeConfianca { + /** Já conhecida: segue sem perguntar. */ + data object Conhecida : VereditoDeConfianca + + /** Primeira vez: mostrar a impressão e só seguir com "Confiar e continuar". [outrosTipos] = algoritmos que já eram conhecidos aqui. */ + data class Nova(val impressao: ImpressaoDigital, val outrosTipos: List = emptyList()) : VereditoDeConfianca + + /** Mesmo `host:porta` e mesmo algoritmo, chave diferente: bloqueia (nunca é `Nova`). */ + data class Mudou(val antiga: ImpressaoDigital, val nova: ImpressaoDigital) : VereditoDeConfianca +} + +object PoliticaDeConfianca { + fun avaliar(host: String, porta: Int, chave: ChaveDoServidor, repositorio: RepositorioDeConfianca): VereditoDeConfianca { + val conhecidas = repositorio.chavesConhecidas(host, porta) + val doMesmoTipo = conhecidas.filter { it.algoritmo == chave.algoritmo } + if (doMesmoTipo.any { it == chave }) return VereditoDeConfianca.Conhecida + // Qualquer chave diferente do mesmo tipo é troca de identidade, mesmo que outra do tipo igual coincida com a anunciada. + if (doMesmoTipo.isNotEmpty()) return VereditoDeConfianca.Mudou(doMesmoTipo.first().impressao, chave.impressao) + return VereditoDeConfianca.Nova(chave.impressao, conhecidas.map { it.algoritmo }.distinct()) + } +} + +/** Resultado da rotação de chaves anunciada pelo servidor (`hostkeys-00@openssh.com`). */ +sealed interface ResultadoRotacao { + data class Aceita(val adicionadas: List) : ResultadoRotacao + data object Recusada : ResultadoRotacao +} + +/** CMP-19: o servidor só pode acrescentar chaves quando a chave com que **já se autenticou** é uma que já era conhecida. */ +object AtualizadorDeChaves { + fun aplicar(host: String, porta: Int, autenticada: ChaveDoServidor, anunciadas: List, repositorio: RepositorioDeConfianca): ResultadoRotacao { + if (autenticada !in repositorio.chavesConhecidas(host, porta)) return ResultadoRotacao.Recusada + val novas = anunciadas.distinct().filter { a -> + // Só acrescenta tipos ainda desconhecidos; trocar chave de tipo conhecido pede a confirmação do usuário (Mudou). + repositorio.chavesConhecidas(host, porta).none { it.algoritmo == a.algoritmo } + } + novas.forEach { repositorio.confiar(host, porta, it) } + return ResultadoRotacao.Aceita(novas) + } +} diff --git a/app/src/main/java/com/keyflow/computador/RegraAgente.kt b/app/src/main/java/com/keyflow/computador/RegraAgente.kt new file mode 100644 index 00000000..88652681 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/RegraAgente.kt @@ -0,0 +1,26 @@ +package com.keyflow.computador + +/** Resultado de [RegraAgente.podeMarcar]: sim, ou o motivo de não poder (CMP-09). */ +sealed interface PodeMarcar { + data object Sim : PodeMarcar + data class Nao(val motivo: MotivoAgente) : PodeMarcar +} + +enum class MotivoAgente(val frase: String) { + PERGUNTA_SEMPRE("Este servidor pede a autenticação na hora; o agente não tem como digitá-la. Guarde uma chave ou a senha."), + SENHA_NAO_LEMBRADA("A senha não está guardada. Ligue \"lembrar senha\" para o agente poder usar este servidor."), + CHAVE_COM_PASSPHRASE("Esta chave pede a senha dela na hora; o agente não pode usá-la."), +} + +/** A marca "Pode ser usado pelo agente" só vale para host cuja autenticação o **app** resolve sozinho (CMP-09, D5). */ +object RegraAgente { + /** + * @param chavePedePassphrase diz se a chave [AutenticacaoHost.Chave.keyId] precisa de passphrase digitada na hora. As chaves + * do app (Keystore/envelope) nunca guardam passphrase, então o padrão é não. + */ + fun podeMarcar(host: Host, chavePedePassphrase: (String) -> Boolean = { false }): PodeMarcar = when (val a = host.autenticacao) { + AutenticacaoHost.Perguntar -> PodeMarcar.Nao(MotivoAgente.PERGUNTA_SEMPRE) + AutenticacaoHost.Senha -> if (host.lembrarSenha) PodeMarcar.Sim else PodeMarcar.Nao(MotivoAgente.SENHA_NAO_LEMBRADA) + is AutenticacaoHost.Chave -> if (chavePedePassphrase(a.keyId)) PodeMarcar.Nao(MotivoAgente.CHAVE_COM_PASSPHRASE) else PodeMarcar.Sim + } +} diff --git a/app/src/main/java/com/keyflow/computador/ValidadorHost.kt b/app/src/main/java/com/keyflow/computador/ValidadorHost.kt new file mode 100644 index 00000000..6010da54 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/ValidadorHost.kt @@ -0,0 +1,93 @@ +package com.keyflow.computador + +/** Campo de um host que a validação sabe julgar. */ +enum class CampoHost { NOME, ENDERECO, PORTA, USUARIO, LIMITE } + +/** Motivo da recusa, por campo. O texto da interface sai de `tr(…)` na tela; aqui só o código e uma frase em português. */ +enum class MotivoHost(val frase: String) { + NOME_VAZIO("Dê um nome ao servidor."), + NOME_LONGO("O nome pode ter no máximo 40 caracteres."), + NOME_REPETIDO("Já existe um servidor com esse nome."), + ENDERECO_VAZIO("Informe o endereço do servidor."), + ENDERECO_LONGO("O endereço pode ter no máximo 253 caracteres."), + ENDERECO_INVALIDO("O endereço não é um nome, um IPv4 nem um IPv6 válido."), + IPV6_SEM_COLCHETES("Endereço IPv6 vai entre colchetes, por exemplo [::1]."), + PORTA_INVALIDA("A porta vai de 1 a 65535."), + USUARIO_VAZIO("Informe o usuário."), + USUARIO_LONGO("O usuário pode ter no máximo 64 caracteres."), + USUARIO_INVALIDO("O usuário não pode ter espaços nem caracteres de controle."), + LISTA_CHEIA("O limite é de 50 servidores."), +} + +data class ErroHost(val campo: CampoHost, val motivo: MotivoHost) + +object ValidadorHost { + private val rotuloDns = Regex("^[A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?$") + private val ipv4 = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$") + + /** Valida [host] contra os [outros] já cadastrados (o próprio id é ignorado ao conferir o nome único). Lista vazia = válido. */ + fun validar(host: Host, outros: List = emptyList()): List { + val erros = mutableListOf() + val nome = host.nome.trim() + when { + nome.length < LimitesHost.NOME_MIN -> erros += ErroHost(CampoHost.NOME, MotivoHost.NOME_VAZIO) + nome.length > LimitesHost.NOME_MAX -> erros += ErroHost(CampoHost.NOME, MotivoHost.NOME_LONGO) + outros.any { it.id != host.id && it.nome.trim().equals(nome, ignoreCase = true) } -> + erros += ErroHost(CampoHost.NOME, MotivoHost.NOME_REPETIDO) + } + validarEndereco(host.endereco)?.let { erros += ErroHost(CampoHost.ENDERECO, it) } + if (host.porta !in LimitesHost.PORTA_MIN..LimitesHost.PORTA_MAX) erros += ErroHost(CampoHost.PORTA, MotivoHost.PORTA_INVALIDA) + when { + host.usuario.isEmpty() -> erros += ErroHost(CampoHost.USUARIO, MotivoHost.USUARIO_VAZIO) + host.usuario.length > LimitesHost.USUARIO_MAX -> erros += ErroHost(CampoHost.USUARIO, MotivoHost.USUARIO_LONGO) + host.usuario.any { it.isWhitespace() || it.isISOControl() } -> erros += ErroHost(CampoHost.USUARIO, MotivoHost.USUARIO_INVALIDO) + } + if (outros.none { it.id == host.id } && outros.size >= LimitesHost.MAX_HOSTS) erros += ErroHost(CampoHost.LIMITE, MotivoHost.LISTA_CHEIA) + return erros + } + + /** `null` = endereço válido. Aceita nome DNS, IPv4 e IPv6 entre colchetes (sem colchetes é recusado com o motivo). */ + fun validarEndereco(endereco: String): MotivoHost? { + if (endereco.isEmpty()) return MotivoHost.ENDERECO_VAZIO + if (endereco.length > LimitesHost.ENDERECO_MAX) return MotivoHost.ENDERECO_LONGO + if (endereco.startsWith("[")) { + return if (endereco.endsWith("]") && ipv6Valido(endereco.substring(1, endereco.length - 1))) null else MotivoHost.ENDERECO_INVALIDO + } + if (endereco.contains(':')) return if (ipv6Valido(endereco)) MotivoHost.IPV6_SEM_COLCHETES else MotivoHost.ENDERECO_INVALIDO + ipv4.matchEntire(endereco)?.let { m -> return if (m.groupValues.drop(1).all { it.toInt() in 0..255 }) null else MotivoHost.ENDERECO_INVALIDO } + // "1.2.3" ou "1.2.3.4.5" parecem IP, mas não são um nome: um nome DNS não termina em rótulo só de dígitos. + val rotulos = endereco.removeSuffix(".").split('.') + if (rotulos.any { !rotuloDns.matches(it) }) return MotivoHost.ENDERECO_INVALIDO + if (rotulos.last().all { it.isDigit() }) return MotivoHost.ENDERECO_INVALIDO + return null + } + + /** IPv6 textual (RFC 4291): 8 grupos hexadecimais, um `::` no máximo, IPv4 embutido no fim, `%zona` opcional. */ + internal fun ipv6Valido(texto: String): Boolean { + var t = texto.substringBefore('%') + if (t.isEmpty() || texto.substringAfter('%', "x").isEmpty()) return false + if (t.count { it == ':' } < 2) return false + var grupos = 0 + var ipv4Final = false + if (t.contains('.')) { + val ult = t.substringAfterLast(':') + val m = ipv4.matchEntire(ult) ?: return false + if (!m.groupValues.drop(1).all { it.toInt() in 0..255 }) return false + t = t.substring(0, t.length - ult.length) + "0:0" + ipv4Final = true + } + val dupla = t.indexOf("::") + if (dupla >= 0 && t.indexOf("::", dupla + 1) >= 0) return false + val partes = if (dupla >= 0) { + val esq = t.substring(0, dupla).let { if (it.isEmpty()) emptyList() else it.split(':') } + val dir = t.substring(dupla + 2).let { if (it.isEmpty()) emptyList() else it.split(':') } + esq + dir + } else t.split(':') + for (p in partes) { + if (p.isEmpty() || p.length > 4 || !p.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) return false + grupos++ + } + if (ipv4Final && grupos < 2) return false + return if (dupla >= 0) grupos <= 7 else grupos == 8 + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/BiometriaAndroid.kt b/app/src/main/java/com/keyflow/computador/app/BiometriaAndroid.kt new file mode 100644 index 00000000..4faea313 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/BiometriaAndroid.kt @@ -0,0 +1,66 @@ +package com.keyflow.computador.app + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +/** Quem autentica o usuário para liberar as chaves com biometria. Fake nos testes; [BiometriaAndroid] no aparelho. */ +interface AutenticadorDeUsuario { + /** Há biometria **forte** cadastrada? Sem isso a opção "exigir biometria" fica desligada com o motivo. */ + fun disponivel(): Disponibilidade + + /** Abre o prompt e devolve `true` se o usuário se autenticou; `false` se cancelou ou falhou. Nunca lança. */ + suspend fun autenticar(titulo: String, descricao: String): Boolean +} + +enum class Disponibilidade(val frase: String) { + SIM(""), + SEM_HARDWARE("Este aparelho não tem leitor de biometria."), + NADA_CADASTRADO("Cadastre uma digital ou o rosto nas configurações do aparelho para usar esta opção."), + INDISPONIVEL("A biometria não está disponível agora."), +} + +/** + * `androidx.biometric` com classe forte. O prompt **autentica o usuário**; durante os 300 s seguintes o Keystore assina sem novo prompt + * (duração > 0 não usa `CryptoObject`, D14). O `BiometricPrompt` precisa de uma [FragmentActivity] visível: [atividade] devolve a + * atual, ou `null` em segundo plano — então o prompt **não abre sozinho** e a autenticação conta como não feita (CMP-22). + */ +class BiometriaAndroid( + private val contexto: Context, + private val atividade: () -> FragmentActivity?, +) : AutenticadorDeUsuario { + + override fun disponivel(): Disponibilidade = when (BiometricManager.from(contexto).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { + BiometricManager.BIOMETRIC_SUCCESS -> Disponibilidade.SIM + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> Disponibilidade.SEM_HARDWARE + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> Disponibilidade.NADA_CADASTRADO + else -> Disponibilidade.INDISPONIVEL + } + + override suspend fun autenticar(titulo: String, descricao: String): Boolean { + val ativa = atividade() ?: return false + return suspendCancellableCoroutine { cont -> + val executor = ContextCompat.getMainExecutor(contexto) + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { if (cont.isActive) cont.resume(true) } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { if (cont.isActive) cont.resume(false) } + // onAuthenticationFailed (digital não reconhecida) deixa o prompt aberto para outra tentativa: nada a fazer. + } + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(titulo) + .setDescription(descricao) + .setNegativeButtonText(ativa.getString(android.R.string.cancel)) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + executor.execute { + val prompt = BiometricPrompt(ativa, executor, callback) + cont.invokeOnCancellation { executor.execute { prompt.cancelAuthentication() } } + prompt.authenticate(info) + } + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/CofreDoApp.kt b/app/src/main/java/com/keyflow/computador/app/CofreDoApp.kt new file mode 100644 index 00000000..b7efe270 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/CofreDoApp.kt @@ -0,0 +1,53 @@ +package com.keyflow.computador.app + +import com.keyflow.auth.CredentialStore +import com.keyflow.computador.chaves.CofreDeSegredos +import org.json.JSONArray + +/** + * O [CofreDeSegredos] sobre o cofre cifrado do `auth/` (`keyflow_auth`, o mesmo das chaves de API). O cofre não enumera as chaves, então + * um índice (também no cofre) lista as que a aba Computador gravou — é dele que sai [valores] para o `SecretFilter`. A pública + * (`ssh_pub.*`) não é segredo e **não** entra em [valores] (senão um `cat id.pub` no terminal sairia mutilado para o agente). + */ +class CofreDoApp(private val store: CredentialStore) : CofreDeSegredos { + + override fun ler(chave: String): String? = store.getSecret(chave) + + override fun gravar(chave: String, valor: String) = synchronized(TRAVA) { + store.putSecret(chave, valor) + val indice = indice() + if (chave !in indice) gravarIndice(indice + chave) + } + + override fun apagar(chave: String) = synchronized(TRAVA) { + store.clearSecret(chave) + val indice = indice() + if (chave in indice) gravarIndice(indice - chave) + } + + override fun valores(): Collection = synchronized(TRAVA) { + indice().filter { !it.startsWith("ssh_pub.") }.mapNotNull { store.getSecret(it)?.takeIf(String::isNotEmpty) } + } + + private fun indice(): Set { + val bruto = store.getSecret(INDICE) ?: return emptySet() + return try { + val a = JSONArray(bruto) + (0 until a.length()).mapNotNull { a.opt(it) as? String }.toSet() + } catch (e: org.json.JSONException) { + emptySet() + } + } + + private fun gravarIndice(chaves: Set) = store.putSecret(INDICE, JSONArray(chaves.toList()).toString()) + + companion object { + /** + * Uma trava **do processo**, não da instância: o índice mora no `CredentialStore`, que é compartilhado, e duas instâncias de + * [CofreDoApp] atualizando o índice ao mesmo tempo perderiam uma das entradas (e o `SecretFilter` deixaria de remover o segredo). + */ + private val TRAVA = Any() + + const val INDICE = "computador_indice" + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/DetectorDeCadastro.kt b/app/src/main/java/com/keyflow/computador/app/DetectorDeCadastro.kt new file mode 100644 index 00000000..fcc24fe8 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/DetectorDeCadastro.kt @@ -0,0 +1,81 @@ +package com.keyflow.computador.app + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +/** + * Detecta que a biometria cadastrada mudou (CMP-14). Nas APIs 24–29 uma chave com duração positiva (os 300 s) fica presa ao root SID e o + * `setInvalidatedByBiometricEnrollment(true)` **não** garante a invalidação ao cadastrar outra digital (AOSP `KeymasterUtils`, ramo de + * duração positiva). Então, nessas APIs, cada chave com biometria ganha uma **chave sentinela**: AES que exige autenticação a cada uso + * (duração -1), invalidada por cadastro novo. Só inicializar a cifra dela basta: `KeyPermanentlyInvalidatedException` = o cadastro mudou. + * Na API 30+ (`AUTH_BIOMETRIC_STRONG`) a própria chave já é invalidada, e este detector não faz nada. + */ +interface DetectorDeCadastro { + /** Cria a sentinela da chave [id] (no-op onde não é preciso). */ + fun criar(id: String) + + /** `true` se o cadastro biométrico mudou depois da criação da chave [id]. */ + fun invalidada(id: String): Boolean + + fun apagar(id: String) +} + +/** O Keystore por trás da sentinela; separado para o teste simular "criada na API 29, usada na 30+" sem aparelho com biometria. */ +interface SentinelaKeystore { + fun existe(id: String): Boolean + fun criar(id: String) + + /** `true` se a cifra da sentinela não inicializa por `KeyPermanentlyInvalidatedException`. */ + fun invalidada(id: String): Boolean + fun apagar(id: String) +} + +/** + * Sentinelas **novas** só nascem na API ≤ 29; as que **já existem** são consultadas em qualquer API. Depois de um OTA de 29 para 30+, a + * chave antiga continua com a janela de 300 s presa ao root SID e só a sentinela sabe que o cadastro mudou. + */ +class DetectorDeCadastroKeystore( + private val api: Int = Build.VERSION.SDK_INT, + private val sentinelas: SentinelaKeystore = SentinelaReal, +) : DetectorDeCadastro { + override fun criar(id: String) { if (api < 30) sentinelas.criar(id) } + + override fun invalidada(id: String): Boolean = sentinelas.existe(id) && sentinelas.invalidada(id) + + override fun apagar(id: String) = sentinelas.apagar(id) +} + +object SentinelaReal : SentinelaKeystore { + private fun alias(id: String) = "keyflow_ssh_sent_$id" + private fun ks(): KeyStore = KeyStore.getInstance(EnvelopeDeChave.PROVEDOR).apply { load(null) } + + override fun existe(id: String): Boolean = try { ks().containsAlias(alias(id)) } catch (e: Exception) { false } + + override fun criar(id: String) { + val spec = KeyGenParameterSpec.Builder(alias(id), KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + @Suppress("DEPRECATION") spec.setUserAuthenticationValidityDurationSeconds(-1) + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, EnvelopeDeChave.PROVEDOR).apply { init(spec.build()) }.generateKey() + } + + override fun invalidada(id: String): Boolean = try { + val k = ks().getKey(alias(id), null) as? SecretKey ?: return false + Cipher.getInstance("AES/CBC/PKCS7Padding").init(Cipher.ENCRYPT_MODE, k) + false + } catch (e: KeyPermanentlyInvalidatedException) { + true + } catch (e: Exception) { + false + } + + override fun apagar(id: String) { runCatching { ks().deleteEntry(alias(id)) } } +} diff --git a/app/src/main/java/com/keyflow/computador/app/EnvelopeDeChave.kt b/app/src/main/java/com/keyflow/computador/app/EnvelopeDeChave.kt new file mode 100644 index 00000000..83c62370 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/EnvelopeDeChave.kt @@ -0,0 +1,134 @@ +package com.keyflow.computador.app + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.util.Base64 +import com.keyflow.computador.chaves.CofreDeSegredos +import com.keyflow.computador.chaves.FormatoDoMaterial +import com.keyflow.computador.chaves.MaterialPrivado +import com.keyflow.computador.chaves.PlanoDeAutenticacao +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** O envelope não conseguiu devolver o segredo; [invalidada] distingue "a biometria mudou" de "não está mais aqui". */ +class EnvelopeIndisponivel(val invalidada: Boolean, causa: Throwable? = null) : Exception(if (invalidada) "chave invalidada" else "envelope indisponível", causa) + +/** + * O envelope (CMP-11): o segredo (semente Ed25519 ou OpenSSH sem senha) é cifrado com **AES-256-GCM de uma chave do Android Keystore** + * (API 24+, alias `keyflow_ssh_env_`, nunca exportável) e o texto cifrado vai ao cofre do app em `ssh_key.`. Em claro, o segredo só + * existe num array durante a assinatura (quem o recebe o zera). O id entra como dado autenticado (AAD): um texto cifrado de uma chave não + * abre sob o alias de outra. Com biometria, a chave AES exige autenticação com a **mesma janela de 300 s** (D14), sem `CryptoObject`. + */ +class EnvelopeDeChave( + private val cofre: CofreDeSegredos, + private val api: Int = Build.VERSION.SDK_INT, + private val aplicarBiometriaNoKeystore: Boolean = true, +) { + private val ks: KeyStore get() = KeyStore.getInstance(PROVEDOR).apply { load(null) } + + fun aliasDe(id: String) = "$PREFIXO_ALIAS$id" + fun chaveDoCofre(id: String) = "$PREFIXO_COFRE$id" + + /** Guarda [material] cifrado. **Não** zera o array recebido: quem o gerou decide (o importador e o provedor o zeram). */ + fun cifrar(id: String, material: MaterialPrivado, exigeBiometria: Boolean) { + val alias = aliasDe(id) + val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + // IV aleatório pelo próprio Keystore (o padrão exige): o IV vai junto do texto cifrado. + .setRandomizedEncryptionRequired(true) + if (exigeBiometria && aplicarBiometriaNoKeystore) AutenticacaoKeystore.aplicar(spec, api) + val gerador = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVEDOR) + gerador.init(spec.build()) + val chave: SecretKey = gerador.generateKey() + try { + val cifra = Cipher.getInstance(TRANSFORMACAO) + cifra.init(Cipher.ENCRYPT_MODE, chave) + cifra.updateAAD(aad(id)) + val cifrado = cifra.doFinal(material.bytes) + val pacote = byteArrayOf(VERSAO, codigo(material.formato)) + byteArrayOf(cifra.iv.size.toByte()) + cifra.iv + cifrado + cofre.gravar(chaveDoCofre(id), Base64.encodeToString(pacote, Base64.NO_WRAP)) + } catch (e: Exception) { + apagar(id) + throw e + } + } + + /** Decifra. Lança [EnvelopeIndisponivel] se a chave foi invalidada ou sumiu; deixa passar `UserNotAuthenticatedException` (o chamador pede a biometria). */ + fun decifrar(id: String): MaterialPrivado { + val texto = cofre.ler(chaveDoCofre(id)) ?: throw EnvelopeIndisponivel(false) + val pacote = try { Base64.decode(texto, Base64.NO_WRAP) } catch (e: IllegalArgumentException) { throw EnvelopeIndisponivel(false, e) } + if (pacote.size < 4 || pacote[0] != VERSAO) throw EnvelopeIndisponivel(false) + val formato = formato(pacote[1]) ?: throw EnvelopeIndisponivel(false) + val ivLen = pacote[2].toInt() and 0xff + if (pacote.size < 3 + ivLen + 16) throw EnvelopeIndisponivel(false) + val iv = pacote.copyOfRange(3, 3 + ivLen) + val cifrado = pacote.copyOfRange(3 + ivLen, pacote.size) + val chave = ks.getKey(aliasDe(id), null) as? SecretKey ?: throw EnvelopeIndisponivel(false) + return try { + val cifra = Cipher.getInstance(TRANSFORMACAO) + cifra.init(Cipher.DECRYPT_MODE, chave, GCMParameterSpec(128, iv)) + cifra.updateAAD(aad(id)) + MaterialPrivado(formato, cifra.doFinal(cifrado)) + } catch (e: KeyPermanentlyInvalidatedException) { + throw EnvelopeIndisponivel(true, e) + } catch (e: java.security.GeneralSecurityException) { + if (e is android.security.keystore.UserNotAuthenticatedException) throw e + throw EnvelopeIndisponivel(false, e) + } + } + + /** Teste barato de "a chave de envelope foi invalidada?" — só inicializa a cifra, sem decifrar nada. */ + fun invalidada(id: String): Boolean { + val chave = ks.getKey(aliasDe(id), null) as? SecretKey ?: return false + return try { + Cipher.getInstance(TRANSFORMACAO).init(Cipher.DECRYPT_MODE, chave, GCMParameterSpec(128, ByteArray(12))) + false + } catch (e: KeyPermanentlyInvalidatedException) { + true + } catch (e: Exception) { + false + } + } + + /** Apaga o alias do Keystore e o texto cifrado do cofre. */ + fun apagar(id: String) { + runCatching { ks.deleteEntry(aliasDe(id)) } + cofre.apagar(chaveDoCofre(id)) + } + + private fun aad(id: String) = "keyflow-ssh:$id".toByteArray(Charsets.UTF_8) + + private fun codigo(f: FormatoDoMaterial): Byte = when (f) { FormatoDoMaterial.SEMENTE_ED25519 -> 1; FormatoDoMaterial.OPENSSH_SEM_SENHA -> 2 } + private fun formato(b: Byte): FormatoDoMaterial? = when (b.toInt()) { 1 -> FormatoDoMaterial.SEMENTE_ED25519; 2 -> FormatoDoMaterial.OPENSSH_SEM_SENHA; else -> null } + + companion object { + const val PROVEDOR = "AndroidKeyStore" + const val PREFIXO_ALIAS = "keyflow_ssh_env_" + const val PREFIXO_COFRE = "ssh_key." + private const val TRANSFORMACAO = "AES/GCM/NoPadding" + private const val VERSAO: Byte = 1 + } +} + +/** Aplica a janela de 300 s ao `KeyGenParameterSpec` conforme a API (D14). Fica aqui porque é o que toca o `android.security.keystore`. */ +internal object AutenticacaoKeystore { + fun aplicar(spec: KeyGenParameterSpec.Builder, api: Int) { + spec.setUserAuthenticationRequired(true) + when (val plano = PlanoDeAutenticacao.para(api)) { + is PlanoDeAutenticacao.Parametros -> + // O `api` recebido é o do plano; a guarda de SDK é a que o lint e o aparelho enxergam. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) spec.setUserAuthenticationParameters(plano.segundos, KeyProperties.AUTH_BIOMETRIC_STRONG) + else @Suppress("DEPRECATION") spec.setUserAuthenticationValidityDurationSeconds(plano.segundos) + is PlanoDeAutenticacao.Validade -> @Suppress("DEPRECATION") spec.setUserAuthenticationValidityDurationSeconds(plano.segundos) + } + // Com aviso antes de ligar (a tela): trocar a biometria cadastrada invalida a chave. + spec.setInvalidatedByBiometricEnrollment(true) + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/LigacaoSessoes.kt b/app/src/main/java/com/keyflow/computador/app/LigacaoSessoes.kt new file mode 100644 index 00000000..1b2db9ad --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/LigacaoSessoes.kt @@ -0,0 +1,19 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.sessao.EventoGerenciador +import com.keyflow.computador.sessao.GerenciadorDeSessoes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch + +/** + * Chama [iniciar] quando a 1ª sessão viva abre. A inscrição nos eventos acontece **antes de retornar** (`UNDISPATCHED`): com o `launch` + * comum, uma sessão aberta logo depois de ligar publicava `PrimeiraSessao` antes de alguém estar ouvindo (o fluxo não tem replay) e o + * serviço nunca subia — a instabilidade medida no `SessoesServiceTest` (1 falha em 15). Sessões que já estavam vivas também contam. + */ +internal fun observarPrimeiraSessao(gerenciador: GerenciadorDeSessoes, escopo: CoroutineScope, iniciar: () -> Unit) { + escopo.launch(start = CoroutineStart.UNDISPATCHED) { + if (gerenciador.resumos.value.any { !it.estado.terminal }) iniciar() + gerenciador.eventos.collect { if (it == EventoGerenciador.PrimeiraSessao) iniciar() } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/NotificacaoSessoes.kt b/app/src/main/java/com/keyflow/computador/app/NotificacaoSessoes.kt new file mode 100644 index 00000000..2a3aa676 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/NotificacaoSessoes.kt @@ -0,0 +1,53 @@ +package com.keyflow.computador.app + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.keyflow.i18n.tr + +/** + * A notificação das sessões SSH (CMP-32): **só a contagem**. Nunca servidor, usuário, comando nem saída. Ações: "Desconectar todas" e, se + * houver sessão do agente, "Parar o agente". Canal próprio, importância baixa; não usa o canal da orbe. + */ +object NotificacaoSessoes { + const val CANAL = "sessoes_computador" + const val ID = 4107 + + fun garantirCanal(contexto: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = contexto.getSystemService(NotificationManager::class.java) + if (nm.getNotificationChannel(CANAL) != null) return + nm.createNotificationChannel( + NotificationChannel(CANAL, tr("Sessões do Computador"), NotificationManager.IMPORTANCE_LOW).apply { + description = tr("Sessões SSH mantidas conectadas em segundo plano") + setShowBadge(false) + }, + ) + } + + /** O título: contagem no singular ou no plural. Público porque o teste confere que nada além disso aparece. */ + fun titulo(n: Int): String = if (n == 1) tr("%d sessão SSH aberta").format(n) else tr("%d sessões SSH abertas").format(n) + + fun montar(contexto: Context, sessoes: Int, comAgente: Boolean, abrir: Intent?): Notification { + garantirCanal(contexto) + val b = NotificationCompat.Builder(contexto, CANAL) + .setSmallIcon(android.R.drawable.ic_lock_idle_lock) + .setContentTitle(titulo(sessoes)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .addAction(0, tr("Desconectar todas"), acao(contexto, SessoesService.ACAO_DESCONECTAR_TODAS, 1)) + if (comAgente) b.addAction(0, tr("Parar o agente"), acao(contexto, SessoesService.ACAO_PARAR_AGENTE, 2)) + if (abrir != null) b.setContentIntent(PendingIntent.getActivity(contexto, 3, abrir, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)) + return b.build() + } + + private fun acao(contexto: Context, acao: String, codigo: Int): PendingIntent = + PendingIntent.getService(contexto, codigo, Intent(contexto, SessoesService::class.java).setAction(acao), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) +} diff --git a/app/src/main/java/com/keyflow/computador/app/ProvedorDeChavesAndroid.kt b/app/src/main/java/com/keyflow/computador/app/ProvedorDeChavesAndroid.kt new file mode 100644 index 00000000..a309374c --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/ProvedorDeChavesAndroid.kt @@ -0,0 +1,369 @@ +package com.keyflow.computador.app + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.security.keystore.UserNotAuthenticatedException +import com.keyflow.computador.Base64Puro +import com.keyflow.computador.chaves.Assinador +import com.keyflow.computador.chaves.AssinadorSoftware +import com.keyflow.computador.chaves.AvaliadorDaSonda +import com.keyflow.computador.chaves.ChavePublicaSsh +import com.keyflow.computador.chaves.ChaveSsh +import com.keyflow.computador.chaves.ChavesSoftware +import com.keyflow.computador.chaves.CofreDeSegredos +import com.keyflow.computador.chaves.ErroChave +import com.keyflow.computador.chaves.EstadoChave +import com.keyflow.computador.chaves.FormatadorChavePublica +import com.keyflow.computador.chaves.ImportadorDeChave +import com.keyflow.computador.chaves.JanelaDeBiometria +import com.keyflow.computador.chaves.MaterialPrivado +import com.keyflow.computador.chaves.Protecao +import com.keyflow.computador.chaves.RelogioDeChaves +import com.keyflow.computador.chaves.Resultado +import com.keyflow.computador.chaves.TipoDeChave +import java.math.BigInteger +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.util.UUID + +/** A proteção que a tela diz ao usuário (CMP-10): nunca em silêncio. */ +fun Protecao.frase(): String = when (this) { + Protecao.HARDWARE -> "Protegida pelo hardware do aparelho" + Protecao.SOFTWARE_COFRE -> "Protegida pelo Keystore (por software)" + Protecao.KEYSTORE_ENVELOPE -> "Guardada cifrada no cofre do aparelho" +} + +/** + * As chaves no Android (CMP-10, 11, 13, 14, 16). **ECDSA P-256** nasce sempre no Keystore, sem envelope (D13). **Ed25519** nasce no + * Keystore só quando a API é 33+ e a [sonda] aprova; senão (ou para chave importada) vira **envelope**: semente em software cifrada por + * uma chave AES-GCM do Keystore. A biometria é opcional por chave, com janela de 300 s (D14). + * + * [aplicarBiometriaNoKeystore] só existe para testes em aparelho sem biometria cadastrada: com `false`, a chave não pede autenticação ao + * Keystore e a janela é aplicada só por esta classe (a lógica do prompt), mas a escolha de API continua em [AutenticacaoKeystore]. + */ +class ProvedorDeChavesAndroid( + private val cofre: CofreDeSegredos, + private val autenticador: AutenticadorDeUsuario, + private val relogio: RelogioDeChaves = RelogioDeChaves { System.currentTimeMillis() }, + private val api: Int = Build.VERSION.SDK_INT, + private val sonda: SondaEd25519 = SondaEd25519(api), + private val forcarEnvelopeEd25519: Boolean = false, + private val aplicarBiometriaNoKeystore: Boolean = true, + private val detector: DetectorDeCadastro = DetectorDeCadastroKeystore(api), + private val gerarEd25519Software: () -> ChavesSoftware.Ed25519Gerada = ChavesSoftware::gerarEd25519, + private val importador: ImportadorDeChave = ImportadorDeChave(), +) { + private val envelope = EnvelopeDeChave(cofre, api, aplicarBiometriaNoKeystore) + private val janelas = HashMap() + + private fun ks(): KeyStore = KeyStore.getInstance(EnvelopeDeChave.PROVEDOR).apply { load(null) } + private fun alias(id: String) = "$PREFIXO_ALIAS$id" + // O estado INVALIDADA é **persistente** (no cofre): sobrevive a reinício do processo e a OTA, e uma chave invalidada não volta a funcionar. + private fun marcaInvalidada(id: String) = "$PREFIXO_INVALIDADA$id" + /** Leitura incerta = bloqueado (fail-closed): se o cofre não responde, a chave conta como invalidada e não assina. */ + private fun jaInvalidada(id: String): Boolean = try { cofre.ler(marcaInvalidada(id)) != null } catch (e: Exception) { true } + private fun invalidar(id: String) = cofre.gravar(marcaInvalidada(id), "1") + + private fun novoId() = UUID.randomUUID().toString().replace("-", "").take(8) + + /** Veredito da sonda do Ed25519 neste aparelho (para a tela explicar por que a chave caiu no envelope). */ + fun veredictoDaSonda(): AvaliadorDaSonda.Veredito = sonda.avaliar() + + /** Como `gerar(ED25519)` vai guardar a chave neste aparelho, sem gerar nada. */ + fun ed25519VaiParaOKeystore(): Boolean = !forcarEnvelopeEd25519 && api >= AvaliadorDaSonda.API_MINIMA && sonda.avaliar() is AvaliadorDaSonda.Veredito.Aprovada + + suspend fun gerar(nome: String, tipo: TipoDeChave, exigeBiometria: Boolean): Resultado { + if (tipo != TipoDeChave.ED25519 && tipo != TipoDeChave.ECDSA_P256) return Resultado.Falha(ErroChave.TIPO_NAO_SUPORTADO) + if (exigeBiometria && autenticador.disponivel() != Disponibilidade.SIM) return Resultado.Falha(ErroChave.SEM_BIOMETRIA) + val id = novoId() + return try { + when (tipo) { + TipoDeChave.ECDSA_P256 -> gerarNoKeystore(id, nome, tipo, exigeBiometria) + else -> if (ed25519VaiParaOKeystore()) gerarNoKeystore(id, nome, tipo, exigeBiometria) else gerarEnvelopeEd25519(id, nome, exigeBiometria) + } + } catch (e: Exception) { + excluirArtefatos(id) + Resultado.Falha(ErroChave.FALHA_DO_APARELHO) + } + } + + /** Chave importada entra sempre no envelope; o texto e a passphrase são zerados pelo importador. */ + suspend fun importar(pem: CharArray, passphrase: CharArray?, nome: String, exigeBiometria: Boolean): Resultado { + if (exigeBiometria && autenticador.disponivel() != Disponibilidade.SIM) { + pem.fill('\u0000'); passphrase?.fill('\u0000') + return Resultado.Falha(ErroChave.SEM_BIOMETRIA) + } + return when (val r = importador.importar(pem, passphrase)) { + is Resultado.Falha -> r + is Resultado.Ok -> { + val id = novoId() + val m = r.valor.material + try { + if (exigeBiometria && !autenticarParaCriar(id)) return Resultado.Falha(ErroChave.CANCELADA) + envelope.cifrar(id, m, exigeBiometria) + if (exigeBiometria && aplicarBiometriaNoKeystore) detector.criar(id) + cofre.gravar("$PREFIXO_PUBLICA$id", Base64Puro.codificar(r.valor.publica.blob)) + Resultado.Ok(ChaveSsh(id, nome, r.valor.tipo, r.valor.impressao, Protecao.KEYSTORE_ENVELOPE, exigeBiometria, relogio.agoraMs())) + } catch (e: Exception) { + excluirArtefatos(id) + Resultado.Falha(ErroChave.FALHA_DO_APARELHO) + } finally { + m.zerar() + } + } + } + } + + /** A pública no formato do tipo (CMP-13); `null` se a chave não está mais no aparelho. */ + fun chavePublica(chave: ChaveSsh): ChavePublicaSsh? = try { + when (chave.protecao) { + Protecao.KEYSTORE_ENVELOPE -> publicaGuardada(chave.id) + else -> publicaDoKeystore(chave.id, chave.tipo) + } + } catch (e: Exception) { + null + } + + fun linhaDaPublica(chave: ChaveSsh, aparelho: String): String? = chavePublica(chave)?.let { FormatadorChavePublica.linha(it, aparelho) } + + fun assinador(chave: ChaveSsh): Assinador = AssinadorAndroid(chave) + + /** `INVALIDADA` quando o Keystore invalidou a chave (biometria cadastrada mudou); a tela mostra a mensagem. */ + fun estado(chave: ChaveSsh): EstadoChave { + if (jaInvalidada(chave.id)) return EstadoChave.INVALIDADA + val invalida = try { + (chave.exigeBiometria && detector.invalidada(chave.id)) || + if (chave.protecao == Protecao.KEYSTORE_ENVELOPE) envelope.invalidada(chave.id) else nativaInvalidada(chave.id, chave.tipo) + } catch (e: Exception) { + false + } + if (invalida) invalidar(chave.id) + return if (invalida) EstadoChave.INVALIDADA else EstadoChave.ATIVA + } + + /** Apaga o alias do Keystore e o texto cifrado do cofre. */ + fun excluir(chave: ChaveSsh) = excluirArtefatos(chave.id) + + private fun excluirArtefatos(id: String) { + runCatching { ks().deleteEntry(alias(id)) } + runCatching { detector.apagar(id) } + envelope.apagar(id) + cofre.apagar("$PREFIXO_PUBLICA$id") + janelas.remove(id) + cofre.apagar(marcaInvalidada(id)) + } + + /** + * Cifrar com uma chave AES que exige autenticação também pede a janela aberta: o prompt vem antes, e a janela já nasce marcada. + * Chave nativa (P-256/Ed25519 no Keystore) não precisa: gerar o par não usa a chave. + */ + private suspend fun autenticarParaCriar(id: String): Boolean { + val ok = autenticador.autenticar("Criar a chave", "Confirme que é você para guardar esta chave.") + if (ok) janelas.getOrPut(id) { JanelaDeBiometria(relogio) }.marcar() + return ok + } + + // ---- geração ------------------------------------------------------------------------------------------------------------------ + + private fun gerarNoKeystore(id: String, nome: String, tipo: TipoDeChave, exigeBiometria: Boolean): Resultado { + val ed = tipo == TipoDeChave.ED25519 + val spec = KeyGenParameterSpec.Builder(alias(id), KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec(if (ed) "ed25519" else "secp256r1")) + .setDigests(if (ed) KeyProperties.DIGEST_NONE else KeyProperties.DIGEST_SHA256) + if (exigeBiometria && aplicarBiometriaNoKeystore) AutenticacaoKeystore.aplicar(spec, api) + val gerador = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, EnvelopeDeChave.PROVEDOR) + gerador.initialize(spec.build()) + gerador.generateKeyPair() + if (exigeBiometria && aplicarBiometriaNoKeystore) detector.criar(id) + val publica = publicaDoKeystore(id, tipo) ?: error("pública ausente") + val protecao = nivelDeProtecao(id) + return Resultado.Ok(ChaveSsh(id, nome, tipo, publica.impressao, protecao, exigeBiometria, relogio.agoraMs())) + } + + private suspend fun gerarEnvelopeEd25519(id: String, nome: String, exigeBiometria: Boolean): Resultado { + if (exigeBiometria && !autenticarParaCriar(id)) return Resultado.Falha(ErroChave.CANCELADA) + val g = gerarEd25519Software() + try { + envelope.cifrar(id, g.material, exigeBiometria) + if (exigeBiometria && aplicarBiometriaNoKeystore) detector.criar(id) + // O envelope guarda a pública junto da semente: o Keystore não a conhece. + cofre.gravar("$PREFIXO_PUBLICA$id", Base64Puro.codificar(g.publica.blob)) + return Resultado.Ok(ChaveSsh(id, nome, TipoDeChave.ED25519, g.publica.impressao, Protecao.KEYSTORE_ENVELOPE, exigeBiometria, relogio.agoraMs())) + } finally { + g.material.zerar() + } + } + + private fun publicaGuardada(id: String): ChavePublicaSsh? { + val b64 = cofre.ler("$PREFIXO_PUBLICA$id") ?: return null + val blob = Base64Puro.decodificar(b64) ?: return null + val tipo = FormatadorChavePublica.tipoDoBlob(blob) ?: return null + return ChavePublicaSsh(if (tipo == "ssh-rsa") "rsa-sha2-256" else tipo, blob) + } + + private fun publicaDoKeystore(id: String, tipo: TipoDeChave): ChavePublicaSsh? { + val cert = ks().getCertificate(alias(id)) ?: return null + val pub = cert.publicKey + return when (tipo) { + TipoDeChave.ED25519 -> { + val bytes = AvaliadorDaSonda.bytesDaPublica(pub.encoded) ?: return null + ChavePublicaSsh("ssh-ed25519", FormatadorChavePublica.blobEd25519(bytes)) + } + TipoDeChave.ECDSA_P256 -> { + val ec = pub as? ECPublicKey ?: return null + ChavePublicaSsh("ecdsa-sha2-nistp256", FormatadorChavePublica.blobEcdsaP256(pontoNaoComprimido(ec))) + } + else -> null + } + } + + private fun pontoNaoComprimido(k: ECPublicKey): ByteArray { + fun fixo(v: BigInteger): ByteArray = v.toByteArray().let { b -> ByteArray(32).also { out -> + val src = if (b.size > 32) b.copyOfRange(b.size - 32, b.size) else b + System.arraycopy(src, 0, out, 32 - src.size, src.size) + } } + return byteArrayOf(4) + fixo(k.w.affineX) + fixo(k.w.affineY) + } + + private fun nivelDeProtecao(id: String): Protecao = try { + val privada = ks().getKey(alias(id), null) as PrivateKey + val info = KeyFactory.getInstance(privada.algorithm, EnvelopeDeChave.PROVEDOR).getKeySpec(privada, KeyInfo::class.java) + val hardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT || info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX + } else { + @Suppress("DEPRECATION") info.isInsideSecureHardware + } + if (hardware) Protecao.HARDWARE else Protecao.SOFTWARE_COFRE + } catch (e: Exception) { + // Não deu para ler o nível: assume o mais fraco e diz isso, em vez de prometer hardware. + Protecao.SOFTWARE_COFRE + } + + private fun nativaInvalidada(id: String, tipo: TipoDeChave): Boolean { + val privada = ks().getKey(alias(id), null) as? PrivateKey ?: return false + return try { + iniciarAssinatura(privada, tipo) + false + } catch (e: KeyPermanentlyInvalidatedException) { + true + } catch (e: UserNotAuthenticatedException) { + false + } + } + + /** Ed25519 do Keystore: o nome do algoritmo de `Signature` varia por provedor (`Ed25519` ou `EdDSA`); tenta os dois. */ + private fun iniciarAssinatura(privada: PrivateKey, tipo: TipoDeChave): Signature { + if (tipo != TipoDeChave.ED25519) return Signature.getInstance("SHA256withECDSA").apply { initSign(privada) } + var ultimo: Exception? = null + for (nome in listOf("Ed25519", "EdDSA")) { + try { + return Signature.getInstance(nome).apply { initSign(privada) } + } catch (e: UserNotAuthenticatedException) { + throw e + } catch (e: KeyPermanentlyInvalidatedException) { + throw e + } catch (e: Exception) { + ultimo = e + } + } + throw ultimo ?: IllegalStateException("nenhum nome de Signature aceito") + } + + // ---- assinatura --------------------------------------------------------------------------------------------------------------- + + private inner class AssinadorAndroid(private val chave: ChaveSsh) : Assinador { + private val janela get() = janelas.getOrPut(chave.id) { JanelaDeBiometria(relogio) } + + override val invalidada: Boolean get() = jaInvalidada(chave.id) + + override fun chavePublica(): ChavePublicaSsh = this@ProvedorDeChavesAndroid.chavePublica(chave) ?: error("chave ausente: ${chave.id}") + + override suspend fun assinar(dados: ByteArray): ByteArray? { + if (jaInvalidada(chave.id)) return null + // APIs 24–29: o Keystore pode não invalidar a chave ao cadastrar outra digital; a sentinela diz. + if (chave.exigeBiometria && detector.invalidada(chave.id)) { invalidar(chave.id); return null } + if (chave.exigeBiometria && !janela.valida() && !pedirBiometria()) return null + return try { + assinarAgora(dados) + } catch (e: UserNotAuthenticatedException) { + // O Keystore diz que a janela dele venceu (o nosso relógio e o do Keystore podem divergir): um prompt e uma nova tentativa. + janela.encerrar() + if (!pedirBiometria()) return null + try { assinarAgora(dados) } catch (e2: UserNotAuthenticatedException) { null } catch (e2: EnvelopeIndisponivel) { marcarInvalidada(e2) } + } catch (e: KeyPermanentlyInvalidatedException) { + invalidar(chave.id) + null + } catch (e: EnvelopeIndisponivel) { + marcarInvalidada(e) + } catch (e: java.security.GeneralSecurityException) { + // Falha do aparelho ao usar a chave: sem assinatura (o cbssh pula a chave), nunca uma exceção para a sessão. + null + } + } + + private fun marcarInvalidada(e: EnvelopeIndisponivel): ByteArray? { + if (e.invalidada) invalidar(chave.id) + return null + } + + private suspend fun pedirBiometria(): Boolean { + val ok = autenticador.autenticar("Usar a chave ${chave.nome.take(40)}", "Confirme que é você para liberar esta chave por 5 minutos.") + if (ok) janela.marcar() + return ok + } + + private suspend fun assinarAgora(dados: ByteArray): ByteArray? = when (chave.protecao) { + Protecao.KEYSTORE_ENVELOPE -> AssinadorSoftware(chavePublica()) { envelope.decifrar(chave.id) }.assinar(dados) + else -> assinarNoKeystore(dados) + } + + private fun assinarNoKeystore(dados: ByteArray): ByteArray { + val privada = ks().getKey(alias(chave.id), null) as? PrivateKey ?: throw EnvelopeIndisponivel(false) + val assinatura = iniciarAssinatura(privada, chave.tipo).apply { update(dados) }.sign() + return if (chave.tipo == TipoDeChave.ED25519) { + FormatadorChavePublica.campo("ssh-ed25519") + FormatadorChavePublica.campo(assinatura) + } else { + val (r, s) = RsDeDer.separar(assinatura) + FormatadorChavePublica.campo("ecdsa-sha2-nistp256") + FormatadorChavePublica.campo(FormatadorChavePublica.mpint(r) + FormatadorChavePublica.mpint(s)) + } + } + } + + companion object { + const val PREFIXO_ALIAS = "keyflow_ssh_" + const val PREFIXO_PUBLICA = "ssh_pub." + const val PREFIXO_INVALIDADA = "ssh_inv." + } +} + +/** Separa `r` e `s` de uma assinatura ECDSA em DER (`SEQUENCE { INTEGER r, INTEGER s }`). */ +internal object RsDeDer { + fun separar(der: ByteArray): Pair { + require(der.isNotEmpty() && der[0] == 0x30.toByte()) { "DER inesperado" } + var i = lerComprimento(der, 1).second + require(der[i++] == 0x02.toByte()) + val (lr, ir) = lerComprimento(der, i) + val r = BigInteger(1, der.copyOfRange(ir, ir + lr)) + i = ir + lr + require(der[i++] == 0x02.toByte()) + val (ls, is_) = lerComprimento(der, i) + return r to BigInteger(1, der.copyOfRange(is_, is_ + ls)) + } + + private fun lerComprimento(b: ByteArray, inicio: Int): Pair { + val primeiro = b[inicio].toInt() and 0xff + if (primeiro < 0x80) return primeiro to inicio + 1 + val n = primeiro and 0x7f + var v = 0 + for (k in 1..n) v = (v shl 8) or (b[inicio + k].toInt() and 0xff) + return v to inicio + 1 + n + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/RepositorioComputador.kt b/app/src/main/java/com/keyflow/computador/app/RepositorioComputador.kt new file mode 100644 index 00000000..576e522e --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/RepositorioComputador.kt @@ -0,0 +1,108 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.ConfigHosts +import com.keyflow.computador.Host +import com.keyflow.computador.MigracaoHostUnico +import com.keyflow.computador.PoliticaAgente +import com.keyflow.computador.RepositorioDeConfianca +import com.keyflow.computador.chaves.ChaveSsh +import com.keyflow.computador.chaves.CofreDeSegredos +import com.keyflow.computador.chaves.Resultado +import com.keyflow.data.SettingsRepository +import java.io.File +import kotlinx.coroutines.flow.Flow + +/** + * A persistência da aba Computador (CMP-05, 06, 07, 15, 19) sobre o mesmo DataStore das Settings: hosts e metadados de chave são JSON + * sem segredo; senhas lembradas moram no [cofre] (`ssh_pw.`); a confiança nos servidores, no arquivo `known_hosts`. + */ +class RepositorioComputador( + private val settings: SettingsRepository, + private val cofre: CofreDeSegredos, + arquivoKnownHosts: File, +) { + val confianca: RepositorioDeConfianca = RepositorioKnownHostsArquivo(arquivoKnownHosts) + + fun hostsFlow(): Flow = settings.hostsFlow() + suspend fun hostsOnce(): ConfigHosts = settings.hostsOnce() + suspend fun atualizarHosts(f: (ConfigHosts) -> ConfigHosts): ConfigHosts = settings.atualizarHosts(f) + fun chavesFlow(): Flow> = settings.chavesFlow() + suspend fun chavesOnce(): List = settings.chavesOnce() + suspend fun atualizarChaves(f: (List) -> List): List = settings.atualizarChaves(f) + + fun senhaLembrada(hostId: String): String? = cofre.ler("$PREFIXO_SENHA$hostId") + fun lembrarSenha(hostId: String, senha: String) = cofre.gravar("$PREFIXO_SENHA$hostId", senha) + + /** + * CMP-07: fecha as sessões do host, apaga a entrada do `known_hosts`, a senha lembrada, e tira o host da lista (a marca do agente vai + * junto). A **chave não é tocada**: pode servir a outros hosts. + */ + suspend fun excluirHost(hostId: String, fecharSessoes: suspend (String) -> Unit) { + val host = hostsOnce().porId(hostId) ?: return + fecharSessoes(hostId) + confianca.esquecer(host.endereco, host.porta) + cofre.apagar("$PREFIXO_SENHA$hostId") + atualizarHosts { c -> ConfigHosts(c.hosts.filter { it.id != hostId }) } + } + + /** Os hosts que usam a chave, para a confirmação de exclusão (CMP-15). */ + suspend fun hostsDaChave(chaveId: String): List = hostsOnce().hosts.filter { (it.autenticacao as? AutenticacaoHost.Chave)?.keyId == chaveId } + + /** + * CMP-15: os hosts que usavam a chave ficam "sem chave" (`Perguntar`) e perdem a marca do agente (CMP-09 não vale mais); depois a chave + * sai dos metadados e [apagarMaterial] a tira do Keystore e do cofre. Devolve os hosts afetados. + */ + suspend fun excluirChave(chave: ChaveSsh, apagarMaterial: (ChaveSsh) -> Unit): List { + val afetados = hostsDaChave(chave.id) + atualizarHosts { c -> + ConfigHosts(c.hosts.map { h -> + if ((h.autenticacao as? AutenticacaoHost.Chave)?.keyId == chave.id) h.copy(autenticacao = AutenticacaoHost.Perguntar, agente = PoliticaAgente.PROIBIDO) else h + }) + } + atualizarChaves { l -> l.filter { it.id != chave.id } } + apagarMaterial(chave) + return afetados + } + + /** + * CMP-06, no primeiro uso: com `computerSshHost` preenchido e sem host novo, cria o host "Servidor SSH", leva a senha ao segredo do + * host e a chave privada a uma `ChaveSsh` importada por [importarChave], grava hosts+chaves+marca numa só transação e **só então** apaga + * `computer_ssh_password`/`computer_ssh_key` do cofre antigo. Rodar de novo não duplica. Se a chave antiga não puder ser importada, o + * host fica com autenticação "perguntar" e a chave legada **não é apagada** (nada se perde em silêncio). Devolve se migrou algo. + */ + suspend fun migrarHostUnico(importarChave: suspend (CharArray) -> Resultado): Boolean { + if (settings.migracaoComputadorFeita()) return false + val legado = settings.hostUnicoLegado() + val existente = hostsOnce() + val migracao = MigracaoHostUnico.migrar(legado, existente) + if (migracao == null) { + // Nada a migrar (legado vazio, ou já há hosts novos): marca e segue. Legado inválido não é apagado. + if (legado.host.isBlank() || existente.hosts.isNotEmpty()) settings.marcarMigracaoComputador() + return false + } + var host = migracao.config.hosts.single() + var chaveCriada: ChaveSsh? = null + var apagarChaveLegada = true + migracao.segredos.chavePem?.let { pem -> + when (val r = importarChave(pem.toCharArray())) { + is Resultado.Ok -> { chaveCriada = r.valor; host = host.copy(autenticacao = AutenticacaoHost.Chave(r.valor.id)) } + is Resultado.Falha -> { host = host.copy(autenticacao = AutenticacaoHost.Perguntar); apagarChaveLegada = false } + } + } + migracao.segredos.senha?.let { cofre.gravar("$PREFIXO_SENHA${host.id}", it) } + try { + settings.gravarMigracaoComputador(ConfigHosts(listOf(host)), listOfNotNull(chaveCriada)) + } catch (e: Exception) { + cofre.apagar("$PREFIXO_SENHA${host.id}") + throw e + } + if (apagarChaveLegada) settings.apagarSegredosDoHostUnicoLegado() + else settings.saveSecret(com.keyflow.data.SettingsCredentialKeys.COMPUTER_SSH_PASSWORD, "") + return true + } + + companion object { + const val PREFIXO_SENHA = "ssh_pw." + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/RepositorioKnownHostsArquivo.kt b/app/src/main/java/com/keyflow/computador/app/RepositorioKnownHostsArquivo.kt new file mode 100644 index 00000000..cca4c6fd --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/RepositorioKnownHostsArquivo.kt @@ -0,0 +1,30 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.KnownHostsArquivo +import com.keyflow.computador.RepositorioDeConfianca +import java.io.File + +/** + * O `known_hosts` em `filesDir` (formato OpenSSH, **fora do backup**, CMP-19). Cada operação lê o arquivo, aplica a mudança pura de + * [KnownHostsArquivo] e grava de forma atômica (arquivo temporário + renomear): uma queda no meio não deixa o arquivo pela metade. + * Linhas que o app não entende voltam como estavam. + */ +class RepositorioKnownHostsArquivo(private val arquivo: File) : RepositorioDeConfianca { + private val trava = Any() + + private fun ler(): KnownHostsArquivo = KnownHostsArquivo.ler(if (arquivo.isFile) arquivo.readText() else null) + + private fun gravar(novo: KnownHostsArquivo) { + arquivo.parentFile?.mkdirs() + val tmp = File(arquivo.parentFile, arquivo.name + ".tmp") + tmp.writeText(novo.serializar()) + check(tmp.renameTo(arquivo) || (arquivo.delete() && tmp.renameTo(arquivo))) { "não consegui gravar o known_hosts" } + } + + override fun chavesConhecidas(host: String, porta: Int): List = synchronized(trava) { ler().chavesConhecidas(host, porta) } + + override fun confiar(host: String, porta: Int, chave: ChaveDoServidor) = synchronized(trava) { gravar(ler().comChave(host, porta, chave)) } + + override fun esquecer(host: String, porta: Int) = synchronized(trava) { gravar(ler().semHost(host, porta)) } +} diff --git a/app/src/main/java/com/keyflow/computador/app/SessoesService.kt b/app/src/main/java/com/keyflow/computador/app/SessoesService.kt new file mode 100644 index 00000000..098e87f3 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/SessoesService.kt @@ -0,0 +1,147 @@ +package com.keyflow.computador.app + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.ServiceCompat +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.EventoGerenciador +import com.keyflow.computador.sessao.GerenciadorDeSessoes +import com.keyflow.computador.sessao.SessaoResumo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** De onde o serviço tira o gerenciador e a opção do WakeLock. O app define isto ao subir; os testes trocam por fakes. */ +object ServicosDoComputador { + @Volatile var gerenciador: GerenciadorDeSessoes? = null + /** A opção "Manter conectado com a tela apagada". Um valor só, lido por qualquer instância do serviço (que pode sobreviver à anterior por instantes). */ + val manterConectado = kotlinx.coroutines.flow.MutableStateFlow(false) +} + +/** + * O serviço em primeiro plano `specialUse` das sessões SSH (CMP-32..34, design §7). Sobe quando a 1ª sessão abre **com o app visível** (quem + * abre chama [iniciar]) e cai quando a última fecha. A notificação mostra só a contagem. **Não** toca o `OverlayService`. + * + * O `WakeLock` parcial só existe com a opção "Manter conectado com a tela apagada" ligada (D22) **e** havendo sessão do usuário; desligada + * (o padrão), o serviço não adquire nada. Sem `POST_NOTIFICATIONS` o serviço funciona igual (a notificação só não aparece). + */ +class SessoesService : Service() { + private var escopo: CoroutineScope? = null + private var observacao: Job? = null + private var wakeLock: PowerManager.WakeLock? = null + @Volatile private var ultimoStartId = 0 + + /** Visível aos testes: há um `WakeLock` segurado agora? */ + val temWakeLock: Boolean get() = wakeLock?.isHeld == true + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + ultimoStartId = startId + val gerenciador = ServicosDoComputador.gerenciador + if (gerenciador == null) { encerrar(); return START_NOT_STICKY } + val resumos = gerenciador.resumos.value + // Primeiro plano **antes** de qualquer outra coisa: o prazo do sistema é curto. + if (!subirEmPrimeiroPlano(resumos.vivas().size.coerceAtLeast(1), resumos.vivas().any { it.dono == DonoDaSessao.AGENTE })) { encerrar(); return START_NOT_STICKY } + val e = escopo ?: CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { escopo = it } + when (intent?.action) { + ACAO_DESCONECTAR_TODAS -> e.launch { gerenciador.fecharTodas() } + ACAO_PARAR_AGENTE -> e.launch { gerenciador.fecharDoAgente() } + } + if (observacao == null) { + observacao = e.launch { + combine(gerenciador.resumos, ServicosDoComputador.manterConectado) { r, manter -> r to manter }.collect { (r, manter) -> + val vivas = r.vivas() + if (vivas.isEmpty()) { encerrar(); return@collect } + atualizarNotificacao(vivas.size, vivas.any { it.dono == DonoDaSessao.AGENTE }) + ajustarWakeLock(manter && vivas.any { it.dono == DonoDaSessao.USUARIO }) + } + } + } + return START_NOT_STICKY + } + + override fun onDestroy() { + soltarWakeLock() + escopo?.cancel() + escopo = null + observacao = null + super.onDestroy() + } + + /** O processo está sendo morto com a tarefa removida: as conexões morrem junto; nada a persistir além do que já está em memória. */ + override fun onTaskRemoved(rootIntent: Intent?) { + ServicosDoComputador.gerenciador?.let { g -> escopo?.launch { g.fecharTodas() } } + super.onTaskRemoved(rootIntent) + } + + private fun List.vivas() = filter { !it.estado.terminal } + + private fun abrirApp(): Intent? = packageManager.getLaunchIntentForPackage(packageName) + + private fun subirEmPrimeiroPlano(n: Int, agente: Boolean): Boolean = try { + ServiceCompat.startForeground( + this, NotificacaoSessoes.ID, NotificacaoSessoes.montar(this, n, agente, abrirApp()), + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + true + } catch (e: RuntimeException) { + // Android 12+ recusa o primeiro plano iniciado em segundo plano (ForegroundServiceStartNotAllowedException): o serviço só vale com o app visível. + false + } + + private fun atualizarNotificacao(n: Int, agente: Boolean) { + getSystemService(android.app.NotificationManager::class.java).notify(NotificacaoSessoes.ID, NotificacaoSessoes.montar(this, n, agente, abrirApp())) + } + + private fun ajustarWakeLock(querer: Boolean) { + if (!querer) return soltarWakeLock() + if (wakeLock?.isHeld == true) return + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "keyflow:sessoes").apply { setReferenceCounted(false); acquire() } + } + + private fun soltarWakeLock() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + } + + /** + * Derruba o serviço **se ainda não há sessão viva**. Uma sessão nova pode ter pedido o serviço entre o fim da anterior e este ponto (um + * `startForegroundService` a caminho): `stopSelf(startId)` ignora o pedido quando chegou um comando mais novo, e a verificação evita + * remover a notificação de quem acabou de abrir. + */ + private fun encerrar() { + if (ServicosDoComputador.gerenciador?.resumos?.value?.vivas()?.isNotEmpty() == true) return + soltarWakeLock() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf(ultimoStartId) + } + + companion object { + const val ACAO_DESCONECTAR_TODAS = "com.keyflow.computador.DESCONECTAR_TODAS" + const val ACAO_PARAR_AGENTE = "com.keyflow.computador.PARAR_AGENTE" + + /** Sobe o serviço. Chamado quando a 1ª sessão abre **com o app visível**. */ + fun iniciar(contexto: Context) { + androidx.core.content.ContextCompat.startForegroundService(contexto, Intent(contexto, SessoesService::class.java)) + } + + /** Liga o serviço ao gerenciador: sobe na 1ª sessão viva. O app chama isto ao criar o gerenciador. */ + fun ligar(contexto: Context, gerenciador: GerenciadorDeSessoes, escopo: CoroutineScope, opcaoManterConectado: Flow? = null) { + ServicosDoComputador.gerenciador = gerenciador + opcaoManterConectado?.let { f -> escopo.launch { f.collect { ServicosDoComputador.manterConectado.value = it } } } + val app = contexto.applicationContext + com.keyflow.computador.app.observarPrimeiraSessao(gerenciador, escopo) { runCatching { iniciar(app) } } + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/SondaEd25519.kt b/app/src/main/java/com/keyflow/computador/app/SondaEd25519.kt new file mode 100644 index 00000000..73d3054a --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/SondaEd25519.kt @@ -0,0 +1,100 @@ +package com.keyflow.computador.app + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import com.google.crypto.tink.subtle.Ed25519Verify +import com.keyflow.computador.chaves.AvaliadorDaSonda +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.util.UUID + +/** Uma chave temporária do Keystore a sondar: pública em SPKI, uma assinatura de teste e a limpeza. */ +interface CandidataEd25519 { + /** SPKI X.509 da pública que o Keystore devolveu. */ + val spki: ByteArray + + /** Assina [dados] com o algoritmo de `Signature` que o provedor aceitou. */ + fun assinar(dados: ByteArray): ByteArray + + fun apagar() +} + +/** De onde vem a candidata. O de produção usa o Keystore; o teste injeta uma que devolve P-256 (a armadilha relatada por terceiros). */ +fun interface GeradorEd25519Keystore { + fun gerar(alias: String): CandidataEd25519 +} + +/** O gerador de produção: `KeyPairGenerator` **EC** com a curva `ed25519` e `DIGEST_NONE`; descobre o nome do algoritmo de `Signature`. */ +object GeradorEd25519DoKeystore : GeradorEd25519Keystore { + private val nomes = listOf("Ed25519", "EdDSA") + + override fun gerar(alias: String): CandidataEd25519 { + val gerador = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, EnvelopeDeChave.PROVEDOR) + gerador.initialize( + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec("ed25519")) + .setDigests(KeyProperties.DIGEST_NONE) + .build(), + ) + val par = gerador.generateKeyPair() + val spki = par.public.encoded + return object : CandidataEd25519 { + override val spki = spki + override fun assinar(dados: ByteArray): ByteArray { + // getKey/getCertificate, não getEntry (relato de falha na API 35). + val ks = KeyStore.getInstance(EnvelopeDeChave.PROVEDOR).apply { load(null) } + val privada = ks.getKey(alias, null) as java.security.PrivateKey + var ultimo: Exception? = null + for (nome in nomes) { + try { + return Signature.getInstance(nome).run { initSign(privada); update(dados); sign() } + } catch (e: Exception) { + ultimo = e + } + } + throw ultimo ?: IllegalStateException("nenhum nome de Signature aceito") + } + override fun apagar() { + runCatching { KeyStore.getInstance(EnvelopeDeChave.PROVEDOR).apply { load(null) }.deleteEntry(alias) } + } + } + } +} + +/** + * A sonda de produção (CMP-11): antes de usar o Keystore para Ed25519 num aparelho, gera uma chave temporária, confere o formato, + * assina e verifica. O resultado é lembrado por processo (e só vale para esta API e este fingerprint de sistema). Reprovou → o + * ed25519 nasce em envelope, e quem chama sabe o motivo para mostrar (nenhuma falha silenciosa). + */ +class SondaEd25519( + private val api: Int = Build.VERSION.SDK_INT, + private val gerador: GeradorEd25519Keystore = GeradorEd25519DoKeystore, +) { + @Volatile + private var resultado: AvaliadorDaSonda.Veredito? = null + + fun avaliar(): AvaliadorDaSonda.Veredito = resultado ?: synchronized(this) { resultado ?: medir().also { resultado = it } } + + private fun medir(): AvaliadorDaSonda.Veredito { + if (api < AvaliadorDaSonda.API_MINIMA) return AvaliadorDaSonda.avaliar(api, null, null) + val alias = "keyflow_ssh_sonda_" + UUID.randomUUID().toString().take(8) + var candidata: CandidataEd25519? = null + try { + candidata = gerador.gerar(alias) + val spki = candidata.spki + val bytes = AvaliadorDaSonda.bytesDaPublica(spki) ?: return AvaliadorDaSonda.avaliar(api, spki, null) + val dados = "keyflow-sonda".toByteArray() + val assinatura = candidata.assinar(dados) + val verifica = try { Ed25519Verify(bytes).verify(assinatura, dados); true } catch (e: java.security.GeneralSecurityException) { false } + return AvaliadorDaSonda.avaliar(api, spki, verifica) + } catch (e: Exception) { + return AvaliadorDaSonda.avaliar(api, null, null, e) + } finally { + candidata?.apagar() + runCatching { KeyStore.getInstance(EnvelopeDeChave.PROVEDOR).apply { load(null) }.deleteEntry(alias) } + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/chaves/AssinadorSoftware.kt b/app/src/main/java/com/keyflow/computador/chaves/AssinadorSoftware.kt new file mode 100644 index 00000000..cd21dfb7 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/chaves/AssinadorSoftware.kt @@ -0,0 +1,44 @@ +package com.keyflow.computador.chaves + +import com.google.crypto.tink.subtle.Ed25519Sign +import org.connectbot.sshlib.SshSigning + +/** Gera chaves Ed25519 em software (a semente de 32 bytes, que o envelope cifra). */ +object ChavesSoftware { + class Ed25519Gerada(val publica: ChavePublicaSsh, val material: MaterialPrivado) { + override fun toString() = "Ed25519Gerada(${publica.impressao.texto}, ***)" + } + + fun gerarEd25519(): Ed25519Gerada { + val par = Ed25519Sign.KeyPair.newKeyPair() + val publica = ChavePublicaSsh("ssh-ed25519", FormatadorChavePublica.blobEd25519(par.publicKey)) + return Ed25519Gerada(publica, MaterialPrivado(FormatoDoMaterial.SEMENTE_ED25519, par.privateKey)) + } +} + +/** + * Assina com material guardado em envelope: pede o [MaterialPrivado] só na hora de assinar, usa e **zera** o array (mesmo em erro). + * [obterMaterial] devolve `null` quando o usuário cancelou a biometria que protege o envelope (então a assinatura é `null`). + */ +class AssinadorSoftware( + private val publica: ChavePublicaSsh, + private val obterMaterial: suspend () -> MaterialPrivado?, +) : Assinador { + override fun chavePublica() = publica + + override suspend fun assinar(dados: ByteArray): ByteArray? { + val material = obterMaterial() ?: return null + try { + return when (material.formato) { + FormatoDoMaterial.SEMENTE_ED25519 -> { + val assinatura = Ed25519Sign(material.bytes).sign(dados) + FormatadorChavePublica.campo("ssh-ed25519") + FormatadorChavePublica.campo(assinatura) + } + FormatoDoMaterial.OPENSSH_SEM_SENHA -> + SshSigning.sign(publica.algoritmo, String(material.bytes, Charsets.UTF_8), null, dados) + } + } finally { + material.zerar() + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/chaves/Biometria.kt b/app/src/main/java/com/keyflow/computador/chaves/Biometria.kt new file mode 100644 index 00000000..719e6893 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/chaves/Biometria.kt @@ -0,0 +1,68 @@ +package com.keyflow.computador.chaves + +/** A janela de 5 minutos (D14): um prompt bem-sucedido libera a chave por [DURACAO_MS]; dentro dela não há novo prompt. */ +class JanelaDeBiometria(private val relogio: RelogioDeChaves, private val duracaoMs: Long = DURACAO_MS) { + @Volatile + private var ultimaMs: Long? = null + + fun valida(): Boolean { + val u = ultimaMs ?: return false + val delta = relogio.agoraMs() - u + // Relógio que anda para trás não renova a janela: conta como vencida. + return delta in 0 until duracaoMs + } + + fun marcar() { + ultimaMs = relogio.agoraMs() + } + + fun encerrar() { + ultimaMs = null + } + + companion object { + const val DURACAO_MS = 300_000L + const val DURACAO_S = 300 + } +} + +/** Como pedir ao Keystore a janela de 300 s, conforme a API (CMP-14). Pura, para testar sem aparelho. */ +sealed interface PlanoDeAutenticacao { + /** API 24–29: `setUserAuthenticationValidityDurationSeconds`. */ + data class Validade(val segundos: Int) : PlanoDeAutenticacao + + /** API 30+: `setUserAuthenticationParameters(segundos, AUTH_BIOMETRIC_STRONG)`; credencial do aparelho junto só se a T2 confirmar. */ + data class Parametros(val segundos: Int, val biometriaForte: Boolean = true) : PlanoDeAutenticacao + + companion object { + fun para(api: Int): PlanoDeAutenticacao = + if (api >= 30) Parametros(JanelaDeBiometria.DURACAO_S) else Validade(JanelaDeBiometria.DURACAO_S) + } +} + +/** + * Decide se a sonda do Ed25519 no Keystore (CMP-11) aprova o aparelho. Recebe só fatos medidos; quem os mede é o Android. + * Reprova (e o ed25519 cai no envelope) por: API < 33, exceção, pública que não é Ed25519 (a armadilha do P-256), assinatura que não verifica. + */ +object AvaliadorDaSonda { + const val API_MINIMA = 33 + private val prefixoSpki = byteArrayOf(0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00) + + sealed interface Veredito { + data object Aprovada : Veredito + data class Reprovada(val motivo: String) : Veredito + } + + fun avaliar(api: Int, spki: ByteArray?, assinaturaVerifica: Boolean?, erro: Throwable? = null): Veredito = when { + api < API_MINIMA -> Veredito.Reprovada("API $api é anterior à 33") + erro != null -> Veredito.Reprovada("exceção ${erro.javaClass.simpleName}") + spki == null -> Veredito.Reprovada("o Keystore não devolveu a chave pública") + spki.size != 44 || !spki.copyOfRange(0, prefixoSpki.size).contentEquals(prefixoSpki) -> Veredito.Reprovada("a chave pública não é Ed25519 (${spki.size} bytes)") + assinaturaVerifica != true -> Veredito.Reprovada("a assinatura não verifica") + else -> Veredito.Aprovada + } + + /** Os 32 bytes da chave dentro do SPKI de 44 bytes, ou `null` se o formato não é o de Ed25519. */ + fun bytesDaPublica(spki: ByteArray): ByteArray? = + if (spki.size == 44 && spki.copyOfRange(0, prefixoSpki.size).contentEquals(prefixoSpki)) spki.copyOfRange(12, 44) else null +} diff --git a/app/src/main/java/com/keyflow/computador/chaves/CodecChaves.kt b/app/src/main/java/com/keyflow/computador/chaves/CodecChaves.kt new file mode 100644 index 00000000..c265b789 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/chaves/CodecChaves.kt @@ -0,0 +1,43 @@ +package com.keyflow.computador.chaves + +import com.keyflow.computador.ImpressaoDigital +import org.json.JSONArray +import org.json.JSONObject + +/** + * `computer_keys_json` (`{"v":1,"keys":[…]}`): só **metadados** (id, nome, tipo, impressão, proteção, biometria, data). Sem material + * secreto. Tolerante como o `CodecHosts`: nunca lança, item ruim é descartado, id repetido vira um. + */ +object CodecChaves { + fun codificar(chaves: List): String { + val a = JSONArray() + for (c in chaves) a.put( + JSONObject().put("id", c.id).put("name", c.nome).put("type", c.tipo.name).put("fp", c.impressao.texto) + .put("protection", c.protecao.name).put("biometric", c.exigeBiometria).put("created", c.criadaEmMs), + ) + return JSONObject().put("v", 1).put("keys", a).toString() + } + + fun decodificar(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return try { + val itens = JSONObject(json).optJSONArray("keys") ?: return emptyList() + val out = mutableListOf() + for (i in 0 until itens.length()) { + val o = itens.optJSONObject(i) ?: continue + val id = (o.opt("id") as? String)?.takeIf { it.isNotEmpty() && it.length <= 64 } ?: continue + if (out.any { it.id == id }) continue + val nome = (o.opt("name") as? String)?.take(80) ?: continue + val tipo = TipoDeChave.entries.firstOrNull { it.name == o.opt("type") } ?: continue + val impressao = (o.opt("fp") as? String)?.let { ImpressaoDigital.deTexto(it) } ?: continue + val protecao = Protecao.entries.firstOrNull { it.name == o.opt("protection") } ?: continue + out += ChaveSsh(id, nome, tipo, impressao, protecao, (o.opt("biometric") as? Boolean) ?: false, (o.opt("created") as? Number)?.toLong() ?: 0L) + } + out + } catch (e: Exception) { + emptyList() + } catch (e: StackOverflowError) { + emptyList() + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/chaves/FormatadorChavePublica.kt b/app/src/main/java/com/keyflow/computador/chaves/FormatadorChavePublica.kt new file mode 100644 index 00000000..2931232c --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/chaves/FormatadorChavePublica.kt @@ -0,0 +1,44 @@ +package com.keyflow.computador.chaves + +import java.nio.ByteBuffer + +/** Escreve a chave pública como o `authorized_keys`: `ssh-ed25519 AAAA… keyflow@aparelho` (CMP-13). */ +object FormatadorChavePublica { + fun linha(publica: ChavePublicaSsh, aparelho: String): String = linha(publica.blob, aparelho) + + fun linha(blob: ByteArray, aparelho: String): String { + val tipo = tipoDoBlob(blob) ?: error("blob de chave pública inválido") + return "$tipo ${com.keyflow.computador.Base64Puro.codificar(blob)} keyflow@${comentarioSeguro(aparelho)}" + } + + /** O nome do aparelho vira comentário: sem espaço, sem controle e com tamanho limitado. */ + fun comentarioSeguro(aparelho: String): String = + aparelho.trim().map { if (it.isWhitespace() || it.isISOControl()) '-' else it }.joinToString("").take(64).ifEmpty { "aparelho" } + + /** O primeiro campo `string` do blob é o nome do tipo. `null` se o blob está truncado. */ + fun tipoDoBlob(blob: ByteArray): String? = try { + val b = ByteBuffer.wrap(blob) + val n = b.int + if (n < 1 || n > b.remaining()) null else ByteArray(n).also { b.get(it) }.toString(Charsets.US_ASCII) + } catch (e: java.nio.BufferUnderflowException) { + null + } + + // Construtores do formato de fio (RFC 4251 §5). + internal fun campo(bytes: ByteArray): ByteArray = ByteBuffer.allocate(4 + bytes.size).putInt(bytes.size).put(bytes).array() + internal fun campo(texto: String): ByteArray = campo(texto.toByteArray(Charsets.US_ASCII)) + + /** `mpint` de um inteiro positivo: bytes mínimos, com 0x00 na frente se o bit alto estiver ligado. */ + internal fun mpint(positivo: java.math.BigInteger): ByteArray = campo(positivo.toByteArray()) + + fun blobEd25519(publica32: ByteArray): ByteArray { + require(publica32.size == 32) { "chave Ed25519 pública tem 32 bytes" } + return campo("ssh-ed25519") + campo(publica32) + } + + /** [ponto] é o ponto não comprimido (0x04 || X || Y), 65 bytes para P-256. */ + fun blobEcdsaP256(ponto: ByteArray): ByteArray { + require(ponto.size == 65 && ponto[0] == 4.toByte()) { "ponto P-256 não comprimido tem 65 bytes" } + return campo("ecdsa-sha2-nistp256") + campo("nistp256") + campo(ponto) + } +} diff --git a/app/src/main/java/com/keyflow/computador/chaves/ImportadorDeChave.kt b/app/src/main/java/com/keyflow/computador/chaves/ImportadorDeChave.kt new file mode 100644 index 00000000..22659f4b --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/chaves/ImportadorDeChave.kt @@ -0,0 +1,133 @@ +package com.keyflow.computador.chaves + +import com.keyflow.computador.ImpressaoDigital +import java.nio.ByteBuffer +import java.security.KeyPair +import java.security.interfaces.RSAPrivateKey +import org.connectbot.sshlib.SshKeys +import org.connectbot.sshlib.SshSigning + +/** O que sai de uma importação: a pública, o tipo e o segredo (que o chamador entrega ao envelope e zera). */ +class ChaveImportada( + val tipo: TipoDeChave, + val bits: Int, + val publica: ChavePublicaSsh, + val material: MaterialPrivado, +) { + val impressao: ImpressaoDigital get() = publica.impressao + val rotulo: String get() = "${tipo.rotuloBase} $bits" + override fun toString() = "ChaveImportada($tipo, $bits, ***)" +} + +/** + * Importa chave privada em OpenSSH ou PEM (PKCS#1, SEC1, PKCS#8) pelo `SshKeys` do cbssh (CMP-12). + * + * Sem estado: **não tem campo algum**, então o texto colado e a passphrase não ficam aqui depois de [importar]. Os `CharArray` + * recebidos são zerados ao fim (mesmo em erro). Limite honesto: o cbssh só lê `String`, então uma cópia imutável existe por instantes + * na pilha da biblioteca até o coletor levá-la; o app não a guarda nem a repassa. + * + * Recusa DSA e RSA < 2048 com o motivo; passphrase errada é [ErroChave.PASSPHRASE], ausente é [ErroChave.PASSPHRASE_NECESSARIA]. + */ +class ImportadorDeChave { + fun importar(pem: CharArray, passphrase: CharArray?): Resultado { + try { + val texto = String(pem).trim() + val exigeSenha = when (val d = inspecionar(texto)) { + is Inspecao.Recusa -> return Resultado.Falha(d.erro) + is Inspecao.Ok -> d.cifrada + } + val senha = passphrase?.takeIf { it.isNotEmpty() }?.let { String(it) } + if (exigeSenha && senha == null) return Resultado.Falha(ErroChave.PASSPHRASE_NECESSARIA) + val par = try { + SshKeys.decodePemPrivateKey(texto, if (exigeSenha) senha else null) + } catch (e: Exception) { + // A mensagem da biblioteca nunca é repassada: ela pode citar trechos do que foi lido. + return Resultado.Falha(if (exigeSenha) ErroChave.PASSPHRASE else ErroChave.FORMATO) + } + return classificar(par) + } finally { + pem.fill('\u0000') + passphrase?.fill('\u0000') + } + } + + private fun classificar(par: KeyPair): Resultado { + val pub = try { + SshSigning.encodePublicKey(par) + } catch (e: Exception) { + return Resultado.Falha(ErroChave.TIPO_NAO_SUPORTADO) + } + val (tipo, bits) = when (pub.algorithmName) { + "ssh-ed25519" -> TipoDeChave.ED25519 to 256 + "ecdsa-sha2-nistp256" -> TipoDeChave.ECDSA_P256 to 256 + "ecdsa-sha2-nistp384" -> TipoDeChave.ECDSA_P384 to 384 + "ecdsa-sha2-nistp521" -> TipoDeChave.ECDSA_P521 to 521 + "ssh-rsa" -> { + val bitsRsa = (par.private as? RSAPrivateKey)?.modulus?.bitLength() ?: return Resultado.Falha(ErroChave.TIPO_NAO_SUPORTADO) + if (bitsRsa < 2048) return Resultado.Falha(ErroChave.RSA_FRACA) + TipoDeChave.RSA to bitsRsa + } + else -> return Resultado.Falha(ErroChave.TIPO_NAO_SUPORTADO) + } + val material = try { + if (tipo == TipoDeChave.ED25519) { + MaterialPrivado(FormatoDoMaterial.SEMENTE_ED25519, sementeDeEd25519(par.private.encoded)) + } else { + MaterialPrivado(FormatoDoMaterial.OPENSSH_SEM_SENHA, SshKeys.encodeOpenSshPrivateKey(par, null).toByteArray(Charsets.UTF_8)) + } + } catch (e: Exception) { + return Resultado.Falha(ErroChave.TIPO_NAO_SUPORTADO) + } + val algoritmo = if (tipo == TipoDeChave.RSA) "rsa-sha2-256" else tipo.algoritmoSsh + return Resultado.Ok(ChaveImportada(tipo, bits, ChavePublicaSsh(algoritmo, pub.publicKeyBlob), material)) + } + + /** PKCS#8 de Ed25519 termina em `OCTET STRING(32)` com a semente (RFC 8410): os últimos 32 bytes. */ + private fun sementeDeEd25519(pkcs8: ByteArray): ByteArray { + require(pkcs8.size >= 34 && pkcs8[pkcs8.size - 34] == 0x04.toByte() && pkcs8[pkcs8.size - 33] == 0x20.toByte()) { "PKCS#8 Ed25519 inesperado" } + return pkcs8.copyOfRange(pkcs8.size - 32, pkcs8.size) + } + + private sealed interface Inspecao { + data class Ok(val cifrada: Boolean) : Inspecao + data class Recusa(val erro: ErroChave) : Inspecao + } + + /** Olha o cabeçalho (sem decifrar nada) para saber se pede senha e recusar DSA antes de qualquer cálculo. */ + private fun inspecionar(texto: String): Inspecao { + return when { + texto.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----") -> inspecionarOpenSsh(texto) + texto.startsWith("-----BEGIN DSA PRIVATE KEY-----") -> Inspecao.Recusa(ErroChave.DSA_RECUSADA) + texto.startsWith("-----BEGIN ENCRYPTED PRIVATE KEY-----") -> Inspecao.Ok(true) + texto.startsWith("-----BEGIN RSA PRIVATE KEY-----") || texto.startsWith("-----BEGIN EC PRIVATE KEY-----") -> + Inspecao.Ok(texto.lineSequence().any { it.trim().startsWith("Proc-Type:") && it.contains("ENCRYPTED") }) + texto.startsWith("-----BEGIN PRIVATE KEY-----") -> Inspecao.Ok(false) + else -> Inspecao.Recusa(ErroChave.FORMATO) + } + } + + private fun inspecionarOpenSsh(texto: String): Inspecao { + val corpo = texto.lineSequence().map { it.trim() }.dropWhile { !it.startsWith("-----BEGIN") }.drop(1).takeWhile { !it.startsWith("-----END") }.joinToString("") + val dados = com.keyflow.computador.Base64Puro.decodificar(corpo) ?: return Inspecao.Recusa(ErroChave.FORMATO) + val magico = "openssh-key-v1\u0000".toByteArray(Charsets.US_ASCII) + if (dados.size < magico.size || !dados.copyOfRange(0, magico.size).contentEquals(magico)) return Inspecao.Recusa(ErroChave.FORMATO) + return try { + val b = ByteBuffer.wrap(dados, magico.size, dados.size - magico.size) + val cifra = string(b) + string(b) // kdfname + string(b) // kdfoptions + if (b.int != 1) return Inspecao.Recusa(ErroChave.FORMATO) + val pub = ByteBuffer.wrap(string(b)) + // A pública vem em claro mesmo com a privada cifrada: dá para recusar DSA antes de pedir a senha. + if (String(string(pub), Charsets.US_ASCII) == "ssh-dss") Inspecao.Recusa(ErroChave.DSA_RECUSADA) else Inspecao.Ok(String(cifra, Charsets.US_ASCII) != "none") + } catch (e: RuntimeException) { + Inspecao.Recusa(ErroChave.FORMATO) + } + } + + private fun string(b: ByteBuffer): ByteArray { + val n = b.int + require(n in 0..b.remaining()) + return ByteArray(n).also { b.get(it) } + } +} diff --git a/app/src/main/java/com/keyflow/computador/chaves/MaterialPrivado.kt b/app/src/main/java/com/keyflow/computador/chaves/MaterialPrivado.kt new file mode 100644 index 00000000..1a2cfe2f --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/chaves/MaterialPrivado.kt @@ -0,0 +1,13 @@ +package com.keyflow.computador.chaves + +/** Como o [MaterialPrivado] está codificado. */ +enum class FormatoDoMaterial { SEMENTE_ED25519, OPENSSH_SEM_SENHA } + +/** + * O segredo de uma chave de software, a caminho do envelope (cifrado) ou vindo dele, só pelo tempo de uma assinatura. + * Não é `data class` (sem `copy`/`equals` que exponham bytes), [toString] não mostra nada e [zerar] apaga o array. + */ +class MaterialPrivado(val formato: FormatoDoMaterial, val bytes: ByteArray) { + fun zerar() = bytes.fill(0) + override fun toString() = "MaterialPrivado($formato, ***)" +} diff --git a/app/src/main/java/com/keyflow/computador/chaves/Modelo.kt b/app/src/main/java/com/keyflow/computador/chaves/Modelo.kt new file mode 100644 index 00000000..e141dc13 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/chaves/Modelo.kt @@ -0,0 +1,103 @@ +package com.keyflow.computador.chaves + +import com.keyflow.computador.ImpressaoDigital + +/** Tipos de chave que o app entende. Gerar só ED25519 e ECDSA_P256 (D13); importar aceita também os demais (CMP-12). */ +enum class TipoDeChave(val algoritmoSsh: String, val rotuloBase: String) { + ED25519("ssh-ed25519", "ED25519"), + ECDSA_P256("ecdsa-sha2-nistp256", "ECDSA"), + ECDSA_P384("ecdsa-sha2-nistp384", "ECDSA"), + ECDSA_P521("ecdsa-sha2-nistp521", "ECDSA"), + RSA("ssh-rsa", "RSA"), +} + +/** Onde a privada vive (CMP-10/11). A tela diz a proteção real; nada é silencioso. */ +enum class Protecao { HARDWARE, KEYSTORE_ENVELOPE, SOFTWARE_COFRE } + +/** Estado de uma chave: `INVALIDADA` quando a biometria cadastrada mudou e o Keystore a invalidou (CMP-14). */ +enum class EstadoChave { ATIVA, INVALIDADA } + +/** + * Metadados de uma chave (persistidos em `computer_keys_json`). **Sem nenhum material secreto**: nem a privada, nem a semente, nem a + * passphrase. A privada vive no Keystore ou, cifrada, no cofre; aqui só há a impressão digital da pública. + */ +class ChaveSsh( + val id: String, + val nome: String, + val tipo: TipoDeChave, + val impressao: ImpressaoDigital, + val protecao: Protecao, + val exigeBiometria: Boolean, + val criadaEmMs: Long, +) { + fun copy( + id: String = this.id, nome: String = this.nome, tipo: TipoDeChave = this.tipo, impressao: ImpressaoDigital = this.impressao, + protecao: Protecao = this.protecao, exigeBiometria: Boolean = this.exigeBiometria, criadaEmMs: Long = this.criadaEmMs, + ) = ChaveSsh(id, nome, tipo, impressao, protecao, exigeBiometria, criadaEmMs) + + override fun equals(other: Any?) = other is ChaveSsh && id == other.id && nome == other.nome && tipo == other.tipo && + impressao == other.impressao && protecao == other.protecao && exigeBiometria == other.exigeBiometria && criadaEmMs == other.criadaEmMs + + override fun hashCode() = id.hashCode() + + /** Mascarado de propósito: id e tipo bastam para um log; nome e impressão ficam de fora. */ + override fun toString() = "ChaveSsh(id=$id, tipo=$tipo, protecao=$protecao)" +} + +/** A chave pública no formato de fio do SSH. [algoritmo] é o nome a oferecer na autenticação (`rsa-sha2-256` para RSA). */ +class ChavePublicaSsh(val algoritmo: String, val blob: ByteArray) { + /** O tipo escrito dentro do blob (`ssh-rsa` mesmo quando o algoritmo oferecido é `rsa-sha2-256`). */ + val tipoDoBlob: String get() = FormatadorChavePublica.tipoDoBlob(blob) ?: algoritmo + + val impressao: ImpressaoDigital get() = ImpressaoDigital.de(blob) + + override fun equals(other: Any?) = other is ChavePublicaSsh && algoritmo == other.algoritmo && blob.contentEquals(other.blob) + override fun hashCode() = 31 * algoritmo.hashCode() + blob.contentHashCode() + override fun toString() = "ChavePublicaSsh($algoritmo, ${impressao.texto})" +} + +/** Liga ao `AuthHandler` do cbssh (`onPublicKeysNeeded` / `onSignatureRequest`) sem importar a biblioteca. */ +interface Assinador { + fun chavePublica(): ChavePublicaSsh + + /** `true` quando `assinar` devolveu `null` porque a chave foi **invalidada** (a biometria cadastrada mudou), não porque o usuário cancelou. */ + val invalidada: Boolean get() = false + + /** A assinatura no formato de fio do SSH (`string algoritmo` + `string assinatura`), ou `null` se o usuário cancelou (o cbssh pula a chave). */ + suspend fun assinar(dados: ByteArray): ByteArray? +} + +/** Resultado de uma operação de chave: sem exceção como controle de fluxo. */ +sealed interface Resultado { + data class Ok(val valor: T) : Resultado + data class Falha(val erro: ErroChave) : Resultado +} + +/** Motivos de recusa ao importar ou gerar uma chave. [frase] é o texto técnico em português; a tela traduz por `tr(…)`. */ +enum class ErroChave(val frase: String) { + FORMATO("Não reconheci o formato desta chave."), + PASSPHRASE_NECESSARIA("Esta chave está protegida por uma senha."), + PASSPHRASE("A senha da chave está errada."), + DSA_RECUSADA("Chaves DSA não são aceitas: o algoritmo é considerado fraco."), + RSA_FRACA("Chaves RSA com menos de 2048 bits não são aceitas."), + TIPO_NAO_SUPORTADO("Este tipo de chave não é suportado."), + SEM_BIOMETRIA("Este aparelho não tem biometria cadastrada."), + BIOMETRIA_INVALIDADA("A chave foi invalidada pela biometria."), + CANCELADA("Autenticação cancelada."), + FALHA_DO_APARELHO("O aparelho não conseguiu criar ou usar a chave."), +} + +/** Cofre de segredos (o `keyflow_auth` no Android). Texto, porque é o que o cofre guarda; binário entra em Base64. */ +interface CofreDeSegredos { + fun ler(chave: String): String? + fun gravar(chave: String, valor: String) + fun apagar(chave: String) + + /** Todos os valores guardados, para o `SecretFilter` trocar por valor. */ + fun valores(): Collection +} + +/** O relógio das chaves (janela de biometria, data de criação); fake nos testes. */ +fun interface RelogioDeChaves { + fun agoraMs(): Long +} diff --git a/app/src/main/java/com/keyflow/computador/sessao/BufferDeTexto.kt b/app/src/main/java/com/keyflow/computador/sessao/BufferDeTexto.kt new file mode 100644 index 00000000..374589b6 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/sessao/BufferDeTexto.kt @@ -0,0 +1,123 @@ +package com.keyflow.computador.sessao + +/** + * Anel de linhas de **texto puro** alimentado pelo mesmo fluxo de bytes que o emulador (CMP-36): é o que o agente lê e a busca usa. + * Decodifica UTF-8 em fluxo (sequências partidas entre pacotes), descarta CSI/OSC/APC/DCS/PM/SOS e escapes curtos, aplica `\r`, `\b` e os + * apagamentos de linha mais simples (`CSI K`, `CSI 2K`). **Não** é um emulador: a fonte da verdade visual é o termlib. Só memória: + * este arquivo não tem nenhuma API de arquivo, e um teste confere isso. + */ +class BufferDeTexto(private val maxLinhas: Int = MAX_LINHAS, private val maxColunas: Int = MAX_COLUNAS) { + private val linhas = ArrayDeque() + private val atual = StringBuilder() + private var coluna = 0 + private var estado = Estado.TEXTO + private val pendente = java.io.ByteArrayOutputStream() + private var parametros = StringBuilder() + + private enum class Estado { TEXTO, ESC, CSI, OSC, OSC_ESC, STRING, STRING_ESC, ESC_INTERMEDIARIO } + + /** Alimenta com bytes da saída do servidor. */ + @Synchronized + fun alimentar(bytes: ByteArray) { + pendente.write(bytes) + val todos = pendente.toByteArray() + pendente.reset() + var i = 0 + while (i < todos.size) { + val b = todos[i].toInt() and 0xff + if (estado == Estado.TEXTO && b >= 0x80) { + val tam = comprimentoUtf8(b) + if (tam == 0) { escrever('�'); i++; continue } + if (i + tam > todos.size) { pendente.write(todos, i, todos.size - i); return } + String(todos, i, tam, Charsets.UTF_8).forEach { escrever(it) } + i += tam + continue + } + processar(b) + i++ + } + } + + /** Escreve texto **local** (não vem do servidor), como o separador de reconexão. */ + @Synchronized + fun adicionarLocal(texto: String) = alimentar(texto.toByteArray(Charsets.UTF_8)) + + /** As últimas [n] linhas, a linha em edição incluída (se não estiver vazia). */ + @Synchronized + fun ultimasLinhas(n: Int): List { + val todas = if (atual.isNotEmpty()) linhas + atual.toString() else linhas.toList() + return todas.takeLast(n.coerceAtLeast(0)) + } + + @Synchronized + fun texto(): String = ultimasLinhas(Int.MAX_VALUE).joinToString("\n") + + @Synchronized + fun quantidadeDeLinhas(): Int = linhas.size + if (atual.isNotEmpty()) 1 else 0 + + @Synchronized + fun limpar() { linhas.clear(); atual.setLength(0); coluna = 0; estado = Estado.TEXTO; pendente.reset() } + + private fun comprimentoUtf8(b: Int) = when { + b in 0xC2..0xDF -> 2 + b in 0xE0..0xEF -> 3 + b in 0xF0..0xF4 -> 4 + else -> 0 + } + + private fun processar(b: Int) { + when (estado) { + Estado.TEXTO -> when (b) { + 0x1b -> estado = Estado.ESC + '\n'.code -> novaLinha() + '\r'.code -> coluna = 0 + 0x08 -> if (coluna > 0) coluna-- + '\t'.code -> repeat(8 - coluna % 8) { escrever(' ') } + in 0x00..0x1f, 0x7f -> Unit + else -> escrever(b.toChar()) + } + Estado.ESC -> when (b.toChar()) { + '[' -> { estado = Estado.CSI; parametros = StringBuilder() } + ']' -> estado = Estado.OSC + 'P', '_', '^', 'X' -> estado = Estado.STRING + in ' '..'/' -> estado = Estado.ESC_INTERMEDIARIO + else -> estado = Estado.TEXTO + } + Estado.ESC_INTERMEDIARIO -> if (b !in 0x20..0x2f) estado = Estado.TEXTO + Estado.CSI -> if (b in 0x40..0x7e) { fimDeCsi(b.toChar()); estado = Estado.TEXTO } else parametros.append(b.toChar()) + Estado.OSC -> when (b) { 0x07 -> estado = Estado.TEXTO; 0x1b -> estado = Estado.OSC_ESC } + Estado.OSC_ESC -> estado = if (b == '\\'.code) Estado.TEXTO else Estado.OSC + Estado.STRING -> if (b == 0x1b) estado = Estado.STRING_ESC + Estado.STRING_ESC -> estado = if (b == '\\'.code) Estado.TEXTO else Estado.STRING + } + } + + private fun fimDeCsi(final: Char) { + if (final != 'K') return + when (parametros.toString().ifEmpty { "0" }) { + "0" -> if (coluna < atual.length) atual.setLength(coluna) + "1" -> for (i in 0 until minOf(coluna, atual.length)) atual.setCharAt(i, ' ') + "2" -> { atual.setLength(0); coluna = 0 } + } + } + + private fun escrever(c: Char) { + if (coluna >= maxColunas) return + while (atual.length < coluna) atual.append(' ') + if (coluna < atual.length) atual.setCharAt(coluna, c) else atual.append(c) + coluna++ + } + + private fun novaLinha() { + linhas.addLast(atual.toString().trimEnd()) + atual.setLength(0) + coluna = 0 + while (linhas.size > maxLinhas) linhas.removeFirst() + } + + companion object { + const val MAX_LINHAS = 5_000 + const val MAX_COLUNAS = 4_096 + const val SEPARADOR_DE_RECONEXAO = "\r\n— conexão reiniciada —\r\n" + } +} diff --git a/app/src/main/java/com/keyflow/computador/sessao/EstadoSessao.kt b/app/src/main/java/com/keyflow/computador/sessao/EstadoSessao.kt new file mode 100644 index 00000000..2680e9fb --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/sessao/EstadoSessao.kt @@ -0,0 +1,41 @@ +package com.keyflow.computador.sessao + +import com.keyflow.computador.ssh.CausaSsh + +enum class DonoDaSessao { USUARIO, AGENTE } + +enum class MotivoEncerramento { USUARIO, OCIOSIDADE, HOST_REMOVIDO, AGENTE_PARADO, SISTEMA } + +/** Máquina de estados da sessão (design §6). */ +sealed interface EstadoSessao { + data object Conectando : EstadoSessao + data object AguardandoServidor : EstadoSessao + data object Autenticando : EstadoSessao + data object Aberta : EstadoSessao + data class Reconectando(val tentativa: Int) : EstadoSessao + + /** Biometria fora da janela de 5 minutos: a reconexão espera o usuário (notificação "toque para reconectar"). */ + data object PrecisaDoUsuario : EstadoSessao + data class Encerrada(val motivo: MotivoEncerramento) : EstadoSessao + data class Falhou(val causa: CausaSsh) : EstadoSessao + + val terminal: Boolean get() = this is Encerrada || this is Falhou + + companion object { + /** As transições que a máquina permite. Tudo fora disto é um estado impossível. */ + fun permitida(de: EstadoSessao, para: EstadoSessao): Boolean { + if (de == para) return true + if (de.terminal) return false + if (para is Encerrada || para is Falhou) return true + return when (de) { + Conectando -> para == AguardandoServidor || para == Autenticando || para == Aberta + AguardandoServidor -> para == Autenticando || para == Aberta + Autenticando -> para == Aberta + Aberta -> para is Reconectando || para == PrecisaDoUsuario + is Reconectando -> para is Reconectando || para == Aberta || para == PrecisaDoUsuario + PrecisaDoUsuario -> para is Reconectando || para == Conectando + is Encerrada, is Falhou -> false + } + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/sessao/GerenciadorDeSessoes.kt b/app/src/main/java/com/keyflow/computador/sessao/GerenciadorDeSessoes.kt new file mode 100644 index 00000000..ca784d21 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/sessao/GerenciadorDeSessoes.kt @@ -0,0 +1,124 @@ +package com.keyflow.computador.sessao + +import com.keyflow.computador.Host +import com.keyflow.computador.ssh.PtyInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** O que a UI e a notificação veem de uma sessão. Nunca carrega host, usuário nem texto. */ +data class SessaoResumo(val id: String, val hostId: String, val dono: DonoDaSessao, val compartilhada: Boolean, val estado: EstadoSessao) + +sealed interface ResultadoAbertura { + data class Aberta(val sessao: SessaoTerminal) : ResultadoAbertura + data class Recusada(val motivo: MotivoRecusa) : ResultadoAbertura +} + +enum class MotivoRecusa(val frase: String) { + LIMITE_TOTAL("O limite é de 8 sessões abertas. Feche uma para abrir outra."), + LIMITE_DO_AGENTE("O agente já tem 3 sessões abertas."), +} + +sealed interface EventoGerenciador { + /** 0 → 1 sessão: o serviço em primeiro plano sobe. */ + data object PrimeiraSessao : EventoGerenciador + /** Fechou a última: o serviço cai. */ + data object SemSessoes : EventoGerenciador +} + +/** + * Abre, lista e fecha sessões (CMP-29): **8 no total, 3 do agente**; ociosidade só da sessão do agente (10 min, D21) — a do usuário + * nunca fecha por ociosidade. A senha e a chave nunca passam por aqui: vêm pelas [DependenciasDeSessao]. + */ +class GerenciadorDeSessoes( + private val escopo: CoroutineScope, + private val dependencias: DependenciasDeSessao, + private val maxTotal: Int = 8, + private val maxAgente: Int = 3, + private val ociosidadeAgenteMs: Long = 10 * 60_000L, + private val intervaloDeVerificacaoMs: Long = 30_000, + private val novoId: () -> String = { java.util.UUID.randomUUID().toString().take(8) }, +) { + private val sessoes = linkedMapOf() + private val trava = Any() + private val _resumos = MutableStateFlow>(emptyList()) + val resumos: StateFlow> = _resumos + private val _eventos = MutableSharedFlow(extraBufferCapacity = 16) + val eventos: SharedFlow = _eventos + + init { + escopo.launch { + while (true) { + delay(intervaloDeVerificacaoMs) + fecharAgenteOcioso() + } + } + } + + fun abrir(host: Host, dono: DonoDaSessao, pty: PtyInfo = PtyInfo(80, 24), porta: PortaDoEmulador? = null): ResultadoAbertura { + val sessao = synchronized(trava) { + limparEncerradas() + if (sessoes.size >= maxTotal) return ResultadoAbertura.Recusada(MotivoRecusa.LIMITE_TOTAL) + if (dono == DonoDaSessao.AGENTE && sessoes.values.count { it.dono == DonoDaSessao.AGENTE } >= maxAgente) return ResultadoAbertura.Recusada(MotivoRecusa.LIMITE_DO_AGENTE) + SessaoTerminal(novoId(), host, dono, dependencias, escopo, porta = porta).also { sessoes[it.id] = it } + } + publicar() + sessao.iniciar(pty) + escopo.launch { sessao.estado.collect { publicar() } } + return ResultadoAbertura.Aberta(sessao) + } + + fun sessao(id: String): SessaoTerminal? = synchronized(trava) { sessoes[id] } + + suspend fun fechar(id: String, motivo: MotivoEncerramento = MotivoEncerramento.USUARIO) { + val s = synchronized(trava) { sessoes[id] } ?: return + s.fechar(motivo) + synchronized(trava) { sessoes.remove(id) } + publicar() + } + + suspend fun fecharDoHost(hostId: String) = copia().filter { it.host.id == hostId }.forEach { fechar(it.id, MotivoEncerramento.HOST_REMOVIDO) } + + suspend fun fecharDoAgente() = copia().filter { it.dono == DonoDaSessao.AGENTE }.forEach { fechar(it.id, MotivoEncerramento.AGENTE_PARADO) } + + suspend fun fecharTodas() = copia().forEach { fechar(it.id) } + + fun compartilhar(id: String, valor: Boolean) { sessao(id)?.compartilhada = valor; publicar() } + + /** As sessões que o agente enxerga: as dele e as que o usuário compartilhou. */ + fun sessoesVisiveisAoAgente(): List = copia().filter { it.dono == DonoDaSessao.AGENTE || it.compartilhada } + + /** Fecha as sessões **do agente** sem atividade há [ociosidadeAgenteMs]. A do usuário nunca. */ + suspend fun fecharAgenteOcioso() { + val agora = dependencias.agoraMs() + copia().filter { it.dono == DonoDaSessao.AGENTE && agora - it.ultimaAtividadeMs >= ociosidadeAgenteMs }.forEach { fechar(it.id, MotivoEncerramento.OCIOSIDADE) } + } + + private fun copia(): List = synchronized(trava) { sessoes.values.toList() } + + private fun limparEncerradas() { sessoes.values.removeAll { it.estado.value.terminal } } + + private var vivasAntes = 0 + + /** Publica a lista e emite `PrimeiraSessao`/`SemSessoes` quando o número de sessões **vivas** (não terminais) passa por zero. */ + private fun publicar() { + // Tudo sob a trava: o resumo gravado é sempre o do instante em que a lista foi lida (duas publicações simultâneas não se cruzam, e + // o último `_resumos.value` nunca é de uma lista mais velha). `tryEmit` não suspende, então segurar a trava aqui é seguro. + synchronized(trava) { + val lista = sessoes.values.toList() + _resumos.value = lista.map { SessaoResumo(it.id, it.host.id, it.dono, it.compartilhada, it.estado.value) } + val vivas = lista.count { !it.estado.value.terminal } + val e = when { + vivasAntes == 0 && vivas > 0 -> EventoGerenciador.PrimeiraSessao + vivasAntes > 0 && vivas == 0 -> EventoGerenciador.SemSessoes + else -> null + } + vivasAntes = vivas + if (e != null) _eventos.tryEmit(e) + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/sessao/PortaDoEmulador.kt b/app/src/main/java/com/keyflow/computador/sessao/PortaDoEmulador.kt new file mode 100644 index 00000000..216abeb6 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/sessao/PortaDoEmulador.kt @@ -0,0 +1,8 @@ +package com.keyflow.computador.sessao + +/** O que a sessão precisa do emulador da tela (implementada na T13 pelo `LigacaoEmulador`; a sessão não conhece o termlib). */ +interface PortaDoEmulador { + /** Bytes que chegaram do servidor (ou texto local, como o separador de reconexão). */ + fun escreverNaTela(bytes: ByteArray) + fun tamanhoAtual(): com.keyflow.computador.ssh.PtyInfo +} diff --git a/app/src/main/java/com/keyflow/computador/sessao/Reconexao.kt b/app/src/main/java/com/keyflow/computador/sessao/Reconexao.kt new file mode 100644 index 00000000..cf85bd54 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/sessao/Reconexao.kt @@ -0,0 +1,23 @@ +package com.keyflow.computador.sessao + +import com.keyflow.computador.ssh.CausaSsh + +/** + * A política de reconexão (CMP-22): espera 1·2·4·8·16·30 s (e 30 s dali em diante), no máximo [MAX_TENTATIVAS] tentativas ou [MAX_TOTAL_MS] + * desde a primeira queda. Pura: quem mede o tempo é a sessão, com relógio injetável. + */ +object Reconexao { + const val MAX_TENTATIVAS = 10 + const val MAX_TOTAL_MS = 10 * 60_000L + private val ESPERAS_S = longArrayOf(1, 2, 4, 8, 16, 30) + + /** Espera antes da tentativa [tentativa] (1-based), ou `null` se acabaram as tentativas ou o tempo. [decorridoMs] conta desde a primeira queda. */ + fun espera(tentativa: Int, decorridoMs: Long): Long? { + if (tentativa < 1 || tentativa > MAX_TENTATIVAS) return null + val ms = ESPERAS_S[minOf(tentativa - 1, ESPERAS_S.size - 1)] * 1000 + return if (decorridoMs + ms > MAX_TOTAL_MS) null else ms + } + + /** Só vale reconectar quando a queda foi da rede ou do servidor; recusa de credencial, de servidor ou de biometria não se repete sozinha. */ + fun reconectavel(causa: CausaSsh): Boolean = causa in setOf(CausaSsh.REDE, CausaSsh.TEMPO, CausaSsh.RECUSADA, CausaSsh.DESCONECTADO, CausaSsh.DNS) +} diff --git a/app/src/main/java/com/keyflow/computador/sessao/SessaoTerminal.kt b/app/src/main/java/com/keyflow/computador/sessao/SessaoTerminal.kt new file mode 100644 index 00000000..d36c4618 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/sessao/SessaoTerminal.kt @@ -0,0 +1,163 @@ +package com.keyflow.computador.sessao + +import com.keyflow.computador.Host +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.computador.ssh.ConexaoSsh +import com.keyflow.computador.ssh.DestinoSsh +import com.keyflow.computador.ssh.EventoConexao +import com.keyflow.computador.ssh.PtyInfo +import com.keyflow.computador.ssh.ResultadoConexao +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Tudo de fora que a sessão usa, em uma porta só (o Android implementa; os testes usam fakes). */ +interface DependenciasDeSessao { + fun novaConexao(): ConexaoSsh + + /** Monta o destino (senha lembrada, assinador). `null` = não deu (cofre, chave sumiu): a sessão falha. */ + suspend fun destino(host: Host, dono: DonoDaSessao): DestinoSsh? + + /** Chave com biometria **fora** da janela de 5 minutos: a reconexão não abre o prompt sozinha (D14). */ + fun precisaDoUsuario(host: Host): Boolean + + /** Há rede? A reconexão só tenta com `true`. */ + val rede: StateFlow + + fun agoraMs(): Long +} + +/** + * Uma sessão SSH: estado, o buffer de texto, a conexão e a reconexão automática (CMP-22). Cada tentativa é uma **conexão nova com + * autenticação nova**. A primeira conexão que falha não se repete sozinha (o usuário vê o erro); depois de aberta, a queda por rede ou + * servidor reconecta com backoff. + */ +class SessaoTerminal( + val id: String, + val host: Host, + val dono: DonoDaSessao, + private val dependencias: DependenciasDeSessao, + private val escopo: CoroutineScope, + val buffer: BufferDeTexto = BufferDeTexto(), + @Volatile var porta: PortaDoEmulador? = null, +) { + @Volatile var compartilhada: Boolean = false + + private val _estado = MutableStateFlow(EstadoSessao.Conectando) + val estado: StateFlow = _estado + + /** Transições que a máquina recusaria (nunca deve ter itens; os testes conferem). */ + val transicoesImpossiveis = java.util.concurrent.CopyOnWriteArrayList>() + + @Volatile var ultimaAtividadeMs: Long = dependencias.agoraMs() + private set + + @Volatile private var conexao: ConexaoSsh? = null + @Volatile private var pty = PtyInfo(80, 24) + private var ciclo: Job? = null + @Volatile private var encerrando = false + + fun iniciar(pty: PtyInfo) { + this.pty = pty + ciclo = escopo.launch { executar(reconectando = false) } + } + + /** O usuário tocou em "reconectar" (biometria já liberada): retoma a partir de [EstadoSessao.PrecisaDoUsuario]. */ + fun retomar() { + if (_estado.value != EstadoSessao.PrecisaDoUsuario) return + ciclo?.cancel() + ciclo = escopo.launch { executar(reconectando = true) } + } + + suspend fun escrever(bytes: ByteArray) { + ultimaAtividadeMs = dependencias.agoraMs() + conexao?.escrever(bytes) + } + + suspend fun redimensionar(novo: PtyInfo) { + pty = novo + conexao?.redimensionar(novo) + } + + suspend fun fechar(motivo: MotivoEncerramento = MotivoEncerramento.USUARIO) { + encerrando = true + if (!_estado.value.terminal) definir(EstadoSessao.Encerrada(motivo)) + ciclo?.cancel() + withContext(NonCancellable) { runCatching { conexao?.fechar() } } + } + + private fun definir(novo: EstadoSessao) { + val atual = _estado.value + if (!EstadoSessao.permitida(atual, novo)) { transicoesImpossiveis += atual to novo; return } + _estado.value = novo + } + + private suspend fun executar(reconectando: Boolean) { + var emReconexao = reconectando + var tentativa = 0 + var primeiraQueda: Long? = null + while (true) { + if (emReconexao) { + tentativa++ + val decorrido = primeiraQueda?.let { dependencias.agoraMs() - it } ?: 0L + val espera = Reconexao.espera(tentativa, decorrido) + ?: return definir(EstadoSessao.Falhou(ultimaCausa ?: CausaSsh.DESCONECTADO)) + if (dependencias.precisaDoUsuario(host)) return definir(EstadoSessao.PrecisaDoUsuario) + definir(EstadoSessao.Reconectando(tentativa)) + dependencias.rede.first { it } + delay(espera) + } + val conn = dependencias.novaConexao() + conexao = conn + val observador = escopo.launch { conn.eventos.collect { traduzir(it, emReconexao) } } + val destino = dependencias.destino(host, dono) + val resultado = if (destino == null) ResultadoConexao.Falhou(CausaSsh.BIOMETRIA) else conn.conectar(destino, pty) + observador.cancel() + when (resultado) { + ResultadoConexao.Aberta -> { + if (emReconexao) { buffer.adicionarLocal(BufferDeTexto.SEPARADOR_DE_RECONEXAO); porta?.escreverNaTela(BufferDeTexto.SEPARADOR_DE_RECONEXAO.toByteArray()) } + definir(EstadoSessao.Aberta) + tentativa = 0 + primeiraQueda = null + ultimaCausa = null + conn.saida.collect { bytes -> + ultimaAtividadeMs = dependencias.agoraMs() + buffer.alimentar(bytes) + porta?.escreverNaTela(bytes) + } + if (encerrando) return + // A conexão emite `Caiu` antes de fechar a saída: o último evento guardado diz por quê. + (conn.eventos.replayCache.lastOrNull { it is EventoConexao.Caiu } as? EventoConexao.Caiu)?.let { ultimaCausa = it.causa } + primeiraQueda = dependencias.agoraMs() + val causa = ultimaCausa ?: CausaSsh.DESCONECTADO + ultimaCausa = causa + if (!Reconexao.reconectavel(causa)) return definir(EstadoSessao.Falhou(causa)) + emReconexao = true + } + is ResultadoConexao.Falhou -> { + ultimaCausa = resultado.causa + if (!emReconexao || !Reconexao.reconectavel(resultado.causa)) return definir(EstadoSessao.Falhou(resultado.causa)) + if (primeiraQueda == null) primeiraQueda = dependencias.agoraMs() + } + } + } + } + + @Volatile private var ultimaCausa: CausaSsh? = null + + private fun traduzir(e: EventoConexao, reconectando: Boolean) { + if (reconectando) return + when (e) { + EventoConexao.Conectando -> Unit + is EventoConexao.AguardandoServidor -> definir(EstadoSessao.AguardandoServidor) + EventoConexao.Autenticando -> definir(EstadoSessao.Autenticando) + else -> Unit + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/ssh/CausaSsh.kt b/app/src/main/java/com/keyflow/computador/ssh/CausaSsh.kt new file mode 100644 index 00000000..dd471f12 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/ssh/CausaSsh.kt @@ -0,0 +1,24 @@ +package com.keyflow.computador.ssh + +/** Por que uma conexão falhou ou caiu (tabela "Erros de conexão" do spec, CMP-21). A mensagem nunca vem da exceção: é [frase]. */ +enum class CausaSsh(val frase: String?) { + DNS("Não encontrei esse endereço."), + RECUSADA("O servidor recusou a conexão."), + TEMPO("O servidor não respondeu a tempo."), + REDE("Sem conexão de rede."), + + /** O usuário cancelou a confirmação da impressão digital: não é erro, encerra calado. */ + SERVIDOR_NAO_CONFIADO(null), + + /** Sessão do agente diante de servidor novo: só o usuário confirma, no aplicativo. */ + SERVIDOR_PEDE_CONFIRMACAO("Confirme o servidor no aplicativo."), + CHAVE_DO_SERVIDOR_MUDOU("A identificação deste servidor mudou."), + AUTENTICACAO("Usuário ou senha incorretos."), + CHAVE_NAO_ACEITA("O servidor não aceitou esta chave. Autorize a chave pública no servidor."), + SEM_ALGORITMO("Este servidor só oferece algoritmos antigos que o KeyFlow não usa."), + BIOMETRIA("Autenticação cancelada."), + BIOMETRIA_INVALIDADA("A chave foi invalidada pela biometria."), + DESCONECTADO("O servidor encerrou a conexão."), + ENCERRADA_PELO_SISTEMA("O Android encerrou a sessão."), + PROTOCOLO("Falha na comunicação com o servidor."), +} diff --git a/app/src/main/java/com/keyflow/computador/ssh/ConexaoCbssh.kt b/app/src/main/java/com/keyflow/computador/ssh/ConexaoCbssh.kt new file mode 100644 index 00000000..0b1019d6 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/ssh/ConexaoCbssh.kt @@ -0,0 +1,322 @@ +package com.keyflow.computador.ssh + +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.AtualizadorDeChaves +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.PoliticaDeConfianca +import com.keyflow.computador.RepositorioDeConfianca +import com.keyflow.computador.VereditoDeConfianca +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.connectbot.sshlib.AuthHandler +import org.connectbot.sshlib.AuthPublicKey +import org.connectbot.sshlib.AuthResult +import org.connectbot.sshlib.ConnectResult +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.KeyboardInteractiveCallback +import org.connectbot.sshlib.PingResult +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SshClient +import org.connectbot.sshlib.SshClientConfig +import org.connectbot.sshlib.SshSession +import org.connectbot.sshlib.transport.IpVersion +import org.connectbot.sshlib.transport.KtorTcpTransportFactory +import org.connectbot.sshlib.transport.TransportFactory +import kotlinx.coroutines.withTimeout + +/** + * O adaptador do cbssh: o **único** arquivo de `ssh/` que o importa (CMP-17). Um [SshClient] por conexão, política criptográfica padrão + * da biblioteca (nada de algoritmo legado), verificação do servidor pela [PoliticaDeConfianca] (suspende até o usuário decidir), + * autenticação por [AuthHandler] ligado ao `Assinador`/senha/código, e erros traduzidos para [CausaSsh] sem ler mensagem de exceção. + * + * Dois limites separados (tabela de erros): o **TCP** tem [tempoTcpMs] (15 s, no próprio `TransportFactory`) e a **troca de chaves** tem [tempoTrocaDeChavesMs] + * (30 s). O `handshakeTimeoutMs` do cbssh fica desligado (0) porque ele contaria o tempo em que o usuário decide a impressão digital; quem vigia a troca é + * o relógio daqui, que **para de contar enquanto o usuário decide**. + */ +class ConexaoCbssh( + private val confianca: RepositorioDeConfianca, + private val interacao: InteracaoComUsuario, + private val escopo: CoroutineScope, + private val tempoTcpMs: Long = 15_000, + private val tempoTrocaDeChavesMs: Long = 30_000, + private val intervaloVigiaMs: Long = 30_000, + private val agoraMs: () -> Long = System::currentTimeMillis, + /** Gancho de teste: ajusta o `SshClientConfig` (por exemplo, o transporte). Em produção não há. */ + private val ajustar: (SshClientConfig.Builder) -> Unit = {}, +) : ConexaoSsh { + private val _eventos = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) + override val eventos: SharedFlow = _eventos + + private val canalSaida = Channel(Channel.UNLIMITED) + override val saida: Flow = canalSaida.receiveAsFlow() + + @Volatile private var cliente: SshClient? = null + @Volatile private var canal: SshSession? = null + private var tarefas: List = emptyList() + private val fechada = AtomicBoolean(false) + + private sealed interface Prep + private class Preparada(val cliente: SshClient, val algoritmo: String, val chave: ChaveDoServidor, val metodo: String) : Prep + private class Falha(val causa: CausaSsh) : Prep + + override suspend fun conectar(destino: DestinoSsh, pty: PtyInfo): ResultadoConexao { + emitir(EventoConexao.Conectando) + val preparada = when (val r = preparar(destino)) { + is Falha -> return falhou(r.causa) + is Preparada -> r + } + val c = preparada.cliente + try { + val sessao = c.openSession() ?: return falhar(c, CausaSsh.PROTOCOLO) + canal = sessao + if (!sessao.requestPty(pty.termo, pty.colunas, pty.linhas, pty.largura, pty.altura)) return falhar(c, CausaSsh.PROTOCOLO) + // Melhor esforço: muitos servidores não aceitam `env` sem AcceptEnv. + runCatching { sessao.requestEnv("COLORTERM", "truecolor") } + if (!sessao.requestShell()) return falhar(c, CausaSsh.PROTOCOLO) + iniciarLeitura(c, sessao) + emitir(EventoConexao.Aberta) + return ResultadoConexao.Aberta + } catch (e: CancellationException) { + withContext(NonCancellable) { runCatching { c.disconnect() } } + throw e + } catch (e: Exception) { + return falhar(c, MapaDeErros.causaDe(e)) + } + } + + override suspend fun testar(destino: DestinoSsh): ResultadoTeste { + val inicio = agoraMs() + return when (val r = preparar(destino)) { + is Falha -> ResultadoTeste.Falhou(r.causa) + is Preparada -> { + val ms = agoraMs() - inicio + // Sem shell: só fecha. + withContext(NonCancellable) { runCatching { r.cliente.disconnect() } } + ResultadoTeste.Ok(r.algoritmo, r.chave.impressao, r.metodo, ms) + } + } + } + + override suspend fun escrever(bytes: ByteArray) { + canal?.write(bytes) + } + + override suspend fun redimensionar(pty: PtyInfo) { + canal?.resizeTerminal(pty.colunas, pty.linhas, pty.largura, pty.altura) + } + + override suspend fun fechar() { + fechada.set(true) + tarefas.forEach { it.cancel() } + withContext(NonCancellable) { + runCatching { canal?.close() } + runCatching { cliente?.disconnect() } + } + canalSaida.close() + } + + // ---- internos ----------------------------------------------------------------------------------------------------------------- + + private fun emitir(e: EventoConexao) { _eventos.tryEmit(e) } + + private fun falhou(causa: CausaSsh): ResultadoConexao { + emitir(EventoConexao.Caiu(causa)) + return ResultadoConexao.Falhou(causa) + } + + private suspend fun falhar(c: SshClient, causa: CausaSsh): ResultadoConexao { + withContext(NonCancellable) { runCatching { c.disconnect() } } + return falhou(causa) + } + + private suspend fun preparar(destino: DestinoSsh): Prep { + val h = destino.host + val endereco = h.endereco.removePrefix("[").removeSuffix("]") + val estado = EstadoDaConexao() + val verificador = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean { + val chave = ChaveDoServidor(key.type, key.encoded) + estado.chaveDoServidor = chave + return when (val v = PoliticaDeConfianca.avaliar(endereco, h.porta, chave, confianca)) { + VereditoDeConfianca.Conhecida -> true + is VereditoDeConfianca.Nova -> { + if (!destino.confiarSozinho) { estado.causa = CausaSsh.SERVIDOR_PEDE_CONFIRMACAO; return false } + emitir(EventoConexao.AguardandoServidor(key.type, v.impressao)) + estado.aguardandoUsuario.set(true) + val ok = try { interacao.confirmarServidor(endereco, h.porta, key.type, v.impressao) } finally { estado.aguardandoUsuario.set(false) } + if (ok) { confianca.confiar(endereco, h.porta, chave); true } else { estado.causa = CausaSsh.SERVIDOR_NAO_CONFIADO; false } + } + is VereditoDeConfianca.Mudou -> { + estado.causa = CausaSsh.CHAVE_DO_SERVIDOR_MUDOU + emitir(EventoConexao.ServidorMudou(v.antiga, v.nova)) + false + } + } + } + + // Rotação anunciada pelo servidor: só grava quando a chave que autenticou o servidor já era conhecida (CMP-19) e a sessão é do usuário. + override suspend fun addKeys(keys: List) { + val atual = estado.chaveDoServidor ?: return + if (!destino.confiarSozinho) return + AtualizadorDeChaves.aplicar(endereco, h.porta, atual, keys.map { ChaveDoServidor(it.type, it.encoded) }, confianca) + } + } + val config = SshClientConfig { + this.host = endereco + this.port = h.porta + this.hostKeyVerifier = verificador + this.keepAliveIntervalMs = intervaloVigiaMs + // Com senha (ou "perguntar"), o cbssh tenta `password` em vez de `keyboard-interactive` quando o servidor oferece os dois. + this.preferPasswordAuth = h.autenticacao !is AutenticacaoHost.Chave + this.handshakeTimeoutMs = 0 + ajustar(this) + // O limite do TCP vai por cima do transporte escolhido (o padrão do cbssh, ou o do teste): conectar o socket tem 15 s. + val base = this.transportFactory ?: KtorTcpTransportFactory(endereco, h.porta, IpVersion.AUTO) + this.transportFactory = TransportFactory { withTimeout(tempoTcpMs) { base.create() } } + } + val c = SshClient(config) + val resultado = try { + conectarComLimite(c, estado) + } catch (e: CancellationException) { + withContext(NonCancellable) { runCatching { c.disconnect() } } + throw e + } + when (resultado) { + is ConnectResult.Success -> Unit + is ConnectResult.HostKeyRejected -> return Falha(estado.causa ?: CausaSsh.SERVIDOR_NAO_CONFIADO) + is ConnectResult.AlgorithmMismatch -> return Falha(CausaSsh.SEM_ALGORITMO) + is ConnectResult.TransportError -> return Falha(if (estado.estourou) CausaSsh.TEMPO else MapaDeErros.causaDe(resultado.cause)) + is ConnectResult.ProtocolError -> return Falha(if (estado.estourou) CausaSsh.TEMPO else resultado.cause?.let { MapaDeErros.causaDe(it) } ?: CausaSsh.PROTOCOLO) + } + if (estado.estourou) { withContext(NonCancellable) { runCatching { c.disconnect() } }; return Falha(CausaSsh.TEMPO) } + emitir(EventoConexao.Autenticando) + val auth = autenticador(destino, estado) + val r = try { c.authenticate(h.usuario, auth) } catch (e: CancellationException) { withContext(NonCancellable) { runCatching { c.disconnect() } }; throw e } + if (r !is AuthResult.Success) { + withContext(NonCancellable) { runCatching { c.disconnect() } } + return Falha( + when { + estado.biometriaInvalidada -> CausaSsh.BIOMETRIA_INVALIDADA + estado.biometriaCancelada -> CausaSsh.BIOMETRIA + r is AuthResult.Error -> r.cause?.let { MapaDeErros.causaDe(it) } ?: CausaSsh.PROTOCOLO + estado.ofereceuChave && estado.metodoSemChave -> CausaSsh.AUTENTICACAO + estado.ofereceuChave -> CausaSsh.CHAVE_NAO_ACEITA + else -> CausaSsh.AUTENTICACAO + }, + ) + } + cliente = c + return Preparada(c, estado.chaveDoServidor?.algoritmo ?: "", estado.chaveDoServidor ?: ChaveDoServidor("", ByteArray(0)), estado.metodoFinal ?: "") + } + + /** `connect()` com os limites de TCP e de troca de chaves que **não conta** o tempo em que o usuário decide a impressão digital. */ + private suspend fun conectarComLimite(c: SshClient, estado: EstadoDaConexao): ConnectResult = coroutineScope { + val tarefa = async { c.connect() } + val relogio = launch { + while (isActive) { + delay(tempoTcpMs + tempoTrocaDeChavesMs) + if (!estado.aguardandoUsuario.get()) { estado.estourou = true; tarefa.cancel(); return@launch } + } + } + try { + tarefa.await() + } catch (e: CancellationException) { + // Estouro do relógio da troca de chaves, ou do limite do TCP (que o `withTimeout` do transporte lança como cancelamento): é tempo, não cancelamento de fora. + if (estado.estourou || (e is kotlinx.coroutines.TimeoutCancellationException && currentCoroutineContext().isActive)) ConnectResult.TransportError(java.net.SocketTimeoutException()) else throw e + } finally { + relogio.cancel() + } + } + + private class EstadoDaConexao { + @Volatile var causa: CausaSsh? = null + @Volatile var chaveDoServidor: ChaveDoServidor? = null + @Volatile var estourou = false + @Volatile var ofereceuChave = false + @Volatile var metodoSemChave = false + @Volatile var biometriaCancelada = false + @Volatile var biometriaInvalidada = false + @Volatile var metodoFinal: String? = null + val aguardandoUsuario = AtomicBoolean(false) + } + + private fun autenticador(destino: DestinoSsh, estado: EstadoDaConexao) = object : AuthHandler { + private val comChave = destino.host.autenticacao is AutenticacaoHost.Chave && destino.assinador != null + + override suspend fun onPublicKeysNeeded(): List { + if (!comChave) return emptyList() + val pub = destino.assinador!!.chavePublica() + estado.ofereceuChave = true + return listOf(AuthPublicKey(pub.algoritmo, pub.blob)) + } + + override suspend fun onSignatureRequest(key: AuthPublicKey, dataToSign: ByteArray): ByteArray? { + val a = destino.assinador ?: return null + val assinatura = a.assinar(dataToSign) + if (assinatura == null) { if (a.invalidada) estado.biometriaInvalidada = true else estado.biometriaCancelada = true } else estado.metodoFinal = "publickey" + return assinatura + } + + override suspend fun onKeyboardInteractivePrompt(name: String, instruction: String, prompts: List): List? { + val r = interacao.pedirCodigo(name, instruction, prompts.map { it.text }, prompts.map { it.echo }) + if (r != null) { estado.metodoFinal = "keyboard-interactive"; estado.metodoSemChave = true } + return r + } + + override suspend fun onPasswordNeeded(): String? { + if (destino.host.autenticacao is AutenticacaoHost.Chave) return null + val s = destino.senhaLembrada ?: interacao.pedirSenha() + if (s != null) { estado.metodoFinal = "password"; estado.metodoSemChave = true } + return s + } + } + + private fun iniciarLeitura(c: SshClient, sessao: SshSession) { + val leitura = escopo.launch { + val saidaPadrao = launch { try { for (b in sessao.stdout) canalSaida.send(b) } catch (_: Exception) { } } + val erro = launch { try { for (b in sessao.stderr) canalSaida.send(b) } catch (_: Exception) { } } + saidaPadrao.join() + erro.cancelAndJoin() + if (!fechada.get()) { emitir(EventoConexao.Caiu(CausaSsh.DESCONECTADO)); canalSaida.close() } + } + val queda = escopo.launch { + c.disconnectedFlow.collect { causa -> + if (!fechada.get()) { + emitir(EventoConexao.Caiu(if (causa == null) CausaSsh.DESCONECTADO else MapaDeErros.causaDe(causa))) + canalSaida.close() + } + } + } + val vigia = escopo.launch { + Vigia(intervaloVigiaMs, 3) { + when (val p = c.ping()) { + is PingResult.Success -> true + PingResult.NotSupported -> null + PingResult.NotAuthenticated, is PingResult.Failure -> false + } + }.vigiar() + if (!fechada.get()) { + emitir(EventoConexao.Caiu(CausaSsh.REDE)) + runCatching { c.disconnect() } + canalSaida.close() + } + } + tarefas = listOf(leitura, queda, vigia) + } +} diff --git a/app/src/main/java/com/keyflow/computador/ssh/ConexaoSsh.kt b/app/src/main/java/com/keyflow/computador/ssh/ConexaoSsh.kt new file mode 100644 index 00000000..73df2a1c --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/ssh/ConexaoSsh.kt @@ -0,0 +1,68 @@ +package com.keyflow.computador.ssh + +import com.keyflow.computador.Host +import com.keyflow.computador.ImpressaoDigital +import com.keyflow.computador.chaves.Assinador +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow + +/** Tamanho do terminal pedido ao servidor (`pty-req` e `window-change`). */ +data class PtyInfo(val colunas: Int, val linhas: Int, val largura: Int = 0, val altura: Int = 0, val termo: String = "xterm-256color") + +/** + * O que a conexão precisa saber do app. [senhaLembrada] vem do cofre e nunca é impressa ([toString] a esconde); [assinador] só existe para + * host com chave. [confiarSozinho] é `false` nas sessões do **agente**: servidor novo ou mudado nunca é aceito sem o usuário (CMP-53). + */ +class DestinoSsh( + val host: Host, + val senhaLembrada: String? = null, + val assinador: Assinador? = null, + val confiarSozinho: Boolean = true, +) { + override fun toString() = "DestinoSsh(host=${host.id}, ***)" +} + +/** Quem responde ao que só o usuário pode responder. Cada chamada **suspende** até a resposta; cancelar a coroutine vale como recusa. */ +interface InteracaoComUsuario { + suspend fun confirmarServidor(host: String, porta: Int, algoritmo: String, impressao: ImpressaoDigital): Boolean + suspend fun pedirSenha(): String? + suspend fun pedirCodigo(nome: String, instrucao: String, perguntas: List, eco: List): List? +} + +/** Acontecimentos da conexão, para a sessão e a tela acompanharem. Nenhum carrega segredo. */ +sealed interface EventoConexao { + data object Conectando : EventoConexao + data class AguardandoServidor(val algoritmo: String, val impressao: ImpressaoDigital) : EventoConexao + data class ServidorMudou(val antiga: ImpressaoDigital, val nova: ImpressaoDigital) : EventoConexao + data object Autenticando : EventoConexao + data object Aberta : EventoConexao + data class Caiu(val causa: CausaSsh) : EventoConexao +} + +sealed interface ResultadoConexao { + data object Aberta : ResultadoConexao + data class Falhou(val causa: CausaSsh) : ResultadoConexao +} + +/** O que o "Testar conexão" mostra (CMP-08). */ +sealed interface ResultadoTeste { + data class Ok(val algoritmoDoServidor: String, val impressao: ImpressaoDigital, val metodo: String, val latenciaMs: Long) : ResultadoTeste + data class Falhou(val causa: CausaSsh) : ResultadoTeste +} + +/** A porta da conexão SSH: o resto do app e os testes de unidade só conhecem isto, não o cbssh (CMP-17). */ +interface ConexaoSsh { + val eventos: SharedFlow + + /** Saída do shell (stdout e stderr, em ordem de chegada). Termina quando a conexão cai ou é fechada. */ + val saida: Flow + + suspend fun conectar(destino: DestinoSsh, pty: PtyInfo): ResultadoConexao + + /** TCP + troca de chaves + verificação do servidor + autenticação, **sem abrir o shell**. */ + suspend fun testar(destino: DestinoSsh): ResultadoTeste + + suspend fun escrever(bytes: ByteArray) + suspend fun redimensionar(pty: PtyInfo) + suspend fun fechar() +} diff --git a/app/src/main/java/com/keyflow/computador/ssh/MapaDeErros.kt b/app/src/main/java/com/keyflow/computador/ssh/MapaDeErros.kt new file mode 100644 index 00000000..5042bdbf --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/ssh/MapaDeErros.kt @@ -0,0 +1,46 @@ +package com.keyflow.computador.ssh + +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import kotlinx.coroutines.TimeoutCancellationException + +/** + * Exceção (de rede, da JVM ou do Ktor) → [CausaSsh]. Olha a cadeia de causas e, para os tipos de bibliotecas que a JVM pura não + * conhece, o **nome** da classe. A mensagem da exceção nunca é lida: pode citar usuário, endereço ou dados da autenticação. + */ +object MapaDeErros { + fun causaDe(erro: Throwable): CausaSsh { + var atual: Throwable? = erro + var profundidade = 0 + while (atual != null && profundidade++ < 10) { + val nome = atual.javaClass.name + if (nome.endsWith("TransportException")) causaPelaMensagem(atual.message)?.let { return it } + when { + atual is UnknownHostException || nome.endsWith("UnresolvedAddressException") -> return CausaSsh.DNS + atual is ConnectException || nome.contains("ConnectionRefused") -> return CausaSsh.RECUSADA + atual is SocketTimeoutException || atual is TimeoutCancellationException || nome.contains("Timeout") -> return CausaSsh.TEMPO + atual is NoRouteToHostException || atual is SocketException || nome.contains("ClosedChannel") || nome.contains("Network") -> return CausaSsh.REDE + } + atual = atual.cause?.takeIf { it !== atual } + } + return CausaSsh.PROTOCOLO + } + + /** + * O transporte do cbssh só diz o motivo no texto (`Failed to connect … Errors: Connection refused`). A mensagem é lida **só aqui**, + * para escolher a causa por palavras-chave; ela nunca é guardada, mostrada nem logada. + */ + private fun causaPelaMensagem(mensagem: String?): CausaSsh? { + val m = mensagem?.lowercase() ?: return null + return when { + "refused" in m -> CausaSsh.RECUSADA + "could not resolve" in m || "unknown host" in m || "unresolved" in m -> CausaSsh.DNS + "timed out" in m || "timeout" in m -> CausaSsh.TEMPO + "unreachable" in m || "no route" in m || "network" in m -> CausaSsh.REDE + else -> null + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/ssh/Vigia.kt b/app/src/main/java/com/keyflow/computador/ssh/Vigia.kt new file mode 100644 index 00000000..de58509f --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/ssh/Vigia.kt @@ -0,0 +1,23 @@ +package com.keyflow.computador.ssh + +import kotlinx.coroutines.delay + +/** + * Detecta servidor mudo (CMP-22). O cbssh só envia `SSH_MSG_IGNORE` como keepalive, que não prova que o servidor responde; então a cada + * [intervaloMs] o vigia pede uma sondagem de verdade. [sondar] devolve `true` (respondeu), `false` (falhou ou não respondeu) ou `null` + * (o servidor não suporta ping: não há como medir, e o vigia não derruba nada). [maxFalhas] falhas seguidas = perdida. + */ +class Vigia(private val intervaloMs: Long = 30_000, private val maxFalhas: Int = 3, private val sondar: suspend () -> Boolean?) { + /** Roda até declarar a conexão perdida (retorna) ou ser cancelado. */ + suspend fun vigiar() { + var falhas = 0 + while (true) { + delay(intervaloMs) + when (sondar()) { + true -> falhas = 0 + false -> if (++falhas >= maxFalhas) return + null -> Unit + } + } + } +} diff --git a/app/src/main/java/com/keyflow/data/SettingsRepository.kt b/app/src/main/java/com/keyflow/data/SettingsRepository.kt index 7e79dcf1..a5f2cf37 100644 --- a/app/src/main/java/com/keyflow/data/SettingsRepository.kt +++ b/app/src/main/java/com/keyflow/data/SettingsRepository.kt @@ -35,6 +35,10 @@ import com.keyflow.core.shortcuts.ShortcutGrid import com.keyflow.data.models.ModelCatalog import com.keyflow.data.models.ModelKind import com.keyflow.data.models.ModelProvisioning +import com.keyflow.computador.CodecHosts +import com.keyflow.computador.ConfigHosts +import com.keyflow.computador.chaves.ChaveSsh +import com.keyflow.computador.chaves.CodecChaves import com.keyflow.fluxos.CodecFluxos import com.keyflow.fluxos.BotoesDeFluxo import com.keyflow.fluxos.ConfigFluxos @@ -410,6 +414,68 @@ class SettingsRepository( dataStore.edit { it[SHORTCUT_GRIDS_JSON] = ShortcutCodec.encodeGrids(grids) } } + // ── Computador (aba Computador): `computer_hosts_json` e `computer_keys_json` ───────────── + // Chaves próprias, fora de [Settings]: o auto-save da tela regrava as Settings inteiras e nunca encosta nestas. Nada aqui é segredo + // (a lista de hosts não tem campo de senha; os metadados de chave não têm material) e nada abre o cofre. Leitura sem a chave = lista + // vazia, **sem gravar nada**. Escrita sempre sobre o valor atual, dentro do `dataStore.edit`. + + suspend fun hostsOnce(): ConfigHosts = CodecHosts.decodificar(dataStore.data.first()[COMPUTER_HOSTS_JSON]) + + fun hostsFlow(): Flow = dataStore.data.map { CodecHosts.decodificar(it[COMPUTER_HOSTS_JSON]) }.distinctUntilChanged() + + /** Altera os hosts **sobre o valor gravado agora**: duas alterações concorrentes ficam ambas. Devolve o que ficou. */ + suspend fun atualizarHosts(alterar: (ConfigHosts) -> ConfigHosts): ConfigHosts { + var resultado = ConfigHosts() + dataStore.edit { prefs -> + resultado = alterar(CodecHosts.decodificar(prefs[COMPUTER_HOSTS_JSON])) + prefs[COMPUTER_HOSTS_JSON] = CodecHosts.codificar(resultado) + } + return resultado + } + + suspend fun chavesOnce(): List = CodecChaves.decodificar(dataStore.data.first()[COMPUTER_KEYS_JSON]) + + fun chavesFlow(): Flow> = dataStore.data.map { CodecChaves.decodificar(it[COMPUTER_KEYS_JSON]) }.distinctUntilChanged() + + suspend fun atualizarChaves(alterar: (List) -> List): List { + var resultado = emptyList() + dataStore.edit { prefs -> + resultado = alterar(CodecChaves.decodificar(prefs[COMPUTER_KEYS_JSON])) + prefs[COMPUTER_KEYS_JSON] = CodecChaves.codificar(resultado) + } + return resultado + } + + /** "Manter conectado com a tela apagada" (D22): desligado por padrão. Ligado, o serviço das sessões segura um `PARTIAL_WAKE_LOCK`. */ + fun manterConectadoFlow(): Flow = dataStore.data.map { it[COMPUTER_KEEP_AWAKE] ?: false }.distinctUntilChanged() + + suspend fun salvarManterConectado(ligado: Boolean) { dataStore.edit { it[COMPUTER_KEEP_AWAKE] = ligado } } + + suspend fun migracaoComputadorFeita(): Boolean = dataStore.data.first()[COMPUTER_MIGRATED] == true + + /** Grava hosts, chaves e a marca de migração **na mesma transação** (CMP-06): ou os três, ou nenhum. */ + suspend fun gravarMigracaoComputador(hosts: ConfigHosts, chaves: List) { + dataStore.edit { prefs -> + prefs[COMPUTER_HOSTS_JSON] = CodecHosts.codificar(hosts) + prefs[COMPUTER_KEYS_JSON] = CodecChaves.codificar(chaves) + prefs[COMPUTER_MIGRATED] = true + } + } + + suspend fun marcarMigracaoComputador() { dataStore.edit { it[COMPUTER_MIGRATED] = true } } + + /** O host único legado (`computerSsh*`), com os segredos lidos do cofre. Só a migração usa. */ + suspend fun hostUnicoLegado(): com.keyflow.computador.HostUnicoLegado { + val s = settingsFlow().first() + return com.keyflow.computador.HostUnicoLegado(s.computerSshHost, s.computerSshPort, s.computerSshUser, s.computerSshAuthType, s.computerSshPassword, s.computerSshKey) + } + + /** Apaga os dois segredos legados do cofre (`computer_ssh_password` e `computer_ssh_key`). */ + suspend fun apagarSegredosDoHostUnicoLegado() { + saveSecret(SettingsCredentialKeys.COMPUTER_SSH_PASSWORD, "") + saveSecret(SettingsCredentialKeys.COMPUTER_SSH_KEY, "") + } + // ── Fluxos (aba Fluxos): `flows_json` e `flow_triggers_json` ───────────── // Duas chaves próprias, fora de [Settings]: o auto-save da tela regrava as Settings inteiras e nunca encosta nestas, e estas // nunca encostam nas Settings (o gatilho sombreia a ação do gesto, não a sobrescreve — D12). Nada aqui é segredo e nada @@ -943,6 +1009,10 @@ class SettingsRepository( val FALA_CUSTOM_VOICE = stringPreferencesKey("fala_custom_voice") val FALA_CUSTOM_FORMAT = stringPreferencesKey("fala_custom_format") val COMPUTER_MODE = stringPreferencesKey("computer_mode") + val COMPUTER_HOSTS_JSON = stringPreferencesKey("computer_hosts_json") + val COMPUTER_KEYS_JSON = stringPreferencesKey("computer_keys_json") + val COMPUTER_KEEP_AWAKE = booleanPreferencesKey("computer_keep_awake") + val COMPUTER_MIGRATED = booleanPreferencesKey("computer_migrated") val COMPUTER_SSH_HOST = stringPreferencesKey("computer_ssh_host") val COMPUTER_SSH_PORT = intPreferencesKey("computer_ssh_port") val COMPUTER_SSH_USER = stringPreferencesKey("computer_ssh_user") diff --git a/app/src/main/java/com/keyflow/i18n/TranslationsEn.kt b/app/src/main/java/com/keyflow/i18n/TranslationsEn.kt index a62a1674..b0e3b9dc 100644 --- a/app/src/main/java/com/keyflow/i18n/TranslationsEn.kt +++ b/app/src/main/java/com/keyflow/i18n/TranslationsEn.kt @@ -2,6 +2,7 @@ package com.keyflow.i18n import com.keyflow.i18n.en.Bancada import com.keyflow.i18n.en.Comum +import com.keyflow.i18n.en.Computador import com.keyflow.i18n.en.Conta import com.keyflow.i18n.en.Docs import com.keyflow.i18n.en.Fluxos @@ -26,6 +27,7 @@ import com.keyflow.i18n.en.Voz internal val TranslationsEn: Map = translationMap( Bancada, Comum, + Computador, Conta, Docs, Fluxos, diff --git a/app/src/main/java/com/keyflow/i18n/TranslationsEs.kt b/app/src/main/java/com/keyflow/i18n/TranslationsEs.kt index 49fab6e0..15157bd1 100644 --- a/app/src/main/java/com/keyflow/i18n/TranslationsEs.kt +++ b/app/src/main/java/com/keyflow/i18n/TranslationsEs.kt @@ -2,6 +2,7 @@ package com.keyflow.i18n import com.keyflow.i18n.es.Bancada import com.keyflow.i18n.es.Comum +import com.keyflow.i18n.es.Computador import com.keyflow.i18n.es.Conta import com.keyflow.i18n.es.Docs import com.keyflow.i18n.es.Fluxos @@ -26,6 +27,7 @@ import com.keyflow.i18n.es.Voz internal val TranslationsEs: Map = translationMap( Bancada, Comum, + Computador, Conta, Docs, Fluxos, diff --git a/app/src/main/java/com/keyflow/i18n/en/Computador.kt b/app/src/main/java/com/keyflow/i18n/en/Computador.kt new file mode 100644 index 00000000..419b5864 --- /dev/null +++ b/app/src/main/java/com/keyflow/i18n/en/Computador.kt @@ -0,0 +1,11 @@ +package com.keyflow.i18n.en + +/** Aba Computador: sessões, notificação e terminal. Chaves em ordem crescente do português. */ +internal val Computador: Map = mapOf( + "%d sessão SSH aberta" to "%d SSH session open", + "%d sessões SSH abertas" to "%d SSH sessions open", + "Desconectar todas" to "Disconnect all", + "Parar o agente" to "Stop the agent", + "Sessões SSH mantidas conectadas em segundo plano" to "SSH sessions kept connected in the background", + "Sessões do Computador" to "Computer sessions", +) diff --git a/app/src/main/java/com/keyflow/i18n/es/Computador.kt b/app/src/main/java/com/keyflow/i18n/es/Computador.kt new file mode 100644 index 00000000..9fa916f9 --- /dev/null +++ b/app/src/main/java/com/keyflow/i18n/es/Computador.kt @@ -0,0 +1,11 @@ +package com.keyflow.i18n.es + +/** Aba Computador: sessões, notificação e terminal. Chaves em ordem crescente do português. */ +internal val Computador: Map = mapOf( + "%d sessão SSH aberta" to "%d sesión SSH abierta", + "%d sessões SSH abertas" to "%d sesiones SSH abiertas", + "Desconectar todas" to "Desconectar todas", + "Parar o agente" to "Detener al agente", + "Sessões SSH mantidas conectadas em segundo plano" to "Sesiones SSH mantenidas conectadas en segundo plano", + "Sessões do Computador" to "Sesiones del Computador", +) diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 0ccee72e..746b4f34 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -23,4 +23,10 @@ + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index f93b36ec..0d30809f 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -16,6 +16,8 @@ + + @@ -25,5 +27,6 @@ + diff --git a/app/src/test/java/com/keyflow/ArchitectureRulesTest.kt b/app/src/test/java/com/keyflow/ArchitectureRulesTest.kt index 7391df23..e49ec6a3 100644 --- a/app/src/test/java/com/keyflow/ArchitectureRulesTest.kt +++ b/app/src/test/java/com/keyflow/ArchitectureRulesTest.kt @@ -284,6 +284,52 @@ class ArchitectureRulesTest { assertEquals(emptyList(), orbeViolations("com/keyflow/orbe/X.kt", listOf("import com.keyflow.data.Settings"))) } + /** + * `com.keyflow.computador` (modelo, codec, validação, confiança, chaves, conexão, sessão), exceto o subpacote `computador/app`, é JVM puro + * (CMP-62): sem `android.*`, `androidx.*` (Compose, DataStore…) nem `org.connectbot.terminal` (o termlib é da tela). O cbssh + * (`org.connectbot.sshlib`) só entra pelos arquivos da lista: o adaptador de conexão e, em `chaves/`, quem lê/assina chave com a + * biblioteca (SPEC_DEVIATION: o design previa só o `ConexaoCbssh`, mas o importador e o assinador de software usam o `SshKeys`). + */ + private val computadorForbidden = listOf( + Regex("""^\s*import\s+android\."""), + Regex("""^\s*import\s+androidx\."""), + Regex("""^\s*import\s+org\.connectbot\.terminal\b"""), + ) + private val sshlibImport = Regex("""^\s*import\s+org\.connectbot\.sshlib\b""") + private val computadorComSshlib = setOf( + "com/keyflow/computador/ssh/ConexaoCbssh.kt", + "com/keyflow/computador/chaves/ImportadorDeChave.kt", + "com/keyflow/computador/chaves/AssinadorSoftware.kt", + ) + + private fun computadorViolations(relPath: String, code: List): List { + val norm = relPath.replace('\\', '/') + if (!norm.startsWith("com/keyflow/computador/") || norm.startsWith("com/keyflow/computador/app/")) return emptyList() + val sshlibOk = norm in computadorComSshlib + return code.mapIndexedNotNull { i, line -> + if (computadorForbidden.any { it.containsMatchIn(line) } || (!sshlibOk && sshlibImport.containsMatchIn(line))) i + 1 else null + } + } + + @Test + fun computadorPackageIsPureJvm() { + val offenders = kotlinFiles(main).flatMap { file -> + computadorViolations(file.rel(main), codeLines(file)).map { "${file.rel(main)}:$it" } + } + assertEquals("com.keyflow.computador (fora de computador/app) é JVM puro: sem android.*, androidx.*, termlib nem cbssh fora da lista", emptyList(), offenders) + } + + @Test + fun computadorRuleCatchesAndroidTermlibAndStraySshlib() { + assertEquals(listOf(2), computadorViolations("com/keyflow/computador/Modelo.kt", listOf("package x", "import android.os.Bundle"))) + assertEquals(listOf(1), computadorViolations("com/keyflow/computador/sessao/X.kt", listOf("import androidx.compose.runtime.Composable"))) + assertEquals(listOf(1), computadorViolations("com/keyflow/computador/X.kt", listOf("import org.connectbot.terminal.Terminal"))) + assertEquals(listOf(1), computadorViolations("com/keyflow/computador/CodecHosts.kt", listOf("import org.connectbot.sshlib.SshKeys"))) + assertEquals(emptyList(), computadorViolations("com/keyflow/computador/chaves/ImportadorDeChave.kt", listOf("import org.connectbot.sshlib.SshKeys"))) + assertEquals(emptyList(), computadorViolations("com/keyflow/computador/app/EnvelopeDeChave.kt", listOf("import android.os.Build", "import org.connectbot.sshlib.SshKeys"))) + assertEquals(emptyList(), computadorViolations("com/keyflow/ime/X.kt", listOf("import android.os.Build"))) + } + /** O texto de uma string Kotlin entre aspas como o programa o vê (`\\` vira `\`, `\"` vira `"`). */ private fun unescapeKotlin(literal: String): String = buildString { var i = 0 diff --git a/app/src/test/java/com/keyflow/agenthost/AgentSecretSourceTest.kt b/app/src/test/java/com/keyflow/agenthost/AgentSecretSourceTest.kt index e6486ca5..ad8e5e3f 100644 --- a/app/src/test/java/com/keyflow/agenthost/AgentSecretSourceTest.kt +++ b/app/src/test/java/com/keyflow/agenthost/AgentSecretSourceTest.kt @@ -50,6 +50,22 @@ class AgentSecretSourceTest { ) } + @Test + fun `the secrets of the Computador tab reach the filter and are redacted`() { + val cofre = com.keyflow.computador.chaves.CofreFalso().also { + it.gravar("ssh_pw.a1b2c3d4", "senha-ssh-sentinela-7731") + it.gravar("ssh_key.k0000001", "semente-sentinela-4402") + } + val source = AgentSecretSource({ vaultWith() }, FakeCredentialStore(), computador = cofre::valores) + assertThat(source()).containsAtLeast("senha-ssh-sentinela-7731", "semente-sentinela-4402") + val filtered = SecretFilter(source).redact("o servidor respondeu senha-ssh-sentinela-7731 e semente-sentinela-4402") + assertThat(filtered).doesNotContain("sentinela") + // Apagar do cofre tira o valor do filtro na leitura seguinte (a fonte lê a cada uso). + cofre.apagar("ssh_pw.a1b2c3d4") + source.invalidate() + assertThat(source()).doesNotContain("senha-ssh-sentinela-7731") + } + @Test fun `identifiers that are not secrets and the empty skeleton of a login are not collected`() { val store = FakeCredentialStore() diff --git a/app/src/test/java/com/keyflow/computador/Base64PuroTest.kt b/app/src/test/java/com/keyflow/computador/Base64PuroTest.kt new file mode 100644 index 00000000..19faed68 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/Base64PuroTest.kt @@ -0,0 +1,25 @@ +package com.keyflow.computador + +import java.util.Base64 +import java.util.Random +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class Base64PuroTest { + @Test fun igualAoDaJdkParaTodosOsTamanhos() { + val r = Random(7) + for (n in 0..200) { + val b = ByteArray(n).also { r.nextBytes(it) } + assertEquals(Base64.getEncoder().encodeToString(b), Base64Puro.codificar(b)) + assertEquals(Base64.getEncoder().withoutPadding().encodeToString(b), Base64Puro.codificar(b, comPadding = false)) + assertArrayEquals(b, Base64Puro.decodificar(Base64.getEncoder().encodeToString(b))) + assertArrayEquals(b, Base64Puro.decodificar(Base64.getEncoder().withoutPadding().encodeToString(b))) + } + } + + @Test fun textoInvalidoViraNulo() { + for (t in listOf("@@@@", "A", "AAAAA", "AA=A", "AAA===", "é")) assertNull(t, Base64Puro.decodificar(t)) + } +} diff --git a/app/src/test/java/com/keyflow/computador/CodecHostsFuzzTest.kt b/app/src/test/java/com/keyflow/computador/CodecHostsFuzzTest.kt new file mode 100644 index 00000000..8246b20f --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/CodecHostsFuzzTest.kt @@ -0,0 +1,52 @@ +package com.keyflow.computador + +import java.util.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** ~10 mil entradas mutadas a partir de um JSON válido: o decodificador nunca lança e o que sai respeita as regras. */ +class CodecHostsFuzzTest { + private val base = CodecHosts.codificar( + ConfigHosts( + listOf( + Host("a1b2c3d4", "dev", "dev.example.org", 22, "yan", AutenticacaoHost.Chave("k1")), + Host("b1b2c3d4", "outro", "[::1]", 2222, "root", AutenticacaoHost.Senha, lembrarSenha = true, agente = PoliticaAgente.PERMITIDO), + ), + ), + ) + + private fun mutar(r: Random): String { + val sb = StringBuilder(base) + repeat(1 + r.nextInt(6)) { + if (sb.isEmpty()) return@repeat + val i = r.nextInt(sb.length) + when (r.nextInt(6)) { + 0 -> sb.deleteCharAt(i) + 1 -> sb.insert(i, "{}[]\":,\\u0000nullтrue-1e999".random(r)) + 2 -> sb.setCharAt(i, (r.nextInt(0x2FFF)).toChar()) + 3 -> sb.setLength(i) + 4 -> sb.insert(i, base.substring(r.nextInt(base.length))) + else -> sb.replace(i, minOf(sb.length, i + 1 + r.nextInt(8)), listOf("null", "9999999999999999999", "\"\"", "[]", "{}", "1.5", "true").random(r)) + } + } + return sb.toString() + } + + private fun String.random(r: Random) = this[r.nextInt(length)].toString() + private fun List.random(r: Random) = this[r.nextInt(size)] + + @Test fun nuncaLancaEOResultadoRespeitaAsRegras() { + val r = Random(20261030) + repeat(10_000) { + val entrada = mutar(r) + val config = CodecHosts.decodificar(entrada) + assertTrue(config.hosts.size <= LimitesHost.MAX_HOSTS) + assertEquals(config.hosts.size, config.hosts.map { it.id }.distinct().size) + for (h in config.hosts) { + assertTrue("host inválido sobreviveu: $h de $entrada", ValidadorHost.validar(h).none { it.campo != CampoHost.LIMITE }) + if (h.agentePodeUsar) assertTrue(RegraAgente.podeMarcar(h) is PodeMarcar.Sim) + } + } + } +} diff --git a/app/src/test/java/com/keyflow/computador/CodecHostsPropertyTest.kt b/app/src/test/java/com/keyflow/computador/CodecHostsPropertyTest.kt new file mode 100644 index 00000000..ca9e2fc0 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/CodecHostsPropertyTest.kt @@ -0,0 +1,39 @@ +package com.keyflow.computador + +import io.kotest.property.Arb +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.element +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.string +import io.kotest.property.checkAll +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +/** `decodificar(codificar(c)) == c` para QUALQUER configuração que a validação aceita (nomes com aspas, acentos, emoji…). */ +class CodecHostsPropertyTest { + private val nomes = Arb.string(1..40).map { it.trim().ifEmpty { "x" }.take(40) } + private val enderecos = Arb.element("dev.example.org", "localhost", "10.1.2.3", "[::1]", "[2001:db8::1]", "a-b.c.d") + private val usuarios = Arb.string(1..64).map { u -> u.filter { !it.isWhitespace() && !it.isISOControl() }.ifEmpty { "u" } } + private val auths = Arb.element(AutenticacaoHost.Senha, AutenticacaoHost.Perguntar, AutenticacaoHost.Chave("k0000001"), AutenticacaoHost.Chave("k-9_x")) + + private val ids = Arb.int().map { "%08x".format(it) } + private val hostsArb = Arb.bind( + ids, nomes, enderecos, Arb.int(1..65535), usuarios, auths, Arb.boolean(), Arb.boolean(), Arb.element(PoliticaImagens.entries), + ) { id, nome, endereco, porta, usuario, auth, lembrar, agente, imagens -> + val h = Host(id, nome, endereco, porta, usuario, auth, lembrar, if (agente) PoliticaAgente.PERMITIDO else PoliticaAgente.PROIBIDO, imagens) + // O codec aplica a regra do agente; o gerador só produz o que o app produziria. + if (h.agentePodeUsar && RegraAgente.podeMarcar(h) !is PodeMarcar.Sim) h.copy(agente = PoliticaAgente.PROIBIDO) else h + } + + private val configs = Arb.list(hostsArb, 0..12).map { lista -> + ConfigHosts(lista.distinctBy { it.id }.fold(emptyList()) { acc, h -> if (acc.any { it.nome.equals(h.nome, true) }) acc else acc + h }) + } + + @Test fun idaEVolta() = runBlocking { + checkAll(500, configs) { c -> assertEquals(c, CodecHosts.decodificar(CodecHosts.codificar(c))) } + } +} diff --git a/app/src/test/java/com/keyflow/computador/CodecHostsTest.kt b/app/src/test/java/com/keyflow/computador/CodecHostsTest.kt new file mode 100644 index 00000000..455327c3 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/CodecHostsTest.kt @@ -0,0 +1,84 @@ +package com.keyflow.computador + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CodecHostsTest { + private val completo = Host( + id = "a1b2c3d4", nome = "Servidor \"principal\" é çã", endereco = "dev.example.org", porta = 2222, usuario = "yan", + autenticacao = AutenticacaoHost.Chave("k9f3a210"), lembrarSenha = true, agente = PoliticaAgente.PERMITIDO, imagens = PoliticaImagens.LIGADO, + ) + + @Test fun idaEVoltaComAcentosAspasETodosOsCampos() { + val outros = listOf( + completo, + Host("b1b2c3d4", "senha", "[::1]", 22, "root", AutenticacaoHost.Senha, lembrarSenha = true, imagens = PoliticaImagens.DESLIGADO), + Host("c1b2c3d4", "pergunta", "10.0.0.2", 22, "u", AutenticacaoHost.Perguntar), + ) + val config = ConfigHosts(outros) + assertEquals(config, CodecHosts.decodificar(CodecHosts.codificar(config))) + } + + @Test fun formatoTemVersaoUm() = assertEquals(1, JSONObject(CodecHosts.codificar(ConfigHosts())).getInt("v")) + + @Test fun entradaNulaVaziaOuLixoViraListaVazia() { + for (s in listOf(null, "", " ", "{", "[]", "null", "{\"hosts\":3}", "\"x\"", "{\"v\":1}")) assertEquals(ConfigHosts(), CodecHosts.decodificar(s)) + } + + @Test fun itemRuimEDescartadoEOsOutrosFicam() { + val bom = JSONObject(CodecHosts.codificar(ConfigHosts(listOf(completo)))).getJSONArray("hosts").getJSONObject(0) + val json = """{"v":1,"hosts":[ 42, "x", {}, {"id":"zz","name":"a"}, ${bom}, {"id":"a1b2c3d5","name":"p","address":"x.y","port":0,"user":"u"} ]}""" + assertEquals(listOf(completo), CodecHosts.decodificar(json).hosts) + } + + @Test fun campoDesconhecidoEIgnorado() { + val bom = JSONObject(CodecHosts.codificar(ConfigHosts(listOf(completo)))).getJSONArray("hosts").getJSONObject(0).put("futuro", mapOf("a" to 1)) + val raiz = JSONObject().put("v", 7).put("novo", true).put("hosts", org.json.JSONArray().put(bom)) + assertEquals(listOf(completo), CodecHosts.decodificar(raiz.toString()).hosts) + } + + @Test fun idRepetidoViraUmENomeRepetidoTambem() { + val a = JSONObject(CodecHosts.codificar(ConfigHosts(listOf(completo)))).getJSONArray("hosts").getJSONObject(0) + val mesmoId = JSONObject(a.toString()).put("name", "outro") + val mesmoNome = JSONObject(a.toString()).put("id", "99999999") + val config = CodecHosts.decodificar("""{"v":1,"hosts":[$a,$mesmoId,$mesmoNome]}""") + assertEquals(listOf(completo), config.hosts) + } + + @Test fun cortaEmCinquentaHosts() { + val muitos = (0 until 60).map { Host("%08x".format(it), "h$it", "x.org", 22, "u") } + val bruto = org.json.JSONArray().also { arr -> for (h in muitos) arr.put(JSONObject(CodecHosts.codificar(ConfigHosts(listOf(h)))).getJSONArray("hosts").getJSONObject(0)) } + assertEquals(50, CodecHosts.decodificar(JSONObject().put("v", 1).put("hosts", bruto).toString()).hosts.size) + } + + @Test fun autenticacaoDesconhecidaViraPerguntar() { + val o = JSONObject(CodecHosts.codificar(ConfigHosts(listOf(completo)))).getJSONArray("hosts").getJSONObject(0) + o.put("auth", JSONObject().put("type", "kerberos")) + assertEquals(AutenticacaoHost.Perguntar, CodecHosts.decodificar("""{"hosts":[$o]}""").hosts.single().autenticacao) + } + + @Test fun arquivoEditadoNaoLigaOAgenteEmHostQueOAppNaoResolve() { + val o = JSONObject(CodecHosts.codificar(ConfigHosts(listOf(completo)))).getJSONArray("hosts").getJSONObject(0) + o.put("auth", JSONObject().put("type", "ask")).put("agent", true) + assertFalse(CodecHosts.decodificar("""{"hosts":[$o]}""").hosts.single().agentePodeUsar) + o.put("auth", JSONObject().put("type", "password")).put("rememberPassword", false).put("agent", true) + assertFalse(CodecHosts.decodificar("""{"hosts":[$o]}""").hosts.single().agentePodeUsar) + o.put("rememberPassword", true) + assertTrue(CodecHosts.decodificar("""{"hosts":[$o]}""").hosts.single().agentePodeUsar) + } + + @Test fun padroesSaoSeguros() { + val h = Host("a1b2c3d4", "n", "x.org", usuario = "u") + assertEquals(PoliticaAgente.PROIBIDO, h.agente) + assertEquals(PoliticaImagens.PERGUNTAR, h.imagens) + assertFalse(h.lembrarSenha) + assertEquals(22, h.porta) + } + + @Test fun jsonMuitoAninhadoNaoLanca() { + assertEquals(ConfigHosts(), CodecHosts.decodificar("[".repeat(100_000))) + } +} diff --git a/app/src/test/java/com/keyflow/computador/HostSemSegredoTest.kt b/app/src/test/java/com/keyflow/computador/HostSemSegredoTest.kt new file mode 100644 index 00000000..37dee2ce --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/HostSemSegredoTest.kt @@ -0,0 +1,45 @@ +package com.keyflow.computador + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** CMP-05/CMP-16: o JSON de um host não tem campo de segredo nem o valor de um segredo plantado. */ +class HostSemSegredoTest { + private val proibidas = setOf("password", "key", "passphrase", "privatekey", "secret", "senha", "chave", "pem", "seed") + + private fun chaves(v: Any?, acc: MutableSet = mutableSetOf()): Set { + when (v) { + is JSONObject -> v.keys().forEach { k -> acc += k.lowercase(); chaves(v.get(k), acc) } + is JSONArray -> for (i in 0 until v.length()) chaves(v.get(i), acc) + } + return acc + } + + @Test fun jsonDoHostNaoTemCampoDeSegredo() { + val host = Host("a1b2c3d4", "n", "x.org", 22, "u", AutenticacaoHost.Chave("k0000001"), lembrarSenha = true, agente = PoliticaAgente.PERMITIDO) + val achadas = chaves(JSONObject(CodecHosts.codificar(ConfigHosts(listOf(host))))).intersect(proibidas) + assertTrue("campo de segredo no JSON do host: $achadas", achadas.isEmpty()) + } + + @Test fun valorDeSegredoPlantadoNaoApareceNoJsonNemNoToString() { + val senha = "SENTINELA-senha-9f8e7d6c" + val pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nSENTINELA-chave-1a2b3c\n-----END OPENSSH PRIVATE KEY-----" + for (tipo in listOf("password", "key")) { + val r = MigracaoHostUnico.migrar(HostUnicoLegado("srv.example.org", 22, "yan", tipo, senha, pem))!! + val json = CodecHosts.codificar(r.config) + for (s in listOf("SENTINELA", "BEGIN OPENSSH")) { + assertFalse("$s no JSON ($tipo)", json.contains(s)) + assertFalse("$s no toString do resultado ($tipo)", r.toString().contains(s) || r.segredos.toString().contains(s)) + assertFalse("$s no toString do host ($tipo)", r.config.hosts.single().toString().contains(s)) + } + } + } + + @Test fun hostNaoTemPropriedadeDeSegredo() { + val nomes = Host::class.java.declaredFields.map { it.name.lowercase() } + assertTrue("$nomes", nomes.none { it in proibidas || it.contains("passphrase") }) + } +} diff --git a/app/src/test/java/com/keyflow/computador/ImpressaoDigitalTest.kt b/app/src/test/java/com/keyflow/computador/ImpressaoDigitalTest.kt new file mode 100644 index 00000000..5c5b402f --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/ImpressaoDigitalTest.kt @@ -0,0 +1,35 @@ +package com.keyflow.computador + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Vetores gerados com o `ssh-keygen -lf`/`-lvf` do OpenSSH 9.2 sobre chaves falsas do teste. */ +class ImpressaoDigitalTest { + @Test fun ed25519BateComOSshKeygen() = + assertEquals("SHA256:MIkL9HeXdvsn5K5gk/LLEBqe1g+glboOZOU9o8wkcqg", ImpressaoDigital.de(vetorEd25519Blob()).texto) + + @Test fun artePadraoBateComOSshKeygen() { + val esperado = """ + +--[ED25519 256]--+ + | . | + | . . . . . | + | . + = . + . | + | . + + = o . . | + |o = + O S . . | + |.= = * O . . + | + |E . * = = = + .| + | . o O o . o | + | .o =.... | + +----[SHA256]-----+ + """.trimIndent() + assertEquals(esperado, ImpressaoDigital.de(vetorEd25519Blob()).arte("ED25519 256")) + } + + @Test fun textoSemPaddingBase64() { + // 32 bytes em base64 teriam um "=" no fim: a impressão do OpenSSH não tem. + assert(!ImpressaoDigital.de(byteArrayOf(1, 2, 3)).texto.endsWith("=")) + } + + private fun vetorEd25519Blob(): ByteArray = + java.util.Base64.getDecoder().decode("AAAAC3NzaC1lZDI1NTE5AAAAIFAFEx8CsWZqAMLFNvILkPP0zn36avMMMlEzsf3NYi4T") +} diff --git a/app/src/test/java/com/keyflow/computador/KnownHostsArquivoTest.kt b/app/src/test/java/com/keyflow/computador/KnownHostsArquivoTest.kt new file mode 100644 index 00000000..f460a339 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/KnownHostsArquivoTest.kt @@ -0,0 +1,80 @@ +package com.keyflow.computador + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class KnownHostsArquivoTest { + private val ed = TesteSsh.servidor("ed25519") + private val ec = TesteSsh.servidor("ecdsa-p256") + private val rsa = TesteSsh.servidor("rsa3072") + private val b64ed = TesteSsh.chave("ed25519.pub").trim().split(" ")[1] + + @Test fun escreveNoFormatoDoOpenSshPorta22SemColchetes() { + val a = KnownHostsArquivo.ler(null).comChave("srv.example.org", 22, ed) + assertEquals("srv.example.org ssh-ed25519 $b64ed\n", a.serializar()) + } + + @Test fun escreveColchetesEmPortaDiferente() { + val a = KnownHostsArquivo.ler(null).comChave("srv.example.org", 2222, ed) + assertEquals("[srv.example.org]:2222 ssh-ed25519 $b64ed\n", a.serializar()) + } + + @Test fun leLinhasDoOpenSsh() { + val a = KnownHostsArquivo.ler("[srv.example.org]:2222 ssh-ed25519 $b64ed comentario\nsrv.example.org ssh-ed25519 $b64ed\n") + assertEquals(listOf(ed), a.chavesConhecidas("srv.example.org", 2222)) + assertEquals(listOf(ed), a.chavesConhecidas("SRV.example.org", 22)) + assertEquals(emptyList(), a.chavesConhecidas("srv.example.org", 2200)) + } + + @Test fun ipv6EmColchetesEPortaDiferente() { + val a = KnownHostsArquivo.ler(null).comChave("[2001:db8::1]", 2222, ed).comChave("[2001:db8::1]", 22, ec) + assertEquals("[2001:db8::1]:2222 ssh-ed25519 $b64ed\n2001:db8::1 ecdsa-sha2-nistp256 ${TesteSsh.chave("ecdsa-p256.pub").trim().split(" ")[1]}\n", a.serializar()) + assertEquals(listOf(ed), KnownHostsArquivo.ler(a.serializar()).chavesConhecidas("2001:db8::1", 2222)) + } + + @Test fun linhaMalformadaPreservaAsOutrasEFicaComoEstava() { + val texto = "# comentario\n\nlixo sem chave\nsrv ssh-ed25519 !!!nao-base64\n@revoked srv ssh-ed25519 $b64ed\n|1|abc|def ssh-ed25519 $b64ed\nsrv.example.org ssh-ed25519 $b64ed\n*.example.org ssh-ed25519 $b64ed\n" + val a = KnownHostsArquivo.ler(texto) + assertEquals(listOf(ed), a.chavesConhecidas("srv.example.org", 22)) + assertEquals(emptyList(), a.chavesConhecidas("srv", 22)) + assertEquals(texto, a.serializar()) + } + + @Test fun removerPorHostNaoTocaOsDemais() { + val a = KnownHostsArquivo.ler(null).comChave("a.org", 22, ed).comChave("b.org", 22, ed).comChave("a.org", 2222, ec).comChave("a.org", 22, rsa) + val sem = a.semHost("a.org", 22) + assertEquals(emptyList(), sem.chavesConhecidas("a.org", 22)) + assertEquals(listOf(ed), sem.chavesConhecidas("b.org", 22)) + assertEquals(listOf(ec), sem.chavesConhecidas("a.org", 2222)) + } + + @Test fun removerDeLinhaComListaDeHostsPreservaOOutro() { + val a = KnownHostsArquivo.ler("a.org,b.org ssh-ed25519 $b64ed\n").semHost("a.org", 22) + assertEquals("b.org ssh-ed25519 $b64ed\n", a.serializar()) + } + + @Test fun mesmoTipoSubstituiETiposDiferentesConvivem() { + val outraEd = ChaveDoServidor("ssh-ed25519", ByteArray(32) { 7 }) + val a = KnownHostsArquivo.ler(null).comChave("a.org", 22, ed).comChave("a.org", 22, ec).comChave("a.org", 22, outraEd) + assertEquals(setOf(ec, outraEd), a.chavesConhecidas("a.org", 22).toSet()) + } + + @Test fun terminacaoDeLinhaWindowsELida() { + assertEquals(listOf(ed), KnownHostsArquivo.ler("srv ssh-ed25519 $b64ed\r\n").chavesConhecidas("srv", 22)) + } + + @Test fun arquivoVazioOuNuloNaoLanca() { + assertTrue(KnownHostsArquivo.ler("").chavesConhecidas("x", 22).isEmpty()) + assertEquals("", KnownHostsArquivo.ler(null).serializar()) + } + + @Test fun repositorioEmMemoriaGravaELeDeVolta() { + val r = RepositorioKnownHostsEmMemoria() + r.confiar("srv", 22, ed) + val recarregado = RepositorioKnownHostsEmMemoria(r.serializar()) + assertEquals(listOf(ed), recarregado.chavesConhecidas("srv", 22)) + recarregado.esquecer("srv", 22) + assertEquals("", recarregado.serializar()) + } +} diff --git a/app/src/test/java/com/keyflow/computador/KnownHostsPropertyTest.kt b/app/src/test/java/com/keyflow/computador/KnownHostsPropertyTest.kt new file mode 100644 index 00000000..6fadf7ba --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/KnownHostsPropertyTest.kt @@ -0,0 +1,44 @@ +package com.keyflow.computador + +import io.kotest.property.Arb +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.byte +import io.kotest.property.arbitrary.byteArray +import io.kotest.property.arbitrary.element +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.checkAll +import kotlinx.coroutines.runBlocking +import java.util.Random +import org.junit.Assert.assertEquals +import org.junit.Test + +class KnownHostsPropertyTest { + private val hosts = Arb.element("a.org", "b.example.com", "10.0.0.1", "[2001:db8::1]", "Servidor-X.local") + private val portas = Arb.element(22, 2222, 22022, 65535) + private val algs = Arb.element("ssh-ed25519", "ecdsa-sha2-nistp256", "ssh-rsa") + private val entrada = Arb.bind(hosts, portas, algs, Arb.byteArray(Arb.int(4..64), Arb.byte())) { h, p, a, bytes -> + (h to p) to ChaveDoServidor(a, bytes) + } + + @Test fun escreverELerPreservaAsChaves() = runBlocking { + checkAll(300, Arb.list(entrada, 0..15)) { itens -> + var arq = KnownHostsArquivo.ler(null) + for ((hp, chave) in itens) arq = arq.comChave(hp.first, hp.second, chave) + val relido = KnownHostsArquivo.ler(arq.serializar()) + for ((hp, _) in itens) assertEquals(arq.chavesConhecidas(hp.first, hp.second).toSet(), relido.chavesConhecidas(hp.first, hp.second).toSet()) + assertEquals(arq.serializar(), relido.serializar()) + } + } + + @Test fun lixoNuncaLancaEPreservaOTexto() { + val r = Random(1030) + val pedacos = listOf("a.org ssh-ed25519 AAAA\n", "[x]:99 ssh-rsa Zm9v\n", "\n", "# c\n", "@x y z\n", "|1|a ssh-rsa Zm9v\n", "\u0000\n", "a,b,c ssh-ed25519 Zm9v extra palavras\n") + repeat(2000) { + val t = buildString { repeat(r.nextInt(8)) { append(pedacos[r.nextInt(pedacos.size)]) }; if (r.nextBoolean()) append("sem fim") } + val a = KnownHostsArquivo.ler(t) + a.chavesConhecidas("a.org", 22) + assertEquals(if (t.isNotEmpty() && !t.endsWith("\n")) t + "\n" else t, a.serializar()) + } + } +} diff --git a/app/src/test/java/com/keyflow/computador/MigracaoHostUnicoTest.kt b/app/src/test/java/com/keyflow/computador/MigracaoHostUnicoTest.kt new file mode 100644 index 00000000..79333645 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/MigracaoHostUnicoTest.kt @@ -0,0 +1,70 @@ +package com.keyflow.computador + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MigracaoHostUnicoTest { + private fun legado(host: String = "srv.example.org", porta: Int = 2200, user: String = "yan", tipo: String = "password", senha: String = "", pem: String = "") = + HostUnicoLegado(host, porta, user, tipo, senha, pem) + + @Test fun hostVazioNaoMigraNada() { + assertNull(MigracaoHostUnico.migrar(legado(host = "", senha = "x", pem = "y"))) + assertNull(MigracaoHostUnico.migrar(legado(host = " "))) + } + + @Test fun senhaViraHostServidorSshESegredoAParte() { + val r = MigracaoHostUnico.migrar(legado(senha = "s3nh4-falsa"))!! + val h = r.config.hosts.single() + assertEquals("Servidor SSH", h.nome) + assertEquals("srv.example.org", h.endereco) + assertEquals(2200, h.porta) + assertEquals("yan", h.usuario) + assertEquals(AutenticacaoHost.Senha, h.autenticacao) + assertTrue(h.lembrarSenha) + assertEquals("s3nh4-falsa", r.segredos.senha) + assertNull(r.segredos.chavePem) + assertEquals(PoliticaAgente.PROIBIDO, h.agente) + } + + @Test fun senhaNaoSalvaNaoLembra() { + val r = MigracaoHostUnico.migrar(legado(senha = ""))!! + assertEquals(AutenticacaoHost.Senha, r.config.hosts.single().autenticacao) + assertEquals(false, r.config.hosts.single().lembrarSenha) + assertNull(r.segredos.senha) + } + + @Test fun chaveViraReferenciaESegredoAParte() { + val r = MigracaoHostUnico.migrar(legado(tipo = "key", senha = "ignorada", pem = "PEM-FALSO"))!! + assertEquals(AutenticacaoHost.Chave(MigracaoHostUnico.ID_CHAVE), r.config.hosts.single().autenticacao) + assertEquals(MigracaoHostUnico.ID_CHAVE, r.chaveId) + assertEquals("PEM-FALSO", r.segredos.chavePem) + assertNull(r.segredos.senha) + } + + @Test fun tipoChaveSemChaveViraPerguntar() { + assertEquals(AutenticacaoHost.Perguntar, MigracaoHostUnico.migrar(legado(tipo = "key"))!!.config.hosts.single().autenticacao) + } + + @Test fun idempotente() { + val primeira = MigracaoHostUnico.migrar(legado(senha = "x"))!! + assertNull(MigracaoHostUnico.migrar(legado(senha = "x"), existente = primeira.config)) + // Dados iguais dão o mesmo host (id determinístico). + assertEquals(primeira.config, MigracaoHostUnico.migrar(legado(senha = "x"))!!.config) + } + + @Test fun portaForaDaFaixaVira22EIpv6GanhaColchetes() { + val h = MigracaoHostUnico.migrar(legado(host = "::1", porta = 0))!!.config.hosts.single() + assertEquals("[::1]", h.endereco) + assertEquals(22, h.porta) + } + + @Test fun legadoInvalidoNaoMigra() = assertNull(MigracaoHostUnico.migrar(legado(host = "com espaço"))) + + @Test fun resultadoNaoVazaNoToString() { + val r = MigracaoHostUnico.migrar(legado(tipo = "key", pem = "PEM-FALSO-XYZ"))!! + assertTrue(!r.toString().contains("PEM-FALSO") && !r.segredos.toString().contains("PEM-FALSO")) + assertTrue(!legado(senha = "SENHA-XYZ").toString().contains("SENHA-XYZ")) + } +} diff --git a/app/src/test/java/com/keyflow/computador/PoliticaDeConfiancaTest.kt b/app/src/test/java/com/keyflow/computador/PoliticaDeConfiancaTest.kt new file mode 100644 index 00000000..d0b8a340 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/PoliticaDeConfiancaTest.kt @@ -0,0 +1,66 @@ +package com.keyflow.computador + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PoliticaDeConfiancaTest { + private val ed = TesteSsh.servidor("ed25519") + private val ec = TesteSsh.servidor("ecdsa-p256") + private val rsa = TesteSsh.servidor("rsa3072") + + private fun repo() = RepositorioKnownHostsEmMemoria() + + @Test fun chaveNovaTrazAImpressaoDoSshKeygen() { + val v = PoliticaDeConfianca.avaliar("srv.example.org", 22, ec, repo()) + assertTrue(v is VereditoDeConfianca.Nova) + assertEquals("SHA256:poVxiS+g4bpceFXLf/2CqFASfV+yVyyQYeNpqZ5ljr0", (v as VereditoDeConfianca.Nova).impressao.texto) + assertEquals(emptyList(), v.outrosTipos) + } + + @Test fun mesmaChaveEConhecida() { + val r = repo().also { it.confiar("srv.example.org", 22, ed) } + assertEquals(VereditoDeConfianca.Conhecida, PoliticaDeConfianca.avaliar("srv.example.org", 22, ed, r)) + } + + @Test fun chaveDiferenteDoMesmoTipoNoMesmoHostPortaEMudou() { + val outraEd = ChaveDoServidor("ssh-ed25519", TesteSsh.servidor("ed25519").blob.copyOf().also { it[it.size - 1] = (it.last() + 1).toByte() }) + val r = repo().also { it.confiar("srv.example.org", 22, ed) } + val v = PoliticaDeConfianca.avaliar("srv.example.org", 22, outraEd, r) + assertEquals(VereditoDeConfianca.Mudou(ed.impressao, outraEd.impressao), v) + } + + @Test fun tiposDiferentesConvivem() { + val r = repo().also { it.confiar("srv.example.org", 22, ed) } + val v = PoliticaDeConfianca.avaliar("srv.example.org", 22, rsa, r) + assertEquals(VereditoDeConfianca.Nova(rsa.impressao, listOf("ssh-ed25519")), v) + r.confiar("srv.example.org", 22, rsa) + assertEquals(VereditoDeConfianca.Conhecida, PoliticaDeConfianca.avaliar("srv.example.org", 22, ed, r)) + assertEquals(VereditoDeConfianca.Conhecida, PoliticaDeConfianca.avaliar("srv.example.org", 22, rsa, r)) + } + + @Test fun portaDiferenteEOutroHost() { + val r = repo().also { it.confiar("srv.example.org", 22, ed) } + assertTrue(PoliticaDeConfianca.avaliar("srv.example.org", 2222, ed, r) is VereditoDeConfianca.Nova) + } + + @Test fun nomeDeHostNaoDistingueMaiusculas() { + val r = repo().also { it.confiar("Srv.Example.ORG", 22, ed) } + assertEquals(VereditoDeConfianca.Conhecida, PoliticaDeConfianca.avaliar("srv.example.org", 22, ed, r)) + } + + @Test fun rotacaoSemChaveConhecidaAutenticadaERecusada() { + val r = repo().also { it.confiar("srv.example.org", 22, ed) } + assertEquals(ResultadoRotacao.Recusada, AtualizadorDeChaves.aplicar("srv.example.org", 22, rsa, listOf(rsa, ec), r)) + assertEquals(1, r.chavesConhecidas("srv.example.org", 22).size) + } + + @Test fun rotacaoComChaveConhecidaAcrescentaSoTiposNovos() { + val r = repo().also { it.confiar("srv.example.org", 22, ed) } + val outraEd = ChaveDoServidor("ssh-ed25519", ByteArray(8) { 9 }) + val res = AtualizadorDeChaves.aplicar("srv.example.org", 22, ed, listOf(ed, outraEd, ec), r) + assertEquals(ResultadoRotacao.Aceita(listOf(ec)), res) + // A chave nova do tipo que já era conhecido NÃO foi aceita em silêncio. + assertEquals(ed, r.chavesConhecidas("srv.example.org", 22).single { it.algoritmo == "ssh-ed25519" }) + } +} diff --git a/app/src/test/java/com/keyflow/computador/RegraAgenteTest.kt b/app/src/test/java/com/keyflow/computador/RegraAgenteTest.kt new file mode 100644 index 00000000..f6da766f --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/RegraAgenteTest.kt @@ -0,0 +1,21 @@ +package com.keyflow.computador + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RegraAgenteTest { + private fun h(a: AutenticacaoHost, lembrar: Boolean = false) = Host("a1b2c3d4", "n", "x.org", 22, "u", a, lembrarSenha = lembrar) + + @Test fun perguntarSempreNaoPode() = + assertEquals(PodeMarcar.Nao(MotivoAgente.PERGUNTA_SEMPRE), RegraAgente.podeMarcar(h(AutenticacaoHost.Perguntar))) + + @Test fun senhaSoComLembrar() { + assertEquals(PodeMarcar.Nao(MotivoAgente.SENHA_NAO_LEMBRADA), RegraAgente.podeMarcar(h(AutenticacaoHost.Senha))) + assertEquals(PodeMarcar.Sim, RegraAgente.podeMarcar(h(AutenticacaoHost.Senha, lembrar = true))) + } + + @Test fun chaveGuardadaPodeMasComPassphraseNaHoraNao() { + assertEquals(PodeMarcar.Sim, RegraAgente.podeMarcar(h(AutenticacaoHost.Chave("k1")))) + assertEquals(PodeMarcar.Nao(MotivoAgente.CHAVE_COM_PASSPHRASE), RegraAgente.podeMarcar(h(AutenticacaoHost.Chave("k1"))) { true }) + } +} diff --git a/app/src/test/java/com/keyflow/computador/TesteSsh.kt b/app/src/test/java/com/keyflow/computador/TesteSsh.kt new file mode 100644 index 00000000..c4ee1102 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/TesteSsh.kt @@ -0,0 +1,19 @@ +package com.keyflow.computador + +import java.io.File +import java.util.Base64 + +/** Auxiliares dos testes: chaves FALSAS de `src/test/resources/computador/chaves` e a saída de referência do `ssh-keygen`. */ +object TesteSsh { + const val FRASE = "frase-falsa-123" + + fun chave(nome: String): String = File("src/test/resources/computador/chaves/$nome").readText() + + /** A linha do `.pub` de referência: (algoritmo, blob). */ + fun publica(nome: String): Pair { + val (alg, b64) = chave("$nome.pub").trim().split(" ") + return alg to Base64.getDecoder().decode(b64) + } + + fun servidor(nome: String) = publica(nome).let { (a, b) -> ChaveDoServidor(a, b) } +} diff --git a/app/src/test/java/com/keyflow/computador/ValidadorHostTest.kt b/app/src/test/java/com/keyflow/computador/ValidadorHostTest.kt new file mode 100644 index 00000000..c4727147 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/ValidadorHostTest.kt @@ -0,0 +1,61 @@ +package com.keyflow.computador + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ValidadorHostTest { + private fun host( + nome: String = "dev", endereco: String = "dev.example.org", porta: Int = 22, usuario: String = "yan", id: String = "a1b2c3d4", + ) = Host(id = id, nome = nome, endereco = endereco, porta = porta, usuario = usuario) + + private fun motivos(h: Host, outros: List = emptyList()) = ValidadorHost.validar(h, outros).map { it.campo to it.motivo } + + @Test fun hostValidoNaoTemErro() = assertEquals(emptyList(), ValidadorHost.validar(host())) + + @Test fun enderecosValidos() { + for (e in listOf("dev.example.org", "localhost", "meu-servidor", "10.0.0.1", "255.255.255.255", "[::1]", "[2001:db8::1]", "[fe80::1%eth0]", "[::ffff:192.0.2.1]", "a_b.example.com", "xn--bcher-kva.example")) + assertNull("deveria aceitar $e", ValidadorHost.validarEndereco(e)) + } + + @Test fun enderecosInvalidosTemMotivo() { + assertEquals(MotivoHost.ENDERECO_VAZIO, ValidadorHost.validarEndereco("")) + assertEquals(MotivoHost.ENDERECO_LONGO, ValidadorHost.validarEndereco("a".repeat(254))) + for (e in listOf("999.1.1.1", "1.2.3", "1.2.3.4.5", "-ruim.com", "ruim-.com", "a..b", "com espaco", "[::1", "[gggg::1]", "[1:2:3:4:5:6:7:8:9]", "[1::2::3]", "[:::1]", "[]", "http://x", "a/b")) + assertEquals("deveria recusar $e", MotivoHost.ENDERECO_INVALIDO, ValidadorHost.validarEndereco(e)) + } + + @Test fun ipv6SemColchetesPedeColchetes() { + assertEquals(MotivoHost.IPV6_SEM_COLCHETES, ValidadorHost.validarEndereco("::1")) + assertEquals(MotivoHost.IPV6_SEM_COLCHETES, ValidadorHost.validarEndereco("2001:db8::1")) + } + + @Test fun portaForaDaFaixaERecusadaComMotivo() { + for (p in listOf(0, -1, 65536, 99999)) assertEquals(listOf(CampoHost.PORTA to MotivoHost.PORTA_INVALIDA), motivos(host(porta = p))) + for (p in listOf(1, 22, 65535)) assertTrue(motivos(host(porta = p)).isEmpty()) + } + + @Test fun nomeVazioLongoERepetido() { + assertEquals(listOf(CampoHost.NOME to MotivoHost.NOME_VAZIO), motivos(host(nome = " "))) + assertEquals(listOf(CampoHost.NOME to MotivoHost.NOME_LONGO), motivos(host(nome = "x".repeat(41)))) + assertTrue(motivos(host(nome = "x".repeat(40))).isEmpty()) + val outro = host(nome = "Dev", id = "11111111") + assertEquals(listOf(CampoHost.NOME to MotivoHost.NOME_REPETIDO), motivos(host(nome = "dev"), listOf(outro))) + // Editar o próprio host mantendo o nome não é repetição. + assertTrue(motivos(host(nome = "Dev", id = "11111111"), listOf(outro)).isEmpty()) + } + + @Test fun usuarioVazioLongoEComEspaco() { + assertEquals(listOf(CampoHost.USUARIO to MotivoHost.USUARIO_VAZIO), motivos(host(usuario = ""))) + assertEquals(listOf(CampoHost.USUARIO to MotivoHost.USUARIO_LONGO), motivos(host(usuario = "u".repeat(65)))) + assertEquals(listOf(CampoHost.USUARIO to MotivoHost.USUARIO_INVALIDO), motivos(host(usuario = "a b"))) + assertTrue(motivos(host(usuario = "u".repeat(64))).isEmpty()) + } + + @Test fun cinquentaEUmHostsRecusa() { + val cheios = (0 until 50).map { host(nome = "h$it", id = "%08x".format(it)) } + assertEquals(listOf(CampoHost.LIMITE to MotivoHost.LISTA_CHEIA), motivos(host(nome = "novo", id = "ffffffff"), cheios)) + assertTrue(motivos(cheios[3], cheios).isEmpty()) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/CofreDoAppTest.kt b/app/src/test/java/com/keyflow/computador/app/CofreDoAppTest.kt new file mode 100644 index 00000000..f7b7cfe9 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/CofreDoAppTest.kt @@ -0,0 +1,70 @@ +package com.keyflow.computador.app + +import com.keyflow.auth.AuthMethod +import com.keyflow.auth.Credential +import com.keyflow.auth.CredentialStore +import com.keyflow.auth.ProviderId +import java.util.concurrent.CountDownLatch +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CofreDoAppTest { + /** Um cofre compartilhado cuja gravação do índice é lenta, para abrir a janela da corrida. */ + private class Compartilhado(private val lentoNoIndice: Boolean) : CredentialStore { + val segredos = java.util.concurrent.ConcurrentHashMap() + override fun get(provider: ProviderId): Credential? = null + override fun put(provider: ProviderId, credential: Credential) = Unit + override fun clear(provider: ProviderId) = Unit + override fun changes(provider: ProviderId): Flow = flowOf(null) + override fun meta(provider: ProviderId, key: String): String? = null + override fun putMeta(provider: ProviderId, key: String, value: String) = Unit + override fun setAuthMode(provider: ProviderId, method: AuthMethod?) = Unit + override fun getSecret(key: String): String? = segredos[key] + override fun putSecret(key: String, value: String) { + if (lentoNoIndice && key == CofreDoApp.INDICE) Thread.sleep(150) + segredos[key] = value + } + override fun clearSecret(key: String) { segredos.remove(key) } + } + + @Test fun duasInstanciasGravandoAoMesmoTempoNaoPerdemEntradaDoIndice() { + val store = Compartilhado(lentoNoIndice = true) + val a = CofreDoApp(store) + val b = CofreDoApp(store) + val largada = CyclicBarrier(2) + val fim = CountDownLatch(2) + for ((cofre, chave, valor) in listOf(Triple(a, "ssh_pw.aaaaaaaa", "SENTINELA-A-12345678"), Triple(b, "ssh_pw.bbbbbbbb", "SENTINELA-B-12345678"))) { + Thread { largada.await(); cofre.gravar(chave, valor); fim.countDown() }.start() + } + assertTrue(fim.await(10, TimeUnit.SECONDS)) + // Qualquer instância enxerga as duas entradas: o SecretFilter vai remover os dois valores. + assertEquals(setOf("SENTINELA-A-12345678", "SENTINELA-B-12345678"), CofreDoApp(store).valores().toSet()) + } + + @Test fun apagarEGravarSimultaneosTambemFicamCoerentes() { + val store = Compartilhado(lentoNoIndice = true) + val a = CofreDoApp(store).also { it.gravar("ssh_pw.aaaaaaaa", "SENTINELA-A-12345678") } + val b = CofreDoApp(store) + val largada = CyclicBarrier(2) + val fim = CountDownLatch(2) + Thread { largada.await(); a.apagar("ssh_pw.aaaaaaaa"); fim.countDown() }.start() + Thread { largada.await(); b.gravar("ssh_pw.bbbbbbbb", "SENTINELA-B-12345678"); fim.countDown() }.start() + assertTrue(fim.await(10, TimeUnit.SECONDS)) + assertEquals(listOf("SENTINELA-B-12345678"), CofreDoApp(store).valores().toList()) + } + + @Test fun publicaNaoEntraNosValoresDoFiltro() { + val store = Compartilhado(lentoNoIndice = false) + val c = CofreDoApp(store) + c.gravar("ssh_pub.k1", "AAAAC3NzaC1lZDI1NTE5-publica") + c.gravar("ssh_key.k1", "cifrado-xyz-12345678") + assertEquals(listOf("cifrado-xyz-12345678"), c.valores().toList()) + assertFalse(c.valores().any { it.contains("publica") }) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/DetectorDeCadastroTest.kt b/app/src/test/java/com/keyflow/computador/app/DetectorDeCadastroTest.kt new file mode 100644 index 00000000..9c44ae0d --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/DetectorDeCadastroTest.kt @@ -0,0 +1,46 @@ +package com.keyflow.computador.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DetectorDeCadastroTest { + private class SentinelaFalsa : SentinelaKeystore { + val existentes = mutableSetOf() + var invalida = false + var criadas = 0 + override fun existe(id: String) = id in existentes + override fun criar(id: String) { existentes += id; criadas++ } + override fun invalidada(id: String) = invalida + override fun apagar(id: String) { existentes -= id } + } + + @Test fun sentinelaNovaSoNasceAteAApi29() { + for (api in 24..29) SentinelaFalsa().also { DetectorDeCadastroKeystore(api, it).criar("k"); assertEquals("API $api", 1, it.criadas) } + for (api in 30..36) SentinelaFalsa().also { DetectorDeCadastroKeystore(api, it).criar("k"); assertEquals("API $api", 0, it.criadas) } + } + + @Test fun chaveCriadaNaApi29EUsadaNa30MaisConsultaASentinelaExistente() { + val s = SentinelaFalsa() + DetectorDeCadastroKeystore(api = 29, sentinelas = s).criar("k1") + // OTA: o app agora roda na API 34, com a mesma sentinela no Keystore. + val depoisDoOta = DetectorDeCadastroKeystore(api = 34, sentinelas = s) + assertFalse(depoisDoOta.invalidada("k1")) + s.invalida = true // outra digital cadastrada + assertTrue("a sentinela antiga vale na API 34", depoisDoOta.invalidada("k1")) + } + + @Test fun semSentinelaNaoHaInvalidacao() { + val s = SentinelaFalsa().also { it.invalida = true } + assertFalse(DetectorDeCadastroKeystore(api = 34, sentinelas = s).invalidada("sem-sentinela")) + assertFalse(DetectorDeCadastroKeystore(api = 28, sentinelas = s).invalidada("sem-sentinela")) + } + + @Test fun apagarRemoveASentinela() { + val s = SentinelaFalsa() + val d = DetectorDeCadastroKeystore(api = 29, sentinelas = s) + d.criar("k"); d.apagar("k") + assertFalse(s.existe("k")) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/LigacaoSessoesTest.kt b/app/src/test/java/com/keyflow/computador/app/LigacaoSessoesTest.kt new file mode 100644 index 00000000..82501d40 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/LigacaoSessoesTest.kt @@ -0,0 +1,61 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.sessao.DependenciasFalsas +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.EventoGerenciador +import com.keyflow.computador.sessao.GerenciadorDeSessoes +import com.keyflow.computador.sessao.HOST +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** A corrida medida no `SessoesServiceTest`: abrir a sessão logo depois de ligar o serviço ao gerenciador não pode perder o aviso de "1ª sessão". */ +class LigacaoSessoesTest { + private val rodadas = 400 + + private fun semear(ligar: (GerenciadorDeSessoes, CoroutineScope, () -> Unit) -> Unit): Int { + val escopo = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val iniciou = AtomicInteger() + try { + repeat(rodadas) { + val g = GerenciadorDeSessoes(escopo, DependenciasFalsas { System.currentTimeMillis() }) + val avisos = AtomicInteger() + ligar(g, escopo) { avisos.incrementAndGet() } + g.abrir(HOST, DonoDaSessao.USUARIO) // logo em seguida, como o teste e o app fazem + val limite = System.currentTimeMillis() + 2_000 + while (avisos.get() == 0 && System.currentTimeMillis() < limite) Thread.sleep(1) + if (avisos.get() > 0) iniciou.incrementAndGet() + } + } finally { escopo.cancel() } + return iniciou.get() + } + + @Test fun ligacaoNovaNuncaPerdeAPrimeiraSessao() { + assertEquals(rodadas, semear { g, e, iniciar -> observarPrimeiraSessao(g, e, iniciar) }) + } + + @Test fun ligacaoAntigaPerdiaAvisos_medicao() { + // O `launch` comum (o código anterior). Só registra a medição; não afirma um número, porque depende da máquina. + val ok = semear { g, e, iniciar -> e.launch { g.eventos.collect { if (it == EventoGerenciador.PrimeiraSessao) iniciar() } } } + println("CMP_T12 ligação antiga: $ok de $rodadas rodadas receberam o aviso") + assertTrue(ok in 0..rodadas) + } + + @Test fun sessaoJaViva_naoDependeDoEvento() = runBlocking { + val escopo = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val g = GerenciadorDeSessoes(escopo, DependenciasFalsas { System.currentTimeMillis() }) + g.abrir(HOST, DonoDaSessao.USUARIO) + val avisos = AtomicInteger() + observarPrimeiraSessao(g, escopo) { avisos.incrementAndGet() } + assertTrue(avisos.get() >= 1) + } finally { escopo.cancel() } + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/ManifestSessoesTest.kt b/app/src/test/java/com/keyflow/computador/app/ManifestSessoesTest.kt new file mode 100644 index 00000000..1fdcec5a --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/ManifestSessoesTest.kt @@ -0,0 +1,66 @@ +package com.keyflow.computador.app + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element + +/** CMP-33: o manifesto declara a permissão, o tipo `specialUse` e a `property` com a justificativa; o WakeLock é permissão normal. */ +class ManifestSessoesTest { + private val manifesto = File("src/main/AndroidManifest.xml") + private val doc = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true }.newDocumentBuilder().parse(manifesto) + private val ns = "http://schemas.android.com/apk/res/android" + + private fun servico(): Element { + val servicos = doc.getElementsByTagName("service") + return (0 until servicos.length).map { servicos.item(it) as Element }.firstOrNull { it.getAttributeNS(ns, "name") == ".computador.app.SessoesService" } + ?: error("o SessoesService não está no manifesto") + } + + @Test fun permissaoDoTipoSpecialUse() { + val nomes = (0 until doc.getElementsByTagName("uses-permission").length).map { (doc.getElementsByTagName("uses-permission").item(it) as Element).getAttributeNS(ns, "name") } + assertTrue(nomes.toString(), "android.permission.FOREGROUND_SERVICE_SPECIAL_USE" in nomes) + assertTrue("android.permission.FOREGROUND_SERVICE" in nomes) + } + + @Test fun tipoSpecialUseEServicoNaoExportado() { + val s = servico() + assertEquals("specialUse", s.getAttributeNS(ns, "foregroundServiceType")) + assertEquals("false", s.getAttributeNS(ns, "exported")) + } + + @Test fun propertyComASubtipoDaJustificativa() { + val props = servico().getElementsByTagName("property") + val p = (0 until props.length).map { props.item(it) as Element }.firstOrNull { it.getAttributeNS(ns, "name") == "android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" } + assertNotNull("falta a property do subtipo", p) + assertEquals("Keeps user-started interactive SSH terminal sessions connected while the app is in the background", p!!.getAttributeNS(ns, "value")) + } + + @Test fun wakeLockEPermissaoNormalDeclarada() { + val nomes = (0 until doc.getElementsByTagName("uses-permission").length).map { (doc.getElementsByTagName("uses-permission").item(it) as Element).getAttributeNS(ns, "name") } + assertTrue("android.permission.WAKE_LOCK" in nomes) + } + + @Test fun oServicoDaOrbeContinuaComSeusTipos() { + val servicos = doc.getElementsByTagName("service") + val orbe = (0 until servicos.length).map { servicos.item(it) as Element }.first { it.getAttributeNS(ns, "name").endsWith("OverlayService") } + assertTrue(orbe.getAttributeNS(ns, "foregroundServiceType").contains("microphone")) + assertTrue(!orbe.getAttributeNS(ns, "foregroundServiceType").contains("specialUse")) + } + + @Test fun checklistDaPlayTemAJustificativa() { + val txt = File("../PLAYSTORE_CHECKLIST.md").readText() + assertTrue(txt.contains("FOREGROUND_SERVICE_SPECIAL_USE") && txt.contains("PROPERTY_SPECIAL_USE_FGS_SUBTYPE") && txt.contains("Policy > App content")) + } + + @Test fun regrasDeBackupExcluemOKnownHostsEOCofre() { + for (f in listOf("backup_rules.xml", "data_extraction_rules.xml")) { + val t = File("src/main/res/xml/$f").readText() + assertTrue("$f sem known_hosts", t.contains("path=\"known_hosts\"")) + assertTrue("$f sem o cofre", t.contains("path=\"keyflow_auth.xml\"")) + } + } +} diff --git a/app/src/test/java/com/keyflow/computador/chaves/AssinadorSoftwareTest.kt b/app/src/test/java/com/keyflow/computador/chaves/AssinadorSoftwareTest.kt new file mode 100644 index 00000000..a666c16c --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/chaves/AssinadorSoftwareTest.kt @@ -0,0 +1,93 @@ +package com.keyflow.computador.chaves + +import com.google.crypto.tink.subtle.Ed25519Verify +import com.keyflow.computador.TesteSsh +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.security.spec.RSAPublicKeySpec +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AssinadorSoftwareTest { + private val dados = "dados da autenticação".toByteArray() + + private fun campos(blob: ByteArray): List { + val b = ByteBuffer.wrap(blob) + val out = mutableListOf() + while (b.hasRemaining()) out += ByteArray(b.int).also { b.get(it) } + return out + } + + private fun importada(nome: String) = (ImportadorDeChave().importar(TesteSsh.chave(nome).toCharArray(), null) as Resultado.Ok).valor + + private fun assinarCom(c: ChaveImportada): ByteArray = runBlocking { + // Cada chamada devolve uma cópia: o assinador zera o que recebe, como o envelope fará. + AssinadorSoftware(c.publica) { MaterialPrivado(c.material.formato, c.material.bytes.copyOf()) }.assinar(dados)!! + } + + @Test fun ed25519VerificaComAPublicaImportada() { + val c = importada("ed25519") + val (alg, sig) = campos(assinarCom(c)).let { String(it[0]) to it[1] } + assertEquals("ssh-ed25519", alg) + Ed25519Verify(campos(c.publica.blob)[1]).verify(sig, dados) // lança se inválida + } + + @Test fun ed25519GeradaEmSoftwareAssinaEVerifica() { + val g = ChavesSoftware.gerarEd25519() + assertEquals("ssh-ed25519", g.publica.tipoDoBlob) + val sig = runBlocking { AssinadorSoftware(g.publica) { MaterialPrivado(g.material.formato, g.material.bytes.copyOf()) }.assinar(dados)!! } + Ed25519Verify(campos(g.publica.blob)[1]).verify(campos(sig)[1], dados) + } + + @Test fun ecdsaP256VerificaComAPublica() { + val c = importada("ecdsa-p256") + val (alg, sigBlob) = campos(assinarCom(c)).let { String(it[0]) to it[1] } + assertEquals("ecdsa-sha2-nistp256", alg) + val ponto = campos(c.publica.blob)[2] + val x = BigInteger(1, ponto.copyOfRange(1, 33)) + val y = BigInteger(1, ponto.copyOfRange(33, 65)) + val params = (java.security.KeyPairGenerator.getInstance("EC").also { it.initialize(java.security.spec.ECGenParameterSpec("secp256r1")) }.generateKeyPair().public as java.security.interfaces.ECPublicKey).params + val pub = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(ECPoint(x, y), params)) + val rs = campos(sigBlob) + val der = derDeRs(BigInteger(rs[0]), BigInteger(rs[1])) + val v = Signature.getInstance("SHA256withECDSA").apply { initVerify(pub); update(dados) } + assertTrue(v.verify(der)) + } + + @Test fun rsaVerificaComAPublicaEUsaRsaSha2() { + val c = importada("rsa3072") + val campos = campos(assinarCom(c)) + assertEquals("rsa-sha2-256", String(campos[0])) + val b = campos(c.publica.blob) + val pub = KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(BigInteger(b[2]), BigInteger(b[1]))) + assertTrue(Signature.getInstance("SHA256withRSA").apply { initVerify(pub); update(dados) }.verify(campos[1])) + } + + @Test fun materialEZeradoDepoisDeAssinarEmAssinaturaNormalEEmErro() { + val c = importada("ed25519") + val m = MaterialPrivado(c.material.formato, c.material.bytes.copyOf()) + runBlocking { AssinadorSoftware(c.publica) { m }.assinar(dados) } + assertTrue(m.bytes.all { it == 0.toByte() }) + val ruim = MaterialPrivado(FormatoDoMaterial.OPENSSH_SEM_SENHA, "lixo".toByteArray()) + val r = runCatching { runBlocking { AssinadorSoftware(c.publica) { ruim }.assinar(dados) } } + assertTrue(r.isFailure) + assertTrue(ruim.bytes.all { it == 0.toByte() }) + } + + @Test fun materialNuloSignificaCancelado() { + assertNull(runBlocking { AssinadorSoftware(importada("ed25519").publica) { null }.assinar(dados) }) + } + + private fun derDeRs(r: BigInteger, s: BigInteger): ByteArray { + fun inteiro(v: BigInteger) = v.toByteArray().let { byteArrayOf(0x02, it.size.toByte()) + it } + val corpo = inteiro(r) + inteiro(s) + return byteArrayOf(0x30, corpo.size.toByte()) + corpo + } +} diff --git a/app/src/test/java/com/keyflow/computador/chaves/BiometriaTest.kt b/app/src/test/java/com/keyflow/computador/chaves/BiometriaTest.kt new file mode 100644 index 00000000..3ece85f9 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/chaves/BiometriaTest.kt @@ -0,0 +1,70 @@ +package com.keyflow.computador.chaves + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BiometriaTest { + private class Relogio(var ms: Long = 1_000_000L) : RelogioDeChaves { override fun agoraMs() = ms } + + @Test fun janelaDeCincoMinutos() { + val r = Relogio() + val j = JanelaDeBiometria(r) + assertFalse("sem prompt ainda", j.valida()) + j.marcar() + assertTrue(j.valida()) + r.ms += 299_999 + assertTrue("dentro dos 300 s", j.valida()) + r.ms += 1 + assertFalse("aos 300 s vence", j.valida()) + } + + @Test fun relogioParaTrasNaoRenovaAJanela() { + val r = Relogio() + val j = JanelaDeBiometria(r) + j.marcar() + r.ms -= 10_000 + assertFalse(j.valida()) + } + + @Test fun encerrarFechaAJanela() { + val r = Relogio() + val j = JanelaDeBiometria(r).also { it.marcar() } + j.encerrar() + assertFalse(j.valida()) + } + + @Test fun duracaoEDeTrezentosSegundos() = assertEquals(300, JanelaDeBiometria.DURACAO_S) + + @Test fun planoPorApi() { + for (api in 24..29) assertEquals("API $api", PlanoDeAutenticacao.Validade(300), PlanoDeAutenticacao.para(api)) + for (api in 30..37) assertEquals("API $api", PlanoDeAutenticacao.Parametros(300, biometriaForte = true), PlanoDeAutenticacao.para(api)) + } + + private val spkiEd = byteArrayOf(0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00) + ByteArray(32) { 1 } + + @Test fun sondaAprovaQuandoTudoBate() = + assertEquals(AvaliadorDaSonda.Veredito.Aprovada, AvaliadorDaSonda.avaliar(34, spkiEd, true)) + + @Test fun sondaReprovaPorApiMenorQue33() = + assertTrue(AvaliadorDaSonda.avaliar(32, spkiEd, true) is AvaliadorDaSonda.Veredito.Reprovada) + + @Test fun sondaReprovaQuandoOProvedorDevolveP256() { + val spkiP256 = ByteArray(91) { 2 } // SPKI de P-256 tem 91 bytes + val v = AvaliadorDaSonda.avaliar(34, spkiP256, true) as AvaliadorDaSonda.Veredito.Reprovada + assertTrue(v.motivo, v.motivo.contains("não é Ed25519")) + } + + @Test fun sondaReprovaPorExcecaoPorFaltaDePublicaEPorAssinaturaInvalida() { + assertTrue(AvaliadorDaSonda.avaliar(34, null, null, IllegalStateException("x")) is AvaliadorDaSonda.Veredito.Reprovada) + assertTrue(AvaliadorDaSonda.avaliar(34, null, null) is AvaliadorDaSonda.Veredito.Reprovada) + assertTrue(AvaliadorDaSonda.avaliar(34, spkiEd, false) is AvaliadorDaSonda.Veredito.Reprovada) + assertTrue(AvaliadorDaSonda.avaliar(34, spkiEd, null) is AvaliadorDaSonda.Veredito.Reprovada) + } + + @Test fun bytesDaPublicaSoParaSpkiEd25519() { + assertEquals(32, AvaliadorDaSonda.bytesDaPublica(spkiEd)!!.size) + assertEquals(null, AvaliadorDaSonda.bytesDaPublica(ByteArray(44))) + } +} diff --git a/app/src/test/java/com/keyflow/computador/chaves/ChavePublicaTest.kt b/app/src/test/java/com/keyflow/computador/chaves/ChavePublicaTest.kt new file mode 100644 index 00000000..cf63d2f1 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/chaves/ChavePublicaTest.kt @@ -0,0 +1,48 @@ +package com.keyflow.computador.chaves + +import com.keyflow.computador.TesteSsh +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ChavePublicaTest { + @Test fun ed25519BateComOSshKeygen() { + val (alg, blob) = TesteSsh.publica("ed25519") + val linha = FormatadorChavePublica.linha(blob, "Galaxy A32") + assertEquals(TesteSsh.chave("ed25519.pub").trim().split(" ").take(2).joinToString(" ") + " keyflow@Galaxy-A32", linha) + assertEquals("ssh-ed25519", alg) + } + + @Test fun blobEd25519ConstruidoPorNosBateComOSshKeygen() { + val (_, blob) = TesteSsh.publica("ed25519") + // Os últimos 32 bytes são a chave; remontar o blob dá o mesmo. + assertArrayEquals(blob, FormatadorChavePublica.blobEd25519(blob.copyOfRange(blob.size - 32, blob.size))) + } + + @Test fun blobP256ConstruidoPorNosBateComOSshKeygen() { + val (alg, blob) = TesteSsh.publica("ecdsa-p256") + assertEquals("ecdsa-sha2-nistp256", alg) + assertArrayEquals(blob, FormatadorChavePublica.blobEcdsaP256(blob.copyOfRange(blob.size - 65, blob.size))) + assertEquals("ecdsa-sha2-nistp256", FormatadorChavePublica.linha(blob, "x").substringBefore(' ')) + } + + @Test fun comentarioNaoTemEspacoNemControle() { + val (_, blob) = TesteSsh.publica("ed25519") + val c = FormatadorChavePublica.linha(blob, "Meu Tablet\n; rm -rf").substringAfterLast(' ') + assertEquals("keyflow@Meu--Tablet-;-rm--rf", c) + assertEquals("keyflow@aparelho", FormatadorChavePublica.linha(blob, " ").substringAfterLast(' ')) + assertEquals(64 + "keyflow@".length, FormatadorChavePublica.linha(blob, "x".repeat(200)).substringAfterLast(' ').length) + } + + @Test fun blobTruncadoNaoLanca() { + assertNull(FormatadorChavePublica.tipoDoBlob(byteArrayOf(0, 0))) + assertNull(FormatadorChavePublica.tipoDoBlob(byteArrayOf(0, 0, 0, 50, 1))) + } + + @Test fun rsaOferecidaComoRsaSha2MasLinhaDizSshRsa() { + val chave = (ImportadorDeChave().importar(TesteSsh.chave("rsa3072").toCharArray(), null) as Resultado.Ok).valor + assertEquals("rsa-sha2-256", chave.publica.algoritmo) + assertEquals("ssh-rsa", FormatadorChavePublica.linha(chave.publica, "x").substringBefore(' ')) + } +} diff --git a/app/src/test/java/com/keyflow/computador/chaves/ChaveSshSemSegredoTest.kt b/app/src/test/java/com/keyflow/computador/chaves/ChaveSshSemSegredoTest.kt new file mode 100644 index 00000000..4f74571a --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/chaves/ChaveSshSemSegredoTest.kt @@ -0,0 +1,41 @@ +package com.keyflow.computador.chaves + +import com.keyflow.computador.ImpressaoDigital +import com.keyflow.computador.TesteSsh +import java.lang.reflect.Modifier +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** CMP-16: nada de material privado em `ChaveSsh` (campos, `toString`, `copy`), no estado nem nos logs. */ +class ChaveSshSemSegredoTest { + private val sentinela = "SENTINELA-privada-5c3a91" + + @Test fun chaveSshNaoTemCampoDeMaterialSecreto() { + val nomes = ChaveSsh::class.java.declaredFields.filter { !Modifier.isStatic(it.modifiers) }.map { it.name.lowercase() } + assertTrue("campos: $nomes", nomes.none { n -> listOf("priv", "seed", "semente", "secret", "segredo", "pem", "passphrase", "senha", "material").any { n.contains(it) } }) + assertTrue(ChaveSsh::class.java.declaredFields.none { it.type == ByteArray::class.java }) + } + + @Test fun toStringECopyNaoMostramNomeNemImpressao() { + val c = ChaveSsh("k0000001", "nome $sentinela", TipoDeChave.ED25519, ImpressaoDigital.de(sentinela.toByteArray()), Protecao.KEYSTORE_ENVELOPE, true, 1L) + assertFalse(c.toString().contains(sentinela)) + assertFalse(c.toString().contains("SHA256")) + assertFalse(c.copy(exigeBiometria = false).toString().contains("SHA256")) + assertTrue(c.copy(nome = "outro") != c) + } + + @Test fun materialPrivadoMascaraEZera() { + val m = MaterialPrivado(FormatoDoMaterial.OPENSSH_SEM_SENHA, sentinela.toByteArray()) + assertFalse(m.toString().contains("SENTINELA")) + assertFalse(m.hashCode().toString().isEmpty()) + m.zerar() + assertTrue(m.bytes.all { it == 0.toByte() }) + } + + @Test fun importadaEGeradaNaoVazamEmToString() { + val chave = (ImportadorDeChave().importar(TesteSsh.chave("ed25519").toCharArray(), null) as Resultado.Ok).valor + assertFalse(chave.toString().contains("BEGIN")) + assertFalse(ChavesSoftware.gerarEd25519().toString().contains("=")) + } +} diff --git a/app/src/test/java/com/keyflow/computador/chaves/CodecChavesTest.kt b/app/src/test/java/com/keyflow/computador/chaves/CodecChavesTest.kt new file mode 100644 index 00000000..19504cd5 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/chaves/CodecChavesTest.kt @@ -0,0 +1,38 @@ +package com.keyflow.computador.chaves + +import com.keyflow.computador.ImpressaoDigital +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class CodecChavesTest { + private val c1 = ChaveSsh("k0000001", "Minha chave \"ç\"", TipoDeChave.ED25519, ImpressaoDigital.de(byteArrayOf(1, 2)), Protecao.KEYSTORE_ENVELOPE, true, 123L) + private val c2 = ChaveSsh("k0000002", "P-256", TipoDeChave.ECDSA_P256, ImpressaoDigital.de(byteArrayOf(3)), Protecao.HARDWARE, false, 456L) + + @Test fun idaEVolta() = assertEquals(listOf(c1, c2), CodecChaves.decodificar(CodecChaves.codificar(listOf(c1, c2)))) + + @Test fun lixoViraListaVazia() { + for (s in listOf(null, "", "{", "[]", "{\"keys\":5}", "\"x\"")) assertEquals(emptyList(), CodecChaves.decodificar(s)) + assertEquals(emptyList(), CodecChaves.decodificar("[".repeat(100_000))) + } + + @Test fun itemRuimEDescartadoEIdRepetidoViraUm() { + val bom = JSONObject(CodecChaves.codificar(listOf(c1))).getJSONArray("keys").getJSONObject(0) + val ruim = JSONObject(bom.toString()).put("fp", "SHA256:curto") + val tipoRuim = JSONObject(bom.toString()).put("id", "outro").put("type", "DSA") + assertEquals(listOf(c1), CodecChaves.decodificar("""{"keys":[ 7, $ruim, $tipoRuim, $bom, $bom ]}""")) + } + + @Test fun jsonNaoTemMaterialSecretoNemCampoDeSegredo() { + val txt = CodecChaves.codificar(listOf(c1, c2)).lowercase() + for (p in listOf("private", "seed", "passphrase", "\"pem\"", "secret")) assertFalse(p, txt.contains(p)) + } + + @Test fun impressaoDeTextoRoundTrip() { + val i = ImpressaoDigital.de(byteArrayOf(9, 9)) + assertEquals(i, ImpressaoDigital.deTexto(i.texto)) + assertEquals(null, ImpressaoDigital.deTexto("SHA256:@@@")) + assertEquals(null, ImpressaoDigital.deTexto("MD5:abc")) + } +} diff --git a/app/src/test/java/com/keyflow/computador/chaves/CofreFalso.kt b/app/src/test/java/com/keyflow/computador/chaves/CofreFalso.kt new file mode 100644 index 00000000..f2bd95e8 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/chaves/CofreFalso.kt @@ -0,0 +1,10 @@ +package com.keyflow.computador.chaves + +/** Cofre em memória para os testes (o de verdade é o `keyflow_auth`). */ +class CofreFalso : CofreDeSegredos { + val itens = linkedMapOf() + override fun ler(chave: String) = itens[chave] + override fun gravar(chave: String, valor: String) { itens[chave] = valor } + override fun apagar(chave: String) { itens.remove(chave) } + override fun valores(): Collection = itens.values.toList() +} diff --git a/app/src/test/java/com/keyflow/computador/chaves/ImportadorDeChaveTest.kt b/app/src/test/java/com/keyflow/computador/chaves/ImportadorDeChaveTest.kt new file mode 100644 index 00000000..ef503563 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/chaves/ImportadorDeChaveTest.kt @@ -0,0 +1,112 @@ +package com.keyflow.computador.chaves + +import com.keyflow.computador.TesteSsh +import java.util.Base64 +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImportadorDeChaveTest { + private val importador = ImportadorDeChave() + + private fun importar(nome: String, frase: String? = null) = + importador.importar(TesteSsh.chave(nome).toCharArray(), frase?.toCharArray()) + + private fun ok(nome: String, frase: String? = null) = (importar(nome, frase) as Resultado.Ok).valor + + private fun erro(nome: String, frase: String? = null) = (importar(nome, frase) as Resultado.Falha).erro + + /** A pública gerada bate com a do `ssh-keygen -y` (o `.pub` de referência). */ + private fun confereComSshKeygen(nome: String, tipo: TipoDeChave, frase: String? = null) { + val chave = ok(nome, frase) + assertEquals(tipo, chave.tipo) + val (alg, blob) = TesteSsh.publica(nome) + assertArrayEquals("blob da pública de $nome", blob, chave.publica.blob) + assertEquals(alg, chave.publica.tipoDoBlob) + val esperado = TesteSsh.chave("$nome.pub").trim().split(" ").take(2).joinToString(" ") + assertEquals(esperado, FormatadorChavePublica.linha(chave.publica, "x").substringBeforeLast(" ")) + } + + @Test fun ed25519OpenSsh() = confereComSshKeygen("ed25519", TipoDeChave.ED25519) + @Test fun ed25519ComFrase() = confereComSshKeygen("ed25519-frase", TipoDeChave.ED25519, TesteSsh.FRASE) + @Test fun ed25519Pkcs8() = confereComSshKeygen("ed25519-pkcs8", TipoDeChave.ED25519) + @Test fun ecdsaP256() = confereComSshKeygen("ecdsa-p256", TipoDeChave.ECDSA_P256) + @Test fun ecdsaP256ComFrase() = confereComSshKeygen("ecdsa-p256-frase", TipoDeChave.ECDSA_P256, TesteSsh.FRASE) + @Test fun ecdsaP256Sec1Pem() = confereComSshKeygen("ecdsa-p256-pem", TipoDeChave.ECDSA_P256) + @Test fun ecdsaP384() = confereComSshKeygen("ecdsa-p384", TipoDeChave.ECDSA_P384) + @Test fun rsa3072() = confereComSshKeygen("rsa3072", TipoDeChave.RSA) + @Test fun rsa3072ComFrase() = confereComSshKeygen("rsa3072-frase", TipoDeChave.RSA, TesteSsh.FRASE) + @Test fun rsa2048Pkcs1Pem() = confereComSshKeygen("rsa2048-pem", TipoDeChave.RSA) + @Test fun rsa3072Pkcs1PemComFrase() = confereComSshKeygen("rsa3072-pem-frase", TipoDeChave.RSA, TesteSsh.FRASE) + + @Test fun rotuloEImpressaoBatemComOSshKeygen() { + assertEquals("RSA 3072", ok("rsa3072").rotulo) + assertEquals("SHA256:JplRazxRAtruXcAiIxVPfXhV2lHkREWz5eAk54b2ILE", ok("rsa3072").impressao.texto) + assertEquals("ECDSA 256", ok("ecdsa-p256").rotulo) + } + + @Test fun passphraseErradaTemErroEspecifico() { + for (nome in listOf("ed25519-frase", "ecdsa-p256-frase", "rsa3072-frase", "rsa3072-pem-frase")) assertEquals(nome, ErroChave.PASSPHRASE, erro(nome, "errada-123")) + } + + @Test fun faltaDePassphraseEOutroErro() { + for (nome in listOf("ed25519-frase", "rsa3072-pem-frase")) { + assertEquals(ErroChave.PASSPHRASE_NECESSARIA, erro(nome)) + assertEquals(ErroChave.PASSPHRASE_NECESSARIA, erro(nome, "")) + } + } + + @Test fun passphraseSobrandoEmChaveSemSenhaEIgnorada() = confereComSshKeygen("ed25519", TipoDeChave.ED25519, "qualquer") + + @Test fun dsaERecusada() { + assertEquals(ErroChave.DSA_RECUSADA, erro("dsa")) + assertEquals(ErroChave.DSA_RECUSADA, erro("dsa-pem")) + } + + @Test fun rsa1024ERecusada() = assertEquals(ErroChave.RSA_FRACA, erro("rsa1024")) + + @Test fun lixoEFormatoDesconhecido() { + for (t in listOf("", " ", "isto não é chave", "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----", "-----BEGIN PRIVATE KEY-----\n!!!\n-----END PRIVATE KEY-----")) + assertEquals(t, ErroChave.FORMATO, (importador.importar(t.toCharArray(), null) as Resultado.Falha).erro) + } + + @Test fun chaveTruncadaNaoLanca() { + val pem = TesteSsh.chave("ed25519") + for (n in listOf(60, 120, 200, pem.length - 40)) importador.importar(pem.take(n).toCharArray(), null) + } + + @Test fun textoEPassphraseSaoZeradosAoFim() { + val pem = TesteSsh.chave("ed25519-frase").toCharArray() + val frase = TesteSsh.FRASE.toCharArray() + importador.importar(pem, frase) + assertTrue(pem.all { it == '\u0000' }) + assertTrue(frase.all { it == '\u0000' }) + val pem2 = TesteSsh.chave("rsa1024").toCharArray() + importador.importar(pem2, null) // erro: também zera + assertTrue(pem2.all { it == '\u0000' }) + } + + @Test fun importadorNaoGuardaNadaEmCampo() { + // Sem estado: nenhuma propriedade de instância que pudesse reter o texto colado ou a passphrase. + assertEquals(emptyList(), ImportadorDeChave::class.java.declaredFields.map { it.name }.filter { it != "\$stable" }) + val chave = ok("ed25519-frase", TesteSsh.FRASE) + val vazou = generateSequence(chave.javaClass as Class<*>?) { it.superclass }.flatMap { it.declaredFields.asSequence() }.filter { !java.lang.reflect.Modifier.isStatic(it.modifiers) } + .any { f -> f.isAccessible = true; f.get(chave).toString().contains(TesteSsh.FRASE) } + assertTrue(!vazou && !chave.toString().contains(TesteSsh.FRASE)) + } + + @Test fun materialDoEd25519ESementeDe32Bytes() { + val m = ok("ed25519").material + assertEquals(FormatoDoMaterial.SEMENTE_ED25519, m.formato) + assertEquals(32, m.bytes.size) + assertEquals("MaterialPrivado(SEMENTE_ED25519, ***)", m.toString()) + } + + @Test fun materialDosOutrosTiposEOpenSshSemSenha() { + val m = ok("ecdsa-p256").material + assertEquals(FormatoDoMaterial.OPENSSH_SEM_SENHA, m.formato) + assertTrue(String(m.bytes).startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) + assertEquals(Base64.getEncoder().encodeToString(TesteSsh.publica("ecdsa-p256").second), Base64.getEncoder().encodeToString(ok("ecdsa-p256").publica.blob)) + } +} diff --git a/app/src/test/java/com/keyflow/computador/fixture/SshFixtureServer.kt b/app/src/test/java/com/keyflow/computador/fixture/SshFixtureServer.kt index f10254e0..b745d2fe 100644 --- a/app/src/test/java/com/keyflow/computador/fixture/SshFixtureServer.kt +++ b/app/src/test/java/com/keyflow/computador/fixture/SshFixtureServer.kt @@ -34,6 +34,8 @@ class SshFixtureServer(private val cairNoMeio: Boolean = false) : AutoCloseable val tamanhos = LinkedBlockingQueue() val ambientes = LinkedBlockingQueue>() val quedas = LinkedBlockingQueue() + /** Quantas vezes o servidor viu um pedido de senha ou de chave (um cliente que cancela antes de autenticar deixa isto em zero). */ + val tentativasDeLogin = java.util.concurrent.atomic.AtomicInteger() private val executor = Executors.newCachedThreadPool { tarefa -> Thread(tarefa, "ssh-fixture-eco").apply { isDaemon = true } } private val chaveHost = KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1"), SecureRandom.getInstance("SHA1PRNG").apply { setSeed("KeyFlow host SOMENTE TESTE 2026".toByteArray()) }) @@ -56,8 +58,8 @@ class SshFixtureServer(private val cairNoMeio: Boolean = false) : AutoCloseable host = "127.0.0.1" port = 0 keyPairProvider = KeyPairProvider.wrap(this@SshFixtureServer.chaveHost) - passwordAuthenticator = org.apache.sshd.server.auth.password.PasswordAuthenticator { nome, senha, _ -> nome == "senha" && senha == SENHA_FALSA } - publickeyAuthenticator = org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator { nome, chave, _ -> nome == "teste" && chave.encoded.contentEquals(usuario.public.encoded) } + passwordAuthenticator = org.apache.sshd.server.auth.password.PasswordAuthenticator { nome, senha, _ -> tentativasDeLogin.incrementAndGet(); nome == "senha" && senha == SENHA_FALSA } + publickeyAuthenticator = org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator { nome, chave, _ -> tentativasDeLogin.incrementAndGet(); nome == "teste" && chave.encoded.contentEquals(usuario.public.encoded) } channelFactories = listOf(object : org.apache.sshd.common.channel.ChannelFactory { override fun getName() = "session" override fun createChannel(session: org.apache.sshd.common.session.Session): Channel = object : ChannelSession() { @@ -79,6 +81,7 @@ class SshFixtureServer(private val cairNoMeio: Boolean = false) : AutoCloseable start() } val porta: Int get() = servidor.port + val sessoesAbertas: Int get() = servidor.activeSessions.size private inner class EcoGravado : Command { private lateinit var entrada: InputStream diff --git a/app/src/test/java/com/keyflow/computador/sessao/BufferDeTextoTest.kt b/app/src/test/java/com/keyflow/computador/sessao/BufferDeTextoTest.kt new file mode 100644 index 00000000..757ff509 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/sessao/BufferDeTextoTest.kt @@ -0,0 +1,80 @@ +package com.keyflow.computador.sessao + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BufferDeTextoTest { + private fun BufferDeTexto.em(s: String) = alimentar(s.toByteArray(Charsets.UTF_8)) + + @Test fun removeAnsiOscApcEDcs() { + val b = BufferDeTexto() + b.em("\u001b[1;31mvermelho\u001b[0m \u001b]0;titulo\u0007ok \u001b]8;;http://x\u001b\\link\u001b]8;;\u001b\\ \u001b_Ga=T;AAAA\u001b\\fim \u001bP1;2qDCS\u001b\\!\n") + assertEquals(listOf("vermelho ok link fim !"), b.ultimasLinhas(5)) + } + + @Test fun retornoDeCarroEBackspaceSobrescrevem() { + val b = BufferDeTexto() + b.em("abcdef\rXY\n") + b.em("123\b\bZ\n") + assertEquals(listOf("XYcdef", "1Z3"), b.ultimasLinhas(5)) + } + + @Test fun apagamentoDeLinha() { + val b = BufferDeTexto() + b.em("0123456789\r\u001b[5C\u001b[K") // CSI C (cursor) é ignorado; K apaga do cursor (coluna 0 aqui) + assertEquals("", b.ultimasLinhas(1).firstOrNull() ?: "") + val c = BufferDeTexto() + c.em("progresso 50%\r\u001b[2Kpronto\n") + assertEquals(listOf("pronto"), c.ultimasLinhas(3)) + } + + @Test fun utf8PartidoEntrePacotes() { + val b = BufferDeTexto() + val bytes = "ação 😀 é\n".toByteArray() + for (byte in bytes) b.alimentar(byteArrayOf(byte)) + assertEquals(listOf("ação 😀 é").map { it.codePoints().toArray().toList() }, b.ultimasLinhas(2).map { it.codePoints().toArray().toList() }) + } + + @Test fun escapePartidoEntrePacotes() { + val b = BufferDeTexto() + for (parte in listOf("a\u001b", "[3", "1mb\u001b]0;ti", "tulo\u0007c\n")) b.em(parte) + assertEquals(listOf("abc"), b.ultimasLinhas(2)) + } + + @Test fun naoCresceAlemDoTeto() { + val b = BufferDeTexto(maxLinhas = 100) + repeat(1_000) { b.em("linha $it\n") } + assertEquals(100, b.quantidadeDeLinhas()) + assertEquals("linha 999", b.ultimasLinhas(1).single()) + val larga = BufferDeTexto(maxColunas = 50) + larga.em("x".repeat(500) + "\n") + assertEquals(50, larga.ultimasLinhas(1).single().length) + } + + @Test fun tetoPadraoESaoCincoMilLinhasPorQuatroMilColunas() { + assertEquals(5_000, BufferDeTexto.MAX_LINHAS) + assertEquals(4_096, BufferDeTexto.MAX_COLUNAS) + } + + @Test fun lixoBinarioNaoLanca() { + val r = java.util.Random(3) + val b = BufferDeTexto() + repeat(500) { b.alimentar(ByteArray(r.nextInt(200)).also(r::nextBytes)) } + b.texto() + } + + @Test fun neverGoesToDisk() { + val tmp = File(System.getProperty("java.io.tmpdir")!!) + val antes = tmp.list()?.toSet().orEmpty() + val b = BufferDeTexto() + repeat(2_000) { b.em("linha $it\n") } + b.texto() + assertEquals("o buffer não pode criar arquivos", antes, tmp.list()?.toSet().orEmpty()) + val fonte = File("src/main/java/com/keyflow/computador/sessao/BufferDeTexto.kt").readText() + for (proibido in listOf("java.io.File", "FileOutputStream", "FileWriter", "RandomAccessFile", "java.nio.file")) assertFalse(proibido, fonte.contains(proibido)) + assertTrue(fonte.contains("ByteArrayOutputStream")) + } +} diff --git a/app/src/test/java/com/keyflow/computador/sessao/Falsos.kt b/app/src/test/java/com/keyflow/computador/sessao/Falsos.kt new file mode 100644 index 00000000..47228ca9 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/sessao/Falsos.kt @@ -0,0 +1,55 @@ +package com.keyflow.computador.sessao + +import com.keyflow.computador.Host +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.computador.ssh.ConexaoSsh +import com.keyflow.computador.ssh.DestinoSsh +import com.keyflow.computador.ssh.EventoConexao +import com.keyflow.computador.ssh.PtyInfo +import com.keyflow.computador.ssh.ResultadoConexao +import com.keyflow.computador.ssh.ResultadoTeste +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow + +val HOST = Host("a1b2c3d4", "dev", "dev.example.org", 22, "yan") + +/** Uma conexão de mentira: o teste decide o resultado e quando a saída acaba. */ +class ConexaoFalsa(val resultado: ResultadoConexao = ResultadoConexao.Aberta, val eventosNaConexao: List = emptyList()) : ConexaoSsh { + private val _eventos = MutableSharedFlow(replay = 8, extraBufferCapacity = 64) + override val eventos: SharedFlow = _eventos + val canal = Channel(Channel.UNLIMITED) + override val saida: Flow = canal.receiveAsFlow() + val escritos = mutableListOf() + var fechou = false + var tamanho: PtyInfo? = null + + override suspend fun conectar(destino: DestinoSsh, pty: PtyInfo): ResultadoConexao { + eventosNaConexao.forEach { _eventos.emit(it) } + if (resultado is ResultadoConexao.Falhou) _eventos.emit(EventoConexao.Caiu(resultado.causa)) + return resultado + } + override suspend fun testar(destino: DestinoSsh): ResultadoTeste = ResultadoTeste.Falhou(CausaSsh.PROTOCOLO) + override suspend fun escrever(bytes: ByteArray) { escritos += bytes } + override suspend fun redimensionar(pty: PtyInfo) { tamanho = pty } + override suspend fun fechar() { fechou = true; canal.close() } + + /** O servidor some: a saída acaba depois de um `Caiu`. */ + suspend fun cair(causa: CausaSsh = CausaSsh.REDE) { _eventos.emit(EventoConexao.Caiu(causa)); canal.close() } +} + +class DependenciasFalsas(private val relogio: () -> Long) : DependenciasDeSessao { + val fila = ArrayDeque() + val criadas = mutableListOf() + var destinos = 0 + var biometriaForaDaJanela = false + override val rede: MutableStateFlow = MutableStateFlow(true) + override fun novaConexao(): ConexaoSsh = (if (fila.isEmpty()) ConexaoFalsa() else fila.removeFirst()).also { criadas += it } + override suspend fun destino(host: Host, dono: DonoDaSessao): DestinoSsh { destinos++; return DestinoSsh(host) } + override fun precisaDoUsuario(host: Host) = biometriaForaDaJanela + override fun agoraMs() = relogio() +} diff --git a/app/src/test/java/com/keyflow/computador/sessao/GerenciadorDeSessoesTest.kt b/app/src/test/java/com/keyflow/computador/sessao/GerenciadorDeSessoesTest.kt new file mode 100644 index 00000000..a56a8332 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/sessao/GerenciadorDeSessoesTest.kt @@ -0,0 +1,152 @@ +package com.keyflow.computador.sessao + +import com.keyflow.computador.Host +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GerenciadorDeSessoesTest { + private fun TestScope.gerenciador(dep: DependenciasFalsas) = GerenciadorDeSessoes(backgroundScope, dep) + + private fun h(n: Int) = Host("%08x".format(n), "h$n", "x.org", 22, "u") + + private fun aberta(r: ResultadoAbertura) = (r as ResultadoAbertura.Aberta).sessao + + @Test fun nonaSessaoERecusadaComOMotivo() = runTest { + val g = gerenciador(DependenciasFalsas { currentTime }) + repeat(8) { aberta(g.abrir(h(it), DonoDaSessao.USUARIO)) } + assertEquals(ResultadoAbertura.Recusada(MotivoRecusa.LIMITE_TOTAL), g.abrir(h(9), DonoDaSessao.USUARIO)) + assertTrue(MotivoRecusa.LIMITE_TOTAL.frase.contains("8")) + } + + @Test fun quartaSessaoDoAgenteERecusadaMasDoUsuarioNao() = runTest { + val g = gerenciador(DependenciasFalsas { currentTime }) + repeat(3) { aberta(g.abrir(h(it), DonoDaSessao.AGENTE)) } + assertEquals(ResultadoAbertura.Recusada(MotivoRecusa.LIMITE_DO_AGENTE), g.abrir(h(4), DonoDaSessao.AGENTE)) + aberta(g.abrir(h(5), DonoDaSessao.USUARIO)) + } + + @Test fun agenteOciosoFechaEUsuarioNao() = runTest { + val dep = DependenciasFalsas { currentTime } + val g = gerenciador(dep) + val a = aberta(g.abrir(h(1), DonoDaSessao.AGENTE)) + val u = aberta(g.abrir(h(2), DonoDaSessao.USUARIO)) + runCurrent() + advanceTimeBy(9 * 60_000L); runCurrent() + assertEquals(EstadoSessao.Aberta, a.estado.value) + advanceTimeBy(2 * 60_000L); runCurrent() + assertEquals(EstadoSessao.Encerrada(MotivoEncerramento.OCIOSIDADE), a.estado.value) + advanceTimeBy(3_600_000L); runCurrent() + assertEquals(EstadoSessao.Aberta, u.estado.value) + } + + @Test fun atividadeAdiaAOciosidadeDoAgente() = runTest { + val dep = DependenciasFalsas { currentTime } + val g = gerenciador(dep) + val a = aberta(g.abrir(h(1), DonoDaSessao.AGENTE)) + runCurrent() + advanceTimeBy(9 * 60_000L); runCurrent() + a.escrever("ls".toByteArray()) + advanceTimeBy(9 * 60_000L); runCurrent() + assertEquals(EstadoSessao.Aberta, a.estado.value) + } + + @Test fun primeiraEUltimaSessaoEmitemEventos() = runTest { + val dep = DependenciasFalsas { currentTime } + val g = gerenciador(dep) + val vistos = mutableListOf() + backgroundScope.launch { g.eventos.collect { vistos += it } } + runCurrent() + val a = aberta(g.abrir(h(1), DonoDaSessao.USUARIO)); runCurrent() + val b = aberta(g.abrir(h(2), DonoDaSessao.USUARIO)); runCurrent() + assertEquals(listOf(EventoGerenciador.PrimeiraSessao), vistos) + g.fechar(a.id); runCurrent() + assertEquals(listOf(EventoGerenciador.PrimeiraSessao), vistos) + g.fechar(b.id); runCurrent() + assertEquals(listOf(EventoGerenciador.PrimeiraSessao, EventoGerenciador.SemSessoes), vistos) + assertTrue(g.resumos.value.isEmpty()) + } + + @Test fun fecharDoHostEDoAgenteSoAfetamOQuePediram() = runTest { + val g = gerenciador(DependenciasFalsas { currentTime }) + val a = aberta(g.abrir(h(1), DonoDaSessao.USUARIO)) + val b = aberta(g.abrir(h(1), DonoDaSessao.AGENTE)) + val c = aberta(g.abrir(h(2), DonoDaSessao.AGENTE)) + runCurrent() + g.fecharDoAgente(); runCurrent() + assertEquals(EstadoSessao.Aberta, a.estado.value) + assertEquals(EstadoSessao.Encerrada(MotivoEncerramento.AGENTE_PARADO), b.estado.value) + assertEquals(EstadoSessao.Encerrada(MotivoEncerramento.AGENTE_PARADO), c.estado.value) + g.fecharDoHost(h(1).id); runCurrent() + assertEquals(EstadoSessao.Encerrada(MotivoEncerramento.HOST_REMOVIDO), a.estado.value) + assertTrue(g.resumos.value.isEmpty()) + } + + @Test fun compartilharMostraAoAgenteSoAQueOUsuarioLiberou() = runTest { + val g = gerenciador(DependenciasFalsas { currentTime }) + val u1 = aberta(g.abrir(h(1), DonoDaSessao.USUARIO)) + aberta(g.abrir(h(2), DonoDaSessao.USUARIO)) + val ag = aberta(g.abrir(h(3), DonoDaSessao.AGENTE)) + assertEquals(listOf(ag.id), g.sessoesVisiveisAoAgente().map { it.id }) + g.compartilhar(u1.id, true) + assertEquals(setOf(ag.id, u1.id), g.sessoesVisiveisAoAgente().map { it.id }.toSet()) + assertTrue(g.resumos.value.first { it.id == u1.id }.compartilhada) + } + + @Test fun resumoNaoCarregaHostNemTexto() { + val campos = SessaoResumo::class.java.declaredFields.map { it.name } + assertTrue(campos.toString(), campos.none { it in setOf("host", "usuario", "texto", "endereco", "buffer") }) + } + + @Test fun sessaoQueFalhouContinuaNaListaAteFecharENaoContaComoViva() = runTest { + val dep = DependenciasFalsas { currentTime } + dep.fila += ConexaoFalsa(com.keyflow.computador.ssh.ResultadoConexao.Falhou(com.keyflow.computador.ssh.CausaSsh.RECUSADA)) + val g = gerenciador(dep) + val vistos = mutableListOf() + backgroundScope.launch { g.eventos.collect { vistos += it } } + runCurrent() + val s = aberta(g.abrir(h(1), DonoDaSessao.USUARIO)); runCurrent() + assertTrue(s.estado.value is EstadoSessao.Falhou) + assertEquals(1, g.resumos.value.size) + assertEquals(listOf(EventoGerenciador.PrimeiraSessao, EventoGerenciador.SemSessoes), vistos) + } + + @Test fun abrirEFecharAoMesmoTempoDeixaOResumoIgualAoEstadoReal() { + // Threads de verdade: o `runTest` seria sequencial e esconderia a corrida. + val relogio = System::currentTimeMillis + val escopo = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Default) + try { + val g = GerenciadorDeSessoes(escopo, DependenciasFalsas { relogio() }, maxTotal = 1_000) + val pool = java.util.concurrent.Executors.newFixedThreadPool(8) + val abertas = java.util.concurrent.ConcurrentLinkedQueue() + val tarefas = (0 until 8).map { t -> + pool.submit { + repeat(150) { i -> + val r = g.abrir(Host("%08x".format(t * 1000 + i), "h", "x.org", 22, "u"), DonoDaSessao.USUARIO) + if (r is ResultadoAbertura.Aberta) { + if (i % 2 == 0) kotlinx.coroutines.runBlocking { g.fechar(r.sessao.id) } else abertas += r.sessao.id + } + } + } + } + tarefas.forEach { it.get(60, java.util.concurrent.TimeUnit.SECONDS) } + pool.shutdown() + Thread.sleep(300) + g.compartilhar(abertas.first(), true) // uma última publicação, já sem concorrência + val esperado = abertas.toSet() + assertEquals(esperado, g.resumos.value.map { it.id }.toSet()) + assertEquals(esperado.size, g.resumos.value.size) + } finally { + escopo.cancel() + } + } +} diff --git a/app/src/test/java/com/keyflow/computador/sessao/ReconexaoTest.kt b/app/src/test/java/com/keyflow/computador/sessao/ReconexaoTest.kt new file mode 100644 index 00000000..cab4d227 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/sessao/ReconexaoTest.kt @@ -0,0 +1,29 @@ +package com.keyflow.computador.sessao + +import com.keyflow.computador.ssh.CausaSsh +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReconexaoTest { + @Test fun backoffUmDoisQuatroOitoDezesseisETrinta() { + assertEquals(listOf(1L, 2, 4, 8, 16, 30, 30, 30, 30, 30).map { it * 1000 }, (1..10).map { Reconexao.espera(it, 0)!! }) + } + + @Test fun decimaPrimeiraTentativaNaoExiste() = assertNull(Reconexao.espera(11, 0)) + + @Test fun dezMinutosDesdeAQuedaAcabam() { + assertEquals(30_000L, Reconexao.espera(7, 570_000)) + assertNull(Reconexao.espera(7, 571_000)) + } + + @Test fun tentativaInvalida() { assertNull(Reconexao.espera(0, 0)); assertNull(Reconexao.espera(-1, 0)) } + + @Test fun soReconectaPorRedeOuServidor() { + for (c in listOf(CausaSsh.REDE, CausaSsh.TEMPO, CausaSsh.RECUSADA, CausaSsh.DESCONECTADO, CausaSsh.DNS)) assertTrue("$c", Reconexao.reconectavel(c)) + for (c in listOf(CausaSsh.AUTENTICACAO, CausaSsh.CHAVE_DO_SERVIDOR_MUDOU, CausaSsh.SERVIDOR_NAO_CONFIADO, CausaSsh.BIOMETRIA, CausaSsh.BIOMETRIA_INVALIDADA, CausaSsh.CHAVE_NAO_ACEITA, CausaSsh.SEM_ALGORITMO)) + assertFalse("$c", Reconexao.reconectavel(c)) + } +} diff --git a/app/src/test/java/com/keyflow/computador/sessao/SessaoTerminalTest.kt b/app/src/test/java/com/keyflow/computador/sessao/SessaoTerminalTest.kt new file mode 100644 index 00000000..827cbce1 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/sessao/SessaoTerminalTest.kt @@ -0,0 +1,192 @@ +package com.keyflow.computador.sessao + +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.computador.ssh.EventoConexao +import com.keyflow.computador.ssh.PtyInfo +import com.keyflow.computador.ssh.ResultadoConexao +import com.keyflow.computador.ImpressaoDigital +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessaoTerminalTest { + private class Tela : PortaDoEmulador { + val recebido = java.io.ByteArrayOutputStream() + override fun escreverNaTela(bytes: ByteArray) { recebido.write(bytes) } + override fun tamanhoAtual() = PtyInfo(80, 24) + } + + private fun TestScope.nova(dep: DependenciasFalsas, tela: Tela? = null) = + SessaoTerminal("s1", HOST, DonoDaSessao.USUARIO, dep, backgroundScope, porta = tela) + + /** `advanceUntilIdle` ignora tarefas do `backgroundScope`; a sessão vive nele, então avança o relógio por cima. */ + private suspend fun TestScope.avancarTudo() { runCurrent(); advanceTimeBy(3_600_000); runCurrent() } + + @Test fun fluxoNormalPassaPorAutenticandoEAbertaERecebeSaida() = runTest { + val dep = DependenciasFalsas { currentTime } + val conn = ConexaoFalsa(eventosNaConexao = listOf(EventoConexao.Autenticando)).also { dep.fila += it } + val tela = Tela() + val s = nova(dep, tela) + val vistos = mutableListOf() + backgroundScope.launch { s.estado.collect { vistos += it } } + s.iniciar(PtyInfo(80, 24)) + runCurrent() + assertEquals(EstadoSessao.Aberta, s.estado.value) + assertTrue(EstadoSessao.Autenticando in vistos || vistos.contains(EstadoSessao.Aberta)) + conn.canal.send("olá\n".toByteArray()) + runCurrent() + assertEquals("olá", s.buffer.ultimasLinhas(1).single()) + assertEquals("olá\n", String(tela.recebido.toByteArray())) + s.escrever("ls".toByteArray()) + assertEquals("ls", String(conn.escritos.single())) + assertTrue(s.transicoesImpossiveis.isEmpty()) + } + + @Test fun quedaNoMeioReconectaEPoeASeparadoraNoBufferENaTela() = runTest { + val dep = DependenciasFalsas { currentTime } + val primeira = ConexaoFalsa().also { dep.fila += it } + val segunda = ConexaoFalsa().also { dep.fila += it } + val tela = Tela() + val s = nova(dep, tela) + s.iniciar(PtyInfo(80, 24)) + runCurrent() + primeira.canal.send("antes\n".toByteArray()) + runCurrent() + primeira.cair(CausaSsh.REDE) + runCurrent() + assertEquals(EstadoSessao.Reconectando(1), s.estado.value) + advanceTimeBy(999); runCurrent() + assertEquals(EstadoSessao.Reconectando(1), s.estado.value) + advanceTimeBy(2); runCurrent() + assertEquals(EstadoSessao.Aberta, s.estado.value) + assertEquals(2, dep.destinos) + assertEquals(listOf("antes", "", "— conexão reiniciada —"), s.buffer.ultimasLinhas(5)) + assertTrue(String(tela.recebido.toByteArray()).contains("— conexão reiniciada —")) + segunda.canal.send("depois\n".toByteArray()); runCurrent() + assertEquals("depois", s.buffer.ultimasLinhas(1).single()) + assertTrue(s.transicoesImpossiveis.isEmpty()) + } + + @Test fun dezTentativasFalhasViramFalhouEAOnzenaNaoExiste() = runTest { + val dep = DependenciasFalsas { currentTime } + dep.fila += ConexaoFalsa() + repeat(10) { dep.fila += ConexaoFalsa(ResultadoConexao.Falhou(CausaSsh.REDE)) } + val s = nova(dep) + s.iniciar(PtyInfo(80, 24)) + runCurrent() + dep.criadas.first().cair(CausaSsh.REDE) + avancarTudo() + assertEquals(EstadoSessao.Falhou(CausaSsh.REDE), s.estado.value) + assertEquals("1 inicial + 10 tentativas", 11, dep.criadas.size) + assertTrue(s.transicoesImpossiveis.isEmpty()) + } + + @Test fun primeiraConexaoQueFalhaNaoSeRepete() = runTest { + val dep = DependenciasFalsas { currentTime } + dep.fila += ConexaoFalsa(ResultadoConexao.Falhou(CausaSsh.RECUSADA)) + val s = nova(dep) + s.iniciar(PtyInfo(80, 24)); avancarTudo() + assertEquals(EstadoSessao.Falhou(CausaSsh.RECUSADA), s.estado.value) + assertEquals(1, dep.criadas.size) + } + + @Test fun autenticacaoRecusadaNaReconexaoNaoTentaDeNovo() = runTest { + val dep = DependenciasFalsas { currentTime } + dep.fila += ConexaoFalsa(); dep.fila += ConexaoFalsa(ResultadoConexao.Falhou(CausaSsh.AUTENTICACAO)) + val s = nova(dep) + s.iniciar(PtyInfo(80, 24)); runCurrent() + dep.criadas.first().cair(); avancarTudo() + assertEquals(EstadoSessao.Falhou(CausaSsh.AUTENTICACAO), s.estado.value) + assertEquals(2, dep.criadas.size) + } + + @Test fun biometriaForaDaJanelaNaoReconectaSozinhaEDentroDaJanelaReconecta() = runTest { + val dep = DependenciasFalsas { currentTime } + dep.fila += ConexaoFalsa(); dep.fila += ConexaoFalsa() + val s = nova(dep) + s.iniciar(PtyInfo(80, 24)); runCurrent() + dep.biometriaForaDaJanela = true + dep.criadas.first().cair(); advanceTimeBy(3_600_000); runCurrent() + assertEquals(EstadoSessao.PrecisaDoUsuario, s.estado.value) + assertEquals("nenhuma tentativa automática", 1, dep.criadas.size) + // O usuário tocou em "reconectar": a janela foi aberta pelo prompt dele. + dep.biometriaForaDaJanela = false + s.retomar(); avancarTudo() + assertEquals(EstadoSessao.Aberta, s.estado.value) + assertEquals(2, dep.criadas.size) + assertTrue(s.transicoesImpossiveis.isEmpty()) + } + + @Test fun dentroDaJanelaReconectaSemPrompt() = runTest { + val dep = DependenciasFalsas { currentTime } + dep.fila += ConexaoFalsa(); dep.fila += ConexaoFalsa() + val s = nova(dep) + s.iniciar(PtyInfo(80, 24)); runCurrent() + dep.biometriaForaDaJanela = false + dep.criadas.first().cair(); advanceTimeBy(1_500); runCurrent() + assertEquals(EstadoSessao.Aberta, s.estado.value) + } + + @Test fun semRedeEsperaARedeVoltar() = runTest { + val dep = DependenciasFalsas { currentTime } + dep.fila += ConexaoFalsa(); dep.fila += ConexaoFalsa() + val s = nova(dep) + s.iniciar(PtyInfo(80, 24)); runCurrent() + dep.rede.value = false + dep.criadas.first().cair(); advanceTimeBy(600_000); runCurrent() + assertEquals(EstadoSessao.Reconectando(1), s.estado.value) + assertEquals(1, dep.criadas.size) + dep.rede.value = true + advanceTimeBy(1_100); runCurrent() + assertEquals(EstadoSessao.Aberta, s.estado.value) + } + + @Test fun fecharEncerraACexaoEMarcaOMotivo() = runTest { + val dep = DependenciasFalsas { currentTime } + val conn = ConexaoFalsa().also { dep.fila += it } + val s = nova(dep) + s.iniciar(PtyInfo(80, 24)); runCurrent() + s.fechar() + runCurrent() + assertEquals(EstadoSessao.Encerrada(MotivoEncerramento.USUARIO), s.estado.value) + assertTrue(conn.fechou) + assertTrue(s.transicoesImpossiveis.isEmpty()) + } + + @Test fun sequenciaAleatoriaNuncaViolaAMaquina() = runTest { + val r = java.util.Random(99) + repeat(60) { rodada -> + val dep = DependenciasFalsas { currentTime } + repeat(14) { + dep.fila += when (r.nextInt(4)) { + 0 -> ConexaoFalsa(ResultadoConexao.Falhou(CausaSsh.entries[r.nextInt(CausaSsh.entries.size)])) + 1 -> ConexaoFalsa(eventosNaConexao = listOf(EventoConexao.AguardandoServidor("ssh-ed25519", ImpressaoDigital.de(byteArrayOf(1))), EventoConexao.Autenticando)) + else -> ConexaoFalsa() + } + } + val s = nova(dep) + s.iniciar(PtyInfo(80, 24)) + repeat(6) { + runCurrent() + when (r.nextInt(4)) { + 0 -> dep.criadas.lastOrNull()?.cair(CausaSsh.entries[r.nextInt(CausaSsh.entries.size)]) + 1 -> dep.rede.value = r.nextBoolean() + 2 -> dep.biometriaForaDaJanela = r.nextBoolean() + else -> if (s.estado.value == EstadoSessao.PrecisaDoUsuario) { dep.biometriaForaDaJanela = false; s.retomar() } + } + advanceTimeBy(r.nextLong(0, 40_000)) + } + if (r.nextBoolean()) s.fechar() + avancarTudo() + assertTrue("rodada $rodada: ${s.transicoesImpossiveis}", s.transicoesImpossiveis.isEmpty()) + } + } +} diff --git a/app/src/test/java/com/keyflow/computador/ssh/ConexaoCbsshTest.kt b/app/src/test/java/com/keyflow/computador/ssh/ConexaoCbsshTest.kt new file mode 100644 index 00000000..246c116d --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/ssh/ConexaoCbsshTest.kt @@ -0,0 +1,257 @@ +package com.keyflow.computador.ssh + +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.Host +import com.keyflow.computador.ImpressaoDigital +import com.keyflow.computador.RepositorioKnownHostsEmMemoria +import com.keyflow.computador.TesteSsh +import com.keyflow.computador.chaves.AssinadorSoftware +import com.keyflow.computador.chaves.ImportadorDeChave +import com.keyflow.computador.chaves.MaterialPrivado +import com.keyflow.computador.chaves.Resultado +import com.keyflow.computador.fixture.SshFixtureServer +import java.io.ByteArrayOutputStream +import java.net.ServerSocket +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.connectbot.sshlib.SshClientConfig +import org.connectbot.sshlib.transport.TransportFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Conexão real do cbssh contra o `SshFixtureServer` (MINA em processo), na JVM. */ +class ConexaoCbsshTest { + private val sentinela = "SENTINELA-senha-8841" + + private class Interacao(var confirma: Boolean = true, var senha: String? = null) : InteracaoComUsuario { + var confirmacoes = 0 + var pedidosDeSenha = 0 + var impressaoVista: ImpressaoDigital? = null + val aguardando = CompletableDeferred() + var segurar: CompletableDeferred? = null + override suspend fun confirmarServidor(host: String, porta: Int, algoritmo: String, impressao: ImpressaoDigital): Boolean { + confirmacoes++; impressaoVista = impressao; aguardando.complete(Unit) + return segurar?.await() ?: confirma + } + override suspend fun pedirSenha(): String? { pedidosDeSenha++; return senha } + override suspend fun pedirCodigo(nome: String, instrucao: String, perguntas: List, eco: List): List? = null + } + + private fun host(s: SshFixtureServer, usuario: String = "senha", auth: AutenticacaoHost = AutenticacaoHost.Senha) = + Host("a1b2c3d4", "fixture", "127.0.0.1", s.porta, usuario, auth) + + private val escopo = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private fun conexao(repo: RepositorioKnownHostsEmMemoria, i: Interacao, tempo: Long = 30_000, ajustar: (SshClientConfig.Builder) -> Unit = {}) = + ConexaoCbssh(repo, i, escopo, tempoTrocaDeChavesMs = tempo, ajustar = ajustar) + + private val pty = PtyInfo(100, 30, 800, 480) + + private suspend fun lerAtePrompt(c: ConexaoSsh): ByteArray = withTimeout(15_000) { + val saida = ByteArrayOutputStream() + val fim = SshFixtureServer.PROMPT + c.saida.first { saida.write(it); saida.toByteArray().let { b -> b.size >= fim.size && b.takeLast(fim.size).toByteArray().contentEquals(fim) } } + saida.toByteArray() + } + + private fun assinadorDe(pem: String): AssinadorSoftware { + val imp = (ImportadorDeChave().importar(pem.toCharArray(), null) as Resultado.Ok).valor + return AssinadorSoftware(imp.publica) { MaterialPrivado(imp.material.formato, imp.material.bytes.copyOf()) } + } + + @Test fun conectaPorSenhaEServidorNovoPedeConfirmacao() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val i = Interacao() + val repo = RepositorioKnownHostsEmMemoria() + val c = conexao(repo, i) + assertEquals(ResultadoConexao.Aberta, c.conectar(DestinoSsh(host(s), senhaLembrada = SshFixtureServer.SENHA_FALSA), pty)) + assertEquals(1, i.confirmacoes) + assertEquals(s.hostSha256, i.impressaoVista!!.texto) + assertTrue(String(lerAtePrompt(c)).endsWith("fixture$ ")) + assertEquals(SshFixtureServer.Pty("xterm-256color", 100, 30, 800, 480), s.ptys.poll(5, TimeUnit.SECONDS)) + assertEquals("COLORTERM" to "truecolor", s.ambientes.poll(5, TimeUnit.SECONDS)) + c.fechar() + val c2 = conexao(repo, i) + assertEquals(ResultadoConexao.Aberta, c2.conectar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA), pty)) + assertEquals("conhecido: não pergunta de novo", 1, i.confirmacoes) + c2.fechar() + } + } + + @Test fun conectaPorChaveEd25519() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val c = conexao(RepositorioKnownHostsEmMemoria(), Interacao()) + assertEquals(ResultadoConexao.Aberta, c.conectar(DestinoSsh(host(s, "teste", AutenticacaoHost.Chave("k1")), assinador = assinadorDe(s.chaveUsuarioPem)), pty)) + assertTrue(String(lerAtePrompt(c)).endsWith("fixture$ ")) + c.fechar() + } + } + + @Test fun chaveQueOServidorNaoConheceNaoEAceita() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val c = conexao(RepositorioKnownHostsEmMemoria(), Interacao()) + val d = DestinoSsh(host(s, "teste", AutenticacaoHost.Chave("k1")), assinador = assinadorDe(TesteSsh.chave("ed25519"))) + assertEquals(ResultadoConexao.Falhou(CausaSsh.CHAVE_NAO_ACEITA), c.conectar(d, pty)) + } + } + + @Test fun senhaErradaEAutenticacao() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val c = conexao(RepositorioKnownHostsEmMemoria(), Interacao()) + val d = DestinoSsh(host(s), senhaLembrada = sentinela) + assertEquals(ResultadoConexao.Falhou(CausaSsh.AUTENTICACAO), c.conectar(d, pty)) + assertFalse(d.toString().contains(sentinela)) + assertFalse(c.eventos.first().toString().contains(sentinela)) + } + } + + @Test fun senhaPedidaAoUsuarioQuandoNaoLembrada() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val i = Interacao(senha = SshFixtureServer.SENHA_FALSA) + val c = conexao(RepositorioKnownHostsEmMemoria(), i) + assertEquals(ResultadoConexao.Aberta, c.conectar(DestinoSsh(host(s)), pty)) + assertEquals(1, i.pedidosDeSenha) + c.fechar() + } + } + + @Test fun servidorDesligadoERecusada() = runBlocking(Dispatchers.IO) { + val porta = ServerSocket(0).use { it.localPort } + val h = Host("a1b2c3d4", "x", "127.0.0.1", porta, "u") + assertEquals(ResultadoConexao.Falhou(CausaSsh.RECUSADA), conexao(RepositorioKnownHostsEmMemoria(), Interacao()).conectar(DestinoSsh(h), pty)) + } + + @Test fun hostInexistenteEDns() = runBlocking(Dispatchers.IO) { + val h = Host("a1b2c3d4", "x", "nome-que-nao-existe.invalid", 22, "u") + assertEquals(ResultadoConexao.Falhou(CausaSsh.DNS), conexao(RepositorioKnownHostsEmMemoria(), Interacao()).conectar(DestinoSsh(h), pty)) + } + + @Test fun nadaRespondeEmTempoComRelogioVirtual() = runTest { + val c = ConexaoCbssh(RepositorioKnownHostsEmMemoria(), Interacao(), backgroundScope, tempoTcpMs = 15_000) { it.transportFactory = TransportFactory { awaitCancellation() } } + val r = async { c.conectar(DestinoSsh(Host("a1b2c3d4", "x", "10.255.255.1", 22, "u")), pty) } + delay(14_000) + assertFalse(r.isCompleted) + delay(2_000) + assertEquals(ResultadoConexao.Falhou(CausaSsh.TEMPO), r.await()) + } + + @Test fun tcpTemQuinzeSegundosEATrocaDeChavesTrinta() = runTest { + // TCP que conecta (transporte falso de leitura eterna) e depois silêncio na troca de chaves: estoura aos 15 + 30 s, não aos 15. + val transporte = object : org.connectbot.sshlib.transport.Transport { + override suspend fun read(count: Int): ByteArray = awaitCancellation() + override suspend fun write(data: ByteArray) = Unit + override suspend fun close() = Unit + override val isConnected: Boolean = true + } + val c = ConexaoCbssh(RepositorioKnownHostsEmMemoria(), Interacao(), backgroundScope) { it.transportFactory = TransportFactory { transporte } } + val r = async { c.conectar(DestinoSsh(Host("a1b2c3d4", "x", "10.255.255.1", 22, "u")), pty) } + delay(16_000) + assertFalse("o TCP já conectou: 15 s não vale para a troca de chaves", r.isCompleted) + delay(30_000) + assertEquals(ResultadoConexao.Falhou(CausaSsh.TEMPO), r.await()) + } + + @Test fun cancelarNaConfirmacaoNaoTentaLogin() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val c = conexao(RepositorioKnownHostsEmMemoria(), Interacao(confirma = false)) + assertEquals(ResultadoConexao.Falhou(CausaSsh.SERVIDOR_NAO_CONFIADO), c.conectar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA), pty)) + assertEquals(0, s.tentativasDeLogin.get()) + } + } + + @Test fun servidorNovoSuspendeEOTempoNaoContaEnquantoOUsuarioDecide() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val i = Interacao().also { it.segurar = CompletableDeferred() } + val c = conexao(RepositorioKnownHostsEmMemoria(), i, tempo = 300) + val r = async { c.conectar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA), pty) } + i.aguardando.await() + delay(1_000) + assertFalse(r.isCompleted) + assertEquals(0, s.tentativasDeLogin.get()) + i.segurar!!.complete(true) + assertEquals(ResultadoConexao.Aberta, r.await()) + c.fechar() + } + } + + @Test fun chaveMudouBloqueiaSemChamarAutenticacao() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val repo = RepositorioKnownHostsEmMemoria() + val primeira = conexao(repo, Interacao()) + primeira.conectar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA), pty); primeira.fechar() + val login0 = s.tentativasDeLogin.get() + val real = repo.chavesConhecidas("127.0.0.1", s.porta).single() + val adulterado = RepositorioKnownHostsEmMemoria().also { it.confiar("127.0.0.1", s.porta, ChaveDoServidor(real.algoritmo, real.blob.copyOf().also { b -> b[b.size - 1] = (b.last() + 1).toByte() })) } + val i = Interacao(senha = "x") + assertEquals(ResultadoConexao.Falhou(CausaSsh.CHAVE_DO_SERVIDOR_MUDOU), conexao(adulterado, i).conectar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA), pty)) + assertEquals(login0, s.tentativasDeLogin.get()) + assertEquals(0, i.confirmacoes) + assertEquals(0, i.pedidosDeSenha) + } + } + + @Test fun sessaoDoAgenteNuncaConfiaSozinha() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val i = Interacao() + val d = DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA, confiarSozinho = false) + assertEquals(ResultadoConexao.Falhou(CausaSsh.SERVIDOR_PEDE_CONFIRMACAO), conexao(RepositorioKnownHostsEmMemoria(), i).conectar(d, pty)) + assertEquals(0, i.confirmacoes) + assertEquals(0, s.tentativasDeLogin.get()) + } + } + + @Test fun redimensionarChegaComoWindowChange() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val c = conexao(RepositorioKnownHostsEmMemoria(), Interacao()) + c.conectar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA), pty) + c.redimensionar(PtyInfo(120, 40, 960, 640)) + assertEquals(SshFixtureServer.Tamanho(120, 40, 960, 640), s.tamanhos.poll(5, TimeUnit.SECONDS)) + c.fechar() + } + } + + @Test fun testarNaoPedeOShell() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val r = conexao(RepositorioKnownHostsEmMemoria(), Interacao()).testar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA)) as ResultadoTeste.Ok + assertEquals("password", r.metodo) + assertEquals(s.hostSha256, r.impressao.texto) + assertTrue(r.algoritmoDoServidor.isNotEmpty() && r.latenciaMs >= 0) + assertEquals(null, s.ptys.poll(500, TimeUnit.MILLISECONDS)) + } + } + + @Test fun servidorQueCaiNoMeioViraDesconectado() = runBlocking(Dispatchers.IO) { + SshFixtureServer(cairNoMeio = true).use { s -> + val c = conexao(RepositorioKnownHostsEmMemoria(), Interacao()) + assertEquals(ResultadoConexao.Aberta, c.conectar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA), pty)) + withTimeout(15_000) { c.eventos.first { it is EventoConexao.Caiu } } + c.fechar() + } + } + + @Test fun cancelarACoroutineFechaOSocket() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val i = Interacao().also { it.segurar = CompletableDeferred() } + val r = async { conexao(RepositorioKnownHostsEmMemoria(), i).conectar(DestinoSsh(host(s), SshFixtureServer.SENHA_FALSA), pty) } + i.aguardando.await() + assertTrue(s.sessoesAbertas >= 1) + r.cancel() + withTimeout(10_000) { while (s.sessoesAbertas > 0) delay(50) } + assertEquals(0, s.sessoesAbertas) + } + } +} diff --git a/app/src/test/java/com/keyflow/computador/ssh/MapaDeErrosTest.kt b/app/src/test/java/com/keyflow/computador/ssh/MapaDeErrosTest.kt new file mode 100644 index 00000000..750d6349 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/ssh/MapaDeErrosTest.kt @@ -0,0 +1,52 @@ +package com.keyflow.computador.ssh + +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class MapaDeErrosTest { + @Test fun excecoesDeRede() { + assertEquals(CausaSsh.DNS, MapaDeErros.causaDe(UnknownHostException("x"))) + assertEquals(CausaSsh.RECUSADA, MapaDeErros.causaDe(ConnectException("Connection refused"))) + assertEquals(CausaSsh.TEMPO, MapaDeErros.causaDe(SocketTimeoutException())) + assertEquals(CausaSsh.REDE, MapaDeErros.causaDe(NoRouteToHostException())) + assertEquals(CausaSsh.REDE, MapaDeErros.causaDe(SocketException("Network is unreachable"))) + } + + @Test fun olhaACadeiaDeCausas() { + assertEquals(CausaSsh.DNS, MapaDeErros.causaDe(RuntimeException("envolvido", RuntimeException("meio", UnknownHostException())))) + assertEquals(CausaSsh.PROTOCOLO, MapaDeErros.causaDe(IllegalStateException("qualquer"))) + } + + @Test fun nomesDeClassesDeBibliotecas() { + class ConnectTimeoutException : Exception() + class UnresolvedAddressException : Exception() + assertEquals(CausaSsh.TEMPO, MapaDeErros.causaDe(ConnectTimeoutException())) + assertEquals(CausaSsh.DNS, MapaDeErros.causaDe(UnresolvedAddressException())) + } + + @Test fun transporteDoCbsshEClassificadoPeloTextoSemExporloNaCausa() { + class TransportException(m: String) : Exception(m) + assertEquals(CausaSsh.RECUSADA, MapaDeErros.causaDe(TransportException("Failed to connect to x:22. Tried 1 addresses. Errors: Connection refused"))) + assertEquals(CausaSsh.DNS, MapaDeErros.causaDe(TransportException("Could not resolve host: x"))) + assertEquals(CausaSsh.TEMPO, MapaDeErros.causaDe(TransportException("Connect timed out"))) + assertEquals(CausaSsh.REDE, MapaDeErros.causaDe(TransportException("Network is unreachable"))) + assertEquals(CausaSsh.PROTOCOLO, MapaDeErros.causaDe(TransportException("Not connected"))) + } + + @Test fun causaCiclicaNaoTravaENaoUsaAMensagem() { + val a = RuntimeException("segredo-na-mensagem") + a.initCause(RuntimeException("b", a)) + assertEquals(CausaSsh.PROTOCOLO, MapaDeErros.causaDe(a)) + } + + @Test fun frasesNuncaVazamEntreCausas() { + assertEquals(null, CausaSsh.SERVIDOR_NAO_CONFIADO.frase) + assertFalse(CausaSsh.entries.filter { it != CausaSsh.SERVIDOR_NAO_CONFIADO }.any { it.frase.isNullOrBlank() }) + } +} diff --git a/app/src/test/java/com/keyflow/computador/ssh/VigiaTest.kt b/app/src/test/java/com/keyflow/computador/ssh/VigiaTest.kt new file mode 100644 index 00000000..b6999bb6 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/ssh/VigiaTest.kt @@ -0,0 +1,31 @@ +package com.keyflow.computador.ssh + +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class VigiaTest { + @Test fun tresFalhasSeguidasDerrubamEmNoveSegundosDeTresIntervalos() = runTest { + val v = Vigia(30_000, 3) { false } + v.vigiar() + assertEquals(90_000L, currentTime) + } + + @Test fun respostaZeraAContagem() = runTest { + val respostas = ArrayDeque(listOf(false, false, true, false, false, false)) + Vigia(30_000, 3) { respostas.removeFirst() }.vigiar() + assertEquals(180_000L, currentTime) + } + + @Test fun servidorSemPingNuncaECondenado() = runTest { + val j = async { Vigia(30_000, 3) { null }.vigiar() } + advanceTimeBy(3_600_000) + assertFalse(j.isCompleted) + j.cancel() + } +} diff --git a/app/src/test/resources/computador/chaves/README.md b/app/src/test/resources/computador/chaves/README.md new file mode 100644 index 00000000..55132438 --- /dev/null +++ b/app/src/test/resources/computador/chaves/README.md @@ -0,0 +1,3 @@ +Chaves FALSAS geradas só para os testes (`ssh-keygen`, comentário `teste-falso`), nunca usadas em servidor algum. +Passphrase das chaves `*-frase`: `frase-falsa-123`. Cada `.pub` é a saída de referência do `ssh-keygen -y`. +`rsa1024` e `dsa*` existem para provar a recusa; `*-pem` e `ed25519-pkcs8` cobrem PKCS#1/SEC1/PKCS#8. diff --git a/app/src/test/resources/computador/chaves/dsa b/app/src/test/resources/computador/chaves/dsa new file mode 100644 index 00000000..6f609723 --- /dev/null +++ b/app/src/test/resources/computador/chaves/dsa @@ -0,0 +1,21 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH +NzAAAAgQDH47ePdqazOOX113+Elm89TSbG+VCRpt0KoP9gvmb1rmDahnV6e2uYYfnbPt5y +Qj/pFZnJUhkmRawUJw3ijhhymIlzNiG59dnrGIR2yDiCVlGhWEOs3sYCTAFxZozUXANLUF +Q6kDKhT2+/JWm4inZU6RqiugIO1mCG5g/57+EmnQAAABUA+RiAZO9kR1+n0MsTdduSGPzJ +ixUAAACAFIXdoLjGo9KyMBCLjclHmqsJvt/lv4UL7/7kbcQcNi0fps60BfqZSH1Ikgnp/+ +4JSTyzNGh7IhkHbufZWshixQ1FgKZkCDztUOLYrbghOFTwWRDhjwnNadk2uLvzk6plBEzw +Zpv7PDlMbaTEkAyf4d45K+Odcm4dH7Mp/vaZ4z0AAACAS/3kEJzsG5QSz2N+7yUYcrsLnV +V0hh7akN3gPC0nywAR7PRs7KKmSdLLox9VvtWd6orhy3yU50tjLSWBqi5NyxlwsGH6yG7v +ppfP9er89CTq/rEUe8aMOfQW8OtVTFkF1/rwKXeJZqvG5+XgJr0a/gxeS5mtshsCkA2Whr +4Qy0kAAAHo7io+S+4qPksAAAAHc3NoLWRzcwAAAIEAx+O3j3amszjl9dd/hJZvPU0mxvlQ +kabdCqD/YL5m9a5g2oZ1entrmGH52z7eckI/6RWZyVIZJkWsFCcN4o4YcpiJczYhufXZ6x +iEdsg4glZRoVhDrN7GAkwBcWaM1FwDS1BUOpAyoU9vvyVpuIp2VOkaoroCDtZghuYP+e/h +Jp0AAAAVAPkYgGTvZEdfp9DLE3Xbkhj8yYsVAAAAgBSF3aC4xqPSsjAQi43JR5qrCb7f5b ++FC+/+5G3EHDYtH6bOtAX6mUh9SJIJ6f/uCUk8szRoeyIZB27n2VrIYsUNRYCmZAg87VDi +2K24IThU8FkQ4Y8JzWnZNri785OqZQRM8Gab+zw5TG2kxJAMn+HeOSvjnXJuHR+zKf72me +M9AAAAgEv95BCc7BuUEs9jfu8lGHK7C51VdIYe2pDd4DwtJ8sAEez0bOyipknSy6MfVb7V +neqK4ct8lOdLYy0lgaouTcsZcLBh+shu76aXz/Xq/PQk6v6xFHvGjDn0FvDrVUxZBdf68C +l3iWarxufl4Ca9Gv4MXkuZrbIbApANloa+EMtJAAAAFQCv+qgrPntB/pj7Y7scxqpMLm6P +DwAAAAt0ZXN0ZS1mYWxzbwECAwQFBgc= +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/dsa-pem b/app/src/test/resources/computador/chaves/dsa-pem new file mode 100644 index 00000000..def72dd2 --- /dev/null +++ b/app/src/test/resources/computador/chaves/dsa-pem @@ -0,0 +1,12 @@ +-----BEGIN DSA PRIVATE KEY----- +MIIBuwIBAAKBgQDH47ePdqazOOX113+Elm89TSbG+VCRpt0KoP9gvmb1rmDahnV6 +e2uYYfnbPt5yQj/pFZnJUhkmRawUJw3ijhhymIlzNiG59dnrGIR2yDiCVlGhWEOs +3sYCTAFxZozUXANLUFQ6kDKhT2+/JWm4inZU6RqiugIO1mCG5g/57+EmnQIVAPkY +gGTvZEdfp9DLE3Xbkhj8yYsVAoGAFIXdoLjGo9KyMBCLjclHmqsJvt/lv4UL7/7k +bcQcNi0fps60BfqZSH1Ikgnp/+4JSTyzNGh7IhkHbufZWshixQ1FgKZkCDztUOLY +rbghOFTwWRDhjwnNadk2uLvzk6plBEzwZpv7PDlMbaTEkAyf4d45K+Odcm4dH7Mp +/vaZ4z0CgYBL/eQQnOwblBLPY37vJRhyuwudVXSGHtqQ3eA8LSfLABHs9GzsoqZJ +0sujH1W+1Z3qiuHLfJTnS2MtJYGqLk3LGXCwYfrIbu+ml8/16vz0JOr+sRR7xow5 +9Bbw61VMWQXX+vApd4lmq8bn5eAmvRr+DF5Lma2yGwKQDZaGvhDLSQIVAK/6qCs+ +e0H+mPtjuxzGqkwubo8P +-----END DSA PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/dsa-pem.pub b/app/src/test/resources/computador/chaves/dsa-pem.pub new file mode 100644 index 00000000..7c2244de --- /dev/null +++ b/app/src/test/resources/computador/chaves/dsa-pem.pub @@ -0,0 +1 @@ +ssh-dss AAAAB3NzaC1kc3MAAACBAMfjt492prM45fXXf4SWbz1NJsb5UJGm3Qqg/2C+ZvWuYNqGdXp7a5hh+ds+3nJCP+kVmclSGSZFrBQnDeKOGHKYiXM2Ibn12esYhHbIOIJWUaFYQ6zexgJMAXFmjNRcA0tQVDqQMqFPb78labiKdlTpGqK6Ag7WYIbmD/nv4SadAAAAFQD5GIBk72RHX6fQyxN125IY/MmLFQAAAIAUhd2guMaj0rIwEIuNyUeaqwm+3+W/hQvv/uRtxBw2LR+mzrQF+plIfUiSCen/7glJPLM0aHsiGQdu59layGLFDUWApmQIPO1Q4tituCE4VPBZEOGPCc1p2Ta4u/OTqmUETPBmm/s8OUxtpMSQDJ/h3jkr451ybh0fsyn+9pnjPQAAAIBL/eQQnOwblBLPY37vJRhyuwudVXSGHtqQ3eA8LSfLABHs9GzsoqZJ0sujH1W+1Z3qiuHLfJTnS2MtJYGqLk3LGXCwYfrIbu+ml8/16vz0JOr+sRR7xow59Bbw61VMWQXX+vApd4lmq8bn5eAmvRr+DF5Lma2yGwKQDZaGvhDLSQ== teste-falso diff --git a/app/src/test/resources/computador/chaves/dsa.pub b/app/src/test/resources/computador/chaves/dsa.pub new file mode 100644 index 00000000..7c2244de --- /dev/null +++ b/app/src/test/resources/computador/chaves/dsa.pub @@ -0,0 +1 @@ +ssh-dss AAAAB3NzaC1kc3MAAACBAMfjt492prM45fXXf4SWbz1NJsb5UJGm3Qqg/2C+ZvWuYNqGdXp7a5hh+ds+3nJCP+kVmclSGSZFrBQnDeKOGHKYiXM2Ibn12esYhHbIOIJWUaFYQ6zexgJMAXFmjNRcA0tQVDqQMqFPb78labiKdlTpGqK6Ag7WYIbmD/nv4SadAAAAFQD5GIBk72RHX6fQyxN125IY/MmLFQAAAIAUhd2guMaj0rIwEIuNyUeaqwm+3+W/hQvv/uRtxBw2LR+mzrQF+plIfUiSCen/7glJPLM0aHsiGQdu59layGLFDUWApmQIPO1Q4tituCE4VPBZEOGPCc1p2Ta4u/OTqmUETPBmm/s8OUxtpMSQDJ/h3jkr451ybh0fsyn+9pnjPQAAAIBL/eQQnOwblBLPY37vJRhyuwudVXSGHtqQ3eA8LSfLABHs9GzsoqZJ0sujH1W+1Z3qiuHLfJTnS2MtJYGqLk3LGXCwYfrIbu+ml8/16vz0JOr+sRR7xow59Bbw61VMWQXX+vApd4lmq8bn5eAmvRr+DF5Lma2yGwKQDZaGvhDLSQ== teste-falso diff --git a/app/src/test/resources/computador/chaves/ecdsa-p256 b/app/src/test/resources/computador/chaves/ecdsa-p256 new file mode 100644 index 00000000..c1683424 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ecdsa-p256 @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTR43Wu1MCy4Rh4PUJXiaZrQM83yTOm +q7sLFOJbcO+wQ9Rx0Iz7Wnm0VXRmE20UGX2ulZQx7/YmtXjkV33wFYdlAAAAqLEftJixH7 +SYAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNHjda7UwLLhGHg9 +QleJpmtAzzfJM6aruwsU4ltw77BD1HHQjPtaebRVdGYTbRQZfa6VlDHv9ia1eORXffAVh2 +UAAAAgUYvqoHm8P77MYu1X9QzWIQZNs3kLl4db6+QOMGFUjzQAAAALdGVzdGUtZmFsc28B +AgMEBQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/ecdsa-p256-frase b/app/src/test/resources/computador/chaves/ecdsa-p256-frase new file mode 100644 index 00000000..b282b4d5 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ecdsa-p256-frase @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDT7VY5rx +vlc609CW0SwQWNAAAAEAAAAAEAAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlz +dHAyNTYAAABBBGoVffHZ4pnkGbcYKo/VjOj0Cl5GVjJUMQ6MoUBnDka/nxwaQS48iuMeab +S3n9I/yUqUhcSA1OLHuW6NnyUYwwAAAACwMpIw4dTQJwG0Lgvy5DAYhhw65VApbltaZNGJ +HfQDlZfJ8dF6xIWfNtJ8URsqoychq11eMlBVBo+/ujLG8TzfJhEjzw7wNG2wyy52pG0PMI +CQ/vRCjKeCkQnqdQ5DXxuKNsKQ95CYY5iIiIVISvts5JZh4Wz/e4QDdsSrOqrkx7eMYdnE +jP05bQAp2G91AfFyrCo3ZBxH7Q7URdxgPe3D/+NlQlm5Z613ppYtejHy4Tw= +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/ecdsa-p256-frase.pub b/app/src/test/resources/computador/chaves/ecdsa-p256-frase.pub new file mode 100644 index 00000000..9cd6c3ca --- /dev/null +++ b/app/src/test/resources/computador/chaves/ecdsa-p256-frase.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGoVffHZ4pnkGbcYKo/VjOj0Cl5GVjJUMQ6MoUBnDka/nxwaQS48iuMeabS3n9I/yUqUhcSA1OLHuW6NnyUYwwA= teste-falso diff --git a/app/src/test/resources/computador/chaves/ecdsa-p256-pem b/app/src/test/resources/computador/chaves/ecdsa-p256-pem new file mode 100644 index 00000000..61bae6d5 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ecdsa-p256-pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDJWUJ/2NsYXGvJ4U1vB55auDnX5zH/BPGvhaYlPBqmhoAoGCCqGSM49 +AwEHoUQDQgAESXWHjAh/OkE4W4Up+KARdfiwnwO8eO6KLVoUSepI5dL7/VqOeyyW +fAmXcZHG/7TSR6wPnRT3Xs4SZ1wZ7mRpOw== +-----END EC PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/ecdsa-p256-pem.pub b/app/src/test/resources/computador/chaves/ecdsa-p256-pem.pub new file mode 100644 index 00000000..bdd07722 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ecdsa-p256-pem.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEl1h4wIfzpBOFuFKfigEXX4sJ8DvHjuii1aFEnqSOXS+/1ajnsslnwJl3GRxv+00kesD50U917OEmdcGe5kaTs= teste-falso diff --git a/app/src/test/resources/computador/chaves/ecdsa-p256.pub b/app/src/test/resources/computador/chaves/ecdsa-p256.pub new file mode 100644 index 00000000..a8d83ef0 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ecdsa-p256.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNHjda7UwLLhGHg9QleJpmtAzzfJM6aruwsU4ltw77BD1HHQjPtaebRVdGYTbRQZfa6VlDHv9ia1eORXffAVh2U= teste-falso diff --git a/app/src/test/resources/computador/chaves/ecdsa-p384 b/app/src/test/resources/computador/chaves/ecdsa-p384 new file mode 100644 index 00000000..8d12a3d9 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ecdsa-p384 @@ -0,0 +1,10 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS +1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQSnrnUimvBpQ+zYJUzKmKBlmMjqB+Ss +xfYUmclSgXCjzW0nwGxt5Eqvidir/NoCcSJaqydSgEjsLCo7ODcHtXQKVOJ6xFEfq7F2Wr +MIbA82SFmC8BF+HALHO4/wi6NtKzAAAADYuQYVjrkGFY4AAAATZWNkc2Etc2hhMi1uaXN0 +cDM4NAAAAAhuaXN0cDM4NAAAAGEEp651IprwaUPs2CVMypigZZjI6gfkrMX2FJnJUoFwo8 +1tJ8BsbeRKr4nYq/zaAnEiWqsnUoBI7CwqOzg3B7V0ClTiesRRH6uxdlqzCGwPNkhZgvAR +fhwCxzuP8IujbSswAAAAMBHsHFgAKdkHM5MQfH08+Ym3kSHoAVf14KqqA+8ZiiKqWKfUqD +hRKEpdXuFlKjNtoAAAAAt0ZXN0ZS1mYWxzbwECAwQF +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/ecdsa-p384.pub b/app/src/test/resources/computador/chaves/ecdsa-p384.pub new file mode 100644 index 00000000..c0520eee --- /dev/null +++ b/app/src/test/resources/computador/chaves/ecdsa-p384.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBKeudSKa8GlD7NglTMqYoGWYyOoH5KzF9hSZyVKBcKPNbSfAbG3kSq+J2Kv82gJxIlqrJ1KASOwsKjs4Nwe1dApU4nrEUR+rsXZaswhsDzZIWYLwEX4cAsc7j/CLo20rMA== teste-falso diff --git a/app/src/test/resources/computador/chaves/ed25519 b/app/src/test/resources/computador/chaves/ed25519 new file mode 100644 index 00000000..d1a95b76 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACC9yOPxUeK2sR6jdlK8179ujh7iutd92eMRUxBxmM9kFAAAAJC4/dKJuP3S +iQAAAAtzc2gtZWQyNTUxOQAAACC9yOPxUeK2sR6jdlK8179ujh7iutd92eMRUxBxmM9kFA +AAAEDstjtQFGfjUI+cHx2DO7K8INycrxb2gW5UR03zcgTFcb3I4/FR4raxHqN2UrzXv26O +HuK6133Z4xFTEHGYz2QUAAAAC3Rlc3RlLWZhbHNvAQI= +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/ed25519-frase b/app/src/test/resources/computador/chaves/ed25519-frase new file mode 100644 index 00000000..0c0da1b1 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ed25519-frase @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCjY1SX2+ +0uavy+fzlWe7GcAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIBBupf0qC/vfP9c8 +vcxtZ5YLIMRmLIsAZApAgr5Jw8UIAAAAkO6YN60dSNczyk5Gca0FVHWAq38ogTnya++ARZ +EIstl4+mvFth716nSNy/4JzArMfVyq4NrQbm+mzLSL0Xe3Ikhg8sX6PTjeyBSEo6eHcG7n +1giH4pnGn13TYfVLzj2OacUbSHdtHh0RVbxPLtxKUgoeiUhKBlBQeMJ1w0AAIMvw8727W7 +0kJIpa9dFGsW4bVA== +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/ed25519-frase.pub b/app/src/test/resources/computador/chaves/ed25519-frase.pub new file mode 100644 index 00000000..947298b3 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ed25519-frase.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBupf0qC/vfP9c8vcxtZ5YLIMRmLIsAZApAgr5Jw8UI teste-falso diff --git a/app/src/test/resources/computador/chaves/ed25519-pkcs8 b/app/src/test/resources/computador/chaves/ed25519-pkcs8 new file mode 100644 index 00000000..9677bef1 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ed25519-pkcs8 @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJzu7a4eTLJiCP4jBl2eDyeeBnO75nOkJIilRg66uvhL +-----END PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/ed25519-pkcs8.pub b/app/src/test/resources/computador/chaves/ed25519-pkcs8.pub new file mode 100644 index 00000000..236e2be9 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ed25519-pkcs8.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAgmblNrTC5sx2QmJwzTLcYB7JuYQnl6FKvgrS6ReDt+ teste-falso diff --git a/app/src/test/resources/computador/chaves/ed25519.pub b/app/src/test/resources/computador/chaves/ed25519.pub new file mode 100644 index 00000000..7f4ba0f9 --- /dev/null +++ b/app/src/test/resources/computador/chaves/ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL3I4/FR4raxHqN2UrzXv26OHuK6133Z4xFTEHGYz2QU teste-falso diff --git a/app/src/test/resources/computador/chaves/rsa1024 b/app/src/test/resources/computador/chaves/rsa1024 new file mode 100644 index 00000000..d4814877 --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa1024 @@ -0,0 +1,16 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAIEAuxHpOpEQb6BjIQdpRYImGeQ2mbDPmNn7FVtbHXvm6Y7OY+1fti4L +3tgrHYPj7B7795Y9sZKcOb7Ewi9htEm0FT1XrFIeWvT0sgg5EVJShCt730WhWWwu2w83D1 +g5D3/YoVOKeO3zeZz0sJmSaAGcORlbufUlBAah/MvwzeK5CkcAAAIIoO/1j6Dv9Y8AAAAH +c3NoLXJzYQAAAIEAuxHpOpEQb6BjIQdpRYImGeQ2mbDPmNn7FVtbHXvm6Y7OY+1fti4L3t +grHYPj7B7795Y9sZKcOb7Ewi9htEm0FT1XrFIeWvT0sgg5EVJShCt730WhWWwu2w83D1g5 +D3/YoVOKeO3zeZz0sJmSaAGcORlbufUlBAah/MvwzeK5CkcAAAADAQABAAAAgQCA5CdhTg +AYGVcTs8qJtNHRtxcTr+ppc493OJm/59XDCL0VxsDSgkl1glW6oVwSHYsYHPFm/xAbDWD+ +Nq1IDhw/83+Mg/ljFcrI/vSC6zQ3WsJ/FV8waV3aB1gufCcj2K6maY+cCnCNTnEfS7RFly +z4U9wuNhmdDECAKzkAvO58eQAAAEAoQJ83tKKbHN2Ah3B2jdYT31iFKGh+ic8YaRUqukPp ++5HtHmceaUCGkAcJZY1cy3ze7snYn8a0f5glipwDjRaeAAAAQQDuHn2Q4P+rzWpXRefTyj +7zfZDTpUqHmUseIaycD21OZPo+n3EW7y4FLs89xNv+L/UygD0eBqM+gcRaXF6CwvYlAAAA +QQDJHhJZL01Jfoy6foKRNNeyuXyoLqFOglLqZe5nS183YoCXWltAwK8VotKNHJ8a8SEpMR +Jp2TA2jPHyXTLduqT7AAAAC3Rlc3RlLWZhbHNvAQIDBAUGBw== +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/rsa1024.pub b/app/src/test/resources/computador/chaves/rsa1024.pub new file mode 100644 index 00000000..980c5afe --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa1024.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC7Eek6kRBvoGMhB2lFgiYZ5DaZsM+Y2fsVW1sde+bpjs5j7V+2Lgve2Csdg+PsHvv3lj2xkpw5vsTCL2G0SbQVPVesUh5a9PSyCDkRUlKEK3vfRaFZbC7bDzcPWDkPf9ihU4p47fN5nPSwmZJoAZw5GVu59SUEBqH8y/DN4rkKRw== teste-falso diff --git a/app/src/test/resources/computador/chaves/rsa2048-pem b/app/src/test/resources/computador/chaves/rsa2048-pem new file mode 100644 index 00000000..c7b8abd6 --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa2048-pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA3bA7Eo7ZkKiR62K4WH6OMVPM3ZwAWCYgpF7e6gJ5miY5hBLZ +UYhVDKCsvVKC3DAUrQyahkE3a+Qz9fAyfrDIHUkZAd8oz4J5ASDO/A3b1V1jIeRz +mtegQFz02hBTpOjMmoYYxuyzneGAJgXcELSu+WrA+6yztv2jcjIWNDyrXWB1d7Rv +rRC+pmB+maigvpYtea8ZtnHaAOxH+0BlyW2cBghSl3qS5KuWbUAwwHgB5XEcX301 +uhORqgU4C4wYwD//vXZ9hQjbp7bbwfLxqRUMgfzYOyCZ/OQVlJ2HRL1uLeVKH9BC +94WFUGN71pPI28nWHZFzLvc5Qe5TfJOIqRaJhQIDAQABAoIBAE5V1anrHGoHhvh8 +O9PZdHltuTidxWlgn6w1ceOS2FFmLHDFEFHFMPhzVlbX7zHLM6Mqjvfd162iR9Ih +/FXUNgJTMCj/KDabThLd+wYKB6JPi5YQWeFfTFsx/buIhWYCdTotJFg2kAm8A0Js +U6T7k9DCrkrAbgi9e6IAnOP8hDlMEf4H2nqNqhN8XqTcE+wtKkt4eTdmBZI6lCxl +OMx7IVdxNadiX35owkQoPRNEbdGgF58VzcA6q3YdplxvJgBMShh/8RREEvpNbl11 +5cBs5eAEw7zNRHQWdjcZ4kwIw2eh8xaToX0uyqa4i7ARage0Pz/8qDUv2HHvSul7 +nOWdBckCgYEA9uhH8YDQZNzTAMYwgm/BVdERmmbYo0HwWG6OuK6Gb/LoXK5yFmLH +109wtZ2u9FmF96pN6OBzz7jrSNxQYu9d2k457GVvfi6o5+hIBmMCS2uI+ysop87P +l7abR/gLUyk0Y8BELXdjPdlc6AQcaP/134vPsO2mCbj7gTMCqSt6RAcCgYEA5doy +t2iV0S9tDmIWGcAsFEvtBAhZgdNM9fNO/ZXJGxxcPVwnhIY4X07dX7nn4gkUGPoa +1xM8RUKxfHXELyf9Vf/OjqUaPy3sczXWTjrhAXV9+FPaqOr9wqGKIsH+iuKsI3Rd +rS5qfSxOYgahn6cjoieXLLvYNZhiLNCrC71DWxMCgYAbu3FBNeeyPH9SqfAO5oB/ +pBDnF8AjbieRtzE+mxOshUsGBe34h7XMgDa3n3NkLJ34eF9x1awMW8fJkj1RL3OK +Lpkg7S+9q337NkLIvNwoHnbvRyS03GzCrJb6WWjkOU+SGKlPtgYmj0B5KuUKAh3a +svhr9fVdSDKvi0w0NhbwhwKBgB/m0PMzyNdnxbMhWf/o9DU89ICCyCmEskHQqz32 +9BqXLRY3HCUAnAJeZ8tJei2dJPTygZcoTE0wnIGxtrD00+yDvJ9/uszaCt9SsZPa +CDCzpfA5czfOpbnFwe5hDnsoSswA8p9+ubj11Us1sGkAQanX5NuIltGZfMbLj3Hp +qteRAoGBAIGAfYZrdGTWErL0tBGKtjQwC1P+T6fXbRaNkInd3UOp7r5rCU+pLitg +Cm5qMvx0dDux/bdn3g7rbCyaoaOUgOWNrHOtsyLhPXDQP0nLQxbTA8ZOgpJ0QnHN +NTAwAZNHl2YAvTDs6YtDw/dserTgM5OKaDRrzPngPjfqZ7ruA54Y +-----END RSA PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/rsa2048-pem.pub b/app/src/test/resources/computador/chaves/rsa2048-pem.pub new file mode 100644 index 00000000..eab6d8e6 --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa2048-pem.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDdsDsSjtmQqJHrYrhYfo4xU8zdnABYJiCkXt7qAnmaJjmEEtlRiFUMoKy9UoLcMBStDJqGQTdr5DP18DJ+sMgdSRkB3yjPgnkBIM78DdvVXWMh5HOa16BAXPTaEFOk6MyahhjG7LOd4YAmBdwQtK75asD7rLO2/aNyMhY0PKtdYHV3tG+tEL6mYH6ZqKC+li15rxm2cdoA7Ef7QGXJbZwGCFKXepLkq5ZtQDDAeAHlcRxffTW6E5GqBTgLjBjAP/+9dn2FCNunttvB8vGpFQyB/Ng7IJn85BWUnYdEvW4t5Uof0EL3hYVQY3vWk8jbydYdkXMu9zlB7lN8k4ipFomF teste-falso diff --git a/app/src/test/resources/computador/chaves/rsa3072 b/app/src/test/resources/computador/chaves/rsa3072 new file mode 100644 index 00000000..ade91a0f --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa3072 @@ -0,0 +1,38 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAYEAsoUGC2/S7fCypLA5rRT5RnHRuYDtb3oV0zLznWrfumb2qvsXSWOt +4teaLqG0EGjxknDBsnTufrC7ZR+2UDsMSqTCeOv4gUyFXAsWRuhBHibxzszeip3WH9RUqc ++UpVBrtke9CXTK9712X2K0Cc+cQ6Z7PNIB+sKdxWNIb0Mh98z0f+3BbCMyjdL9yaQ39mlE +wHZzolbz7EoHqdlPjTf8LirqYaheK5BW16W2phz3T+HDWC0WND5Wa9gPze0VIwN4/0yMre +wgDHhNAVJh3IlChSAyb++9gyFin59lJl1Z3GorvMjTiG+FfoFHgb5tC8jLf8nUjDI2CaAV +g8+5NRQBwzVGWGgX/h2LvnA7J5et0nOlJhx/cXnwi5Rv2L/fAi0jiMxkwQc4SXc6HvApQ5 +LjSJ8QlXKtiiI7PXVr3b8LnVnARVbHAQk3Wy1hPd3d+eMWj62f7P0YUDSaQAU7MU2LL1OX +mS2TyppEM16waV+ZSI33BfnPiR+xJvjdE03HGvyvAAAFiHaHL+t2hy/rAAAAB3NzaC1yc2 +EAAAGBALKFBgtv0u3wsqSwOa0U+UZx0bmA7W96FdMy851q37pm9qr7F0ljreLXmi6htBBo +8ZJwwbJ07n6wu2UftlA7DEqkwnjr+IFMhVwLFkboQR4m8c7M3oqd1h/UVKnPlKVQa7ZHvQ +l0yve9dl9itAnPnEOmezzSAfrCncVjSG9DIffM9H/twWwjMo3S/cmkN/ZpRMB2c6JW8+xK +B6nZT403/C4q6mGoXiuQVteltqYc90/hw1gtFjQ+VmvYD83tFSMDeP9MjK3sIAx4TQFSYd +yJQoUgMm/vvYMhYp+fZSZdWdxqK7zI04hvhX6BR4G+bQvIy3/J1IwyNgmgFYPPuTUUAcM1 +RlhoF/4di75wOyeXrdJzpSYcf3F58IuUb9i/3wItI4jMZMEHOEl3Oh7wKUOS40ifEJVyrY +oiOz11a92/C51ZwEVWxwEJN1stYT3d3fnjFo+tn+z9GFA0mkAFOzFNiy9Tl5ktk8qaRDNe +sGlfmUiN9wX5z4kfsSb43RNNxxr8rwAAAAMBAAEAAAGARdFOpuRqCxLK2yvssqt0Pj+/dR +xeP+3wYTQZIOSucTlZmU8wMUyhsgj+TOSDBrP0XoBo0MF4kOXPtGHtrH0bdonvYNlwzCjA +M1pGsR6ecQtsIKPxGSdXmgUxw2Piz7CfH5/455zc0H0xsWZp4Thn9fFDW2sKySaaC5ujtZ +f8zexDZ1D2B7PQTrVI+jaWiHFtKRRqbwAwz6W4T44k1aciom+4UM9HXSRLQnWV9Gt5sa0q +9+5SM8Z5iB3tOG1TiekOHw2DQb1KvArJvCqvp3hCyUuYHQJG+Qt/2bgZ4wCYhdcsxm56CX +mk0gHEVJfB/QO7vSaKPrQ57Z7J+G+6o/hp76K+HJPrb7Q5K3QUQmyNm+qxOHNfe+8pvRP0 +qtUm7+tcIeFx2u9vIbeMyKlx69AQwCh8WaCNWgwAs8TmTEx1t08ELmfoU64+rrlQvT8YLo +h0iwvAOQRhArw1hocYWWb6ke2cMU9fPHAeMj0R8bJYmJ7syBAqMpGf2hDhm2V2F/P5AAAA +wQCR2IvPUtrQd5QyQb+6Hex9Vpj7UyJ/Oij6QswavsDCoAA8rrDMvB1OvfzE5NOG1mUZeP +yoFz7JX0hN6KiUUr1Be+iEaDWbk3nZJnEIMqiSWBenLjGnw1sXmRyyATvVsQo3OJZ64VJj +XMAoG7V/OL3g8B7tYVpXD5KgSXb04k8hOzm9WMTkTrTKbLWVTdb8TP7W6CerW4zujLvZjv +nkFtE4LWoy5ztWNwgsRdFrDrRyDr8N9QuBI8IjttytRmphXt8AAADBANtHPJs0giczAes8 +8hS5JO+Uah0mc17xmxH3s/FIuMOQiuMIqPyKIC8V/RwiXV23yg3SA5nAQm8aZTdCcT6YpG +qVsIjZ/QQpYcnlp9qmIbOr/rEHbW/Etk39hjufzQmQxC51ovNXeUA/UncYNsKBwEP0owkO +wluhqjtrUMy68Jpj6KKc/WLqeObx5xIAgmPQVRBhe5K6F4PcmNiKrB9/mU4ls4mmz2Zc7w ++RkTzkO7jjklYV8yWSsJNRT+fEhppi4wAAAMEA0GpoQuS3qTpooYVLAOUJpItHbvVRBRHR +HA2Xv0S3PvJuoBaEVpV1p2gccGAtjlj9rk/qYkFIwITfre1N1rW3BGDscVGjceRVlexjyw +XSjN0SqjZH4Nx5Juj3OrnqjFQ4JeBsSIFAek37sHNpxvPTn0Qi/18+msmcSV35NymKP8oS +B9zEk+/ME+3RD32ekZ9ODVKPwT2zAuKx5zKoO+4gS1lRK1jbdyxRHHUd0+Vxr+lNmFvu0b +gbGQaR1OZ+gMzFAAAAC3Rlc3RlLWZhbHNvAQIDBAUGBw== +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/rsa3072-frase b/app/src/test/resources/computador/chaves/rsa3072-frase new file mode 100644 index 00000000..b0057f8d --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa3072-frase @@ -0,0 +1,39 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBrwl65b4 +kGboAwdb2noCf4AAAAEAAAAAEAAAGXAAAAB3NzaC1yc2EAAAADAQABAAABgQCYWQgXHa1O +XHRQ4z4pAlqHG1KMQ4J2Ongbs8PoiyKAU3Z7ifiWnlgpdZ9BNZEfRW1QzpgU8viQcmjxD9 +wdoNhuCYCIJ/uXrBv6a9009j0Ds9v7l47vChNSwx7S79m3wztwEUIhZEAb6QC8v+2yvYF5 +GAKh/whWlHdjpzxD25Qlm2s263I8lUv/3zf3lRfo2VJK4JHUmX6rDmHTh3CuJmE0YNEQdC +CygVyULikYqUiOlZDjfxpCOoILarRfbAjBDCBpC4re9DPhY/pNoT8C4p03YKmyx0A4BSfv +72pU7eS2S94/WamW9bVdYzPxeTGruX+1X8Z+mk6QdeJumCvQvGfRkbbS7cQ0jiIQ2KU0du +GKwZtu4uVILe2ujerIct+aKnpAzwSUmpAe80/QwBnC+d4sgV/9WYet1lCGWYUaHtRF7zdl +i+/iXpucMxEQ5OnUfVkA9b4Zu+TgJrt4JSVrp3RcIFa3GMizIz+E6Wp895qcFYyg2fakbY +C3KwmqTqB2V4kAAAWQBiC7ewBbN0+pkWkaE6IzoUM5eD8zmndjkeSrgx5WUnb8HH8EdU3M +AKcWd52nlHQVkU6L0/MC8+Y74LnXRXdQjtqoUiMVsXvbotc1aXksFy1M1X0JcBVuJ8F6p2 +1HK85/6be48+5J7ojSij5x6YkYgTW3+yeveWW1Ow/dl4oHwLU17hSEllkcdINCkyANiLT4 +AExuzQElRknGtTF4Gk2W3YNvQ2TqBNY6egVmmsHpqx+XM+pz/1SIG6RSCfwO4uR5EYC5Mx +yK/v8ZBVfM3koEyhTY+VQctcmOSi832H1NP91RyU5bPQS1gA++S+tabUv0Xr0QSbvr/PKy +qbb559gTQRgjVl347LQmzoWIgqmmMFD46GUDI9BYFX/w848x2lbVVRooPFoMpUBySTZxNC +nZvSBo9YSpu2+0GNhbGrC5RZDWKT8hfnv2wxZQjvgPQbTdIjtNAdDISEIpv0TyB5VaHoUw +Nf4neVMW2JJ8hfwfAXbdzrzvUIrw/Ml4A3qeNVUeDqmFLzEnX8QX2YrrfajbVAylnAFRHS +nK38JyM5vBTtn7Qt12bGNowwJJf2zIBUx5hED+EZEDxWe8GcBPnRRPFHfSVtxtD+pe1Ebk +WtgpGxRNXJrw/nECnmZY8bXrjtljiUjkWl9Nq8FbZBkT1gajgQfc6YlxUfVNhhhE79DIER +So0Uplkro36c06Wz6eTSlGug/JnwN7L4KBaUmNvZxJTcgb7OJjKiDQ7A3FYi3BTBPURKvW +Sb0105x09gwSghG1xfO+ErOVuPFGpdX1fRR5yRuw3l9xw3A33FR6z5aUFa/BM7t3hw4FZz +vb89gjimsnUpn0KWJ0yVpwXT94WFYacRqDz+3UUMNstsgs6Jp2R8XwIZDaU1uhpUYnlbCs +cHeDW10+uoCWsTl2HQRBldLOlv8W67w1urR/hg7NpI3RSvyrIA9c9TDgZiOHP+ozXCmkqL +zfbOp25Vlg5Xjmji2hv5mSUbJ62SGdmHSHb7pecZIiDniIe+4uaM9BF7DfXrrUIJY9t9XG +qtZejI8EnIDGlUWIscQwqg3eGmZZAjQetocWnboRYzLZbFZOuFuxU7tDsb6g6L40xrqwxe +fvHlgSukvoeRlIOe+1xCVQvzGhygRuvjRO639VeTOCdMt0bHB4bRpcVuNeoCfF8Ob6xCuJ +2Fxr+sE5PGviZ97PudRuumsOq4JvJF1pD+IRtESIo1OB177dG7P7ckuZTmmp4K2XZ1CrqK +PkllNaOHn/3Ta2ajdnoqsa0C9VbrZwhr6nwNYBNcKgXJ5apFxVCqrVF+s5scfvsDZ2H+2s +++SeJsjm9MRMxP8fhFeVRBBEBBDcTxlKOJu3YaJqNTh7QeAoZhYUYrdogeukKuYlVLo0pB +4hlv2YVL143znLn6bgr/kp2R3n0XmtAMluGFMgfUDPgo+/GfVu4Gwt4EAZ8WVdOtn+GqCt +F6qayqhVY0Ap+0DhYYKUDUbw7e0AHuUTeVmg3I2NBWowqoIGGQ8bqKKDjHmtM247rGkPgo +R4Y+c67Z2xAh4QCRgftc5XeSUdIZXLg/BmbV4Hw5kjrlMwzoVbU1Nbjn7ZrGheTlMJ/DuP +gYhhgAI62y7DcAlfnAK5zq1/oukqY7a5zD21yQ6XOxZDQTyjgAu68bKTki80S2WIrgZV8d +pbYvhY2S7q7AyxetrGdJlPlXF6doNu/kTaz9kI/VuxYIs4q3IunwgBBb5I+TeAQV+8oFNF +m7UivG/lg1C4/eHr8UqmxDUgPDRReYlvazMcrLq4nZgSL+xaqrf42yKSFn9HjoZB2jzdyd +wo6XVhrQefKlMtD1lZ+WJ9pQDwYCqXq1Un2lBkfslQdcT/FjYkaks9WmvwrKaTZnqD7UkL +XzWsp5RGrgfrZcSWo9UORVd1SzE= +-----END OPENSSH PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/rsa3072-frase.pub b/app/src/test/resources/computador/chaves/rsa3072-frase.pub new file mode 100644 index 00000000..ccb53e6d --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa3072-frase.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCYWQgXHa1OXHRQ4z4pAlqHG1KMQ4J2Ongbs8PoiyKAU3Z7ifiWnlgpdZ9BNZEfRW1QzpgU8viQcmjxD9wdoNhuCYCIJ/uXrBv6a9009j0Ds9v7l47vChNSwx7S79m3wztwEUIhZEAb6QC8v+2yvYF5GAKh/whWlHdjpzxD25Qlm2s263I8lUv/3zf3lRfo2VJK4JHUmX6rDmHTh3CuJmE0YNEQdCCygVyULikYqUiOlZDjfxpCOoILarRfbAjBDCBpC4re9DPhY/pNoT8C4p03YKmyx0A4BSfv72pU7eS2S94/WamW9bVdYzPxeTGruX+1X8Z+mk6QdeJumCvQvGfRkbbS7cQ0jiIQ2KU0duGKwZtu4uVILe2ujerIct+aKnpAzwSUmpAe80/QwBnC+d4sgV/9WYet1lCGWYUaHtRF7zdli+/iXpucMxEQ5OnUfVkA9b4Zu+TgJrt4JSVrp3RcIFa3GMizIz+E6Wp895qcFYyg2fakbYC3KwmqTqB2V4k= teste-falso diff --git a/app/src/test/resources/computador/chaves/rsa3072-pem-frase b/app/src/test/resources/computador/chaves/rsa3072-pem-frase new file mode 100644 index 00000000..551c6302 --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa3072-pem-frase @@ -0,0 +1,42 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,F944A489E452F201BC0C9AD9B67AD3E9 + +lNe2+v2fx/mWoDtxEguUEop8VbIuQ1N1GjqJKzIjGmCJ39KD6K7DHJblbuSAzMdW +tfTed1RgGo3Msnq0h4ela1rR3LROG+E2a4Mlx5sMGGSAQyzXmC0GSe+yYjxWP2pt +qL5DDZndzHd5vQbURfvbo+s6mLBBjZpxQN0DWivLutVwBtLZQNX9XXfINX7IS3UT +MbaFycd03U6WevYOgAQCr9f2LMB7t6DNGInJsANKI0cG4EzwUnpr8z4foTx4k4Rz +EWKYuwkcrjDt8k7/ulRkafV3hXQFd7gyhAVr7EbUdvviVvOkVenYVL19PUU1Kz9J +JGtAwVOuZADl54aj/ee7sg4HXQQkeY6Ko6cpYBC9VE/XjJ74NfRtTH899DtFqSN2 +2qxoitZVi12zPOARWA8C0GZiRxIR7uZ9mV/R1bOrpNRHYiQTT6wRN4mTwWg0Y111 +jLt5cD3oVW4160S3j2b3BYfOHniUE0FVxgl45rkLsRl6LR40iEQntAlrnXSXgix3 +FnPbvRUqmmAduF+nAkrPnJKeOEqvzKelqp8qR8kDerV+fNqQ6zEUIjF+s2PHjrm5 +PxBVISNXtnAd8y2YymxHb+/VcQo5zi9FD955Chxm2PX9jQlXs2eYvNkytFGhSrQr +6in6avsorEzlyZjnWGpa/XINk3Kc5tLmq6yUtUEoxSQmkojjS6Rz/nmK5slDJH5Y +pjjDkuRZLBLblYr8CNZxEuawpc67koS5WWIFOo36Be62fNntrArxJ3qW36Q21deG +sRcB5MwZFMyMN0DqIjh3OJAHlhSacs64WplxSSTVav4zJ6FjbRui5N7Xp0YZ69eP +BvGCFqUbw1U4PVlczgpoI6pzX2qQErDm+66/opyZc7m7pO22sctUE+kzS5TcFiGO +RWFIAG2xChMPVtf7RPb87N+s1+6z3b+EC0wDFrxKr5Pa29dZVXFismarLVjpScQb +wMRfL6jp9CRf+oXNDFlDh/Pnxq/3jWyy7MG24lPecSde5P1putADAqEpaoVZu8Wx +EoWDd6O9tFv4S6ipGJCeofz+yugxOm8So3P/ZVXnDodf1cQW6NLFVk5Dsc0GpHIC +mB4Cl4jbpa+OdAPWfNlaB4ftVAFOt5X8JGNDDz9UYJdSvCP3grftRPH7ToaSawgv +4VkIx3apK1f1pKl722pk/J4xrEZT10G4oVH24/e3CBjrbYCGBuYj5rXurbRG/XGb +RgZeAzkofyv9gN5wLZFqh76nf/z/TTZZZqrqMdnYPTzWJ1VeU0oHlP5UK0tlEBbs +/6FVbRtaKMw7eze03nV6cAybMDTb43cU066IKZ0INMhrA0cEoDwTXkniFuZ6BLEw +GMhGREASq7brZmG6dH3z0q4PL4ngImfkqOeQAlVO+kotsKimoSboGguHTEG5qxVs +RzC39tHDxeY66p86MIvwZZpeJO62lfemUQ+G1Em0MayfMSKmp1Wp4Uck50CiyD3x +Xg2Jp12Z5ilo3X/jJZXEKWZKAGgfXUL5HALqjx4p1Fhr7LiBAWMSMEU4C/QJDrVZ +D25gAzZCqJUA3ElAAZN3yA+ewopdTtIESop66q2NLMvscDKYbUkgPnx4o7NoRrb8 +yGI+YTUF1yHUawT5TR76xbGrCmpmmCvlM7m6fszyoq4HSBUJrQ/HaK5Uz0lKD9CJ +l6DqsWdex5LZYMp/+8kWpNgktkSo3qCf32w6hMwv4gJ8IAGmHIRkXotfZ1irSDcQ +vC02LoNJTuhDDxEcEL9GfTFtPtcfsEqj4FIBRxV+bzhRHMMr1Oo21zj2f1ojnaWO +CTNN7owYNG9tPtzyKYCbtoTfs6HGZnRDZjFKjZJ/cJw46CgX5X6SEJ4fcv7slTSq +P5UsmowD+ddATEA4PJ/ZipsC5anPejz+DHB+K/anvxzBbyvL16Z36pCjELuBmBH/ +96CFC3NgAvPPbCCaZThXUJOP/xGIyUQys3GT7oHu4KfatfWyu3P+HEkD4an+jlNL +8oZThufajDF3/4aFewoHrOwf68K3vEdwcQu2nbET+GAQlaiuvAmMcAj+SqDZN4m/ +hRYLmUsZpgXtrB1u/1o140PSh7El1rKsW7g9Wi19ey4fsGUNm6WUkmkKslI96s1G +jdYAxm4qc8IzRTPnr43BDYB/QzZpWbqg6GWTLtrrkG9zxpmI0NgyMq/SfubA/SCh +i698IkU+8bk9BDogillSguNi+ZbIR83dL3Bvivqs8baO48avUcNjFWEx94C+fFcZ +unkeKvSAf6DxkEhyi001OVDhPB+Xdyg0xcGSjskI9DiU7i1tsDNQDTvN3ndjzywX +RMn2fOX8ze2QjqzqrTNs/xJmLZXSNA5IzhAL7PYrIrZMHftzZeQth2/O/Vq04P5p +-----END RSA PRIVATE KEY----- diff --git a/app/src/test/resources/computador/chaves/rsa3072-pem-frase.pub b/app/src/test/resources/computador/chaves/rsa3072-pem-frase.pub new file mode 100644 index 00000000..5406e77c --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa3072-pem-frase.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCmtnIEqyoGOiARKC0qyf0sNaIf2V2MjGm0JSFIPc/DQChJ8D/h7k0jDwOCiIRuy904c9N1VoSIqJFiV/JU49HQh4rd7hQwSkTswwAd7DZRQe32qXUeF8QtNKidHYhuWHcImyPNXWKZFklt/qiNWpeGq6mVhA2rI0NwWzNtX1hIoM/xLFBhUjG4c6UZetsjf3lXYFrVVsbAOioN4z2xR+0zKgFfGqwQU9EgVMLWXXwMnET9ReYiGONGAYCKsMnloiPyRwfvJhoTwAzBMwk6kii5aD9JW+ZckLjP6UBRIrw4uopRpNgzLvpNDk4bjVM1CV7yRXHtogrCm6ewkftyaGPpbBX1vCjhZnXnMKdkXppcPy8eUMBUp9zVHyuyRD/9ysjhqUlyu9LgVjV7c8EGkogFAhxBIaX/qNnoDKCQJw/YhVc7GD/RE731Mc1B/v+JdOBDWvlQ95j7Z674pVSKKNMyvwnhxdWMhyJxvO7am32QSf3iCKjTOftaQaluaZCwzn8= teste-falso diff --git a/app/src/test/resources/computador/chaves/rsa3072.pub b/app/src/test/resources/computador/chaves/rsa3072.pub new file mode 100644 index 00000000..43a4d083 --- /dev/null +++ b/app/src/test/resources/computador/chaves/rsa3072.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCyhQYLb9Lt8LKksDmtFPlGcdG5gO1vehXTMvOdat+6Zvaq+xdJY63i15ouobQQaPGScMGydO5+sLtlH7ZQOwxKpMJ46/iBTIVcCxZG6EEeJvHOzN6KndYf1FSpz5SlUGu2R70JdMr3vXZfYrQJz5xDpns80gH6wp3FY0hvQyH3zPR/7cFsIzKN0v3JpDf2aUTAdnOiVvPsSgep2U+NN/wuKuphqF4rkFbXpbamHPdP4cNYLRY0PlZr2A/N7RUjA3j/TIyt7CAMeE0BUmHciUKFIDJv772DIWKfn2UmXVncaiu8yNOIb4V+gUeBvm0LyMt/ydSMMjYJoBWDz7k1FAHDNUZYaBf+HYu+cDsnl63Sc6UmHH9xefCLlG/Yv98CLSOIzGTBBzhJdzoe8ClDkuNInxCVcq2KIjs9dWvdvwudWcBFVscBCTdbLWE93d354xaPrZ/s/RhQNJpABTsxTYsvU5eZLZPKmkQzXrBpX5lIjfcF+c+JH7Em+N0TTcca/K8= teste-falso