diff --git a/.specs/features/computer-terminal/spec.md b/.specs/features/computer-terminal/spec.md index b50ac9a2..55dc189c 100644 --- a/.specs/features/computer-terminal/spec.md +++ b/.specs/features/computer-terminal/spec.md @@ -386,21 +386,21 @@ 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-16 | P1-B | CMP-T7, CMP-T8 | Parcial — sentinelas e `SecretFilter` (CMP-T7/T8) verificados | | 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-18 | P1-C | CMP-T6, CMP-T16 | Parcial — política TOFU (CMP-T6) verificada; falta T16 | +| CMP-19 | P1-C | CMP-T6, CMP-T11 | Parcial — `known_hosts` e rotação (CMP-T6) verificados; falta T11 | | 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 | @@ -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..d51eb174 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`). 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/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/main/java/com/keyflow/agenthost/AgentHost.kt b/app/src/main/java/com/keyflow/agenthost/AgentHost.kt index d580552d..a81b70fe 100644 --- a/app/src/main/java/com/keyflow/agenthost/AgentHost.kt +++ b/app/src/main/java/com/keyflow/agenthost/AgentHost.kt @@ -113,6 +113,8 @@ object AgentHost { credentials = auth.store, // A fonte antiga pode ser migrada por outro processo enquanto o filtro roda; a leitura do snapshot espera só a cópia síncrona. vaultSnapshot = { vault -> 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..7c708b00 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/ImpressaoDigital.kt @@ -0,0 +1,66 @@ +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)) + } +} 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/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/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/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/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/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/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/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