diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9f37d89a..3b4e3de8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,8 +1,12 @@ name: CI on: + # Les branches de travail aussi, et pas seulement `main` : pour un événement `push`, la + # forge exécute le workflow *du commit poussé*. Un job ajouté sur une branche s'y exerce + # donc tout de suite, sans dépendre de la façon dont elle résout les workflows d'une + # pull request — depuis la source ou depuis la cible, selon les versions. push: - branches: [main] + branches: [main, "feat/**", "fix/**"] pull_request: # Principe du moindre privilège : lecture seule par défaut. Conservé pour Gitea Actions ; si @@ -80,3 +84,86 @@ jobs: - name: Build working-directory: apps/web run: npm run build + + # Un job filtré par `if:` disparaît sans un mot du rapport : personne ne remarque que + # les tests iOS n'ont pas tourné. Ce job-ci tourne toujours, sur un runner Linux, et dit + # à voix haute ce que le garde a décidé — et, au passage, si le contexte `vars` est + # seulement disponible sur cette forge (il demande Gitea 1.20). + ios-gate: + name: Tests iOS — activés ? + runs-on: ubuntu-latest + steps: + - name: État du garde + run: | + valeur='${{ vars.MACOS_RUNNER }}' + echo "MACOS_RUNNER = « $valeur »" + if [ "$valeur" = "false" ]; then + echo "::warning::MACOS_RUNNER vaut « false » : les tests iOS sont volontairement écartés." + elif [ -z "$valeur" ]; then + echo "MACOS_RUNNER n'est pas définie pour ce dépôt — le job iOS tourne quand même." + echo "Une variable définie au niveau *utilisateur* ne s'applique pas à un dépôt" + echo "d'organisation : pour la voir ici, il faut la poser sur le dépôt ou sur" + echo "l'organisation (Paramètres du dépôt > Actions > Variables)." + else + echo "Le job iOS est activé et sera dispatché vers un runner étiqueté « macos »." + fi + + ios: + name: App iOS (SwiftUI + UniFFI) + # Xcode et le simulateur n'existent que sur macOS : ce job réclame un runner de cette + # famille, étiqueté `macos` (voir tools/ci/README.md pour en brancher un). + # + # L'interrupteur est *à l'arrêt explicite*, et c'est le fruit d'une leçon. Un runner + # self-hosted est une machine réelle : éteinte, elle laisse le job en file d'attente et + # la pull request sans verdict — Gitea n'échoue pas, il attend. On avait donc écrit + # `== 'true'`, pour ne rien lancer par défaut. + # + # Sauf qu'une variable qui ne se résout pas — posée au niveau utilisateur alors que le + # dépôt appartient à une organisation, par exemple — rend une chaîne vide, et le job + # disparaît sans un mot : on croit avoir une CI iOS, on n'en a pas. Une file d'attente + # se voit ; une absence, non. Entre les deux, on préfère celle qui se voit. + # + # `MACOS_RUNNER` (Paramètres du **dépôt** ou de l'**organisation** > Actions > + # Variables) reste donc l'interrupteur, mais il ne sert qu'à *couper* : à `false` le + # job est écarté, dans tous les autres cas il tourne. + if: vars.MACOS_RUNNER != 'false' + runs-on: macos + # Le cœur crypto et son binding doivent déjà être verts : sans eux, l'app n'a rien + # à lier, et un échec ici ne dirait pas où le problème se trouve. + needs: crypto + steps: + - uses: actions/checkout@v4 + # Pas d'action d'installation ici : sur une machine self-hosted, Xcode, rustup, + # xcodegen et Node sont ceux du poste. On vérifie qu'ils répondent plutôt que de + # les réinstaller à chaque exécution — et l'absence de l'un doit se lire d'un coup + # d'œil, pas se deviner trente lignes plus bas. + - name: Prérequis du poste + run: | + set -e + for outil in xcodebuild xcrun xcodegen cargo node npm; do + command -v "$outil" >/dev/null || { echo "::error::$outil introuvable sur ce runner"; exit 1; } + done + # `| head -1` ferait sortir xcodebuild en 141 (SIGPIPE) dès qu'il écrit la + # deuxième ligne : le shell des steps tourne en `-e -o pipefail`, et l'étape + # échouerait *après* avoir réussi. `sed -n 1p` lit tout le flux, lui. + xcodebuild -version | sed -n 1p + - name: Cibles Rust pour iOS + run: rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + - name: Dépendances du serveur de test + working-directory: apps/server + run: npm ci + # Construit l'XCFramework et les bindings, monte un serveur GhostPass local, amorce + # un coffre de test (registre gp:folders compris), déroule les suites, coupe le + # serveur pour éprouver le hors-ligne, puis remplit un formulaire dans Safari. + - name: Tests iOS + run: ./tools/ios/run-ios-tests.sh + # Un échec ne laisse qu'un nom de test : les rapports portent les captures d'écran + # et le journal du serveur, seuls moyens de comprendre sans reproduire localement. + - name: Rapports de test + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-test-results + path: apps/ios/TestResults/ + retention-days: 7 + diff --git a/.gitignore b/.gitignore index 20511ab0..9be84594 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,14 @@ dist/ CLAUDE.md .claude/ + +# iOS : tout ceci est produit par tools/ios/build-xcframework.sh ou par XcodeGen. +apps/ios/Ghostpass.xcodeproj/ +apps/ios/Generated/ +apps/ios/Headers/ +apps/ios/GhostpassCrypto.xcframework/ + +# Rapports de tests iOS (conservés en cas d'échec, régénérables) +apps/ios/TestResults/ +apps/ios/.build/ +__pycache__/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 06604467..aa80f893 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -15,3 +15,12 @@ paths = [ '''(^|/)Cargo\.lock$''', '''(^|/)package-lock\.json$''', ] + +# Vecteur de test de la RFC 6238 (base32 de « 12345678901234567890 », doublé pour atteindre +# 160 bits) : il est publié dans la norme et sert à vérifier que notre TOTP calcule les codes +# attendus. `generic-api-key` le prend pour une clé à cause de son entropie. On autorise la +# valeur elle-même, et non le fichier entier : un vrai secret collé dans les tests iOS doit +# continuer à faire échouer le scan. +regexes = [ + '''GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ''', +] diff --git a/Cargo.lock b/Cargo.lock index ff19e5cc..451731f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "argon2" version = "0.5.3" @@ -24,6 +36,54 @@ dependencies = [ "password-hash", ] +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.118", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -36,6 +96,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "blake2" version = "0.10.6" @@ -69,6 +144,44 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -110,6 +223,45 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmov" version = "0.5.4" @@ -223,7 +375,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -249,12 +401,43 @@ dependencies = [ "ctutils", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -303,6 +486,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "ghostpass-crypto" version = "0.0.0" @@ -319,19 +513,61 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ghostpass-crypto-ffi" +version = "0.0.0" +dependencies = [ + "base64", + "crypto_box", + "ghostpass-crypto", + "serde_json", + "thiserror", + "uniffi", + "zeroize", +] + [[package]] name = "ghostpass-crypto-wasm" version = "0.0.0" dependencies = [ "base64", "crypto_box", - "getrandom", + "getrandom 0.2.17", "ghostpass-crypto", "serde_json", "wasm-bindgen", "zeroize", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hkdf" version = "0.13.0" @@ -359,6 +595,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.1.4" @@ -391,12 +637,40 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -420,12 +694,24 @@ dependencies = [ "subtle", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "poly1305" version = "0.8.0" @@ -455,15 +741,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -473,6 +771,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -488,11 +799,35 @@ dependencies = [ "cipher", ] +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -521,7 +856,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -548,12 +883,36 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -571,6 +930,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -588,7 +980,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", ] [[package]] @@ -603,6 +1004,127 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "uniffi" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "uniffi_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -657,7 +1179,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -670,6 +1192,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 00000000..16abcc07 Binary files /dev/null and b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..f22e10cd --- /dev/null +++ b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Ghostpass/Assets.xcassets/Contents.json b/apps/ios/Ghostpass/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/apps/ios/Ghostpass/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/Contents.json b/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/Contents.json new file mode 100644 index 00000000..0a063793 --- /dev/null +++ b/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "LogoMark@1x.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "LogoMark@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "LogoMark@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@1x.png b/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@1x.png new file mode 100644 index 00000000..fb6e205f Binary files /dev/null and b/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@1x.png differ diff --git a/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@2x.png b/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@2x.png new file mode 100644 index 00000000..b826d988 Binary files /dev/null and b/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@2x.png differ diff --git a/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@3x.png b/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@3x.png new file mode 100644 index 00000000..fc5d0604 Binary files /dev/null and b/apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@3x.png differ diff --git a/apps/ios/Ghostpass/Ghostpass.entitlements b/apps/ios/Ghostpass/Ghostpass.entitlements new file mode 100644 index 00000000..5f2757f4 --- /dev/null +++ b/apps/ios/Ghostpass/Ghostpass.entitlements @@ -0,0 +1,16 @@ + + + + + + com.apple.security.application-groups + + group.ch.stackops.ghostpass + + + com.apple.developer.authentication-services.autofill-credential-provider + + + diff --git a/apps/ios/Ghostpass/GhostpassApp.swift b/apps/ios/Ghostpass/GhostpassApp.swift new file mode 100644 index 00000000..c6357aab --- /dev/null +++ b/apps/ios/Ghostpass/GhostpassApp.swift @@ -0,0 +1,57 @@ +import SwiftUI + +@main +struct GhostpassApp: App { + init() { + // Les barres de navigation gardent leurs teintes système, qui jurent avec la nuit + // de la suite : on les aligne une fois pour toutes plutôt qu'écran par écran. + let barre = UINavigationBarAppearance() + barre.configureWithTransparentBackground() + barre.titleTextAttributes = [.foregroundColor: UIColor(Color.gpInk)] + barre.largeTitleTextAttributes = [.foregroundColor: UIColor(Color.gpInk)] + UINavigationBar.appearance().standardAppearance = barre + UINavigationBar.appearance().scrollEdgeAppearance = barre + UINavigationBar.appearance().compactAppearance = barre + } + + @StateObject private var store = VaultStore() + @StateObject private var prefs = Preferences.shared + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + Group { + if store.isUnlocked { + VaultListView() + } else { + UnlockView() + } + } + // Le sélecteur d'applications photographie l'écran à la sortie. Si le coffre + // reste ouvert derrière, son contenu se retrouverait dans cette vignette, et + // dans les captures que le système garde sur disque. + .overlay { + if scenePhase != .active && store.isUnlocked { + VoileDeConfidentialite() + } + } + .environmentObject(store) + .environmentObject(prefs) + // Les deux réglages s'appliquent à la racine : tout ce qui est présenté + // par-dessus — feuilles, alertes — en hérite, alors qu'un réglage posé + // écran par écran laisserait des îlots dans l'autre thème ou l'autre langue. + .preferredColorScheme(prefs.colorScheme) + .environment(\.locale, prefs.locale ?? Locale.autoupdatingCurrent) + } + .onChange(of: scenePhase) { _, phase in + // Un coffre qui reste ouvert pendant que le téléphone circule n'est plus un + // coffre — mais refermer à chaque aller-retour vers Safari fait renoncer à + // s'en servir. Le délai est donc réglable, et vaut zéro par défaut. + switch phase { + case .background: store.noterLaSortieDeLEcran(delai: prefs.verrouillage.delai) + case .active: store.verrouillerSiLeDelaiEstEcoule(delai: prefs.verrouillage.delai) + default: break + } + } + } +} diff --git a/apps/ios/Ghostpass/InfoPlist.xcstrings b/apps/ios/Ghostpass/InfoPlist.xcstrings new file mode 100644 index 00000000..a4f2c466 --- /dev/null +++ b/apps/ios/Ghostpass/InfoPlist.xcstrings @@ -0,0 +1,52 @@ +{ + "sourceLanguage" : "fr", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GhostPass" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GhostPass" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "new", + "value" : "Ghostpass" + } + } + } + }, + "NSFaceIDUsageDescription" : { + "comment" : "Privacy - Face ID Usage Description", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID unlocks your vault without retyping your master password." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID déverrouille votre coffre sans ressaisir le mot de passe maître." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/apps/ios/Ghostpass/Model/VaultItem.swift b/apps/ios/Ghostpass/Model/VaultItem.swift new file mode 100644 index 00000000..a2042c4d --- /dev/null +++ b/apps/ios/Ghostpass/Model/VaultItem.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Miroir Swift du `VaultItem` du cœur Rust. Les noms de champs et le balisage de +/// l'énumération sont ceux de serde : toute divergence ici se traduirait par un item +/// que le cœur refuse de déchiffrer, ou pire, par un champ perdu en silence. +struct VaultItem: Codable, Hashable { + var name: String + var notes: String? + var folder: String? + var data: ItemData +} + +/// `#[serde(tag = "kind", content = "data")]` côté Rust : `{"kind":"Login","data":{…}}`. +enum ItemData: Hashable { + case login(Login) + case secureNote(SecureNote) + case card(Card) +} + +struct Login: Codable, Hashable { + var username: String = "" + var password: String = "" + var uris: [String] = [] + var totp: String? + /// Anciens mots de passe, plus récent en tête. + var passwordHistory: [String] = [] + + enum CodingKeys: String, CodingKey { + case username, password, uris, totp + case passwordHistory = "password_history" + } +} + +struct SecureNote: Codable, Hashable { + var content: String = "" +} + +struct Card: Codable, Hashable { + var cardholder: String = "" + var number: String = "" + var expMonth: String = "" + var expYear: String = "" + var code: String = "" + + enum CodingKeys: String, CodingKey { + case cardholder, number, code + case expMonth = "exp_month" + case expYear = "exp_year" + } +} + +extension ItemData: Codable { + private enum CodingKeys: String, CodingKey { + case kind, data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "Login": self = .login(try container.decode(Login.self, forKey: .data)) + case "SecureNote": self = .secureNote(try container.decode(SecureNote.self, forKey: .data)) + case "Card": self = .card(try container.decode(Card.self, forKey: .data)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, in: container, + debugDescription: "type d'item inconnu : \(kind)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .login(let value): + try container.encode("Login", forKey: .kind) + try container.encode(value, forKey: .data) + case .secureNote(let value): + try container.encode("SecureNote", forKey: .kind) + try container.encode(value, forKey: .data) + case .card(let value): + try container.encode("Card", forKey: .kind) + try container.encode(value, forKey: .data) + } + } +} + +/// Un item tel qu'il vit dans l'app : le contenu déchiffré, plus l'identité serveur. +struct VaultEntry: Identifiable, Hashable { + let id: String + var item: VaultItem + /// Millisecondes depuis l'epoch, telles que renvoyées par le serveur. + var updatedAt: Int? + + var login: Login? { + if case .login(let l) = item.data { return l } + return nil + } +} + +enum VaultConstants { + /// Le coffre ne connaît que des éléments chiffrés : tout ce que l'application doit + /// retenir en plus — la liste des dossiers vides, celle des favoris — vit donc dans + /// des éléments comme les autres, sous un nom commençant par un octet NUL. Aucun nom + /// choisi par l'utilisateur ne peut entrer en collision avec celui-là, et l'interface + /// masque tout ce qui commence ainsi plutôt que d'énumérer les registres un par un : + /// c'est ce qui permet d'en ajouter un sans faire apparaître de ligne fantôme dans + /// les autres applications de la suite. + static let registryPrefix = "\u{0}gp:" + + /// Les dossiers vides — ceux qu'aucun élément n'habite. + static let foldersItemName = registryPrefix + "folders" + + /// Les identifiants des éléments mis en favori. + static let favoritesItemName = registryPrefix + "favorites" + + /// Combien d'anciens mots de passe un élément conserve. Le même nombre que la web + /// app : un historique plus long d'un côté que de l'autre ferait croire à une perte. + static let passwordHistoryLimit = 20 +} diff --git a/apps/ios/Ghostpass/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings new file mode 100644 index 00000000..b29ee05a --- /dev/null +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -0,0 +1,3193 @@ +{ + "sourceLanguage": "fr", + "strings": { + "": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "" + } + } + } + }, + " — effacé dans %lld s": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " — cleared in %lld s" + } + } + } + }, + "%1$@ n'a pas pu être activé (code %2$d).": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ could not be enabled (code %2$d)." + } + } + } + }, + "%1$d membres · %2$d collections": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$d members · %2$d collections" + } + } + } + }, + "%@ n'a pas permis d'ouvrir le coffre.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ could not open the vault." + } + } + } + }, + "%@ n'a pas pu être activé (code %d).": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ could not be enabled (code %d)." + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "%1$@ n'a pas pu être activé (code %2$d)." + } + } + } + }, + "%@ ne pourra plus ouvrir son coffre.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ will no longer be able to open their vault." + } + } + } + }, + "%d jours": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d days" + } + } + } + }, + "%lld": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld" + } + } + } + }, + "%lld entrées importées.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld entries imported." + } + } + } + }, + "%lld mots de passe à revoir": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld passwords to review" + } + } + } + }, + "%lld s": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld s" + } + } + } + }, + "%lld éléments examinés": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld items examined" + } + } + } + }, + "(sans nom)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(no name)" + } + } + } + }, + "123456": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "123456" + } + } + } + }, + "AAAA": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "YYYY" + } + } + } + }, + "Accepter": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept" + } + } + } + }, + "Accepter vous donnera accès aux coffres que l'équipe partage avec vous.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accepting will give you access to the vaults the team shares with you." + } + } + } + }, + "Accès automatique le %@ si vous ne faites rien.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access opens automatically on %@ unless you act." + } + } + } + }, + "Accès d'urgence": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Emergency access" + } + } + } + }, + "Accès demandé": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access requested" + } + } + } + }, + "Accès ouvert": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access open" + } + } + } + }, + "Accès possible à partir du %@.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access available from %@." + } + } + } + }, + "Accès révoqué": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access revoked" + } + } + } + }, + "Accès révoqués": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Revoked access" + } + } + } + }, + "Activer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn on" + } + } + } + }, + "Activer %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn on %@" + } + } + } + }, + "Administrateur": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Administrator" + } + } + } + }, + "Administration": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Administration" + } + } + } + }, + "Adresse copiée": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Address copied" + } + } + } + }, + "Adresse de serveur invalide.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid server address." + } + } + } + }, + "Adresse du site": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Website address" + } + } + } + }, + "Adresse e-mail": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email address" + } + } + } + }, + "Adresses": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Addresses" + } + } + } + }, + "Afficher les logos": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show logos" + } + } + } + }, + "Ajoutez un identifiant avec le bouton +.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add a login with the + button." + } + } + } + }, + "Ancien mot de passe copié": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous password copied" + } + } + } + }, + "Anciens mots de passe (%lld)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous passwords (%lld)" + } + } + } + }, + "Annuler": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } + }, + "Aperçu": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" + } + } + } + }, + "Apparence": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + } + } + }, + "Après 1 minute": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After 1 minute" + } + } + } + }, + "Après 15 minutes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After 15 minutes" + } + } + } + }, + "Après 5 minutes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After 5 minutes" + } + } + } + }, + "Aucun accès": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No access" + } + } + } + }, + "Aucun dossier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No folders" + } + } + } + }, + "Aucun identifiant": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No logins" + } + } + } + }, + "Aucun mot de passe connu des fuites publiques.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No password known to public breaches." + } + } + } + }, + "Aucune clé ne vous a encore été remise pour ce coffre.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No key has been handed to you for this vault yet." + } + } + } + }, + "Aucune collection ne vous est ouverte dans cette équipe. Un administrateur doit vous y donner accès.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No collection is open to you in this team. An administrator must grant you access." + } + } + } + }, + "Aucune collection.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No collection." + } + } + } + }, + "Aucune entrée trouvée : le fichier est vide, ou ses colonnes ne sont pas reconnues.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No entry found: the file is empty, or its columns are not recognised." + } + } + } + }, + "Aucune session enregistrée sur cet appareil.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No session saved on this device." + } + } + } + }, + "Autres identifiants": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other logins" + } + } + } + }, + "Bon": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Good" + } + } + } + }, + "Caractères": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Characters" + } + } + } + }, + "Carte": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Card" + } + } + } + }, + "Ce coffre est vide.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This vault is empty." + } + } + } + }, + "Ce qu'il pourra faire": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What they will be able to do" + } + } + } + }, + "Ce que d'autres vous ont laissé, et ce que vous pouvez en faire.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What others have left you, and what you can do with it." + } + } + } + }, + "Ce que le groupe ouvre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What the group opens" + } + } + } + }, + "Ces coffres ne s'ouvrent plus. Après une rotation de clé, ce que vous en aviez vu reste connu de vous — les mots de passe concernés doivent être changés par l'équipe.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These vaults no longer open. After a key rotation, what you already saw remains known to you — the passwords involved must be changed by the team." + } + } + } + }, + "Cette collection est vide.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This collection is empty." + } + } + } + }, + "Chiffres": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Digits" + } + } + } + }, + "Choisir un fichier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a file" + } + } + } + }, + "Choisir un nouveau mot de passe maître rendra le coffre inaccessible à son propriétaire actuel. À ne faire que s'il ne peut plus s'en servir lui-même.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Setting a new master password will lock the current owner out of the vault. Only do this if they can no longer use it themselves." + } + } + } + }, + "Choisissez le fichier CSV exporté par votre gestionnaire actuel. Les colonnes de Bitwarden, 1Password, LastPass et Chrome sont reconnues.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose the CSV file exported by your current manager. Bitwarden, 1Password, LastPass and Chrome column names are recognised." + } + } + } + }, + "Clair": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + } + } + }, + "Classement": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Organisation" + } + } + } + }, + "Clé de récupération": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recovery key" + } + } + } + }, + "Clé de récupération refusée.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recovery key refused." + } + } + } + }, + "Clé de vérification illisible.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unreadable verification key." + } + } + } + }, + "Clé ou URI otpauth://": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key or otpauth:// URI" + } + } + } + }, + "Code copié": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Code copied" + } + } + } + }, + "Code à six chiffres": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Six-digit code" + } + } + } + }, + "Code à usage unique": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One-time code" + } + } + } + }, + "Coffre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vault" + } + } + } + }, + "Coffre chiffré de bout en bout": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End-to-end encrypted vault" + } + } + } + }, + "Coffre enregistré sur cet appareil": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vault saved on this device" + } + } + } + }, + "Coffre indisponible": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vault unavailable" + } + } + } + }, + "Coffre vide": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Empty vault" + } + } + } + }, + "Coffres partagés": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shared vaults" + } + } + } + }, + "Coffres qui me sont confiés": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vaults entrusted to me" + } + } + } + }, + "Collection": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collection" + } + } + } + }, + "Collections": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collections" + } + } + } + }, + "Compte": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + } + } + }, + "Confier l'accès": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Grant access" + } + } + } + }, + "Confirmer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm" + } + } + } + }, + "Contact accepté": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Contact accepted" + } + } + } + }, + "Contenu": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Content" + } + } + } + }, + "Copier la clé": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy key" + } + } + } + }, + "Copier le code": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy code" + } + } + } + }, + "Copier le mot de passe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy password" + } + } + } + }, + "Copier le nom d'utilisateur": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy username" + } + } + } + }, + "Copiée": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied" + } + } + } + }, + "Corbeille": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trash" + } + } + } + }, + "Corbeille vide": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trash is empty" + } + } + } + }, + "Correct": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fair" + } + } + } + }, + "Cryptogramme": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Security code" + } + } + } + }, + "Créer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create" + } + } + } + }, + "Créer une clé de récupération": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create a recovery key" + } + } + } + }, + "Créez-en un, ou renseignez un dossier sur un élément.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create one, or set a folder on an item." + } + } + } + }, + "Créez-en une, ou attendez qu'on vous invite : les équipes dont vous ferez partie apparaîtront ici.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create one, or wait to be invited: the teams you belong to will appear here." + } + } + } + }, + "Demande refusée": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request denied" + } + } + } + }, + "Demander l'accès": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request access" + } + } + } + }, + "Demander l'accès préviendra %1$@, qui aura %2$d jours pour refuser.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Requesting access will notify %1$@, who will have %2$d days to deny it." + } + } + } + }, + "Dossier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folder" + } + } + } + }, + "Dossier — Travail/Serveurs": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folder — Work/Servers" + } + } + } + }, + "Dossiers": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folders" + } + } + } + }, + "Douze caractères au minimum. Il remplacera celui du propriétaire.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Twelve characters minimum. It will replace the owner's." + } + } + } + }, + "Désactiver %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn off %@" + } + } + } + }, + "Déverrouiller": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlock" + } + } + } + }, + "Déverrouiller avec %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlock with %@" + } + } + } + }, + "Elle doit déjà posséder un compte GhostPass : c'est sa clé publique qui protège celle de l'équipe.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "They must already have a GhostPass account: it is their public key that protects the team's." + } + } + } + }, + "Elle ne sera plus affichée. Sans elle, un mot de passe maître oublié rend le coffre définitivement illisible.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "It will not be shown again. Without it, a forgotten master password makes the vault permanently unreadable." + } + } + } + }, + "Enregistrer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + } + } + }, + "Entrées trouvées": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Entries found" + } + } + } + }, + "Erreur": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + } + } + }, + "Excellent": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Excellent" + } + } + } + }, + "Expiration": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expiry" + } + } + } + }, + "Exporter": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export" + } + } + } + }, + "Exporter le coffre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export the vault" + } + } + } + }, + "Faible": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weak" + } + } + } + }, + "Favoris": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favourites" + } + } + } + }, + "Fermer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + } + } + }, + "Fichier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File" + } + } + } + }, + "Fichier enregistré.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File saved." + } + } + } + }, + "Force": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Strength" + } + } + } + }, + "Fuites connues": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Known breaches" + } + } + } + }, + "Gestion": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage" + } + } + } + }, + "GhostPass": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GhostPass" + } + } + } + }, + "GitHub, Amazon…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub, Amazon…" + } + } + } + }, + "Groupes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Groups" + } + } + } + }, + "Générer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Generate" + } + } + } + }, + "Hors ligne — coffre affiché depuis cet appareil": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Offline — vault shown from this device" + } + } + } + }, + "Huit caractères au minimum.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Eight characters minimum." + } + } + } + }, + "Icônes des sites": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Site icons" + } + } + } + }, + "Identifiant": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Login" + } + } + } + }, + "Identifiants": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Credentials" + } + } + } + }, + "Il doit déjà posséder un compte GhostPass : c'est sa clé publique qui protège la vôtre.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "They must already have a GhostPass account: it is their public key that protects yours." + } + } + } + }, + "Ils pourront ouvrir votre coffre si vous ne le pouvez plus. Vous gardez la main tant que vous répondez.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "They can open your vault if you no longer can. You stay in control as long as you answer." + } + } + } + }, + "Immédiatement": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Immediately" + } + } + } + }, + "Importer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import" + } + } + } + }, + "Importer %lld entrées": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import %lld entries" + } + } + } + }, + "Importer un CSV": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import a CSV" + } + } + } + }, + "Impossible d'obtenir la clé publique de %@ : rotation annulée.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not obtain %@'s public key: rotation cancelled." + } + } + } + }, + "Invitation envoyée": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invitation sent" + } + } + } + }, + "Invitation reçue": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invitation received" + } + } + } + }, + "Invitations": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invitations" + } + } + } + }, + "Inviter": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invite" + } + } + } + }, + "Inviter quelqu'un": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invite someone" + } + } + } + }, + "Inviter quelqu'un lui scelle la clé de l'équipe vers sa clé publique. Le serveur ne transporte qu'un blob qu'il ne peut pas ouvrir.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inviting someone seals the team key to their public key. The server only carries a blob it cannot open." + } + } + } + }, + "La clé de l'équipe sera changée et redistribuée aux autres membres. Ce que cette personne a déjà vu reste connu d'elle : les mots de passe concernés doivent être changés.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The team key will be changed and redistributed to the other members. What this person already saw remains known to them: the passwords involved must be changed." + } + } + } + }, + "La clé de récupération est la seule issue. Notez-la et rangez-la ailleurs que dans ce coffre.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The recovery key is the only way out. Write it down and keep it somewhere other than this vault." + } + } + } + }, + "La clé du coffre est créée sur cet appareil et scellée pour vous seul. Le serveur n'en connaîtra jamais le contenu.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The vault key is created on this device and sealed for you alone. The server will never know its contents." + } + } + } + }, + "La clé reste chiffrée dans l'élément ; les codes sont calculés sur l'appareil.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The key stays encrypted in the item; codes are computed on the device." + } + } + } + }, + "La personne": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The person" + } + } + } + }, + "Langue": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + } + } + }, + "Le changement s'applique aussitôt, sans redémarrer l'application.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The change takes effect immediately, without restarting the app." + } + } + } + }, + "Le coffre est chiffré de bout en bout : le serveur n'en connaît pas le contenu. Réclamer une icône, en revanche, lui nomme un domaine. Aucun tiers n'est sollicité.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The vault is end-to-end encrypted: the server does not know its contents. Asking for an icon, however, names a domain to it. No third party is involved." + } + } + } + }, + "Le coffre local ne contient rien.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The local vault is empty." + } + } + } + }, + "Le contact": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The contact" + } + } + } + }, + "Le contact ne pourra plus ouvrir votre coffre. La clé qui lui avait été scellée devient inutilisable.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The contact will no longer be able to open your vault. The key sealed for them becomes unusable." + } + } + } + }, + "Le contact pourra en plus choisir un nouveau mot de passe maître — ce qui vous exclura de votre propre coffre.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The contact can also set a new master password — which will lock you out of your own vault." + } + } + } + }, + "Le contact pourra lire vos identifiants, sans rien y changer ni vous en priver.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The contact can read your credentials, without changing them or locking you out." + } + } + } + }, + "Le délai avant ouverture": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The delay before access opens" + } + } + } + }, + "Le fichier contiendra vos %lld entrées, mots de passe compris, en clair. Il se relit dans n'importe quel gestionnaire — et par n'importe qui.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file will hold your %lld entries, passwords included, in the clear. Any manager can read it — and so can anyone." + } + } + } + }, + "Le fichier est lu sur l'appareil et rien d'autre n'en sort : chaque entrée est chiffrée avant d'être déposée. Pensez à l'effacer ensuite — un export CSV contient vos mots de passe en clair.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file is read on the device and nothing else leaves it: each entry is encrypted before being uploaded. Remember to delete it afterwards — a CSV export holds your passwords in the clear." + } + } + } + }, + "Le fichier n'a pas pu être lu.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file could not be read." + } + } + } + }, + "Le fichier n'a pas pu être ouvert.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file could not be opened." + } + } + } + }, + "Le nouveau mot de passe rechiffre la clé du coffre. Son contenu reste intact : rien n'est perdu, rien n'est déchiffré côté serveur.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The new password re-encrypts the vault key. Its contents stay intact: nothing is lost, nothing is decrypted on the server." + } + } + } + }, + "Le propriétaire a refusé.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The owner denied the request." + } + } + } + }, + "Le second facteur protège même un mot de passe connu.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A second factor protects even a known password." + } + } + } + }, + "Le service de vérification n'a pas répondu.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The checking service did not respond." + } + } + } + }, + "Lecture": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read" + } + } + } + }, + "Lecture pour consulter, écriture pour modifier, gestion pour régler les accès de la collection.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read to consult, write to modify, manage to set the collection's access." + } + } + } + }, + "Lecture seule": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View only" + } + } + } + }, + "Les coffres partagés se créent depuis l'application web ; ils apparaîtront ici dès qu'on vous y aura invité.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shared vaults are created from the web app; they will appear here once you are invited." + } + } + } + }, + "Les deux saisies diffèrent.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The two entries differ." + } + } + } + }, + "Les données ne sont pas perdues : elles restent chiffrées par la même clé, seule la façon de l'ouvrir change. Mais elle ne s'ouvrira plus qu'avec le mot de passe que vous choisissez ici.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The data is not lost: it stays encrypted under the same key, only the way to unlock it changes. But it will only unlock with the password you choose here." + } + } + } + }, + "Les éléments supprimés atterrissent ici.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deleted items land here." + } + } + } + }, + "Longueur — %lld caractères": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Length — %lld characters" + } + } + } + }, + "MM": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "MM" + } + } + } + }, + "Majuscules": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uppercase" + } + } + } + }, + "Membre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Member" + } + } + } + }, + "Membre actif": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active member" + } + } + } + }, + "Membres": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Members" + } + } + } + }, + "Membres du groupe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Group members" + } + } + } + }, + "Mes contacts de confiance": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "My trusted contacts" + } + } + } + }, + "Mes équipes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "My teams" + } + } + } + }, + "Mettez-le à l'abri, puis effacez-le : tant qu'il existe, vos mots de passe sont lisibles par qui met la main dessus.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Put it somewhere safe, then delete it: as long as it exists, your passwords are readable by whoever gets hold of it." + } + } + } + }, + "Mettre en favori": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add to favourites" + } + } + } + }, + "Minuscules": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lowercase" + } + } + } + }, + "Modifier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + } + } + }, + "Modifier ce membre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit this member" + } + } + } + }, + "Mot de passe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, + "Mot de passe copié": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password copied" + } + } + } + }, + "Mot de passe maître": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Master password" + } + } + } + }, + "Mot de passe maître incorrect.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Incorrect master password." + } + } + } + }, + "Mot de passe maître oublié ?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forgotten master password?" + } + } + } + }, + "Mot de passe oublié": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forgotten password" + } + } + } + }, + "Mot de passe réinitialisé. Connectez-vous avec le nouveau.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password reset. Sign in with the new one." + } + } + } + }, + "Mots de passe faibles": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weak passwords" + } + } + } + }, + "Mots de passe réutilisés": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reused passwords" + } + } + } + }, + "Moyen": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium" + } + } + } + }, + "N'oubliez pas d'effacer le fichier CSV : il contient vos mots de passe en clair.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Do not forget to delete the CSV file: it holds your passwords in the clear." + } + } + } + }, + "Nom": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + } + } + }, + "Nom d'utilisateur": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + } + } + }, + "Nom d'utilisateur copié": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Username copied" + } + } + } + }, + "Nom de l'équipe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Team name" + } + } + } + }, + "Note": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Note" + } + } + } + }, + "Notes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notes" + } + } + } + }, + "Nouveau groupe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New group" + } + } + } + }, + "Nouveau mot de passe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New password" + } + } + } + }, + "Nouveau mot de passe maître": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New master password" + } + } + } + }, + "Nouvel élément": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New item" + } + } + } + }, + "Nouvelle collection": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New collection" + } + } + } + }, + "Nouvelle équipe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New team" + } + } + } + }, + "Numéro": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Number" + } + } + } + }, + "Numéro copié": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Number copied" + } + } + } + }, + "OK": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + } + } + }, + "Ouvrez GhostPass et connectez-vous une fois.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open GhostPass and sign in once." + } + } + } + }, + "Ouvrez GhostPass une fois pour activer le remplissage.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open GhostPass once to enable autofill." + } + } + } + }, + "Ouvrir le coffre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open the vault" + } + } + } + }, + "Ouvrir maintenant": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open now" + } + } + } + }, + "Pendant ce délai, le coffre reste ouvert en mémoire — jamais sur le disque — et son contenu est masqué dans le sélecteur d'applications.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "During this delay the vault stays open in memory — never on disk — and its contents are hidden in the app switcher." + } + } + } + }, + "Personne ne vous a désigné.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No one has named you." + } + } + } + }, + "Personne pour l'instant.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No one yet." + } + } + } + }, + "Peut inviter, révoquer et gérer les droits.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can invite, revoke and manage permissions." + } + } + } + }, + "Peut lire et modifier les identifiants partagés.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can read and edit the shared credentials." + } + } + } + }, + "Peut lire, sans rien modifier.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can read, without modifying anything." + } + } + } + }, + "Plus tard": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Later" + } + } + } + }, + "Pour ce site": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "For this site" + } + } + } + }, + "Pour confirmer que c'est bien vous": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To confirm it is really you" + } + } + } + }, + "Préparer le fichier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prepare the file" + } + } + } + }, + "Quand le contact demandera l'accès, vous serez prévenu et pourrez refuser pendant ce temps. Passé ce délai sans réponse de votre part, l'accès s'ouvre — c'est précisément ce qui le rend utile le jour où vous ne pouvez plus répondre.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When the contact requests access, you are notified and can deny it during this window. Once it elapses without an answer from you, access opens — which is exactly what makes it useful the day you can no longer answer." + } + } + } + }, + "Rechercher": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + } + } + }, + "Refuser": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deny" + } + } + } + }, + "Rejoindre l'équipe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Join the team" + } + } + } + }, + "Remplacé": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replaced" + } + } + } + }, + "Renouvelé toutes les %lld secondes.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renewed every %lld seconds." + } + } + } + }, + "Reprendre le compte": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Take over the account" + } + } + } + }, + "Reprise du compte": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account takeover" + } + } + } + }, + "Restaurer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore" + } + } + } + }, + "Retirer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + } + } + }, + "Retirer cet accès": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this access" + } + } + } + }, + "Retirer cet accès ?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this access?" + } + } + } + }, + "Retirer des favoris": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from favourites" + } + } + } + }, + "Retirer l'accès": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove access" + } + } + } + }, + "Revenir au coffre enregistré": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to saved vault" + } + } + } + }, + "Rien n'est encore déposé. Les entrées rejoindront le coffre telles quelles ; aucune n'écrase ce qui s'y trouve déjà.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing has been uploaded yet. The entries will join the vault as they are; none overwrites what is already there." + } + } + } + }, + "Rien à signaler": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing to report" + } + } + } + }, + "Réglages": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + } + } + }, + "Régénérer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Regenerate" + } + } + } + }, + "Réinitialiser": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + } + } + }, + "Réponse du serveur incompréhensible.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The server’s response could not be understood." + } + } + } + }, + "Révoquer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Revoke" + } + } + } + }, + "Révoquer ce membre ?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Revoke this member?" + } + } + } + }, + "Sans code à usage unique": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Without a one-time code" + } + } + } + }, + "Santé du coffre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vault health" + } + } + } + }, + "Se connecter": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in" + } + } + } + }, + "Se déconnecter": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out" + } + } + } + }, + "Serveur": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server" + } + } + } + }, + "Serveur injoignable : l'import s'est arrêté en chemin.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: the import stopped partway." + } + } + } + }, + "Serveur injoignable : l'item n'a pas été restauré.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: the item was not restored." + } + } + } + }, + "Serveur injoignable : l'item n'a pas été supprimé.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: the item was not deleted." + } + } + } + }, + "Serveur injoignable : la clé de récupération n'a pas été enregistrée.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: the recovery key was not saved." + } + } + } + }, + "Serveur injoignable : la modification n'a pas été enregistrée.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: the change was not saved." + } + } + } + }, + "Serveur injoignable : la suppression n'a pas été enregistrée.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: the deletion was not saved." + } + } + } + }, + "Serveur injoignable : les dossiers n'ont pas été enregistrés.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: folders were not saved." + } + } + } + }, + "Serveur injoignable : les favoris n'ont pas été enregistrés.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: favourites were not saved." + } + } + } + }, + "Serveur injoignable.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable." + } + } + } + }, + "Ses membres perdront les accès qu'il leur donnait. Les identifiants, eux, restent dans leurs collections.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Its members will lose the access it granted them. The credentials themselves stay in their collections." + } + } + } + }, + "Seuls les cinq premiers caractères de l'empreinte du mot de passe sont envoyés : ni le mot de passe ni son empreinte complète ne quittent l'appareil.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only the first five characters of the password’s hash are sent: neither the password nor its full hash leaves the device." + } + } + } + }, + "Solide": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Strong" + } + } + } + }, + "Sombre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + } + } + }, + "Son rôle": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Their role" + } + } + } + }, + "Supprimer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + } + } + }, + "Supprimer ce dossier ?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this folder?" + } + } + } + }, + "Supprimer ce groupe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this group" + } + } + } + }, + "Supprimer ce groupe ?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this group?" + } + } + } + }, + "Supprimer définitivement ?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete permanently?" + } + } + } + }, + "Symboles": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Symbols" + } + } + } + }, + "Système": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + } + } + }, + "Terminé": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + } + } + }, + "Titulaire": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cardholder" + } + } + } + }, + "Titulaire copié": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cardholder copied" + } + } + } + }, + "Tous les éléments": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All items" + } + } + } + }, + "Travail/Serveurs": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Work/Servers" + } + } + } + }, + "Trop courts, ou faits d'une seule sorte de caractères.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too short, or made of a single kind of character." + } + } + } + }, + "Très bon": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Very good" + } + } + } + }, + "Très faible": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Very weak" + } + } + } + }, + "Un coffre chiffré de bout en bout ne se rouvre pas sans son mot de passe maître : personne, pas même le serveur, ne peut le retrouver à votre place.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An end-to-end encrypted vault does not reopen without its master password: nobody, not even the server, can retrieve it for you." + } + } + } + }, + "Un groupe donne accès à des collections. C'est par lui qu'on ouvre un coffre à plusieurs personnes sans les nommer une à une.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A group grants access to collections. It is how you open a vault to several people without naming them one by one." + } + } + } + }, + "Une seule fuite suffit alors à ouvrir plusieurs comptes.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A single breach then opens several accounts." + } + } + } + }, + "Utiliser": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use" + } + } + } + }, + "Utiliser %@ ?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use %@?" + } + } + } + }, + "Utiliser un autre compte": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use another account" + } + } + } + }, + "Verrouillage": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Locking" + } + } + } + }, + "Verrouiller": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock" + } + } + } + }, + "Votre clé de récupération": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your recovery key" + } + } + } + }, + "Votre mot de passe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your password" + } + } + } + }, + "Votre mot de passe maître sera conservé dans le trousseau de cet appareil, relisible par %@ seul.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your master password will be kept in this device’s keychain, readable by %@ alone." + } + } + } + }, + "Votre note": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your note" + } + } + } + }, + "Vous consultez le coffre de quelqu'un d'autre. Cet accès est enregistré dans son journal.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You are viewing someone else's vault. This access is recorded in their audit log." + } + } + } + }, + "Vous n'appartenez à aucune équipe.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You do not belong to any team." + } + } + } + }, + "Vérification…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking…" + } + } + } + }, + "Vérifier les fuites": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for breaches" + } + } + } + }, + "adresse@exemple.com": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "address@example.com" + } + } + } + }, + "et %lld autres": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "and %lld more" + } + } + } + }, + "https://ghostpass.stackops.ch": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://ghostpass.stackops.ch" + } + } + } + }, + "vous@exemple.ch": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + } + } + }, + "« %@ » disparaîtra de la liste. Aucun élément n'est supprimé.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "“%@” will disappear from the list. No item is deleted." + } + } + } + }, + "« %@ » sera perdu, sans possibilité de retour.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "“%@” will be lost, with no way back." + } + } + } + }, + "« Système » suit le réglage de l'appareil, y compris son passage automatique à la nuit.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "“System” follows the device setting, including its automatic switch to night." + } + } + } + }, + "À importer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To import" + } + } + } + }, + "Écriture": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Write" + } + } + } + }, + "≈ %lld bits": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "≈ %lld bits" + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/ios/Ghostpass/Services/APIClient.swift b/apps/ios/Ghostpass/Services/APIClient.swift new file mode 100644 index 00000000..42284ee0 --- /dev/null +++ b/apps/ios/Ghostpass/Services/APIClient.swift @@ -0,0 +1,601 @@ +import Foundation + +/// Erreurs remontées à l'UI. Le message du serveur est repris tel quel : il est +/// délibérément générique côté serveur (anti-énumération), donc le détailler ici +/// n'apporterait rien et risquerait d'en dire plus que voulu. +enum APIError: LocalizedError, Equatable { + case badURL + case http(status: Int, message: String) + case mfaRequired(type: String) + case malformedResponse + + var errorDescription: String? { + switch self { + case .badURL: return "Adresse de serveur invalide." + case .http(_, let message): return message + case .mfaRequired: return "Second facteur requis." + case .malformedResponse: return "Réponse inattendue du serveur." + } + } +} + +struct PreloginResponse: Decodable { + /// Le serveur stocke les paramètres KDF en colonne TEXT et les renvoie **tels quels** : + /// `kdfParams` est donc une chaîne contenant du JSON, pas un objet JSON. C'est + /// exactement ce que le cœur Rust attend, et c'est ce que fait déjà la web app. + /// La décoder puis la ré-encoder produirait une chaîne doublement échappée, que + /// `serde` rejette (« invalid type: string, expected struct KdfParams »). + let kdfParams: String +} + +/// Ce que le serveur rend pour tenter une récupération. `kdfParams` est ici aussi une +/// chaîne contenant du JSON, pour la même raison qu'au prélogin. +struct RecoveryBlobResponse: Decodable { + let kdfParams: String + let encryptedUserKeyRecovery: String + let encryptedPrivateKey: String +} + +struct LoginResponse: Decodable { + let token: String + let kdfParams: String + let encryptedUserKey: String + let encryptedPrivateKey: String +} + +struct EncryptedItemDTO: Codable, Identifiable { + let id: String + let encryptedKey: String + let encryptedData: String + /// Horodatages en millisecondes depuis l'epoch : les colonnes sont des `INTEGER` + /// et le serveur les expose tels quels. Les attendre en `String` faisait échouer + /// le décodage de la liste entière, donc du coffre entier. + var updatedAt: Int? + var deletedAt: Int? +} + +private struct ItemsEnvelope: Decodable { + let items: [EncryptedItemDTO] +} + +private struct ItemBody: Encodable { + let encryptedKey: String + let encryptedData: String +} + +private struct ServerError: Decodable { + let error: String? + let mfaRequired: Bool? + let mfaType: String? +} + +// ─── Accès d'urgence ─── + +/// Un lien d'accès d'urgence, vu depuis l'un ou l'autre bout. +struct EmergencyContactDTO: Decodable, Identifiable { + let id: String + let contactEmail: String + let role: String + let waitDays: Int + let status: String + let requestedAt: Int? + /// Renseigné seulement dans le sens « je suis le contact » : le délai est-il écoulé. + var available: Bool? +} + +struct EmergencyListDTO: Decodable { + let asGrantor: [EmergencyContactDTO] + let asGrantee: [EmergencyContactDTO] +} + +/// Coffre du donneur, tel que le serveur le remet au contact une fois l'accès ouvert. +struct EmergencyAccessDTO: Decodable { + let role: String + let sealedUserKey: String + let grantorPublicKey: String + let grantorEmail: String + /// Chaîne contenant du JSON, comme au prélogin : colonne TEXT rendue telle quelle. + let grantorKdfParams: String + let items: [EncryptedItemDTO] +} + +private struct PublicKeyDTO: Decodable { + let userId: String + let publicKey: String +} + +private struct EmergencyInviteBody: Encodable { + let email: String + let role: String + let waitDays: Int + let sealedUserKey: String +} + +private struct TakeoverBody: Encodable { + let masterPasswordHash: String + let encryptedUserKey: String +} + + +// ─── Organisations ─── + +struct OrgSummaryDTO: Decodable, Identifiable { + let orgId: String + let name: String + let role: String + let status: String + + var id: String { orgId } +} + +private struct OrgListDTO: Decodable { + let organizations: [OrgSummaryDTO] +} + +/// Ce qu'il faut pour ouvrir le coffre d'une organisation : l'Org Key scellée pour nous, et +/// la clé publique de l'admin qui l'a scellée — sans elle, on ne pourrait pas vérifier +/// qu'elle vient bien de lui, et un serveur actif pourrait en substituer une autre. +struct OrgMembershipDTO: Decodable { + let role: String + let status: String + let encryptedOrgKey: String? + let sealedByPublicKey: String? +} + +struct OrgCollectionDTO: Decodable, Identifiable { + let id: String + let name: String +} + +private struct OrgCollectionsDTO: Decodable { + let collections: [OrgCollectionDTO] +} + +/// Un membre d'organisation, vu par un administrateur. +struct OrgMemberDTO: Decodable, Identifiable { + let userId: String + let email: String? + let role: String + let status: String + + var id: String { userId } +} + +private struct OrgMembersDTO: Decodable { + let members: [OrgMemberDTO] +} + +struct OrgGroupMemberDTO: Decodable, Identifiable { + let userId: String + let email: String? + + var id: String { userId } +} + +struct OrgGroupCollectionDTO: Decodable, Identifiable { + let collectionId: String + let permission: String + + var id: String { collectionId } +} + +struct OrgGroupDTO: Decodable, Identifiable { + let id: String + let name: String + let members: [OrgGroupMemberDTO] + let collections: [OrgGroupCollectionDTO] +} + +private struct OrgGroupsDTO: Decodable { + let groups: [OrgGroupDTO] +} + +private struct CreateOrgBody: Encodable { + let name: String + let encryptedOrgKey: String +} + +private struct AddMemberBody: Encodable { + let email: String + let role: String + let encryptedOrgKey: String +} + +private struct RoleBody: Encodable { + let role: String +} + +private struct NameBody: Encodable { + let name: String +} + +private struct UserIdBody: Encodable { + let userId: String +} + +private struct PermissionBody: Encodable { + let permission: String +} + +/// Une rotation d'Org Key : la nouvelle clé redistribuée à chaque membre restant, et toutes +/// les item keys ré-enveloppées. Le tout part en une seule requête — le serveur l'applique +/// dans une transaction, faute de quoi une rotation interrompue laisserait un coffre dont +/// une partie serait illisible pour tout le monde. +struct RotationBody: Encodable { + let revokeUserId: String? + let members: [MembreScelle] + let items: [ItemReenveloppe] + + struct MembreScelle: Encodable { + let userId: String + let encryptedOrgKey: String + } + + struct ItemReenveloppe: Encodable { + let id: String + let encryptedKey: String + } +} + +/// Client HTTP du serveur GhostPass. Il ne voit jamais que du chiffré : le clair +/// n'existe que de l'autre côté de la frontière FFI. +struct APIClient { + var baseURL: URL + var session: URLSession = .shared + + private func request( + _ method: String, _ path: String, token: String? = nil, body: Data? = nil + ) async throws -> Data { + guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.badURL } + var req = URLRequest(url: url) + req.httpMethod = method + req.httpBody = body + if body != nil { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + if let token { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { throw APIError.malformedResponse } + guard (200..<300).contains(http.statusCode) else { + let parsed = try? JSONDecoder().decode(ServerError.self, from: data) + if parsed?.mfaRequired == true { + throw APIError.mfaRequired(type: parsed?.mfaType ?? "totp") + } + throw APIError.http( + status: http.statusCode, + message: parsed?.error ?? "Erreur serveur (\(http.statusCode)).") + } + return data + } + + private func decode(_ type: T.Type, from data: Data) throws -> T { + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw APIError.malformedResponse + } + } + + // ─── Authentification ─── + + func prelogin(email: String) async throws -> PreloginResponse { + let body = try JSONEncoder().encode(["email": email]) + return try decode(PreloginResponse.self, from: await request("POST", "api/auth/prelogin", body: body)) + } + + func login(email: String, masterPasswordHash: String, totpCode: String?) async throws + -> LoginResponse + { + var payload: [String: String] = ["email": email, "masterPasswordHash": masterPasswordHash] + if let totpCode, !totpCode.isEmpty { payload["totpCode"] = totpCode } + let body = try JSONEncoder().encode(payload) + return try decode(LoginResponse.self, from: await request("POST", "api/auth/login", body: body)) + } + + func logout(token: String) async throws { + _ = try await request("POST", "api/auth/logout", token: token) + } + + // ─── Récupération de compte ─── + // + // Le serveur ne voit jamais la clé de récupération : il reçoit une preuve dérivée + // d'elle, qu'il re-hache avant de la stocker, et une copie de la clé du coffre + // enveloppée par cette même clé de récupération. Sans la clé, les deux ne valent rien. + + func enrollRecovery(token: String, recoveryAuthHash: String, encryptedUserKeyRecovery: String) + async throws + { + let body = try JSONEncoder().encode([ + "recoveryAuthHash": recoveryAuthHash, + "encryptedUserKeyRecovery": encryptedUserKeyRecovery, + ]) + _ = try await request("POST", "api/account/recovery", token: token, body: body) + } + + /// Les blobs nécessaires à une tentative de récupération. Le serveur répond de la même + /// façon pour un compte sans kit — avec des leurres — pour ne pas révéler qui existe. + func recoveryBlob(email: String) async throws -> RecoveryBlobResponse { + let body = try JSONEncoder().encode(["email": email]) + return try decode( + RecoveryBlobResponse.self, + from: await request("POST", "api/auth/recovery-blob", body: body)) + } + + func recover( + email: String, recoveryAuthHash: String, newMasterPasswordHash: String, + newEncryptedUserKey: String + ) async throws { + let body = try JSONEncoder().encode([ + "email": email, + "recoveryAuthHash": recoveryAuthHash, + "newMasterPasswordHash": newMasterPasswordHash, + "newEncryptedUserKey": newEncryptedUserKey, + ]) + _ = try await request("POST", "api/auth/recover", body: body) + } + + // ─── Coffre ─── + + func listItems(token: String) async throws -> [EncryptedItemDTO] { + try decode(ItemsEnvelope.self, from: await request("GET", "api/vault/items", token: token)).items + } + + func createItem(token: String, encryptedKey: String, encryptedData: String) async throws + -> EncryptedItemDTO + { + let body = try JSONEncoder().encode( + ItemBody(encryptedKey: encryptedKey, encryptedData: encryptedData)) + return try decode( + EncryptedItemDTO.self, + from: await request("POST", "api/vault/items", token: token, body: body)) + } + + func updateItem(token: String, id: String, encryptedKey: String, encryptedData: String) + async throws -> EncryptedItemDTO + { + let body = try JSONEncoder().encode( + ItemBody(encryptedKey: encryptedKey, encryptedData: encryptedData)) + return try decode( + EncryptedItemDTO.self, + from: await request("PUT", "api/vault/items/\(id)", token: token, body: body)) + } + + /// Suppression douce : l'item part à la corbeille côté serveur. + func deleteItem(token: String, id: String) async throws { + _ = try await request("DELETE", "api/vault/items/\(id)", token: token) + } + + // ─── Corbeille ─── + + func listTrash(token: String) async throws -> [EncryptedItemDTO] { + try decode(ItemsEnvelope.self, from: await request("GET", "api/vault/trash", token: token)) + .items + } + + func restoreItem(token: String, id: String) async throws { + _ = try await request("POST", "api/vault/trash/\(id)/restore", token: token) + } + + /// Suppression définitive : l'item ne revient pas. + func purgeItem(token: String, id: String) async throws { + _ = try await request("DELETE", "api/vault/trash/\(id)", token: token) + } + + // ─── Accès d'urgence ─── + + /// Clé publique de partage d'un autre utilisateur, pour lui sceller quelque chose. + func lookupPublicKey(token: String, email: String) async throws -> String { + var composants = URLComponents() + composants.path = "api/users/lookup" + composants.queryItems = [URLQueryItem(name: "email", value: email)] + guard let chemin = composants.string else { throw APIError.badURL } + return try decode(PublicKeyDTO.self, from: await request("GET", chemin, token: token)) + .publicKey + } + + func listEmergency(token: String) async throws -> EmergencyListDTO { + try decode(EmergencyListDTO.self, from: await request("GET", "api/emergency", token: token)) + } + + /// Désigne un contact. `sealedUserKey` a été scellée par le cœur vers SA clé publique : + /// le serveur transporte un blob qu'il ne peut pas ouvrir. + func inviteEmergency( + token: String, email: String, role: String, waitDays: Int, sealedUserKey: String + ) async throws { + let body = try JSONEncoder().encode( + EmergencyInviteBody( + email: email, role: role, waitDays: waitDays, sealedUserKey: sealedUserKey)) + _ = try await request("POST", "api/emergency", token: token, body: body) + } + + /// `accept` et `request` sont du ressort du contact, `approve` et `reject` du donneur. + func emergencyAction(token: String, id: String, action: String) async throws { + _ = try await request("POST", "api/emergency/\(id)/\(action)", token: token) + } + + func removeEmergency(token: String, id: String) async throws { + _ = try await request("DELETE", "api/emergency/\(id)", token: token) + } + + /// Le coffre du donneur. Refusé par le serveur tant que le délai d'attente court. + func emergencyAccess(token: String, id: String) async throws -> EmergencyAccessDTO { + try decode( + EmergencyAccessDTO.self, + from: await request("GET", "api/emergency/\(id)/access", token: token)) + } + + /// Reprise : impose au donneur un nouveau mot de passe maître, calculé par le cœur. + func emergencyTakeover( + token: String, id: String, masterPasswordHash: String, encryptedUserKey: String + ) async throws { + let body = try JSONEncoder().encode( + TakeoverBody( + masterPasswordHash: masterPasswordHash, encryptedUserKey: encryptedUserKey)) + _ = try await request("POST", "api/emergency/\(id)/takeover", token: token, body: body) + } + // ─── Organisations ─── + + func listOrgs(token: String) async throws -> [OrgSummaryDTO] { + try decode(OrgListDTO.self, from: await request("GET", "api/orgs", token: token)) + .organizations + } + + func orgMembership(token: String, org: String) async throws -> OrgMembershipDTO { + try decode( + OrgMembershipDTO.self, + from: await request("GET", "api/orgs/\(org)/membership", token: token)) + } + + func acceptOrg(token: String, org: String) async throws { + _ = try await request("POST", "api/orgs/\(org)/accept", token: token) + } + + /// Le serveur ne rend que les collections auxquelles ce membre a droit : la permission + /// est tranchée là-bas, l'application ne fait qu'afficher ce qu'on lui donne. + func orgCollections(token: String, org: String) async throws -> [OrgCollectionDTO] { + try decode( + OrgCollectionsDTO.self, + from: await request("GET", "api/orgs/\(org)/collections", token: token) + ).collections + } + + func orgItems(token: String, org: String, collection: String) async throws + -> [EncryptedItemDTO] + { + try decode( + ItemsEnvelope.self, + from: await request( + "GET", "api/orgs/\(org)/collections/\(collection)/items", token: token) + ).items + } + + func createOrgItem( + token: String, org: String, collection: String, encryptedKey: String, + encryptedData: String + ) async throws -> EncryptedItemDTO { + let body = try JSONEncoder().encode( + ItemBody(encryptedKey: encryptedKey, encryptedData: encryptedData)) + return try decode( + EncryptedItemDTO.self, + from: await request( + "POST", "api/orgs/\(org)/collections/\(collection)/items", token: token, + body: body)) + } + + func updateOrgItem( + token: String, org: String, collection: String, id: String, encryptedKey: String, + encryptedData: String + ) async throws { + let body = try JSONEncoder().encode( + ItemBody(encryptedKey: encryptedKey, encryptedData: encryptedData)) + _ = try await request( + "PUT", "api/orgs/\(org)/collections/\(collection)/items/\(id)", token: token, + body: body) + } + + func deleteOrgItem(token: String, org: String, collection: String, id: String) async throws { + _ = try await request( + "DELETE", "api/orgs/\(org)/collections/\(collection)/items/\(id)", token: token) + } + + // ─── Administration d'organisation ─── + + /// Crée une équipe. `encryptedOrgKey` est l'Org Key que le créateur s'est scellée à + /// lui-même : c'est par elle qu'il rouvrira le coffre à sa prochaine session. + func createOrg(token: String, name: String, encryptedOrgKey: String) async throws { + let body = try JSONEncoder().encode( + CreateOrgBody(name: name, encryptedOrgKey: encryptedOrgKey)) + _ = try await request("POST", "api/orgs", token: token, body: body) + } + + func orgMembers(token: String, org: String) async throws -> [OrgMemberDTO] { + try decode( + OrgMembersDTO.self, from: await request("GET", "api/orgs/\(org)/members", token: token) + ).members + } + + /// Invite un membre. L'Org Key a été scellée en local vers SA clé publique : le serveur + /// transporte un blob qu'il ne peut pas ouvrir. + func addOrgMember( + token: String, org: String, email: String, role: String, encryptedOrgKey: String + ) async throws { + let body = try JSONEncoder().encode( + AddMemberBody(email: email, role: role, encryptedOrgKey: encryptedOrgKey)) + _ = try await request("POST", "api/orgs/\(org)/members", token: token, body: body) + } + + func setOrgMemberRole(token: String, org: String, userId: String, role: String) async throws { + let body = try JSONEncoder().encode(RoleBody(role: role)) + _ = try await request( + "PUT", "api/orgs/\(org)/members/\(userId)", token: token, body: body) + } + + /// Révoque un membre **et** fait tourner l'Org Key dans le même mouvement. Les deux sont + /// indissociables : retirer quelqu'un sans changer la clé le laisserait capable de lire + /// tout ce qui s'écrira ensuite. + func rotateOrgKey(token: String, org: String, corps: RotationBody) async throws { + let body = try JSONEncoder().encode(corps) + _ = try await request("POST", "api/orgs/\(org)/rotate", token: token, body: body) + } + + /// Tous les items de l'organisation, pour les ré-envelopper lors d'une rotation. + func allOrgItems(token: String, org: String) async throws -> [EncryptedItemDTO] { + try decode( + ItemsEnvelope.self, from: await request("GET", "api/orgs/\(org)/items", token: token) + ).items + } + + func createOrgCollection(token: String, org: String, name: String) async throws { + let body = try JSONEncoder().encode(NameBody(name: name)) + _ = try await request("POST", "api/orgs/\(org)/collections", token: token, body: body) + } + + // ─── Groupes ─── + + func orgGroups(token: String, org: String) async throws -> [OrgGroupDTO] { + try decode( + OrgGroupsDTO.self, from: await request("GET", "api/orgs/\(org)/groups", token: token) + ).groups + } + + func createOrgGroup(token: String, org: String, name: String) async throws { + let body = try JSONEncoder().encode(NameBody(name: name)) + _ = try await request("POST", "api/orgs/\(org)/groups", token: token, body: body) + } + + func deleteOrgGroup(token: String, org: String, group: String) async throws { + _ = try await request("DELETE", "api/orgs/\(org)/groups/\(group)", token: token) + } + + func addToOrgGroup(token: String, org: String, group: String, userId: String) async throws { + let body = try JSONEncoder().encode(UserIdBody(userId: userId)) + _ = try await request( + "POST", "api/orgs/\(org)/groups/\(group)/members", token: token, body: body) + } + + func removeFromOrgGroup(token: String, org: String, group: String, userId: String) async throws + { + _ = try await request( + "DELETE", "api/orgs/\(org)/groups/\(group)/members/\(userId)", token: token) + } + + /// Donne à un groupe un droit sur une collection : lecture, écriture ou gestion. + func setGroupCollectionAccess( + token: String, org: String, group: String, collection: String, permission: String + ) async throws { + let body = try JSONEncoder().encode(PermissionBody(permission: permission)) + _ = try await request( + "PUT", "api/orgs/\(org)/groups/\(group)/collections/\(collection)", token: token, + body: body) + } + + func revokeGroupCollectionAccess( + token: String, org: String, group: String, collection: String + ) async throws { + _ = try await request( + "DELETE", "api/orgs/\(org)/groups/\(group)/collections/\(collection)", token: token) + } + +} diff --git a/apps/ios/Ghostpass/Services/Biometrics.swift b/apps/ios/Ghostpass/Services/Biometrics.swift new file mode 100644 index 00000000..24b5e29a --- /dev/null +++ b/apps/ios/Ghostpass/Services/Biometrics.swift @@ -0,0 +1,40 @@ +import LocalAuthentication + +/// Disponibilité de la biométrie sur l'appareil. +/// +/// Face ID ne remplace pas le mot de passe maître : il en autorise la relecture depuis le +/// trousseau. Le déverrouillage lui-même reste une dérivation Argon2id faite par le cœur +/// Rust — la biométrie ne fait qu'ouvrir la porte du coffre-fort où dort le mot de passe. +enum Biometrics { + /// L'appareil dispose-t-il d'une biométrie *utilisable* (matériel présent, enrôlée, + /// non verrouillée par trop d'échecs) ? + static var isAvailable: Bool { + var error: NSError? + return LAContext().canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, error: &error) + } + + /// Nom à afficher : « Face ID », « Touch ID », ou un libellé neutre si l'appareil + /// annonce une biométrie que cette version de l'app ne connaît pas. + static var label: String { + let context = LAContext() + _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + switch context.biometryType { + case .faceID: return "Face ID" + case .touchID: return "Touch ID" + default: return "la biométrie" + } + } + + /// Le symbole qui va avec ce nom : un visage ou une empreinte, jamais un cadenas + /// générique — c'est le geste attendu qu'il faut annoncer. + static var icon: String { + let context = LAContext() + _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + switch context.biometryType { + case .faceID: return "faceid" + case .touchID: return "touchid" + default: return "lock.shield" + } + } +} diff --git a/apps/ios/Ghostpass/Services/Breach.swift b/apps/ios/Ghostpass/Services/Breach.swift new file mode 100644 index 00000000..0e71e67b --- /dev/null +++ b/apps/ios/Ghostpass/Services/Breach.swift @@ -0,0 +1,56 @@ +import CryptoKit +import Foundation + +/// Vérification de fuite auprès de Have I Been Pwned, en k-anonymat. +/// +/// Transposition de `apps/web/src/lib/breach.ts`. Seuls les cinq premiers caractères du +/// SHA-1 du mot de passe partent sur le réseau ; le mot de passe — et même son empreinte +/// complète — ne quitte jamais l'appareil. Le service renvoie tous les suffixes connus +/// pour ce préfixe, et la correspondance se fait ici. +/// +/// C'est le seul endroit de l'application qui parle à un tiers. L'appel n'a donc jamais +/// lieu tout seul : il faut que quelqu'un demande explicitement la vérification. +enum Breach { + /// SHA-1 est cassé pour signer, mais l'API de HIBP est indexée ainsi et une empreinte + /// n'est ici qu'une clef de recherche. Rien de tout cela ne touche au coffre : le + /// chiffrement reste l'affaire du cœur Rust. + private static func sha1Hex(_ texte: String) -> String { + Insecure.SHA1.hash(data: Data(texte.utf8)) + .map { String(format: "%02X", $0) } + .joined() + } + + /// Le nombre d'apparitions du mot de passe dans des fuites connues — 0 s'il n'y figure + /// pas. + static func pwnedCount(_ motDePasse: String) async throws -> Int { + guard !motDePasse.isEmpty else { return 0 } + let empreinte = sha1Hex(motDePasse) + let prefixe = String(empreinte.prefix(5)) + let suffixe = String(empreinte.dropFirst(5)) + + guard let url = URL(string: "https://api.pwnedpasswords.com/range/\(prefixe)") else { + return 0 + } + var requete = URLRequest(url: url) + // Le service ajoute alors des entrées factices : la taille de la réponse cesse de + // renseigner sur le nombre de correspondances réelles. + requete.setValue("true", forHTTPHeaderField: "Add-Padding") + requete.timeoutInterval = 15 + + let (data, reponse) = try await URLSession.shared.data(for: requete) + guard let http = reponse as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw BreachError.indisponible + } + + for ligne in String(decoding: data, as: UTF8.self).split(separator: "\n") { + let morceaux = ligne.trimmingCharacters(in: .whitespaces).split(separator: ":") + guard morceaux.count == 2, morceaux[0] == suffixe else { continue } + return Int(morceaux[1]) ?? 0 + } + return 0 + } +} + +enum BreachError: Error { + case indisponible +} diff --git a/apps/ios/Ghostpass/Services/Clipboard.swift b/apps/ios/Ghostpass/Services/Clipboard.swift new file mode 100644 index 00000000..182ba870 --- /dev/null +++ b/apps/ios/Ghostpass/Services/Clipboard.swift @@ -0,0 +1,19 @@ +import UIKit +import UniformTypeIdentifiers + +/// Copie de secrets dans le presse-papiers. +/// +/// Toujours avec une date d'expiration : le presse-papiers d'iOS est lisible par +/// n'importe quelle application au premier plan, et il se synchronise avec les autres +/// appareils du même compte iCloud. Un mot de passe qui y reste jusqu'à la prochaine +/// copie est un mot de passe posé sur la table. +enum Clipboard { + /// Trente secondes : le temps de coller, pas celui d'oublier. + static let lifetime: TimeInterval = 30 + + static func copy(_ value: String) { + UIPasteboard.general.setItems( + [[UTType.utf8PlainText.identifier: value]], + options: [.expirationDate: Date().addingTimeInterval(lifetime)]) + } +} diff --git a/apps/ios/Ghostpass/Services/CredentialIdentities.swift b/apps/ios/Ghostpass/Services/CredentialIdentities.swift new file mode 100644 index 00000000..8b70880a --- /dev/null +++ b/apps/ios/Ghostpass/Services/CredentialIdentities.swift @@ -0,0 +1,41 @@ +import AuthenticationServices + +/// Inscription des identifiants auprès d'iOS. +/// +/// Sans elle, GhostPass n'apparaît qu'au fond du menu de remplissage. Avec elle, iOS +/// propose le bon identifiant directement au-dessus du clavier — ce qui fait toute la +/// différence entre un gestionnaire qu'on utilise et un qu'on contourne. +/// +/// Ce qui est déposé ici — nom d'utilisateur et domaine — est visible du système. Le mot +/// de passe, lui, n'est jamais transmis : iOS rappelle l'extension pour l'obtenir. +enum CredentialIdentities { + static func sync(_ entries: [VaultEntry]) async { + let store = ASCredentialIdentityStore.shared + guard await store.state().isEnabled else { return } + + let identities: [ASPasswordCredentialIdentity] = entries.flatMap { entry in + guard let login = entry.login, !login.username.isEmpty else { + return [ASPasswordCredentialIdentity]() + } + return login.uris.compactMap { uri in + let hote = SiteMatching.host(of: uri) + guard !hote.isEmpty else { return nil } + return ASPasswordCredentialIdentity( + serviceIdentifier: ASCredentialServiceIdentifier( + identifier: hote, type: .domain), + user: login.username, + // L'identifiant de l'item : c'est ce qu'iOS rendra à l'extension + // quand il faudra fournir le mot de passe. + recordIdentifier: entry.id) + } + } + try? await store.replaceCredentialIdentities(identities) + } + + /// À la déconnexion : plus rien à proposer, et laisser des noms d'utilisateur inscrits + /// après le départ d'un compte serait une fuite. + static func clear() async { + guard await ASCredentialIdentityStore.shared.state().isEnabled else { return } + try? await ASCredentialIdentityStore.shared.removeAllCredentialIdentities() + } +} diff --git a/apps/ios/Ghostpass/Services/CsvExport.swift b/apps/ios/Ghostpass/Services/CsvExport.swift new file mode 100644 index 00000000..faa65cf1 --- /dev/null +++ b/apps/ios/Ghostpass/Services/CsvExport.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Export du coffre en CSV, au format que produit la web app — et que relit l'import de +/// cette application. Un coffre doit pouvoir sortir aussi librement qu'il est entré : un +/// gestionnaire dont on ne peut pas partir n'est pas un coffre, c'est une nasse. +/// +/// Le fichier contient les mots de passe **en clair**. C'est inhérent à un export CSV, et +/// c'est pourquoi l'écran qui le déclenche demande le mot de passe maître et rappelle +/// d'effacer le fichier. +enum CsvExport { + /// L'en-tête, dans l'ordre de la web app. Le relire avec `CsvImport` doit redonner le + /// même coffre : les deux formats se répondent, et un test l'exige. + static let entete = "name,folder,url,username,password,totp" + + static func texte(_ entries: [VaultEntry]) -> String { + var lignes = [entete] + for entry in entries { + let login = entry.login + lignes.append( + [ + entry.item.name, + entry.item.folder ?? "", + login?.uris.first ?? "", + login?.username ?? "", + login?.password ?? "", + login?.totp ?? "", + ] + .map(echapper).joined(separator: ",")) + } + return lignes.joined(separator: "\n") + } + + /// Tout est mis entre guillemets, guillemets internes doublés : c'est la seule forme + /// qui survit à une virgule, à un saut de ligne et à un guillemet dans un mot de passe. + private static func echapper(_ valeur: String) -> String { + "\"" + valeur.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + + /// Le nom du fichier proposé. La date évite d'écraser un export précédent sans le dire. + static func nomDeFichier(_ date: Date = Date()) -> String { + let formateur = DateFormatter() + formateur.locale = Locale(identifier: "en_US_POSIX") + formateur.dateFormat = "yyyy-MM-dd" + return "ghostpass-export-\(formateur.string(from: date)).csv" + } +} diff --git a/apps/ios/Ghostpass/Services/CsvImport.swift b/apps/ios/Ghostpass/Services/CsvImport.swift new file mode 100644 index 00000000..c8ee8861 --- /dev/null +++ b/apps/ios/Ghostpass/Services/CsvImport.swift @@ -0,0 +1,123 @@ +import Foundation + +/// Import d'un fichier CSV — celui qu'exporte un autre gestionnaire de mots de passe. +/// +/// Transposition de `apps/web/src/lib/csv.ts`, y compris la liste des colonnes reconnues : +/// Bitwarden, 1Password, LastPass et Chrome n'emploient pas les mêmes noms, et c'est +/// précisément ce que cette table d'équivalences absorbe. Un même fichier doit produire le +/// même coffre des deux côtés — sans quoi migrer depuis le téléphone et migrer depuis le +/// navigateur donneraient deux résultats différents. +/// +/// Rien ne part sur le réseau ici : l'analyse est locale, et le chiffrement reste l'affaire +/// du cœur Rust au moment du dépôt. +enum CsvImport { + /// Découpe le texte en enregistrements. Gère les guillemets, les guillemets doublés et + /// les retours à la ligne échappés — un mot de passe peut contenir une virgule, et un + /// champ « notes » un saut de ligne. + static func lignes(_ texte: String) -> [[String]] { + var lignes: [[String]] = [] + var ligne: [String] = [] + var champ = "" + var dansGuillemets = false + // Les fins de ligne Windows et classiques Mac se ramènent au saut simple. + let source = texte.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + + var index = source.startIndex + while index < source.endIndex { + let caractere = source[index] + if dansGuillemets { + if caractere == "\"" { + let suivant = source.index(after: index) + if suivant < source.endIndex, source[suivant] == "\"" { + champ.append("\"") + index = suivant + } else { + dansGuillemets = false + } + } else { + champ.append(caractere) + } + } else if caractere == "\"" { + dansGuillemets = true + } else if caractere == "," { + ligne.append(champ) + champ = "" + } else if caractere == "\n" { + ligne.append(champ) + lignes.append(ligne) + ligne = [] + champ = "" + } else { + champ.append(caractere) + } + index = source.index(after: index) + } + if !champ.isEmpty || !ligne.isEmpty { + ligne.append(champ) + lignes.append(ligne) + } + return lignes + } + + /// Les enregistrements, indexés par en-tête en minuscules. Une ligne entièrement vide + /// est ignorée : les exports en sèment volontiers en fin de fichier. + static func enregistrements(_ texte: String) -> [[String: String]] { + let toutes = lignes(texte).filter { ligne in + ligne.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + } + guard toutes.count >= 2 else { return [] } + let entetes = toutes[0].map { + $0.trimmingCharacters(in: .whitespaces).lowercased() + } + return toutes.dropFirst().map { cellules in + var enregistrement: [String: String] = [:] + for (rang, entete) in entetes.enumerated() { + let valeur = rang < cellules.count ? cellules[rang] : "" + enregistrement[entete] = valeur.trimmingCharacters(in: .whitespaces) + } + return enregistrement + } + } + + /// Les items à déposer. Tout devient un identifiant : c'est ce que fait la web app, et + /// c'est ce que contiennent les exports des gestionnaires concurrents. + static func items(_ texte: String) -> [VaultItem] { + enregistrements(texte).map { ligne in + // Les noms de colonnes relevés dans les exports réels, et non devinés : + // Bitwarden folder, name, notes, login_uri, login_username, login_password, + // login_totp + // 1Password Title, Url, Username, Password, OTPAuth, Tags, Notes + // LastPass url, username, password, totp, extra, name, grouping + // Dashlane title, username, password, note, url, category, otpSecret + // Chrome name, url, username, password, note + // KeePass Group, Title, Username, Password, URL, Notes + let dossier = premier(ligne, "folder", "vault", "group", "grouping", "category", "tags") + let totp = premier(ligne, "totp", "login_totp", "otpauth", "otpsecret", "otp") + let adresse = premier(ligne, "url", "login_uri", "website", "uri", "urls") + // `extra` chez LastPass, `note` au singulier chez Dashlane et Chrome. Les + // omettre perdait les notes de tout coffre migré — silencieusement. + let note = premier(ligne, "notes", "note", "extra", "comments") + return VaultItem( + name: premier(ligne, "name", "title").isEmpty + ? NSLocalizedString("(sans nom)", comment: "") + : premier(ligne, "name", "title"), + notes: note.isEmpty ? nil : note, + folder: dossier.isEmpty ? nil : dossier, + data: .login( + Login( + username: premier(ligne, "username", "login_username", "login", "user"), + password: premier(ligne, "password", "login_password"), + uris: adresse.isEmpty ? [] : [adresse], + totp: totp.isEmpty ? nil : totp))) + } + } + + /// La première colonne renseignée parmi celles qui désignent la même chose. + private static func premier(_ ligne: [String: String], _ noms: String...) -> String { + for nom in noms { + if let valeur = ligne[nom], !valeur.isEmpty { return valeur } + } + return "" + } +} diff --git a/apps/ios/Ghostpass/Services/EmergencyAccess.swift b/apps/ios/Ghostpass/Services/EmergencyAccess.swift new file mode 100644 index 00000000..4f154701 --- /dev/null +++ b/apps/ios/Ghostpass/Services/EmergencyAccess.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Ce qui sait ouvrir un item chiffré : le compte pour son propre coffre, le coffre d'urgence +/// pour celui d'un donneur. Les deux viennent du cœur Rust et portent la même méthode ; le +/// protocole évite de réécrire la construction de l'enveloppe une seconde fois. +protocol DechiffreurDItems { + func decryptItem(encryptedItemJson: String) throws -> String +} + +extension Account: DechiffreurDItems {} +extension EmergencyVault: DechiffreurDItems {} + +extension DechiffreurDItems { + /// Reconstruit l'enveloppe attendue par le cœur et rend l'item en clair. + func ouvrir(_ dto: EncryptedItemDTO) throws -> VaultItem { + let enveloppe = ["encrypted_key": dto.encryptedKey, "encrypted_data": dto.encryptedData] + let json = String(data: try JSONEncoder().encode(enveloppe), encoding: .utf8) ?? "{}" + let clair = try decryptItem(encryptedItemJson: json) + return try JSONDecoder().decode(VaultItem.self, from: Data(clair.utf8)) + } +} + +/// Rôle confié au contact. `view` lit le coffre ; `takeover` permet en plus d'imposer un +/// nouveau mot de passe maître au donneur — ce qui l'en exclut, donc. +enum RoleDUrgence: String, CaseIterable, Identifiable { + case view + case takeover + + var id: String { rawValue } + + @MainActor + var intitule: String { + switch self { + case .view: return tr("Lecture seule") + case .takeover: return tr("Reprise du compte") + } + } + + @MainActor + var explication: String { + switch self { + case .view: return tr("Le contact pourra lire vos identifiants, sans rien y changer ni vous en priver.") + case .takeover: return tr("Le contact pourra en plus choisir un nouveau mot de passe maître — ce qui vous exclura de votre propre coffre.") + } + } +} + +/// Où en est un lien d'urgence. Les valeurs viennent du serveur ; on ne les invente pas. +enum EtatDUrgence: String { + case invited + case accepted + case requested + case granted + case rejected + + @MainActor + var intitule: String { + switch self { + case .invited: return tr("Invitation envoyée") + case .accepted: return tr("Contact accepté") + case .requested: return tr("Accès demandé") + case .granted: return tr("Accès ouvert") + case .rejected: return tr("Demande refusée") + } + } +} + +/// Un lien d'urgence prêt à afficher : le DTO du serveur, débarrassé de ses chaînes libres. +struct LienDUrgence: Identifiable { + let id: String + let contactEmail: String + let role: RoleDUrgence + let waitDays: Int + let etat: EtatDUrgence + let requestedAt: Date? + /// Seulement dans le sens « je suis le contact » : le délai est-il écoulé. + let disponible: Bool + + init?(_ dto: EmergencyContactDTO) { + guard let role = RoleDUrgence(rawValue: dto.role), + let etat = EtatDUrgence(rawValue: dto.status) + else { return nil } + self.id = dto.id + self.contactEmail = dto.contactEmail + self.role = role + self.waitDays = dto.waitDays + self.etat = etat + self.requestedAt = dto.requestedAt.map { + Date(timeIntervalSince1970: TimeInterval($0) / 1000) + } + self.disponible = dto.available ?? false + } + + /// Moment où l'accès s'ouvrira, si une demande court. Le serveur tranche pour de bon — + /// ceci ne sert qu'à l'afficher, jamais à décider. + func ouverturePrevue() -> Date? { + guard etat == .requested, let requestedAt else { return nil } + return requestedAt.addingTimeInterval(TimeInterval(waitDays) * 86_400) + } +} diff --git a/apps/ios/Ghostpass/Services/Favicon.swift b/apps/ios/Ghostpass/Services/Favicon.swift new file mode 100644 index 00000000..fd4689a2 --- /dev/null +++ b/apps/ios/Ghostpass/Services/Favicon.swift @@ -0,0 +1,121 @@ +import SwiftUI + +/// Les icônes des sites, servies par *notre* serveur. +/// +/// Demander une icône à Google ou à un service tiers reviendrait à lui annoncer les sites +/// que contient le coffre, un par un. Le backend de la suite expose donc son propre proxy +/// (`GET /api/icons?domain=…`), qui va chercher l'icône et la met en cache : aucun tiers ne +/// voit les domaines. +/// +/// Le serveur, lui, les voit — c'est le prix de l'icône, et la raison pour laquelle +/// l'affichage se coupe depuis les réglages. Un coffre chiffré de bout en bout ne dit rien +/// de son contenu au serveur ; les requêtes d'icônes, elles, en disent quelque chose. +enum Favicon { + /// L'adresse du proxy pour cet URI, ou `nil` s'il n'y a pas de domaine exploitable. + static func url(pour adresse: String, serveur: String) -> URL? { + guard estUnDomainePublic(SiteMatching.host(of: adresse)) else { return nil } + let hote = SiteMatching.host(of: adresse) + // Une adresse de serveur vide donnerait une URL relative — `/api/icons?…` sans + // hôte — qui ne mène nulle part. Exiger l'hôte ferme ce chemin. + guard var composants = URLComponents(string: serveur), composants.host != nil else { + return nil + } + composants.path = "/api/icons" + composants.queryItems = [URLQueryItem(name: "domain", value: hote)] + return composants.url + } + + /// Un domaine public, au sens où le proxy l'entend : des labels séparés par des points + /// et une extension alphabétique. La règle est celle du serveur, appliquée ici pour ne + /// pas lui envoyer une requête qu'il refusera. + /// + /// Elle écarte « localhost », les noms de machine du réseau local et les adresses IP — + /// littérales ou entre crochets. Demander au serveur d'aller chercher une icône sur une + /// IP privée n'aurait aucun sens, et lui apprendrait l'adressage du réseau de + /// l'utilisateur pour rien. + private static func estUnDomainePublic(_ hote: String) -> Bool { + guard !hote.hasPrefix("["), hote.contains(".") else { return false } + let labels = hote.components(separatedBy: ".") + guard labels.count >= 2, let extension_ = labels.last, extension_.count >= 2 else { + return false + } + return extension_.allSatisfy { $0.isLetter } + } + + /// Une couleur de repli déterministe, la même que celle de la web app : deux écrans + /// qui montrent le même coffre doivent lui donner les mêmes couleurs. + private static let couleurs = [ + 0xE0533F, 0xE0892F, 0x3F9E6B, 0x3F86E0, 0x7C5CF0, 0xD9528A, 0x2FA3A3, 0x9A7B3F, + ] + + static func couleur(pour nom: String) -> Color { + var empreinte: UInt32 = 0 + for scalaire in nom.unicodeScalars { + empreinte = empreinte &* 31 &+ (scalaire.value & 0xFFFF) + } + return Color(rgb: couleurs[Int(empreinte) % couleurs.count]) + } + + /// L'initiale affichée à défaut d'icône. Un nom vide donne un point d'interrogation + /// plutôt qu'une pastille muette. + static func initiale(_ nom: String) -> String { + let propre = nom.trimmingCharacters(in: .whitespacesAndNewlines) + guard let premier = propre.first else { return "?" } + return String(premier).uppercased() + } +} + +/// La pastille d'un élément : l'icône du site si on peut l'obtenir, sinon une initiale sur +/// fond coloré — celui-ci se calcule sur l'appareil et ne demande rien à personne. +struct SiteIcon: View { + let nom: String + var adresse: String? + var taille: CGFloat = 38 + + @EnvironmentObject private var prefs: Preferences + + var body: some View { + Group { + if prefs.afficheLesIcones, let url { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFit().padding(taille * 0.18) + // Une icône absente ou un serveur qui refuse ne doit rien changer à la + // lisibilité de la liste : on retombe sur l'initiale, sans un mot. + default: + monogramme + } + } + } else { + monogramme + } + } + .frame(width: taille, height: taille) + .background(fond, in: RoundedRectangle(cornerRadius: taille * 0.26)) + .overlay( + RoundedRectangle(cornerRadius: taille * 0.26) + .strokeBorder(Color.gpBorder, lineWidth: 1)) + .accessibilityHidden(true) + } + + private var url: URL? { + guard let adresse, !adresse.isEmpty else { return nil } + let serveur = SharedStore.load()?.serverURL ?? "" + guard !serveur.isEmpty else { return nil } + return Favicon.url(pour: adresse, serveur: serveur) + } + + private var monogramme: some View { + Text(verbatim: Favicon.initiale(nom)) + .font(.system(size: taille * 0.42, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.gpOnAccent) + } + + /// Fond blanc sous une icône — beaucoup sont transparentes et taillées pour du clair —, + /// teinté sous une initiale. + private var fond: Color { + if prefs.afficheLesIcones, url != nil { return .white } + return Favicon.couleur(pour: nom) + } +} diff --git a/apps/ios/Ghostpass/Services/Keychain.swift b/apps/ios/Ghostpass/Services/Keychain.swift new file mode 100644 index 00000000..231aadde --- /dev/null +++ b/apps/ios/Ghostpass/Services/Keychain.swift @@ -0,0 +1,107 @@ +import Foundation +import LocalAuthentication +import Security + +/// Stockage des éléments de session dans le trousseau iOS. +/// +/// Ce qui y est déposé — jeton de session et blobs chiffrés — ne suffit pas à ouvrir +/// le coffre : il faut le mot de passe maître, qui n'est jamais écrit nulle part. +/// `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` interdit la sortie par sauvegarde +/// iCloud et la restauration sur un autre appareil. +enum Keychain { + private static let service = "ch.stackops.ghostpass" + + static func set(_ value: String, for key: String) { + let data = Data(value.utf8) + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + SecItemAdd(query as CFDictionary, nil) + } + + static func get(_ key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + /// Dépose une valeur que seule la biométrie pourra relire. + /// + /// `.biometryCurrentSet` : enrôler un nouveau visage ou une nouvelle empreinte + /// **invalide** l'entrée. Sans cela, quiconque peut ajouter son visage aux réglages + /// — donc quiconque connaît le code de l'appareil — ouvrirait le coffre. + /// `WhenPasscodeSetThisDeviceOnly` : rien ne sort par sauvegarde ni restauration. + /// Renvoie l'`OSStatus` brut : `errSecSuccess`, ou de quoi diagnostiquer un refus + /// (typiquement `errSecAuthFailed` / `-34018` quand l'appareil n'a pas de code). + @discardableResult + static func setBiometric(_ value: String, for key: String) -> OSStatus { + guard let access = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, nil) + else { return errSecParam } + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + query[kSecValueData as String] = Data(value.utf8) + query[kSecAttrAccessControl as String] = access + return SecItemAdd(query as CFDictionary, nil) + } + + /// Relit une valeur biométrique. L'appel **déclenche** l'authentification : il bloque + /// le temps que l'utilisateur se présente, d'où l'exécution hors du fil principal. + static func getBiometric(_ key: String, prompt: String) -> String? { + let context = LAContext() + context.localizedReason = prompt + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + static func remove(_ key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } + + enum Key { + /// Le jeton ouvre le compte côté serveur : c'est le seul élément de session qui + /// mérite le trousseau. Les blobs chiffrés, eux, vivent dans `SharedStore` — le + /// serveur les détient déjà, et l'extension de remplissage doit pouvoir les lire. + static let token = "sessionToken" + /// Mot de passe maître, protégé par la biométrie. Présent seulement si + /// l'utilisateur a explicitement activé le déverrouillage biométrique. + static let masterPassword = "masterPassword" + /// Drapeau lisible sans biométrie, pour savoir s'il faut proposer le bouton + /// sans déclencher une demande de Face ID à chaque affichage de l'écran. + static let biometricsEnabled = "biometricsEnabled" + } +} diff --git a/apps/ios/Ghostpass/Services/Organizations.swift b/apps/ios/Ghostpass/Services/Organizations.swift new file mode 100644 index 00000000..94ec5b04 --- /dev/null +++ b/apps/ios/Ghostpass/Services/Organizations.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Le coffre d'organisation ouvre les items comme le compte ouvre les siens : même +/// enveloppe, autre clé d'enveloppe. Voir `DechiffreurDItems`. +extension Org: DechiffreurDItems {} + +/// Ce qu'un membre peut faire dans une organisation. Les valeurs viennent du serveur, qui +/// reste seul juge : ces rôles ne servent ici qu'à ne pas proposer un geste qui sera refusé. +enum RoleDOrganisation: String { + case admin + case member + case readonly + + @MainActor + var intitule: String { + switch self { + case .admin: return tr("Administrateur") + case .member: return tr("Membre") + case .readonly: return tr("Lecture seule") + } + } + + /// Peut-on écrire dans le coffre partagé ? + var peutEcrire: Bool { self != .readonly } +} + +enum EtatDAppartenance: String { + case invited + case active + case revoked + + @MainActor + var intitule: String { + switch self { + case .invited: return tr("Invitation reçue") + case .active: return tr("Membre actif") + case .revoked: return tr("Accès révoqué") + } + } +} + +/// Une organisation telle qu'elle se présente dans la liste. +struct Organisation: Identifiable { + let id: String + let nom: String + let role: RoleDOrganisation + let etat: EtatDAppartenance + + init?(_ dto: OrgSummaryDTO) { + guard let role = RoleDOrganisation(rawValue: dto.role), + let etat = EtatDAppartenance(rawValue: dto.status) + else { return nil } + self.id = dto.orgId + self.nom = dto.name + self.role = role + self.etat = etat + } +} + +/// Un coffre partagé ouvert : ses collections, et l'`Org` du cœur Rust qui détient l'Org Key. +/// +/// Rien n'est mis en cache hors ligne, contrairement au coffre personnel. Un coffre partagé +/// se révoque — garder une copie locale déchiffrable après un départ irait contre la +/// rotation d'Org Key, qui est justement la façon de reprendre ses clés à quelqu'un. +struct CoffrePartageOuvert { + let organisation: Organisation + let collections: [OrgCollectionDTO] + let org: Org +} + +/// Un membre d'équipe, vu par un administrateur. +struct MembreDEquipe: Identifiable { + let id: String + let email: String? + let role: RoleDOrganisation + let etat: EtatDAppartenance + + init?(_ dto: OrgMemberDTO) { + guard let role = RoleDOrganisation(rawValue: dto.role), + let etat = EtatDAppartenance(rawValue: dto.status) + else { return nil } + self.id = dto.userId + self.email = dto.email + self.role = role + self.etat = etat + } +} + +/// Ce qu'un groupe peut faire d'une collection. +enum DroitSurCollection: String, CaseIterable, Identifiable { + case read + case write + case manage + + var id: String { rawValue } + + @MainActor + var intitule: String { + switch self { + case .read: return tr("Lecture") + case .write: return tr("Écriture") + case .manage: return tr("Gestion") + } + } +} + +/// Ce qui empêche une rotation d'aboutir, quand la faute n'est pas technique. +/// +/// Pas de `LocalizedError` ici : `errorDescription` est appelé hors du fil principal, alors +/// que la traduction dépend de la langue choisie et vit sur le `MainActor`. L'erreur porte +/// donc de quoi composer son message, et c'est l'affichage qui le compose. +enum RotationImpossible: Error { + /// Un membre restant dont on n'a pas pu obtenir la clé publique. Le sauter reviendrait à + /// l'exclure sans le dire : il ne recevrait pas la nouvelle Org Key et perdrait l'accès + /// au prochain déverrouillage, sans que personne ne l'ait décidé. + case cleIntrouvable(membre: String) + + @MainActor + var message: String { + switch self { + case .cleIntrouvable(let membre): + return String( + format: tr("Impossible d'obtenir la clé publique de %@ : rotation annulée."), + membre) + } + } +} diff --git a/apps/ios/Ghostpass/Services/PasswordGenerator.swift b/apps/ios/Ghostpass/Services/PasswordGenerator.swift new file mode 100644 index 00000000..31148ed1 --- /dev/null +++ b/apps/ios/Ghostpass/Services/PasswordGenerator.swift @@ -0,0 +1,73 @@ +import Foundation +import Security + +/// Générateur de mots de passe, transposition fidèle de `apps/web/src/lib/generator.ts` : +/// mêmes jeux de caractères, mêmes valeurs par défaut, mêmes garanties. Deux appareils du +/// même compte doivent produire des mots de passe de même nature. +/// +/// L'aléa vient de `SecRandomCopyBytes`, le générateur du système — l'équivalent du +/// `crypto.getRandomValues` qu'utilise la web app. Rien n'est réimplémenté ici : tirer un +/// entier au hasard n'est pas du chiffrement, et le cœur Rust n'expose pas de générateur. +struct GeneratorOptions: Equatable { + var length: Int = 20 + var lowercase = true + var uppercase = true + var digits = true + var symbols = true +} + +enum PasswordGenerator { + private static let lowercase = "abcdefghijklmnopqrstuvwxyz" + private static let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + private static let digits = "0123456789" + private static let symbols = "!@#$%^&*()-_=+[]{};:,.?/" + + /// Entier uniforme dans `[0, max)`. Le rejet des valeurs hautes évite le biais que + /// produirait un simple modulo : sans lui, les premiers caractères du jeu sortiraient + /// un peu plus souvent que les derniers. + private static func randomIndex(_ max: Int) -> Int { + precondition(max > 0) + let limit = UInt32.max - (UInt32.max % UInt32(max)) + var value: UInt32 = 0 + repeat { + var bytes = [UInt8](repeating: 0, count: 4) + guard SecRandomCopyBytes(kSecRandomDefault, 4, &bytes) == errSecSuccess else { + // Le générateur du système ne peut pas échouer en pratique ; s'il le + // faisait, produire un mot de passe prévisible serait la pire réponse. + fatalError("le générateur aléatoire du système est indisponible") + } + value = bytes.withUnsafeBytes { $0.load(as: UInt32.self) } + } while value >= limit + return Int(value % UInt32(max)) + } + + static func generate(_ options: GeneratorOptions) -> String { + var sets: [String] = [] + if options.lowercase { sets.append(lowercase) } + if options.uppercase { sets.append(uppercase) } + if options.digits { sets.append(digits) } + if options.symbols { sets.append(symbols) } + // Tout décocher ne doit pas rendre un mot de passe vide. + if sets.isEmpty { sets.append(lowercase) } + + let length = max(sets.count, min(128, options.length > 0 ? options.length : 20)) + let pool = Array(sets.joined()) + + // Un caractère au moins de chaque jeu demandé : cocher « chiffres » et n'en + // obtenir aucun serait un mot de passe qui ne respecte pas la consigne reçue. + var chars: [Character] = sets.map { set in + let characters = Array(set) + return characters[randomIndex(characters.count)] + } + while chars.count < length { + chars.append(pool[randomIndex(pool.count)]) + } + + // Mélange de Fisher-Yates : sans lui, les caractères garantis resteraient en tête, + // et la position d'un chiffre trahirait la façon dont le mot de passe a été fait. + for i in stride(from: chars.count - 1, to: 0, by: -1) { + chars.swapAt(i, randomIndex(i + 1)) + } + return String(chars) + } +} diff --git a/apps/ios/Ghostpass/Services/PasswordHealth.swift b/apps/ios/Ghostpass/Services/PasswordHealth.swift new file mode 100644 index 00000000..5a9ba83c --- /dev/null +++ b/apps/ios/Ghostpass/Services/PasswordHealth.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// L'état de santé du coffre, calculé entièrement sur l'appareil. +/// +/// Le barème est celui de la web app, au point près : longueur et variété de caractères. +/// Ce n'est pas une science — aucune formule ne dit si un mot de passe a fuité — mais +/// c'est une mesure reproductible, et surtout la même des deux côtés. Un mot de passe +/// jugé faible dans le navigateur doit l'être aussi sur le téléphone, sans quoi les deux +/// écrans se contrediraient sur le même coffre. +enum PasswordHealth { + struct Force { + /// De 0 (très faible) à 4 (très bon). + let niveau: Int + + var libelle: LocalizedStringKey { + switch niveau { + case 0: return "Très faible" + case 1: return "Faible" + case 2: return "Moyen" + case 3: return "Bon" + default: return "Très bon" + } + } + + var couleur: Color { + switch niveau { + case 0, 1: return .gpDanger + case 2: return .gpAccentText + default: return .gpSuccess + } + } + } + + static func force(_ motDePasse: String) -> Force { + guard !motDePasse.isEmpty else { return Force(niveau: 0) } + var score = 0 + if motDePasse.count >= 8 { score += 1 } + if motDePasse.count >= 14 { score += 1 } + if motDePasse.count >= 20 { score += 1 } + let classes = [ + CharacterSet.lowercaseLetters, .uppercaseLetters, .decimalDigits, + ].filter { jeu in motDePasse.unicodeScalars.contains { jeu.contains($0) } }.count + let autres = motDePasse.unicodeScalars.contains { scalaire in + !CharacterSet.alphanumerics.contains(scalaire) + } + let varietes = classes + (autres ? 1 : 0) + if varietes >= 2 { score += 1 } + if varietes >= 3 { score += 1 } + return Force(niveau: min(4, Int((Double(score) / 5 * 4).rounded()))) + } + + /// Ce qu'un coffre a de fragile. Trois listes plutôt qu'une note globale : une note + /// ne dit pas quoi corriger, et c'est la seule chose qui compte ici. + struct Bilan { + var faibles: [VaultEntry] = [] + var reutilises: [VaultEntry] = [] + var sansCode: [VaultEntry] = [] + + var estSain: Bool { faibles.isEmpty && reutilises.isEmpty } + } + + static func bilan(_ entries: [VaultEntry]) -> Bilan { + var occurrences: [String: Int] = [:] + for entry in entries { + guard let mot = entry.login?.password, !mot.isEmpty else { continue } + occurrences[mot, default: 0] += 1 + } + + var bilan = Bilan() + for entry in entries { + guard let login = entry.login else { continue } + if !login.password.isEmpty { + if force(login.password).niveau <= 1 { bilan.faibles.append(entry) } + if occurrences[login.password, default: 0] > 1 { bilan.reutilises.append(entry) } + } + if login.totp?.isEmpty ?? true { bilan.sansCode.append(entry) } + } + return bilan + } +} diff --git a/apps/ios/Ghostpass/Services/Preferences.swift b/apps/ios/Ghostpass/Services/Preferences.swift new file mode 100644 index 00000000..045519f2 --- /dev/null +++ b/apps/ios/Ghostpass/Services/Preferences.swift @@ -0,0 +1,191 @@ +import SwiftUI + +/// Les deux réglages que l'utilisateur impose à l'application : son apparence et sa langue. +/// +/// L'un et l'autre suivent le système par défaut — c'est ce qu'attend quelqu'un qui a déjà +/// réglé son téléphone —, mais le défaut n'est pas une fatalité : un coffre s'ouvre aussi +/// bien dans un train de nuit que dans un bureau en plein soleil, et la langue du téléphone +/// n'est pas toujours celle dans laquelle on veut lire ses mots de passe. +/// +/// Le choix vit dans les préférences du groupe d'applications plutôt que dans celles de +/// l'app : l'extension de remplissage automatique est un autre processus, et une fenêtre +/// surgie en plein Safari qui reviendrait au thème clair trahirait qu'il s'agit d'un +/// morceau rapporté. +@MainActor +final class Preferences: ObservableObject { + static let shared = Preferences() + + @Published var apparence: Apparence { + didSet { defaults?.set(apparence.rawValue, forKey: Clefs.apparence) } + } + + @Published var langue: Langue { + didSet { defaults?.set(langue.rawValue, forKey: Clefs.langue) } + } + + /// Afficher l'icône des sites. Le coffre est chiffré de bout en bout : le serveur n'en + /// connaît pas le contenu. Réclamer une icône, en revanche, lui nomme un domaine — le + /// choix revient donc à l'utilisateur, et il est réversible. + @Published var afficheLesIcones: Bool { + didSet { defaults?.set(afficheLesIcones, forKey: Clefs.icones) } + } + + /// Au bout de combien de temps hors de l'écran le coffre se referme. + @Published var verrouillage: Verrouillage { + didSet { defaults?.set(verrouillage.rawValue, forKey: Clefs.verrouillage) } + } + + private let defaults = UserDefaults(suiteName: SharedStore.appGroup) + + private enum Clefs { + static let apparence = "gp.apparence" + static let langue = "gp.langue" + static let icones = "gp.icones" + static let verrouillage = "gp.verrouillage" + } + + private init() { + let lus = UserDefaults(suiteName: SharedStore.appGroup) + apparence = Apparence(rawValue: lus?.string(forKey: Clefs.apparence) ?? "") ?? .systeme + langue = Langue(rawValue: lus?.string(forKey: Clefs.langue) ?? "") ?? .systeme + // Allumé par défaut, comme sur le web : une liste de pastilles grises se reconnaît + // moins vite qu'une liste de logos, et le proxy est celui du serveur de l'utilisateur. + afficheLesIcones = lus?.object(forKey: Clefs.icones) as? Bool ?? true + // Immédiat par défaut : c'est le comportement qu'avait l'application, et le plus + // sûr des quatre. Le relâcher est un choix, pas un réglage qu'on subit. + verrouillage = + Verrouillage(rawValue: lus?.string(forKey: Clefs.verrouillage) ?? "") ?? .immediat + } + + /// `nil` laisse SwiftUI suivre le réglage du système. + var colorScheme: ColorScheme? { apparence.colorScheme } + + /// `nil` laisse le système choisir parmi les langues que l'app connaît. + var locale: Locale? { langue.locale } + + /// Le paquet de la langue choisie ; celui de l'application quand on suit le système. + /// C'est lui, et non la locale, qui décide dans quel `.lproj` la traduction est lue. + var bundle: Bundle { + guard let code = langue.code, + let chemin = Bundle.main.path(forResource: code, ofType: "lproj"), + let paquet = Bundle(path: chemin) + else { return .main } + return paquet + } +} + +/// Traduit une chaîne fabriquée en code — un message d'erreur, surtout — dans la langue +/// choisie par l'utilisateur. +/// +/// `Text("…")` passe par le catalogue tout seul, avec la locale de l'environnement. Une +/// `String` construite dans un service, elle, ne traverse aucune vue : sans cette fonction +/// elle resterait en français au milieu d'une interface anglaise. Le littéral reste écrit +/// en clair pour que la compilation continue de l'extraire vers le catalogue. +@MainActor +func tr(_ valeur: String.LocalizationValue) -> String { + String( + localized: valeur, bundle: Preferences.shared.bundle, + locale: Preferences.shared.locale ?? .autoupdatingCurrent) +} + +/// Combien de temps le coffre reste ouvert une fois l'application quittée. +/// +/// Immédiat protège le mieux : rien ne survit à un passage dans une autre application. +/// C'est aussi le plus pénible quand on fait des allers-retours vers Safari pour coller un +/// mot de passe — d'où les délais, qui laissent revenir sans redemander. +/// +/// Pendant ce délai, les clés restent en mémoire. Elles ne touchent jamais le disque, et +/// l'écran est masqué dans le sélecteur d'applications ; mais le coffre est ouvert, et +/// c'est bien ce qu'on a demandé. +enum Verrouillage: String, CaseIterable, Identifiable { + case immediat + case uneMinute + case cinqMinutes + case quinzeMinutes + + var id: String { rawValue } + + /// `nil` : on referme sans attendre. + var delai: TimeInterval? { + switch self { + case .immediat: return nil + case .uneMinute: return 60 + case .cinqMinutes: return 300 + case .quinzeMinutes: return 900 + } + } + + var libelle: LocalizedStringKey { + switch self { + case .immediat: return "Immédiatement" + case .uneMinute: return "Après 1 minute" + case .cinqMinutes: return "Après 5 minutes" + case .quinzeMinutes: return "Après 15 minutes" + } + } +} + +/// Système, clair, sombre — les trois états qu'offre iOS lui-même. +enum Apparence: String, CaseIterable, Identifiable { + case systeme + case clair + case sombre + + var id: String { rawValue } + + var colorScheme: ColorScheme? { + switch self { + case .systeme: return nil + case .clair: return .light + case .sombre: return .dark + } + } + + /// Traduit par le catalogue, comme le reste de l'interface. + var libelle: LocalizedStringKey { + switch self { + case .systeme: return "Système" + case .clair: return "Clair" + case .sombre: return "Sombre" + } + } + + var icone: String { + switch self { + case .systeme: return "circle.lefthalf.filled" + case .clair: return "sun.max" + case .sombre: return "moon.stars" + } + } +} + +/// Les langues dans lesquelles l'application existe. En ajouter une, c'est ajouter un cas +/// ici et une colonne au catalogue — le reste du code n'en sait rien. +enum Langue: String, CaseIterable, Identifiable { + case systeme + case francais + case anglais + + var id: String { rawValue } + + var code: String? { + switch self { + case .systeme: return nil + case .francais: return "fr" + case .anglais: return "en" + } + } + + var locale: Locale? { code.map(Locale.init(identifier:)) } + + /// Le nom d'une langue s'écrit dans cette langue : quelqu'un qui cherche l'anglais + /// cherche « English », pas « Anglais ». D'où `nil` pour le cas « Système », seul + /// libellé de cette liste qui, lui, se traduit. + var nomNatif: String? { + switch self { + case .systeme: return nil + case .francais: return "Français" + case .anglais: return "English" + } + } +} diff --git a/apps/ios/Ghostpass/Services/SharedStore.swift b/apps/ios/Ghostpass/Services/SharedStore.swift new file mode 100644 index 00000000..bd786d13 --- /dev/null +++ b/apps/ios/Ghostpass/Services/SharedStore.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Ce que l'application dépose à l'intention de l'extension de remplissage. +/// +/// L'extension est un autre processus, avec son propre conteneur : elle ne voit que ce +/// qui a été posé dans le groupe d'applications. On y met ce qu'il faut pour **ouvrir** +/// le coffre — jamais de quoi le déverrouiller sans le mot de passe maître. +/// +/// Les blobs ci-dessous ne sont pas des secrets au sens strict : le serveur les détient +/// déjà, et sans la dérivation Argon2id du mot de passe maître ils n'ouvrent rien. Le +/// jeton de session, lui, reste dans le trousseau : celui-là ouvre le compte côté serveur. +enum SharedStore { + /// Doit correspondre au groupe déclaré dans les deux fichiers `.entitlements`. + static let appGroup = "group.ch.stackops.ghostpass" + + /// Racine partagée, ou le conteneur privé de l'application si le groupe n'est pas + /// disponible — sans entitlement, l'application continue de fonctionner seule, et + /// c'est l'extension qui ne verra rien. + static var container: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + } + + /// Vrai quand le groupe est réellement accessible : l'extension ne peut travailler + /// que dans ce cas, et le dire vaut mieux que de la laisser afficher un coffre vide. + static var isShared: Bool { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) != nil + } + + /// De quoi rouvrir le coffre : l'adresse du serveur, le compte, et les blobs chiffrés. + struct Session: Codable, Equatable { + var serverURL: String + var email: String + var kdfParams: String + var encryptedUserKey: String + var encryptedPrivateKey: String + } + + private static var sessionURL: URL? { + guard let container else { return nil } + try? FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) + return container.appendingPathComponent("session.json") + } + + static func save(_ session: Session) { + guard let sessionURL, let data = try? JSONEncoder().encode(session) else { return } + try? data.write(to: sessionURL, options: [.atomic, .completeFileProtection]) + } + + static func load() -> Session? { + guard let sessionURL, let data = try? Data(contentsOf: sessionURL) else { return nil } + return try? JSONDecoder().decode(Session.self, from: data) + } + + static func clear() { + guard let sessionURL else { return } + try? FileManager.default.removeItem(at: sessionURL) + } +} diff --git a/apps/ios/Ghostpass/Services/SiteMatching.swift b/apps/ios/Ghostpass/Services/SiteMatching.swift new file mode 100644 index 00000000..aca60b42 --- /dev/null +++ b/apps/ios/Ghostpass/Services/SiteMatching.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Rapprochement entre l'adresse d'un champ de saisie et les adresses d'un item. +/// +/// Partagé par l'application et l'extension : c'est le même code qui décide, des deux +/// côtés, si un identifiant vaut pour un site. Deux règles qui divergeraient donneraient +/// des suggestions différentes selon l'endroit d'où l'on regarde. +enum SiteMatching { + /// Ramène une URL ou un domaine à son hôte : minuscules, sans schéma, sans chemin, + /// sans port, sans `www.`. + static func host(of value: String) -> String { + var texte = value.trimmingCharacters(in: .whitespaces).lowercased() + if let separateur = texte.range(of: "://") { + texte = String(texte[separateur.upperBound...]) + } + texte = texte.components(separatedBy: "/").first ?? texte + texte = texte.components(separatedBy: "?").first ?? texte + // Un IPv6 entre crochets n'a pas de port à retirer de cette façon. + if !texte.hasPrefix("[") { texte = texte.components(separatedBy: ":").first ?? texte } + if texte.hasPrefix("www.") { texte.removeFirst(4) } + return texte + } + + /// `login.example.com` doit correspondre à `example.com` : les sites déplacent leur + /// formulaire d'authentification sur un sous-domaine sans prévenir personne. + static func sameSite(_ a: String, _ b: String) -> Bool { + guard !a.isEmpty, !b.isEmpty else { return false } + return a == b || a.hasSuffix("." + b) || b.hasSuffix("." + a) + } + + /// L'item convient-il à l'un des domaines demandés ? + static func matches(_ item: VaultItem, domains: [String]) -> Bool { + guard case .login(let login) = item.data else { return false } + let cibles = domains.map(host(of:)).filter { !$0.isEmpty } + guard !cibles.isEmpty else { return false } + let adresses = login.uris.map(host(of:)).filter { !$0.isEmpty } + return adresses.contains { adresse in cibles.contains { sameSite(adresse, $0) } } + } +} diff --git a/apps/ios/Ghostpass/Services/Totp.swift b/apps/ios/Ghostpass/Services/Totp.swift new file mode 100644 index 00000000..32025e53 --- /dev/null +++ b/apps/ios/Ghostpass/Services/Totp.swift @@ -0,0 +1,111 @@ +import CryptoKit +import Foundation + +/// Codes TOTP (RFC 6238), transposition de `apps/web/src/lib/totp.ts`. +/// +/// Le secret vit dans l'item chiffré et ne quitte jamais l'appareil. Le HMAC vient de +/// CryptoKit, la bibliothèque du système — exactement ce que fait la web app avec +/// WebCrypto. Rien n'est réimplémenté : seule la mécanique de la RFC est ici. +struct OtpConfig: Equatable { + var secret: String + var period: Int = 30 + var digits: Int = 6 + var algorithm: Algorithm = .sha1 + + enum Algorithm: String, Equatable { + case sha1, sha256, sha512 + } +} + +enum Totp { + /// Accepte un secret base32 brut ou une URI `otpauth://`. Renvoie `nil` si le champ + /// est vide ou inexploitable — un item sans TOTP est le cas courant, pas une erreur. + static func parse(_ input: String) -> OtpConfig? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + var secret = trimmed + var config = OtpConfig(secret: "") + + if trimmed.lowercased().hasPrefix("otpauth://") { + guard let components = URLComponents(string: trimmed) else { return nil } + let items = components.queryItems ?? [] + func value(_ name: String) -> String? { + items.first { $0.name == name }?.value + } + secret = value("secret") ?? "" + config.period = value("period").flatMap(Int.init) ?? 30 + config.digits = value("digits").flatMap(Int.init) ?? 6 + switch value("algorithm")?.uppercased() { + case "SHA256": config.algorithm = .sha256 + case "SHA512": config.algorithm = .sha512 + default: config.algorithm = .sha1 + } + } + + config.secret = secret.replacingOccurrences( + of: "\\s", with: "", options: .regularExpression + ).uppercased() + guard !config.secret.isEmpty else { return nil } + // Des paramètres absurdes valent mieux corrigés que propagés jusqu'au calcul. + if config.period <= 0 { config.period = 30 } + if !(1...9).contains(config.digits) { config.digits = 6 } + return config + } + + /// Le code courant et le nombre de secondes qu'il lui reste à vivre. + static func code(for config: OtpConfig, at date: Date = Date()) -> (code: String, remaining: Int)? { + let key = base32Decode(config.secret) + guard !key.isEmpty else { return nil } + + let seconds = Int(date.timeIntervalSince1970) + let counter = UInt64(seconds / config.period) + var message = Data(count: 8) + for index in 0..<8 { + message[index] = UInt8truncating(counter >> (8 * UInt64(7 - index))) + } + + let secret = SymmetricKey(data: key) + let digest: Data + switch config.algorithm { + case .sha1: digest = Data(HMAC.authenticationCode(for: message, using: secret)) + case .sha256: digest = Data(HMAC.authenticationCode(for: message, using: secret)) + case .sha512: digest = Data(HMAC.authenticationCode(for: message, using: secret)) + } + + // Troncature dynamique de la RFC : les quatre derniers bits désignent l'offset. + let offset = Int(digest[digest.count - 1] & 0x0f) + let binary = + (UInt32(digest[offset] & 0x7f) << 24) + | (UInt32(digest[offset + 1]) << 16) + | (UInt32(digest[offset + 2]) << 8) + | UInt32(digest[offset + 3]) + // En 64 bits : 10^10 déborde d'un UInt32, et un `digits` fantaisiste ne doit pas + // faire tomber l'application. + let modulo = UInt64(pow(10, Double(config.digits))) + let code = String(format: "%0\(config.digits)llu", UInt64(binary) % modulo) + let remaining = config.period - (seconds % config.period) + return (code, remaining) + } + + private static func UInt8truncating(_ value: UInt64) -> UInt8 { UInt8(value & 0xff) } + + /// Base32 (RFC 4648) sans padding, caractères inconnus ignorés — les secrets + /// recopiés à la main arrivent souvent avec des espaces ou des tirets. + private static func base32Decode(_ input: String) -> Data { + let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") + var out = Data() + var bits = 0 + var value = 0 + for character in input.uppercased() where character != "=" { + guard let index = alphabet.firstIndex(of: character) else { continue } + value = (value << 5) | index + bits += 5 + if bits >= 8 { + out.append(UInt8((value >> (bits - 8)) & 0xff)) + bits -= 8 + } + } + return out + } +} diff --git a/apps/ios/Ghostpass/Services/VaultCache.swift b/apps/ios/Ghostpass/Services/VaultCache.swift new file mode 100644 index 00000000..35e63bfc --- /dev/null +++ b/apps/ios/Ghostpass/Services/VaultCache.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Copie locale du coffre, telle qu'elle vient du serveur : **déjà chiffrée**. +/// +/// Ce sont exactement les blobs que le serveur stocke sans pouvoir les lire ; les poser +/// sur le disque n'élargit donc pas la surface d'exposition. Ce qui les ouvre — l'USK — +/// dérive du mot de passe maître et n'est jamais écrit nulle part. +/// +/// `.completeFileProtection` par-dessus : tant que l'appareil est verrouillé, le fichier +/// n'est même pas lisible. C'est une ceinture, pas la bretelle. +enum VaultCache { + private static let fileName = "vault-cache.json" + + /// Dans le conteneur partagé : l'extension de remplissage lit le même coffre, sans + /// avoir à le retélécharger ni à ouvrir de session à elle. + private static var url: URL? { + guard let directory = SharedStore.container else { return nil } + try? FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent(fileName) + } + + static func save(_ items: [EncryptedItemDTO]) { + guard let url, let data = try? JSONEncoder().encode(items) else { return } + try? data.write(to: url, options: [.atomic, .completeFileProtection]) + } + + /// Renvoie `nil` s'il n'y a rien, ou si le fichier n'est plus lisible — un cache + /// illisible n'est pas une erreur à remonter, seulement un coffre à recharger. + static func load() -> [EncryptedItemDTO]? { + guard let url, let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode([EncryptedItemDTO].self, from: data) + } + + static func clear() { + guard let url else { return } + try? FileManager.default.removeItem(at: url) + } +} diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift new file mode 100644 index 00000000..fdf3552b --- /dev/null +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -0,0 +1,1234 @@ +import Foundation + +/// État de l'application : session, coffre déchiffré, opérations CRUD. +/// +/// `Account` vient du binding UniFFI ; il détient les clés côté Rust. On ne le +/// conserve qu'en mémoire, et `lock()` le relâche — le verrouillage n'est pas +/// une bascule d'affichage, c'est la perte effective des clés. +@MainActor +final class VaultStore: ObservableObject { + @Published private(set) var entries: [VaultEntry] = [] + @Published private(set) var isUnlocked = false + @Published private(set) var isBusy = false + @Published var errorMessage: String? + /// Le coffre affiché vient du disque, faute d'avoir pu joindre le serveur. + @Published private(set) var isOffline = false + /// Dossiers **vides**, ceux qu'aucun élément n'habite. Les autres se déduisent des + /// éléments eux-mêmes ; seuls ceux-ci ont besoin d'être écrits quelque part, faute + /// de quoi créer un dossier avant d'y ranger quoi que ce soit ne laisserait aucune + /// trace. C'est le rôle de l'item de registre, partagé avec la web app. + @Published private(set) var emptyFolders: [String] = [] + /// Les éléments mis en favori, par identifiant. Un favori n'est pas une propriété de + /// l'élément — le modèle du cœur Rust n'en a pas — mais une liste tenue à part, dans + /// son propre registre chiffré. + @Published private(set) var favorites: Set = [] + /// Identité de chaque registre, par nom, pour les mettre à jour plutôt que les + /// multiplier. Un registre absent de ce dictionnaire n'existe pas encore côté serveur. + private var registryIDs: [String: String] = [:] + /// Vrai juste après un déverrouillage réussi, quand la biométrie est disponible mais + /// pas encore configurée : l'UI peut alors proposer de l'activer. + @Published var offersBiometricEnrollment = false + + private var account: Account? + private var token: String? + private var api: APIClient? + /// Mot de passe maître retenu le temps de proposer l'enrôlement biométrique, jamais + /// au-delà : `enableBiometrics` et `declineBiometrics` l'effacent tous les deux. + private var pendingPassword: String? + + var hasSavedSession: Bool { SharedStore.load() != nil } + + var savedEmail: String { SharedStore.load()?.email ?? "" } + var savedServer: String { SharedStore.load()?.serverURL ?? "" } + + // ─── Session ─── + + /// Connexion complète : prelogin pour les paramètres KDF, dérivation du hash + /// d'authentification côté Rust, puis déverrouillage local avec les blobs reçus. + /// Le mot de passe ne quitte jamais l'appareil ; le serveur ne voit qu'un hash. + func signIn(server: String, email: String, password: String, totpCode: String?) async { + guard let url = URL(string: server) else { + errorMessage = APIError.badURL.localizedDescription + return + } + isBusy = true + defer { isBusy = false } + do { + let client = APIClient(baseURL: url) + let kdf = try await client.prelogin(email: email).kdfParams + let hash = try masterPasswordHash( + password: password, email: email, kdfParamsJson: kdf) + let session = try await client.login( + email: email, masterPasswordHash: hash, totpCode: totpCode) + + let unlocked = try Account.unlock( + password: password, + email: email, + kdfParamsJson: session.kdfParams, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey) + + account = unlocked + token = session.token + api = client + isUnlocked = true + errorMessage = nil + + // Le jeton ouvre le compte côté serveur : il reste au trousseau. Les blobs, + // eux, vont dans le conteneur partagé — l'extension de remplissage en a besoin + // et le serveur les détient déjà. + Keychain.set(session.token, for: Keychain.Key.token) + SharedStore.save( + SharedStore.Session( + serverURL: server, email: email, kdfParams: session.kdfParams, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey)) + + proposeBiometricsIfPossible(password) + await refresh() + } catch { + errorMessage = error.localizedDescription + } + } + + /// Réouverture sans appel réseau : les blobs sont déjà dans le trousseau, seul le + /// mot de passe manque. C'est ce chemin qu'emprunte l'extension AutoFill, qui doit + /// pouvoir déverrouiller sans dépendre de la disponibilité du serveur. + func unlockOffline(password: String) async { + guard let session = SharedStore.load(), let url = URL(string: session.serverURL) else { + errorMessage = tr("Aucune session enregistrée sur cet appareil.") + return + } + isBusy = true + defer { isBusy = false } + do { + account = try Account.unlock( + password: password, email: session.email, kdfParamsJson: session.kdfParams, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey) + token = Keychain.get(Keychain.Key.token) + api = APIClient(baseURL: url) + isUnlocked = true + errorMessage = nil + proposeBiometricsIfPossible(password) + await refresh() + } catch { + errorMessage = tr("Mot de passe maître incorrect.") + } + } + + // ─── Verrouillage différé ─── + + /// L'instant où l'application a quitté l'écran, si elle est encore ouverte. + private var sortieDeLEcran: Date? + + /// L'application passe en arrière-plan. Sans délai, on referme tout de suite ; avec, + /// on note l'heure et on décidera au retour. + func noterLaSortieDeLEcran(delai: TimeInterval?) { + guard delai != nil else { + sortieDeLEcran = nil + lock() + return + } + sortieDeLEcran = Date() + } + + /// L'application revient. On referme si le délai est écoulé. + func verrouillerSiLeDelaiEstEcoule(delai: TimeInterval?, maintenant: Date = Date()) { + defer { sortieDeLEcran = nil } + guard let sortie = sortieDeLEcran else { return } + if Self.doitVerrouiller(sortie: sortie, delai: delai, maintenant: maintenant) { lock() } + } + + /// La décision, isolée du reste pour être vérifiable sans monter d'application. + /// + /// Une horloge qui recule — changement de fuseau, correction NTP — donnerait un écart + /// négatif et laisserait le coffre ouvert indéfiniment. On referme dans ce cas : entre + /// se tromper vers l'ouvert et se tromper vers le fermé, le choix est fait d'avance. + nonisolated static func doitVerrouiller( + sortie: Date, delai: TimeInterval?, maintenant: Date + ) -> Bool { + guard let delai else { return true } + let ecoule = maintenant.timeIntervalSince(sortie) + return ecoule < 0 || ecoule >= delai + } + + /// Relâche les clés. Les blobs chiffrés restent, pour permettre une réouverture. + func lock() { + account = nil + entries = [] + isUnlocked = false + isOffline = false + pendingPassword = nil + offersBiometricEnrollment = false + emptyFolders = [] + favorites = [] + registryIDs = [:] + } + + /// Déconnexion : révoque la session côté serveur et efface tout localement. + func signOut() async { + if let api, let token { + try? await api.logout(token: token) + } + lock() + token = nil + api = nil + VaultCache.clear() + for key in [ + Keychain.Key.token, Keychain.Key.masterPassword, Keychain.Key.biometricsEnabled, + ] { + Keychain.remove(key) + } + SharedStore.clear() + await CredentialIdentities.clear() + } + + // ─── Biométrie ─── + + /// Le bouton « Déverrouiller avec Face ID » a-t-il un sens ici et maintenant ? + var canUnlockWithBiometrics: Bool { + Biometrics.isAvailable + && Keychain.get(Keychain.Key.biometricsEnabled) == "1" + && hasSavedSession + } + + var biometryLabel: String { Biometrics.label } + var biometryIcon: String { Biometrics.icon } + + /// La biométrie est-elle disponible sur cet appareil, indépendamment du choix fait ? + var biometryAvailable: Bool { Biometrics.isAvailable } + + /// Le déverrouillage biométrique est-il actif ? + var isBiometricEnabled: Bool { Keychain.get(Keychain.Key.biometricsEnabled) == "1" } + + /// Déverrouille sans saisie : la biométrie autorise la relecture du mot de passe + /// maître, et c'est toujours lui qui ouvre le coffre côté Rust. + func unlockWithBiometrics() async { + guard Keychain.get(Keychain.Key.biometricsEnabled) == "1" else { return } + isBusy = true + let prompt = "Déverrouiller votre coffre GhostPass" + // La demande biométrique bloque le fil sur lequel elle est faite. + let password = await Task.detached { + Keychain.getBiometric(Keychain.Key.masterPassword, prompt: prompt) + }.value + isBusy = false + NSLog("GP-BIO relecture=%@", password == nil ? "échec" : "ok") + guard let password else { + // Refus, échec, ou entrée invalidée par un nouvel enrôlement : on ne + // reste pas coincé, le mot de passe maître marche toujours. + errorMessage = tr("\(Biometrics.label) n'a pas permis d'ouvrir le coffre.") + return + } + await unlockOffline(password: password) + } + + /// Retient le mot de passe le temps de poser la question, si elle a lieu d'être. + private func proposeBiometricsIfPossible(_ password: String) { + guard Biometrics.isAvailable, + Keychain.get(Keychain.Key.biometricsEnabled) == nil + else { return } + pendingPassword = password + offersBiometricEnrollment = true + NSLog("GP-BIO proposition") + } + + /// L'utilisateur accepte la proposition faite juste après un déverrouillage : le mot + /// de passe est encore en main, inutile de le redemander. + func acceptOfferedBiometrics() { + defer { pendingPassword = nil; offersBiometricEnrollment = false } + guard let password = pendingPassword else { return } + store(password) + } + + /// Active la biométrie à froid, depuis les réglages. Le mot de passe maître n'est plus + /// en mémoire : on le redemande, et surtout **on le vérifie** en rouvrant réellement le + /// coffre avec — on ne dépose au trousseau qu'un secret dont on sait qu'il ouvre. + @discardableResult + func enableBiometrics(password: String) -> Bool { + guard let session = SharedStore.load() else { + errorMessage = tr("Aucune session enregistrée sur cet appareil.") + return false + } + do { + _ = try Account.unlock( + password: password, email: session.email, kdfParamsJson: session.kdfParams, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey) + } catch { + errorMessage = tr("Mot de passe maître incorrect.") + return false + } + return store(password) + } + + @discardableResult + private func store(_ password: String) -> Bool { + let status = Keychain.setBiometric(password, for: Keychain.Key.masterPassword) + guard status == errSecSuccess else { + errorMessage = tr("\(Biometrics.label) n'a pas pu être activé (code \(status)).") + return false + } + Keychain.set("1", for: Keychain.Key.biometricsEnabled) + return true + } + + /// L'utilisateur refuse : on oublie le mot de passe et on ne repose pas la question à + /// chaque ouverture. Le refus n'est pas définitif : les réglages du coffre permettent + /// de revenir dessus, sans quoi un « Plus tard » condamnerait la fonction. + func declineBiometrics() { + NSLog("GP-BIO refus") + pendingPassword = nil + offersBiometricEnrollment = false + Keychain.set("0", for: Keychain.Key.biometricsEnabled) + } + + /// Retire le déverrouillage biométrique sans se déconnecter. + func disableBiometrics() { + Keychain.remove(Keychain.Key.masterPassword) + Keychain.set("0", for: Keychain.Key.biometricsEnabled) + } + + // ─── Coffre ─── + + /// Recharge le coffre. La copie locale s'affiche d'abord : un coffre qui reste vide + /// parce que le réseau manque n'est pas un coffre. Le serveur, lui, fait autorité dès + /// qu'il répond. + func refresh() async { + guard let account else { return } + if entries.isEmpty, let caches = VaultCache.load() { + appliquer(Self.lecture(caches, account)) + isOffline = true + } + guard let api, let token else { return } + do { + let dtos = try await api.listItems(token: token) + VaultCache.save(dtos) + appliquer(Self.lecture(dtos, account)) + isOffline = false + errorMessage = nil + await CredentialIdentities.sync(entries) + } catch { + // Avec une copie locale sous la main, l'absence de réseau se signale sans + // rien interrompre. Sans elle, il n'y a rien à montrer : c'est une erreur. + isOffline = true + errorMessage = entries.isEmpty ? error.localizedDescription : nil + } + } + + /// Déchiffre, écarte le registre interne, ordonne. Un item illisible est ignoré + /// plutôt que de faire échouer la liste entière. + private static func entries(from dtos: [EncryptedItemDTO], with account: Account) + -> [VaultEntry] + { + lecture(dtos, account).entries + } + + /// Ce qu'un tour de déchiffrement rapporte : les éléments, et les registres cueillis + /// au passage. Ils traversent la liste comme les autres éléments — autant les prendre + /// là plutôt que de refaire un tour pour eux seuls. + struct Lecture { + var entries: [VaultEntry] = [] + var folders: [String] = [] + var favorites: Set = [] + /// Identité serveur de chaque registre, par nom. + var registryIDs: [String: String] = [:] + } + + nonisolated static func lecture(_ dtos: [EncryptedItemDTO], _ account: Account) -> Lecture { + var resultat = Lecture() + + for dto in dtos { + guard let item = try? decrypt(dto, with: account) else { continue } + if isRegistry(item) { + resultat.registryIDs[item.name] = dto.id + let liste = contenuDeRegistre(item) + switch item.name { + case VaultConstants.foldersItemName: resultat.folders = liste + case VaultConstants.favoritesItemName: resultat.favorites = Set(liste) + // Un registre d'une version plus récente, ou d'un autre produit de la + // suite : on ne sait pas le lire, mais on sait ne pas l'afficher. + default: break + } + continue + } + resultat.entries.append(VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt)) + } + + resultat.entries.sort { + $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending + } + resultat.folders.sort { $0.localizedCompare($1) == .orderedAscending } + return resultat + } + + /// Un registre est un `SecureNote` dont le contenu est un tableau JSON de chaînes. + /// Illisible, il vaut mieux le tenir pour vide que faire échouer toute la lecture. + nonisolated private static func contenuDeRegistre(_ item: VaultItem) -> [String] { + guard case .secureNote(let note) = item.data, + let data = note.content.data(using: .utf8), + let liste = try? JSONDecoder().decode([String].self, from: data) + else { return [] } + return liste + } + + func save(_ item: VaultItem, id: String?) async { + guard let api, let token, let account else { return } + isBusy = true + defer { isBusy = false } + do { + let (key, data) = try Self.encrypt(item, with: account) + if let id { + _ = try await api.updateItem( + token: token, id: id, encryptedKey: key, encryptedData: data) + } else { + _ = try await api.createItem(token: token, encryptedKey: key, encryptedData: data) + } + await refresh() + } catch is URLError { + // Écrire suppose le serveur : il n'y a pas de file d'attente hors ligne, et + // laisser croire à un enregistrement serait pire que de le refuser. + errorMessage = tr("Serveur injoignable : la modification n'a pas été enregistrée.") + } catch { + errorMessage = error.localizedDescription + } + } + + func delete(_ entry: VaultEntry) async { + guard let api, let token else { return } + do { + try await api.deleteItem(token: token, id: entry.id) + entries.removeAll { $0.id == entry.id } + if let dtos = VaultCache.load() { + VaultCache.save(dtos.filter { $0.id != entry.id }) + } + } catch is URLError { + errorMessage = tr("Serveur injoignable : la suppression n'a pas été enregistrée.") + } catch { + errorMessage = error.localizedDescription + } + } + + private func appliquer(_ lecture: Lecture) { + entries = lecture.entries + registryIDs = lecture.registryIDs + emptyFolders = lecture.folders + favorites = lecture.favorites + } + + // ─── Dossiers ─── + + /// Tous les chemins de dossiers : ceux qu'habitent des éléments, et ceux que le + /// registre garde en mémoire faute d'occupant. + var folderPaths: [String] { + let occupes = entries.compactMap { $0.item.folder }.filter { !$0.isEmpty } + return Array(Set(occupes).union(emptyFolders)) + .sorted { $0.localizedCompare($1) == .orderedAscending } + } + + /// Combien d'éléments habitent ce dossier — ses sous-dossiers compris, sans quoi un + /// dossier parent paraîtrait vide alors qu'il ne l'est pas. + func itemCount(in path: String) -> Int { + entries.filter { entry in + guard let folder = entry.item.folder else { return false } + return folder == path || folder.hasPrefix(path + "/") + } + .count + } + + /// Crée un dossier vide. Un chemin est normalisé — sans blancs ni barres aux extrémités — + /// car « Travail/ » et « Travail » désignent le même endroit et ne doivent pas coexister. + func createFolder(_ chemin: String) async { + let path = Self.normaliser(chemin) + guard !path.isEmpty, !folderPaths.contains(path) else { return } + emptyFolders = (emptyFolders + [path]) + .sorted { $0.localizedCompare($1) == .orderedAscending } + await saveFolders() + } + + /// Retire un dossier du registre, ses sous-dossiers avec lui. Les éléments qui s'y + /// trouvent ne bougent pas : supprimer un rangement n'est pas supprimer ce qu'il range. + func removeFolder(_ path: String) async { + emptyFolders.removeAll { $0 == path || $0.hasPrefix(path + "/") } + await saveFolders() + } + + nonisolated static func normaliser(_ chemin: String) -> String { + chemin.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private func saveFolders() async { + await enregistrerRegistre( + VaultConstants.foldersItemName, emptyFolders, + echec: tr("Serveur injoignable : les dossiers n'ont pas été enregistrés.")) + } + + // ─── Favoris ─── + + /// Les favoris, dans l'ordre du coffre. Un identifiant qui ne correspond plus à rien — + /// l'élément a été supprimé ailleurs — disparaît de lui-même : le registre garde une + /// trace inoffensive, que la prochaine écriture nettoie. + var favoriteEntries: [VaultEntry] { + entries.filter { favorites.contains($0.id) } + } + + func isFavorite(_ entry: VaultEntry) -> Bool { favorites.contains(entry.id) } + + func toggleFavorite(_ entry: VaultEntry) async { + if favorites.contains(entry.id) { + favorites.remove(entry.id) + } else { + favorites.insert(entry.id) + } + // On ne réécrit que ce qui existe encore : sans ce filtrage, le registre + // accumulerait les identifiants d'éléments supprimés depuis longtemps. + let vivants = Set(entries.map(\.id)) + favorites.formIntersection(vivants) + await enregistrerRegistre( + VaultConstants.favoritesItemName, favorites.sorted(), + echec: tr("Serveur injoignable : les favoris n'ont pas été enregistrés.")) + } + + // ─── Export ─── + + /// Le mot de passe maître est-il celui du coffre ? La question se pose avant de + /// fabriquer un fichier qui contiendra tout en clair : un téléphone déverrouillé posé + /// sur une table ne doit pas suffire à le vider. + /// + /// La vérification passe par le cœur : c'est une dérivation Argon2id sur les blobs + /// enregistrés, pas une comparaison de chaînes. + func verifyMasterPassword(_ password: String) -> Bool { + guard let session = SharedStore.load() else { + errorMessage = tr("Aucune session enregistrée sur cet appareil.") + return false + } + do { + _ = try Account.unlock( + password: password, email: session.email, kdfParamsJson: session.kdfParams, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey) + errorMessage = nil + return true + } catch { + errorMessage = tr("Mot de passe maître incorrect.") + return false + } + } + + // ─── Import ─── + + /// Dépose une fournée d'items d'un coup. Un seul rechargement à la fin : rafraîchir le + /// coffre après chaque ligne d'un fichier de deux cents entrées ferait deux cents + /// allers-retours pour rien, et l'écran clignoterait tout du long. + /// + /// Rend le nombre d'items effectivement déposés. En cas de coupure à mi-chemin, ceux + /// qui sont passés restent : le dire vaut mieux que laisser croire à un échec total. + @discardableResult + func importItems(_ items: [VaultItem]) async -> Int { + guard let api, let token, let account else { return 0 } + isBusy = true + defer { isBusy = false } + var deposes = 0 + do { + for item in items { + let (key, data) = try Self.encrypt(item, with: account) + _ = try await api.createItem(token: token, encryptedKey: key, encryptedData: data) + deposes += 1 + } + } catch is URLError { + errorMessage = tr("Serveur injoignable : l'import s'est arrêté en chemin.") + } catch { + errorMessage = error.localizedDescription + } + await refresh() + return deposes + } + + // ─── Récupération de compte ─── + + /// Fabrique un kit de récupération et le dépose au serveur. Rend la clé à afficher — + /// une seule fois, car personne ne la conserve : ni le serveur, qui n'en reçoit qu'une + /// preuve re-hachée, ni l'application. C'est tout l'intérêt, et c'est aussi ce qui + /// rend l'écran qui l'affiche irremplaçable. + func createRecoveryKit() async -> String? { + guard let api, let token, let account else { return nil } + isBusy = true + defer { isBusy = false } + do { + let json = try account.createRecovery() + let kit = try JSONDecoder().decode(RecoveryKit.self, from: Data(json.utf8)) + try await api.enrollRecovery( + token: token, recoveryAuthHash: kit.recoveryAuthHash, + encryptedUserKeyRecovery: kit.encryptedUserKeyRecovery) + errorMessage = nil + return kit.recoveryKey + } catch is URLError { + errorMessage = tr("Serveur injoignable : la clé de récupération n'a pas été enregistrée.") + } catch { + errorMessage = error.localizedDescription + } + return nil + } + + /// Réinitialise le mot de passe maître à partir de la clé de récupération. + /// + /// Rien n'est déverrouillé ici : le serveur invalide toutes les sessions, et c'est + /// voulu — si quelqu'un a réinitialisé le mot de passe, les sessions ouvertes ailleurs + /// n'ont plus lieu d'être. L'utilisateur se reconnecte ensuite, avec le nouveau. + func recoverAccount(server: String, email: String, recoveryKey: String, newPassword: String) + async -> Bool + { + guard let url = URL(string: server) else { + errorMessage = tr("Adresse de serveur invalide.") + return false + } + isBusy = true + defer { isBusy = false } + let client = APIClient(baseURL: url) + do { + let blob = try await client.recoveryBlob(email: email) + // `recover` est une fonction libre du binding, pas une méthode d'`Account` : + // il n'y a pas encore de compte ouvert au moment où on l'appelle. + let resultat = try recover( + recoveryKey: recoveryKey.trimmingCharacters(in: .whitespacesAndNewlines), + email: email, newPassword: newPassword, kdfParamsJson: blob.kdfParams, + encryptedUserKeyRecovery: blob.encryptedUserKeyRecovery, + encryptedPrivateKey: blob.encryptedPrivateKey) + let reset = try JSONDecoder().decode(ResetBlob.self, from: Data(resultat.reset().utf8)) + try await client.recover( + email: email, recoveryAuthHash: reset.recoveryAuthHash, + newMasterPasswordHash: reset.masterPasswordHash, + newEncryptedUserKey: reset.encryptedUserKey) + errorMessage = nil + return true + } catch is URLError { + errorMessage = tr("Serveur injoignable.") + } catch is DecodingError { + errorMessage = tr("Réponse du serveur incompréhensible.") + } catch { + // Le serveur répond de la même façon pour une clé fausse et pour un compte + // sans kit : le dire autrement révélerait lequel des deux. + errorMessage = tr("Clé de récupération refusée.") + } + return false + } + + /// Le JSON que rend `createRecovery()`. Les noms sont ceux de serde, côté Rust. + private struct RecoveryKit: Decodable { + let recoveryKey: String + let recoveryAuthHash: String + let encryptedUserKeyRecovery: String + + enum CodingKeys: String, CodingKey { + case recoveryKey = "recovery_key" + case recoveryAuthHash = "recovery_auth_hash" + case encryptedUserKeyRecovery = "encrypted_user_key_recovery" + } + } + + /// Le JSON que rend `RecoveryResult.reset()`, à transmettre tel quel au serveur. + private struct ResetBlob: Decodable { + let masterPasswordHash: String + let recoveryAuthHash: String + let encryptedUserKey: String + + enum CodingKeys: String, CodingKey { + case masterPasswordHash = "master_password_hash" + case recoveryAuthHash = "recovery_auth_hash" + case encryptedUserKey = "encrypted_user_key" + } + } + + // ─── Registres ─── + + /// Écrit un registre : un `SecureNote` dont le contenu est un tableau JSON, sous un nom + /// que l'interface masque. Même format que la web app, au caractère près. + private func enregistrerRegistre( + _ nom: String, _ valeurs: [String], echec: String + ) async { + guard let api, let token, let account else { return } + let contenu = String( + decoding: (try? JSONEncoder().encode(valeurs)) ?? Data("[]".utf8), as: UTF8.self) + let item = VaultItem( + name: nom, notes: nil, folder: nil, + data: .secureNote(SecureNote(content: contenu))) + do { + let (key, data) = try Self.encrypt(item, with: account) + if let identifiant = registryIDs[nom] { + _ = try await api.updateItem( + token: token, id: identifiant, encryptedKey: key, encryptedData: data) + } else { + let cree = try await api.createItem( + token: token, encryptedKey: key, encryptedData: data) + registryIDs[nom] = cree.id + } + await refresh() + } catch is URLError { + errorMessage = echec + } catch { + errorMessage = error.localizedDescription + } + } + + // ─── Corbeille ─── + + /// Les items supprimés, déchiffrés à la demande. Ils ne sont pas conservés dans + /// `entries` : la corbeille se consulte, elle n'encombre pas le coffre. + func loadTrash() async -> [VaultEntry] { + guard let api, let token, let account else { return [] } + do { + let dtos = try await api.listTrash(token: token) + errorMessage = nil + return Self.entries(from: dtos, with: account) + } catch { + errorMessage = error.localizedDescription + return [] + } + } + + func restore(_ entry: VaultEntry) async { + guard let api, let token else { return } + do { + try await api.restoreItem(token: token, id: entry.id) + await refresh() + } catch is URLError { + errorMessage = tr("Serveur injoignable : l'item n'a pas été restauré.") + } catch { + errorMessage = error.localizedDescription + } + } + + /// Suppression définitive. Rien ne la rattrape : l'appelant doit avoir demandé + /// confirmation avant d'arriver ici. + func purge(_ entry: VaultEntry) async { + guard let api, let token else { return } + do { + try await api.purgeItem(token: token, id: entry.id) + } catch is URLError { + errorMessage = tr("Serveur injoignable : l'item n'a pas été supprimé.") + } catch { + errorMessage = error.localizedDescription + } + } + + // ─── Passage de frontière ─── + // + // `static` et non `private` : c'est la couture où le JSON du serveur rencontre celui + // du cœur Rust, donc l'endroit exact où une divergence de contrat se paie. Les tests + // l'exercent directement, sans monter ni session ni interface. + + /// Un item de registre interne — l'arborescence des dossiers partagée avec la web app — + /// n'a rien à faire dans la liste. L'afficher serait une régression visible. + nonisolated static func isRegistry(_ item: VaultItem) -> Bool { + item.name.hasPrefix(VaultConstants.registryPrefix) + } + + /// Le cœur échange des `EncryptedItem` en JSON (`encrypted_key` / `encrypted_data`), + /// l'API les expose en camelCase et à plat. La conversion tient ici, en un seul endroit. + nonisolated static func decrypt(_ dto: EncryptedItemDTO, with account: Account) throws + -> VaultItem + { + let envelope = ["encrypted_key": dto.encryptedKey, "encrypted_data": dto.encryptedData] + let json = String(data: try JSONEncoder().encode(envelope), encoding: .utf8) ?? "{}" + let clear = try account.decryptItem(encryptedItemJson: json) + return try JSONDecoder().decode(VaultItem.self, from: Data(clear.utf8)) + } + + nonisolated static func encrypt(_ item: VaultItem, with account: Account) throws + -> (String, String) + { + let json = String(data: try JSONEncoder().encode(item), encoding: .utf8) ?? "{}" + let encrypted = try account.encryptItem(itemJson: json) + let fields = try JSONDecoder().decode( + [String: String].self, from: Data(encrypted.utf8)) + guard let key = fields["encrypted_key"], let data = fields["encrypted_data"] else { + throw APIError.malformedResponse + } + return (key, data) + } + + // ─── Accès d'urgence ─── + + /// Les deux sens du lien : ceux à qui j'ai confié une clé, et ceux qui m'en ont confié une. + func lienDUrgence() async -> (donnes: [LienDUrgence], recus: [LienDUrgence]) { + guard let api, let token else { return ([], []) } + do { + let liste = try await api.listEmergency(token: token) + return ( + liste.asGrantor.compactMap(LienDUrgence.init), + liste.asGrantee.compactMap(LienDUrgence.init) + ) + } catch { + errorMessage = error.localizedDescription + return ([], []) + } + } + + /// Confie l'accès à un contact. Le scellement se fait ici, en local : le serveur ne reçoit + /// qu'un blob chiffré vers la clé publique du contact, qu'il ne peut pas ouvrir lui-même. + func confierLAcces(a email: String, role: RoleDUrgence, delai: Int) async -> Bool { + guard let api, let token, let account else { return false } + isBusy = true + defer { isBusy = false } + do { + let cle = try await api.lookupPublicKey(token: token, email: email) + let scelle = try account.sealUserKeyFor(contactPublicKey: cle) + try await api.inviteEmergency( + token: token, email: email, role: role.rawValue, waitDays: delai, + sealedUserKey: scelle) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// `accept` et `request` appartiennent au contact, `approve` et `reject` au donneur. + /// Le serveur vérifie qui a le droit de quoi ; on ne fait que transmettre. + func agirSurLUrgence(_ id: String, action: String) async -> Bool { + guard let api, let token else { return false } + do { + try await api.emergencyAction(token: token, id: id, action: action) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func revoquerLUrgence(_ id: String) async -> Bool { + guard let api, let token else { return false } + do { + try await api.removeEmergency(token: token, id: id) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// Ouvre le coffre du donneur. L'USK récupérée reste dans le cœur Rust : on ne rend que + /// des items déjà déchiffrés, et le coffre d'urgence lui-même n'est pas conservé. + func ouvrirLeCoffreDUrgence(_ id: String) async -> CoffreDUrgenceOuvert? { + guard let api, let token, let account else { return nil } + isBusy = true + defer { isBusy = false } + do { + let acces = try await api.emergencyAccess(token: token, id: id) + let coffre = try account.openEmergency( + grantorPublicKey: acces.grantorPublicKey, sealed: acces.sealedUserKey) + let entrees = acces.items.compactMap { dto -> VaultEntry? in + guard let item = try? coffre.ouvrir(dto), !Self.isRegistry(item) + else { return nil } + return VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt) + } + return CoffreDUrgenceOuvert( + lien: id, + role: RoleDUrgence(rawValue: acces.role) ?? .view, + donneur: acces.grantorEmail, + kdfParams: acces.grantorKdfParams, + entrees: entrees.sorted { $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending }, + coffre: coffre) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + /// Reprise : impose un nouveau mot de passe maître au donneur. Le calcul est fait par le + /// cœur à partir de l'USK récupérée ; le serveur reçoit un hash et une USK ré-enveloppée. + func reprendreLeCompte(_ ouvert: CoffreDUrgenceOuvert, nouveauMotDePasse: String) async -> Bool + { + guard let api, let token else { return false } + isBusy = true + defer { isBusy = false } + do { + let json = try ouvert.coffre.takeover( + grantorEmail: ouvert.donneur, kdfParamsJson: ouvert.kdfParams, + newPassword: nouveauMotDePasse) + let reset = try JSONDecoder().decode(RepriseDUrgence.self, from: Data(json.utf8)) + try await api.emergencyTakeover( + token: token, id: ouvert.lien, masterPasswordHash: reset.masterPasswordHash, + encryptedUserKey: reset.encryptedUserKey) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + // ─── Organisations ─── + + /// Les organisations dont on est membre, invitations comprises. + func organisations() async -> [Organisation] { + guard let api, let token else { return [] } + do { + return try await api.listOrgs(token: token).compactMap(Organisation.init) + } catch { + errorMessage = error.localizedDescription + return [] + } + } + + func accepterLOrganisation(_ id: String) async -> Bool { + guard let api, let token else { return false } + do { + try await api.acceptOrg(token: token, org: id) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// Ouvre le coffre d'une organisation : récupère l'Org Key scellée pour nous et la fait + /// ouvrir par le cœur, **en vérifiant qu'elle vient bien de l'admin**. Sans cette + /// vérification, un serveur actif pourrait substituer une Org Key de son choix et lire + /// tout ce qu'on y écrirait ensuite. + func ouvrirLOrganisation(_ organisation: Organisation) async -> CoffrePartageOuvert? { + guard let api, let token, let account else { return nil } + isBusy = true + defer { isBusy = false } + do { + let appartenance = try await api.orgMembership(token: token, org: organisation.id) + guard let scellee = appartenance.encryptedOrgKey, + let adminPublicKey = appartenance.sealedByPublicKey + else { + errorMessage = tr("Aucune clé ne vous a encore été remise pour ce coffre.") + return nil + } + let org = try account.openOrg(adminPublicKey: adminPublicKey, sealed: scellee) + let collections = try await api.orgCollections(token: token, org: organisation.id) + return CoffrePartageOuvert( + organisation: organisation, collections: collections, org: org) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + /// Les items d'une collection, déchiffrés sous l'Org Key. + func itemsPartages(_ ouvert: CoffrePartageOuvert, collection: String) async -> [VaultEntry] { + guard let api, let token else { return [] } + do { + let dtos = try await api.orgItems( + token: token, org: ouvert.organisation.id, collection: collection) + return dtos.compactMap { dto -> VaultEntry? in + guard let item = try? ouvert.org.ouvrir(dto) else { return nil } + return VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt) + } + .sorted { + $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending + } + } catch { + errorMessage = error.localizedDescription + return [] + } + } + + /// Dépose ou met à jour un item dans une collection partagée. Le chiffrement se fait sous + /// l'Org Key, jamais sous la nôtre : c'est ce qui rend l'item lisible par les autres + /// membres et illisible pour quiconque quitte l'organisation après une rotation. + func enregistrerDansLaCollection( + _ ouvert: CoffrePartageOuvert, collection: String, item: VaultItem, remplace id: String? + ) async -> Bool { + guard let api, let token else { return false } + isBusy = true + defer { isBusy = false } + do { + let json = String(data: try JSONEncoder().encode(item), encoding: .utf8) ?? "{}" + let chiffre = try ouvert.org.encryptItem(itemJson: json) + let enveloppe = try JSONDecoder().decode( + EnveloppeChiffree.self, from: Data(chiffre.utf8)) + if let id { + try await api.updateOrgItem( + token: token, org: ouvert.organisation.id, collection: collection, id: id, + encryptedKey: enveloppe.encryptedKey, encryptedData: enveloppe.encryptedData) + } else { + _ = try await api.createOrgItem( + token: token, org: ouvert.organisation.id, collection: collection, + encryptedKey: enveloppe.encryptedKey, encryptedData: enveloppe.encryptedData) + } + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func supprimerDeLaCollection( + _ ouvert: CoffrePartageOuvert, collection: String, id: String + ) async -> Bool { + guard let api, let token else { return false } + do { + try await api.deleteOrgItem( + token: token, org: ouvert.organisation.id, collection: collection, id: id) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + // ─── Administration d'organisation ─── + + /// Crée une équipe. L'Org Key naît ici, dans le cœur Rust, et le créateur se la scelle à + /// lui-même : le serveur reçoit un blob qu'il ne peut pas ouvrir, et n'a donc jamais + /// connu la clé du coffre qu'il héberge. + func creerUneOrganisation(nom: String) async -> Bool { + guard let api, let token, let account else { return false } + isBusy = true + defer { isBusy = false } + do { + let creation = try account.createOrg() + try await api.createOrg( + token: token, name: nom, encryptedOrgKey: creation.sealedForSelf()) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func membres(_ organisation: Organisation) async -> [MembreDEquipe] { + guard let api, let token else { return [] } + do { + return try await api.orgMembers(token: token, org: organisation.id) + .compactMap(MembreDEquipe.init) + } catch { + errorMessage = error.localizedDescription + return [] + } + } + + /// Invite quelqu'un dans une équipe : on récupère sa clé publique, on lui scelle l'Org + /// Key, et c'est ce blob-là qui part au serveur. + func inviterDansLEquipe( + _ ouvert: CoffrePartageOuvert, email: String, role: RoleDOrganisation + ) async -> Bool { + guard let api, let token, let account else { return false } + isBusy = true + defer { isBusy = false } + do { + let cle = try await api.lookupPublicKey(token: token, email: email) + let scellee = try account.sealOrgKeyForMember(org: ouvert.org, memberPublicKey: cle) + try await api.addOrgMember( + token: token, org: ouvert.organisation.id, email: email, role: role.rawValue, + encryptedOrgKey: scellee) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func changerLeRole( + _ organisation: Organisation, membre: String, role: RoleDOrganisation + ) async -> Bool { + guard let api, let token else { return false } + do { + try await api.setOrgMemberRole( + token: token, org: organisation.id, userId: membre, role: role.rawValue) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// Révoque un membre, et fait tourner l'Org Key dans le même mouvement. + /// + /// Les deux sont indissociables : retirer quelqu'un sans changer la clé le laisserait + /// capable de lire tout ce qui s'écrira ensuite, puisqu'il en garde une copie. La + /// rotation ne re-protège pas pour autant ce qu'il a déjà vu — ces mots de passe-là + /// doivent être changés, et l'interface le dit. + /// + /// Tout est calculé ici et part en une seule requête, que le serveur applique dans une + /// transaction : une rotation à moitié appliquée laisserait un coffre dont une part + /// serait illisible pour tout le monde, y compris pour l'administrateur. + func revoquerEtFaireTourner( + _ ouvert: CoffrePartageOuvert, revoquer membre: String?, restants: [MembreDEquipe] + ) async -> Bool { + guard let api, let token, let account else { return false } + isBusy = true + defer { isBusy = false } + do { + let anciens = try await api.allOrgItems(token: token, org: ouvert.organisation.id) + + // La nouvelle Org Key naît d'une création d'organisation jetable : c'est le seul + // chemin qu'expose le binding pour en produire une, et il fait exactement cela. + let neuve = try account.createOrg().org() + + var reenveloppes: [RotationBody.ItemReenveloppe] = [] + for dto in anciens { + let enveloppe = [ + "encrypted_key": dto.encryptedKey, "encrypted_data": dto.encryptedData, + ] + let json = String(data: try JSONEncoder().encode(enveloppe), encoding: .utf8) ?? "{}" + let refait = try neuve.rewrapItem(oldOrg: ouvert.org, encryptedItemJson: json) + let relu = try JSONDecoder().decode( + EnveloppeChiffree.self, from: Data(refait.utf8)) + reenveloppes.append( + .init(id: dto.id, encryptedKey: relu.encryptedKey)) + } + + // Le serveur ne rend pas la clé publique des membres : il faut la résoudre par + // leur adresse. Un échec ici **annule la rotation** au lieu de sauter la + // personne — la sauter l'exclurait en silence, puisqu'elle ne recevrait pas la + // nouvelle clé et perdrait l'accès sans que personne ne l'ait décidé. + var scelles: [RotationBody.MembreScelle] = [] + for membre in restants { + guard let email = membre.email else { + throw RotationImpossible.cleIntrouvable(membre: membre.id) + } + let cle: String + do { + cle = try await api.lookupPublicKey(token: token, email: email) + } catch { + throw RotationImpossible.cleIntrouvable(membre: email) + } + scelles.append( + .init( + userId: membre.id, + encryptedOrgKey: try account.sealOrgKeyForMember( + org: neuve, memberPublicKey: cle))) + } + + try await api.rotateOrgKey( + token: token, org: ouvert.organisation.id, + corps: RotationBody( + revokeUserId: membre, members: scelles, items: reenveloppes)) + return true + } catch let refus as RotationImpossible { + errorMessage = refus.message + return false + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func creerUneCollection(_ ouvert: CoffrePartageOuvert, nom: String) async -> Bool { + guard let api, let token else { return false } + do { + try await api.createOrgCollection( + token: token, org: ouvert.organisation.id, name: nom) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + // ─── Groupes ─── + + func groupes(_ organisation: Organisation) async -> [OrgGroupDTO] { + guard let api, let token else { return [] } + do { + return try await api.orgGroups(token: token, org: organisation.id) + } catch { + errorMessage = error.localizedDescription + return [] + } + } + + func creerUnGroupe(_ organisation: Organisation, nom: String) async -> Bool { + await agirSurLEquipe(organisation) { api, token in + try await api.createOrgGroup(token: token, org: organisation.id, name: nom) + } + } + + func supprimerLeGroupe(_ organisation: Organisation, groupe: String) async -> Bool { + await agirSurLEquipe(organisation) { api, token in + try await api.deleteOrgGroup(token: token, org: organisation.id, group: groupe) + } + } + + func ajouterAuGroupe( + _ organisation: Organisation, groupe: String, membre: String + ) async -> Bool { + await agirSurLEquipe(organisation) { api, token in + try await api.addToOrgGroup( + token: token, org: organisation.id, group: groupe, userId: membre) + } + } + + func retirerDuGroupe( + _ organisation: Organisation, groupe: String, membre: String + ) async -> Bool { + await agirSurLEquipe(organisation) { api, token in + try await api.removeFromOrgGroup( + token: token, org: organisation.id, group: groupe, userId: membre) + } + } + + func donnerAcces( + _ organisation: Organisation, groupe: String, collection: String, droit: DroitSurCollection + ) async -> Bool { + await agirSurLEquipe(organisation) { api, token in + try await api.setGroupCollectionAccess( + token: token, org: organisation.id, group: groupe, collection: collection, + permission: droit.rawValue) + } + } + + func retirerLAcces( + _ organisation: Organisation, groupe: String, collection: String + ) async -> Bool { + await agirSurLEquipe(organisation) { api, token in + try await api.revokeGroupCollectionAccess( + token: token, org: organisation.id, group: groupe, collection: collection) + } + } + + /// Les gestes de groupe ne diffèrent que par l'appel : même garde, même report d'erreur. + /// Les écrire un par un aurait multiplié la même dizaine de lignes par sept. + private func agirSurLEquipe( + _ organisation: Organisation, _ geste: (APIClient, String) async throws -> Void + ) async -> Bool { + guard let api, let token else { return false } + do { + try await geste(api, token) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + +} + +/// Coffre d'un donneur, ouvert le temps d'une consultation. `coffre` reste un objet opaque du +/// cœur : il détient l'USK, elle ne traverse jamais la frontière. +struct CoffreDUrgenceOuvert { + let lien: String + let role: RoleDUrgence + let donneur: String + let kdfParams: String + let entrees: [VaultEntry] + let coffre: EmergencyVault +} + +/// Ce que le cœur renvoie pour une reprise, en snake_case comme le reste du binding. +private struct RepriseDUrgence: Decodable { + let masterPasswordHash: String + let encryptedUserKey: String + + enum CodingKeys: String, CodingKey { + case masterPasswordHash = "master_password_hash" + case encryptedUserKey = "encrypted_user_key" + } +} + +/// L'enveloppe que rend le cœur : les deux moitiés d'un item chiffré, telles que le serveur +/// les stocke. En snake_case, comme tout ce qui traverse la frontière FFI. +private struct EnveloppeChiffree: Decodable { + let encryptedKey: String + let encryptedData: String + + enum CodingKeys: String, CodingKey { + case encryptedKey = "encrypted_key" + case encryptedData = "encrypted_data" + } +} diff --git a/apps/ios/Ghostpass/Theme/Theme.swift b/apps/ios/Ghostpass/Theme/Theme.swift new file mode 100644 index 00000000..62ed1533 --- /dev/null +++ b/apps/ios/Ghostpass/Theme/Theme.swift @@ -0,0 +1,387 @@ +import SwiftUI + +/// Le système visuel néon de la suite, partagé avec `ghostbit`, `ghostmon`, `ghostboard`, +/// `ghostauth` et `ghostcal`. +/// +/// La structure est commune à tous les produits : une base sombre dérivée de Dracula, des +/// surfaces de verre fumé, et un halo coloré diffusé depuis le haut. Ce qui les distingue +/// est leur teinte néon, celle de leur logo — violet pour ghostbit, lime pour ghostmon, +/// orange pour ghostboard, menthe pour ghostauth, cyan pour ghostcal, jaune pour +/// ghostmail. Pour GhostPass, c'est le **bleu `#2E7DFF`**, seul créneau froid encore libre +/// et déjà celui de la marque. +/// +/// Le néon n'est pas dans la couleur mais dans son rayonnement : deux halos superposés, +/// l'un serré et vif, l'autre large et discret. Voir `neon()`. +/// +/// Les deux palettes existent, comme sur le web : le sombre est la teinte d'origine, le +/// clair une déclinaison de plein jour. On suit le réglage du système plutôt que d'imposer +/// l'un des deux. +extension Color { + // ─── Base sombre commune à la suite (Dracula) ─── + + /// Fond de page. + static let gpBase = adaptative(sombre: 0x21222C, clair: 0xF5F8FC) + /// Cartes et lignes. + static let gpSurface = adaptative(sombre: 0x282A36, clair: 0xFFFFFF) + /// Champs et surfaces imbriquées. + static let gpSurface2 = adaptative(sombre: 0x323445, clair: 0xEEF3F9) + /// Texte principal. + static let gpInk = adaptative(sombre: 0xF8F8F2, clair: 0x0F1B2D) + /// Texte secondaire. + static let gpMuted = adaptative(sombre: 0x8B9CC8, clair: 0x5B6B82) + + // ─── Teinte de marque : bleu néon ─── + + /// Accent plein : fonds de boutons, pastilles. Assez sombre pour porter du blanc. + static let gpAccent = adaptative(sombre: 0x2E7DFF, clair: 0x1A4FCC) + /// Accent en texte ou en icône. Sur fond de nuit, le bleu plein manque de clarté : + /// c'est la teinte haute de la marque qui prend le relais. + static let gpAccentText = adaptative(sombre: 0x7FB2FF, clair: 0x1A4FCC) + /// Ce qui se pose sur `gpAccent`. + static let gpOnAccent = Color.white + /// La teinte du halo. Fixe : un néon garde sa couleur, c'est ce qui le fait lire comme + /// une source lumineuse plutôt que comme une ombre portée teintée. + static let gpNeon = Color(rgb: 0x2E7DFF) + + static let gpBorder = adaptativeAlpha(sombre: (0xFFFFFF, 0.08), clair: (0x0F172A, 0.10)) + static let gpBorderStrong = adaptativeAlpha(sombre: (0xFFFFFF, 0.16), clair: (0x0F172A, 0.16)) + + /// Sémantiques, reprises telles quelles de la base Dracula partagée. + static let gpDanger = adaptative(sombre: 0xFF5555, clair: 0xD11F45) + static let gpSuccess = adaptative(sombre: 0x50FA7B, clair: 0x15803D) + + /// Une teinte fixe, en hexadécimal. Sert aux couleurs qui ne dépendent pas du thème : + /// les pastilles d'initiale, dont la palette est partagée avec la web app. + init(rgb: Int) { + self.init(UIColor(rgb: rgb, alpha: 1)) + } + + private static func adaptative(sombre: Int, clair: Int) -> Color { + Color( + UIColor { traits in + UIColor(rgb: traits.userInterfaceStyle == .dark ? sombre : clair, alpha: 1) + }) + } + + private static func adaptativeAlpha( + sombre: (Int, CGFloat), clair: (Int, CGFloat) + ) -> Color { + Color( + UIColor { traits in + let (rgb, alpha) = traits.userInterfaceStyle == .dark ? sombre : clair + return UIColor(rgb: rgb, alpha: alpha) + }) + } +} + +extension UIColor { + fileprivate convenience init(rgb: Int, alpha: CGFloat) { + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: alpha) + } +} + +/// Mesures partagées. Les mêmes que sur le web : contrôles à 8, cartes à 16. +enum GP { + static let radius: CGFloat = 12 + static let radiusCard: CGFloat = 16 + static let gap: CGFloat = 12 + static let padding: CGFloat = 16 + static let paddingCard: CGFloat = 24 +} + +/// Le halo néon de la suite : deux rayonnements superposés, l'un serré et vif, l'autre +/// large et diffus. +/// +/// C'est la transposition exacte du `--brand-glow` partagé — `drop-shadow(0 0 8px …0.6)` +/// puis `drop-shadow(0 0 20px …0.35)`. Les deux comptent : un seul halo fait une tache +/// molle, tandis que la superposition d'un noyau net et d'une aura étalée est ce qui donne +/// l'impression d'une source de lumière. Le rayon est réduit en thème clair, où un néon +/// sur fond blanc devient une bavure. +struct Neon: ViewModifier { + /// Multiplicateur d'intensité : 1 pour une marque, moins pour un détail. + var force: Double = 1 + + @Environment(\.colorScheme) private var schema + + func body(content: Content) -> some View { + let echelle = schema == .dark ? force : force * 0.45 + return + content + .shadow(color: Color.gpNeon.opacity(0.60 * echelle), radius: 8 * echelle) + .shadow(color: Color.gpNeon.opacity(0.35 * echelle), radius: 20 * echelle) + } +} + +extension View { + /// Fait rayonner un élément dans la teinte de la marque. + func neon(_ force: Double = 1) -> some View { modifier(Neon(force: force)) } +} + +/// Le fond de l'application : nuit profonde et halo néon diffusé depuis le haut — la lueur +/// du fantôme, qui donne sa profondeur à l'ensemble sans rien coûter en lisibilité. +/// +/// Le dégradé du fond est celui de la suite : quatre arrêts à 150°, teintés de la couleur +/// de marque, qui empêchent le noir d'être plat. +struct GhostBackground: View { + @Environment(\.colorScheme) private var schema + + var body: some View { + fond + .overlay(alignment: .top) { + RadialGradient( + colors: [Color.gpNeon.opacity(schema == .dark ? 0.26 : 0.12), .clear], + center: .top, startRadius: 0, endRadius: 520 + ) + .frame(height: 620) + .offset(y: -140) + .blur(radius: 40) + } + .ignoresSafeArea() + } + + @ViewBuilder private var fond: some View { + if schema == .dark { + LinearGradient( + colors: [ + Color(rgb: 0x080D18), Color(rgb: 0x0A1220), + Color(rgb: 0x080F1A), Color(rgb: 0x090A10), + ], + startPoint: .top, endPoint: .bottom) + } else { + Color.gpBase + } + } +} + +/// Carte de verre fumé : la surface translucide, bordée d'un filet clair. +struct GlassCard: ViewModifier { + var padding: CGFloat = GP.paddingCard + + func body(content: Content) -> some View { + content + .padding(padding) + .background(Color.gpSurface.opacity(0.65), in: rect) + .overlay(rect.strokeBorder(Color.gpBorder, lineWidth: 1)) + .background(.ultraThinMaterial, in: rect) + } + + private var rect: RoundedRectangle { RoundedRectangle(cornerRadius: GP.radiusCard) } +} + +extension View { + func glassCard(padding: CGFloat = GP.paddingCard) -> some View { + modifier(GlassCard(padding: padding)) + } +} + +/// Action principale : un bloc d'accent plein, pleine largeur. +struct PrimaryButtonStyle: ButtonStyle { + var enabled = true + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(.body, weight: .semibold)) + .foregroundStyle(Color.gpOnAccent) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + Color.gpAccent.opacity(enabled ? (configuration.isPressed ? 0.8 : 1) : 0.35), + in: RoundedRectangle(cornerRadius: GP.radius) + ) + // Seule l'action disponible rayonne. Faire luire un bouton inerte reviendrait + // à appeler l'œil vers ce sur quoi on ne peut pas appuyer. + .neon(enabled ? (configuration.isPressed ? 0.5 : 0.85) : 0) + .contentShape(RoundedRectangle(cornerRadius: GP.radius)) + } +} + +/// Action secondaire : le même bloc, mais creusé plutôt que plein. +struct SecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(Color.gpAccentText) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + Color.gpSurface2.opacity(configuration.isPressed ? 0.6 : 1), + in: RoundedRectangle(cornerRadius: GP.radius) + ) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder(Color.gpBorder, lineWidth: 1)) + } +} + +/// Champ de saisie : surface creusée, bordure discrète, texte au repos lisible. +struct GhostFieldStyle: ViewModifier { + func body(content: Content) -> some View { + content + .foregroundStyle(Color.gpInk) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color.gpSurface2, in: RoundedRectangle(cornerRadius: GP.radius)) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder(Color.gpBorder, lineWidth: 1)) + } +} + +extension View { + func ghostField() -> some View { modifier(GhostFieldStyle()) } + + /// Intitulé de section : petites capitales espacées, comme sur le web. + func sectionLabel() -> some View { + font(.caption.weight(.semibold)) + .textCase(.uppercase) + .kerning(0.6) + .foregroundStyle(Color.gpMuted) + } +} + +// ─── Structures d'écran ─── + +/// Un écran de la suite : le fond de nuit, et un contenu qui défile par-dessus. +struct GhostScreen: View { + var espacement: CGFloat = 22 + @ViewBuilder var contenu: () -> Contenu + + var body: some View { + ZStack { + GhostBackground() + ScrollView { + VStack(alignment: .leading, spacing: espacement) { + contenu() + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 40) + .frame(maxWidth: 560, alignment: .leading) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + } + } +} + +/// Une section : un intitulé en petites capitales, puis une carte qui réunit ses lignes. +struct GhostSection: View { + // Clefs de traduction, pas des chaînes : ce qui s'écrit ici s'affiche, et doit donc + // passer par le catalogue. Une `String` s'afficherait telle quelle, en français. + var titre: LocalizedStringKey? + var note: LocalizedStringKey? + @ViewBuilder var contenu: () -> Contenu + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let titre { Text(titre).sectionLabel() } + VStack(spacing: 0) { contenu() } + .background( + Color.gpSurface.opacity(0.7), + in: RoundedRectangle(cornerRadius: GP.radiusCard) + ) + .overlay( + RoundedRectangle(cornerRadius: GP.radiusCard) + .strokeBorder(Color.gpBorder, lineWidth: 1)) + if let note { + Text(note) + .font(.caption) + .foregroundStyle(Color.gpMuted) + .padding(.horizontal, 2) + } + } + } +} + +/// Un filet de séparation entre deux lignes d'une même carte. +struct GhostDivider: View { + var body: some View { + Rectangle() + .fill(Color.gpBorder) + .frame(height: 1) + .padding(.leading, 14) + } +} + +/// Une ligne de consultation : un intitulé, une valeur, et ce qu'on peut en faire. +struct GhostRow: View { + let intitule: LocalizedStringKey + /// Le contenu du coffre, jamais traduit : un mot de passe reste ce qu'il est. + let valeur: String + var monospace = false + var estSecret = false + @ViewBuilder var actions: () -> Actions + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(intitule) + .font(.caption) + .foregroundStyle(Color.gpMuted) + // `.enabled` et `.disabled` sont deux types distincts : un ternaire ne + // les unifie pas. Le modificateur ne porte donc que sur ce qui se copie. + Group { + if estSecret { + Text(verbatim: valeur) + } else { + Text(verbatim: valeur).textSelection(.enabled) + } + } + .font(monospace ? .system(.body, design: .monospaced) : .body) + .foregroundStyle(Color.gpInk) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + HStack(spacing: 14) { actions() } + .foregroundStyle(Color.gpAccentText) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + } +} + +/// Bouton d'action discret d'une ligne : une icône, une zone de frappe correcte. +struct GhostIconButton: View { + let systemImage: String + var action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 16, weight: .medium)) + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(Color.gpAccentText) + } +} + +/// Ce que voit le sélecteur d'applications à la place du coffre : l'enseigne, rien d'autre. +/// +/// iOS photographie l'écran quand l'application le quitte et garde cette vignette sur +/// disque. Un coffre déverrouillé s'y retrouverait en clair — noms de sites, identifiants — +/// visible d'un simple glissement, et lisible par qui inspecte le système de fichiers. +struct VoileDeConfidentialite: View { + var body: some View { + ZStack { + GhostBackground() + VStack(spacing: 12) { + Image("LogoMark") + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + Text("GhostPass") + .font(.system(.headline, design: .rounded, weight: .semibold)) + .foregroundStyle(Color.gpMuted) + } + } + .ignoresSafeArea() + .transition(.opacity) + .accessibilityIdentifier("view.privacyVeil") + } +} diff --git a/apps/ios/Ghostpass/Views/BiometricOfferView.swift b/apps/ios/Ghostpass/Views/BiometricOfferView.swift new file mode 100644 index 00000000..d1e4d501 --- /dev/null +++ b/apps/ios/Ghostpass/Views/BiometricOfferView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// La proposition d'activer la biométrie, juste après un premier déverrouillage. +/// +/// C'était une alerte. Une alerte SwiftUI posée sur cet écran s'affichait bien mais ne +/// répondait plus : ses boutons n'exécutaient rien, et elle restait là, à bloquer le +/// coffre — le même travers que les deux `.sheet` concurrents corrigés plus tôt. Une +/// feuille fait le travail, et laisse la place d'expliquer ce qu'on met dans le trousseau +/// plutôt que de l'énoncer en trois lignes serrées. +struct BiometricOfferView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ZStack { + GhostBackground() + VStack(spacing: 22) { + Spacer(minLength: 0) + + Image(systemName: store.biometryIcon) + .font(.system(size: 44, weight: .light)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 92, height: 92) + .background(Color.gpAccent.opacity(0.14), in: Circle()) + + VStack(spacing: 10) { + Text("Utiliser \(store.biometryLabel) ?") + .font(.system(.title2, design: .rounded, weight: .semibold)) + .foregroundStyle(Color.gpInk) + Text( + "Votre mot de passe maître sera conservé dans le trousseau de cet appareil, relisible par \(store.biometryLabel) seul." + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 8) + + Spacer(minLength: 0) + + VStack(spacing: 10) { + Button("Activer") { + store.acceptOfferedBiometrics() + dismiss() + } + .buttonStyle(PrimaryButtonStyle(enabled: true)) + .accessibilityIdentifier("button.acceptBiometric") + + Button("Plus tard") { + store.declineBiometrics() + dismiss() + } + .buttonStyle(SecondaryButtonStyle()) + .accessibilityIdentifier("button.laterBiometric") + } + } + .padding(24) + .frame(maxWidth: 520) + } + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium]) + .tint(Color.gpAccentText) + .onDisappear { + // Refermer d'un glissement, c'est écarter la proposition : sans cela, elle + // resterait « en attente » et reviendrait au premier changement d'écran. + if store.offersBiometricEnrollment { store.declineBiometrics() } + } + } +} diff --git a/apps/ios/Ghostpass/Views/BiometricSetupView.swift b/apps/ios/Ghostpass/Views/BiometricSetupView.swift new file mode 100644 index 00000000..f46d8245 --- /dev/null +++ b/apps/ios/Ghostpass/Views/BiometricSetupView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +/// Activation du déverrouillage biométrique, depuis les réglages du coffre. +/// +/// Une feuille et non une alerte : présentée depuis un menu qui se referme, une alerte se +/// perd un cycle sur deux et son champ n'apparaît jamais. Une feuille se présente toujours. +struct BiometricSetupView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + @State private var password = "" + + var body: some View { + NavigationStack { + GhostScreen { + VStack(spacing: 14) { + Image(systemName: "faceid") + .font(.system(size: 40, weight: .light)) + .foregroundStyle(Color.gpAccentText) + Text("Déverrouiller avec \(store.biometryLabel)") + .font(.system(.title3, weight: .semibold)) + .foregroundStyle(Color.gpInk) + .multilineTextAlignment(.center) + Text( + "Votre mot de passe maître sera conservé dans le trousseau de cet " + + "appareil, relisible par \(store.biometryLabel) seul. Ajouter un " + + "visage ou une empreinte annule cet accès." + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .padding(.top, 12) + + GhostSection(titre: "Mot de passe maître") { + SecureField("", text: $password, prompt: invite("Pour confirmer que c'est bien vous")) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.masterConfirm") + } + + if let message = store.errorMessage { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + + Button("Activer") { + // On ne referme que si le mot de passe a réellement ouvert le + // coffre : sinon l'erreur reste sous les yeux, ici même. + if store.enableBiometrics(password: password) { dismiss() } + password = "" + } + .buttonStyle(PrimaryButtonStyle(enabled: !password.isEmpty)) + .disabled(password.isEmpty) + .accessibilityIdentifier("button.confirmBiometric") + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { + store.errorMessage = nil + dismiss() + } + .foregroundStyle(Color.gpMuted) + } + } + } + .tint(Color.gpAccentText) + } + + private func invite(_ texte: LocalizedStringKey) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) + } +} diff --git a/apps/ios/Ghostpass/Views/EmergencyInviteView.swift b/apps/ios/Ghostpass/Views/EmergencyInviteView.swift new file mode 100644 index 00000000..883fecc4 --- /dev/null +++ b/apps/ios/Ghostpass/Views/EmergencyInviteView.swift @@ -0,0 +1,126 @@ +import SwiftUI + +/// Désigner un contact de confiance. +/// +/// Deux réglages seulement, mais tous deux irréversibles dans leurs effets : le rôle, et le +/// délai pendant lequel on pourra encore dire non. L'écran les explique plutôt que de les +/// nommer — « reprise du compte » ne dit pas à qui appartiendra le coffre après coup. +struct EmergencyInviteView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + let apres: () async -> Void + + @State private var email = "" + @State private var role: RoleDUrgence = .view + @State private var delai = 7 + + private var emailValide: Bool { + let parts = email.split(separator: "@") + return parts.count == 2 && parts.allSatisfy { !$0.isEmpty } && parts[1].contains(".") + } + + var body: some View { + NavigationStack { + GhostScreen { + VStack(alignment: .leading, spacing: 16) { + GhostSection( + titre: "Le contact", + note: "Il doit déjà posséder un compte GhostPass : c'est sa clé publique qui protège la vôtre." + ) { + TextField("adresse@exemple.com", text: $email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.emergencyEmail") + } + + GhostSection(titre: "Ce qu'il pourra faire") { + VStack(alignment: .leading, spacing: 12) { + ForEach(RoleDUrgence.allCases) { candidat in + Button { + role = candidat + } label: { + HStack(alignment: .top, spacing: 12) { + Image( + systemName: role == candidat + ? "largecircle.fill.circle" : "circle" + ) + .foregroundStyle( + role == candidat ? Color.gpAccentText : Color.gpMuted) + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: candidat.intitule) + .foregroundStyle(Color.gpInk) + Text(verbatim: candidat.explication) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .accessibilityIdentifier("role.\(candidat.rawValue)") + } + } + .padding(14) + } + + GhostSection( + titre: "Le délai avant ouverture", + note: "Quand le contact demandera l'accès, vous serez prévenu et pourrez refuser pendant ce temps. Passé ce délai sans réponse de votre part, l'accès s'ouvre — c'est précisément ce qui le rend utile le jour où vous ne pouvez plus répondre." + ) { + Stepper( + value: $delai, in: 1...90, + label: { + Text( + verbatim: String( + format: tr("%d jours"), delai) + ) + .foregroundStyle(Color.gpInk) + } + ) + .padding(14) + .accessibilityIdentifier("stepper.emergencyDelay") + } + + Button("Confier l'accès") { + Task { + let ok = await store.confierLAcces( + a: email.trimmingCharacters(in: .whitespaces), role: role, + delai: delai) + if ok { + await apres() + dismiss() + } + } + } + .buttonStyle(PrimaryButtonStyle(enabled: emailValide && !store.isBusy)) + .disabled(!emailValide || store.isBusy) + .accessibilityIdentifier("button.confirmInvite") + + if let message = store.errorMessage { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .scrollDismissesKeyboard(.immediately) + .navigationTitle("Confier l'accès") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { dismiss() } + .foregroundStyle(Color.gpMuted) + } + } + } + .tint(Color.gpAccentText) + } +} diff --git a/apps/ios/Ghostpass/Views/EmergencyTakeoverView.swift b/apps/ios/Ghostpass/Views/EmergencyTakeoverView.swift new file mode 100644 index 00000000..15b860ca --- /dev/null +++ b/apps/ios/Ghostpass/Views/EmergencyTakeoverView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// La reprise d'un compte : imposer un nouveau mot de passe maître au donneur. +/// +/// C'est l'action la plus lourde de l'application — après elle, le propriétaire d'origine ne +/// peut plus ouvrir son propre coffre. L'écran ne cherche donc pas à la rendre fluide : il +/// dit ce qu'elle fait, redemande le mot de passe, et n'active le bouton qu'ensuite. +struct EmergencyTakeoverView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + let ouvert: CoffreDUrgenceOuvert + let apres: () async -> Void + + @State private var motDePasse = "" + @State private var confirmation = "" + + /// Même exigence qu'à l'inscription : ce mot de passe protégera un coffre entier. + private var assezSolide: Bool { motDePasse.count >= 12 } + private var valide: Bool { assezSolide && motDePasse == confirmation } + + var body: some View { + NavigationStack { + GhostScreen { + VStack(alignment: .leading, spacing: 16) { + avertissement + + GhostSection( + titre: "Nouveau mot de passe maître", + note: "Douze caractères au minimum. Il remplacera celui du propriétaire." + ) { + VStack(spacing: 0) { + SecureField("Nouveau mot de passe", text: $motDePasse) + .padding(14) + .accessibilityIdentifier("field.takeoverPassword") + Divider().overlay(Color.gpBorder) + SecureField("Confirmer", text: $confirmation) + .padding(14) + .accessibilityIdentifier("field.takeoverConfirm") + } + .foregroundStyle(Color.gpInk) + } + + if !confirmation.isEmpty && motDePasse != confirmation { + Text("Les deux saisies diffèrent.") + .font(.footnote) + .foregroundStyle(Color.gpDanger) + } + + Button("Reprendre le compte") { + Task { + let ok = await store.reprendreLeCompte( + ouvert, nouveauMotDePasse: motDePasse) + if ok { + await apres() + dismiss() + } + } + } + .buttonStyle(PrimaryButtonStyle(enabled: valide && !store.isBusy)) + .disabled(!valide || store.isBusy) + .accessibilityIdentifier("button.confirmTakeover") + + if let message = store.errorMessage { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .scrollDismissesKeyboard(.immediately) + .navigationTitle("Reprise du compte") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { dismiss() } + .foregroundStyle(Color.gpMuted) + } + } + } + .tint(Color.gpAccentText) + } + + private var avertissement: some View { + VStack(alignment: .leading, spacing: 10) { + Label { + Text( + verbatim: String( + format: tr("%@ ne pourra plus ouvrir son coffre."), ouvert.donneur)) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + + Text( + "Les données ne sont pas perdues : elles restent chiffrées par la même clé, seule la façon de l'ouvrir change. Mais elle ne s'ouvrira plus qu'avec le mot de passe que vous choisissez ici." + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .glassCard() + } +} diff --git a/apps/ios/Ghostpass/Views/EmergencyVaultView.swift b/apps/ios/Ghostpass/Views/EmergencyVaultView.swift new file mode 100644 index 00000000..3748577c --- /dev/null +++ b/apps/ios/Ghostpass/Views/EmergencyVaultView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +/// Le coffre d'un donneur, ouvert par son contact de confiance. +/// +/// En lecture seule, toujours : on regarde les identifiants de quelqu'un d'autre, on ne les +/// modifie pas. Rien n'est mis en cache — à la fermeture de cet écran, le coffre déchiffré +/// disparaît, et il faudra repasser par le serveur pour le rouvrir. +struct EmergencyVaultView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + let ouvert: CoffreDUrgenceOuvert + let apres: () async -> Void + + @State private var recherche = "" + @State private var repriseDemandee = false + + private var visibles: [VaultEntry] { + let q = recherche.trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return ouvert.entrees } + return ouvert.entrees.filter { + $0.item.name.localizedCaseInsensitiveContains(q) + || ($0.login?.username ?? "").localizedCaseInsensitiveContains(q) + } + } + + var body: some View { + NavigationStack { + GhostScreen { + VStack(alignment: .leading, spacing: 16) { + bandeau + + if ouvert.entrees.isEmpty { + Text("Ce coffre est vide.") + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .glassCard() + } else { + GhostSection(titre: "Identifiants") { + ForEach(visibles) { entree in + NavigationLink { + ItemDetailView(entry: entree, lectureSeule: true) + .environmentObject(store) + } label: { + GhostRow( + intitule: LocalizedStringKey(entree.item.name), + valeur: entree.login?.username ?? "") {} + } + if entree.id != visibles.last?.id { + Divider().overlay(Color.gpBorder) + } + } + } + } + + if ouvert.role == .takeover { + reprise + } + } + } + .searchable(text: $recherche, prompt: Text("Rechercher")) + .navigationTitle(Text(verbatim: ouvert.donneur)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fermer") { dismiss() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.closeGrantorVault") + } + } + } + .tint(Color.gpAccentText) + .sheet(isPresented: $repriseDemandee) { + EmergencyTakeoverView(ouvert: ouvert) { + await apres() + dismiss() + } + .environmentObject(store) + } + } + + private var bandeau: some View { + Label { + Text( + "Vous consultez le coffre de quelqu'un d'autre. Cet accès est enregistré dans son journal." + ) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "eye") + } + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .padding(14) + .glassCard() + } + + private var reprise: some View { + GhostSection( + titre: "Reprise du compte", + note: "Choisir un nouveau mot de passe maître rendra le coffre inaccessible à son propriétaire actuel. À ne faire que s'il ne peut plus s'en servir lui-même." + ) { + Button("Reprendre le compte") { repriseDemandee = true } + .buttonStyle(SecondaryButtonStyle()) + .padding(14) + .accessibilityIdentifier("button.takeover") + } + } +} diff --git a/apps/ios/Ghostpass/Views/EmergencyView.swift b/apps/ios/Ghostpass/Views/EmergencyView.swift new file mode 100644 index 00000000..a99de517 --- /dev/null +++ b/apps/ios/Ghostpass/Views/EmergencyView.swift @@ -0,0 +1,253 @@ +import SwiftUI + +/// L'accès d'urgence : confier à quelqu'un la possibilité d'ouvrir son coffre, un jour où +/// l'on ne pourra plus le faire soi-même. +/// +/// Tout le sel est dans le délai. Le contact demande l'accès, le donneur est prévenu et peut +/// refuser pendant N jours ; passé ce délai sans refus, l'accès s'ouvre. C'est ce qui permet +/// de survivre à un décès sans donner les clés à un vivant — et c'est le serveur qui compte +/// les jours, jamais l'application, qui ne fait que les afficher. +struct EmergencyView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + @State private var donnes: [LienDUrgence] = [] + @State private var recus: [LienDUrgence] = [] + @State private var chargement = true + @State private var invitation = false + @State private var ouvert: CoffreDUrgenceOuvert? + @State private var aRevoquer: LienDUrgence? + + var body: some View { + NavigationStack { + GhostScreen { + if chargement { + ProgressView().tint(Color.gpAccentText) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + VStack(alignment: .leading, spacing: 16) { + mesContacts + ceuxQuiMOntChoisi + } + } + } + .navigationTitle("Accès d'urgence") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Terminé") { dismiss() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.closeEmergency") + } + ToolbarItem(placement: .primaryAction) { + Button { + invitation = true + } label: { + Image(systemName: "person.badge.plus") + } + .accessibilityIdentifier("button.inviteEmergency") + } + } + } + .tint(Color.gpAccentText) + .task { await recharger() } + .sheet(isPresented: $invitation) { + EmergencyInviteView { await recharger() } + .environmentObject(store) + } + .sheet(item: $ouvert) { coffre in + EmergencyVaultView(ouvert: coffre) { await recharger() } + .environmentObject(store) + } + .alert( + "Retirer cet accès ?", isPresented: Binding( + get: { aRevoquer != nil }, set: { if !$0 { aRevoquer = nil } }) + ) { + Button("Annuler", role: .cancel) { aRevoquer = nil } + Button("Retirer", role: .destructive) { + if let lien = aRevoquer { + Task { + _ = await store.revoquerLUrgence(lien.id) + aRevoquer = nil + await recharger() + } + } + } + } message: { + Text( + "Le contact ne pourra plus ouvrir votre coffre. La clé qui lui avait été scellée devient inutilisable." + ) + } + } + + // ─── Ce que j'ai confié ─── + + private var mesContacts: some View { + GhostSection( + titre: "Mes contacts de confiance", + note: "Ils pourront ouvrir votre coffre si vous ne le pouvez plus. Vous gardez la main tant que vous répondez." + ) { + if donnes.isEmpty { + Text("Personne pour l'instant.") + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } else { + ForEach(donnes) { lien in + ligneDonnee(lien) + if lien.id != donnes.last?.id { Divider().overlay(Color.gpBorder) } + } + } + } + } + + private func ligneDonnee(_ lien: LienDUrgence) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: lien.contactEmail) + .foregroundStyle(Color.gpInk) + Text(verbatim: "\(lien.role.intitule) · \(lien.etat.intitule)") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + } + Spacer() + Button { + aRevoquer = lien + } label: { + Image(systemName: "trash") + .foregroundStyle(Color.gpDanger) + } + .accessibilityLabel("Retirer cet accès") + } + + // Une demande en cours est le seul moment où le donneur doit agir vite : c'est + // maintenant, ou l'accès s'ouvrira tout seul. + if lien.etat == .requested { + if let ouverture = lien.ouverturePrevue() { + Text( + verbatim: String( + format: tr("Accès automatique le %@ si vous ne faites rien."), + ouverture.formatted(date: .abbreviated, time: .shortened))) + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + HStack(spacing: 10) { + Button("Refuser") { + Task { + _ = await store.agirSurLUrgence(lien.id, action: "reject") + await recharger() + } + } + .buttonStyle(SecondaryButtonStyle()) + Button("Ouvrir maintenant") { + Task { + _ = await store.agirSurLUrgence(lien.id, action: "approve") + await recharger() + } + } + .buttonStyle(SecondaryButtonStyle()) + } + } + } + .padding(14) + .accessibilityElement(children: .combine) + } + + // ─── Ce qu'on m'a confié ─── + + private var ceuxQuiMOntChoisi: some View { + GhostSection( + titre: "Coffres qui me sont confiés", + note: "Ce que d'autres vous ont laissé, et ce que vous pouvez en faire." + ) { + if recus.isEmpty { + Text("Personne ne vous a désigné.") + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } else { + ForEach(recus) { lien in + ligneRecue(lien) + if lien.id != recus.last?.id { Divider().overlay(Color.gpBorder) } + } + } + } + } + + private func ligneRecue(_ lien: LienDUrgence) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(verbatim: lien.contactEmail) + .foregroundStyle(Color.gpInk) + Text(verbatim: "\(lien.role.intitule) · \(lien.etat.intitule)") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + + if lien.disponible { + Button("Ouvrir le coffre") { + Task { ouvert = await store.ouvrirLeCoffreDUrgence(lien.id) } + } + .buttonStyle(SecondaryButtonStyle()) + .accessibilityIdentifier("button.openGrantorVault") + } else { + switch lien.etat { + case .invited: + Button("Accepter") { + Task { + _ = await store.agirSurLUrgence(lien.id, action: "accept") + await recharger() + } + } + .buttonStyle(SecondaryButtonStyle()) + case .accepted: + Text( + verbatim: String( + format: tr( + "Demander l'accès préviendra %1$@, qui aura %2$d jours pour refuser." + ), lien.contactEmail, lien.waitDays) + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + Button("Demander l'accès") { + Task { + _ = await store.agirSurLUrgence(lien.id, action: "request") + await recharger() + } + } + .buttonStyle(SecondaryButtonStyle()) + case .requested: + if let ouverture = lien.ouverturePrevue() { + Text( + verbatim: String( + format: tr("Accès possible à partir du %@."), + ouverture.formatted(date: .abbreviated, time: .shortened)) + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + } + case .rejected: + Text("Le propriétaire a refusé.") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + case .granted: + EmptyView() + } + } + } + .padding(14) + .accessibilityElement(children: .combine) + } + + private func recharger() async { + let (d, r) = await store.lienDUrgence() + donnes = d + recus = r + chargement = false + } +} + +extension CoffreDUrgenceOuvert: Identifiable { + var id: String { lien } +} diff --git a/apps/ios/Ghostpass/Views/ExportView.swift b/apps/ios/Ghostpass/Views/ExportView.swift new file mode 100644 index 00000000..1aa6d12b --- /dev/null +++ b/apps/ios/Ghostpass/Views/ExportView.swift @@ -0,0 +1,149 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// Export du coffre en CSV. +/// +/// Le fichier produit contient tous les mots de passe en clair — c'est ce qu'est un export +/// CSV, et c'est ce qui le rend utile pour partir ailleurs. D'où deux garde-fous : le mot +/// de passe maître est redemandé, parce qu'un téléphone déverrouillé posé sur une table ne +/// doit pas suffire à vider le coffre ; et l'écran rappelle d'effacer le fichier ensuite. +struct ExportView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + @State private var motDePasse = "" + @State private var document: DocumentCsv? + @State private var enregistrement = false + @State private var fait = false + + var body: some View { + NavigationStack { + GhostScreen { + if fait { + resultat + } else { + formulaire + } + } + .navigationTitle("Exporter") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(fait ? "Terminé" : "Annuler") { + store.errorMessage = nil + dismiss() + } + .foregroundStyle(fait ? Color.gpAccentText : Color.gpMuted) + .accessibilityIdentifier("button.closeExport") + } + } + .fileExporter( + isPresented: $enregistrement, document: document, + contentType: .commaSeparatedText, + defaultFilename: CsvExport.nomDeFichier() + ) { resultat in + if case .success = resultat { fait = true } + // Un export annulé n'est pas un échec : on laisse l'écran en place. + document = nil + } + } + .tint(Color.gpAccentText) + } + + private var formulaire: some View { + VStack(alignment: .leading, spacing: 18) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 76, height: 76) + .background(Color.gpAccent.opacity(0.14), in: Circle()) + .frame(maxWidth: .infinity, alignment: .center) + + Text( + "Le fichier contiendra vos \(store.entries.count) entrées, mots de passe compris, en clair. Il se relit dans n'importe quel gestionnaire — et par n'importe qui." + ) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 7) { + Text("Mot de passe maître").sectionLabel() + SecureField("", text: $motDePasse, prompt: invite("Votre mot de passe")) + .ghostField() + .accessibilityIdentifier("field.exportMaster") + } + + if let message = store.errorMessage { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + + Button("Préparer le fichier") { preparer() } + .buttonStyle( + PrimaryButtonStyle(enabled: !motDePasse.isEmpty && !store.entries.isEmpty)) + .disabled(motDePasse.isEmpty || store.entries.isEmpty) + .accessibilityIdentifier("button.prepareExport") + } + .glassCard() + } + + private var resultat: some View { + VStack(alignment: .leading, spacing: 14) { + Label { + Text("Fichier enregistré.") + } icon: { + Image(systemName: "checkmark.circle.fill") + } + .font(.system(.headline, design: .rounded)) + .foregroundStyle(Color.gpInk) + .accessibilityIdentifier("text.exported") + + Text( + "Mettez-le à l'abri, puis effacez-le : tant qu'il existe, vos mots de passe sont lisibles par qui met la main dessus." + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + } + .glassCard() + } + + /// Vérifie le mot de passe maître avant de fabriquer le fichier. La vérification passe + /// par le cœur Rust — c'est une dérivation Argon2id, pas une comparaison de chaînes. + private func preparer() { + guard store.verifyMasterPassword(motDePasse) else { return } + motDePasse = "" + document = DocumentCsv(texte: CsvExport.texte(store.entries)) + enregistrement = true + } + + private func invite(_ texte: LocalizedStringKey) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) + } +} + +/// Le fichier tel que le voit `fileExporter`. Rien n'est écrit sur le disque tant que +/// l'utilisateur n'a pas choisi la destination. +struct DocumentCsv: FileDocument { + static var readableContentTypes: [UTType] { [.commaSeparatedText] } + + let texte: String + + init(texte: String) { self.texte = texte } + + init(configuration: ReadConfiguration) throws { + guard let donnees = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) + } + texte = String(decoding: donnees, as: UTF8.self) + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(texte.utf8)) + } +} diff --git a/apps/ios/Ghostpass/Views/FoldersView.swift b/apps/ios/Ghostpass/Views/FoldersView.swift new file mode 100644 index 00000000..2ee6501c --- /dev/null +++ b/apps/ios/Ghostpass/Views/FoldersView.swift @@ -0,0 +1,206 @@ +import SwiftUI + +/// Choix du dossier affiché, et gestion de ceux qui existent. +/// +/// Une feuille plutôt qu'une colonne latérale : sur un téléphone, l'arborescence de la web +/// app tiendrait mal, et le filtre ne sert que par intermittence. Les chemins hiérarchiques +/// (`Travail/Serveurs`) restent lisibles tels quels, sans arbre à déplier. +struct FoldersView: View { + @Binding var selection: String? + + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + @State private var creation = false + @State private var nouveau = "" + @State private var aSupprimer: String? + + var body: some View { + NavigationStack { + ZStack { + GhostBackground() + + List { + if creation { + champDeCreation + .listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + ligne( + titre: Text("Tous les éléments"), icone: "tray.full", + compte: store.entries.count, chemin: nil) + + if !store.folderPaths.isEmpty { + Section { + ForEach(store.folderPaths, id: \.self) { chemin in + ligne( + // Un nom de dossier appartient à l'utilisateur : il + // s'affiche tel quel, jamais traduit. + titre: Text(verbatim: chemin), icone: "folder", + compte: store.itemCount(in: chemin), chemin: chemin + ) + .swipeActions { + // Seuls les dossiers vides s'effacent du registre : les + // autres n'y figurent pas, ils naissent de leurs éléments. + if store.emptyFolders.contains(chemin) { + Button("Supprimer", role: .destructive) { + aSupprimer = chemin + } + } + } + } + } header: { + Text("Dossiers").sectionLabel().padding(.leading, 2) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .overlay { + if store.folderPaths.isEmpty { + ContentUnavailableView { + Label("Aucun dossier", systemImage: "folder") + } description: { + Text("Créez-en un, ou renseignez un dossier sur un élément.") + } + .foregroundStyle(Color.gpMuted) + } + } + } + .navigationTitle("Dossiers") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fermer") { dismiss() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.closeFolders") + } + ToolbarItem(placement: .confirmationAction) { + Button { + nouveau = "" + creation.toggle() + } label: { + Image(systemName: "folder.badge.plus") + } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.newFolder") + } + } + .alert( + "Supprimer ce dossier ?", isPresented: presentation($aSupprimer), + presenting: aSupprimer, + actions: { chemin in + Button("Supprimer", role: .destructive) { + Task { await store.removeFolder(chemin) } + if selection == chemin { selection = nil } + aSupprimer = nil + } + Button("Annuler", role: .cancel) { aSupprimer = nil } + }, + message: { chemin in + Text("« \(chemin) » disparaîtra de la liste. Aucun élément n'est supprimé.") + }) + } + .tint(Color.gpAccentText) + } + + /// Saisie en ligne plutôt qu'en alerte : un champ d'alerte SwiftUI n'expose pas son + /// identifiant d'accessibilité, et l'ensemble se présente mal depuis une feuille. + private var champDeCreation: some View { + HStack(spacing: 10) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 34, height: 34) + .background(Color.gpAccent.opacity(0.16), in: RoundedRectangle(cornerRadius: 9)) + + TextField( + "", text: $nouveau, + prompt: Text("Travail/Serveurs").foregroundColor(Color.gpMuted.opacity(0.7)) + ) + .foregroundStyle(Color.gpInk) + .autocorrectionDisabled() + .submitLabel(.done) + .onSubmit { creer() } + .accessibilityIdentifier("field.folderName") + + Button("Créer", action: creer) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(nouveau.isEmpty ? Color.gpMuted : Color.gpAccentText) + .disabled(nouveau.isEmpty) + .accessibilityIdentifier("button.createFolder") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.gpSurface.opacity(0.7), in: RoundedRectangle(cornerRadius: GP.radius)) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder(Color.gpAccent, lineWidth: 1.5)) + } + + private func creer() { + let chemin = nouveau + nouveau = "" + creation = false + guard !VaultStore.normaliser(chemin).isEmpty else { return } + Task { await store.createFolder(chemin) } + } + + private func ligne(titre: Text, icone: String, compte: Int, chemin: String?) -> some View { + Button { + selection = chemin + dismiss() + } label: { + HStack(spacing: 14) { + Image(systemName: icone) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 34, height: 34) + .background(Color.gpAccent.opacity(0.16), in: RoundedRectangle(cornerRadius: 9)) + + titre + .font(.system(.body, weight: .medium)) + .foregroundStyle(Color.gpInk) + .lineLimit(1) + + Spacer(minLength: 8) + + Text("\(compte)") + .font(.caption.monospacedDigit()) + .foregroundStyle(Color.gpMuted) + + if selection == chemin { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.gpAccentText) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(Color.gpSurface.opacity(0.7), in: RoundedRectangle(cornerRadius: GP.radius)) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder( + selection == chemin ? Color.gpAccent : Color.gpBorder, + lineWidth: selection == chemin ? 1.5 : 1)) + } + .buttonStyle(.plain) + .listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + /// Une liaison qui écrit en retour. `.constant` refuse la fermeture que le système + /// lui demande d'enregistrer : SwiftUI croit alors l'alerte encore présentée, et la + /// suivante ne s'affiche plus — le défaut qui a rendu la proposition biométrique + /// intapable, en plus discret ici puisqu'il n'y a qu'une alerte sur cet écran. + private func presentation(_ valeur: Binding) -> Binding { + Binding( + get: { valeur.wrappedValue != nil }, + set: { presente in + if !presente { valeur.wrappedValue = nil } + }) + } +} diff --git a/apps/ios/Ghostpass/Views/HealthView.swift b/apps/ios/Ghostpass/Views/HealthView.swift new file mode 100644 index 00000000..a06a4e3b --- /dev/null +++ b/apps/ios/Ghostpass/Views/HealthView.swift @@ -0,0 +1,222 @@ +import SwiftUI + +/// La santé du coffre : ce qui est faible, ce qui est réutilisé, ce qui a fuité. +/// +/// Les deux premières listes se calculent sur l'appareil, sans rien demander à personne. +/// La troisième interroge Have I Been Pwned, et c'est la seule chose de cette application +/// qui parle à un tiers : elle ne part donc qu'à la demande, sur un bouton, jamais toute +/// seule au chargement de l'écran. +struct HealthView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + var onOuvrir: (VaultEntry) -> Void + + @State private var verification = Verification.pasEncore + @State private var compromis: [VaultEntry] = [] + + private enum Verification { + case pasEncore + case enCours + case faite + case echouee + } + + private var bilan: PasswordHealth.Bilan { PasswordHealth.bilan(store.entries) } + + var body: some View { + NavigationStack { + GhostScreen { + let bilan = bilan + resume(bilan) + + if !bilan.faibles.isEmpty { + section( + "Mots de passe faibles", + note: "Trop courts, ou faits d'une seule sorte de caractères.", + bilan.faibles, "exclamationmark.shield") + } + if !bilan.reutilises.isEmpty { + section( + "Mots de passe réutilisés", + note: "Une seule fuite suffit alors à ouvrir plusieurs comptes.", + bilan.reutilises, "arrow.triangle.2.circlepath") + } + fuites + if !bilan.sansCode.isEmpty { + section( + "Sans code à usage unique", + note: "Le second facteur protège même un mot de passe connu.", + bilan.sansCode, "number") + } + } + .navigationTitle("Santé du coffre") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fermer") { dismiss() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.closeHealth") + } + } + } + .tint(Color.gpAccentText) + } + + /// Un état d'ensemble en tête : sans cela, un coffre sain n'afficherait qu'une page + /// vide, qu'on prendrait pour un écran qui n'a pas fini de charger. + private func resume(_ bilan: PasswordHealth.Bilan) -> some View { + let sain = bilan.estSain + return HStack(spacing: 14) { + Image(systemName: sain ? "checkmark.shield.fill" : "exclamationmark.shield.fill") + .font(.system(size: 26, weight: .medium)) + .foregroundStyle(sain ? Color.gpSuccess : Color.gpDanger) + .frame(width: 52, height: 52) + .background( + (sain ? Color.gpSuccess : Color.gpDanger).opacity(0.14), in: Circle()) + VStack(alignment: .leading, spacing: 3) { + Group { + if sain { + Text("Rien à signaler") + } else { + Text( + "\(bilan.faibles.count + bilan.reutilises.count) mots de passe à revoir" + ) + } + } + .font(.system(.headline, design: .rounded)) + .foregroundStyle(Color.gpInk) + Text("\(store.entries.count) éléments examinés") + .font(.caption) + .foregroundStyle(Color.gpMuted) + } + Spacer(minLength: 0) + } + .glassCard() + // Sans cette combinaison, la carte n'est pas un élément d'accessibilité à elle + // seule : son identifiant ne désigne rien, et un test qui la cherche ne trouve + // que le titre de l'écran — il croirait vérifier le résumé sans l'avoir vu. + .accessibilityElement(children: .combine) + .accessibilityIdentifier("card.healthSummary") + } + + @ViewBuilder + private var fuites: some View { + GhostSection( + titre: "Fuites connues", + note: "Seuls les cinq premiers caractères de l'empreinte du mot de passe sont envoyés : ni le mot de passe ni son empreinte complète ne quittent l'appareil." + ) { + switch verification { + case .pasEncore, .echouee: + VStack(alignment: .leading, spacing: 10) { + if case .echouee = verification { + Label { + Text("Le service de vérification n'a pas répondu.") + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + } + Button("Vérifier les fuites") { Task { await verifier() } } + .buttonStyle(SecondaryButtonStyle()) + .accessibilityIdentifier("button.checkBreaches") + } + .padding(14) + + case .enCours: + HStack(spacing: 10) { + ProgressView().tint(Color.gpAccentText) + Text("Vérification…").foregroundStyle(Color.gpMuted) + } + .padding(14) + + case .faite where compromis.isEmpty: + Label { + Text("Aucun mot de passe connu des fuites publiques.") + } icon: { + Image(systemName: "checkmark.circle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpSuccess) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .accessibilityIdentifier("text.noBreach") + + case .faite: + ForEach(Array(compromis.enumerated()), id: \.element.id) { index, entry in + if index > 0 { GhostDivider() } + ligne(entry, "flame") + } + } + } + } + + private func section( + _ titre: LocalizedStringKey, note: LocalizedStringKey, _ entries: [VaultEntry], + _ icone: String + ) -> some View { + GhostSection(titre: titre, note: note) { + ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in + if index > 0 { GhostDivider() } + ligne(entry, icone) + } + } + } + + private func ligne(_ entry: VaultEntry, _ icone: String) -> some View { + Button { + // On désigne l'élément, puis on se referme : c'est l'écran du coffre qui + // poussera le détail une fois la feuille partie. + onOuvrir(entry) + dismiss() + } label: { + HStack(spacing: 12) { + Image(systemName: icone) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Color.gpDanger) + .frame(width: 30, height: 30) + .background(Color.gpDanger.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: entry.item.name) + .foregroundStyle(Color.gpInk) + if let login = entry.login, !login.username.isEmpty { + Text(verbatim: login.username) + .font(.caption) + .foregroundStyle(Color.gpMuted) + } + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.gpMuted) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + /// Un appel par mot de passe *distinct* : deux comptes qui partagent le même mot de + /// passe ne valent qu'une requête, et le service n'apprend rien de plus. + private func verifier() async { + verification = .enCours + let motsDePasse = Set( + store.entries.compactMap { $0.login?.password }.filter { !$0.isEmpty }) + var comptes: [String: Int] = [:] + do { + for mot in motsDePasse { + comptes[mot] = try await Breach.pwnedCount(mot) + } + } catch { + verification = .echouee + return + } + compromis = store.entries.filter { entry in + guard let mot = entry.login?.password else { return false } + return (comptes[mot] ?? 0) > 0 + } + verification = .faite + } +} diff --git a/apps/ios/Ghostpass/Views/ImportView.swift b/apps/ios/Ghostpass/Views/ImportView.swift new file mode 100644 index 00000000..b97b7fdc --- /dev/null +++ b/apps/ios/Ghostpass/Views/ImportView.swift @@ -0,0 +1,203 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// Import d'un CSV exporté par un autre gestionnaire de mots de passe. +/// +/// Deux temps : on lit le fichier et on montre ce qu'on y a trouvé, puis on dépose. Un +/// import qui part sans rien montrer est un import qu'on n'ose pas lancer — et celui-ci +/// écrit dans le coffre, ce qui ne se défait qu'entrée par entrée. +struct ImportView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + @State private var choix = false + @State private var trouves: [VaultItem] = [] + @State private var nomDuFichier = "" + @State private var echec: LocalizedStringKey? + @State private var deposes: Int? + + var body: some View { + NavigationStack { + GhostScreen { + if let deposes { + resultat(deposes) + } else if trouves.isEmpty { + presentation + } else { + apercu + } + } + .navigationTitle("Importer") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(deposes == nil ? "Annuler" : "Terminé") { dismiss() } + .foregroundStyle(deposes == nil ? Color.gpMuted : Color.gpAccentText) + .accessibilityIdentifier("button.closeImport") + } + } + .fileImporter( + isPresented: $choix, allowedContentTypes: [.commaSeparatedText, .text] + ) { resultat in + lire(resultat) + } + } + .tint(Color.gpAccentText) + } + + private var presentation: some View { + VStack(alignment: .leading, spacing: 18) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 76, height: 76) + .background(Color.gpAccent.opacity(0.14), in: Circle()) + .frame(maxWidth: .infinity, alignment: .center) + + Text( + "Choisissez le fichier CSV exporté par votre gestionnaire actuel. Les colonnes de Bitwarden, 1Password, LastPass et Chrome sont reconnues." + ) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + + Text( + "Le fichier est lu sur l'appareil et rien d'autre n'en sort : chaque entrée est chiffrée avant d'être déposée. Pensez à l'effacer ensuite — un export CSV contient vos mots de passe en clair." + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + + Button("Choisir un fichier") { choix = true } + .buttonStyle(PrimaryButtonStyle(enabled: !store.isBusy)) + .disabled(store.isBusy) + .accessibilityIdentifier("button.pickCsv") + + if let echec { + Label { + Text(echec) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("text.importError") + } + } + .glassCard() + } + + private var apercu: some View { + VStack(alignment: .leading, spacing: 16) { + GhostSection( + titre: "À importer", + note: "Rien n'est encore déposé. Les entrées rejoindront le coffre telles quelles ; aucune n'écrase ce qui s'y trouve déjà." + ) { + GhostRow(intitule: "Fichier", valeur: nomDuFichier) {} + GhostDivider() + GhostRow(intitule: "Entrées trouvées", valeur: "\(trouves.count)") {} + } + + GhostSection(titre: "Aperçu") { + // Les premières seulement : une liste de deux cents lignes n'apprendrait + // rien de plus sur la bonne lecture des colonnes. + ForEach(Array(trouves.prefix(5).enumerated()), id: \.offset) { rang, item in + if rang > 0 { GhostDivider() } + ligne(item) + } + if trouves.count > 5 { + GhostDivider() + Text("et \(trouves.count - 5) autres") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + } + + Button("Importer \(trouves.count) entrées") { + Task { + let n = await store.importItems(trouves) + deposes = n + } + } + .buttonStyle(PrimaryButtonStyle(enabled: !store.isBusy)) + .disabled(store.isBusy) + .accessibilityIdentifier("button.confirmImport") + } + } + + private func resultat(_ nombre: Int) -> some View { + VStack(alignment: .leading, spacing: 14) { + Label { + Text("\(nombre) entrées importées.") + } icon: { + Image(systemName: "checkmark.circle.fill") + } + .font(.system(.headline, design: .rounded)) + .foregroundStyle(Color.gpInk) + .accessibilityIdentifier("text.imported") + + if let message = store.errorMessage { + Text(verbatim: message) + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + + Text("N'oubliez pas d'effacer le fichier CSV : il contient vos mots de passe en clair.") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + } + .glassCard() + } + + private func ligne(_ item: VaultItem) -> some View { + HStack(spacing: 12) { + Image(systemName: "person.badge.key") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 30, height: 30) + .background(Color.gpAccent.opacity(0.14), in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: item.name) + .foregroundStyle(Color.gpInk) + .lineLimit(1) + if case .login(let login) = item.data, !login.username.isEmpty { + Text(verbatim: login.username) + .font(.caption) + .foregroundStyle(Color.gpMuted) + .lineLimit(1) + } + } + Spacer(minLength: 8) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + } + + private func lire(_ resultat: Result) { + echec = nil + guard case .success(let url) = resultat else { + if case .failure = resultat { echec = "Le fichier n'a pas pu être ouvert." } + return + } + // Un fichier choisi hors du bac à sable de l'app ne se lit qu'après cette + // permission, et elle se rend aussitôt. + let autorise = url.startAccessingSecurityScopedResource() + defer { if autorise { url.stopAccessingSecurityScopedResource() } } + + guard let donnees = try? Data(contentsOf: url) else { + echec = "Le fichier n'a pas pu être lu." + return + } + let items = CsvImport.items(String(decoding: donnees, as: UTF8.self)) + guard !items.isEmpty else { + echec = "Aucune entrée trouvée : le fichier est vide, ou ses colonnes ne sont pas reconnues." + return + } + nomDuFichier = url.lastPathComponent + trouves = items + } +} diff --git a/apps/ios/Ghostpass/Views/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift new file mode 100644 index 00000000..2ca525fc --- /dev/null +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -0,0 +1,329 @@ +import SwiftUI + +/// Le détail d'un élément du coffre. +struct ItemDetailView: View { + let entry: VaultEntry + var onEdit: () -> Void = {} + /// Coffre d'un tiers, ouvert par accès d'urgence : on regarde, on ne touche pas. Sans + /// cela, le bouton favori écrirait dans *notre* coffre pour l'identifiant d'un autre. + var lectureSeule = false + + @EnvironmentObject private var store: VaultStore + @State private var revealed = false + /// L'historique est replié par défaut : ce sont des mots de passe périmés, on ne les + /// consulte qu'en cas de besoin, et les déployer d'office allongerait l'écran pour rien. + @State private var historiqueDeploye = false + /// Les anciens mots de passe dévoilés, par rang. Un par un : les afficher tous d'un + /// coup exposerait sans nécessité tout ce que l'élément a jamais protégé. + @State private var anciensDevoiles: Set = [] + /// Le libellé de la dernière copie — une clef de traduction, pas un texte tout fait : + /// il s'affiche, donc il se traduit. + @State private var copie: LocalizedStringKey? + + /// L'entrée telle qu'elle est *maintenant* dans le coffre. Sans cela, l'écran garderait + /// la copie reçue à la navigation et continuerait d'afficher l'ancien contenu après une + /// modification — l'utilisateur croirait son enregistrement perdu. + private var live: VaultEntry { + guard !lectureSeule else { return entry } + return store.entries.first { $0.id == entry.id } ?? entry + } + + var body: some View { + GhostScreen { + switch live.item.data { + case .login(let login): contenuIdentifiant(login) + case .secureNote(let note): contenuNote(note) + case .card(let card): contenuCarte(card) + } + + if let notes = live.item.notes, !notes.isEmpty { + GhostSection(titre: "Notes") { + Text(verbatim: notes) + .foregroundStyle(Color.gpInk) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + } + if let folder = live.item.folder, !folder.isEmpty { + GhostSection(titre: "Dossier") { + // Le nom du dossier est celui qu'a choisi l'utilisateur : il se + // montre tel quel, sans passer par le catalogue. + Label { + Text(verbatim: folder) + } icon: { + Image(systemName: "folder") + } + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + } + } + .navigationTitle(Text(verbatim: live.item.name)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !lectureSeule { + Button { + Task { await store.toggleFavorite(live) } + } label: { + Image(systemName: store.isFavorite(live) ? "star.fill" : "star") + } + .foregroundStyle(Color.gpAccentText) + .accessibilityLabel( + store.isFavorite(live) ? "Retirer des favoris" : "Mettre en favori" + ) + .accessibilityIdentifier("button.favorite") + + Button("Modifier", action: onEdit) + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.edit") + } + } + .overlay(alignment: .bottom) { confirmationDeCopie } + .animation(.snappy, value: copie) + } + + @ViewBuilder + private func contenuIdentifiant(_ login: Login) -> some View { + GhostSection(titre: "Identifiants") { + GhostRow(intitule: "Nom d'utilisateur", valeur: login.username) { + GhostIconButton(systemImage: "doc.on.doc") { + copier(login.username, "Nom d'utilisateur copié") + } + } + GhostDivider() + GhostRow( + intitule: "Mot de passe", + valeur: revealed ? login.password : String(repeating: "•", count: max(login.password.count, 8)), + monospace: true, estSecret: !revealed + ) { + GhostIconButton(systemImage: revealed ? "eye.slash" : "eye") { + revealed.toggle() + } + .accessibilityIdentifier("button.reveal") + GhostIconButton(systemImage: "doc.on.doc") { + copier(login.password, "Mot de passe copié") + } + } + if !login.password.isEmpty { + GhostDivider() + force(login.password) + } + } + + if !login.passwordHistory.isEmpty { + historique(login.passwordHistory) + } + + if let config = login.totp.flatMap(Totp.parse) { + GhostSection(titre: "Code à usage unique", note: "Renouvelé toutes les \(config.period) secondes.") { + // `TimelineView` réévalue chaque seconde : pas de minuterie à démarrer ni + // à arrêter, et rien ne continue de tourner une fois l'écran quitté. + TimelineView(.periodic(from: .now, by: 1)) { context in + if let otp = Totp.code(for: config, at: context.date) { + HStack(spacing: 14) { + Text(verbatim: otp.code) + .font(.system(.title2, design: .monospaced, weight: .semibold)) + .foregroundStyle(Color.gpInk) + .accessibilityIdentifier("text.totp") + Spacer() + compteARebours(otp.remaining, sur: config.period) + GhostIconButton(systemImage: "doc.on.doc") { + copier(otp.code, "Code copié") + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + } else { + Text("Clé de vérification illisible.") + .foregroundStyle(Color.gpMuted) + .padding(14) + } + } + } + } + + if !login.uris.isEmpty { + GhostSection(titre: "Adresses") { + ForEach(Array(login.uris.enumerated()), id: \.offset) { index, uri in + if index > 0 { GhostDivider() } + HStack { + Text(verbatim: uri) + .font(.callout) + .foregroundStyle(Color.gpInk) + .textSelection(.enabled) + Spacer(minLength: 8) + GhostIconButton(systemImage: "doc.on.doc") { copier(uri, "Adresse copiée") } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + } + } + } + } + + @ViewBuilder + private func contenuNote(_ note: SecureNote) -> some View { + GhostSection(titre: "Note") { + Text(verbatim: note.content) + .foregroundStyle(Color.gpInk) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + } + + @ViewBuilder + private func contenuCarte(_ card: Card) -> some View { + GhostSection(titre: "Carte") { + GhostRow(intitule: "Titulaire", valeur: card.cardholder) { + GhostIconButton(systemImage: "doc.on.doc") { copier(card.cardholder, "Titulaire copié") } + } + GhostDivider() + GhostRow( + intitule: "Numéro", + valeur: revealed ? card.number : String(repeating: "•", count: max(card.number.count, 8)), + monospace: true, estSecret: !revealed + ) { + GhostIconButton(systemImage: revealed ? "eye.slash" : "eye") { revealed.toggle() } + .accessibilityIdentifier("button.reveal") + GhostIconButton(systemImage: "doc.on.doc") { copier(card.number, "Numéro copié") } + } + GhostDivider() + GhostRow(intitule: "Expiration", valeur: "\(card.expMonth)/\(card.expYear)") {} + } + } + + /// Les mots de passe qu'on a remplacés. Ils servent le jour où un changement a échoué + /// à mi-chemin — le service a gardé l'ancien, l'application le nouveau — et ce jour-là, + /// les avoir gardés fait la différence entre récupérer un compte et le perdre. + private func historique(_ anciens: [String]) -> some View { + GhostSection { + Button { + historiqueDeploye.toggle() + if !historiqueDeploye { anciensDevoiles = [] } + } label: { + HStack(spacing: 10) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Color.gpMuted) + Text("Anciens mots de passe (\(anciens.count))") + .font(.subheadline) + .foregroundStyle(Color.gpInk) + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.gpMuted) + .rotationEffect(.degrees(historiqueDeploye ? 90 : 0)) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("button.history") + + if historiqueDeploye { + ForEach(Array(anciens.enumerated()), id: \.offset) { rang, ancien in + GhostDivider() + let devoile = anciensDevoiles.contains(rang) + GhostRow( + intitule: "Remplacé", + valeur: devoile ? ancien : String(repeating: "•", count: max(ancien.count, 8)), + monospace: true, estSecret: !devoile + ) { + GhostIconButton(systemImage: devoile ? "eye.slash" : "eye") { + if devoile { + anciensDevoiles.remove(rang) + } else { + anciensDevoiles.insert(rang) + } + } + GhostIconButton(systemImage: "doc.on.doc") { + copier(ancien, "Ancien mot de passe copié") + } + } + } + } + } + .animation(.snappy, value: historiqueDeploye) + } + + /// La force du mot de passe, au même barème que la web app : une jauge et un mot. + /// Le dire ici évite d'avoir à ouvrir l'écran de santé pour le savoir. + private func force(_ motDePasse: String) -> some View { + let force = PasswordHealth.force(motDePasse) + return HStack(spacing: 10) { + Text("Force").font(.caption).foregroundStyle(Color.gpMuted) + Spacer(minLength: 8) + HStack(spacing: 3) { + ForEach(0..<4, id: \.self) { index in + Capsule() + .fill(index < force.niveau ? force.couleur : Color.gpBorderStrong) + .frame(width: 18, height: 4) + } + } + Text(force.libelle) + .font(.caption.weight(.semibold)) + .foregroundStyle(force.couleur) + .accessibilityIdentifier("text.strength") + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + } + + /// Le temps qu'il reste au code, lisible d'un coup d'œil : un anneau qui se vide, et + /// qui vire au rouge quand il ne faut plus compter dessus. + private func compteARebours(_ restant: Int, sur periode: Int) -> some View { + let fraction = Double(restant) / Double(max(periode, 1)) + let urgent = restant <= 5 + return HStack(spacing: 6) { + ZStack { + Circle().stroke(Color.gpBorderStrong, lineWidth: 2.5) + Circle() + .trim(from: 0, to: fraction) + .stroke( + urgent ? Color.gpDanger : Color.gpAccentText, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + } + .frame(width: 18, height: 18) + Text("\(restant) s") + .font(.footnote.monospacedDigit()) + .foregroundStyle(urgent ? Color.gpDanger : Color.gpMuted) + } + } + + /// Une copie ne dit rien d'elle-même : sans retour, on ne sait pas si elle a eu lieu. + @ViewBuilder + private var confirmationDeCopie: some View { + if let copie { + Label { + // Le délai s'ajoute au message plutôt que d'y être interpolé : deux clefs + // de traduction se composent, une clef ne s'insère pas dans une autre. + Text(copie) + Text(" — effacé dans \(Int(Clipboard.lifetime)) s") + } icon: { + Image(systemName: "checkmark.circle.fill") + } + .font(.footnote.weight(.medium)) + .foregroundStyle(Color.gpInk) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.gpBorder, lineWidth: 1)) + .padding(.bottom, 24) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + + private func copier(_ valeur: String, _ message: LocalizedStringKey) { + Clipboard.copy(valeur) + copie = message + Task { + try? await Task.sleep(for: .seconds(2.5)) + copie = nil + } + } +} diff --git a/apps/ios/Ghostpass/Views/ItemEditView.swift b/apps/ios/Ghostpass/Views/ItemEditView.swift new file mode 100644 index 00000000..78aff4e1 --- /dev/null +++ b/apps/ios/Ghostpass/Views/ItemEditView.swift @@ -0,0 +1,308 @@ +import SwiftUI + +/// Création et modification. Le formulaire travaille sur des champs à plat puis +/// reconstitue le `VaultItem` au moment d'enregistrer : c'est le cœur Rust qui +/// chiffre, l'écran ne manipule que du clair éphémère. +struct ItemEditView: View { + let target: EditTarget + /// Où enregistrer. Par défaut le coffre personnel ; une collection d'équipe fournit le + /// sien, car un item partagé se chiffre sous l'Org Key et non sous la nôtre. Le + /// formulaire, lui, est le même — il n'y a aucune raison d'en tenir deux. + var enregistrer: ((VaultItem, String?) async -> Void)? + + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + @State private var kind: Kind = .login + @State private var name = "" + @State private var notes = "" + @State private var folder = "" + @State private var username = "" + @State private var password = "" + @State private var uri = "" + @State private var totp = "" + @State private var noteContent = "" + @State private var cardholder = "" + @State private var cardNumber = "" + @State private var expMonth = "" + @State private var expYear = "" + @State private var cardCode = "" + @State private var generating = false + + enum Kind: String, CaseIterable, Identifiable { + case login + case note + case card + var id: String { rawValue } + + /// Une clef de traduction, pas le `rawValue` : celui-ci identifie le cas dans le + /// code, ce qui s'affiche est autre chose et change avec la langue. + var libelle: LocalizedStringKey { + switch self { + case .login: return "Identifiant" + case .note: return "Note" + case .card: return "Carte" + } + } + + var icone: String { + switch self { + case .login: return "person.badge.key" + case .note: return "note.text" + case .card: return "creditcard" + } + } + } + + var body: some View { + NavigationStack { + GhostScreen { + GhostSection(titre: "Nom") { + TextField("", text: $name, prompt: invite("GitHub, Amazon…")) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.name") + } + + if isNew { + selecteurDeType + } + + switch kind { + case .login: champsIdentifiant + case .note: champsNote + case .card: champsCarte + } + + GhostSection(titre: "Classement") { + TextField("", text: $folder, prompt: invite("Dossier — Travail/Serveurs")) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.folder") + GhostDivider() + TextField("", text: $notes, prompt: invite("Notes"), axis: .vertical) + .lineLimit(2...6) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.notes") + } + } + .navigationTitle(isNew ? Text("Nouvel élément") : Text("Modifier")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { dismiss() } + .foregroundStyle(Color.gpMuted) + .accessibilityIdentifier("button.cancel") + } + ToolbarItem(placement: .confirmationAction) { + Button("Enregistrer") { + Task { + if let enregistrer { + await enregistrer(build(), existingID) + } else { + await store.save(build(), id: existingID) + } + dismiss() + } + } + .fontWeight(.semibold) + .foregroundStyle(Color.gpAccentText) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .accessibilityIdentifier("button.save") + } + } + .onAppear(perform: load) + .sheet(isPresented: $generating) { + PasswordGeneratorView { password = $0 } + } + } + .tint(Color.gpAccentText) + } + + /// Le type ne se choisit qu'à la création : le changer ensuite reviendrait à remplacer + /// l'élément, pas à le modifier. + private var selecteurDeType: some View { + HStack(spacing: 10) { + ForEach(Kind.allCases) { cas in + Button { + kind = cas + } label: { + VStack(spacing: 6) { + Image(systemName: cas.icone).font(.system(size: 17, weight: .medium)) + Text(cas.libelle).font(.caption.weight(.medium)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + kind == cas ? Color.gpAccent.opacity(0.18) : Color.gpSurface.opacity(0.7), + in: RoundedRectangle(cornerRadius: GP.radius) + ) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder( + kind == cas ? Color.gpAccent : Color.gpBorder, + lineWidth: kind == cas ? 1.5 : 1)) + .foregroundStyle(kind == cas ? Color.gpAccentText : Color.gpMuted) + } + .buttonStyle(.plain) + } + } + } + + private var champsIdentifiant: some View { + Group { + GhostSection(titre: "Identifiants") { + TextField("", text: $username, prompt: invite("Nom d'utilisateur")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.username") + GhostDivider() + HStack(spacing: 8) { + SecureField("", text: $password, prompt: invite("Mot de passe")) + .foregroundStyle(Color.gpInk) + .accessibilityIdentifier("field.password") + GhostIconButton(systemImage: "dice") { generating = true } + .accessibilityIdentifier("button.generate") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + GhostDivider() + TextField("", text: $uri, prompt: invite("Adresse du site")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.uri") + } + + GhostSection( + titre: "Code à usage unique", + note: "La clé reste chiffrée dans l'élément ; les codes sont calculés sur l'appareil." + ) { + TextField("", text: $totp, prompt: invite("Clé ou URI otpauth://")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.totp") + } + } + } + + private var champsNote: some View { + GhostSection(titre: "Contenu") { + TextField("", text: $noteContent, prompt: invite("Votre note"), axis: .vertical) + .lineLimit(6...14) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.note") + } + } + + private var champsCarte: some View { + GhostSection(titre: "Carte") { + TextField("", text: $cardholder, prompt: invite("Titulaire")) + .foregroundStyle(Color.gpInk) + .padding(14) + GhostDivider() + TextField("", text: $cardNumber, prompt: invite("Numéro")) + .keyboardType(.numberPad) + .foregroundStyle(Color.gpInk) + .padding(14) + GhostDivider() + HStack(spacing: 12) { + TextField("", text: $expMonth, prompt: invite("MM")).keyboardType(.numberPad) + TextField("", text: $expYear, prompt: invite("AAAA")).keyboardType(.numberPad) + SecureField("", text: $cardCode, prompt: invite("Cryptogramme")) + } + .foregroundStyle(Color.gpInk) + .padding(14) + } + } + + private func invite(_ texte: LocalizedStringKey) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) + } + + private var isNew: Bool { + if case .new = target { return true } + return false + } + + private var existingID: String? { + if case .existing(let entry) = target { return entry.id } + return nil + } + + private func load() { + guard case .existing(let entry) = target else { return } + name = entry.item.name + notes = entry.item.notes ?? "" + folder = entry.item.folder ?? "" + switch entry.item.data { + case .login(let login): + kind = .login + username = login.username + password = login.password + uri = login.uris.first ?? "" + totp = login.totp ?? "" + case .secureNote(let note): + kind = .note + noteContent = note.content + case .card(let card): + kind = .card + cardholder = card.cardholder + cardNumber = card.number + expMonth = card.expMonth + expYear = card.expYear + cardCode = card.code + } + } + + private func build() -> VaultItem { + let data: ItemData + switch kind { + case .login: + data = .login( + Login( + username: username, password: password, + uris: uri.isEmpty ? [] : [uri], + // Sans cette ligne, modifier un item effaçait sa clé TOTP en silence. + totp: totp.isEmpty ? nil : totp, + passwordHistory: previousPasswords())) + case .note: + data = .secureNote(SecureNote(content: noteContent)) + case .card: + data = .card( + Card( + cardholder: cardholder, number: cardNumber, expMonth: expMonth, + expYear: expYear, code: cardCode)) + } + return VaultItem( + name: name.trimmingCharacters(in: .whitespaces), + notes: notes.isEmpty ? nil : notes, + folder: folder.isEmpty ? nil : folder, + data: data) + } + + /// Un mot de passe remplacé rejoint l'historique plutôt que de disparaître : c'est + /// ce que fait la web app, et c'est ce qui permet de récupérer un compte dont le + /// changement de mot de passe a échoué à mi-chemin. + /// + /// L'historique est plafonné, comme sur le web. Sans plafond il grossirait sans fin, + /// et un item chiffré qui enfle à chaque modification finit par coûter cher à + /// transporter — pour des mots de passe que personne ne remontera jamais si loin. + private func previousPasswords() -> [String] { + guard case .existing(let entry) = target, let login = entry.login else { return [] } + guard login.password != password, !login.password.isEmpty else { + return Array(login.passwordHistory.prefix(VaultConstants.passwordHistoryLimit)) + } + return Array( + ([login.password] + login.passwordHistory) + .prefix(VaultConstants.passwordHistoryLimit)) + } +} diff --git a/apps/ios/Ghostpass/Views/OrgAdminView.swift b/apps/ios/Ghostpass/Views/OrgAdminView.swift new file mode 100644 index 00000000..ac9451a3 --- /dev/null +++ b/apps/ios/Ghostpass/Views/OrgAdminView.swift @@ -0,0 +1,239 @@ +import SwiftUI + +/// L'administration d'une équipe : membres, collections, groupes. +/// +/// Réservé aux administrateurs — le serveur le vérifie de toute façon, mais proposer un +/// geste qu'il refusera est une promesse non tenue. +struct OrgAdminView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + let ouvert: CoffrePartageOuvert + /// Rejoué après toute opération qui change ce que le coffre contient — les collections + /// sont capturées à l'ouverture, une nouvelle ne s'y ajoute pas toute seule. + let apres: () async -> Void + + @State private var membres: [MembreDEquipe] = [] + @State private var groupes: [OrgGroupDTO] = [] + @State private var chargement = true + @State private var invitation = false + @State private var nouvelleCollection = false + @State private var nouveauGroupe = false + @State private var aRevoquer: MembreDEquipe? + @State private var saisie = "" + + var body: some View { + NavigationStack { + GhostScreen { + if chargement { + ProgressView().tint(Color.gpAccentText) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + VStack(alignment: .leading, spacing: 16) { + sectionMembres + sectionCollections + sectionGroupes + } + } + } + .navigationTitle("Administration") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Terminé") { dismiss() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.closeOrgAdmin") + } + } + } + .tint(Color.gpAccentText) + .task { await recharger() } + .sheet(isPresented: $invitation) { + OrgInviteView(ouvert: ouvert) { await recharger() } + .environmentObject(store) + } + .alert("Nouvelle collection", isPresented: $nouvelleCollection) { + TextField("Nom", text: $saisie) + Button("Annuler", role: .cancel) { saisie = "" } + Button("Créer") { + let nom = saisie + saisie = "" + Task { + if await store.creerUneCollection(ouvert, nom: nom) { await apres() } + } + } + } + .alert("Nouveau groupe", isPresented: $nouveauGroupe) { + TextField("Nom", text: $saisie) + Button("Annuler", role: .cancel) { saisie = "" } + Button("Créer") { + let nom = saisie + saisie = "" + Task { + if await store.creerUnGroupe(ouvert.organisation, nom: nom) { + await recharger() + } + } + } + } + .alert( + "Révoquer ce membre ?", + isPresented: Binding( + get: { aRevoquer != nil }, set: { if !$0 { aRevoquer = nil } }) + ) { + Button("Annuler", role: .cancel) { aRevoquer = nil } + Button("Révoquer", role: .destructive) { + if let cible = aRevoquer { Task { await revoquer(cible) } } + } + } message: { + Text( + "La clé de l'équipe sera changée et redistribuée aux autres membres. Ce que cette personne a déjà vu reste connu d'elle : les mots de passe concernés doivent être changés." + ) + } + } + + // ─── Membres ─── + + private var sectionMembres: some View { + GhostSection( + titre: "Membres", + note: "Inviter quelqu'un lui scelle la clé de l'équipe vers sa clé publique. Le serveur ne transporte qu'un blob qu'il ne peut pas ouvrir." + ) { + VStack(spacing: 0) { + ForEach(membres) { membre in + ligneMembre(membre) + Divider().overlay(Color.gpBorder) + } + Button("Inviter quelqu'un") { invitation = true } + .buttonStyle(SecondaryButtonStyle()) + .padding(14) + .accessibilityIdentifier("button.inviteMember") + } + } + } + + private func ligneMembre(_ membre: MembreDEquipe) -> some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: membre.email ?? membre.id) + .foregroundStyle(Color.gpInk) + Text(verbatim: "\(membre.role.intitule) · \(membre.etat.intitule)") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + } + Spacer() + Menu { + ForEach([RoleDOrganisation.admin, .member, .readonly], id: \.rawValue) { role in + Button(LocalizedStringKey(role.intitule)) { + Task { + if await store.changerLeRole( + ouvert.organisation, membre: membre.id, role: role) + { + await recharger() + } + } + } + } + Divider() + Button("Révoquer", role: .destructive) { aRevoquer = membre } + } label: { + Image(systemName: "ellipsis.circle").foregroundStyle(Color.gpAccentText) + } + .accessibilityLabel("Modifier ce membre") + } + .padding(14) + .accessibilityElement(children: .combine) + } + + private func revoquer(_ membre: MembreDEquipe) async { + let restants = membres.filter { $0.id != membre.id } + let ok = await store.revoquerEtFaireTourner( + ouvert, revoquer: membre.id, restants: restants) + aRevoquer = nil + if ok { + await recharger() + // La clé de l'équipe a changé : le coffre ouvert tient encore l'ancienne, et + // tout ce qu'il afficherait désormais serait illisible. On referme. + await apres() + dismiss() + } + } + + // ─── Collections ─── + + private var sectionCollections: some View { + GhostSection(titre: "Collections") { + VStack(spacing: 0) { + if ouvert.collections.isEmpty { + Text("Aucune collection.") + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } else { + ForEach(ouvert.collections) { collection in + Text(verbatim: collection.name) + .foregroundStyle(Color.gpInk) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + Divider().overlay(Color.gpBorder) + } + } + Button("Nouvelle collection") { nouvelleCollection = true } + .buttonStyle(SecondaryButtonStyle()) + .padding(14) + .accessibilityIdentifier("button.newCollection") + } + } + } + + // ─── Groupes ─── + + private var sectionGroupes: some View { + GhostSection( + titre: "Groupes", + note: "Un groupe donne accès à des collections. C'est par lui qu'on ouvre un coffre à plusieurs personnes sans les nommer une à une." + ) { + VStack(spacing: 0) { + ForEach(groupes) { groupe in + ligneGroupe(groupe) + Divider().overlay(Color.gpBorder) + } + Button("Nouveau groupe") { nouveauGroupe = true } + .buttonStyle(SecondaryButtonStyle()) + .padding(14) + .accessibilityIdentifier("button.newGroup") + } + } + } + + private func ligneGroupe(_ groupe: OrgGroupDTO) -> some View { + NavigationLink { + OrgGroupView(ouvert: ouvert, groupe: groupe, membres: membres) { + await recharger() + } + .environmentObject(store) + } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: groupe.name).foregroundStyle(Color.gpInk) + Text( + verbatim: String( + format: tr("%1$d membres · %2$d collections"), + groupe.members.count, groupe.collections.count) + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + } + Spacer() + Image(systemName: "chevron.right").foregroundStyle(Color.gpMuted) + } + .padding(14) + } + } + + private func recharger() async { + membres = await store.membres(ouvert.organisation) + groupes = await store.groupes(ouvert.organisation) + chargement = false + } +} diff --git a/apps/ios/Ghostpass/Views/OrgGroupView.swift b/apps/ios/Ghostpass/Views/OrgGroupView.swift new file mode 100644 index 00000000..38fb50a4 --- /dev/null +++ b/apps/ios/Ghostpass/Views/OrgGroupView.swift @@ -0,0 +1,145 @@ +import SwiftUI + +/// Un groupe : qui en fait partie, et ce qu'il ouvre. +/// +/// C'est le seul endroit où les droits se règlent. Passer par un groupe plutôt que par des +/// accès nommés permet de retirer quelqu'un d'un coup, sans repasser sur chaque collection. +struct OrgGroupView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + let ouvert: CoffrePartageOuvert + let groupe: OrgGroupDTO + let membres: [MembreDEquipe] + let apres: () async -> Void + + @State private var dedans: Set = [] + @State private var droits: [String: DroitSurCollection] = [:] + @State private var aSupprimer = false + + var body: some View { + GhostScreen { + VStack(alignment: .leading, spacing: 16) { + sectionMembres + sectionCollections + Button("Supprimer ce groupe", role: .destructive) { aSupprimer = true } + .buttonStyle(SecondaryButtonStyle()) + .accessibilityIdentifier("button.deleteGroup") + } + } + .navigationTitle(Text(verbatim: groupe.name)) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + dedans = Set(groupe.members.map(\.userId)) + droits = Dictionary( + uniqueKeysWithValues: groupe.collections.compactMap { acces in + DroitSurCollection(rawValue: acces.permission).map { (acces.collectionId, $0) } + }) + } + .alert("Supprimer ce groupe ?", isPresented: $aSupprimer) { + Button("Annuler", role: .cancel) {} + Button("Supprimer", role: .destructive) { + Task { + if await store.supprimerLeGroupe(ouvert.organisation, groupe: groupe.id) { + await apres() + dismiss() + } + } + } + } message: { + Text( + "Ses membres perdront les accès qu'il leur donnait. Les identifiants, eux, restent dans leurs collections." + ) + } + } + + private var sectionMembres: some View { + GhostSection(titre: "Membres du groupe") { + VStack(spacing: 0) { + ForEach(membres) { membre in + Toggle( + isOn: Binding( + get: { dedans.contains(membre.id) }, + set: { dedans_ in Task { await basculer(membre, dedans_) } }) + ) { + Text(verbatim: membre.email ?? membre.id) + .foregroundStyle(Color.gpInk) + } + .tint(Color.gpAccent) + .padding(14) + if membre.id != membres.last?.id { Divider().overlay(Color.gpBorder) } + } + } + } + } + + private var sectionCollections: some View { + GhostSection( + titre: "Ce que le groupe ouvre", + note: "Lecture pour consulter, écriture pour modifier, gestion pour régler les accès de la collection." + ) { + VStack(spacing: 0) { + ForEach(ouvert.collections) { collection in + HStack { + Text(verbatim: collection.name).foregroundStyle(Color.gpInk) + Spacer() + Menu { + ForEach(DroitSurCollection.allCases) { droit in + Button(LocalizedStringKey(droit.intitule)) { + Task { await regler(collection.id, droit) } + } + } + if droits[collection.id] != nil { + Divider() + Button("Retirer l'accès", role: .destructive) { + Task { await retirer(collection.id) } + } + } + } label: { + Text(verbatim: droits[collection.id]?.intitule ?? tr("Aucun accès")) + .font(.footnote) + .foregroundStyle(Color.gpAccentText) + } + } + .padding(14) + if collection.id != ouvert.collections.last?.id { + Divider().overlay(Color.gpBorder) + } + } + } + } + } + + private func basculer(_ membre: MembreDEquipe, _ dedansMaintenant: Bool) async { + let ok = + dedansMaintenant + ? await store.ajouterAuGroupe( + ouvert.organisation, groupe: groupe.id, membre: membre.id) + : await store.retirerDuGroupe( + ouvert.organisation, groupe: groupe.id, membre: membre.id) + // On ne coche que si le serveur a suivi : un interrupteur qui bouge sans effet ferait + // croire à un droit accordé qui ne l'est pas. + if ok { + if dedansMaintenant { dedans.insert(membre.id) } else { dedans.remove(membre.id) } + await apres() + } + } + + private func regler(_ collection: String, _ droit: DroitSurCollection) async { + if await store.donnerAcces( + ouvert.organisation, groupe: groupe.id, collection: collection, droit: droit) + { + droits[collection] = droit + await apres() + } + } + + private func retirer(_ collection: String) async { + if await store.retirerLAcces( + ouvert.organisation, groupe: groupe.id, collection: collection) + { + droits[collection] = nil + await apres() + } + } +} diff --git a/apps/ios/Ghostpass/Views/OrgInviteView.swift b/apps/ios/Ghostpass/Views/OrgInviteView.swift new file mode 100644 index 00000000..b84bc172 --- /dev/null +++ b/apps/ios/Ghostpass/Views/OrgInviteView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +/// Inviter quelqu'un dans une équipe. +/// +/// Le geste a l'air anodin — une adresse, un rôle — mais il scelle la clé du coffre vers la +/// clé publique de cette personne. C'est irrévocable au sens strict : la reprendre suppose +/// une rotation, et ce qu'elle aura vu d'ici là restera connu d'elle. +struct OrgInviteView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + let ouvert: CoffrePartageOuvert + let apres: () async -> Void + + @State private var email = "" + @State private var role: RoleDOrganisation = .member + + private var emailValide: Bool { + let parts = email.split(separator: "@") + return parts.count == 2 && parts.allSatisfy { !$0.isEmpty } && parts[1].contains(".") + } + + var body: some View { + NavigationStack { + GhostScreen { + VStack(alignment: .leading, spacing: 16) { + GhostSection( + titre: "La personne", + note: "Elle doit déjà posséder un compte GhostPass : c'est sa clé publique qui protège celle de l'équipe." + ) { + TextField("adresse@exemple.com", text: $email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.memberEmail") + } + + GhostSection(titre: "Son rôle") { + VStack(alignment: .leading, spacing: 12) { + ForEach([RoleDOrganisation.admin, .member, .readonly], id: \.rawValue) { + candidat in + Button { + role = candidat + } label: { + HStack(alignment: .top, spacing: 12) { + Image( + systemName: role == candidat + ? "largecircle.fill.circle" : "circle" + ) + .foregroundStyle( + role == candidat ? Color.gpAccentText : Color.gpMuted) + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: candidat.intitule) + .foregroundStyle(Color.gpInk) + Text(verbatim: explication(candidat)) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .accessibilityIdentifier("orgRole.\(candidat.rawValue)") + } + } + .padding(14) + } + + Button("Inviter") { + Task { + let ok = await store.inviterDansLEquipe( + ouvert, email: email.trimmingCharacters(in: .whitespaces), + role: role) + if ok { + await apres() + dismiss() + } + } + } + .buttonStyle(PrimaryButtonStyle(enabled: emailValide && !store.isBusy)) + .disabled(!emailValide || store.isBusy) + .accessibilityIdentifier("button.confirmMember") + + if let message = store.errorMessage { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .scrollDismissesKeyboard(.immediately) + .navigationTitle("Inviter") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { dismiss() } + .foregroundStyle(Color.gpMuted) + } + } + } + .tint(Color.gpAccentText) + } + + private func explication(_ role: RoleDOrganisation) -> String { + switch role { + case .admin: return tr("Peut inviter, révoquer et gérer les droits.") + case .member: return tr("Peut lire et modifier les identifiants partagés.") + case .readonly: return tr("Peut lire, sans rien modifier.") + } + } +} diff --git a/apps/ios/Ghostpass/Views/OrgVaultView.swift b/apps/ios/Ghostpass/Views/OrgVaultView.swift new file mode 100644 index 00000000..9384efd4 --- /dev/null +++ b/apps/ios/Ghostpass/Views/OrgVaultView.swift @@ -0,0 +1,174 @@ +import SwiftUI + +/// Le coffre d'une équipe : ses collections, et leurs identifiants. +/// +/// Rien n'est mis en cache, contrairement au coffre personnel qui s'ouvre hors ligne. Un +/// accès partagé se révoque, et la révocation passe par une rotation d'Org Key : garder une +/// copie locale déchiffrable après un départ viderait cette rotation de son sens. +struct OrgVaultView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + let ouvert: CoffrePartageOuvert + + @State private var choisie: OrgCollectionDTO? + @State private var entrees: [VaultEntry] = [] + @State private var chargement = false + @State private var recherche = "" + @State private var edition: VaultEntry? + @State private var creation = false + @State private var administration = false + + private var visibles: [VaultEntry] { + let q = recherche.trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return entrees } + return entrees.filter { + $0.item.name.localizedCaseInsensitiveContains(q) + || ($0.login?.username ?? "").localizedCaseInsensitiveContains(q) + } + } + + var body: some View { + NavigationStack { + GhostScreen { + VStack(alignment: .leading, spacing: 16) { + if ouvert.collections.isEmpty { + aucuneCollection + } else { + selecteurDeCollection + contenu + } + } + } + .searchable(text: $recherche, prompt: Text("Rechercher")) + .navigationTitle(Text(verbatim: ouvert.organisation.nom)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fermer") { dismiss() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.closeOrgVault") + } + if ouvert.organisation.role == .admin { + ToolbarItem(placement: .primaryAction) { + Button { + administration = true + } label: { + Image(systemName: "person.2.badge.gearshape") + } + .accessibilityIdentifier("button.orgAdmin") + } + } + if ouvert.organisation.role.peutEcrire && choisie != nil { + ToolbarItem(placement: .primaryAction) { + Button { + creation = true + } label: { + Image(systemName: "plus") + } + .accessibilityIdentifier("button.addOrgItem") + } + } + } + } + .tint(Color.gpAccentText) + .task { + choisie = ouvert.collections.first + await recharger() + } + .onChange(of: choisie?.id) { _, _ in Task { await recharger() } } + .sheet(isPresented: $administration) { + // L'administration change les collections et peut faire tourner la clé : dans les + // deux cas ce qu'on affiche devient périmé, donc on referme plutôt que de montrer + // un coffre dont la moitié ne se déchiffrerait plus. + OrgAdminView(ouvert: ouvert) { dismiss() } + .environmentObject(store) + } + .sheet(isPresented: $creation) { editeur(nil) } + .sheet(item: $edition) { entree in editeur(entree) } + } + + private var aucuneCollection: some View { + Text( + "Aucune collection ne vous est ouverte dans cette équipe. Un administrateur doit vous y donner accès." + ) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + .glassCard() + } + + /// Un sélecteur plutôt qu'une navigation à deux niveaux : les équipes ont rarement plus + /// d'une poignée de collections, et un aller-retour par écran pour chacune coûterait + /// plus qu'il ne range. + private var selecteurDeCollection: some View { + GhostSection(titre: "Collection") { + Picker( + selection: Binding( + get: { choisie?.id ?? "" }, + set: { id in choisie = ouvert.collections.first { $0.id == id } }) + ) { + ForEach(ouvert.collections) { collection in + Text(verbatim: collection.name).tag(collection.id) + } + } label: { + EmptyView() + } + .pickerStyle(.menu) + .tint(Color.gpAccentText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .accessibilityIdentifier("picker.collection") + } + } + + @ViewBuilder private var contenu: some View { + if chargement { + ProgressView().tint(Color.gpAccentText) + .frame(maxWidth: .infinity, minHeight: 100) + } else if entrees.isEmpty { + Text("Cette collection est vide.") + .foregroundStyle(Color.gpMuted) + .glassCard() + } else { + GhostSection(titre: "Identifiants") { + ForEach(visibles) { entree in + NavigationLink { + ItemDetailView( + entry: entree, + onEdit: { edition = entree }, + lectureSeule: !ouvert.organisation.role.peutEcrire + ) + .environmentObject(store) + } label: { + GhostRow( + intitule: LocalizedStringKey(entree.item.name), + valeur: entree.login?.username ?? "") {} + } + if entree.id != visibles.last?.id { Divider().overlay(Color.gpBorder) } + } + } + } + } + + private func editeur(_ entree: VaultEntry?) -> some View { + ItemEditView( + target: entree.map(EditTarget.existing) ?? .new, + enregistrer: { item, id in + guard let collection = choisie else { return } + if await store.enregistrerDansLaCollection( + ouvert, collection: collection.id, item: item, remplace: id) + { + await recharger() + } + } + ) + .environmentObject(store) + } + + private func recharger() async { + guard let collection = choisie else { return } + chargement = true + entrees = await store.itemsPartages(ouvert, collection: collection.id) + chargement = false + } +} diff --git a/apps/ios/Ghostpass/Views/OrganizationsView.swift b/apps/ios/Ghostpass/Views/OrganizationsView.swift new file mode 100644 index 00000000..0fbb9475 --- /dev/null +++ b/apps/ios/Ghostpass/Views/OrganizationsView.swift @@ -0,0 +1,165 @@ +import SwiftUI + +/// Les coffres partagés d'une équipe. +/// +/// Un coffre d'organisation ne se déchiffre pas avec la clé du compte mais avec une Org Key, +/// que l'administrateur a scellée vers notre clé publique. L'ouvrir, c'est la faire ouvrir +/// par le cœur Rust **en vérifiant qu'elle vient bien de lui** — sans quoi un serveur actif +/// pourrait en glisser une autre et lire tout ce qu'on y écrirait ensuite. +struct OrganizationsView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + @State private var organisations: [Organisation] = [] + @State private var chargement = true + @State private var ouvert: CoffrePartageOuvert? + @State private var creation = false + @State private var nom = "" + + var body: some View { + NavigationStack { + GhostScreen { + if chargement { + ProgressView().tint(Color.gpAccentText) + .frame(maxWidth: .infinity, minHeight: 120) + } else if organisations.isEmpty { + vide + } else { + VStack(alignment: .leading, spacing: 16) { + if !invitations.isEmpty { + GhostSection( + titre: "Invitations", + note: "Accepter vous donnera accès aux coffres que l'équipe partage avec vous." + ) { + lignes(invitations) + } + } + if !actives.isEmpty { + GhostSection(titre: "Mes équipes") { lignes(actives) } + } + if !revoquees.isEmpty { + GhostSection( + titre: "Accès révoqués", + note: "Ces coffres ne s'ouvrent plus. Après une rotation de clé, ce que vous en aviez vu reste connu de vous — les mots de passe concernés doivent être changés par l'équipe." + ) { + lignes(revoquees) + } + } + } + } + } + .navigationTitle("Coffres partagés") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Terminé") { dismiss() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.closeOrgs") + } + ToolbarItem(placement: .primaryAction) { + Button { + creation = true + } label: { + Image(systemName: "plus") + } + .accessibilityIdentifier("button.newOrg") + } + } + } + .tint(Color.gpAccentText) + .task { await recharger() } + .sheet(item: $ouvert) { coffre in + OrgVaultView(ouvert: coffre).environmentObject(store) + } + .alert("Nouvelle équipe", isPresented: $creation) { + TextField("Nom de l'équipe", text: $nom) + Button("Annuler", role: .cancel) { nom = "" } + Button("Créer") { + let choisi = nom + nom = "" + Task { + if await store.creerUneOrganisation(nom: choisi) { await recharger() } + } + } + } message: { + Text( + "La clé du coffre est créée sur cet appareil et scellée pour vous seul. Le serveur n'en connaîtra jamais le contenu." + ) + } + } + + private var invitations: [Organisation] { organisations.filter { $0.etat == .invited } } + private var actives: [Organisation] { organisations.filter { $0.etat == .active } } + private var revoquees: [Organisation] { organisations.filter { $0.etat == .revoked } } + + private var vide: some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: "person.2") + .font(.system(size: 30, weight: .light)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 68, height: 68) + .background(Color.gpAccent.opacity(0.14), in: Circle()) + .frame(maxWidth: .infinity, alignment: .center) + Text("Vous n'appartenez à aucune équipe.") + .foregroundStyle(Color.gpInk) + .frame(maxWidth: .infinity, alignment: .center) + Text( + "Créez-en une, ou attendez qu'on vous invite : les équipes dont vous ferez partie apparaîtront ici." + ) + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .glassCard() + } + + @ViewBuilder private func lignes(_ groupe: [Organisation]) -> some View { + ForEach(groupe) { organisation in + ligne(organisation) + if organisation.id != groupe.last?.id { Divider().overlay(Color.gpBorder) } + } + } + + private func ligne(_ organisation: Organisation) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(verbatim: organisation.nom) + .foregroundStyle(Color.gpInk) + Text(verbatim: "\(organisation.role.intitule) · \(organisation.etat.intitule)") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + + switch organisation.etat { + case .invited: + Button("Rejoindre l'équipe") { + Task { + if await store.accepterLOrganisation(organisation.id) { + await recharger() + } + } + } + .buttonStyle(SecondaryButtonStyle()) + .accessibilityIdentifier("button.joinOrg") + case .active: + Button("Ouvrir le coffre") { + Task { ouvert = await store.ouvrirLOrganisation(organisation) } + } + .buttonStyle(SecondaryButtonStyle()) + .accessibilityIdentifier("button.openOrg") + case .revoked: + EmptyView() + } + } + .padding(14) + .accessibilityElement(children: .combine) + } + + private func recharger() async { + organisations = await store.organisations() + chargement = false + } +} + +extension CoffrePartageOuvert: Identifiable { + var id: String { organisation.id } +} diff --git a/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift b/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift new file mode 100644 index 00000000..1deb8354 --- /dev/null +++ b/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift @@ -0,0 +1,148 @@ +import SwiftUI + +/// Génération d'un mot de passe, présentée depuis le formulaire d'édition. +/// +/// Le mot de passe n'est que proposé ; il n'est retenu que si l'utilisateur l'accepte. +/// Rien n'est enregistré ici — c'est l'écran d'édition qui décide. +struct PasswordGeneratorView: View { + /// Appelé avec le mot de passe retenu. + var onUse: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var options = GeneratorOptions() + @State private var password = "" + + var body: some View { + NavigationStack { + GhostScreen { + GhostSection { + VStack(spacing: 14) { + Text(verbatim: password.isEmpty ? " " : password) + .font(.system(.title3, design: .monospaced, weight: .medium)) + .foregroundStyle(Color.gpInk) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("text.generated") + + HStack(spacing: 10) { + force + Spacer() + Button { + regenerate() + } label: { + Label("Régénérer", systemImage: "arrow.clockwise") + .font(.subheadline.weight(.medium)) + } + .buttonStyle(.plain) + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.regenerate") + } + } + .padding(16) + } + + GhostSection(titre: "Longueur — \(options.length) caractères") { + // Pas moins de 8 : en dessous, la longueur ne protège plus de rien. + Slider(value: lengthBinding, in: 8...64, step: 1) + .tint(Color.gpAccent) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .accessibilityIdentifier("slider.length") + } + + GhostSection(titre: "Caractères") { + bascule("Minuscules", "a-z", \.lowercase) + GhostDivider() + bascule("Majuscules", "A-Z", \.uppercase) + GhostDivider() + bascule("Chiffres", "0-9", \.digits) + GhostDivider() + bascule("Symboles", "!@#$…", \.symbols) + } + } + .navigationTitle("Générer") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { dismiss() } + .foregroundStyle(Color.gpMuted) + } + ToolbarItem(placement: .confirmationAction) { + Button("Utiliser") { + onUse(password) + dismiss() + } + .fontWeight(.semibold) + .foregroundStyle(Color.gpAccentText) + .disabled(password.isEmpty) + .accessibilityIdentifier("button.usePassword") + } + } + .onAppear(perform: regenerate) + } + .tint(Color.gpAccentText) + } + + private func bascule( + _ titre: LocalizedStringKey, _ exemple: String, + _ path: WritableKeyPath + ) -> some View { + Toggle(isOn: binding(path)) { + HStack(spacing: 8) { + Text(titre).foregroundStyle(Color.gpInk) + // « a-z », « 0-9 » : des exemples de caractères, pas de la prose. + Text(verbatim: exemple) + .font(.caption.monospaced()) + .foregroundStyle(Color.gpMuted) + } + } + .tint(Color.gpAccent) + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + /// Une jauge indicative, fondée sur la seule chose qu'on puisse mesurer ici : le + /// nombre de combinaisons possibles. Elle ne dit rien de la fuite d'un mot de passe, + /// seulement de ce qu'il en coûterait de le deviner. + private var force: some View { + let bits = entropie + let (libelle, couleur): (LocalizedStringKey, Color) = + bits >= 100 ? ("Excellent", .gpSuccess) + : bits >= 72 ? ("Solide", .gpSuccess) + : bits >= 50 ? ("Correct", .gpAccentText) + : ("Faible", .gpDanger) + return HStack(spacing: 8) { + Text(libelle).font(.caption.weight(.semibold)).foregroundStyle(couleur) + Text("≈ \(Int(bits)) bits").font(.caption2).foregroundStyle(Color.gpMuted) + } + } + + private var entropie: Double { + var taille = 0 + if options.lowercase { taille += 26 } + if options.uppercase { taille += 26 } + if options.digits { taille += 10 } + if options.symbols { taille += 24 } + if taille == 0 { taille = 26 } + return Double(options.length) * log2(Double(taille)) + } + + private func regenerate() { + password = PasswordGenerator.generate(options) + } + + /// Toute modification d'option régénère : voir l'effet du réglage évite de se + /// demander s'il a été pris en compte. + private func binding(_ path: WritableKeyPath) -> Binding { + Binding( + get: { options[keyPath: path] }, + set: { options[keyPath: path] = $0; regenerate() }) + } + + private var lengthBinding: Binding { + Binding( + get: { Double(options.length) }, + set: { options.length = Int($0); regenerate() }) + } +} diff --git a/apps/ios/Ghostpass/Views/RecoverAccountView.swift b/apps/ios/Ghostpass/Views/RecoverAccountView.swift new file mode 100644 index 00000000..355ccb86 --- /dev/null +++ b/apps/ios/Ghostpass/Views/RecoverAccountView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +/// Réinitialiser le mot de passe maître avec la clé de récupération. +/// +/// Rien ne s'ouvre ici : le serveur invalide toutes les sessions, et c'est voulu — si +/// quelqu'un vient de réinitialiser le mot de passe, celles restées ouvertes ailleurs +/// n'ont plus lieu d'être. L'écran renvoie donc à la connexion, avec le nouveau. +struct RecoverAccountView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + /// L'adresse et le compte déjà saisis sur l'écran de déverrouillage : les redemander + /// ne servirait qu'à les faire retaper. + let serveur: String + let email: String + /// Appelé après une réinitialisation réussie, pour prévenir l'écran d'accueil. + var onReussite: () -> Void + + @State private var cle = "" + @State private var nouveau = "" + + private var pretARepondre: Bool { + !cle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && nouveau.count >= 8 + && !store.isBusy + } + + var body: some View { + NavigationStack { + GhostScreen { + GhostSection( + note: "Le nouveau mot de passe rechiffre la clé du coffre. Son contenu reste intact : rien n'est perdu, rien n'est déchiffré côté serveur." + ) { + TextField("", text: .constant(email), prompt: invite("Adresse e-mail")) + .disabled(true) + .foregroundStyle(Color.gpMuted) + .padding(14) + GhostDivider() + TextField("", text: $cle, prompt: invite("Clé de récupération"), axis: .vertical) + .lineLimit(2...4) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.recoveryKey") + GhostDivider() + SecureField("", text: $nouveau, prompt: invite("Nouveau mot de passe maître")) + .foregroundStyle(Color.gpInk) + .padding(14) + .accessibilityIdentifier("field.newMaster") + } + + if !nouveau.isEmpty && nouveau.count < 8 { + Text("Huit caractères au minimum.") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + } + + if let message = store.errorMessage { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + + Button("Réinitialiser") { + Task { + let fait = await store.recoverAccount( + server: serveur, email: email, recoveryKey: cle, newPassword: nouveau) + if fait { + onReussite() + dismiss() + } + } + } + .buttonStyle(PrimaryButtonStyle(enabled: pretARepondre)) + .disabled(!pretARepondre) + .accessibilityIdentifier("button.submitRecovery") + } + .navigationTitle("Mot de passe oublié") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { + store.errorMessage = nil + dismiss() + } + .foregroundStyle(Color.gpMuted) + .accessibilityIdentifier("button.cancelRecovery") + } + } + } + .tint(Color.gpAccentText) + } + + private func invite(_ texte: LocalizedStringKey) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) + } +} diff --git a/apps/ios/Ghostpass/Views/RecoveryKeyView.swift b/apps/ios/Ghostpass/Views/RecoveryKeyView.swift new file mode 100644 index 00000000..bed6976c --- /dev/null +++ b/apps/ios/Ghostpass/Views/RecoveryKeyView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// La clé de récupération : le seul moyen de rouvrir un coffre dont on a oublié le mot de +/// passe maître. +/// +/// Elle ne s'affiche qu'une fois. Le serveur n'en reçoit qu'une preuve re-hachée, et +/// l'application ne la garde nulle part — c'est ce qui fait que personne d'autre ne peut +/// s'en servir, et c'est aussi ce qui rend cet écran irremplaçable. D'où l'insistance : +/// tant que la clé est à l'écran, on ne propose pas de partir sans l'avoir notée. +struct RecoveryKeyView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + @State private var cle: String? + @State private var copiee = false + + var body: some View { + NavigationStack { + GhostScreen { + if let cle { + resultat(cle) + } else { + presentation + } + } + .navigationTitle("Clé de récupération") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(cle == nil ? "Annuler" : "Terminé") { dismiss() } + .foregroundStyle(cle == nil ? Color.gpMuted : Color.gpAccentText) + .fontWeight(cle == nil ? .regular : .semibold) + .accessibilityIdentifier("button.closeRecovery") + } + } + } + .tint(Color.gpAccentText) + } + + private var presentation: some View { + VStack(alignment: .leading, spacing: 18) { + Image(systemName: "key.horizontal") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 76, height: 76) + .background(Color.gpAccent.opacity(0.14), in: Circle()) + .frame(maxWidth: .infinity, alignment: .center) + + Text( + "Un coffre chiffré de bout en bout ne se rouvre pas sans son mot de passe maître : personne, pas même le serveur, ne peut le retrouver à votre place." + ) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + + Text( + "La clé de récupération est la seule issue. Notez-la et rangez-la ailleurs que dans ce coffre." + ) + .foregroundStyle(Color.gpMuted) + .fixedSize(horizontal: false, vertical: true) + + Button("Créer une clé de récupération") { + Task { cle = await store.createRecoveryKit() } + } + .buttonStyle(PrimaryButtonStyle(enabled: !store.isBusy)) + .disabled(store.isBusy) + .accessibilityIdentifier("button.createRecovery") + + if let message = store.errorMessage { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + } + .glassCard() + } + + private func resultat(_ cle: String) -> some View { + VStack(alignment: .leading, spacing: 16) { + GhostSection( + titre: "Votre clé de récupération", + note: "Elle ne sera plus affichée. Sans elle, un mot de passe maître oublié rend le coffre définitivement illisible." + ) { + Text(verbatim: cle) + .font(.system(.body, design: .monospaced, weight: .medium)) + .foregroundStyle(Color.gpInk) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(14) + .accessibilityIdentifier("text.recoveryKey") + } + + Button { + Clipboard.copy(cle) + copiee = true + } label: { + Label( + copiee ? "Copiée" : "Copier la clé", + systemImage: copiee ? "checkmark" : "doc.on.doc") + } + .buttonStyle(SecondaryButtonStyle()) + .accessibilityIdentifier("button.copyRecovery") + } + .animation(.snappy, value: copiee) + } +} diff --git a/apps/ios/Ghostpass/Views/SettingsView.swift b/apps/ios/Ghostpass/Views/SettingsView.swift new file mode 100644 index 00000000..ecd75c36 --- /dev/null +++ b/apps/ios/Ghostpass/Views/SettingsView.swift @@ -0,0 +1,144 @@ +import SwiftUI + +/// Apparence et langue. Deux réglages, présentés comme des choix et non comme des +/// options cachées : celui qui veut son coffre en clair, ou en anglais sur un téléphone +/// français, n'a pas à modifier les réglages de tout l'appareil pour l'obtenir. +struct SettingsView: View { + @EnvironmentObject private var prefs: Preferences + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + GhostScreen { + GhostSection( + titre: "Apparence", + note: "« Système » suit le réglage de l'appareil, y compris son passage automatique à la nuit." + ) { + // Trois vignettes plutôt qu'une liste déroulante : le choix est visuel, + // et l'aperçu vaut mieux qu'un nom. + HStack(spacing: 10) { + ForEach(Apparence.allCases) { cas in + vignette(cas) + } + } + .padding(12) + } + + GhostSection( + titre: "Verrouillage", + note: "Pendant ce délai, le coffre reste ouvert en mémoire — jamais sur le disque — et son contenu est masqué dans le sélecteur d'applications." + ) { + ForEach(Array(Verrouillage.allCases.enumerated()), id: \.element.id) { + index, cas in + if index > 0 { GhostDivider() } + Button { + prefs.verrouillage = cas + } label: { + HStack(spacing: 12) { + Text(cas.libelle).foregroundStyle(Color.gpInk) + Spacer(minLength: 8) + if prefs.verrouillage == cas { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.gpAccentText) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("row.lock.\(cas.rawValue)") + } + } + + GhostSection( + titre: "Icônes des sites", + note: "Le coffre est chiffré de bout en bout : le serveur n'en connaît pas le contenu. Réclamer une icône, en revanche, lui nomme un domaine. Aucun tiers n'est sollicité." + ) { + Toggle(isOn: $prefs.afficheLesIcones) { + Text("Afficher les logos").foregroundStyle(Color.gpInk) + } + .tint(Color.gpAccent) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .accessibilityIdentifier("toggle.icons") + } + + GhostSection( + titre: "Langue", + note: "Le changement s'applique aussitôt, sans redémarrer l'application." + ) { + ForEach(Array(Langue.allCases.enumerated()), id: \.element.id) { index, cas in + if index > 0 { GhostDivider() } + Button { + prefs.langue = cas + } label: { + HStack(spacing: 12) { + Group { + if let nom = cas.nomNatif { + // Le nom d'une langue s'écrit dans cette langue. + Text(verbatim: nom) + } else { + Text("Système") + } + } + .foregroundStyle(Color.gpInk) + Spacer(minLength: 8) + if prefs.langue == cas { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.gpAccentText) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("row.langue.\(cas.rawValue)") + } + } + } + .navigationTitle("Réglages") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Terminé") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.doneSettings") + } + } + } + .tint(Color.gpAccentText) + } + + private func vignette(_ cas: Apparence) -> some View { + let choisi = prefs.apparence == cas + return Button { + prefs.apparence = cas + } label: { + VStack(spacing: 8) { + Image(systemName: cas.icone) + .font(.system(size: 19, weight: .medium)) + Text(cas.libelle) + .font(.caption.weight(.medium)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + choisi ? Color.gpAccent.opacity(0.18) : Color.gpSurface2, + in: RoundedRectangle(cornerRadius: GP.radius) + ) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder( + choisi ? Color.gpAccent : Color.gpBorder, + lineWidth: choisi ? 1.5 : 1)) + .foregroundStyle(choisi ? Color.gpAccentText : Color.gpMuted) + } + .buttonStyle(.plain) + .accessibilityIdentifier("tile.apparence.\(cas.rawValue)") + } +} diff --git a/apps/ios/Ghostpass/Views/TrashView.swift b/apps/ios/Ghostpass/Views/TrashView.swift new file mode 100644 index 00000000..ac162a75 --- /dev/null +++ b/apps/ios/Ghostpass/Views/TrashView.swift @@ -0,0 +1,133 @@ +import SwiftUI + +/// La corbeille : ce que le serveur garde après une suppression. +/// +/// Elle se consulte, elle ne se garde pas en mémoire. Un item supprimé n'a rien à faire +/// dans le coffre déverrouillé, et le recharger à l'ouverture coûte moins cher que de le +/// tenir à jour pour un écran qu'on visite rarement. +struct TrashView: View { + @EnvironmentObject private var store: VaultStore + @Environment(\.dismiss) private var dismiss + + @State private var entries: [VaultEntry] = [] + @State private var chargement = true + /// L'item dont on s'apprête à se débarrasser pour de bon. + @State private var aPurger: VaultEntry? + + var body: some View { + NavigationStack { + ZStack { + GhostBackground() + + List { + ForEach(entries) { entry in + ligne(entry) + .listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .swipeActions(edge: .leading) { + Button("Restaurer") { + Task { + await store.restore(entry) + await recharger() + } + } + .tint(Color.gpAccent) + } + .swipeActions { + Button("Supprimer", role: .destructive) { aPurger = entry } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .overlay { + if chargement { + ProgressView().tint(Color.gpAccentText) + } else if entries.isEmpty { + ContentUnavailableView { + Label("Corbeille vide", systemImage: "trash") + } description: { + Text("Les éléments supprimés atterrissent ici.") + } + .foregroundStyle(Color.gpMuted) + } + } + } + .navigationTitle("Corbeille") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fermer") { dismiss() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.closeTrash") + } + } + // Une suppression définitive ne se rattrape pas : elle se confirme. + .alert( + "Supprimer définitivement ?", + isPresented: presentation($aPurger), + presenting: aPurger, + actions: { entry in + Button("Supprimer", role: .destructive) { + Task { + await store.purge(entry) + aPurger = nil + await recharger() + } + } + .accessibilityIdentifier("button.confirmPurge") + Button("Annuler", role: .cancel) { aPurger = nil } + }, + message: { entry in + Text("« \(entry.item.name) » sera perdu, sans possibilité de retour.") + }) + .task { await recharger() } + } + .tint(Color.gpAccentText) + } + + private func ligne(_ entry: VaultEntry) -> some View { + HStack(spacing: 14) { + Image(systemName: "trash") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color.gpMuted) + .frame(width: 34, height: 34) + .background(Color.gpSurface2, in: RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: entry.item.name) + .font(.system(.body, weight: .medium)) + .foregroundStyle(Color.gpInk) + if let login = entry.login, !login.username.isEmpty { + Text(verbatim: login.username).font(.caption) + .foregroundStyle(Color.gpMuted) + } + } + Spacer(minLength: 8) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(Color.gpSurface.opacity(0.7), in: RoundedRectangle(cornerRadius: GP.radius)) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder(Color.gpBorder, lineWidth: 1)) + } + + private func recharger() async { + chargement = true + entries = await store.loadTrash() + chargement = false + } + + /// Une liaison qui écrit en retour. `.constant` refuse la fermeture que le système + /// lui demande d'enregistrer : SwiftUI croit alors l'alerte encore présentée, et la + /// suivante ne s'affiche plus — le défaut qui a rendu la proposition biométrique + /// intapable, en plus discret ici puisqu'il n'y a qu'une alerte sur cet écran. + private func presentation(_ valeur: Binding) -> Binding { + Binding( + get: { valeur.wrappedValue != nil }, + set: { presente in + if !presente { valeur.wrappedValue = nil } + }) + } +} diff --git a/apps/ios/Ghostpass/Views/UnlockView.swift b/apps/ios/Ghostpass/Views/UnlockView.swift new file mode 100644 index 00000000..54e3d790 --- /dev/null +++ b/apps/ios/Ghostpass/Views/UnlockView.swift @@ -0,0 +1,261 @@ +import SwiftUI + +/// Déverrouillage : la carte de verre posée sur la nuit, comme l'écran d'entrée des +/// autres produits de la suite. Deux états — une session enregistrée qu'on rouvre d'un +/// mot de passe, ou une connexion complète à décliner. +struct UnlockView: View { + @EnvironmentObject private var store: VaultStore + + @State private var server = "" + @State private var email = "" + @State private var password = "" + @State private var totpCode = "" + @State private var needsTotp = false + /// Une session enregistrée se rouvre avec le seul mot de passe maître, sans réseau. + @State private var useSavedSession = false + /// L'écran de réinitialisation, ouvert depuis « Mot de passe maître oublié ? ». + @State private var recuperation = false + /// Affiché après une réinitialisation réussie : sans un mot, on retomberait sur le + /// formulaire de connexion sans savoir si quelque chose s'est passé. + @State private var messageDeReinitialisation = false + + var body: some View { + ZStack { + GhostBackground() + + ScrollView { + VStack(spacing: 24) { + enseigne + carte + } + .frame(maxWidth: 420) + .padding(.horizontal, 20) + .padding(.vertical, 40) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + // Le clavier couvre le bas de la carte — le lien de récupération, notamment. + // Un tap dans le vide ne le referme pas en SwiftUI ; faire défiler, si. + .scrollDismissesKeyboard(.immediately) + } + .sheet(isPresented: $recuperation) { + RecoverAccountView(serveur: serveurEffectif, email: email) { + messageDeReinitialisation = true + password = "" + useSavedSession = false + } + .environmentObject(store) + } + .onAppear { + if store.hasSavedSession { + useSavedSession = true + // Une session enregistrée et la biométrie configurée : on la propose + // d'emblée, c'est le geste attendu à l'ouverture de l'app. + if store.canUnlockWithBiometrics { + Task { await store.unlockWithBiometrics() } + } + } else { + server = store.savedServer + email = store.savedEmail + } + } + } + + private var enseigne: some View { + VStack(spacing: 10) { + Image("LogoMark") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + // La marque rayonne : c'est le seul néon de cet écran, et c'est ce qui + // rattache GhostPass au reste de la suite. + .neon() + Text("GhostPass") + .font(.system(.title, design: .rounded, weight: .bold)) + .foregroundStyle(Color.gpInk) + Group { + // Deux `Text` plutôt qu'un ternaire : les deux branches sont des clefs de + // traduction, et un ternaire les ramènerait à de simples chaînes. + if useSavedSession { + Text("Coffre enregistré sur cet appareil") + } else { + Text("Coffre chiffré de bout en bout") + } + } + .font(.footnote) + .foregroundStyle(Color.gpMuted) + } + } + + private var carte: some View { + VStack(alignment: .leading, spacing: 18) { + if useSavedSession { + champ("Compte") { + Text(verbatim: store.savedEmail) + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .ghostField() + } + champ("Mot de passe maître") { + SecureField("", text: $password, prompt: invite("Votre mot de passe")) + .ghostField() + .accessibilityIdentifier("field.master") + } + } else { + champ("Serveur") { + TextField("", text: $server, prompt: invite("https://ghostpass.stackops.ch")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .ghostField() + .accessibilityIdentifier("field.server") + } + champ("Adresse e-mail") { + TextField("", text: $email, prompt: invite("vous@exemple.ch")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .ghostField() + .accessibilityIdentifier("field.email") + } + champ("Mot de passe maître") { + SecureField("", text: $password, prompt: invite("Votre mot de passe")) + .ghostField() + .accessibilityIdentifier("field.master") + } + if needsTotp { + champ("Code à six chiffres") { + TextField("", text: $totpCode, prompt: invite("123456")) + .keyboardType(.numberPad) + .ghostField() + .accessibilityIdentifier("field.totp") + } + } + } + + if messageDeReinitialisation { + Label { + Text("Mot de passe réinitialisé. Connectez-vous avec le nouveau.") + } icon: { + Image(systemName: "checkmark.circle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpSuccess) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("text.recovered") + } + + if let message = store.errorMessage { + // Le message arrive déjà traduit : les services passent par `tr(…)`. + // Le réafficher comme une clef le ferait chercher une seconde fois. + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(spacing: 10) { + Button(action: submit) { + if store.isBusy { + ProgressView().tint(Color.gpOnAccent) + } else if useSavedSession { + Text("Déverrouiller") + } else { + Text("Se connecter") + } + } + .buttonStyle(PrimaryButtonStyle(enabled: !store.isBusy && !password.isEmpty)) + .disabled(store.isBusy || password.isEmpty) + .accessibilityIdentifier("button.submit") + + if useSavedSession && store.canUnlockWithBiometrics { + Button("Déverrouiller avec \(store.biometryLabel)") { + Task { await store.unlockWithBiometrics() } + } + .buttonStyle(SecondaryButtonStyle()) + .disabled(store.isBusy) + .accessibilityIdentifier("button.biometric") + } + + // La récupération n'a de sens que sur une connexion complète : elle a + // besoin de l'adresse du serveur et du compte, et elle réinitialise pour + // de bon. On ne la propose donc pas derrière une session déjà enregistrée. + if !useSavedSession { + Button("Mot de passe maître oublié ?") { + store.errorMessage = nil + recuperation = true + } + .font(.footnote) + .foregroundStyle(Color.gpAccentText) + .disabled(email.isEmpty || store.isBusy) + .padding(.top, 2) + .accessibilityIdentifier("button.forgotPassword") + } + + if store.hasSavedSession { + Button { + useSavedSession.toggle() + password = "" + store.errorMessage = nil + } label: { + if useSavedSession { + Text("Utiliser un autre compte") + } else { + Text("Revenir au coffre enregistré") + } + } + .font(.footnote) + .foregroundStyle(Color.gpAccentText) + .padding(.top, 2) + .accessibilityIdentifier("button.switchAccount") + } + } + } + .glassCard() + } + + /// L'adresse saisie, ou celle de la session enregistrée si le champ est vide : la + /// récupération part du même serveur que la connexion. + private var serveurEffectif: String { + let saisi = server.trimmingCharacters(in: .whitespacesAndNewlines) + return saisi.isEmpty ? store.savedServer : saisi + } + + private func champ( + _ intitule: LocalizedStringKey, @ViewBuilder _ contenu: () -> Contenu + ) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(intitule).sectionLabel() + contenu() + } + } + + /// Un texte d'invite lisible : le gris par défaut de SwiftUI disparaît sur nos surfaces. + private func invite(_ texte: LocalizedStringKey) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) + } + + private func submit() { + Task { + if useSavedSession { + await store.unlockOffline(password: password) + } else { + await store.signIn( + server: server, email: email, password: password, + totpCode: needsTotp ? totpCode : nil) + // Le serveur ne réclame le second facteur qu'après validation du + // mot de passe : le champ n'apparaît donc qu'une fois utile. + if case .some(let message) = store.errorMessage, + message.contains("2FA") || message.contains("Second facteur") + { + needsTotp = true + } + } + if store.isUnlocked { password = "" } + } + } +} diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift new file mode 100644 index 00000000..f8b9dc56 --- /dev/null +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -0,0 +1,476 @@ +import SwiftUI + +/// Le coffre : la liste des identifiants, posée sur la nuit de la suite. +struct VaultListView: View { + @EnvironmentObject private var store: VaultStore + @State private var search = "" + /// Une seule feuille à la fois : deux modificateurs `.sheet` sur la même vue se + /// marchent dessus, et c'est la première déclarée qui cesse de s'ouvrir. + @State private var sheet: VaultSheet? + /// Dossier affiché ; `nil` pour tout le coffre. + @State private var folder: String? + /// Le chemin de navigation, tenu à la main : c'est ce qui permet à l'écran de santé + /// d'envoyer directement sur l'élément qu'il signale. + @State private var chemin: [VaultEntry] = [] + /// L'élément qu'une feuille demande d'ouvrir. On attend qu'elle soit refermée pour + /// pousser l'écran : présenter et empiler en même temps, et l'un des deux se perd. + @State private var aOuvrir: VaultEntry? + + private var visible: [VaultEntry] { + store.entries.filter { entry in + // Un dossier contient aussi ce que rangent ses sous-dossiers. + if let folder { + let range = entry.item.folder ?? "" + guard range == folder || range.hasPrefix(folder + "/") else { return false } + } + guard !search.isEmpty else { return true } + return entry.item.name.localizedCaseInsensitiveContains(search) + || (entry.login?.username.localizedCaseInsensitiveContains(search) ?? false) + || (entry.login?.uris.contains { $0.localizedCaseInsensitiveContains(search) } ?? false) + } + } + + var body: some View { + NavigationStack(path: $chemin) { + ZStack { + GhostBackground() + + List { + if store.isOffline { + bandeauHorsLigne + .listRowInsets(.init(top: 0, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + filtreDeDossier + .listRowInsets(.init(top: 0, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + // Les favoris en tête, et seulement quand on regarde le coffre + // entier : sous un filtre ou une recherche, les répéter reviendrait à + // montrer deux fois les mêmes lignes. + if !favoris.isEmpty { + Section { + ForEach(favoris) { entry in + ligne(entry) + } + } header: { + // L'intitulé s'affiche en capitales : c'est l'identifiant, et + // non le libellé, qui permet de le retrouver dans les tests. + Text("Favoris").sectionLabel().padding(.leading, 2) + .accessibilityIdentifier("header.favorites") + } + .listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + ForEach(visible) { entry in + ligne(entry) + .listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .overlay { + if store.entries.isEmpty { + coffreVide + } else if visible.isEmpty { + aucunResultat + } + } + } + .navigationDestination(for: VaultEntry.self) { entry in + // On repart de l'entrée telle qu'elle est dans le coffre, pas de la copie + // capturée à la navigation : sinon une seconde modification rouvrirait + // le formulaire avec le contenu d'avant la première. + ItemDetailView(entry: entry) { + sheet = .editItem(store.entries.first { $0.id == entry.id } ?? entry) + } + } + .searchable(text: $search, prompt: "Rechercher") + .refreshable { await store.refresh() } + .navigationTitle("Coffre") + .toolbarBackground(Color.gpBase.opacity(0.9), for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Verrouiller") { store.lock() } + .foregroundStyle(Color.gpAccentText) + .accessibilityIdentifier("button.lock") + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + if store.biometryAvailable { + if store.isBiometricEnabled { + Button("Désactiver \(store.biometryLabel)", role: .destructive) { + store.disableBiometrics() + } + .accessibilityIdentifier("button.biometricOff") + } else { + Button("Activer \(store.biometryLabel)") { sheet = .biometrics } + .accessibilityIdentifier("button.biometricOn") + } + } + Button("Santé du coffre", systemImage: "checkmark.shield") { + sheet = .health + } + .accessibilityIdentifier("button.health") + Button("Clé de récupération", systemImage: "key.horizontal") { + sheet = .recoveryKey + } + .accessibilityIdentifier("button.recoveryKey") + Button("Accès d'urgence", systemImage: "person.2.badge.key") { + sheet = .emergency + } + .accessibilityIdentifier("button.emergency") + Button("Coffres partagés", systemImage: "person.2") { + sheet = .organizations + } + .accessibilityIdentifier("button.organizations") + Button("Importer un CSV", systemImage: "square.and.arrow.down") { + sheet = .importCSV + } + .accessibilityIdentifier("button.import") + Button("Exporter le coffre", systemImage: "square.and.arrow.up") { + sheet = .exportCSV + } + .accessibilityIdentifier("button.export") + Button("Corbeille", systemImage: "trash") { sheet = .trash } + .accessibilityIdentifier("button.trash") + Button("Réglages", systemImage: "gearshape") { sheet = .settings } + .accessibilityIdentifier("button.preferences") + Button("Se déconnecter", role: .destructive) { + Task { await store.signOut() } + } + .accessibilityIdentifier("button.signOut") + } label: { + Image(systemName: "ellipsis.circle") + .foregroundStyle(Color.gpAccentText) + } + .accessibilityIdentifier("button.settings") + } + ToolbarItem(placement: .topBarTrailing) { + Button { + sheet = .newItem + } label: { + Image(systemName: "plus") + .foregroundStyle(Color.gpAccentText) + } + .accessibilityIdentifier("button.add") + } + } + .sheet(item: $sheet, onDismiss: ouvrirLElementDemande) { destination in + switch destination { + case .newItem: + ItemEditView(target: .new).environmentObject(store) + case .editItem(let entry): + ItemEditView(target: .existing(entry)).environmentObject(store) + case .biometrics: + BiometricSetupView().environmentObject(store) + case .trash: + TrashView().environmentObject(store) + case .folders: + FoldersView(selection: $folder).environmentObject(store) + case .settings: + SettingsView().environmentObject(Preferences.shared) + case .biometricOffer: + BiometricOfferView().environmentObject(store) + case .health: + HealthView { aOuvrir = $0 }.environmentObject(store) + case .recoveryKey: + RecoveryKeyView().environmentObject(store) + case .importCSV: + ImportView().environmentObject(store) + case .exportCSV: + ExportView().environmentObject(store) + case .emergency: + EmergencyView().environmentObject(store) + case .organizations: + OrganizationsView().environmentObject(store) + } + } + // Une seule alerte, et rien d'autre par-dessus : deux modificateurs `.alert` + // sur la même vue se marchent dessus exactement comme deux `.sheet`. La + // liaison écrit en retour — un `.constant` ignore la fermeture que le système + // lui demande d'enregistrer, et SwiftUI croit ensuite l'alerte encore là. + .alert( + "Erreur", isPresented: erreurAffichee, + actions: { Button("OK") { store.errorMessage = nil } }, + message: { Text(verbatim: store.errorMessage ?? "") }) + // La proposition d'activer la biométrie arrive avec le coffre, une fois le + // déchiffrement terminé. + .onAppear { proposerLaBiometrieSiBesoin() } + .onChange(of: store.offersBiometricEnrollment) { _, _ in + proposerLaBiometrieSiBesoin() + } + } + .tint(Color.gpAccentText) + } + + /// Une liaison qui écrit en retour : fermer l'alerte efface le message qu'elle + /// portait, plutôt que de laisser SwiftUI la croire encore présentée. + private var erreurAffichee: Binding { + Binding( + get: { store.errorMessage != nil }, + set: { presente in + if !presente { store.errorMessage = nil } + }) + } + + /// Les favoris, tant qu'aucun filtre ne restreint déjà la liste. + private var favoris: [VaultEntry] { + guard folder == nil, search.isEmpty else { return [] } + return store.favoriteEntries + } + + /// Une ligne du coffre. Un lien en bonne et due forme : masquer le `NavigationLink` + /// sous une opacité nulle le rendrait inatteignable, au clavier comme au doigt. Le + /// chevron du système fait donc l'affaire. + private func ligne(_ entry: VaultEntry) -> some View { + NavigationLink(value: entry) { + VaultRow(entry: entry, favori: store.isFavorite(entry)) + } + .swipeActions(edge: .leading) { + Button(store.isFavorite(entry) ? "Retirer des favoris" : "Mettre en favori") { + Task { await store.toggleFavorite(entry) } + } + .tint(Color.gpAccent) + } + .swipeActions { + Button("Supprimer", role: .destructive) { + Task { await store.delete(entry) } + } + } + // Sur un téléphone, la raison d'ouvrir un identifiant est le plus souvent de le + // recopier. L'appui long l'offre depuis la liste, sans traverser deux écrans. + .contextMenu { + if let login = entry.login { + if !login.password.isEmpty { + Button("Copier le mot de passe", systemImage: "key") { + Clipboard.copy(login.password) + } + } + if !login.username.isEmpty { + Button("Copier le nom d'utilisateur", systemImage: "person") { + Clipboard.copy(login.username) + } + } + if let config = login.totp.flatMap(Totp.parse), + let otp = Totp.code(for: config) + { + Button("Copier le code", systemImage: "number") { + Clipboard.copy(otp.code) + } + } + } + Button( + store.isFavorite(entry) ? "Retirer des favoris" : "Mettre en favori", + systemImage: store.isFavorite(entry) ? "star.slash" : "star" + ) { + Task { await store.toggleFavorite(entry) } + } + Button("Supprimer", systemImage: "trash", role: .destructive) { + Task { await store.delete(entry) } + } + } + } + + private func ouvrirLElementDemande() { + guard let entry = aOuvrir else { return } + aOuvrir = nil + chemin = [entry] + } + + /// N'ouvre la feuille que si rien d'autre n'est déjà présenté : deux feuilles qui se + /// succèdent trop vite s'annulent, et l'utilisateur se retrouve devant rien. + private func proposerLaBiometrieSiBesoin() { + guard store.offersBiometricEnrollment, sheet == nil else { return } + sheet = .biometricOffer + } + + /// Le filtre courant, toujours visible : un dossier sélectionné qu'on aurait oublié + /// donnerait l'impression d'un coffre amputé. + private var filtreDeDossier: some View { + Button { + sheet = .folders + } label: { + HStack(spacing: 8) { + Image(systemName: folder == nil ? "tray.full" : "folder.fill") + .font(.system(size: 13, weight: .medium)) + Group { + if let folder { + Text(verbatim: folder) + } else { + Text("Tous les éléments") + } + } + .font(.subheadline.weight(.medium)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + Spacer(minLength: 4) + Text("\(visible.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(Color.gpMuted) + } + .foregroundStyle(folder == nil ? Color.gpMuted : Color.gpAccentText) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + folder == nil ? Color.gpSurface2 : Color.gpAccent.opacity(0.16), + in: RoundedRectangle(cornerRadius: GP.radius) + ) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder(folder == nil ? Color.gpBorder : Color.gpAccent, lineWidth: 1)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("button.folderFilter") + } + + private var bandeauHorsLigne: some View { + Label("Hors ligne — coffre affiché depuis cet appareil", systemImage: "wifi.slash") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.gpSurface2, in: RoundedRectangle(cornerRadius: GP.radius)) + .accessibilityIdentifier("banner.offline") + } + + private var coffreVide: some View { + ContentUnavailableView { + Label("Coffre vide", systemImage: "lock") + } description: { + Text("Ajoutez un identifiant avec le bouton +.") + } + .foregroundStyle(Color.gpMuted) + } + + private var aucunResultat: some View { + ContentUnavailableView.search(text: search) + .foregroundStyle(Color.gpMuted) + } +} + +/// Ce que la liste peut présenter par-dessus elle. +enum VaultSheet: Identifiable { + case newItem + case editItem(VaultEntry) + case biometrics + case trash + case folders + case settings + case biometricOffer + case health + case recoveryKey + case importCSV + case exportCSV + case emergency + case organizations + + var id: String { + switch self { + case .newItem: return "new" + case .editItem(let entry): return entry.id + case .biometrics: return "biometrics" + case .trash: return "trash" + case .folders: return "folders" + case .settings: return "settings" + case .biometricOffer: return "biometricOffer" + case .health: return "health" + case .recoveryKey: return "recoveryKey" + case .emergency: return "emergency" + case .organizations: return "organizations" + case .importCSV: return "import" + case .exportCSV: return "export" + } + } +} + +/// Cible d'édition : soit un item existant, soit une création. +enum EditTarget: Identifiable { + case new + case existing(VaultEntry) + + var id: String { + switch self { + case .new: return "new" + case .existing(let entry): return entry.id + } + } +} + +/// Une ligne du coffre : une pastille de type, le nom, ce qui aide à le reconnaître. +private struct VaultRow: View { + let entry: VaultEntry + var favori = false + + var body: some View { + HStack(spacing: 14) { + // Un identifiant se reconnaît à son logo bien avant son nom ; une note ou une + // carte n'en a pas, et garde son pictogramme. + if case .login(let login) = entry.item.data { + SiteIcon(nom: entry.item.name, adresse: login.uris.first) + } else { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gpAccent.opacity(0.16)) + Image(systemName: icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color.gpAccentText) + } + .frame(width: 38, height: 38) + } + + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: entry.item.name) + .font(.system(.body, weight: .medium)) + .foregroundStyle(Color.gpInk) + .lineLimit(1) + if let subtitle, !subtitle.isEmpty { + Text(verbatim: subtitle) + .font(.caption) + .foregroundStyle(Color.gpMuted) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + if favori { + Image(systemName: "star.fill") + .font(.system(size: 12)) + .foregroundStyle(Color.gpAccentText) + .accessibilityHidden(true) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(Color.gpSurface.opacity(0.7), in: RoundedRectangle(cornerRadius: GP.radius)) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder(Color.gpBorder, lineWidth: 1)) + } + + private var icon: String { + switch entry.item.data { + case .login: return "person.badge.key" + case .secureNote: return "note.text" + case .card: return "creditcard" + } + } + + private var subtitle: String? { + switch entry.item.data { + case .login(let login): return login.username + case .secureNote: return "Note sécurisée" + case .card(let card): return card.cardholder + } + } +} diff --git a/apps/ios/GhostpassAutoFill/AutoFillStore.swift b/apps/ios/GhostpassAutoFill/AutoFillStore.swift new file mode 100644 index 00000000..0596194e --- /dev/null +++ b/apps/ios/GhostpassAutoFill/AutoFillStore.swift @@ -0,0 +1,109 @@ +import Foundation + +/// État de l'extension de remplissage. +/// +/// Elle ne parle pas au serveur : tout vient du conteneur partagé, déposé par +/// l'application. Un remplissage doit aboutir en quelques secondes, dans un contexte où +/// le réseau peut manquer — et l'extension n'a de toute façon pas de session à elle. +@MainActor +final class AutoFillStore: ObservableObject { + @Published private(set) var entries: [VaultEntry] = [] + @Published private(set) var isUnlocked = false + @Published private(set) var isBusy = false + @Published var errorMessage: String? + + /// Domaines du champ qui réclame un identifiant, pour remonter les entrées qui + /// correspondent. + var domains: [String] = [] + /// Identifiant précis attendu, quand iOS en désigne un. + var requested: String? + + var onPick: ((String, String) -> Void)? + var onCancel: (() -> Void)? + + var account: String { SharedStore.load()?.email ?? "" } + var hasSession: Bool { SharedStore.load() != nil } + var canUseBiometrics: Bool { + Biometrics.isAvailable && Keychain.get(Keychain.Key.biometricsEnabled) == "1" + } + var biometryLabel: String { Biometrics.label } + + /// Les entrées dont une adresse correspond au domaine demandé, d'abord ; le reste + /// ensuite, car un identifiant peut servir sur un domaine que le coffre ignore. + var suggested: [VaultEntry] { entries.filter { matches($0) } } + var others: [VaultEntry] { entries.filter { !matches($0) } } + + private func matches(_ entry: VaultEntry) -> Bool { + SiteMatching.matches(entry.item, domains: domains) + } + + // ─── Déverrouillage ─── + + func unlock(password: String) async { + guard let session = SharedStore.load() else { + errorMessage = tr("Ouvrez GhostPass une fois pour activer le remplissage.") + return + } + isBusy = true + defer { isBusy = false } + do { + let account = try Account.unlock( + password: password, email: session.email, kdfParamsJson: session.kdfParams, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey) + load(with: account) + } catch { + errorMessage = tr("Mot de passe maître incorrect.") + } + } + + func unlockWithBiometrics() async { + guard canUseBiometrics else { return } + isBusy = true + let prompt = "Remplir depuis votre coffre GhostPass" + let password = await Task.detached { + Keychain.getBiometric(Keychain.Key.masterPassword, prompt: prompt) + }.value + isBusy = false + guard let password else { + errorMessage = tr("\(Biometrics.label) n'a pas permis d'ouvrir le coffre.") + return + } + await unlock(password: password) + } + + /// Déchiffre la copie locale. Pas d'appel réseau : le remplissage doit aboutir même + /// dans un ascenseur, et l'extension n'a pas de session à elle. + private func load(with account: Account) { + let dtos = VaultCache.load() ?? [] + entries = dtos.compactMap { dto in + guard let item = try? VaultStore.decrypt(dto, with: account), + !VaultStore.isRegistry(item), item.data.isLogin + else { return nil } + return VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt) + } + .sorted { $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending } + isUnlocked = true + errorMessage = entries.isEmpty ? "Aucun identifiant dans la copie locale du coffre." : nil + + // iOS peut désigner l'entrée attendue : la fournir sans rien demander de plus. + if let requested, let entry = entries.first(where: { $0.id == requested }) { + pick(entry) + } + } + + func pick(_ entry: VaultEntry) { + guard let login = entry.login else { return } + onPick?(login.username, login.password) + } + + func cancel() { onCancel?() } +} + +extension ItemData { + /// Seuls les identifiants ont un nom d'utilisateur et un mot de passe à remplir. + var isLogin: Bool { + if case .login = self { return true } + return false + } +} diff --git a/apps/ios/GhostpassAutoFill/AutoFillView.swift b/apps/ios/GhostpassAutoFill/AutoFillView.swift new file mode 100644 index 00000000..14a90418 --- /dev/null +++ b/apps/ios/GhostpassAutoFill/AutoFillView.swift @@ -0,0 +1,184 @@ +import SwiftUI + +/// L'écran que voit l'utilisateur quand il demande un remplissage : d'abord le +/// déverrouillage, puis la liste — les entrées du site en cours en tête. +struct AutoFillView: View { + @EnvironmentObject private var store: AutoFillStore + @State private var password = "" + + var body: some View { + NavigationStack { + ZStack { + GhostBackground() + Group { + if store.isUnlocked { + liste + } else { + deverrouillage + } + } + } + .navigationTitle("GhostPass") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { store.cancel() } + .foregroundStyle(Color.gpMuted) + .accessibilityIdentifier("button.cancel") + } + } + } + .tint(Color.gpAccentText) + } + + private var deverrouillage: some View { + ScrollView { + VStack(spacing: 18) { + if store.hasSession { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 7) { + Text("Compte").sectionLabel() + Text(verbatim: store.account) + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .ghostField() + } + VStack(alignment: .leading, spacing: 7) { + Text("Mot de passe maître").sectionLabel() + SecureField("", text: $password, prompt: invite("Votre mot de passe")) + .ghostField() + .accessibilityIdentifier("field.master") + } + + if let message = store.errorMessage { + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(spacing: 10) { + Button("Déverrouiller") { + Task { + await store.unlock(password: password) + password = "" + } + } + .buttonStyle(PrimaryButtonStyle(enabled: !store.isBusy && !password.isEmpty)) + .disabled(store.isBusy || password.isEmpty) + .accessibilityIdentifier("button.submit") + + if store.canUseBiometrics { + Button("Déverrouiller avec \(store.biometryLabel)") { + Task { await store.unlockWithBiometrics() } + } + .buttonStyle(SecondaryButtonStyle()) + .disabled(store.isBusy) + .accessibilityIdentifier("button.biometric") + } + } + } + .glassCard() + } else { + // Sans session déposée, l'extension n'a rien à offrir : le dire vaut + // mieux que d'afficher un formulaire qui ne mènera nulle part. + ContentUnavailableView { + Label("Coffre indisponible", systemImage: "lock") + } description: { + Text("Ouvrez GhostPass et connectez-vous une fois.") + } + .foregroundStyle(Color.gpMuted) + } + } + .padding(20) + } + .onAppear { + // La biométrie d'emblée : c'est le geste attendu, et le remplissage doit + // aboutir en quelques secondes. + if store.canUseBiometrics { + Task { await store.unlockWithBiometrics() } + } + } + } + + private var liste: some View { + List { + if !store.suggested.isEmpty { + section(Text("Pour ce site"), store.suggested) + } + if !store.others.isEmpty { + section( + store.suggested.isEmpty ? Text("Coffre") : Text("Autres identifiants"), + store.others) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .overlay { + if store.entries.isEmpty { + ContentUnavailableView { + Label("Aucun identifiant", systemImage: "key") + } description: { + if let message = store.errorMessage { + Text(verbatim: message) + } else { + Text("Le coffre local ne contient rien.") + } + } + .foregroundStyle(Color.gpMuted) + } + } + } + + private func section(_ titre: Text, _ entries: [VaultEntry]) -> some View { + Section { + ForEach(entries) { entry in + ligne(entry) + .listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } header: { + titre.sectionLabel().padding(.leading, 2) + } + } + + private func ligne(_ entry: VaultEntry) -> some View { + Button { + store.pick(entry) + } label: { + HStack(spacing: 14) { + Image(systemName: "person.badge.key") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color.gpAccentText) + .frame(width: 34, height: 34) + .background(Color.gpAccent.opacity(0.16), in: RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: entry.item.name) + .font(.system(.body, weight: .medium)) + .foregroundStyle(Color.gpInk) + if let login = entry.login, !login.username.isEmpty { + Text(verbatim: login.username).font(.caption) + .foregroundStyle(Color.gpMuted) + } + } + Spacer(minLength: 8) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(Color.gpSurface.opacity(0.7), in: RoundedRectangle(cornerRadius: GP.radius)) + .overlay( + RoundedRectangle(cornerRadius: GP.radius) + .strokeBorder(Color.gpBorder, lineWidth: 1)) + } + .buttonStyle(.plain) + } + + private func invite(_ texte: LocalizedStringKey) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) + } +} diff --git a/apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift b/apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift new file mode 100644 index 00000000..21ec9dca --- /dev/null +++ b/apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift @@ -0,0 +1,71 @@ +import AuthenticationServices +import SwiftUI + +/// Point d'entrée du remplissage automatique. +/// +/// L'extension est un processus séparé, à durée de vie courte, lancé par iOS au moment où +/// un champ de saisie réclame un identifiant. Elle ne partage rien avec l'application +/// sinon ce qui a été déposé : le coffre chiffré dans le conteneur du groupe, la session +/// et les clés d'ouverture dans le trousseau. +final class CredentialProviderViewController: ASCredentialProviderViewController { + private let store = AutoFillStore() + + /// Appelé quand l'utilisateur choisit GhostPass dans le menu de remplissage. + /// `serviceIdentifiers` porte le ou les domaines du champ visé. + override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { + store.domains = serviceIdentifiers.map(\.identifier) + present() + } + + /// Appelé quand iOS espère un identifiant sans rien afficher. Le coffre étant + /// verrouillé, il faut du monde à l'écran : on le dit plutôt que d'échouer sans motif. + override func provideCredentialWithoutUserInteraction( + for credentialRequest: any ASCredentialRequest + ) { + extensionContext.cancelRequest( + withError: NSError( + domain: ASExtensionErrorDomain, + code: ASExtensionError.userInteractionRequired.rawValue)) + } + + /// Appelé après le refus précédent : cette fois l'interface a le droit de s'afficher. + override func prepareInterfaceToProvideCredential( + for credentialRequest: any ASCredentialRequest + ) { + if let identity = credentialRequest.credentialIdentity as? ASPasswordCredentialIdentity { + store.requested = identity.recordIdentifier + store.domains = [identity.serviceIdentifier.identifier] + } + present() + } + + private func present() { + store.onCancel = { [weak self] in + self?.extensionContext.cancelRequest( + withError: NSError( + domain: ASExtensionErrorDomain, + code: ASExtensionError.userCanceled.rawValue)) + } + store.onPick = { [weak self] identifiant, motDePasse in + self?.extensionContext.completeRequest( + withSelectedCredential: ASPasswordCredential( + user: identifiant, password: motDePasse)) + } + + // L'extension est un autre processus : elle relit les mêmes préférences pour ne + // pas surgir en plein Safari dans un thème ou une langue que l'utilisateur a + // justement écartés. + let prefs = Preferences.shared + let root = UIHostingController( + rootView: AutoFillView() + .environmentObject(store) + .environmentObject(prefs) + .preferredColorScheme(prefs.colorScheme) + .environment(\.locale, prefs.locale ?? Locale.autoupdatingCurrent)) + addChild(root) + root.view.frame = view.bounds + root.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(root.view) + root.didMove(toParent: self) + } +} diff --git a/apps/ios/GhostpassAutoFill/GhostpassAutoFill.entitlements b/apps/ios/GhostpassAutoFill/GhostpassAutoFill.entitlements new file mode 100644 index 00000000..3651f61a --- /dev/null +++ b/apps/ios/GhostpassAutoFill/GhostpassAutoFill.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.application-groups + + group.ch.stackops.ghostpass + + com.apple.developer.authentication-services.autofill-credential-provider + + + diff --git a/apps/ios/GhostpassAutoFill/Info.plist b/apps/ios/GhostpassAutoFill/Info.plist new file mode 100644 index 00000000..64692b3d --- /dev/null +++ b/apps/ios/GhostpassAutoFill/Info.plist @@ -0,0 +1,50 @@ + + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + GhostPass + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSFaceIDUsageDescription + Face ID déverrouille votre coffre pour remplir vos identifiants. + + + NSExtension + + NSExtensionPointIdentifier + com.apple.authentication-services-credential-provider-ui + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).CredentialProviderViewController + NSExtensionAttributes + + + ASCredentialProviderExtensionCapabilities + + ProvidesPasswords + + + + + + diff --git a/apps/ios/GhostpassAutoFill/InfoPlist.xcstrings b/apps/ios/GhostpassAutoFill/InfoPlist.xcstrings new file mode 100644 index 00000000..cd00b2d5 --- /dev/null +++ b/apps/ios/GhostpassAutoFill/InfoPlist.xcstrings @@ -0,0 +1,52 @@ +{ + "sourceLanguage" : "fr", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GhostPass" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GhostPass" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "new", + "value" : "GhostpassAutoFill" + } + } + } + }, + "NSFaceIDUsageDescription" : { + "comment" : "Privacy - Face ID Usage Description", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID unlocks your vault to fill in your credentials." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID déverrouille votre coffre pour remplir vos identifiants." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/apps/ios/README.md b/apps/ios/README.md new file mode 100644 index 00000000..68796077 --- /dev/null +++ b/apps/ios/README.md @@ -0,0 +1,190 @@ +# GhostPass iOS + +Application native. Le chiffrement n'est pas réimplémenté en Swift : elle appelle le +même cœur Rust que la web app, à travers le binding UniFFI `ghostpass-crypto-ffi`. +Les clés restent côté Rust ; Swift ne détient qu'un `Account` opaque et du chiffré. + +## Prérequis (macOS) + +```sh +xcode-select --install +brew install xcodegen +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +## Construire + +```sh +# 1. Le cœur en XCFramework + les bindings Swift (ajoute les cibles iOS au passage). +./tools/ios/build-xcframework.sh + +# 2. Le projet Xcode, régénéré depuis project.yml. +cd apps/ios && xcodegen generate + +# 3. Ouvrir, choisir un simulateur, lancer. +open Ghostpass.xcodeproj +``` + +Rien de ce que produisent ces trois commandes n'est versionné : `Ghostpass.xcodeproj`, +`Generated/`, `Headers/` et `GhostpassCrypto.xcframework/` sont ignorés. La description +du projet vit dans `project.yml`, qui se relit et se fusionne — contrairement à un +`.xcodeproj`, que deux personnes ne peuvent pas modifier sans conflit. + +## Tests + +```sh +./tools/ios/run-ios-tests.sh # tout : contrat + parcours de bout en bout +./tools/ios/run-ios-tests.sh --unit-only # contrat seulement, quelques secondes +``` + +**En intégration continue, ces tests dépendent d'un runner macOS** : Xcode et le +simulateur n'existent pas ailleurs. Le job vit dans `.gitea/workflows/ci.yml` et attend un +runner étiqueté `macos` — `tools/ci/README.md` explique comment en brancher un, ce que cela +engage, et ce qui distingue un poste prêté en dépannage d'une machine dédiée. Il est piloté par la variable de dépôt `MACOS_RUNNER` : tant qu'elle n'est pas +à `true`, le job est ignoré et **les tests se lancent à la main avant toute pull request +touchant à l'application**. + +Le script se suffit à lui-même : il construit l'XCFramework, crée un **simulateur +éphémère** (le trousseau d'un simulateur survit à la désinstallation — sans cela un run +hériterait des choix du précédent), démarre un serveur GhostPass sur SQLite jetable, +amorce un compte de test, puis nettoie tout. En cas d'échec, les rapports restent dans +`apps/ios/TestResults/` ; la CI les publie en artefact. + +| Cible | Ce qu'elle couvre | +|---|---| +| `Tests/` | **Contrat** : décodage des réponses du serveur, aller-retour d'un `VaultItem` à travers le vrai binding Rust, filtrage du registre `gp:folders`, refus d'activer la biométrie sur un mot de passe faux. Ni réseau ni interface, moins d'une seconde. | +| `UITests/` | **Parcours réel** : connexion, liste, création, modification, suppression, verrouillage, réouverture au seul mot de passe maître — et l'absence de ligne fantôme à chaque étape. Puis, serveur éteint, la réouverture hors ligne ; enfin un remplissage dans Safari, de la page de connexion au formulaire rempli. | + +Les deux régressions qui rendaient l'application inutilisable (paramètres KDF pris pour un +objet JSON, horodatages pris pour des chaînes) sont des divergences de contrat : elles sont +tenues par `Tests/`, qui les rattraperait en une seconde et sans simulateur. + +Elles le sont **aussi côté serveur**, par `apps/server/test/api-shape.test.ts`, qui vérifie +la forme des réponses — types, pas valeurs. C'est l'autre bout de la même couture, et le +seul qui tourne en intégration continue : ces tests-là n'ont besoin ni de Xcode ni de +simulateur. Un changement de serveur qui casserait le contrat serait donc arrêté avant +d'atteindre l'application, même sans runner macOS. + +**Limite connue** : le déverrouillage biométrique de bout en bout n'est pas exerçable de +façon fiable dans le simulateur — l'inscription simulée (`BiometricKit.enrollmentChanged`) +ne survit pas toujours au recyclage que fait `xcodebuild`. Quand l'application ne voit pas +de biométrie, `test02Biometrie` **s'ignore explicitement** plutôt que de passer au vert +sans rien avoir exercé. Ce qui reste couvert sans elle : le refus d'activer sur un mot de +passe faux, et le fait qu'aucune session n'autorise l'activation. + +## Structure + +| Chemin | Rôle | +|---|---| +| `Ghostpass/Model/` | Miroirs Swift du JSON que parle le cœur Rust (`VaultItem`, `ItemData`) | +| `Ghostpass/Services/APIClient.swift` | Routes du serveur : prelogin, login, CRUD du coffre | +| `Ghostpass/Services/Keychain.swift` | Jeton de session et blobs chiffrés, non exportables | +| `Ghostpass/Services/Biometrics.swift` | Disponibilité et nom de la biométrie (Face ID / Touch ID) | +| `Ghostpass/Services/VaultCache.swift` | Copie locale du coffre, déjà chiffrée : le coffre s'ouvre sans réseau | +| `Ghostpass/Services/PasswordGenerator.swift` | Générateur, transposition de `apps/web/src/lib/generator.ts` | +| `Ghostpass/Services/Totp.swift` | Codes TOTP (RFC 6238), transposition de `apps/web/src/lib/totp.ts` | +| `Ghostpass/Services/Clipboard.swift` | Copie de secrets, toujours avec expiration | +| `Ghostpass/Services/SharedStore.swift` | Ce que l'app dépose pour l'extension : session et blobs | +| `Ghostpass/Services/SiteMatching.swift` | Rapprochement site ↔ adresses d'un item | +| `Ghostpass/Services/CredentialIdentities.swift` | Inscription des identifiants auprès d'iOS | +| `GhostpassAutoFill/` | L'extension de remplissage : écran, état, point d'entrée | +| `Ghostpass/Assets.xcassets` | Icône : la marque de GhostPass, cadenas et dégradé d'accent | +| `Ghostpass/Services/VaultStore.swift` | État de l'app, passage de frontière chiffré/clair | +| `Ghostpass/Theme/` | Le système visuel de la suite : palette, surfaces, composants | +| `Ghostpass/Views/` | SwiftUI : déverrouillage, liste, détail, édition, corbeille, générateur | + +## Hors ligne + +Le coffre s'ouvre sans réseau. `VaultCache` conserve sur l'appareil les blobs **tels que +le serveur les stocke** — déjà chiffrés, inutilisables sans l'USK — sous +`.completeFileProtection`. Au déverrouillage, la copie locale s'affiche d'abord ; le +serveur reprend la main dès qu'il répond, et un bandeau signale l'écart. + +En revanche **écrire suppose le serveur** : il n'y a pas de file d'attente hors ligne. +Une création ou une suppression sans réseau est refusée avec un message explicite, plutôt +que d'être acceptée en apparence puis perdue. + +## Remplissage automatique + +L'extension `GhostpassAutoFill` fournit les identifiants aux autres applications et à +Safari. C'est un **processus séparé**, lancé par iOS au moment où un champ réclame un +identifiant : elle ne voit de GhostPass que ce qui a été déposé dans le groupe +d'applications `group.ch.stackops.ghostpass` — le coffre chiffré et les blobs +d'ouverture. Elle ne parle jamais au serveur : un remplissage doit aboutir en quelques +secondes, réseau ou pas. + +Elle réclame le mot de passe maître (ou Face ID), déchiffre la copie locale, et présente +d'abord les identifiants du site en cours. Le rapprochement entre le site et les adresses +d'un item vit dans `SiteMatching`, partagé avec l'application : une seule règle, pour que +les suggestions ne dépendent pas de l'endroit d'où l'on regarde. + +Pour l'activer sur un appareil : Réglages > Apps > Mots de passe > Remplissage +automatique > GhostPass. + +**Sur appareil réel**, deux points restent à régler et n'ont pas pu être vérifiés ici : +le groupe d'applications et l'entitlement AutoFill demandent une équipe de développement +(`DEVELOPMENT_TEAM`), et le trousseau n'est **pas** partagé entre l'application et son +extension — il faudra un `keychain-access-groups` commun pour que Face ID fonctionne +aussi côté remplissage. Sans lui, l'extension demandera le mot de passe maître. + +## Ce qui n'y est pas encore + +- **Passkey (WebAuthn PRF)** : le binding l'expose (`Account.withPasskey`), l'app ne + l'utilise pas encore. À ne pas confondre avec Face ID ci-dessous : la passkey déverrouille + *sans* mot de passe maître, Face ID ne fait qu'en autoriser la relecture. +- **Organisations et accès d'urgence** : absents du binding tant qu'aucun écran n'en a besoin. +- **Corbeille** : la suppression est douce côté serveur, mais l'app n'affiche pas la corbeille. +- **Modifications hors ligne** : lecture oui, écriture non — pas de file de synchronisation. +- **Santé du coffre** (mots de passe faibles, réutilisés, compromis) : le web a `breach.ts`, pas l'iOS. +- **Localisation** : l'interface est en français, sans catalogue de traductions. + +## Apparence + +L'interface reprend le système visuel de la suite, transposé de +`ghostcal/frontend/src/app/globals.css` : nuit profonde, surfaces de verre fumé, halo +diffusé depuis le haut, intitulés en petites capitales espacées. Les produits partagent +cette structure et ne se distinguent que par leur accent, qui est **la teinte de leur +logo** — ici le violet `#7B4DFF`, avec sa variante claire `#B79CFF` pour ce qui doit +rester lisible sur fond sombre. + +Tout vit dans `Ghostpass/Theme/Theme.swift` : couleurs, mesures, et les quelques +composants qui font le vocabulaire commun (`GhostScreen`, `GhostSection`, `GhostRow`, +`GlassCard`, les styles de boutons et de champs). Les deux palettes existent, comme sur +le web ; on suit le réglage du système plutôt que d'imposer l'une des deux — mais le +sombre est la teinte d'origine, et c'est là que l'ensemble prend son sens. + +## Points de vigilance + +- **L'icône vient du logo officiel**, `assets/logo/favicon.svg` — la variante *remplie* + du logo de la suite, celle qu'emploie ghostboard. Le logo au trait + (`assets/logo/ghostpass.svg`) ne convient pas : son trait fait 4 % de la hauteur et + disparaît aux petites tailles, comme l'explique le fichier lui-même. + `tools/ios/make-app-icon.sh` la régénère. Il n'écarte de la source que sur deux points, + documentés dans le script : le cadrage est recentré sur le tracé — aligné en haut, le + fantôme n'a que 9 px de marge au-dessus contre 52 en dessous et frôlerait le bord sous + le masque arrondi — et le fond est aplati en blanc, une icône d'application ne pouvant + pas être transparente. + Les logos sont **générés** par `tools/brand/ghost_suite.py` et portent la mention « ne + pas éditer à la main » : ne pas les retoucher ici. +- **Face ID ne remplace pas le mot de passe maître.** Activé sur proposition explicite, il + dépose le mot de passe maître dans le trousseau sous `.biometryCurrentSet` + + `WhenPasscodeSetThisDeviceOnly` : rien ne sort par sauvegarde, et **enrôler un nouveau + visage invalide l'entrée** — sans quoi qui connaît le code de l'appareil ouvrirait le + coffre. Le déverrouillage reste une dérivation Argon2id faite par le cœur Rust ; la + biométrie n'ouvre que le tiroir où dort le mot de passe. Un refus n'enferme personne : + la saisie manuelle reste disponible, et le menu du coffre permet d'activer ou de retirer + le déverrouillage biométrique à tout moment. Activer à froid **redemande** le mot de + passe maître et le vérifie en rouvrant réellement le coffre : on ne confie au trousseau + qu'un secret dont on sait qu'il ouvre. +- **Le presse-papiers expire.** Toute copie de secret porte une date d'expiration + (`Clipboard.lifetime`, 30 s) : le presse-papiers d'iOS est lisible par n'importe quelle + application au premier plan et se synchronise entre appareils iCloud. Un mot de passe + qui y resterait jusqu'à la copie suivante serait un mot de passe posé sur la table. +- **Le générateur et le TOTP n'utilisent pas le cœur Rust** — il n'expose ni l'un ni + l'autre, et la web app fait de même en TypeScript. Ils s'appuient sur les primitives du + système (`SecRandomCopyBytes`, CryptoKit), comme la web app s'appuie sur WebCrypto : + rien de cryptographique n'est réimplémenté ici, et les secrets ne quittent pas l'appareil. +- Les paramètres KDF transitent **verbatim** du serveur au cœur Rust. Le serveur les + stocke en colonne TEXT : `kdfParams` est une *chaîne* contenant du JSON, jamais un + objet JSON. La décoder pour la ré-encoder donnerait une chaîne doublement échappée, + que `serde` rejette — d'où le `String` brut, comme dans la web app. diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift new file mode 100644 index 00000000..29b3ba5d --- /dev/null +++ b/apps/ios/Tests/ContractTests.swift @@ -0,0 +1,1470 @@ +import SwiftUI +import XCTest + +@testable import Ghostpass + +/// Tests de contrat : la couture entre le JSON du serveur, le JSON du cœur Rust et les +/// types Swift. C'est là que se sont logées les deux régressions qui rendaient l'app +/// inutilisable — une connexion impossible, puis un coffre qui restait vide. +/// +/// Rien ici n'a besoin du réseau ni de l'interface : les charges utiles sont les octets +/// exacts que renvoie le backend, recopiés depuis une réponse réelle. +final class ContractTests: XCTestCase { + + // ─── Réponses d'authentification ─── + + /// Le serveur stocke `kdf_params` en colonne TEXT et la renvoie **verbatim** : + /// `kdfParams` est une chaîne contenant du JSON, jamais un objet JSON. L'attendre + /// comme un objet, puis le ré-encoder, produisait une chaîne doublement échappée que + /// serde rejette — et toute connexion échouait dès le hash d'authentification. + func testPreloginRenvoieLesParametresKdfSousFormeDeChaine() throws { + let json = Data(#"{"kdfParams":"{\"mem_cost_kib\":65536,\"time_cost\":3,\"parallelism\":4}"}"#.utf8) + let res = try JSONDecoder().decode(PreloginResponse.self, from: json) + XCTAssertEqual(res.kdfParams, #"{"mem_cost_kib":65536,"time_cost":3,"parallelism":4}"#) + } + + /// La chaîne doit arriver au cœur Rust telle quelle : c'est elle, et pas une version + /// re-sérialisée, qui doit produire un hash d'authentification. + func testLesParametresKdfDuServeurSontAcceptesParLeCoeur() throws { + let json = Data(#"{"kdfParams":"{\"mem_cost_kib\":65536,\"time_cost\":3,\"parallelism\":4}"}"#.utf8) + let kdf = try JSONDecoder().decode(PreloginResponse.self, from: json).kdfParams + XCTAssertNoThrow( + try masterPasswordHash( + password: "correct horse battery staple", email: "clara@ghostpass.test", + kdfParamsJson: kdf)) + } + + func testLoginRenvoieLesBlobsEtLesParametresKdf() throws { + let json = Data(#""" + {"token":"tok","kdfParams":"{\"mem_cost_kib\":65536,\"time_cost\":3,\"parallelism\":4}", + "encryptedUserKey":"2.aaa.bbb","encryptedPrivateKey":"2.ccc.ddd"} + """#.utf8) + let res = try JSONDecoder().decode(LoginResponse.self, from: json) + XCTAssertEqual(res.token, "tok") + XCTAssertEqual(res.encryptedUserKey, "2.aaa.bbb") + XCTAssertTrue(res.kdfParams.hasPrefix("{"), "kdfParams doit rester du JSON brut") + } + + // ─── Items du coffre ─── + + /// `created_at` / `updated_at` / `deleted_at` sont des colonnes `INTEGER` et sortent + /// en millisecondes depuis l'epoch. Les attendre en `String` faisait échouer le + /// décodage de la liste **entière** : le coffre restait vide, sans autre explication + /// qu'un « Réponse inattendue du serveur ». + func testLesHorodatagesDesItemsSontDesEntiers() throws { + let json = Data(#""" + {"items":[{"id":"abc","encryptedKey":"2.k.k","encryptedData":"2.d.d", + "createdAt":1787669299110,"updatedAt":1787669299110,"deletedAt":null}]} + """#.utf8) + struct Envelope: Decodable { let items: [EncryptedItemDTO] } + let items = try JSONDecoder().decode(Envelope.self, from: json).items + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0].updatedAt, 1_787_669_299_110) + XCTAssertNil(items[0].deletedAt) + + // La preuve par l'absurde, pour que la raison du type ne se perde pas : attendre + // une chaîne fait échouer le décodage, et il échoue pour la liste entière. + struct Ancien: Decodable { let updatedAt: String? } + struct AncienneEnveloppe: Decodable { let items: [Ancien] } + XCTAssertThrowsError(try JSONDecoder().decode(AncienneEnveloppe.self, from: json)) + } + + // ─── Aller-retour à travers le cœur Rust ─── + + private func compteDeTest() throws -> Account { + let reg = try register(password: "correct horse battery staple", email: "clara@ghostpass.test") + return reg.account() + } + + /// Chiffrer puis déchiffrer un item doit le rendre à l'identique. Ce test tient les + /// noms de champs serde (`password_history`, `exp_month`, …) : un seul qui diverge et + /// le champ se perd en silence, ce qu'aucune erreur ne signalerait. + func testUnItemSurvitAuChiffrementEtAuDechiffrement() throws { + let account = try compteDeTest() + let items: [VaultItem] = [ + VaultItem( + name: "Forgejo", notes: "compte de service", folder: "Travail/Serveurs", + data: .login( + Login( + username: "clara", password: "s3cret", uris: ["https://git.stackops.ch"], + totp: "otpauth://totp/x", passwordHistory: ["ancien1", "ancien2"]))), + VaultItem( + name: "Note", notes: nil, folder: nil, + data: .secureNote(SecureNote(content: "à ne pas oublier"))), + VaultItem( + name: "Carte", notes: nil, folder: nil, + data: .card( + Card( + cardholder: "Clara", number: "4111111111111111", expMonth: "04", + expYear: "2030", code: "123"))), + ] + for item in items { + let (key, data) = try VaultStore.encrypt(item, with: account) + let dto = EncryptedItemDTO( + id: "x", encryptedKey: key, encryptedData: data, updatedAt: nil, deletedAt: nil) + XCTAssertEqual(try VaultStore.decrypt(dto, with: account), item, "aller-retour de « \(item.name) »") + } + } + + /// Une clé d'enveloppe étrangère ne doit rien pouvoir ouvrir. + func testUnAutreCompteNeDechiffrePas() throws { + let (a, b) = (try compteDeTest(), try compteDeTest()) + let item = VaultItem(name: "x", notes: nil, folder: nil, data: .secureNote(SecureNote(content: "y"))) + let (key, data) = try VaultStore.encrypt(item, with: a) + let dto = EncryptedItemDTO( + id: "x", encryptedKey: key, encryptedData: data, updatedAt: nil, deletedAt: nil) + XCTAssertThrowsError(try VaultStore.decrypt(dto, with: b)) + } + + // ─── Registre des dossiers ─── + + /// Le nom du registre doit être exactement celui de la web app : un octet NUL suivi + /// de `gp:folders`. À un octet près, l'item cesse d'être filtré et apparaît dans la + /// liste comme une ligne fantôme. + func testLeNomDuRegistreCommenceParUnOctetNul() { + XCTAssertEqual(VaultConstants.foldersItemName, "\u{0}gp:folders") + XCTAssertEqual(Array(VaultConstants.foldersItemName.utf8).first, 0) + } + + /// Le registre tel que l'écrit la web app — un `SecureNote` dont le contenu est la + /// liste des dossiers — traverse le cœur et doit être reconnu comme à masquer. + func testLeRegistreEcritParLaWebAppEstFiltre() throws { + let account = try compteDeTest() + let registre = VaultItem( + name: VaultConstants.foldersItemName, notes: nil, folder: nil, + data: .secureNote(SecureNote(content: #"["Travail/Serveurs"]"#))) + let (key, data) = try VaultStore.encrypt(registre, with: account) + let dto = EncryptedItemDTO( + id: "r", encryptedKey: key, encryptedData: data, updatedAt: nil, deletedAt: nil) + + let relu = try VaultStore.decrypt(dto, with: account) + XCTAssertTrue(VaultStore.isRegistry(relu), "le registre doit être masqué") + + let ordinaire = VaultItem( + name: "gp:folders", notes: nil, folder: nil, + data: .secureNote(SecureNote(content: ""))) + XCTAssertFalse( + VaultStore.isRegistry(ordinaire), + "un item que l'utilisateur pourrait nommer ainsi ne doit pas disparaître") + } + + + /// Le trousseau du simulateur survit à la désinstallation : un test qui ne le vide pas + /// hérite de l'état laissé par le précédent. + private func viderLeTrousseau() { + for clef in [ + Keychain.Key.token, Keychain.Key.masterPassword, Keychain.Key.biometricsEnabled, + ] { + Keychain.remove(clef) + } + SharedStore.clear() + } + + // ─── Déverrouillage biométrique ─── + + /// Activer la biométrie dépose le mot de passe maître dans le trousseau. On ne l'y met + /// qu'après avoir prouvé qu'il ouvre réellement le coffre : sans cette vérification, + /// une faute de frappe enfermerait l'utilisateur derrière un secret qui n'ouvre rien. + @MainActor + func testActiverLaBiometrieRefuseUnMotDePasseFaux() throws { + viderLeTrousseau() + defer { viderLeTrousseau() } + let mail = "biometrie@ghostpass.test" + let motDePasse = "correct horse battery staple" + let blob = try JSONSerialization.jsonObject( + with: Data(try register(password: motDePasse, email: mail).blob().utf8)) + let champs = try XCTUnwrap(blob as? [String: Any]) + + // Une session telle que l'app en dépose une après une connexion réussie. + SharedStore.save( + SharedStore.Session( + serverURL: "http://127.0.0.1:3111", email: mail, + kdfParams: String( + decoding: try JSONSerialization.data(withJSONObject: champs["kdf_params"]!), + as: UTF8.self), + encryptedUserKey: try XCTUnwrap(champs["encrypted_user_key"] as? String), + encryptedPrivateKey: try XCTUnwrap(champs["encrypted_private_key"] as? String))) + let store = VaultStore() + XCTAssertFalse(store.enableBiometrics(password: "ce n'est pas le bon")) + XCTAssertFalse(store.isBiometricEnabled, "un mot de passe faux ne doit rien activer") + XCTAssertNotNil(store.errorMessage) + } + + /// Sans session enregistrée, il n'y a rien à confier au trousseau. + @MainActor + func testActiverLaBiometrieSansSessionEchoue() { + viderLeTrousseau() + defer { viderLeTrousseau() } + let store = VaultStore() + XCTAssertFalse(store.enableBiometrics(password: "peu importe")) + XCTAssertFalse(store.isBiometricEnabled) + } +} + +/// Le conteneur partagé entre l'application et l'extension de remplissage. +final class SharedStoreTests: XCTestCase { + override func setUp() { SharedStore.clear() } + override func tearDown() { SharedStore.clear() } + + /// Sans groupe d'applications, l'extension ne voit ni le coffre ni la session : elle + /// s'ouvre sur un écran vide, et rien dans l'application ne le laisse deviner. Ce test + /// tient la configuration — entitlements des deux cibles comprises. + func testLeGroupeDApplicationsEstAccessible() { + XCTAssertTrue( + SharedStore.isShared, + "groupe \(SharedStore.appGroup) inaccessible : vérifiez les fichiers .entitlements") + } + + func testLaSessionSeRelitEtSEfface() throws { + XCTAssertNil(SharedStore.load()) + let session = SharedStore.Session( + serverURL: "http://127.0.0.1:3111", email: "clara@ghostpass.test", + kdfParams: #"{"mem_cost_kib":65536,"time_cost":3,"parallelism":4}"#, + encryptedUserKey: "2.uuu.uuu", encryptedPrivateKey: "2.ppp.ppp") + SharedStore.save(session) + XCTAssertEqual(try XCTUnwrap(SharedStore.load()), session) + SharedStore.clear() + XCTAssertNil(SharedStore.load()) + } +} + +/// Copie locale du coffre : ce sont les blobs chiffrés du serveur, reposés tels quels. +final class VaultCacheTests: XCTestCase { + private let items = [ + EncryptedItemDTO( + id: "a", encryptedKey: "2.kkk.kkk", encryptedData: "2.ddd.ddd", + updatedAt: 1_787_669_299_110, deletedAt: nil) + ] + + override func setUp() { VaultCache.clear() } + override func tearDown() { VaultCache.clear() } + + /// Sans copie locale, un coffre sans réseau s'affiche vide — ce qui ressemble à s'y + /// méprendre à un coffre qu'on aurait perdu. + func testLaCopieLocaleSeRelit() throws { + XCTAssertNil(VaultCache.load(), "on part d'un cache vide") + VaultCache.save(items) + let relu = try XCTUnwrap(VaultCache.load()) + XCTAssertEqual(relu.map(\.id), ["a"]) + XCTAssertEqual(relu.first?.encryptedData, "2.ddd.ddd") + XCTAssertEqual(relu.first?.updatedAt, 1_787_669_299_110) + } + + /// Se déconnecter doit effacer la copie : laisser le coffre d'un compte sur + /// l'appareil après son départ serait une fuite, même chiffré. + func testLaCopieLocaleSEfface() { + VaultCache.save(items) + XCTAssertNotNil(VaultCache.load()) + VaultCache.clear() + XCTAssertNil(VaultCache.load()) + } +} + +/// Générateur et TOTP : ils ne touchent pas au cœur Rust, mais ils doivent se comporter +/// exactement comme leurs équivalents de la web app — un mot de passe généré ici et un +/// code lu là doivent être de même nature, sinon les deux clients divergent en silence. +final class GeneratorAndTotpTests: XCTestCase { + + // ─── Générateur ─── + + func testLeMotDePasseRespecteLaLongueurDemandee() { + for longueur in [8, 20, 64, 128] { + var options = GeneratorOptions() + options.length = longueur + XCTAssertEqual(PasswordGenerator.generate(options).count, longueur) + } + } + + /// Cocher « chiffres » et n'en obtenir aucun serait un mot de passe qui ne respecte + /// pas la consigne — le générateur garantit au moins un caractère par jeu demandé. + func testChaqueJeuDemandeEstRepresente() { + var options = GeneratorOptions() + options.length = 8 + for _ in 0..<200 { + let mot = PasswordGenerator.generate(options) + XCTAssertTrue(mot.contains { $0.isLowercase }, "minuscule absente de « \(mot) »") + XCTAssertTrue(mot.contains { $0.isUppercase }, "majuscule absente de « \(mot) »") + XCTAssertTrue(mot.contains { $0.isNumber }, "chiffre absent de « \(mot) »") + XCTAssertTrue( + mot.contains { "!@#$%^&*()-_=+[]{};:,.?/".contains($0) }, + "symbole absent de « \(mot) »") + } + } + + func testUnSeulJeuNeProduitQueCeJeu() { + var options = GeneratorOptions(length: 40, lowercase: false, uppercase: false, digits: true, symbols: false) + options.length = 40 + let mot = PasswordGenerator.generate(options) + XCTAssertTrue(mot.allSatisfy(\.isNumber), "« \(mot) » ne devrait contenir que des chiffres") + } + + /// Tout décocher ne doit pas rendre un mot de passe vide. + func testAucunJeuRetombeSurLesMinuscules() { + let options = GeneratorOptions( + length: 16, lowercase: false, uppercase: false, digits: false, symbols: false) + let mot = PasswordGenerator.generate(options) + XCTAssertEqual(mot.count, 16) + XCTAssertTrue(mot.allSatisfy(\.isLowercase)) + } + + func testDeuxAppelsNeDonnentPasLeMemeMotDePasse() { + let options = GeneratorOptions() + XCTAssertNotEqual(PasswordGenerator.generate(options), PasswordGenerator.generate(options)) + } + + // ─── TOTP ─── + + /// Vecteurs de la RFC 6238 (secret ASCII « 12345678901234567890 », SHA-1, 8 chiffres). + /// S'ils passent, la mécanique est celle que tout le monde attend. + func testVecteursDeLaRfc6238() throws { + let config = OtpConfig( + secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", period: 30, digits: 8, algorithm: .sha1) + let attendus: [(TimeInterval, String)] = [ + (59, "94287082"), + (1_111_111_109, "07081804"), + (1_111_111_111, "14050471"), + (1_234_567_890, "89005924"), + (2_000_000_000, "69279037"), + ] + for (instant, attendu) in attendus { + let resultat = try XCTUnwrap(Totp.code(for: config, at: Date(timeIntervalSince1970: instant))) + XCTAssertEqual(resultat.code, attendu, "à t=\(Int(instant))") + } + } + + func testLeTempsRestantDecroitDansLaPeriode() throws { + let config = OtpConfig(secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + let debut = try XCTUnwrap(Totp.code(for: config, at: Date(timeIntervalSince1970: 60))) + let fin = try XCTUnwrap(Totp.code(for: config, at: Date(timeIntervalSince1970: 89))) + XCTAssertEqual(debut.remaining, 30) + XCTAssertEqual(fin.remaining, 1) + XCTAssertEqual(debut.code, fin.code, "le code ne change qu'au changement de période") + } + + func testUneUriOtpauthEstComprise() throws { + let config = try XCTUnwrap( + Totp.parse("otpauth://totp/GhostPass:clara?secret=GEZDGNBVGY3TQOJQ&period=60&digits=8&algorithm=SHA256")) + XCTAssertEqual(config.secret, "GEZDGNBVGY3TQOJQ") + XCTAssertEqual(config.period, 60) + XCTAssertEqual(config.digits, 8) + XCTAssertEqual(config.algorithm, .sha256) + } + + /// Un secret recopié à la main arrive avec des espaces et en minuscules. + func testUnSecretBrutEstNormalise() throws { + let config = try XCTUnwrap(Totp.parse(" gezd gnbv gy3t qojq ")) + XCTAssertEqual(config.secret, "GEZDGNBVGY3TQOJQ") + XCTAssertEqual(config.period, 30) + XCTAssertEqual(config.digits, 6) + } + + func testUnChampVideNEstPasUneErreur() { + XCTAssertNil(Totp.parse("")) + XCTAssertNil(Totp.parse(" ")) + } + + /// Des paramètres absurdes sont ramenés à des valeurs utilisables plutôt que + /// propagés jusqu'au calcul. + func testDesParametresAbsurdesSontCorriges() throws { + let config = try XCTUnwrap( + Totp.parse("otpauth://totp/x?secret=GEZDGNBVGY3TQOJQ&period=0&digits=42")) + XCTAssertEqual(config.period, 30) + XCTAssertEqual(config.digits, 6) + XCTAssertNotNil(Totp.code(for: config)) + } +} + +/// Rapprochement entre le site où l'on se trouve et les adresses d'un item. C'est cette +/// règle qui décide de ce que le remplissage propose : trop stricte, elle ne propose +/// rien ; trop lâche, elle offre les identifiants d'un site à un autre. +final class SiteMatchingTests: XCTestCase { + + func testUneUrlEstRamenéeASonHote() { + XCTAssertEqual(SiteMatching.host(of: "https://github.com/login?next=/x"), "github.com") + XCTAssertEqual(SiteMatching.host(of: "HTTPS://WWW.GitHub.COM/"), "github.com") + XCTAssertEqual(SiteMatching.host(of: "git.stackops.ch"), "git.stackops.ch") + XCTAssertEqual(SiteMatching.host(of: "http://127.0.0.1:3111/api"), "127.0.0.1") + XCTAssertEqual(SiteMatching.host(of: " https://Example.com "), "example.com") + XCTAssertEqual(SiteMatching.host(of: ""), "") + } + + /// Les sites déplacent leur formulaire d'authentification sur un sous-domaine sans + /// prévenir : un identifiant enregistré pour `example.com` doit valoir sur + /// `login.example.com`. + func testUnSousDomaineCorrespondAuDomaine() { + XCTAssertTrue(SiteMatching.sameSite("login.example.com", "example.com")) + XCTAssertTrue(SiteMatching.sameSite("example.com", "login.example.com")) + XCTAssertTrue(SiteMatching.sameSite("example.com", "example.com")) + } + + /// Le point de séparation compte : sans lui, `notexample.com` passerait pour un + /// sous-domaine d'`example.com` et le coffre livrerait ses identifiants à un voisin. + func testUnDomaineVoisinNeCorrespondPas() { + XCTAssertFalse(SiteMatching.sameSite("notexample.com", "example.com")) + XCTAssertFalse(SiteMatching.sameSite("example.com.attaquant.net", "example.com")) + XCTAssertFalse(SiteMatching.sameSite("example.org", "example.com")) + XCTAssertFalse(SiteMatching.sameSite("", "example.com")) + } + + private func identifiant(_ adresses: [String]) -> VaultItem { + VaultItem( + name: "x", notes: nil, folder: nil, + data: .login(Login(username: "clara", password: "s", uris: adresses))) + } + + func testUnItemEstProposeSurSonSite() { + let item = identifiant(["https://github.com/login"]) + XCTAssertTrue(SiteMatching.matches(item, domains: ["github.com"])) + XCTAssertTrue(SiteMatching.matches(item, domains: ["https://gist.github.com"])) + XCTAssertFalse(SiteMatching.matches(item, domains: ["gitlab.com"])) + XCTAssertFalse(SiteMatching.matches(item, domains: [])) + } + + /// Une note ou une carte n'a rien à remplir dans un champ d'identifiant. + func testSeulsLesIdentifiantsSontProposes() { + let note = VaultItem( + name: "n", notes: nil, folder: nil, data: .secureNote(SecureNote(content: "x"))) + XCTAssertFalse(SiteMatching.matches(note, domains: ["github.com"])) + } + + /// Un identifiant sans adresse ne peut être rattaché à aucun site — il reste + /// accessible dans la liste complète, mais n'est pas suggéré. + func testUnItemSansAdresseNEstPasSuggere() { + XCTAssertFalse(SiteMatching.matches(identifiant([]), domains: ["github.com"])) + } +} + +/// Ce que fait l'extension de remplissage quand un site réclame un identifiant : relire +/// la copie locale, la déchiffrer avec le mot de passe maître, et ne proposer que ce qui +/// vaut pour ce site. L'extension vit dans un autre processus ; ce test exerce ici le +/// chemin qu'elle emprunte, avec le même code. +final class AutoFillLogicTests: XCTestCase { + override func setUp() { VaultCache.clear() } + override func tearDown() { VaultCache.clear() } + + private func identifiant(_ nom: String, _ adresse: String) -> VaultItem { + VaultItem( + name: nom, notes: nil, folder: nil, + data: .login(Login(username: "clara", password: "s3cret-\(nom)", uris: [adresse]))) + } + + func testLeRemplissageNeProposeQueLesIdentifiantsDuSite() throws { + let account = try register( + password: "correct horse battery staple", email: "clara@ghostpass.test" + ).account() + + // Un coffre comme l'application en dépose un : deux sites et le registre interne. + let coffre = [ + identifiant("GitHub", "https://github.com/login"), + identifiant("Forgejo", "https://git.stackops.ch"), + VaultItem( + name: VaultConstants.foldersItemName, notes: nil, folder: nil, + data: .secureNote(SecureNote(content: "[]"))), + ] + let dtos = try coffre.enumerated().map { index, item -> EncryptedItemDTO in + let (key, data) = try VaultStore.encrypt(item, with: account) + return EncryptedItemDTO( + id: "item-\(index)", encryptedKey: key, encryptedData: data, + updatedAt: nil, deletedAt: nil) + } + VaultCache.save(dtos) + + // Le chemin de l'extension : cache → déchiffrement → filtrage. + let items = try XCTUnwrap(VaultCache.load()).compactMap { + try? VaultStore.decrypt($0, with: account) + } + let visibles = items.filter { !VaultStore.isRegistry($0) } + XCTAssertEqual(visibles.count, 2, "le registre interne n'a rien à faire ici non plus") + + let surGitHub = visibles.filter { + SiteMatching.matches($0, domains: ["https://gist.github.com/clara"]) + } + XCTAssertEqual(surGitHub.map(\.name), ["GitHub"], "un sous-domaine doit correspondre") + + let surLaForge = visibles.filter { SiteMatching.matches($0, domains: ["git.stackops.ch"]) } + XCTAssertEqual(surLaForge.map(\.name), ["Forgejo"]) + + // Le mot de passe fourni est bien celui de l'item retenu. + guard case .login(let login) = try XCTUnwrap(surGitHub.first).data else { + return XCTFail("un identifiant était attendu") + } + XCTAssertEqual(login.password, "s3cret-GitHub") + + // Sur un site inconnu, rien n'est suggéré — la liste complète reste accessible. + XCTAssertTrue(visibles.filter { SiteMatching.matches($0, domains: ["exemple.test"]) }.isEmpty) + } +} + +/// Le registre des dossiers, partagé avec la web app. +/// +/// Seuls les dossiers **vides** y figurent : les autres se déduisent des éléments qui les +/// habitent. Le format est celui qu'écrit `encryptFolders` côté web — un `SecureNote` dont +/// le contenu est la liste des chemins en JSON, sous un nom que les deux clients masquent. +/// Un écart ici et chaque client verrait des dossiers que l'autre ignore. +final class FolderRegistryTests: XCTestCase { + + private func compte() throws -> Account { + try register(password: "correct horse battery staple", email: "clara@ghostpass.test") + .account() + } + + /// Le registre tel que l'écrirait l'application doit se relire à l'identique, et + /// rester invisible dans la liste. + func testLeRegistreSeRelitEtResteMasque() throws { + let account = try compte() + let chemins = ["Perso", "Travail", "Travail/Serveurs"] + let contenu = String(decoding: try JSONEncoder().encode(chemins), as: UTF8.self) + let registre = VaultItem( + name: VaultConstants.foldersItemName, notes: nil, folder: nil, + data: .secureNote(SecureNote(content: contenu))) + + let (key, data) = try VaultStore.encrypt(registre, with: account) + let dto = EncryptedItemDTO( + id: "r", encryptedKey: key, encryptedData: data, updatedAt: nil, deletedAt: nil) + let relu = try VaultStore.decrypt(dto, with: account) + + XCTAssertTrue(VaultStore.isRegistry(relu), "le registre doit rester masqué") + guard case .secureNote(let note) = relu.data else { return XCTFail("un SecureNote était attendu") } + XCTAssertEqual( + try JSONDecoder().decode([String].self, from: Data(note.content.utf8)), chemins) + } + + /// « Travail/ », « /Travail » et « Travail » désignent le même endroit : sans + /// normalisation, ils coexisteraient dans le registre comme trois dossiers distincts. + func testLesCheminsSontNormalises() { + XCTAssertEqual(VaultStore.normaliser(" Travail "), "Travail") + XCTAssertEqual(VaultStore.normaliser("/Travail/"), "Travail") + XCTAssertEqual(VaultStore.normaliser("Travail/Serveurs/"), "Travail/Serveurs") + XCTAssertEqual(VaultStore.normaliser(" "), "") + XCTAssertEqual(VaultStore.normaliser("//"), "") + } +} + +// ─── Traduction ─────────────────────────────────────────────────────────────── + +/// L'application se veut disponible en français et en anglais. Ce qui peut casser sans +/// bruit, ce n'est pas le sélecteur de langue — c'est le catalogue : une chaîne oubliée, +/// une région non déclarée, et l'écran reste en français en jurant que la langue a changé. +/// Ces tests interrogent le paquet réellement construit, pas le fichier source. +final class TraductionTests: XCTestCase { + /// Le paquet anglais existe : sans lui, choisir « English » ne changerait rien. + func testLePaquetAnglaisEstEmbarque() throws { + let chemin = try XCTUnwrap( + Bundle.main.path(forResource: "en", ofType: "lproj"), + "aucun en.lproj dans l'application : le catalogue n'a pas été compilé") + let paquet = try XCTUnwrap(Bundle(path: chemin)) + XCTAssertEqual(paquet.localizedString(forKey: "Coffre", value: nil, table: nil), "Vault") + XCTAssertEqual( + paquet.localizedString(forKey: "Réglages", value: nil, table: nil), "Settings") + } + + /// Le français est la langue de développement : ses clefs sont ses propres textes, + /// et une clef absente du catalogue s'affiche telle quelle plutôt que de disparaître. + func testLeFrancaisResteLaLangueDeDeveloppement() { + XCTAssertEqual(Bundle.main.developmentLocalization, "fr") + XCTAssertTrue( + Bundle.main.localizations.contains("en"), + "l'anglais n'est pas déclaré parmi les localisations de l'application") + } + + /// Les écrans les plus exposés — déverrouillage, coffre, réglages — sont traduits. + /// Une chaîne oubliée laisse ici sa clef française en évidence. + func testLesEcransPrincipauxSontTraduits() throws { + let chemin = try XCTUnwrap(Bundle.main.path(forResource: "en", ofType: "lproj")) + let paquet = try XCTUnwrap(Bundle(path: chemin)) + let attendus = [ + "Mot de passe maître": "Master password", + "Se connecter": "Sign in", + "Verrouiller": "Lock", + "Nouvel élément": "New item", + "Corbeille": "Trash", + "Apparence": "Appearance", + "Langue": "Language", + "Sombre": "Dark", + "Mot de passe maître incorrect.": "Incorrect master password.", + ] + for (clef, traduction) in attendus { + XCTAssertEqual( + paquet.localizedString(forKey: clef, value: nil, table: nil), traduction, + "« \(clef) » n'est pas traduit") + } + } + + /// Les deux réglages ne sont que des choix : ce qu'ils désignent doit rester juste. + func testLesChoixDeThemeEtDeLangueDesignentBienCeQuIlFaut() { + XCTAssertNil(Apparence.systeme.colorScheme, "« Système » ne doit rien imposer") + XCTAssertEqual(Apparence.clair.colorScheme, .light) + XCTAssertEqual(Apparence.sombre.colorScheme, .dark) + + XCTAssertNil(Langue.systeme.code, "« Système » ne doit forcer aucune langue") + XCTAssertEqual(Langue.francais.code, "fr") + XCTAssertEqual(Langue.anglais.code, "en") + XCTAssertEqual(Langue.anglais.locale?.identifier, "en") + } +} + +// ─── Santé du coffre ────────────────────────────────────────────────────────── + +/// Le barème est celui de la web app. Ce qui casserait sans bruit, c'est une divergence : +/// un mot de passe jugé faible dans le navigateur et bon sur le téléphone ferait douter +/// des deux. Ces vecteurs sont donc ceux du barème web, recopiés. +final class PasswordHealthTests: XCTestCase { + private func niveau(_ mot: String) -> Int { PasswordHealth.force(mot).niveau } + + func testLeBaremeSuitCeluiDeLaWebApp() { + // Rien : niveau plancher. + XCTAssertEqual(niveau(""), 0) + // 7 caractères, deux classes : score 1 → niveau 1. + XCTAssertEqual(niveau("abc123"), 1) + // 8 caractères, deux classes : score 2 → niveau 2. + XCTAssertEqual(niveau("abcd1234"), 2) + // 14 caractères, trois classes : score 4 → niveau 3. C'est la longueur de 20 + // qui manque pour atteindre le dernier cran, et non la variété. + XCTAssertEqual(niveau("Abcdefgh123456"), 3) + // 20 caractères, quatre classes : score 5 → niveau 4. + XCTAssertEqual(niveau("Abcdefgh1234567890!!"), 4) + } + + /// Un mot de passe long mais d'une seule sorte de caractères reste faible : c'est + /// exactement le cas que la longueur seule laisserait passer. + func testUneSeuleSorteDeCaracteresNeSuffitPas() { + XCTAssertLessThanOrEqual(niveau("aaaaaaaa"), 1) + XCTAssertLessThanOrEqual(niveau("motdepasse"), 1) + } + + func testLesMotsDePasseReutilisesSontReperes() { + let entries = [ + entree("A", motDePasse: "correct horse battery staple"), + entree("B", motDePasse: "correct horse battery staple"), + entree("C", motDePasse: "Zx9!kQ2m#Lp4vT7w"), + ] + let bilan = PasswordHealth.bilan(entries) + XCTAssertEqual(Set(bilan.reutilises.map(\.item.name)), ["A", "B"]) + XCTAssertTrue(bilan.faibles.isEmpty, "aucun de ces mots de passe n'est faible") + } + + /// Une note et une carte n'ont pas de mot de passe : les compter comme faibles + /// remplirait l'écran de santé d'alertes sans objet. + func testSeulsLesIdentifiantsSontJuges() { + let note = VaultEntry( + id: "n", item: VaultItem(name: "Note", notes: nil, folder: nil, + data: .secureNote(SecureNote(content: "x"))), + updatedAt: nil) + let bilan = PasswordHealth.bilan([note, entree("Faible", motDePasse: "abc")]) + XCTAssertEqual(bilan.faibles.map(\.item.name), ["Faible"]) + XCTAssertEqual(bilan.sansCode.map(\.item.name), ["Faible"]) + } + + private func entree(_ nom: String, motDePasse: String) -> VaultEntry { + VaultEntry( + id: nom, + item: VaultItem( + name: nom, notes: nil, folder: nil, + data: .login(Login(username: "clara", password: motDePasse))), + updatedAt: nil) + } +} + +// ─── Registres ──────────────────────────────────────────────────────────────── + +/// Le coffre range ses métadonnées dans des items comme les autres, sous un nom masqué. +/// Le jour où un autre client de la suite en ajoute un, il ne doit pas apparaître dans la +/// liste : c'est le préfixe, et non le nom exact, qui décide. +final class RegistryTests: XCTestCase { + private func item(_ nom: String) -> VaultItem { + VaultItem(name: nom, notes: nil, folder: nil, data: .secureNote(SecureNote(content: "[]"))) + } + + func testTousLesRegistresSontMasques() { + XCTAssertTrue(VaultStore.isRegistry(item(VaultConstants.foldersItemName))) + XCTAssertTrue(VaultStore.isRegistry(item(VaultConstants.favoritesItemName))) + // Un registre qu'aucune version actuelle ne connaît. + XCTAssertTrue(VaultStore.isRegistry(item(VaultConstants.registryPrefix + "avenir"))) + } + + /// Un nom choisi par l'utilisateur ne peut pas passer pour un registre : le préfixe + /// commence par un octet NUL, qu'aucun clavier ne produit. + func testUnNomOrdinaireNEstPasUnRegistre() { + XCTAssertFalse(VaultStore.isRegistry(item("gp:folders"))) + XCTAssertFalse(VaultStore.isRegistry(item("Favoris"))) + XCTAssertFalse(VaultStore.isRegistry(item(""))) + } +} + +// ─── Historique des mots de passe ───────────────────────────────────────────── + +/// Un mot de passe remplacé rejoint l'historique. C'est ce qui sauve un compte dont le +/// changement a échoué à mi-chemin — le service a gardé l'ancien, l'application le +/// nouveau. Encore faut-il que l'historique traverse le chiffrement intact, et qu'il +/// cesse de grossir : un item qui enfle à chaque modification finit par coûter cher. +final class PasswordHistoryTests: XCTestCase { + private func compte() throws -> Account { + try register(password: "correct horse battery staple", email: "clara@ghostpass.test") + .account() + } + + func testLHistoriqueSurvitAuChiffrement() throws { + let account = try compte() + let anciens = ["premier", "deuxième", "troisième"] + let item = VaultItem( + name: "Forgejo", notes: nil, folder: nil, + data: .login( + Login( + username: "clara", password: "actuel", uris: [], totp: nil, + passwordHistory: anciens))) + + let (key, data) = try VaultStore.encrypt(item, with: account) + let dto = EncryptedItemDTO( + id: "x", encryptedKey: key, encryptedData: data, updatedAt: nil, deletedAt: nil) + let relu = try VaultStore.decrypt(dto, with: account) + + guard case .login(let login) = relu.data else { return XCTFail("un Login était attendu") } + XCTAssertEqual( + login.passwordHistory, anciens, + "l'historique ne traverse pas le chiffrement — le champ `password_history` a changé de nom") + } + + /// Le plafond est celui de la web app. S'il divergeait, un même coffre montrerait + /// plus d'anciens mots de passe d'un côté que de l'autre, et on croirait à une perte. + func testLePlafondEstCeluiDeLaWebApp() { + XCTAssertEqual(VaultConstants.passwordHistoryLimit, 20) + } +} + +// ─── Récupération de compte ─────────────────────────────────────────────────── + +/// La clé de récupération est la seule issue d'un mot de passe maître oublié : sans elle, +/// un coffre chiffré de bout en bout est perdu pour de bon. Ce qui casserait sans bruit, +/// ce sont les noms de champs — le cœur Rust les écrit en `snake_case`, l'API les attend +/// en camelCase — et le fait que la clé du coffre survive à la réinitialisation. Un coffre +/// qu'on rouvre mais dont les items ne se déchiffrent plus n'est pas un coffre récupéré. +final class RecoveryTests: XCTestCase { + private let motDePasse = "correct horse battery staple" + private let mail = "clara@ghostpass.test" + + /// Le blob d'inscription porte les paramètres KDF comme **objet** JSON, alors que le + /// serveur les stocke — et les rend — comme une chaîne contenant du JSON. C'est la + /// même couture qu'au prélogin, et c'est là qu'une régression s'était déjà logée : le + /// cœur Rust veut la chaîne, pas l'objet. + private func parametresKdf(_ blobs: [String: Any]) throws -> String { + let objet = try XCTUnwrap(blobs["kdf_params"]) + return String( + decoding: try JSONSerialization.data(withJSONObject: objet), as: UTF8.self) + } + + /// Les clefs du JSON de `create_recovery()` sont celles que l'application décode. + func testLeKitPorteLesTroisChampsAttendus() throws { + let account = try register(password: motDePasse, email: mail).account() + let json = try account.createRecovery() + let kit = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + for clef in ["recovery_key", "recovery_auth_hash", "encrypted_user_key_recovery"] { + XCTAssertNotNil(kit[clef], "le kit de récupération n'a pas de champ « \(clef) »") + XCTAssertFalse( + (kit[clef] as? String ?? "").isEmpty, "le champ « \(clef) » est vide") + } + } + + /// Le parcours entier, contre le vrai binding : un coffre, une clé de récupération, + /// un nouveau mot de passe — et l'item d'origine qui se relit. + func testUnCoffreSeRouvreApresReinitialisation() throws { + let inscription = try register(password: motDePasse, email: mail) + let account = try inscription.account() + let blobs = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(inscription.blob().utf8)) as? [String: Any]) + let kdf = try parametresKdf(blobs) + let clePrivee = try XCTUnwrap(blobs["encrypted_private_key"] as? String) + + // Un item déposé avant l'oubli : c'est lui qui dira si la clé du coffre a survécu. + let item = VaultItem( + name: "Forgejo", notes: nil, folder: nil, + data: .login(Login(username: "clara", password: "s3cret-initial"))) + let (key, data) = try VaultStore.encrypt(item, with: account) + + let kit = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(try account.createRecovery().utf8)) + as? [String: Any]) + let cleDeRecuperation = try XCTUnwrap(kit["recovery_key"] as? String) + let uskRecuperation = try XCTUnwrap(kit["encrypted_user_key_recovery"] as? String) + + // Le mot de passe maître est oublié : on repart de la clé de récupération seule. + let resultat = try recover( + recoveryKey: cleDeRecuperation, email: mail, newPassword: "nouveau mot de passe maître", + kdfParamsJson: kdf, encryptedUserKeyRecovery: uskRecuperation, + encryptedPrivateKey: clePrivee) + + let reset = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(resultat.reset().utf8)) as? [String: Any]) + for clef in ["master_password_hash", "recovery_auth_hash", "encrypted_user_key"] { + XCTAssertNotNil(reset[clef], "le blob de réinitialisation n'a pas de champ « \(clef) »") + } + + let dto = EncryptedItemDTO( + id: "x", encryptedKey: key, encryptedData: data, updatedAt: nil, deletedAt: nil) + let relu = try VaultStore.decrypt(dto, with: resultat.account()) + XCTAssertEqual( + relu.name, "Forgejo", + "le coffre ne se relit plus après récupération : la clé du coffre n'a pas survécu") + } + + /// Une clé fausse ne doit pas ouvrir le coffre — et doit échouer ici, dans le cœur, + /// pas seulement au refus du serveur. + func testUneCleFausseEstRefusee() throws { + let inscription = try register(password: motDePasse, email: mail) + let blobs = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(inscription.blob().utf8)) as? [String: Any]) + let kdf = try parametresKdf(blobs) + let clePrivee = try XCTUnwrap(blobs["encrypted_private_key"] as? String) + let kit = try XCTUnwrap( + try JSONSerialization.jsonObject( + with: Data(try inscription.account().createRecovery().utf8)) as? [String: Any]) + let uskRecuperation = try XCTUnwrap(kit["encrypted_user_key_recovery"] as? String) + + XCTAssertThrowsError( + try recover( + recoveryKey: "AAAA-AAAA-AAAA-AAAA-AAAA-AAAA", email: mail, + newPassword: "nouveau mot de passe maître", kdfParamsJson: kdf, + encryptedUserKeyRecovery: uskRecuperation, encryptedPrivateKey: clePrivee), + "une clé de récupération fausse a été acceptée") + } +} + +// ─── Import CSV ─────────────────────────────────────────────────────────────── + +/// Un import est irréversible à l'échelle d'un coffre : deux cents entrées mal lues se +/// reprennent une par une. Ce qui casse silencieusement, ce sont les cas de bord du format +/// — un mot de passe qui contient une virgule, un guillemet, un saut de ligne — et les +/// noms de colonnes, qui diffèrent d'un gestionnaire à l'autre. Les vecteurs ci-dessous +/// sont ceux du parseur de la web app, pour que le même fichier donne le même coffre. +/// +/// Les littéraux emploient les délimiteurs étendus de Swift : un CSV contient des +/// guillemets, et `"""` au fil d'une ligne refermerait le littéral au mauvais endroit. +final class CsvImportTests: XCTestCase { + private func login(_ item: VaultItem) throws -> Login { + guard case .login(let login) = item.data else { + throw XCTSkip("un Login était attendu") + } + return login + } + + func testUnFichierSimpleSeLit() throws { + let csv = #""" + name,username,password,url + GitHub,clara,s3cret,https://github.com + """# + let items = CsvImport.items(csv) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0].name, "GitHub") + let compte = try login(items[0]) + XCTAssertEqual(compte.username, "clara") + XCTAssertEqual(compte.password, "s3cret") + XCTAssertEqual(compte.uris, ["https://github.com"]) + } + + /// Une virgule dans un mot de passe est le cas qui casse un découpage naïf — et il + /// donnerait un mot de passe tronqué, sans que rien ne le signale. + func testUnChampEntreGuillemetsGardeSesVirgules() throws { + let csv = #""" + name,password + Forgejo,"a,b,c" + """# + XCTAssertEqual(try login(CsvImport.items(csv)[0]).password, "a,b,c") + } + + /// Deux guillemets consécutifs valent un guillemet littéral. + func testUnGuillemetDoubleSeReduit() throws { + let csv = #""" + name,password + Forgejo,"il a dit ""bonjour""" + """# + XCTAssertEqual(try login(CsvImport.items(csv)[0]).password, #"il a dit "bonjour""#) + } + + func testUnSautDeLigneEchappeNeCoupePasLEnregistrement() { + let csv = "name,password\n\"Deux\nlignes\",s3cret\n" + let items = CsvImport.items(csv) + XCTAssertEqual(items.count, 1, "le saut de ligne échappé a coupé l'enregistrement") + XCTAssertEqual(items[0].name, "Deux\nlignes") + } + + /// Chaque gestionnaire nomme ses colonnes à sa façon. La table d'équivalences est ce + /// qui rend l'import utilisable sans retoucher le fichier à la main. + func testLesColonnesDesConcurrentsSontReconnues() throws { + let bitwarden = #""" + folder,favorite,type,name,notes,fields,login_uri,login_username,login_password,login_totp + Travail,,login,Forgejo,,,https://git.stackops.ch,clara,s3cret,GEZDGNBVGY3TQOJQ + """# + let items = CsvImport.items(bitwarden) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0].name, "Forgejo") + XCTAssertEqual(items[0].folder, "Travail") + let compte = try login(items[0]) + XCTAssertEqual(compte.username, "clara") + XCTAssertEqual(compte.password, "s3cret") + XCTAssertEqual(compte.uris, ["https://git.stackops.ch"]) + XCTAssertEqual(compte.totp, "GEZDGNBVGY3TQOJQ") + + let chrome = #""" + name,url,username,password + GitHub,https://github.com,clara,s3cret + """# + XCTAssertEqual(CsvImport.items(chrome).first?.name, "GitHub") + + let onepassword = #""" + title,website,login,password + Amazon,https://amazon.fr,clara,s3cret + """# + let un = CsvImport.items(onepassword) + XCTAssertEqual(un.first?.name, "Amazon") + XCTAssertEqual(try login(un[0]).username, "clara") + } + + /// Les exports sèment des lignes vides ; les avaler produirait des entrées fantômes. + func testLesLignesVidesSontIgnorees() { + XCTAssertEqual(CsvImport.items("name,password\n\nGitHub,s3cret\n\n\n").count, 1) + } + + func testUnFichierSansEnregistrementNeDonneRien() { + XCTAssertTrue(CsvImport.items("").isEmpty) + XCTAssertTrue( + CsvImport.items("name,password").isEmpty, "l'en-tête seul n'est pas une entrée") + } + + /// Une colonne absente ne doit pas décaler les suivantes ni faire échouer la lecture. + func testUneLigneTropCourteSeCompleteEnVide() throws { + let csv = #""" + name,username,password,url + GitHub,clara + """# + let items = CsvImport.items(csv) + XCTAssertEqual(items.count, 1) + let compte = try login(items[0]) + XCTAssertEqual(compte.password, "") + XCTAssertTrue(compte.uris.isEmpty, "une adresse vide ne doit pas produire d'URI vide") + } + + /// Sans nom, l'entrée reste identifiable dans la liste plutôt que d'y figurer en blanc. + func testUneEntreeSansNomEnRecoitUn() { + let csv = #""" + name,username,password + ,clara,s3cret + """# + XCTAssertFalse(CsvImport.items(csv).first?.name.isEmpty ?? true) + } +} + +// ─── Export CSV ─────────────────────────────────────────────────────────────── + +/// Un coffre doit pouvoir sortir aussi librement qu'il est entré. Le test qui compte est +/// l'aller-retour : ce que l'export écrit, l'import doit le relire à l'identique. Sans +/// cela, on découvrirait le problème le jour où l'on quitte l'application — c'est-à-dire +/// trop tard pour s'en plaindre. +final class CsvExportTests: XCTestCase { + private func entree( + _ nom: String, _ utilisateur: String, _ motDePasse: String, + adresse: String = "", dossier: String? = nil, totp: String? = nil + ) -> VaultEntry { + VaultEntry( + id: nom, + item: VaultItem( + name: nom, notes: nil, folder: dossier, + data: .login( + Login( + username: utilisateur, password: motDePasse, + uris: adresse.isEmpty ? [] : [adresse], totp: totp))), + updatedAt: nil) + } + + func testLEnteteEstCelleDeLaWebApp() { + XCTAssertEqual(CsvExport.entete, "name,folder,url,username,password,totp") + } + + /// L'aller-retour complet, avec les caractères qui cassent un format mal échappé. + func testCeQuiSortSeRelitALIdentique() throws { + let coffre = [ + entree("GitHub", "clara", "s3cret", adresse: "https://github.com", dossier: "Travail"), + entree("Virgule", "clara", "a,b,c"), + entree("Guillemet", "clara", #"il a dit "bonjour""#), + entree("Décathlon", "clara@exemple.ch", "p@ss", adresse: "https://decathlon.fr"), + entree("AvecCode", "clara", "s3cret", totp: "GEZDGNBVGY3TQOJQ"), + ] + + let relus = CsvImport.items(CsvExport.texte(coffre)) + XCTAssertEqual(relus.count, coffre.count, "l'aller-retour a perdu ou inventé des entrées") + + for (origine, relu) in zip(coffre, relus) { + XCTAssertEqual(relu.name, origine.item.name) + XCTAssertEqual(relu.folder, origine.item.folder) + guard case .login(let apres) = relu.data, let avant = origine.login else { + return XCTFail("un Login était attendu") + } + XCTAssertEqual(apres.username, avant.username) + XCTAssertEqual( + apres.password, avant.password, + "« \(origine.item.name) » : le mot de passe n'a pas survécu à l'aller-retour") + XCTAssertEqual(apres.uris, avant.uris) + XCTAssertEqual(apres.totp, avant.totp) + } + } + + /// Un saut de ligne dans un champ ne doit pas couper l'enregistrement à la relecture. + func testUnSautDeLigneSurvitALAllerRetour() throws { + let coffre = [entree("Deux\nlignes", "clara", "s3cret")] + let relus = CsvImport.items(CsvExport.texte(coffre)) + XCTAssertEqual(relus.count, 1) + XCTAssertEqual(relus[0].name, "Deux\nlignes") + } + + /// Une note et une carte n'ont pas d'identifiants : leurs colonnes restent vides plutôt + /// que de décaler la ligne. + func testUnElementSansIdentifiantsNeCassePasLaLigne() { + let note = VaultEntry( + id: "n", + item: VaultItem( + name: "Note", notes: nil, folder: nil, + data: .secureNote(SecureNote(content: "x"))), + updatedAt: nil) + let lignes = CsvExport.texte([note]).components(separatedBy: "\n") + XCTAssertEqual(lignes.count, 2) + XCTAssertEqual(lignes[1], #""Note","","","","","""#) + } + + func testLeNomDeFichierPorteLaDate() { + let date = Date(timeIntervalSince1970: 1_787_000_000) + XCTAssertTrue( + CsvExport.nomDeFichier(date).hasSuffix(".csv"), + "le nom de fichier doit garder son extension") + XCTAssertTrue(CsvExport.nomDeFichier(date).contains("2026-")) + } +} + +// ─── Icônes des sites ───────────────────────────────────────────────────────── + +/// Les icônes viennent du proxy du serveur de l'utilisateur, jamais d'un tiers : c'est ce +/// qui empêche Google — ou n'importe qui d'autre — d'apprendre quels sites contient le +/// coffre. Ces tests vérifient que l'adresse construite reste bien celle de ce serveur. +final class FaviconTests: XCTestCase { + private let serveur = "https://ghostpass.stackops.ch" + + func testLAdresseViseLeProxyDuServeur() throws { + let url = try XCTUnwrap(Favicon.url(pour: "https://www.decathlon.fr/rayon", serveur: serveur)) + XCTAssertEqual(url.host, "ghostpass.stackops.ch", "l'icône ne doit venir que de notre serveur") + XCTAssertEqual(url.path, "/api/icons") + let composants = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + XCTAssertEqual( + composants.queryItems?.first(where: { $0.name == "domain" })?.value, "decathlon.fr", + "le sous-domaine « www. » et le chemin doivent être écartés") + } + + /// Sans domaine exploitable, pas de requête du tout : une pastille d'initiale suffit. + func testUneAdresseInexploitableNeDonneAucuneUrl() { + XCTAssertNil(Favicon.url(pour: "", serveur: serveur)) + XCTAssertNil(Favicon.url(pour: "localhost", serveur: serveur)) + XCTAssertNil(Favicon.url(pour: "http://192.168.1.10:8080", serveur: serveur), "une IP n'est pas un domaine") + XCTAssertNil(Favicon.url(pour: "https://decathlon.fr", serveur: "")) + } + + /// La couleur de repli est déterministe et partagée avec la web app : le même élément + /// doit garder la même pastille d'un écran à l'autre, et d'un lancement au suivant. + func testLaCouleurDeReplyEstStable() { + XCTAssertEqual(Favicon.couleur(pour: "GitHub"), Favicon.couleur(pour: "GitHub")) + XCTAssertEqual(Favicon.initiale("décathlon"), "D") + XCTAssertEqual(Favicon.initiale(" forgejo"), "F") + XCTAssertEqual(Favicon.initiale(""), "?") + } +} + +// ─── Verrouillage différé ───────────────────────────────────────────────────── + +/// La décision de refermer le coffre au retour dans l'application. Elle se prend sur des +/// dates, et une erreur de sens laisserait un coffre ouvert qu'on croit fermé — c'est +/// exactement le genre de défaut qu'aucun essai à la main ne révèle, puisqu'il faudrait +/// attendre un quart d'heure pour le voir. +final class VerrouillageTests: XCTestCase { + private let sortie = Date(timeIntervalSince1970: 1_787_000_000) + + /// Sans délai, on referme quoi qu'il arrive : c'est le réglage par défaut. + func testSansDelaiOnRefermeToujours() { + XCTAssertTrue( + VaultStore.doitVerrouiller(sortie: sortie, delai: nil, maintenant: sortie)) + XCTAssertTrue( + VaultStore.doitVerrouiller( + sortie: sortie, delai: nil, maintenant: sortie.addingTimeInterval(0.1))) + } + + func testAvantLeDelaiLeCoffreResteOuvert() { + XCTAssertFalse( + VaultStore.doitVerrouiller( + sortie: sortie, delai: 300, maintenant: sortie.addingTimeInterval(299))) + } + + /// Au délai pile, on referme. Un « strictement supérieur » laisserait passer le cas + /// limite, et le cas limite est celui qu'on teste. + func testAuDelaiPileOnReferme() { + XCTAssertTrue( + VaultStore.doitVerrouiller( + sortie: sortie, delai: 300, maintenant: sortie.addingTimeInterval(300))) + XCTAssertTrue( + VaultStore.doitVerrouiller( + sortie: sortie, delai: 300, maintenant: sortie.addingTimeInterval(301))) + } + + /// Une horloge qui recule — fuseau, correction NTP — donnerait un écart négatif. Sans + /// ce garde-fou, le coffre resterait ouvert indéfiniment. + func testUneHorlogeQuiReculeRefermeLeCoffre() { + XCTAssertTrue( + VaultStore.doitVerrouiller( + sortie: sortie, delai: 900, maintenant: sortie.addingTimeInterval(-3600))) + } + + /// Les délais proposés sont ceux qu'annoncent les libellés. + func testLesDelaisCorrespondentAuxLibelles() { + XCTAssertNil(Verrouillage.immediat.delai) + XCTAssertEqual(Verrouillage.uneMinute.delai, 60) + XCTAssertEqual(Verrouillage.cinqMinutes.delai, 300) + XCTAssertEqual(Verrouillage.quinzeMinutes.delai, 900) + } +} + +// ─── Accès d'urgence ────────────────────────────────────────────────────────── + +final class AccesDUrgenceTests: XCTestCase { + private func dto( + role: String = "view", status: String = "invited", waitDays: Int = 7, + requestedAt: Int? = nil, available: Bool? = nil + ) -> EmergencyContactDTO { + EmergencyContactDTO( + id: "lien-1", contactEmail: "kevin@stackops.ch", role: role, waitDays: waitDays, + status: status, requestedAt: requestedAt, available: available) + } + + func testUnLienValideSeTraduitFidelement() throws { + let lien = try XCTUnwrap(LienDUrgence(dto(role: "takeover", status: "requested"))) + XCTAssertEqual(lien.contactEmail, "kevin@stackops.ch") + XCTAssertEqual(lien.role, .takeover) + XCTAssertEqual(lien.etat, .requested) + XCTAssertEqual(lien.waitDays, 7) + } + + /// Un rôle ou un état que l'application ne connaît pas ne doit pas produire une ligne + /// muette dans l'écran : mieux vaut ne rien afficher que d'afficher n'importe quoi. + func testUnRoleInconnuEstEcarte() { + XCTAssertNil(LienDUrgence(dto(role: "administrateur"))) + } + + func testUnEtatInconnuEstEcarte() { + XCTAssertNil(LienDUrgence(dto(status: "en_cours_de_reflexion"))) + } + + /// Le serveur renvoie des millisecondes ; les confondre avec des secondes placerait la + /// demande en 1970 et l'ouverture prévue juste après. + func testLHorodatageEstLuEnMillisecondes() throws { + let quandEnMs = 1_800_000_000_000 + let lien = try XCTUnwrap( + LienDUrgence(dto(status: "requested", requestedAt: quandEnMs))) + XCTAssertEqual( + try XCTUnwrap(lien.requestedAt).timeIntervalSince1970, 1_800_000_000, accuracy: 1) + } + + func testLOuverturePrevueTombeApresLeDelai() throws { + let depart = 1_800_000_000_000 + let lien = try XCTUnwrap( + LienDUrgence(dto(status: "requested", waitDays: 3, requestedAt: depart))) + let attendue = Date(timeIntervalSince1970: 1_800_000_000 + 3 * 86_400) + XCTAssertEqual( + try XCTUnwrap(lien.ouverturePrevue()).timeIntervalSince1970, + attendue.timeIntervalSince1970, accuracy: 1) + } + + /// Sans demande en cours, il n'y a pas de date à annoncer — et en inventer une ferait + /// croire à un compte à rebours qui n'a pas commencé. + func testAucuneOuverturePrevueSansDemande() throws { + let accepte = try XCTUnwrap(LienDUrgence(dto(status: "accepted", requestedAt: nil))) + XCTAssertNil(accepte.ouverturePrevue()) + let invite = try XCTUnwrap( + LienDUrgence(dto(status: "invited", requestedAt: 1_800_000_000_000))) + XCTAssertNil(invite.ouverturePrevue()) + } + + /// `available` n'existe que dans le sens « je suis le contact ». Absent, il vaut faux : + /// on n'ouvre pas un coffre parce qu'un champ manquait. + func testLaDisponibiliteAbsenteVautFaux() throws { + XCTAssertFalse(try XCTUnwrap(LienDUrgence(dto(available: nil))).disponible) + XCTAssertTrue( + try XCTUnwrap(LienDUrgence(dto(status: "granted", available: true))).disponible) + } + + @MainActor + func testLesLibellesDesRolesSontTraduits() { + for role in RoleDUrgence.allCases { + XCTAssertFalse(role.intitule.isEmpty, "intitulé vide pour \(role.rawValue)") + XCTAssertFalse(role.explication.isEmpty, "explication vide pour \(role.rawValue)") + } + } +} + +// ─── Organisations ──────────────────────────────────────────────────────────── + +final class OrganisationsTests: XCTestCase { + private func dto(role: String = "member", status: String = "active") -> OrgSummaryDTO { + OrgSummaryDTO(orgId: "org-1", name: "Équipe Sécurité", role: role, status: status) + } + + func testUneOrganisationValideSeTraduitFidelement() throws { + let org = try XCTUnwrap(Organisation(dto(role: "admin", status: "invited"))) + XCTAssertEqual(org.id, "org-1") + XCTAssertEqual(org.nom, "Équipe Sécurité") + XCTAssertEqual(org.role, .admin) + XCTAssertEqual(org.etat, .invited) + } + + /// Un rôle que cette version ne connaît pas ne doit pas produire une ligne muette : on + /// préfère ne rien afficher qu'afficher une équipe dont on ignore ce qu'on y peut. + func testUnRoleInconnuEstEcarte() { + XCTAssertNil(Organisation(dto(role: "owner"))) + } + + func testUnEtatInconnuEstEcarte() { + XCTAssertNil(Organisation(dto(status: "pending"))) + } + + /// La lecture seule est le seul rôle qui n'écrit pas. Se tromper ici proposerait un + /// bouton « ajouter » que le serveur refuserait ensuite — une promesse non tenue. + func testSeuleLaLectureSeuleNEcritPas() { + XCTAssertFalse(RoleDOrganisation.readonly.peutEcrire) + XCTAssertTrue(RoleDOrganisation.member.peutEcrire) + XCTAssertTrue(RoleDOrganisation.admin.peutEcrire) + } + + @MainActor + func testLesLibellesDesRolesEtEtatsSontTraduits() { + for role in [RoleDOrganisation.admin, .member, .readonly] { + XCTAssertFalse(role.intitule.isEmpty, "intitulé vide pour \(role.rawValue)") + } + for etat in [EtatDAppartenance.invited, .active, .revoked] { + XCTAssertFalse(etat.intitule.isEmpty, "intitulé vide pour \(etat.rawValue)") + } + } + + /// Le coffre partagé et le coffre personnel ouvrent la même enveloppe : c'est ce que le + /// protocole garantit, et c'est ce qui permet de n'écrire ce code qu'une fois. + func testLeCoffreDEquipeOuvreLaMemeEnveloppeQueLeCompte() throws { + let inscription = try register(password: "correct horse battery staple", email: "clara@test.ch") + let compte = inscription.account() + let creation = try compte.createOrg() + let org = creation.org() + + let item = VaultItem( + name: "Serveur de production", notes: nil, folder: nil, + data: .login(Login(username: "root", password: "s3cr3t"))) + let json = String(data: try JSONEncoder().encode(item), encoding: .utf8) ?? "{}" + let chiffre = try org.encryptItem(itemJson: json) + + // Le DTO tel que le serveur le rendrait. + struct Enveloppe: Decodable { + let encryptedKey: String + let encryptedData: String + enum CodingKeys: String, CodingKey { + case encryptedKey = "encrypted_key" + case encryptedData = "encrypted_data" + } + } + let e = try JSONDecoder().decode(Enveloppe.self, from: Data(chiffre.utf8)) + let dto = EncryptedItemDTO( + id: "item-1", encryptedKey: e.encryptedKey, encryptedData: e.encryptedData) + + let relu = try org.ouvrir(dto) + XCTAssertEqual(relu.name, "Serveur de production") + + // Et le compte, lui, ne peut pas l'ouvrir : ce n'est pas sa clé. + XCTAssertThrowsError(try compte.ouvrir(dto)) + } +} + +// ─── Administration d'équipe ────────────────────────────────────────────────── + +final class AdministrationDEquipeTests: XCTestCase { + private func membre(id: String, email: String?, role: String = "member") -> MembreDEquipe? { + MembreDEquipe( + OrgMemberDTO(userId: id, email: email, role: role, status: "active")) + } + + func testUnMembreValideSeTraduitFidelement() throws { + let m = try XCTUnwrap(membre(id: "u1", email: "kevin@stackops.ch", role: "readonly")) + XCTAssertEqual(m.id, "u1") + XCTAssertEqual(m.email, "kevin@stackops.ch") + XCTAssertEqual(m.role, .readonly) + XCTAssertEqual(m.etat, .active) + } + + func testUnMembreSansAdresseResteUtilisable() throws { + // Le serveur peut rendre `email: null` ; la ligne doit tout de même s'afficher, + // sous l'identifiant, plutôt que de disparaître de la liste des membres. + let m = try XCTUnwrap(membre(id: "u2", email: nil)) + XCTAssertNil(m.email) + XCTAssertEqual(m.id, "u2") + } + + @MainActor + func testLesDroitsSurCollectionSontTousTraduits() { + for droit in DroitSurCollection.allCases { + XCTAssertFalse(droit.intitule.isEmpty, "intitulé vide pour \(droit.rawValue)") + } + XCTAssertEqual(DroitSurCollection.allCases.map(\.rawValue), ["read", "write", "manage"]) + } + + /// Le refus de rotation doit nommer la personne concernée : « rotation annulée » sans + /// dire de qui il s'agit laisserait l'administrateur sans rien à faire. + @MainActor + func testLeRefusDeRotationNommeLaPersonne() { + let message = RotationImpossible.cleIntrouvable(membre: "kevin@stackops.ch").message + XCTAssertTrue( + message.contains("kevin@stackops.ch"), + "le refus ne nomme pas la personne : « \(message) »") + } + + /// Une rotation ré-enveloppe les items **sans** toucher au contenu chiffré. Si le + /// contenu changeait, ce ne serait plus une rotation de clé mais un re-chiffrement + /// complet — beaucoup plus coûteux, et inutile. + func testLaRotationNeReChiffrePasLeContenu() throws { + let compte = try register(password: "correct horse battery staple", email: "clara@test.ch") + .account() + let ancienne = try compte.createOrg().org() + let nouvelle = try compte.createOrg().org() + + let item = VaultItem( + name: "Base de production", notes: nil, folder: nil, + data: .login(Login(username: "admin", password: "tr3s-secret"))) + let json = String(data: try JSONEncoder().encode(item), encoding: .utf8) ?? "{}" + let chiffre = try ancienne.encryptItem(itemJson: json) + let refait = try nouvelle.rewrapItem(oldOrg: ancienne, encryptedItemJson: chiffre) + + struct Enveloppe: Decodable { + let encryptedKey: String + let encryptedData: String + enum CodingKeys: String, CodingKey { + case encryptedKey = "encrypted_key" + case encryptedData = "encrypted_data" + } + } + let avant = try JSONDecoder().decode(Enveloppe.self, from: Data(chiffre.utf8)) + let apres = try JSONDecoder().decode(Enveloppe.self, from: Data(refait.utf8)) + XCTAssertEqual(avant.encryptedData, apres.encryptedData, "le contenu a été re-chiffré") + XCTAssertNotEqual(avant.encryptedKey, apres.encryptedKey, "l'enveloppe n'a pas changé") + + // Et la conséquence qui compte : l'ancienne clé ne lit plus l'item ré-enveloppé. + let dto = EncryptedItemDTO( + id: "i1", encryptedKey: apres.encryptedKey, encryptedData: apres.encryptedData) + XCTAssertNoThrow(try nouvelle.ouvrir(dto)) + XCTAssertThrowsError(try ancienne.ouvrir(dto)) + } +} + +// ─── Import depuis les gestionnaires concurrents ────────────────────────────── + +/// Un export par gestionnaire, avec ses en-têtes réels. Migrer est le premier geste d'un +/// nouvel utilisateur : ce qui se perd ici se perd pour de bon, et en silence. +final class ImportDepuisConcurrentsTests: XCTestCase { + private func seul(_ csv: String) throws -> VaultItem { + let items = CsvImport.items(csv) + XCTAssertEqual(items.count, 1, "une seule ligne attendue") + return try XCTUnwrap(items.first) + } + + /// L'identifiant que porte un item. `login` est une commodité de `VaultEntry`, pas de + /// `VaultItem` — ici on n'a que ce dernier, avant tout dépôt. + private func identifiant(_ item: VaultItem) throws -> Login { + guard case .login(let l) = item.data else { + throw XCTSkip("l'item importé n'est pas un identifiant") + } + return l + } + + func testBitwarden() throws { + let item = try seul( + """ + folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp + Travail,0,login,Forgejo,Compte de service — ne pas partager,,0,https://git.example.ch,clara,s3cr3t,JBSWY3DPEHPK3PXP + """) + XCTAssertEqual(item.name, "Forgejo") + XCTAssertEqual(item.folder, "Travail") + XCTAssertEqual(item.notes, "Compte de service — ne pas partager") + XCTAssertEqual(try identifiant(item).username, "clara") + XCTAssertEqual(try identifiant(item).password, "s3cr3t") + XCTAssertEqual(try identifiant(item).uris, ["https://git.example.ch"]) + XCTAssertEqual(try identifiant(item).totp, "JBSWY3DPEHPK3PXP") + } + + func test1Password() throws { + let item = try seul( + """ + Title,Url,Username,Password,OTPAuth,Favorite,Archived,Tags,Notes + Forgejo,https://git.example.ch,clara,s3cr3t,otpauth://totp/x,false,false,Travail,Note de migration + """) + XCTAssertEqual(item.name, "Forgejo") + XCTAssertEqual(item.folder, "Travail", "Tags sert de dossier") + XCTAssertEqual(item.notes, "Note de migration") + XCTAssertEqual(try identifiant(item).totp, "otpauth://totp/x") + } + + /// LastPass nomme la note `extra` et le dossier `grouping` : deux colonnes qu'aucune + /// autre n'emploie, et que l'ancienne table ignorait toutes les deux. + func testLastPass() throws { + let item = try seul( + """ + url,username,password,totp,extra,name,grouping,fav + https://git.example.ch,clara,s3cr3t,JBSWY3DPEHPK3PXP,Ma note LastPass,Forgejo,Travail,0 + """) + XCTAssertEqual(item.name, "Forgejo") + XCTAssertEqual(item.folder, "Travail") + XCTAssertEqual(item.notes, "Ma note LastPass") + } + + func testDashlane() throws { + let item = try seul( + """ + username,username2,username3,title,password,note,url,category,otpSecret + clara,,,Forgejo,s3cr3t,Note Dashlane,https://git.example.ch,Travail,JBSWY3DPEHPK3PXP + """) + XCTAssertEqual(item.name, "Forgejo") + XCTAssertEqual(item.folder, "Travail") + XCTAssertEqual(item.notes, "Note Dashlane") + XCTAssertEqual(try identifiant(item).totp, "JBSWY3DPEHPK3PXP") + } + + func testChrome() throws { + let item = try seul( + """ + name,url,username,password,note + git.example.ch,https://git.example.ch,clara,s3cr3t,Note Chrome + """) + XCTAssertEqual(item.name, "git.example.ch") + XCTAssertEqual(item.notes, "Note Chrome") + } + + func testKeePass() throws { + let item = try seul( + """ + "Group","Title","Username","Password","URL","Notes" + "Travail","Forgejo","clara","s3cr3t","https://git.example.ch","Note KeePass" + """) + XCTAssertEqual(item.name, "Forgejo") + XCTAssertEqual(item.folder, "Travail") + XCTAssertEqual(item.notes, "Note KeePass") + } + + /// Une note contenant une virgule, un saut de ligne et des guillemets doit survivre : + /// c'est le cas courant d'une note de plusieurs lignes, et celui qui casse les analyses + /// naïves. + func testUneNoteMultilignePasseEntiere() throws { + let csv = #""" + name,username,password,notes + Forgejo,clara,s3cr3t,"Première ligne, avec virgule + Deuxième ligne avec ""guillemets""" + """# + let item = try seul(csv) + XCTAssertEqual( + item.notes, "Première ligne, avec virgule\nDeuxième ligne avec \"guillemets\"") + } +} diff --git a/apps/ios/UITests/AutoFillSafariTests.swift b/apps/ios/UITests/AutoFillSafariTests.swift new file mode 100644 index 00000000..4bc4f2a6 --- /dev/null +++ b/apps/ios/UITests/AutoFillSafariTests.swift @@ -0,0 +1,127 @@ +import XCTest + +/// Remplissage automatique réel : une page de connexion ouverte dans Safari, et GhostPass +/// appelé depuis le clavier pour remplir le formulaire. +/// +/// Le chemin traverse des écrans du système dont l'agencement change d'une version d'iOS +/// à l'autre. Ce qui relève du système est donc traité en `skip` — on ne fait pas échouer +/// une intégration parce qu'Apple a déplacé un bouton. Ce qui relève de GhostPass, en +/// revanche, doit marcher : l'extension s'ouvre, déchiffre le coffre local, propose +/// l'identifiant du site, et le formulaire finit rempli. +/// +/// Prérequis posés par `tools/ios/run-ios-tests.sh` : application installée, extension +/// activée, session déposée par le parcours précédent, page servie sur le port 8099. +final class AutoFillSafariTests: XCTestCase { + private let page = "127.0.0.1:8099" + private let master = "correct horse battery staple" + + private func capture(_ app: XCUIApplication, _ nom: String) { + let a = XCTAttachment(screenshot: app.screenshot()) + a.name = nom + a.lifetime = .keepAlways + add(a) + } + + func test04Remplissage() throws { + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + safari.launch() + XCTAssertTrue(safari.wait(for: .runningForeground, timeout: 30), "Safari ne démarre pas") + sleep(3) + + let barre = safari.textFields.firstMatch + try XCTSkipUnless(barre.waitForExistence(timeout: 20), "barre d'adresse de Safari introuvable") + barre.tap() + sleep(1) + safari.typeText(page + "\n") + sleep(5) + + // Le champ de la page, trouvé dans l'arbre plutôt que deviné. La version précédente + // tapait à 23 % de la hauteur de l'écran : il suffisait que l'habillage de Safari + // change d'une version d'iOS à l'autre pour taper à côté, et le test concluait « le + // clavier ne s'est pas ouvert » — ce qui accusait le système d'une erreur de visée. + // + // Le tap reste en coordonnées : dans une vue web, le champ existe dans l'arbre sans + // toujours être « hittable », et un `tap()` ordinaire s'y perd. Mais on vise + // maintenant le centre de son cadre réel. + let champDeLaPage = safari.webViews.textFields.firstMatch + try XCTSkipUnless( + champDeLaPage.waitForExistence(timeout: 20), + "la page de test ne s'est pas chargée dans Safari") + let cadre = champDeLaPage.frame + safari.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: cadre.midX, dy: cadre.midY)) + .tap() + sleep(4) + // L'entrée « Mots de passe » vit dans la barre d'accessoires du clavier : sans + // clavier, pas de barre, donc pas de remplissage. Le constat a été fait en joignant + // l'écran au saut — l'hypothèse inverse, qu'un simulateur sans clavier logiciel + // proposerait quand même le remplissage, était fausse. + // + // Ce simulateur sans interface n'en affiche aucun, et le réglage + // `ConnectHardwareKeyboard` n'y peut rien : il est lu par l'application Simulator, + // qui ne tourne pas ici. C'est une limite de l'environnement, pas de GhostPass. + let sansClavier = safari.keyboards.count == 0 + if sansClavier { + NSLog("GP-AUTOFILL aucun clavier logiciel : la barre de remplissage n'existera pas") + } + + let motsDePasse = safari.buttons["Mots de passe"].firstMatch + if !motsDePasse.waitForExistence(timeout: 20) { + // Deux causes possibles, et le message seul ne les distingue pas : GhostPass + // n'est pas activé comme fournisseur de remplissage, ou le libellé a changé + // avec la version d'iOS. On joint donc l'écran plutôt que de trancher à + // l'aveugle — c'est la seule façon de savoir laquelle des deux. + capture(safari, "A0-sans-remplissage") + NSLog("GP-AUTOFILL écran sans « Mots de passe » :\n%@", safari.debugDescription) + throw XCTSkip( + sansClavier + ? "aucun clavier logiciel dans ce simulateur sans interface : la barre " + + "de remplissage n'y apparaît jamais, GhostPass n'est pas en cause" + : "le clavier est là mais ne propose pas « Mots de passe » : GhostPass " + + "n'est probablement pas activé comme fournisseur de remplissage") + } + motsDePasse.tap() + sleep(4) + + // À partir d'ici, c'est GhostPass qui est en cause. + let ghostpass = safari.buttons["GhostPass"].firstMatch + XCTAssertTrue( + ghostpass.waitForExistence(timeout: 20), + "GhostPass n'est pas proposé : extension non activée ou mal déclarée") + ghostpass.tap() + sleep(6) + capture(safari, "A1-extension") + + let champMaitre = safari.descendants(matching: .any) + .matching(identifier: "field.master").firstMatch + XCTAssertTrue( + champMaitre.waitForExistence(timeout: 30), + "l'extension ne demande pas le mot de passe maître — session absente du conteneur partagé ?") + champMaitre.tap() + safari.typeText(master) + safari.buttons["button.submit"].firstMatch.tap() + + // L'identifiant enregistré pour cette page doit remonter en tête, sous « Pour ce + // site » : c'est tout l'intérêt du remplissage, et ce qui distingue une + // suggestion d'une simple liste. + let attendu = safari.staticTexts["Site local"].firstMatch + XCTAssertTrue( + attendu.waitForExistence(timeout: 60), + "le coffre local ne s'est pas ouvert dans l'extension") + XCTAssertTrue( + safari.staticTexts["Pour ce site"].firstMatch.exists, + "l'identifiant de la page n'est pas reconnu comme correspondant") + capture(safari, "A2-liste") + + attendu.tap() + sleep(4) + capture(safari, "A3-rempli") + + // La preuve : le champ porte le nom d'utilisateur du coffre. + let rempli = safari.webViews.textFields.firstMatch + XCTAssertTrue(rempli.waitForExistence(timeout: 20)) + XCTAssertEqual( + rempli.value as? String, "clara", + "le formulaire n'a pas été rempli par l'extension") + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift new file mode 100644 index 00000000..f329f962 --- /dev/null +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -0,0 +1,815 @@ +import LocalAuthentication +import UIKit +import XCTest + +/// Parcours de bout en bout contre un serveur GhostPass local, amorcé par +/// `tools/ios/run-ios-tests.sh` avec un compte de test et un item de registre. +/// +/// Les champs sont adressés par `accessibilityIdentifier` — jamais par position ni par +/// libellé, qui dépendent tous deux de la mise en page et de la langue. La frappe passe +/// par `app.typeText` plutôt que par l'élément : re-résoudre une requête pendant que le +/// curseur clignote fait expirer la recherche d'instantané. +/// +/// L'adresse du serveur et le compte sont ceux qu'amorce le script ; les variables +/// d'environnement ne servent qu'à lancer ces tests à la main depuis Xcode. `xcodebuild` +/// ne transmet pas d'environnement au processus de test, d'où ces valeurs par défaut — +/// elles doivent rester alignées sur celles de `tools/ios/run-ios-tests.sh`. +final class VaultFlowTests: XCTestCase { + private lazy var env = ProcessInfo.processInfo.environment + private var server: String { env["GHOSTPASS_SERVER"] ?? "http://127.0.0.1:3111" } + private var email: String { env["GHOSTPASS_EMAIL"] ?? "clara@ghostpass.test" } + private var master: String { env["GHOSTPASS_PASSWORD"] ?? "correct horse battery staple" } + private var demoItem: String { env["GHOSTPASS_DEMO_ITEM"] ?? "GitHub" } + /// Le contact de confiance amorcé par le script : un compte sans coffre, qui n'existe + /// que pour avoir une clé publique vers laquelle sceller. + private var contact: String { env["GHOSTPASS_CONTACT"] ?? "kevin@ghostpass.test" } + + override func setUp() { + continueAfterFailure = false + NSLog("GP-CONF serveur=%@ email=%@", server, email) + } + + private func shot(_ app: XCUIApplication, _ name: String) { + let a = XCTAttachment(screenshot: app.screenshot()) + a.name = name + a.lifetime = .keepAlways + add(a) + } + + /// Un tap sur un champ SwiftUI en sélectionne tout le contenu : la frappe le remplace. + private func remplir(_ app: XCUIApplication, _ id: String, _ text: String) { + let field = app.descendants(matching: .any).matching(identifier: id).firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 30), "champ « \(id) » absent") + // Exister ne suffit pas : un champ apparaît dans la hiérarchie avant d'être + // frappable — une feuille finit de se refermer, un écran de s'installer. Le + // frapper trop tôt échoue sur « not hittable », ce qui ne ressemble en rien à + // sa cause. + let frappable = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "isHittable == true"), object: field) + XCTAssertEqual( + XCTWaiter().wait(for: [frappable], timeout: 15), .completed, + "champ « \(id) » hors d'atteinte — voici l'écran :\n\(app.debugDescription)") + field.tap() + app.typeText(text) + } + + private func seConnecter(_ app: XCUIApplication) { + let champServeur = app.descendants(matching: .any) + .matching(identifier: "field.server").firstMatch + if !champServeur.waitForExistence(timeout: 8) { + // Une session est déjà enregistrée : l'écran ne demande que le mot de passe. + let autre = app.buttons["button.switchAccount"] + XCTAssertTrue( + autre.waitForExistence(timeout: 20), + "ni formulaire de connexion, ni bascule vers un autre compte") + autre.tap() + } + remplir(app, "field.server", server) + remplir(app, "field.email", email) + remplir(app, "field.master", master) + app.buttons["button.submit"].tap() + XCTAssertTrue( + app.navigationBars["Coffre"].waitForExistence(timeout: 180), + "le coffre ne s'est pas ouvert — serveur injoignable ou identifiants refusés") + // La liste apparaît avant que le coffre ne soit déchiffré : tant que `refresh()` + // occupe le fil principal, aucune alerte ne peut se poser. On attend donc que + // l'écran soit vraiment en place avant de chercher la proposition biométrique. + _ = app.buttons["button.add"].waitForExistence(timeout: 60) + ecarterLaPropositionBiometrique(app) + } + + /// Frappe un élément même si l'interface vient de changer. + /// + /// Un `tap()` ordinaire commence par calculer un point de frappe ; sur une vue encore + /// en cours d'animation, ce calcul rend {-1, -1} et le geste se perd en silence. Le + /// tap en coordonnées, lui, vise le centre du cadre sans rien demander à personne. + private func taper(_ element: XCUIElement) { + if element.isHittable { + element.tap() + } else { + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + } + + /// Une connexion complète avec le mot de passe donné. Après une déconnexion il n'y a + /// plus de session enregistrée : le formulaire redemande tout, serveur compris. + private func seConnecterAvec(_ app: XCUIApplication, _ motDePasse: String) { + remplir(app, "field.server", server) + remplir(app, "field.email", email) + remplir(app, "field.master", motDePasse) + let valider = app.buttons["button.submit"] + degager(app, valider) + taper(valider) + } + + /// Referme et rouvre l'application, puis attend le formulaire de connexion. + private func relancer(_ app: XCUIApplication) { + app.terminate() + app.launch() + XCTAssertTrue( + app.descendants(matching: .any).matching(identifier: "field.master").firstMatch + .waitForExistence(timeout: 60), + "le formulaire de connexion ne revient pas après relance") + } + + /// Amène un élément à portée : referme le clavier, qui couvre le bas de l'écran, et + /// fait défiler si l'élément reste hors champ. + /// + /// Ce qui se trouve sous le clavier n'est pas « atteignable » : le tap ordinaire + /// échoue, et le tap en coordonnées de secours frappe une touche du clavier. Un tap + /// dans le vide ne referme rien en SwiftUI — c'est le défilement qui le fait, l'écran + /// le déclarant avec `.scrollDismissesKeyboard(.immediately)`. + private func degager(_ app: XCUIApplication, _ element: XCUIElement) { + for _ in 0..<3 { + if element.exists && element.isHittable { return } + app.swipeUp() + } + } + + private func aDisparu(_ element: XCUIElement, delai: TimeInterval) -> Bool { + let attente = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), object: element) + return XCTWaiter().wait(for: [attente], timeout: delai) == .completed + } + + /// Écarte la proposition d'activer la biométrie, si elle se présente. + /// + /// Cette alerte ne surgit qu'après le premier déverrouillage d'un appareil où une + /// biométrie est inscrite — et elle surgit à son rythme, une fois le déchiffrement du + /// coffre terminé. Tant qu'elle est là, elle intercepte toutes les frappes, et les + /// échecs qui suivent ne ressemblent en rien à leur cause. On l'attend donc pour de + /// bon, et on vérifie qu'elle est bien partie : le premier tap sur une alerte qui + /// s'anime encore ne porte pas. + private func ecarterLaPropositionBiometrique( + _ app: XCUIApplication, delai: TimeInterval = 20 + ) { + let plusTard = app.buttons["button.laterBiometric"].firstMatch + guard plusTard.waitForExistence(timeout: delai) else { return } + for _ in 0..<5 { + taper(plusTard) + if aDisparu(plusTard, delai: 3) { return } + } + XCTFail("la proposition d'activer la biométrie ne se referme pas") + } + + /// Les registres internes — dossiers, favoris — voyagent dans la liste comme les + /// autres éléments. Aucun ne doit s'y montrer, et pas seulement celui des dossiers : + /// c'est le préfixe qui les masque, il faut donc l'éprouver sur plusieurs. + private func aucuneLigneFantome(_ app: XCUIApplication, _ contexte: String) { + for nom in ["gp:folders", "gp:favorites", "gp:"] { + let fantome = app.staticTexts.containing( + NSPredicate(format: "label CONTAINS[c] %@", nom)) + XCTAssertEqual(fantome.count, 0, "ligne fantôme « \(nom) » visible (\(contexte))") + } + } + + private func ouvrirReglages(_ app: XCUIApplication) { + ouvrirLeMenu(app, "button.preferences") + XCTAssertTrue( + app.buttons["button.doneSettings"].waitForExistence(timeout: 20), + "l'écran des réglages ne s'est pas ouvert") + } + + /// Ouvre le menu du coffre et frappe une de ses entrées. + private func ouvrirLeMenu(_ app: XCUIApplication, _ identifiant: String) { + XCTAssertTrue( + deplierLeMenu(app, jusqua: identifiant), "« \(identifiant) » absent du menu") + app.buttons[identifiant].tap() + } + + /// Déplie le menu du coffre et attend qu'une entrée connue y paraisse. Rend `false` si + /// le menu ne s'est pas ouvert — à l'appelant de décider si c'est une erreur. + /// + /// Un tap sur une barre de navigation encore en cours de mise en page ne porte pas : + /// XCUITest calcule un point de frappe {-1, -1} et le menu ne s'ouvre jamais. On + /// réessaie plutôt que d'en conclure que l'entrée n'existe pas — cette confusion-là a + /// fait passer `test02Biometrie` pour « pas de biométrie sur ce simulateur » pendant + /// des mois, alors que l'application la voyait très bien. + @discardableResult + private func deplierLeMenu(_ app: XCUIApplication, jusqua identifiant: String) -> Bool { + let menu = app.buttons["button.settings"] + XCTAssertTrue(menu.waitForExistence(timeout: 30), "le menu du coffre est absent") + let entree = app.buttons[identifiant] + for _ in 0..<4 { + taper(menu) + if entree.waitForExistence(timeout: 5) { return true } + } + return false + } + + /// La luminance moyenne d'une capture, entre 0 et 1. + /// + /// C'est la seule façon honnête de vérifier qu'un thème s'applique : les couleurs ne + /// sont pas des éléments d'accessibilité, et se contenter de constater que la case + /// « Sombre » est cochée reviendrait à tester la case, pas le thème. + private func luminance(_ capture: XCUIScreenshot) -> Double { + guard let cg = capture.image.cgImage else { return -1 } + let largeur = 32, hauteur = 64 + var pixels = [UInt8](repeating: 0, count: largeur * hauteur * 4) + guard + let ctx = CGContext( + data: &pixels, width: largeur, height: hauteur, bitsPerComponent: 8, + bytesPerRow: largeur * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { return -1 } + ctx.draw(cg, in: CGRect(x: 0, y: 0, width: largeur, height: hauteur)) + var total = 0.0 + for i in stride(from: 0, to: pixels.count, by: 4) { + total += + 0.2126 * Double(pixels[i]) + 0.7152 * Double(pixels[i + 1]) + + 0.0722 * Double(pixels[i + 2]) + } + return total / Double(largeur * hauteur) / 255 + } + + func test01ParcoursComplet() throws { + let app = XCUIApplication() + app.launch() + + // 1. Connexion + seConnecter(app) + shot(app, "1-coffre") + + // 2. La liste montre le coffre, et rien de ce qui doit rester caché + XCTAssertTrue( + app.staticTexts[demoItem].waitForExistence(timeout: 30), + "l'item de démonstration « \(demoItem) » est absent : la liste n'a pas pu se charger") + aucuneLigneFantome(app, "après connexion") + + // 3. Création + let plus = app.buttons["button.add"] + XCTAssertTrue(plus.waitForExistence(timeout: 30)) + // `taper` et non `tap` : « Ajouter » vit dans la barre de navigation, où un tap + // ordinaire sur une vue encore en cours de mise en page rend {-1, -1} et se perd en + // silence. La reprise d'avant réessayait le même geste raté — elle a laissé passer + // un échec intermittent qui ne disait rien de son sujet, seulement que « field.name » + // était introuvable sur un écran qui ne l'a jamais porté. + var ouverte = false + for _ in 0..<4 { + taper(plus) + if app.buttons["button.cancel"].waitForExistence(timeout: 8) { + ouverte = true + break + } + } + XCTAssertTrue(ouverte, "la feuille de création ne s'ouvre pas") + remplir(app, "field.name", "Forgejo") + remplir(app, "field.username", "clara") + + // Le générateur : on vérifie qu'il propose bien quelque chose, puis on renonce — + // la suite du parcours a besoin d'un mot de passe connu. + app.buttons["button.generate"].tap() + let propose = app.staticTexts["text.generated"] + XCTAssertTrue(propose.waitForExistence(timeout: 20), "le générateur ne s'ouvre pas") + let premier = propose.label + XCTAssertGreaterThanOrEqual(premier.count, 8, "mot de passe généré trop court") + app.buttons["button.regenerate"].tap() + XCTAssertNotEqual(propose.label, premier, "régénérer redonne le même mot de passe") + shot(app, "5-generateur") + app.buttons["Annuler"].firstMatch.tap() + + remplir(app, "field.password", "s3cret-initial") + remplir(app, "field.uri", "https://git.stackops.ch") + remplir(app, "field.totp", "GEZDGNBVGY3TQOJQ") + app.buttons["button.save"].tap() + XCTAssertTrue( + app.staticTexts["Forgejo"].waitForExistence(timeout: 60), + "l'item créé n'apparaît pas dans la liste") + + // 4. Favori : l'étoile du détail, puis la section en tête de liste + app.buttons["Forgejo, clara"].firstMatch.tap() + let etoile = app.buttons["button.favorite"] + XCTAssertTrue(etoile.waitForExistence(timeout: 30), "l'étoile des favoris est absente") + taper(etoile) + if app.navigationBars.buttons["Coffre"].exists { app.navigationBars.buttons["Coffre"].tap() } + let sectionFavoris = app.descendants(matching: .any) + .matching(identifier: "header.favorites").firstMatch + XCTAssertTrue( + sectionFavoris.waitForExistence(timeout: 60), + "la section des favoris n'apparaît pas après la mise en favori") + // Le favori vit dans son propre registre chiffré : il ne doit pas non plus se + // montrer comme un élément. + aucuneLigneFantome(app, "après mise en favori") + shot(app, "4-favoris") + + // 5. Import : l'écran s'ouvre et explique. Le choix du fichier appartient au + // sélecteur du système ; ce qui nous revient — la lecture du CSV — est éprouvé + // par les tests de contrat, sur des vecteurs qu'un parcours ne saurait produire. + ouvrirLeMenu(app, "button.import") + XCTAssertTrue( + app.buttons["button.pickCsv"].waitForExistence(timeout: 30), + "l'écran d'import ne s'ouvre pas") + app.buttons["button.closeImport"].tap() + + // 6. Export : l'écran demande le mot de passe maître avant de fabriquer un + // fichier qui contiendra tout en clair. Le choix de la destination appartient au + // système ; ce qui nous revient — le format — est éprouvé par les contrats. + ouvrirLeMenu(app, "button.export") + let preparer = app.buttons["button.prepareExport"] + XCTAssertTrue(preparer.waitForExistence(timeout: 30), "l'écran d'export ne s'ouvre pas") + XCTAssertFalse( + preparer.isEnabled, "l'export part sans mot de passe maître") + app.buttons["button.closeExport"].tap() + + // 7. Santé du coffre + ouvrirLeMenu(app, "button.health") + let resume = app.descendants(matching: .any) + .matching(identifier: "card.healthSummary").firstMatch + XCTAssertTrue( + resume.waitForExistence(timeout: 30), + "l'écran de santé ne montre pas son résumé") + shot(app, "4-sante") + app.buttons["button.closeHealth"].tap() + + // 6. Modification + app.buttons["Forgejo, clara"].firstMatch.tap() + XCTAssertTrue(app.buttons["button.edit"].waitForExistence(timeout: 30)) + app.buttons["button.edit"].tap() + XCTAssertTrue(app.buttons["button.cancel"].waitForExistence(timeout: 30)) + remplir(app, "field.name", "Forgejo prod") + app.buttons["button.save"].tap() + XCTAssertTrue( + app.staticTexts["Forgejo prod"].waitForExistence(timeout: 60), + "l'écran de détail garde l'ancien contenu après modification") + + // 4b. Une modification ne doit emporter ni le mot de passe ni la clé TOTP : + // ni l'un ni l'autre n'a été touché, et leur disparition serait silencieuse. + app.buttons["button.reveal"].tap() + XCTAssertTrue( + app.staticTexts["s3cret-initial"].waitForExistence(timeout: 30), + "le mot de passe a été écrasé par l'édition") + let code = app.staticTexts["text.totp"] + XCTAssertTrue( + code.waitForExistence(timeout: 30), + "la clé TOTP a été effacée par l'édition") + XCTAssertEqual( + code.label.count, 6, "un code TOTP à six chiffres était attendu, pas « \(code.label) »") + XCTAssertTrue(code.label.allSatisfy(\.isNumber), "code TOTP non numérique : « \(code.label) »") + shot(app, "2-detail") + + // 6b. Changer le mot de passe archive l'ancien. C'est ce qui sauve un compte dont + // le changement a échoué à mi-chemin ; encore faut-il que l'écran le montre. + app.buttons["button.edit"].tap() + XCTAssertTrue(app.buttons["button.cancel"].waitForExistence(timeout: 30)) + remplir(app, "field.password", "s3cret-remplace") + app.buttons["button.save"].tap() + let historique = app.buttons["button.history"] + XCTAssertTrue( + historique.waitForExistence(timeout: 60), + "l'ancien mot de passe n'a pas été archivé") + taper(historique) + XCTAssertTrue( + app.staticTexts["Remplacé"].waitForExistence(timeout: 20), + "l'historique ne montre rien une fois déplié") + shot(app, "2-historique") + + // 4c. Dossiers : en créer un, y ranger l'élément, filtrer dessus + if app.navigationBars.buttons["Coffre"].exists { app.navigationBars.buttons["Coffre"].tap() } + app.buttons["button.folderFilter"].tap() + XCTAssertTrue( + app.buttons["button.newFolder"].waitForExistence(timeout: 20), "écran des dossiers absent") + app.buttons["button.newFolder"].tap() + remplir(app, "field.folderName", "Travail") + app.buttons["button.createFolder"].firstMatch.tap() + XCTAssertTrue( + app.staticTexts["Travail"].waitForExistence(timeout: 30), + "le dossier créé n'apparaît pas") + app.buttons["button.closeFolders"].firstMatch.tap() + + // On y range l'élément, puis on filtre : le dossier doit le contenir. + app.buttons["Forgejo prod, clara"].firstMatch.tap() + XCTAssertTrue(app.buttons["button.edit"].waitForExistence(timeout: 20)) + app.buttons["button.edit"].tap() + XCTAssertTrue(app.buttons["button.cancel"].waitForExistence(timeout: 20)) + remplir(app, "field.folder", "Travail") + app.buttons["button.save"].tap() + sleep(2) + if app.navigationBars.buttons["Coffre"].exists { app.navigationBars.buttons["Coffre"].tap() } + + app.buttons["button.folderFilter"].tap() + XCTAssertTrue(app.staticTexts["Travail"].waitForExistence(timeout: 20)) + app.staticTexts["Travail"].tap() + XCTAssertTrue( + app.staticTexts["Forgejo prod"].waitForExistence(timeout: 30), + "l'élément rangé n'apparaît pas dans son dossier") + XCTAssertFalse( + app.staticTexts[demoItem].exists, + "le filtre laisse passer un élément d'un autre dossier") + shot(app, "4c-dossier") + + // Retour à la vue complète, sans quoi la suite filtrerait sans le savoir. + app.buttons["button.folderFilter"].tap() + XCTAssertTrue(app.staticTexts["Tous les éléments"].waitForExistence(timeout: 20)) + app.staticTexts["Tous les éléments"].tap() + XCTAssertTrue(app.staticTexts[demoItem].waitForExistence(timeout: 30)) + + // 5. Suppression + if app.navigationBars.buttons["Coffre"].exists { app.navigationBars.buttons["Coffre"].tap() } + let ligne = app.buttons["Forgejo prod, clara"].firstMatch + XCTAssertTrue(ligne.waitForExistence(timeout: 30)) + ligne.swipeLeft() + XCTAssertTrue(app.buttons["Supprimer"].waitForExistence(timeout: 30)) + app.buttons["Supprimer"].tap() + expectation( + for: NSPredicate(format: "exists == false"), + evaluatedWith: app.staticTexts["Forgejo prod"]) + waitForExpectations(timeout: 60) + + // 5b. L'item supprimé est à la corbeille, d'où il revient + app.buttons["button.settings"].tap() + app.buttons["button.trash"].firstMatch.tap() + let dansLaCorbeille = app.staticTexts["Forgejo prod"] + XCTAssertTrue( + dansLaCorbeille.waitForExistence(timeout: 30), + "l'item supprimé n'est pas dans la corbeille") + shot(app, "5-corbeille") + // Balayage vers la droite : restaurer. + dansLaCorbeille.swipeRight() + XCTAssertTrue(app.buttons["Restaurer"].waitForExistence(timeout: 20)) + app.buttons["Restaurer"].tap() + // On vérifie que la corbeille se vide, et non que le nom disparaît : l'item + // restauré réapparaît dans le coffre, sous la feuille, où il reste visible de + // l'arbre d'accessibilité. + XCTAssertTrue( + app.staticTexts["Corbeille vide"].waitForExistence(timeout: 30), + "l'item restauré n'a pas quitté la corbeille") + app.buttons["button.closeTrash"].firstMatch.tap() + XCTAssertTrue( + app.staticTexts["Forgejo prod"].waitForExistence(timeout: 30), + "l'item restauré n'est pas revenu dans le coffre") + + // 5c. Cette fois pour de bon : suppression, puis purge confirmée + app.buttons["Forgejo prod, clara"].firstMatch.swipeLeft() + XCTAssertTrue(app.buttons["Supprimer"].waitForExistence(timeout: 20)) + app.buttons["Supprimer"].tap() + expectation( + for: NSPredicate(format: "exists == false"), + evaluatedWith: app.staticTexts["Forgejo prod"]) + waitForExpectations(timeout: 30) + + app.buttons["button.settings"].tap() + app.buttons["button.trash"].firstMatch.tap() + let aPurger = app.staticTexts["Forgejo prod"] + XCTAssertTrue(aPurger.waitForExistence(timeout: 30)) + aPurger.swipeLeft() + XCTAssertTrue(app.buttons["Supprimer"].waitForExistence(timeout: 20)) + app.buttons["Supprimer"].tap() + // Une suppression définitive se confirme : sans cette étape, rien ne part. + XCTAssertTrue( + app.buttons["button.confirmPurge"].firstMatch.waitForExistence(timeout: 20), + "la suppression définitive ne demande pas confirmation") + app.buttons["button.confirmPurge"].firstMatch.tap() + XCTAssertTrue( + app.staticTexts["Corbeille vide"].waitForExistence(timeout: 30), + "l'item purgé est encore là") + app.buttons["button.closeTrash"].firstMatch.tap() + + // 6. Verrouiller relâche vraiment les clés + app.buttons["button.lock"].tap() + XCTAssertTrue(app.buttons["button.submit"].waitForExistence(timeout: 30)) + XCTAssertFalse(app.staticTexts[demoItem].exists, "le coffre reste visible après verrouillage") + + // 7. Réouverture avec le seul mot de passe maître, sans réseau ni ressaisie du compte + remplir(app, "field.master", master) + app.buttons["button.submit"].tap() + XCTAssertTrue( + app.staticTexts[demoItem].waitForExistence(timeout: 180), + "la réouverture au mot de passe maître a échoué") + aucuneLigneFantome(app, "après réouverture") + XCTAssertFalse(app.staticTexts["Forgejo prod"].exists, "l'item supprimé est revenu") + shot(app, "3-reouvert") + } + + /// Enrôlement puis réouverture biométrique. Ne s'exécute que si le script a inscrit + /// une biométrie dans le simulateur et se charge d'envoyer les correspondances : + /// sinon le test est ignoré, jamais vert par accident. + func test02Biometrie() throws { + // On interroge le simulateur plutôt que de se fier à une variable transmise : + // `xcodebuild` n'en propage aucune jusqu'ici. Le script inscrit une biométrie et + // envoie les correspondances ; sans lui, ce test est ignoré, jamais vert par défaut. + try XCTSkipUnless( + LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil), + "aucune biométrie inscrite : lancez tools/ios/run-ios-tests.sh") + + let app = XCUIApplication() + app.launch() + + // Si la biométrie est déjà active, l'app se déverrouille seule au lancement. + if !app.navigationBars["Coffre"].waitForExistence(timeout: 45) { + seConnecter(app) + let plusTard = app.buttons["button.laterBiometric"].firstMatch + if plusTard.waitForExistence(timeout: 5) { plusTard.tap() } + } + + // Activation par les réglages, et non par la proposition qui suit la connexion : + // celle-ci ne paraît qu'une fois, alors que le réglage est toujours là — c'est + // d'ailleurs le seul recours après un « Plus tard ». + // « Santé du coffre » est toujours là, quelle que soit la biométrie : c'est donc + // elle qui atteste que le menu s'est bien déplié, et pas l'entrée qu'on cherche. + XCTAssertTrue( + deplierLeMenu(app, jusqua: "button.health"), + "le menu du coffre ne s'ouvre pas — impossible de conclure sur la biométrie") + + let activer = app.buttons["button.biometricOn"].firstMatch + let desactiver = app.buttons["button.biometricOff"].firstMatch + if activer.waitForExistence(timeout: 10) { + activer.tap() + remplir(app, "field.masterConfirm", master) + app.buttons["button.confirmBiometric"].firstMatch.tap() + // La feuille ne se referme que si le mot de passe a ouvert le coffre : la + // voir rester, c'est un refus du trousseau, qu'on remonte plutôt que + // d'échouer trois écrans plus loin sur un symptôme. + if app.buttons["button.confirmBiometric"].firstMatch.waitForExistence(timeout: 5), + app.navigationBars.staticTexts.containing( + NSPredicate(format: "label BEGINSWITH %@", "Activer")).element.exists + { + shot(app, "4-echec-activation") + XCTFail("activation biométrique refusée par le trousseau") + } + } else if desactiver.exists { + // Déjà active : refermer le menu sans y toucher. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap() + } else { + // Le menu est bien ouvert — « Santé du coffre » l'atteste — mais il ne propose + // ni activation ni désactivation : l'application ne voit réellement aucune + // biométrie, alors que le runner en voyait une. On s'arrête là plutôt que de + // rendre vert un chemin qu'on n'a pas exercé. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap() + throw XCTSkip( + "l'application ne voit pas de biométrie sur ce simulateur — " + + "déverrouillage biométrique non vérifié") + } + + // Le cycle qui compte : verrouiller, puis rouvrir sans aucune saisie. + app.buttons["button.lock"].tap() + sleep(5) + shot(app, "4-apres-verrouillage") + XCTAssertTrue( + app.staticTexts[demoItem].waitForExistence(timeout: 120), + "la biométrie n'a pas rouvert le coffre") + shot(app, "4-biometrie") + + // Reposer la machine comme on l'a trouvée. Un coffre qui se rouvre tout seul n'a + // plus d'écran de connexion : les tests suivants y cherchent un formulaire qui + // n'apparaît jamais, et échouent sur « ni formulaire, ni bascule de compte » — + // c'est-à-dire sur un symptôme qui ne dit rien de leur propre sujet. Tant que ce + // test sautait, la question ne se posait pas ; elle se pose maintenant qu'il + // s'exécute vraiment. + desactiverLaBiometrie(app) + } + + /// Coupe le déverrouillage biométrique s'il est actif, pour rendre à l'application son + /// écran de connexion. Sans effet s'il ne l'est pas. + private func desactiverLaBiometrie(_ app: XCUIApplication) { + guard deplierLeMenu(app, jusqua: "button.health") else { + XCTFail("le menu ne s'ouvre pas : impossible de reposer la biométrie") + return + } + let couper = app.buttons["button.biometricOff"].firstMatch + if couper.waitForExistence(timeout: 5) { + couper.tap() + } else { + // Déjà coupée : refermer le menu sans rien changer. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap() + } + XCTAssertTrue( + app.navigationBars["Coffre"].waitForExistence(timeout: 30), + "le coffre n'est pas revenu après la coupure de la biométrie") + } + + /// Le coffre doit s'ouvrir **sans serveur**. Lancé par le script après extinction du + /// backend, ce test suppose qu'un run précédent a laissé une session et une copie + /// locale — c'est-à-dire l'état d'un téléphone qui perd le réseau. + func test03HorsLigne() throws { + let app = XCUIApplication() + app.launch() + + // Une session est enregistrée : l'écran ne demande que le mot de passe maître. + let champ = app.descendants(matching: .any) + .matching(identifier: "field.master").firstMatch + XCTAssertTrue( + champ.waitForExistence(timeout: 30), + "aucune session enregistrée : lancez ce test après le parcours complet") + champ.tap() + app.typeText(master) + app.buttons["button.submit"].tap() + + // Déverrouiller ne demande pas le réseau : les blobs sont sur l'appareil. + XCTAssertTrue( + app.staticTexts[demoItem].waitForExistence(timeout: 180), + "le coffre ne s'ouvre pas hors ligne") + XCTAssertTrue( + app.otherElements["banner.offline"].waitForExistence(timeout: 30) + || app.staticTexts.containing( + NSPredicate(format: "label CONTAINS[c] %@", "Hors ligne")).element.exists, + "rien n'indique que le coffre affiché vient de l'appareil") + aucuneLigneFantome(app, "hors ligne") + shot(app, "6-hors-ligne") + } + + /// Le thème et la langue appartiennent à l'utilisateur, pas au système. + /// + /// Ce test ne se contente pas de cliquer : il vérifie que le titre du coffre change + /// bien de langue, que le choix survit à une relance, et que le fond de l'écran + /// s'éclaircit vraiment. Il repose ensuite tout comme il l'a trouvé — la suite qui + /// vient après lui attend une application en français. + func test05Preferences() throws { + let app = XCUIApplication() + app.launch() + seConnecter(app) + + // 1. Passage à l'anglais + ouvrirReglages(app) + app.buttons["row.langue.anglais"].tap() + app.buttons["button.doneSettings"].tap() + XCTAssertTrue( + app.navigationBars["Vault"].waitForExistence(timeout: 30), + "le coffre est resté en français après le passage à l'anglais") + shot(app, "7-anglais") + + // 2. Le choix survit à une relance : sinon ce ne serait qu'un état d'écran. + app.terminate() + app.launch() + XCTAssertTrue( + app.staticTexts["Vault saved on this device"].waitForExistence(timeout: 60), + "la langue choisie a été oubliée au redémarrage") + remplir(app, "field.master", master) + app.buttons["button.submit"].tap() + XCTAssertTrue(app.navigationBars["Vault"].waitForExistence(timeout: 180)) + + // 3. Thème clair, puis sombre, mesurés à l'écran + ouvrirReglages(app) + app.buttons["tile.apparence.clair"].tap() + app.buttons["button.doneSettings"].tap() + XCTAssertTrue(app.navigationBars["Vault"].waitForExistence(timeout: 20)) + let clair = luminance(app.screenshot()) + shot(app, "8-clair") + + ouvrirReglages(app) + app.buttons["tile.apparence.sombre"].tap() + app.buttons["button.doneSettings"].tap() + XCTAssertTrue(app.navigationBars["Vault"].waitForExistence(timeout: 20)) + let sombre = luminance(app.screenshot()) + shot(app, "9-sombre") + + XCTAssertGreaterThan( + clair, sombre + 0.25, + "le thème clair (\(clair)) ne se distingue pas du sombre (\(sombre))") + + // 4. Retour aux réglages du système, pour la suite des tests + ouvrirReglages(app) + app.buttons["tile.apparence.systeme"].tap() + app.buttons["row.langue.systeme"].tap() + app.buttons["button.doneSettings"].tap() + XCTAssertTrue( + app.navigationBars["Coffre"].waitForExistence(timeout: 30), + "le retour au réglage du système n'a pas ramené le français") + } + + /// Le parcours de récupération, en entier : créer une clé, oublier le mot de passe, + /// réinitialiser, rouvrir. + /// + /// C'est le seul chemin de l'application où une erreur coûte le coffre entier : sans + /// clé de récupération, un mot de passe maître oublié rend un coffre chiffré de bout + /// en bout définitivement illisible. Le test repose ensuite le compte comme il l'a + /// trouvé — il réinitialise une seconde fois vers le mot de passe d'origine — pour que + /// la suite hors ligne retrouve la session qu'elle attend. + func test06Recuperation() throws { + let app = XCUIApplication() + app.launch() + seConnecter(app) + + // 1. Créer la clé, et la lire pendant qu'elle est affichée : elle ne le sera plus. + ouvrirLeMenu(app, "button.recoveryKey") + let creer = app.buttons["button.createRecovery"] + XCTAssertTrue(creer.waitForExistence(timeout: 30), "l'écran de la clé ne s'ouvre pas") + taper(creer) + let affichee = app.staticTexts["text.recoveryKey"] + XCTAssertTrue( + affichee.waitForExistence(timeout: 60), "aucune clé de récupération n'est produite") + let cle = affichee.label + XCTAssertGreaterThan(cle.count, 8, "clé de récupération suspecte : « \(cle) »") + shot(app, "10-cle-recuperation") + app.buttons["button.closeRecovery"].tap() + + // 2. Se déconnecter : le mot de passe maître est « oublié ». + ouvrirLeMenu(app, "button.signOut") + XCTAssertTrue( + app.descendants(matching: .any).matching(identifier: "field.server").firstMatch + .waitForExistence(timeout: 60), + "la déconnexion ne ramène pas au formulaire de connexion") + + // 3. Réinitialiser vers un nouveau mot de passe, puis s'en servir. + reinitialiser(app, cle: cle, versLeMotDePasse: "nouveau mot de passe maître") + // On repart d'un écran neuf : une feuille qui vient de se refermer laisse parfois + // le clavier ou un voile de présentation devant les champs, et c'est de toute + // façon ce que ferait quelqu'un qui vient de réinitialiser son mot de passe. + relancer(app) + seConnecterAvec(app, "nouveau mot de passe maître") + XCTAssertTrue( + app.navigationBars["Coffre"].waitForExistence(timeout: 180), + "le nouveau mot de passe n'ouvre pas le coffre") + XCTAssertTrue( + app.staticTexts[demoItem].waitForExistence(timeout: 60), + "le coffre s'ouvre mais son contenu ne se déchiffre plus") + shot(app, "11-recupere") + + // 4. Reposer le compte comme on l'a trouvé. + ouvrirLeMenu(app, "button.signOut") + XCTAssertTrue( + app.descendants(matching: .any).matching(identifier: "field.server").firstMatch + .waitForExistence(timeout: 60)) + reinitialiser(app, cle: cle, versLeMotDePasse: master) + relancer(app) + seConnecterAvec(app, master) + XCTAssertTrue( + app.navigationBars["Coffre"].waitForExistence(timeout: 180), + "le mot de passe d'origine ne revient pas") + ecarterLaPropositionBiometrique(app, delai: 5) + } + + + /// Confier l'accès de son coffre à un contact, puis le retirer. + /// + /// Ce que le test prouve vraiment, c'est la chaîne complète du scellement : l'app va + /// chercher la clé publique du contact, demande au cœur Rust de sceller l'USK vers + /// elle, et le serveur accepte un blob qu'il ne peut pas ouvrir. Aucune de ces trois + /// étapes n'est observable depuis l'écran — seul leur enchaînement l'est, sous la + /// forme d'un contact qui apparaît dans la liste. + func test07Urgence() throws { + let app = XCUIApplication() + app.launch() + seConnecter(app) + + ouvrirLeMenu(app, "button.emergency") + let inviter = app.buttons["button.inviteEmergency"] + XCTAssertTrue( + inviter.waitForExistence(timeout: 30), "l'écran d'accès d'urgence ne s'ouvre pas") + shot(app, "12-urgence-vide") + + // 1. Confier l'accès. Le rôle par défaut suffit : c'est le scellement qu'on éprouve. + taper(inviter) + remplir(app, "field.emergencyEmail", contact) + let confirmer = app.buttons["button.confirmInvite"] + XCTAssertTrue(confirmer.waitForExistence(timeout: 30), "le formulaire d'invitation manque") + degager(app, confirmer) + confirmer.tap() + + // 2. Le contact apparaît. S'il n'apparaît pas, c'est que l'une des trois étapes + // invisibles a échoué — la recherche de clé, le scellement, ou le dépôt. + let ligne = app.staticTexts[contact] + XCTAssertTrue( + ligne.waitForExistence(timeout: 60), + "le contact n'apparaît pas : recherche de clé publique, scellement ou dépôt en échec") + shot(app, "13-urgence-confie") + + // 3. Le retirer, et vérifier qu'il s'en va pour de bon. + app.buttons["Retirer cet accès"].firstMatch.tap() + let retirer = app.buttons["Retirer"] + XCTAssertTrue(retirer.waitForExistence(timeout: 30), "la confirmation de retrait manque") + retirer.tap() + XCTAssertTrue( + aDisparu(ligne, delai: 60), "le contact reste affiché après le retrait") + + app.buttons["button.closeEmergency"].tap() + XCTAssertTrue( + app.navigationBars["Coffre"].waitForExistence(timeout: 30), + "la fermeture ne ramène pas au coffre") + aucuneLigneFantome(app, "après un aller-retour par l'accès d'urgence") + } + + + /// Remplit le formulaire « mot de passe oublié » et attend la confirmation. Laisse + /// l'écran de connexion prêt, serveur et compte déjà saisis. + private func reinitialiser( + _ app: XCUIApplication, cle: String, versLeMotDePasse motDePasse: String + ) { + remplir(app, "field.server", server) + remplir(app, "field.email", email) + let oublie = app.buttons["button.forgotPassword"] + XCTAssertTrue(oublie.waitForExistence(timeout: 30), "« Mot de passe oublié » est absent") + degager(app, oublie) + XCTAssertTrue( + oublie.isHittable, + "« Mot de passe oublié » reste hors d'atteinte — clavier ou défilement") + oublie.tap() + XCTAssertTrue( + app.buttons["button.submitRecovery"].waitForExistence(timeout: 30), + "l'écran de réinitialisation ne s'ouvre pas") + remplir(app, "field.recoveryKey", cle) + remplir(app, "field.newMaster", motDePasse) + let valider = app.buttons["button.submitRecovery"] + degager(app, valider) + taper(valider) + XCTAssertTrue( + app.staticTexts.matching(identifier: "text.recovered").firstMatch + .waitForExistence(timeout: 120), + "la réinitialisation n'aboutit pas") + // Ce qui est derrière une feuille existe encore : le message de confirmation se + // trouve sur l'écran de connexion, et se voit donc avant même que la feuille soit + // partie. Sans cette attente, la frappe suivante viserait un champ recouvert. + XCTAssertTrue( + aDisparu(valider, delai: 30), + "la feuille de réinitialisation ne se referme pas") + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml new file mode 100644 index 00000000..59732e02 --- /dev/null +++ b/apps/ios/project.yml @@ -0,0 +1,124 @@ +# Projet Xcode décrit en texte : `xcodegen generate` reconstruit le .xcodeproj. +# Un .xcodeproj est un format que deux personnes ne peuvent pas éditer sans conflit ; +# ce fichier-ci se relit, se diffe et se fusionne. Le .xcodeproj n'est pas versionné. +name: Ghostpass +options: + bundleIdPrefix: ch.stackops + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + # L'interface s'écrit en français ; c'est donc le français qui sert de clef dans le + # catalogue, et l'anglais qui en est une traduction. Déclarer les deux régions ici + # évite qu'un `en` oublié fasse retomber l'app sur la langue de développement. + developmentLanguage: fr + knownRegions: [fr, en, Base] + +settings: + base: + SWIFT_VERSION: "5.9" + # Vide en local : Xcode signe alors avec un profil de développement automatique. + # La CI et les builds TestFlight l'injectent. + DEVELOPMENT_TEAM: ${DEVELOPMENT_TEAM} + CODE_SIGN_STYLE: Automatic + # Sans cela, la compilation ne relève pas les chaînes du code et le catalogue reste + # vide : on croirait l'application traduite alors que rien n'aurait été extrait. + SWIFT_EMIT_LOC_STRINGS: YES + +targets: + Ghostpass: + type: application + platform: iOS + sources: + - path: Ghostpass + # Bindings produits par tools/ios/build-xcframework.sh : compilés avec l'app, + # jamais édités à la main, jamais versionnés. + - path: Generated + dependencies: + # Bibliothèque statique : liée, jamais embarquée (embed: false). + - framework: GhostpassCrypto.xcframework + embed: false + # L'extension voyage dans l'application : c'est ainsi qu'iOS la découvre. + - target: GhostpassAutoFill + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ch.stackops.ghostpass + CODE_SIGN_ENTITLEMENTS: Ghostpass/Ghostpass.entitlements + GENERATE_INFOPLIST_FILE: YES + # Icône : la déclinaison GhostPass de la charte ghost-suite, variante « remplie » + # (assets/logo/ghostpass-icon.svg) — celle que les autres produits emploient + # comme icône, le trait du logo disparaissant aux petites tailles. + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + INFOPLIST_KEY_CFBundleDisplayName: GhostPass + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait + # Sans cette clé, iOS tue l'app à la première demande biométrique. + INFOPLIST_KEY_NSFaceIDUsageDescription: >- + Face ID déverrouille votre coffre sans ressaisir le mot de passe maître. + OTHER_LDFLAGS: -lc++ + + # Remplissage automatique : une cible à part, lancée par iOS dans son propre processus. + # Elle partage les sources du modèle et des services — le même code déchiffre le coffre + # des deux côtés, plutôt qu'une seconde implémentation qui divergerait. + GhostpassAutoFill: + type: app-extension + platform: iOS + sources: + - path: GhostpassAutoFill + - path: Ghostpass/Model + - path: Ghostpass/Services + - path: Ghostpass/Theme + # L'extension a son propre paquet : sans le catalogue, ses écrans resteraient + # en français quelle que soit la langue choisie dans l'application. + - path: Ghostpass/Resources + - path: Generated + dependencies: + - framework: GhostpassCrypto.xcframework + embed: false + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ch.stackops.ghostpass.autofill + INFOPLIST_FILE: GhostpassAutoFill/Info.plist + CODE_SIGN_ENTITLEMENTS: GhostpassAutoFill/GhostpassAutoFill.entitlements + INFOPLIST_KEY_CFBundleDisplayName: GhostPass + INFOPLIST_KEY_NSFaceIDUsageDescription: >- + Face ID déverrouille votre coffre pour remplir vos identifiants. + OTHER_LDFLAGS: -lc++ + + # Tests de contrat : la couture JSON serveur ↔ cœur Rust ↔ types Swift. + # Ni réseau ni interface — ils tournent en quelques secondes. + GhostpassTests: + type: bundle.unit-test + platform: iOS + sources: [Tests] + dependencies: + - target: Ghostpass + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ch.stackops.ghostpass.tests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: $(BUILT_PRODUCTS_DIR)/Ghostpass.app/Ghostpass + BUNDLE_LOADER: $(TEST_HOST) + + # Parcours de bout en bout. Exige un serveur GhostPass amorcé : passer par + # tools/ios/run-ios-tests.sh, qui le démarre et crée le compte de test. + GhostpassUITests: + type: bundle.ui-testing + platform: iOS + sources: [UITests] + dependencies: + - target: Ghostpass + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ch.stackops.ghostpass.uitests + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: Ghostpass + +schemes: + Ghostpass: + build: + targets: + Ghostpass: all + test: + targets: + - GhostpassTests + - GhostpassUITests diff --git a/apps/server/test/api-shape.test.ts b/apps/server/test/api-shape.test.ts new file mode 100644 index 00000000..ea90dcf5 --- /dev/null +++ b/apps/server/test/api-shape.test.ts @@ -0,0 +1,128 @@ +// Forme des réponses de l'API — le contrat que lisent les clients. +// +// Ces tests ne vérifient pas des valeurs mais des **types**. Ils existent parce que deux +// divergences de forme, invisibles côté serveur, ont rendu l'application iOS inutilisable : +// +// — `kdfParams` est une *chaîne* contenant du JSON, jamais un objet : la colonne est un +// TEXT et le serveur la renvoie verbatim. Le client iOS l'attendait en objet, le +// ré-encodait, et obtenait une chaîne doublement échappée que serde refuse. Toute +// connexion échouait dès le calcul du hash d'authentification. +// — `createdAt` / `updatedAt` / `deletedAt` sont des *nombres* — des millisecondes +// depuis l'epoch, les colonnes étant des INTEGER. Le client iOS les attendait en +// chaînes, et le décodage échouait pour la liste entière : le coffre restait vide. +// +// Aucun test ne les couvrait, ni ici ni côté client : le serveur était juste, et les +// clients se trompaient chacun de leur côté. Les tests iOS disent la même chose, mais ils +// réclament un runner macOS que la forge n'a pas ; ceux-ci tournent partout. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildApp } from "../src/app.js"; +import { openDatabase } from "../src/db/database.js"; + +const KDF = JSON.stringify({ mem_cost_kib: 65536, time_cost: 3, parallelism: 4 }); + +const REG = { + email: "kevin@stackops.ch", + masterPasswordHash: "client-auth-hash-AAA", + kdfParams: KDF, + encryptedUserKey: "2.bm9uY2U.Y2lwaGVy", + encryptedPrivateKey: "2.bm9uY2Uy.Y2lwaGVyMg", + publicKey: "cHVibGlja2V5LWJhc2U2NA", +}; + +const ITEM = { encryptedKey: "2.aWtleW5vbmNl.aWtleWN0", encryptedData: "2.ZGF0YW5vbmNl.ZGF0YWN0" }; + +async function appWithUser() { + const app = buildApp(openDatabase(":memory:")); + const res = await app.inject({ method: "POST", url: "/api/auth/register", payload: REG }); + return { app, token: res.json().token as string }; +} + +function auth(token: string) { + return { authorization: `Bearer ${token}` }; +} + +test("prelogin renvoie kdfParams en chaîne, pas en objet", async () => { + const app = buildApp(openDatabase(":memory:")); + for (const email of ["inconnu@example.com", REG.email]) { + if (email === REG.email) { + await app.inject({ method: "POST", url: "/api/auth/register", payload: REG }); + } + const res = await app.inject({ + method: "POST", + url: "/api/auth/prelogin", + payload: { email }, + }); + assert.equal(res.statusCode, 200); + const { kdfParams } = res.json(); + assert.equal(typeof kdfParams, "string", `kdfParams doit rester une chaîne (${email})`); + // Et une chaîne que le cœur sait relire : du JSON, avec les champs attendus. + const parsed = JSON.parse(kdfParams); + assert.equal(typeof parsed.mem_cost_kib, "number"); + assert.equal(typeof parsed.time_cost, "number"); + assert.equal(typeof parsed.parallelism, "number"); + } + await app.close(); +}); + +test("login renvoie les blobs d'ouverture et kdfParams en chaîne", async () => { + const { app } = await appWithUser(); + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { email: REG.email, masterPasswordHash: REG.masterPasswordHash }, + }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(typeof body.token, "string"); + assert.equal(typeof body.kdfParams, "string", "kdfParams doit rester une chaîne"); + assert.equal(typeof body.encryptedUserKey, "string"); + assert.equal(typeof body.encryptedPrivateKey, "string"); + await app.close(); +}); + +test("les horodatages des items sont des nombres", async () => { + const { app, token } = await appWithUser(); + const cree = await app.inject({ + method: "POST", + url: "/api/vault/items", + headers: auth(token), + payload: ITEM, + }); + assert.equal(cree.statusCode, 201); + + for (const item of [cree.json(), (await app.inject({ + method: "GET", + url: "/api/vault/items", + headers: auth(token), + }).then((r) => r.json())).items[0]]) { + assert.equal(typeof item.id, "string"); + assert.equal(typeof item.encryptedKey, "string"); + assert.equal(typeof item.encryptedData, "string"); + assert.equal(typeof item.createdAt, "number", "createdAt doit rester un nombre"); + assert.equal(typeof item.updatedAt, "number", "updatedAt doit rester un nombre"); + // `deletedAt` est nul tant que l'item n'est pas à la corbeille — jamais une chaîne. + assert.equal(item.deletedAt, null); + } + await app.close(); +}); + +test("un item mis à la corbeille porte un deletedAt numérique", async () => { + const { app, token } = await appWithUser(); + const { id } = (await app.inject({ + method: "POST", + url: "/api/vault/items", + headers: auth(token), + payload: ITEM, + })).json(); + + await app.inject({ method: "DELETE", url: `/api/vault/items/${id}`, headers: auth(token) }); + const corbeille = (await app.inject({ + method: "GET", + url: "/api/vault/trash", + headers: auth(token), + })).json(); + assert.equal(corbeille.items.length, 1); + assert.equal(typeof corbeille.items[0].deletedAt, "number"); + await app.close(); +}); diff --git a/apps/web/index.html b/apps/web/index.html index 5cb60bc0..a5da77e4 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,15 +3,13 @@ - + + GhostPass — Coffre zero-knowledge - - - +
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 00000000..04643520 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/apps/web/src/App.svelte b/apps/web/src/App.svelte index 83ac82cb..69368fc6 100644 --- a/apps/web/src/App.svelte +++ b/apps/web/src/App.svelte @@ -14,6 +14,7 @@ ensureCryptoReady, faviconUrl, FOLDERS_ITEM_NAME, + REGISTRY_PREFIX, openEmergency, recoverAccount, register, @@ -135,12 +136,19 @@ for (const r of rows) { const enc = encryptItem(account, { kind: "login", + // Noms relevés dans les exports réels : Bitwarden, 1Password, LastPass, + // Dashlane, Chrome, KeePass. `extra` est la note de LastPass, `note` au + // singulier celle de Dashlane et Chrome — les omettre perdait les notes de tout + // coffre migré, sans rien dire. Cette table doit rester identique à celle de + // CsvImport.swift, sans quoi migrer depuis le téléphone et migrer depuis le + // navigateur donneraient deux coffres différents. name: r.name || r.title || "(sans nom)", - username: r.username || r.login_username || r.login || "", + username: r.username || r.login_username || r.login || r.user || "", password: r.password || r.login_password || "", - url: r.url || r.login_uri || r.website || r.uri || "", - folder: r.folder || r.vault || r.group || "", - totp: r.totp || r.login_totp || r.otpauth || "", + url: r.url || r.login_uri || r.website || r.uri || r.urls || "", + folder: r.folder || r.vault || r.group || r.grouping || r.category || r.tags || "", + totp: r.totp || r.login_totp || r.otpauth || r.otpsecret || r.otp || "", + note: r.notes || r.note || r.extra || r.comments || "", }); await api.createItem(token, enc); n++; @@ -396,7 +404,7 @@ const vault = openEmergency(account, data.grantorPublicKey, data.sealedUserKey); emgViewItems = data.items .map((it) => decryptEmergencyItem(vault, it.encryptedKey, it.encryptedData)) - .filter((i) => i.name !== FOLDERS_ITEM_NAME); + .filter((i) => !i.name.startsWith(REGISTRY_PREFIX)); emgViewFrom = data.grantorEmail; } catch (err) { error = errMsg(err); @@ -897,7 +905,7 @@ if (r.kind === "folders") { registryId = d.id; registryPaths = r.paths; - } else { + } else if (r.kind === "item") { entries.push({ ...r.item, id: d.id, updatedAt: d.updatedAt }); } } @@ -1106,9 +1114,18 @@ {#snippet logoMark()} - - - + + {/snippet} {#snippet eyeIcon()} diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 77f2d5a6..e1c1a2ad 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3,35 +3,66 @@ :root { /* Typographies */ - --font-sans: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; - --font-serif: "Spectral", Georgia, "Times New Roman", serif; - --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; + /* Les polices de la suite, à l'identique de ghostbit et ghostmon. Aucune n'est + téléchargée : un coffre zero-knowledge qui appelle Google Fonts à chaque ouverture + livrerait l'adresse IP de ses utilisateurs à un tiers, à chaque déverrouillage. */ + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + /* Le serif de l'ancienne charte n'existe plus dans la suite ; les titres prennent la + police d'interface. Le jeton reste, pour ne pas casser ce qui le référence. */ + --font-serif: var(--font-sans); + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, Consolas, monospace; /* Rayons (partagés) */ --radius: 12px; --radius-sm: 8px; --radius-pill: 999px; - /* ── Thème SOMBRE (défaut) ── */ - --canvas: #15171c; - --surface: #1f2229; - --surface-2: #292d35; - --ink: #e7e9ed; - --ink-2: #a9afba; - --ink-3: #757c88; - --line: #2f333b; - --line-2: #3a3f49; - - --accent: #5394f5; - --accent-2: #6ea7f7; - --accent-soft: #243349; - --ring: rgba(83, 148, 245, 0.35); - - --danger: #ef6b72; + /* ── Thème SOMBRE (défaut) ── + Base Dracula, commune à toute la suite ; seule la teinte de marque distingue les + produits. Pour GhostPass c'est le bleu #2E7DFF, celui du logo — le seul créneau + froid encore libre : ghostbit est violet, ghostmon lime, ghostboard orange, + ghostauth menthe, ghostcal cyan, ghostmail jaune. */ + --canvas: #21222c; + --surface: #282a36; + --surface-2: #323445; + --ink: #f8f8f2; + --ink-2: #8b9cc8; + --ink-3: #6b7a9e; + --line: #44475a; + --line-2: #565a70; + + /* Marque : bleu néon */ + --brand-primary: #2e7dff; + --brand-light: #7fb2ff; + --brand-deep: #1a4fcc; + --brand-ghost: #d6e6ff; + --brand-bg: #080d18; + --brand-gradient: linear-gradient(135deg, #2e7dff 0%, #1a4fcc 100%); + /* Le néon de la suite : un noyau serré et vif, une aura large et discrète. Les deux + comptent — un seul halo fait une tache molle, la superposition fait une source. */ + --brand-glow: drop-shadow(0 0 8px rgba(46, 125, 255, 0.6)) + drop-shadow(0 0 20px rgba(46, 125, 255, 0.35)); + /* Le même halo en `box-shadow`, pour ce qui a un fond : `filter` s'applique au rendu + entier de l'élément et mangerait son propre remplissage. */ + --glow-shadow: 0 0 10px rgba(46, 125, 255, 0.55), 0 0 28px rgba(46, 125, 255, 0.32); + --glow-shadow-faible: 0 0 12px rgba(46, 125, 255, 0.25); + /* Voiles de survol. Sur la nuit ils éclaircissent, en plein jour ils assombrissent : un + voile sombre posé sur du sombre ne se voit pas, et le survol cesse de répondre. */ + --voile: rgba(255, 255, 255, 0.06); + --voile-fort: rgba(255, 255, 255, 0.10); + --bg-gradient: linear-gradient(150deg, #080d18 0%, #0a1220 45%, #080f1a 75%, #090a10 100%); + + --accent: var(--brand-primary); + --accent-2: var(--brand-light); + --accent-soft: rgba(46, 125, 255, 0.15); + --ring: rgba(46, 125, 255, 0.35); + + /* Sémantiques : celles de la base Dracula partagée. */ + --danger: #ff5555; --danger-soft: #3a2123; - --success: #4fc28a; + --success: #50fa7b; --success-soft: #1d3a2c; - --warn: #e0a23c; + --warn: #ffb86c; --warn-soft: #3a2f1a; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); @@ -54,10 +85,17 @@ --line: #e4e7ec; --line-2: #d3d8e0; - --accent: #2e5cc5; + --accent: var(--brand-deep); --accent-2: #244a9e; --accent-soft: #eaf0fb; - --ring: rgba(46, 92, 197, 0.26); + --ring: rgba(26, 79, 204, 0.26); + /* En plein jour, le néon devient une bavure : on le réduit sans le supprimer. */ + --brand-glow: drop-shadow(0 0 6px rgba(46, 125, 255, 0.3)); + --glow-shadow: none; + --glow-shadow-faible: none; + --voile: rgba(15, 23, 42, 0.05); + --voile-fort: rgba(15, 23, 42, 0.09); + --bg-gradient: none; --danger: #b3261e; --danger-soft: #fbece9; @@ -86,7 +124,11 @@ body { color: var(--ink); font-size: 15px; line-height: 1.5; + /* Le dégradé de la suite empêche le noir d'être plat ; en thème clair il n'a pas lieu + d'être, et `--canvas` reprend la main. */ background: var(--canvas); + background-image: var(--bg-gradient, none); + background-attachment: fixed; } /* ─── Typographie ─── */ @@ -145,15 +187,16 @@ code, place-items: center; width: 1.9em; height: 1.9em; - color: #fff; - background: linear-gradient(160deg, var(--accent), var(--accent-2)); - border-radius: 9px; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2); + /* Pas de badge : la silhouette porte elle-même le dégradé de la charte. Le poser aussi en + fond aurait rendu le fantôme invisible sur son propre dégradé. */ + background: none; + /* La marque rayonne : c'est elle qui rattache GhostPass au reste de la suite. */ + filter: var(--brand-glow); } .brand .mark svg { - width: 1em; - height: 1em; + width: 100%; + height: 100%; } /* ─── App shell : topbar + (sidebar · liste · détail) ─── */ @@ -239,7 +282,7 @@ code, } .nav-item:hover { - background: rgba(20, 23, 28, 0.05); + background: var(--voile); color: var(--ink); } @@ -437,7 +480,7 @@ code, } .tree-all:hover { - background: rgba(128, 135, 145, 0.12); + background: var(--voile-fort); color: var(--ink); } @@ -469,7 +512,7 @@ code, } .tree-folder:hover { - background: rgba(128, 135, 145, 0.12); + background: var(--voile-fort); } .tree-folder.active { @@ -939,17 +982,31 @@ a.kv-value:hover { justify-content: space-between; gap: 2rem; padding: clamp(2rem, 5vw, 3.75rem); - color: #fff; - background: linear-gradient(150deg, var(--accent) 0%, var(--accent-2) 68%, #16306b 100%); + color: var(--ink); + /* La suite pose du sombre et laisse le néon faire l'accent. Inonder la moitié de l'écran + de la couleur de marque écrasait tout le reste — et rendait le halo invisible, faute + de nuit pour le porter. */ + background: var(--bg-gradient, var(--canvas)); overflow: hidden; } +/* Le halo de marque, diffusé depuis le haut : la lueur du fantôme. */ +.auth-brand::before { + content: ""; + position: absolute; + inset: -20% -20% auto -20%; + height: 70%; + background: radial-gradient(60% 60% at 30% 0%, var(--accent-soft), transparent 70%); + filter: blur(40px); + pointer-events: none; +} + /* Motif de points discret, estompé vers un coin */ .auth-brand::after { content: ""; position: absolute; inset: 0; - background-image: radial-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px); + background-image: radial-gradient(var(--voile-fort) 1px, transparent 1px); background-size: 22px 22px; -webkit-mask-image: radial-gradient(120% 75% at 85% 15%, #000, transparent 70%); mask-image: radial-gradient(120% 75% at 85% 15%, #000, transparent 70%); @@ -962,13 +1019,18 @@ a.kv-value:hover { } .auth-brand .brand { - color: #fff; + /* Ces règles supposaient un panneau bleu foncé, donc du blanc partout. Le panneau suit + maintenant le thème : en plein jour, du blanc y devient invisible. */ + color: var(--ink); font-size: 1.3rem; } .auth-brand .brand .mark { - background: rgba(255, 255, 255, 0.16); + /* Pas de fond : c'est la silhouette elle-même qui porte le dégradé et le halo. Le badge + translucide d'avant aplatissait les deux. */ + background: none; box-shadow: none; + filter: var(--brand-glow); } .auth-hero { @@ -995,11 +1057,11 @@ a.kv-value:hover { align-items: flex-start; gap: 0.75rem; font-size: 0.98rem; - color: rgba(255, 255, 255, 0.88); + color: var(--ink-2); } .auth-points li strong { - color: #fff; + color: var(--ink); font-weight: 600; } @@ -1009,13 +1071,14 @@ a.kv-value:hover { flex: none; margin-top: 0.05rem; padding: 3px; - background: rgba(255, 255, 255, 0.18); + color: var(--accent-2); + background: var(--accent-soft); border-radius: 50%; } .auth-brand-foot { font-size: 0.8rem; - color: rgba(255, 255, 255, 0.72); + color: var(--ink-3); } /* Formulaire (droite) */ @@ -1220,7 +1283,8 @@ button { button:focus-visible { outline: none; - box-shadow: 0 0 0 3px var(--ring); + box-shadow: 0 0 0 3px var(--ring), var(--glow-shadow-faible, none); + border-color: var(--brand-primary, var(--accent)); } form > button[type="submit"], @@ -1229,9 +1293,18 @@ form > button[type="submit"], font-weight: 600; font-size: 0.92rem; color: #fff; - background: var(--accent); + background: var(--brand-gradient, var(--accent)); border-color: var(--accent); + /* Le néon de la suite, en ombre portée plutôt qu'en filtre : un `filter` sur un bouton + rognerait son propre fond. */ + box-shadow: var(--shadow-sm), var(--glow-shadow, none); +} + +/* Faire luire ce sur quoi on ne peut pas appuyer appellerait l'œil au mauvais endroit. */ +form > button[type="submit"]:disabled, +.btn-primary:disabled { box-shadow: var(--shadow-sm); + filter: saturate(0.5); } .btn-primary { @@ -1610,8 +1683,10 @@ button:disabled { max-width: min(92vw, 460px); padding: 0.75rem 1rem; font-size: 0.88rem; - color: #fff; - background: #2a1614; + color: var(--ink); + /* Dérivé de --danger : figer un rouge le laissait à l'ancienne palette, et il jurait + avec les sémantiques Dracula de la suite. */ + background: color-mix(in srgb, var(--danger) 22%, var(--surface)); border: 1px solid color-mix(in srgb, var(--danger) 50%, transparent); border-radius: 10px; box-shadow: var(--shadow-md); @@ -1622,7 +1697,7 @@ button:disabled { width: 16px; height: 16px; flex: none; - color: #ff8b80; + color: var(--danger); } .toast button { diff --git a/apps/web/src/lib/crypto.ts b/apps/web/src/lib/crypto.ts index 4ad2e39f..a6232317 100644 Binary files a/apps/web/src/lib/crypto.ts and b/apps/web/src/lib/crypto.ts differ diff --git a/crates/ghostpass-crypto-ffi/Cargo.toml b/crates/ghostpass-crypto-ffi/Cargo.toml new file mode 100644 index 00000000..6695c575 --- /dev/null +++ b/crates/ghostpass-crypto-ffi/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ghostpass-crypto-ffi" +version = "0.0.0" +edition.workspace = true +license = "MIT" +publish = false +description = "Binding UniFFI (iOS, Android) du cœur crypto GhostPass" + +[lib] +# `staticlib` : c'est cette forme que l'XCFramework iOS embarque. +# `lib` : nécessaire au binaire `uniffi-bindgen` et aux tests Rust. +crate-type = ["lib", "staticlib", "cdylib"] +name = "ghostpass_crypto_ffi" + +[dependencies] +ghostpass-crypto = { path = "../ghostpass-crypto" } +uniffi = { version = "0.29", features = ["cli"] } +serde_json = "1.0.150" +base64 = "0.22.1" +thiserror = "2.0.18" +# Mêmes versions que le cœur et que le binding WASM : `PublicKey` traverse la frontière +# entre les crates, deux versions de crypto_box donneraient deux types incompatibles. +crypto_box = "0.9.1" +zeroize = "1.9" + +[[bin]] +name = "uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" diff --git a/crates/ghostpass-crypto-ffi/examples/seed-vault.rs b/crates/ghostpass-crypto-ffi/examples/seed-vault.rs new file mode 100644 index 00000000..effeb149 --- /dev/null +++ b/crates/ghostpass-crypto-ffi/examples/seed-vault.rs @@ -0,0 +1,83 @@ +//! Prépare le coffre de test consommé par les tests iOS de bout en bout. +//! +//! N'effectue aucun appel réseau : imprime sur la sortie standard le JSON d'inscription +//! et les items déjà chiffrés, que `tools/ios/run-ios-tests.sh` transmet au serveur. +//! Le chiffrement passe par le **même binding** que l'application, ce qui garantit que +//! le coffre amorcé est exactement celui qu'elle saura rouvrir. +//! +//! Usage : `cargo run -p ghostpass-crypto-ffi --example seed-vault -- ` + +use ghostpass_crypto_ffi::{register, Account}; +use std::sync::Arc; + +/// Un `VaultItem` en clair, tel que le sérialise serde côté cœur. +fn vault_item(name: &str, data: &str) -> String { + format!( + r#"{{"name":{},"notes":null,"folder":null,"data":{}}}"#, + serde_json::to_string(name).expect("un nom est toujours sérialisable"), + data + ) +} + +/// Chiffre un item et le présente sous les noms de champs qu'attend l'API HTTP. +fn encrypted(account: &Account, item: String) -> serde_json::Value { + let enc: serde_json::Value = serde_json::from_str( + &account + .encrypt_item(item) + .expect("le chiffrement ne doit pas échouer"), + ) + .expect("le cœur renvoie du JSON"); + serde_json::json!({ + "encryptedKey": enc["encrypted_key"], + "encryptedData": enc["encrypted_data"], + }) +} + +fn main() { + let mut args = std::env::args().skip(1); + let (email, password) = match (args.next(), args.next()) { + (Some(e), Some(p)) => (e, p), + _ => { + eprintln!("usage : seed-vault "); + std::process::exit(2); + } + }; + + let registration = register(password, email.clone()).expect("inscription"); + let blob: serde_json::Value = + serde_json::from_str(®istration.blob()).expect("le blob est du JSON"); + let account: Arc = registration.account(); + + // L'item de registre des dossiers, écrit exactement comme le fait la web app : un + // `SecureNote` dont le nom commence par un octet NUL. L'application doit le filtrer ; + // le voir apparaître dans la liste est la régression que ce coffre sert à détecter. + let folders = vault_item( + "\u{0}gp:folders", + r#"{"kind":"SecureNote","data":{"content":"[\"Travail/Serveurs\"]"}}"#, + ); + let demo = vault_item( + "GitHub", + r#"{"kind":"Login","data":{"username":"clara","password":"hunter2","uris":["https://github.com"],"totp":null,"password_history":[]}}"#, + ); + + // Un identifiant pointant sur la page de test locale : c'est lui qui doit remonter + // en tête quand le remplissage est demandé depuis cette page. + let local = vault_item( + "Site local", + r#"{"kind":"Login","data":{"username":"clara","password":"local-s3cret","uris":["http://127.0.0.1:8099"],"totp":null,"password_history":[]}}"#, + ); + + let out = serde_json::json!({ + "registration": { + "email": email, + "masterPasswordHash": blob["master_password_hash"], + // Le serveur stocke les paramètres KDF en TEXT : on lui envoie une chaîne. + "kdfParams": serde_json::to_string(&blob["kdf_params"]).expect("kdf sérialisable"), + "encryptedUserKey": blob["encrypted_user_key"], + "encryptedPrivateKey": blob["encrypted_private_key"], + "publicKey": account.public_key(), + }, + "items": [encrypted(&account, folders), encrypted(&account, demo), encrypted(&account, local)], + }); + println!("{out}"); +} diff --git a/crates/ghostpass-crypto-ffi/src/bin/uniffi-bindgen.rs b/crates/ghostpass-crypto-ffi/src/bin/uniffi-bindgen.rs new file mode 100644 index 00000000..886ba917 --- /dev/null +++ b/crates/ghostpass-crypto-ffi/src/bin/uniffi-bindgen.rs @@ -0,0 +1,4 @@ +//! Générateur des bindings Swift/Kotlin. Appelé par `tools/ios/build-xcframework.sh`. +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/crates/ghostpass-crypto-ffi/src/lib.rs b/crates/ghostpass-crypto-ffi/src/lib.rs new file mode 100644 index 00000000..3dc7efa6 --- /dev/null +++ b/crates/ghostpass-crypto-ffi/src/lib.rs @@ -0,0 +1,402 @@ +//! Binding UniFFI du cœur crypto, pour les applications mobiles natives (iOS, Android). +//! +//! C'est le pendant exact de `ghostpass-crypto-wasm` : même découpage, mêmes noms, mêmes +//! échanges en JSON. Tout le chiffrement reste **dans le cœur Rust** ; Swift ne manipule +//! qu'un objet `Account` opaque et des données déjà chiffrées. Aucune clé en clair ne +//! traverse la frontière FFI, donc rien de sensible ne peut atterrir dans un log Swift, +//! un crash report ou une capture d'écran de débogueur. +//! +//! Périmètre : cycle de vie du compte, coffre personnel, passkey, coffres partagés et accès +//! d'urgence. Les deux derniers sont transposés du binding WASM sans en changer les noms ni +//! les échanges, pour qu'un coffre partagé écrit depuis le web s'ouvre depuis l'iPhone. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use crypto_box::PublicKey; +use ghostpass_crypto::{keys, org, sharing, vault, EncString, EncryptedItem, KdfParams, VaultItem}; +use std::sync::Arc; +use zeroize::Zeroizing; + +uniffi::setup_scaffolding!(); + +/// Erreur unique côté FFI : le détail cryptographique ne doit pas fuiter dans un type +/// structuré que l'appelant serait tenté d'inspecter — un message suffit à diagnostiquer. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum GhostpassError { + #[error("{message}")] + Crypto { message: String }, +} + +fn err(e: E) -> GhostpassError { + GhostpassError::Crypto { + message: e.to_string(), + } +} + +/// Décode une clé symétrique de 32 octets depuis sa représentation base64. +fn decode_key_32(b64: &str) -> Result<[u8; 32], GhostpassError> { + let bytes = STANDARD.decode(b64).map_err(err)?; + bytes.try_into().map_err(|_| GhostpassError::Crypto { + message: "clé de 32 octets invalide".to_string(), + }) +} + +/// Décode une clé publique de partage (X25519, 32 octets) depuis sa représentation base64. +fn decode_public_key(b64: &str) -> Result { + Ok(PublicKey::from(decode_key_32(b64)?)) +} + +/// Paramètres KDF par défaut (JSON), à stocker avec le compte côté serveur. +#[uniffi::export] +pub fn default_kdf_params() -> Result { + serde_json::to_string(&KdfParams::default()).map_err(err) +} + +/// Hash d'authentification (base64) à envoyer au serveur lors d'une connexion. +#[uniffi::export] +pub fn master_password_hash( + password: String, + email: String, + kdf_params_json: String, +) -> Result { + let params: KdfParams = serde_json::from_str(&kdf_params_json).map_err(err)?; + params.ensure_strong().map_err(err)?; + keys::master_password_hash(password.as_bytes(), &email, params).map_err(err) +} + +/// Crée un compte : génère les clés et le blob chiffré d'inscription. +#[uniffi::export] +pub fn register(password: String, email: String) -> Result, GhostpassError> { + let (account_keys, blob) = + keys::register(password.as_bytes(), &email, KdfParams::default()).map_err(err)?; + let blob_json = serde_json::to_string(&blob).map_err(err)?; + Ok(Arc::new(Registration { + account: Arc::new(Account { keys: account_keys }), + blob_json, + })) +} + +/// Récupère un compte via sa clé de récupération et réinitialise le mot de passe maître. +#[uniffi::export] +pub fn recover( + recovery_key: String, + email: String, + new_password: String, + kdf_params_json: String, + encrypted_user_key_recovery: String, + encrypted_private_key: String, +) -> Result, GhostpassError> { + let params: KdfParams = serde_json::from_str(&kdf_params_json).map_err(err)?; + params.ensure_strong().map_err(err)?; + let euk_rec: EncString = encrypted_user_key_recovery.parse().map_err(err)?; + let epk: EncString = encrypted_private_key.parse().map_err(err)?; + let (account_keys, reset) = keys::recover( + &recovery_key, + &email, + new_password.as_bytes(), + params, + &euk_rec, + &epk, + ) + .map_err(err)?; + let reset_json = serde_json::to_string(&reset).map_err(err)?; + Ok(Arc::new(RecoveryResult { + account: Arc::new(Account { keys: account_keys }), + reset_json, + })) +} + +/// Résultat d'une inscription : le `blob` (JSON à envoyer au serveur) et l'`Account` (les clés). +#[derive(uniffi::Object)] +pub struct Registration { + account: Arc, + blob_json: String, +} + +#[uniffi::export] +impl Registration { + /// JSON du `RegistrationBlob` à transmettre au serveur (qui ne peut rien en déchiffrer). + pub fn blob(&self) -> String { + self.blob_json.clone() + } + + /// Le compte déverrouillé issu de l'inscription. + pub fn account(&self) -> Arc { + Arc::clone(&self.account) + } +} + +/// Résultat d'une récupération : le `reset` (JSON à envoyer au serveur) + l'`Account` rouvert. +#[derive(uniffi::Object)] +pub struct RecoveryResult { + account: Arc, + reset_json: String, +} + +#[uniffi::export] +impl RecoveryResult { + /// JSON du `ResetBlob` (nouveau hash d'auth, preuve de récupération, nouvelle USK enveloppée). + pub fn reset(&self) -> String { + self.reset_json.clone() + } + + pub fn account(&self) -> Arc { + Arc::clone(&self.account) + } +} + +/// Compte déverrouillé : détient les clés côté Rust (jamais exposées en clair à Swift). +#[derive(uniffi::Object)] +pub struct Account { + keys: keys::AccountKeys, +} + +#[uniffi::export] +impl Account { + /// Déverrouille un compte existant à partir des blobs chiffrés (format `EncString`). + #[uniffi::constructor] + pub fn unlock( + password: String, + email: String, + kdf_params_json: String, + encrypted_user_key: String, + encrypted_private_key: String, + ) -> Result, GhostpassError> { + let params: KdfParams = serde_json::from_str(&kdf_params_json).map_err(err)?; + params.ensure_strong().map_err(err)?; + let euk = encrypted_user_key.parse().map_err(err)?; + let epk = encrypted_private_key.parse().map_err(err)?; + let account_keys = + keys::unlock(password.as_bytes(), &email, params, &euk, &epk).map_err(err)?; + Ok(Arc::new(Account { keys: account_keys })) + } + + /// Déverrouille un compte SANS mot de passe à partir du secret PRF (base64) de la passkey, + /// de l'USK enveloppée par PRF et de la clé privée chiffrée. + #[uniffi::constructor] + pub fn with_passkey( + prf_secret_b64: String, + prf_wrapped_user_key: String, + encrypted_private_key: String, + ) -> Result, GhostpassError> { + let prf = decode_key_32(&prf_secret_b64)?; + let wrapped: EncString = prf_wrapped_user_key.parse().map_err(err)?; + let epk: EncString = encrypted_private_key.parse().map_err(err)?; + let account_keys = keys::unlock_with_passkey(&prf, &wrapped, &epk).map_err(err)?; + Ok(Arc::new(Account { keys: account_keys })) + } + + /// Clé publique de partage (base64), à publier côté serveur pour recevoir des secrets partagés. + pub fn public_key(&self) -> String { + STANDARD.encode(self.keys.public_key.as_bytes()) + } + + /// Chiffre un item de coffre (JSON `VaultItem`) avec l'USK. Renvoie un JSON `EncryptedItem`. + pub fn encrypt_item(&self, item_json: String) -> Result { + let item: VaultItem = serde_json::from_str(&item_json).map_err(err)?; + let enc = vault::encrypt_item(&self.keys.user_key, &item).map_err(err)?; + serde_json::to_string(&enc).map_err(err) + } + + /// Déchiffre un JSON `EncryptedItem` avec l'USK. Renvoie le JSON `VaultItem`. + pub fn decrypt_item(&self, encrypted_item_json: String) -> Result { + let enc: EncryptedItem = serde_json::from_str(&encrypted_item_json).map_err(err)?; + let item = vault::decrypt_item(&self.keys.user_key, &enc).map_err(err)?; + serde_json::to_string(&item).map_err(err) + } + + /// Crée un kit de récupération. Renvoie un JSON + /// `{ recovery_key, recovery_auth_hash, encrypted_user_key_recovery }`. + /// `recovery_key` est à AFFICHER à l'utilisateur ; les deux autres champs partent au serveur. + pub fn create_recovery(&self) -> Result { + let artifacts = keys::create_recovery(&self.keys.user_key).map_err(err)?; + serde_json::to_string(&artifacts).map_err(err) + } + + /// Enveloppe l'USK avec le secret PRF (base64) d'une passkey, pour le déverrouillage sans + /// mot de passe. Renvoie l'`EncString` à stocker côté serveur. + pub fn wrap_user_key_for_passkey( + &self, + prf_secret_b64: String, + ) -> Result { + let prf = decode_key_32(&prf_secret_b64)?; + let wrapped = keys::wrap_user_key_for_passkey(&self.keys.user_key, &prf).map_err(err)?; + Ok(wrapped.to_string()) + } +} + +// ─── Partage / organisations ─────────────────────────────────────────────── + +/// Contexte d'une organisation : détient l'Org Key en mémoire Rust, jamais exposée à Swift. +#[derive(uniffi::Object)] +pub struct Org { + org_key: Zeroizing<[u8; 32]>, +} + +#[uniffi::export] +impl Org { + /// Chiffre un item (JSON `VaultItem`) sous l'Org Key. Renvoie un JSON `EncryptedItem`. + pub fn encrypt_item(&self, item_json: String) -> Result { + let item: VaultItem = serde_json::from_str(&item_json).map_err(err)?; + let enc = vault::encrypt_item(&self.org_key, &item).map_err(err)?; + serde_json::to_string(&enc).map_err(err) + } + + /// Déchiffre un JSON `EncryptedItem` sous l'Org Key. Renvoie le JSON `VaultItem`. + pub fn decrypt_item(&self, encrypted_item_json: String) -> Result { + let enc: EncryptedItem = serde_json::from_str(&encrypted_item_json).map_err(err)?; + let item = vault::decrypt_item(&self.org_key, &enc).map_err(err)?; + serde_json::to_string(&item).map_err(err) + } + + /// Ré-enveloppe un item d'une ancienne Org Key vers celle-ci (rotation / révocation), + /// **sans** déchiffrer le contenu. + pub fn rewrap_item( + &self, + old_org: Arc, + encrypted_item_json: String, + ) -> Result { + let enc: EncryptedItem = serde_json::from_str(&encrypted_item_json).map_err(err)?; + let rewrapped = + vault::rewrap_item_key(&old_org.org_key, &self.org_key, &enc).map_err(err)?; + serde_json::to_string(&rewrapped).map_err(err) + } +} + +/// Résultat de la création d'une org : le contexte `Org` et l'Org Key scellée pour le créateur, +/// à stocker côté serveur comme entrée du membre-admin. +/// +/// Le binding WASM protège son accesseur par un « ne peut être appelé qu'une fois », parce que +/// son `Org` n'est pas clonable. Ici l'objet est derrière un `Arc` : le partager ne duplique pas +/// la clé, qui ne quitte de toute façon jamais le Rust. Le verrou n'aurait rien protégé. +#[derive(uniffi::Object)] +pub struct OrgCreation { + org: Arc, + sealed_for_self: String, +} + +#[uniffi::export] +impl OrgCreation { + /// Org Key scellée pour le créateur (base64), à transmettre au serveur. + pub fn sealed_for_self(&self) -> String { + self.sealed_for_self.clone() + } + + /// Contexte `Org` correspondant. + pub fn org(&self) -> Arc { + Arc::clone(&self.org) + } +} + +/// Coffre d'un donneur ouvert par son contact de confiance, le temps d'un accès d'urgence. +#[derive(uniffi::Object)] +pub struct EmergencyVault { + user_key: Zeroizing<[u8; 32]>, +} + +#[uniffi::export] +impl EmergencyVault { + /// Lecture : déchiffre un item du coffre du donneur avec son USK récupéré. + pub fn decrypt_item(&self, encrypted_item_json: String) -> Result { + let enc: EncryptedItem = serde_json::from_str(&encrypted_item_json).map_err(err)?; + let item = vault::decrypt_item(&self.user_key, &enc).map_err(err)?; + serde_json::to_string(&item).map_err(err) + } + + /// Reprise : prépare la réinitialisation du mot de passe maître du donneur à partir de son + /// USK récupéré. Renvoie un JSON `{ master_password_hash, encrypted_user_key }`. + pub fn takeover( + &self, + grantor_email: String, + kdf_params_json: String, + new_password: String, + ) -> Result { + let params: KdfParams = serde_json::from_str(&kdf_params_json).map_err(err)?; + params.ensure_strong().map_err(err)?; + let reset = keys::takeover_reset( + &self.user_key, + &grantor_email, + new_password.as_bytes(), + params, + ) + .map_err(err)?; + serde_json::to_string(&reset).map_err(err) + } +} + +#[uniffi::export] +impl Account { + /// Crée une organisation : génère une Org Key et la scelle, de façon authentifiée, pour soi. + pub fn create_org(&self) -> Result, GhostpassError> { + let org_key = org::generate_org_key(); + let sealed = sharing::box_seal(&self.keys.secret_key, &self.keys.public_key, &org_key) + .map_err(err)?; + Ok(Arc::new(OrgCreation { + org: Arc::new(Org { + org_key: Zeroizing::new(org_key), + }), + sealed_for_self: STANDARD.encode(sealed), + })) + } + + /// Ouvre une Org Key reçue d'un admin, **en vérifiant qu'elle provient de sa clé publique**. + /// Sans cette vérification, un serveur actif pourrait substituer une Org Key de son choix. + pub fn open_org( + &self, + admin_public_key: String, + sealed: String, + ) -> Result, GhostpassError> { + let admin_public = decode_public_key(&admin_public_key)?; + let sealed_bytes = STANDARD.decode(sealed).map_err(err)?; + let org_key = + org::open_org_key(&self.keys.secret_key, &admin_public, &sealed_bytes).map_err(err)?; + Ok(Arc::new(Org { + org_key: Zeroizing::new(org_key), + })) + } + + /// Scelle l'Org Key pour un membre, en tant qu'admin émetteur. Renvoie le blob base64. + pub fn seal_org_key_for_member( + &self, + org: Arc, + member_public_key: String, + ) -> Result { + let member_public = decode_public_key(&member_public_key)?; + let sealed = + org::seal_org_key_for_member(&self.keys.secret_key, &member_public, &org.org_key) + .map_err(err)?; + Ok(STANDARD.encode(sealed)) + } + + /// Scelle l'USK du compte pour un contact de confiance (accès d'urgence), de façon + /// authentifiée. Le blob part au serveur et n'est ouvrable qu'avec la clé privée du contact. + pub fn seal_user_key_for(&self, contact_public_key: String) -> Result { + let contact_public = decode_public_key(&contact_public_key)?; + let sealed = sharing::box_seal( + &self.keys.secret_key, + &contact_public, + self.keys.user_key.as_slice(), + ) + .map_err(err)?; + Ok(STANDARD.encode(sealed)) + } + + /// (Contact) Ouvre un accès d'urgence reçu d'un donneur : récupère son USK en mémoire Rust, + /// en vérifiant que le blob provient bien de la clé publique du donneur. + pub fn open_emergency( + &self, + grantor_public_key: String, + sealed: String, + ) -> Result, GhostpassError> { + let grantor_public = decode_public_key(&grantor_public_key)?; + let sealed_bytes = STANDARD.decode(sealed).map_err(err)?; + let opened = sharing::box_open(&self.keys.secret_key, &grantor_public, &sealed_bytes) + .map_err(err)?; + let arr: [u8; 32] = opened.try_into().map_err(|_| GhostpassError::Crypto { + message: "USK d'urgence invalide".to_string(), + })?; + Ok(Arc::new(EmergencyVault { + user_key: Zeroizing::new(arr), + })) + } +} diff --git a/crates/ghostpass-crypto-ffi/tests/facade.rs b/crates/ghostpass-crypto-ffi/tests/facade.rs new file mode 100644 index 00000000..61c50802 --- /dev/null +++ b/crates/ghostpass-crypto-ffi/tests/facade.rs @@ -0,0 +1,303 @@ +//! Ces tests exercent la façade FFI exactement comme l'application iOS l'appellera : +//! par les mêmes fonctions publiques, avec les mêmes chaînes JSON. Ils tournent sur +//! n'importe quelle machine, donc le cœur mobile reste prouvable sans Mac. + +use ghostpass_crypto_ffi::{default_kdf_params, master_password_hash, recover, register, Account}; +use serde_json::Value; + +const EMAIL: &str = "clara@stackops.ch"; +const PASSWORD: &str = "correct horse battery staple"; +const NOUVEAU: &str = "un tout autre mot de passe, bien assez long"; + +fn item_json() -> String { + serde_json::json!({ + "name": "Serveur de test", + "notes": "note privée", + "folder": "Travail/Serveurs", + "data": { + "kind": "Login", + "data": { + "username": "clara", + "password": "s3cr3t", + "uris": ["https://exemple.test"], + "totp": null, + "password_history": [] + } + } + }) + .to_string() +} + +/// Le blob d'inscription, tel que le serveur le renverra à la connexion suivante. +fn blob_fields(blob: &str) -> (String, String, String) { + let v: Value = serde_json::from_str(blob).unwrap(); + ( + v["kdf_params"].to_string(), + v["encrypted_user_key"].as_str().unwrap().to_string(), + v["encrypted_private_key"].as_str().unwrap().to_string(), + ) +} + +#[test] +fn inscription_puis_deverrouillage_rouvre_le_meme_coffre() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let enc = reg.account().encrypt_item(item_json()).unwrap(); + + let (kdf, euk, epk) = blob_fields(®.blob()); + let account = Account::unlock(PASSWORD.into(), EMAIL.into(), kdf, euk, epk).unwrap(); + + let clair: Value = serde_json::from_str(&account.decrypt_item(enc).unwrap()).unwrap(); + assert_eq!(clair["name"], "Serveur de test"); + assert_eq!(clair["data"]["data"]["password"], "s3cr3t"); + assert_eq!(clair["folder"], "Travail/Serveurs"); + assert_eq!(account.public_key(), reg.account().public_key()); +} + +#[test] +fn le_hash_envoye_au_serveur_est_celui_de_l_inscription() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let v: Value = serde_json::from_str(®.blob()).unwrap(); + let attendu = v["master_password_hash"].as_str().unwrap(); + + let calcule = + master_password_hash(PASSWORD.into(), EMAIL.into(), v["kdf_params"].to_string()).unwrap(); + assert_eq!(calcule, attendu, "le login serait refusé par le serveur"); +} + +#[test] +fn un_mauvais_mot_de_passe_ne_deverrouille_pas() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let (kdf, euk, epk) = blob_fields(®.blob()); + assert!(Account::unlock("mauvais".into(), EMAIL.into(), kdf, euk, epk).is_err()); +} + +#[test] +fn les_parametres_kdf_par_defaut_sont_acceptes_comme_forts() { + let params = default_kdf_params().unwrap(); + let v: Value = serde_json::from_str(¶ms).unwrap(); + assert!(v["mem_cost_kib"].as_u64().unwrap() >= 64 * 1024); + // `unlock` refuse des paramètres faibles : s'il va jusqu'à l'erreur de déchiffrement, + // c'est que le contrôle de robustesse a laissé passer les paramètres par défaut. + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let (_, euk, epk) = blob_fields(®.blob()); + assert!(Account::unlock(PASSWORD.into(), EMAIL.into(), params, euk, epk).is_ok()); +} + +#[test] +fn la_passkey_rouvre_le_coffre_sans_mot_de_passe() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let account = reg.account(); + let enc = account.encrypt_item(item_json()).unwrap(); + + // Le secret PRF vient de WebAuthn côté iOS ; ici on en simule un. + let prf = base64_secret(); + let wrapped = account.wrap_user_key_for_passkey(prf.clone()).unwrap(); + let (_, _, epk) = blob_fields(®.blob()); + + let sans_mdp = Account::with_passkey(prf, wrapped, epk).unwrap(); + let clair: Value = serde_json::from_str(&sans_mdp.decrypt_item(enc).unwrap()).unwrap(); + assert_eq!(clair["data"]["data"]["username"], "clara"); +} + +#[test] +fn la_cle_de_recuperation_rouvre_le_coffre_et_change_le_mot_de_passe() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let enc = reg.account().encrypt_item(item_json()).unwrap(); + let kit: Value = serde_json::from_str(®.account().create_recovery().unwrap()).unwrap(); + + let (kdf, _, epk) = blob_fields(®.blob()); + let res = recover( + kit["recovery_key"].as_str().unwrap().into(), + EMAIL.into(), + "nouveau mot de passe maître".into(), + kdf, + kit["encrypted_user_key_recovery"].as_str().unwrap().into(), + epk, + ) + .unwrap(); + + let clair: Value = serde_json::from_str(&res.account().decrypt_item(enc).unwrap()).unwrap(); + assert_eq!(clair["name"], "Serveur de test"); + assert!(!res.reset().is_empty()); +} + +/// 32 octets déterministes en base64, pour tenir lieu de secret PRF dans les tests. +fn base64_secret() -> String { + use base64::{engine::general_purpose::STANDARD, Engine}; + STANDARD.encode([7u8; 32]) +} + +// ─── Coffres partagés ─── + +/// Deux comptes distincts, comme deux membres d'une équipe. +fn deux_comptes() -> (std::sync::Arc, std::sync::Arc) { + let admin = register(PASSWORD.into(), EMAIL.into()).unwrap().account(); + let membre = register( + "un autre mot de passe long".into(), + "kevin@stackops.ch".into(), + ) + .unwrap() + .account(); + (admin, membre) +} + +#[test] +fn un_membre_ouvre_le_coffre_partage_que_l_admin_lui_a_scelle() { + let (admin, membre) = deux_comptes(); + + let creation = admin.create_org().unwrap(); + let chiffre = creation.org().encrypt_item(item_json()).unwrap(); + + let scelle = admin + .seal_org_key_for_member(creation.org(), membre.public_key()) + .unwrap(); + let org_du_membre = membre.open_org(admin.public_key(), scelle).unwrap(); + + let clair: Value = serde_json::from_str(&org_du_membre.decrypt_item(chiffre).unwrap()).unwrap(); + assert_eq!(clair["data"]["data"]["password"], "s3cr3t"); +} + +#[test] +fn l_admin_retrouve_son_propre_coffre_par_le_sceau_qu_il_s_est_adresse() { + let admin = register(PASSWORD.into(), EMAIL.into()).unwrap().account(); + let creation = admin.create_org().unwrap(); + let chiffre = creation.org().encrypt_item(item_json()).unwrap(); + + // Le serveur ne stocke que `sealed_for_self` : au prochain déverrouillage, c'est par lui + // que l'admin retrouve l'Org Key, exactement comme un membre ordinaire. + let org = admin + .open_org(admin.public_key(), creation.sealed_for_self()) + .unwrap(); + assert!(org.decrypt_item(chiffre).is_ok()); +} + +#[test] +fn un_sceau_forge_par_un_tiers_est_refuse() { + let (admin, membre) = deux_comptes(); + let intrus = register( + "encore un mot de passe long".into(), + "intrus@ailleurs.test".into(), + ) + .unwrap() + .account(); + + // L'intrus scelle SA propre Org Key vers le membre : techniquement valide, mais elle ne + // vient pas de l'admin. C'est ce qu'un serveur actif tenterait pour lire le coffre. + let creation = intrus.create_org().unwrap(); + let contrefacon = intrus + .seal_org_key_for_member(creation.org(), membre.public_key()) + .unwrap(); + + assert!(membre.open_org(admin.public_key(), contrefacon).is_err()); +} + +#[test] +fn apres_rotation_l_ancienne_org_key_ne_lit_plus_les_items_reenveloppes() { + let admin = register(PASSWORD.into(), EMAIL.into()).unwrap().account(); + let ancienne = admin.create_org().unwrap().org(); + let chiffre = ancienne.encrypt_item(item_json()).unwrap(); + + let nouvelle = admin.create_org().unwrap().org(); + let reenveloppe = nouvelle + .rewrap_item(ancienne.clone(), chiffre.clone()) + .unwrap(); + + // La nouvelle clé lit l'item ré-enveloppé, l'ancienne non : c'est la révocation. + assert!(nouvelle.decrypt_item(reenveloppe.clone()).is_ok()); + assert!(ancienne.decrypt_item(reenveloppe).is_err()); + // Le contenu n'a pas été re-chiffré, seule l'enveloppe de la clé a changé. + let a: Value = serde_json::from_str(&chiffre).unwrap(); + let b: Value = serde_json::from_str(&nouvelle.rewrap_item(ancienne, chiffre).unwrap()).unwrap(); + assert_eq!(a["encrypted_data"], b["encrypted_data"]); +} + +// ─── Accès d'urgence ─── + +#[test] +fn le_contact_de_confiance_lit_le_coffre_du_donneur() { + let (donneur, contact) = deux_comptes(); + let chiffre = donneur.encrypt_item(item_json()).unwrap(); + + let scelle = donneur.seal_user_key_for(contact.public_key()).unwrap(); + let coffre = contact + .open_emergency(donneur.public_key(), scelle) + .unwrap(); + + let clair: Value = serde_json::from_str(&coffre.decrypt_item(chiffre).unwrap()).unwrap(); + assert_eq!(clair["name"], "Serveur de test"); +} + +#[test] +fn un_acces_d_urgence_adresse_a_quelqu_un_d_autre_ne_s_ouvre_pas() { + let (donneur, contact) = deux_comptes(); + let tiers = register( + "un mot de passe bien assez long".into(), + "tiers@ailleurs.test".into(), + ) + .unwrap() + .account(); + + let scelle = donneur.seal_user_key_for(contact.public_key()).unwrap(); + assert!(tiers.open_emergency(donneur.public_key(), scelle).is_err()); +} + +#[test] +fn la_reprise_ouvre_le_coffre_du_donneur_avec_un_nouveau_mot_de_passe() { + // On garde le blob d'inscription du donneur : la reprise ne ré-enveloppe que l'USK, la + // clé privée de partage reste celle d'origine et doit venir du même compte. + let inscription = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let donneur = inscription.account(); + let (kdf, _, epk) = blob_fields(&inscription.blob()); + let chiffre = donneur.encrypt_item(item_json()).unwrap(); + + let contact = register( + "un autre mot de passe long".into(), + "kevin@stackops.ch".into(), + ) + .unwrap() + .account(); + let scelle = donneur.seal_user_key_for(contact.public_key()).unwrap(); + let coffre = contact + .open_emergency(donneur.public_key(), scelle) + .unwrap(); + + let reset: Value = serde_json::from_str( + &coffre + .takeover(EMAIL.into(), kdf.clone(), NOUVEAU.into()) + .unwrap(), + ) + .unwrap(); + let euk = reset["encrypted_user_key"].as_str().unwrap().to_string(); + + let repris = Account::unlock(NOUVEAU.into(), EMAIL.into(), kdf, euk, epk).unwrap(); + let clair: Value = serde_json::from_str(&repris.decrypt_item(chiffre).unwrap()).unwrap(); + assert_eq!(clair["data"]["data"]["password"], "s3cr3t"); +} + +#[test] +fn l_ancien_mot_de_passe_du_donneur_ne_vaut_plus_apres_la_reprise() { + let inscription = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let donneur = inscription.account(); + let (kdf, _, epk) = blob_fields(&inscription.blob()); + + let contact = register( + "un autre mot de passe long".into(), + "kevin@stackops.ch".into(), + ) + .unwrap() + .account(); + let scelle = donneur.seal_user_key_for(contact.public_key()).unwrap(); + let coffre = contact + .open_emergency(donneur.public_key(), scelle) + .unwrap(); + + let reset: Value = serde_json::from_str( + &coffre + .takeover(EMAIL.into(), kdf.clone(), NOUVEAU.into()) + .unwrap(), + ) + .unwrap(); + let euk = reset["encrypted_user_key"].as_str().unwrap().to_string(); + + assert!(Account::unlock(PASSWORD.into(), EMAIL.into(), kdf, euk, epk).is_err()); +} diff --git a/tools/brand/ghost_suite.py b/tools/brand/ghost_suite.py new file mode 100755 index 00000000..95e429db --- /dev/null +++ b/tools/brand/ghost_suite.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Générateur de la charte ghost-suite. + +Les produits de la suite partagent une même silhouette de fantôme ; seuls le glyphe +qu'elle porte et ses trois teintes les distinguent. Ce script tient cette silhouette une +fois pour toutes et en tire, pour chaque produit, les deux variantes de la charte : + + logo — la silhouette au trait, avec ses trois tirets détachés. Le trait fait 4 % de la + hauteur et disparaît en dessous de 16 px ; c'est la variante des en-têtes. + icone — la même courbe refermée et remplie, le glyphe découpé en creux, cadrée au carré. + C'est la variante des favicons et des icônes d'application, qui tient petit. + +Usage : + python3 tools/brand/ghost_suite.py ghostpass # écrit les deux SVG + python3 tools/brand/ghost_suite.py --liste # produits déclarés + +Les SVG produits sont des *sorties* : on ne les retouche pas à la main, on change la +déclaration du produit ici et on régénère. C'est ce qui permet à un changement de teinte +de rester une opération d'une ligne. +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from pathlib import Path + +# ─── Ce qui est commun à toute la suite ─────────────────────────────────────── + +# La silhouette, en coordonnées d'un cadre 421 × 548. Tracé ouvert : la variante logo le +# caresse au trait, la variante icône le referme et le remplit. +SILHOUETTE = 'M 240.0 9.0 C 245.3 9.3 262.5 9.7 272.0 11.0 C 281.5 12.3 288.0 13.8 297.0 17.0 C 306.0 20.2 317.8 25.7 326.0 30.0 C 334.2 34.3 338.5 36.8 346.0 43.0 C 353.5 49.2 365.2 60.8 371.0 67.0 C 376.8 73.2 377.0 73.5 381.0 80.0 C 385.0 86.5 390.8 96.2 395.0 106.0 C 399.2 115.8 403.5 129.0 406.0 139.0 C 408.5 149.0 409.3 154.0 410.0 166.0 C 410.7 178.0 410.8 195.8 410.0 211.0 C 409.2 226.2 407.0 244.0 405.0 257.0 C 403.0 270.0 401.2 277.5 398.0 289.0 C 394.8 300.5 391.0 313.2 386.0 326.0 C 381.0 338.8 374.7 353.0 368.0 366.0 C 361.3 379.0 354.8 390.8 346.0 404.0 C 337.2 417.2 322.2 436.2 315.0 445.0 C 307.8 453.8 308.0 454.0 303.0 457.0 C 298.0 460.0 290.7 462.5 285.0 463.0 C 279.3 463.5 273.7 462.0 269.0 460.0 C 264.3 458.0 259.7 453.8 257.0 451.0 C 254.3 448.2 253.8 446.5 253.0 443.0 C 252.2 439.5 251.5 434.3 252.0 430.0 C 252.5 425.7 253.0 422.3 256.0 417.0 C 259.0 411.7 267.2 403.2 270.0 398.0 C 272.8 392.8 272.7 389.7 273.0 386.0 C 273.3 382.3 272.7 378.8 272.0 376.0 C 271.3 373.2 271.2 371.5 269.0 369.0 C 266.8 366.5 263.7 362.3 259.0 361.0 C 254.3 359.7 244.8 360.5 241.0 361.0 C 237.2 361.5 238.5 361.7 236.0 364.0 C 233.5 366.3 234.0 363.0 226.0 375.0 C 218.0 387.0 199.5 419.3 188.0 436.0 C 176.5 452.7 164.3 466.3 157.0 475.0 C 149.7 483.7 147.8 484.8 144.0 488.0 C 140.2 491.2 137.2 492.7 134.0 494.0 C 130.8 495.3 129.2 495.8 125.0 496.0 C 120.8 496.2 113.2 495.8 109.0 495.0 C 104.8 494.2 102.5 492.7 100.0 491.0 C 97.5 489.3 95.7 487.3 94.0 485.0 C 92.3 482.7 90.7 482.0 90.0 477.0 C 89.3 472.0 88.7 461.0 90.0 455.0 C 91.3 449.0 92.5 447.8 98.0 441.0 C 103.5 434.2 116.5 421.8 123.0 414.0 C 129.5 406.2 134.0 400.2 137.0 394.0 C 140.0 387.8 141.2 382.5 141.0 377.0 C 140.8 371.5 138.3 364.8 136.0 361.0 C 133.7 357.2 131.5 355.2 127.0 354.0 C 122.5 352.8 113.8 352.7 109.0 354.0 C 104.2 355.3 101.8 357.7 98.0 362.0 C 94.2 366.3 89.3 375.7 86.0 380.0 C 82.7 384.3 81.0 385.5 78.0 388.0 C 75.0 390.5 71.8 393.0 68.0 395.0 C 64.2 397.0 60.5 399.0 55.0 400.0 C 49.5 401.0 40.0 401.2 35.0 401.0 C 30.0 400.8 28.5 400.7 25.0 399.0 C 21.5 397.3 16.5 394.0 14.0 391.0 C 11.5 388.0 10.7 384.5 10.0 381.0 C 9.3 377.5 9.3 373.7 10.0 370.0 C 10.7 366.3 11.2 363.0 14.0 359.0 C 16.8 355.0 22.0 351.8 27.0 346.0 C 32.0 340.2 38.8 332.0 44.0 324.0 C 49.2 316.0 54.0 306.5 58.0 298.0 C 62.0 289.5 65.0 282.0 68.0 273.0 C 71.0 264.0 74.0 253.2 76.0 244.0 C 78.0 234.8 78.8 233.3 80.0 218.0 C 81.2 202.7 82.0 165.5 83.0 152.0 C 84.0 138.5 84.5 143.2 86.0 137.0 C 87.5 130.8 88.3 123.8 92.0 115.0 C 95.7 106.2 102.8 92.5 108.0 84.0 C 113.2 75.5 118.2 69.7 123.0 64.0 C 127.8 58.3 130.8 55.2 137.0 50.0 C 143.2 44.8 150.3 38.5 160.0 33.0 C 169.7 27.5 185.0 20.7 195.0 17.0 C 205.0 13.3 212.7 12.2 220.0 11.0 C 227.3 9.8 235.8 10.2 239.0 10.0' + +# Les trois tirets détachés qui accompagnent la silhouette dans la variante logo. Ils +# disparaissent de la variante icône, où ils deviendraient des salissures à 16 px. +TIRETS = 'M 201.0 509.0 L 227.0 478.0 M 38.0 537.0 L 63.0 508.0 M 50.0 442.0 L 26.0 470.0' + +LARGEUR, HAUTEUR = 421, 548 +EPAISSEUR = 22 # 4 % de la hauteur + + +@dataclass(frozen=True) +class Produit: + """Un produit de la suite : son nom, ses trois teintes, son glyphe. + + `glyphe_trait` est dessiné au trait par-dessus la silhouette (variante logo) ; + `glyphe_plein` est découpé en creux dans la silhouette remplie (variante icône). + Les deux décrivent la même figure, dans les deux techniques que réclament les deux + variantes — un tracé au trait ne se remplit pas, et l'inverse non plus. + """ + + nom: str + claire: str + primaire: str + profonde: str + glyphe_trait: str + # `None` quand aucune variante icône n'a été publiée pour ce produit : on ne devine + # pas un glyphe plein à partir d'un glyphe au trait. + glyphe_plein: str | None + + +PRODUITS = { + "ghostpass": Produit( + nom="GhostPass", + claire="#9CC3FF", + primaire="#2E7DFF", + profonde="#143F8F", + # Une clé : l'anneau, la tige, et deux dents. + glyphe_trait='\n \n \n ', + glyphe_plein='M 142 196 A 48 48 0 1 0 238 196 A 48 48 0 1 0 142 196 Z M 170 196 A 20 20 0 1 0 210 196 A 20 20 0 1 0 170 196 Z M 243 181 H 325 A 15 15 0 0 1 340 196 V 196 A 15 15 0 0 1 325 211 H 243 A 15 15 0 0 1 228 196 V 196 A 15 15 0 0 1 243 181 Z M 298 205 H 298 A 12 12 0 0 1 310 217 V 235 A 12 12 0 0 1 298 247 H 298 A 12 12 0 0 1 286 235 V 217 A 12 12 0 0 1 298 205 Z M 328 205 H 328 A 12 12 0 0 1 340 217 V 225 A 12 12 0 0 1 328 237 H 328 A 12 12 0 0 1 316 225 V 217 A 12 12 0 0 1 328 205 Z', + ), + "ghostauth": Produit( + nom="GhostAuth", + claire="#FFA05C", + primaire="#FF6A00", + profonde="#A34400", + # Orange à 25°, dans le plus large intervalle libre restant — 55° de ghostlink + # (330°) et 29° de ghostmail (54°). Son `brand.css` déclare #00E6A8, un vert d'eau : + # comme le logo de ghostboard, ce fichier a dérivé et devra suivre. + # + # Un bouclier portant une coche. La clé de ghostpass dit « un secret que tu + # détiens » ; l'authentification dit « on a vérifié qui tu es » — c'est un verdict, + # pas un objet. Aucun autre glyphe de la suite ne l'occupe, et il tient à 16 px là + # où un trousseau ou une empreinte se brouillent. + glyphe_trait='\n ', + # Bouclier plein, coche découpée en creux : au contour, il se refermerait en tache. + glyphe_plein='M 175.0 140.0 L 260.0 117.5 L 345.0 140.0 L 345.0 192.5 A 110.0 110.0 0 0 1 260.0 267.5 A 110.0 110.0 0 0 1 175.0 192.5 Z M 207.4 199.4 L 247.5 242.8 L 317.6 166.9 L 297.4 148.1 L 247.5 202.2 L 227.6 180.6 Z', + ), + "ghostbit": Produit( + nom="GhostBit", + claire="#F050FF", + primaire="#A93BFF", + profonde="#6C1BD8", + glyphe_trait='\n ', + glyphe_plein='M 250 122 L 190 180 L 250 238 L 222 238 L 162 180 L 222 122 Z M 268 122 L 328 180 L 268 238 L 296 238 L 356 180 L 296 122 Z', + ), + "ghostmon": Produit( + nom="GhostMon", + claire="#CFFF5E", + primaire="#A3FF00", + profonde="#5BC800", + glyphe_trait='\n \n ', + glyphe_plein='M 168.7 198.8 A 13.8 13.8 0 0 1 196.3 198.8 L 196.3 218.7 A 13.8 13.8 0 0 1 168.7 218.7 Z M 246.2 163.8 A 13.8 13.8 0 0 1 273.8 163.8 L 273.8 218.7 A 13.8 13.8 0 0 1 246.2 218.7 Z M 323.7 133.8 A 13.8 13.8 0 0 1 351.3 133.8 L 351.3 218.7 A 13.8 13.8 0 0 1 323.7 218.7 Z', + ), + "ghostboard": Produit( + nom="GhostBoard", + claire="#5CFF7F", + primaire="#00FF37", + profonde="#00A323", + # Vert à 133°, décidé après avoir constaté que le logo publié (#3D7BFF) et le + # `style.css` (#FF6B1A) se contredisaient — et qu'aucune des deux teintes n'allait : + # le bleu se confondait avec ghostpass à quatre points près, l'orange est pris. + # 133° tombe dans le plus large intervalle libre de la suite : 51° de ghostmon (82°) + # et 31° de ghostauth (164°). + # + # Ce script ne reproduit donc plus le logo publié de ghostboard : c'est le logo qui + # doit être régénéré, pas cette déclaration qui doit revenir en arrière. + glyphe_trait='\n \n \n ', + glyphe_plein='M 185.0 122.5 L 230.0 122.5 A 17.5 17.5 0 0 1 247.5 140.0 L 247.5 167.5 A 17.5 17.5 0 0 1 230.0 185.0 L 185.0 185.0 A 17.5 17.5 0 0 1 167.5 167.5 L 167.5 140.0 A 17.5 17.5 0 0 1 185.0 122.5 Z M 287.5 122.5 L 332.5 122.5 A 17.5 17.5 0 0 1 350.0 140.0 L 350.0 167.5 A 17.5 17.5 0 0 1 332.5 185.0 L 287.5 185.0 A 17.5 17.5 0 0 1 270.0 167.5 L 270.0 140.0 A 17.5 17.5 0 0 1 287.5 122.5 Z M 185.0 197.5 L 230.0 197.5 A 17.5 17.5 0 0 1 247.5 215.0 L 247.5 242.5 A 17.5 17.5 0 0 1 230.0 260.0 L 185.0 260.0 A 17.5 17.5 0 0 1 167.5 242.5 L 167.5 215.0 A 17.5 17.5 0 0 1 185.0 197.5 Z M 287.5 197.5 L 332.5 197.5 A 17.5 17.5 0 0 1 350.0 215.0 L 350.0 242.5 A 17.5 17.5 0 0 1 332.5 260.0 L 287.5 260.0 A 17.5 17.5 0 0 1 270.0 242.5 L 270.0 215.0 A 17.5 17.5 0 0 1 287.5 197.5 Z', + ), + "ghostlink": Produit( + nom="GhostLink", + claire="#FF8FC6", + primaire="#FF2D95", + profonde="#B00A5E", + glyphe_trait='\n ', + glyphe_plein='M 200.0 210.0 L 240.0 170.0 A 37.5 37.5 0 0 1 292.5 222.5 L 252.5 262.5 A 37.5 37.5 0 0 1 200.0 210.0 Z M 213.8 223.8 L 253.8 183.8 A 9.9 9.9 0 0 1 278.7 208.7 L 238.7 248.7 A 9.9 9.9 0 0 1 213.8 223.8 Z M 230.0 132.5 L 270.0 92.5 A 37.5 37.5 0 0 1 322.5 145.0 L 282.5 185.0 A 37.5 37.5 0 0 1 230.0 132.5 Z M 243.8 146.3 L 283.8 106.3 A 9.9 9.9 0 0 1 308.7 131.2 L 268.7 171.2 A 9.9 9.9 0 0 1 243.8 146.3 Z', + ), + "ghostcal": Produit( + nom="GhostCal", + claire="#9CFBFF", + primaire="#00F0FF", + profonde="#0E8FA8", + glyphe_trait='\n \n \n ', + glyphe_plein='M 164 140 L 356 140 L 356 174 L 164 174 Z M 164 192 L 356 192 L 356 268 L 164 268 Z M 190 112 L 216 112 L 216 156 L 190 156 Z M 304 112 L 330 112 L 330 156 L 304 156 Z', + ), + "ghostmail": Produit( + nom="GhostMail", + claire="#FFF48A", + primaire="#FFE500", + profonde="#D9A800", + glyphe_trait='\n ', + glyphe_plein='M 160 138 L 360 138 L 260 226 Z M 160 170 L 160 268 L 360 268 L 360 170 L 260 254 Z', + ), +} + +# La suite est complète : sept produits, sept teintes, sept glyphes. Ceux de ghostbit, +# ghostcal, ghostmail, ghostmon, ghostboard et ghostlink ont été relevés sur leurs logos +# publiés ; celui de ghostauth a été composé ici, faute de tracé publié. + + +# ─── Rendu ──────────────────────────────────────────────────────────────────── + + +def indente(tracé: str, marge: str = " ") -> str: + """Aligne un bloc de tracés sur l'indentation du SVG produit.""" + return "\n".join(marge + l.strip() for l in tracé.strip().split("\n")) + + +def degrade(produit: Produit) -> str: + """Le dégradé diagonal de la charte : claire en haut à droite, profonde en bas à gauche.""" + return ( + f' \n' + f' \n' + f' \n' + f' \n' + f' \n' + f' \n' + f' ' + ) + + +def logo(produit: Produit) -> str: + """La silhouette au trait, ses tirets, et le glyphe par-dessus.""" + return f""" + +{degrade(produit)} + + + + + +{indente(produit.glyphe_trait)} + + +""" + + +def icone(produit: Produit) -> str: + """La même courbe, refermée et remplie, le glyphe en creux, cadrée au carré. + + Le cadre passe de 421 × 548 à 548 × 548 : on écarte le viewBox de part et d'autre + plutôt que de déformer la silhouette. + """ + if produit.glyphe_plein is None: + raise ValueError( + f"{produit.nom} n'a pas de glyphe plein : la variante icône ne peut pas être" + " produite sans lui, et l'approcher donnerait un logo faux." + ) + marge = (HAUTEUR - LARGEUR) / 2 + return f""" + +{degrade(produit)} + + +""" + + +def verifier(repertoire: Path) -> int: + """Les SVG du dépôt sont-ils bien ce que ce script produit ? + + Un générateur qui a dérivé de ses propres sorties ne sert plus à rien : on croit tenir + la source, on tient un fichier écrit à la main à côté d'un script qui dit autre chose. + Cette vérification ne demande pas le réseau et tient en une seconde. + """ + import re + + def normalise(svg: str) -> str: + return re.sub(r"\s+", " ", re.sub(r"", "", svg, flags=re.S)).strip() + + ecarts = [] + for cle, produit in sorted(PRODUITS.items()): + variantes = [("brand", logo(produit))] + if produit.glyphe_plein is not None: + variantes.append(("icon", icone(produit))) + for suffixe, attendu in variantes: + chemin = repertoire / f"{cle}-{suffixe}.svg" + if not chemin.exists(): + continue # seuls les produits dont le dépôt porte les SVG sont comparés + if normalise(chemin.read_text(encoding="utf-8")) != normalise(attendu): + ecarts.append(chemin.name) + else: + print(f"{chemin.name:28} conforme") + + if ecarts: + print(f"\nont dérivé : {', '.join(ecarts)}", file=sys.stderr) + return 1 + print("\nLes SVG du dépôt sont bien ceux que ce script produit.") + return 0 + + +def main() -> int: + analyseur = argparse.ArgumentParser(description=__doc__) + analyseur.add_argument("produit", nargs="?", help="nom du produit à générer") + analyseur.add_argument("--liste", action="store_true", help="produits déclarés") + analyseur.add_argument( + "--verifier", + action="store_true", + help="régénère et compare aux SVG du dépôt, sans rien écrire", + ) + analyseur.add_argument( + "--sortie", type=Path, default=Path(__file__).parent, help="répertoire de sortie" + ) + args = analyseur.parse_args() + + if args.verifier: + return verifier(args.sortie) + + if args.liste or not args.produit: + for cle, p in sorted(PRODUITS.items()): + print(f"{cle:12} {p.nom:12} {p.primaire}") + return 0 + + produit = PRODUITS.get(args.produit) + if produit is None: + connus = ", ".join(sorted(PRODUITS)) + print(f"produit inconnu : {args.produit} (connus : {connus})", file=sys.stderr) + return 1 + + args.sortie.mkdir(parents=True, exist_ok=True) + variantes = [("brand", logo(produit))] + try: + variantes.append(("icon", icone(produit))) + except ValueError as refus: + print(f"variante icône non produite — {refus}", file=sys.stderr) + + for suffixe, contenu in variantes: + chemin = args.sortie / f"{args.produit}-{suffixe}.svg" + chemin.write_text(contenu, encoding="utf-8") + print(f"écrit {chemin}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/brand/ghostpass-brand.svg b/tools/brand/ghostpass-brand.svg new file mode 100644 index 00000000..59424034 --- /dev/null +++ b/tools/brand/ghostpass-brand.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tools/brand/ghostpass-icon.svg b/tools/brand/ghostpass-icon.svg new file mode 100644 index 00000000..04643520 --- /dev/null +++ b/tools/brand/ghostpass-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/tools/brand/render-assets.sh b/tools/brand/render-assets.sh new file mode 100755 index 00000000..807545c0 --- /dev/null +++ b/tools/brand/render-assets.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Rend les assets applicatifs depuis les sources vectorielles de la charte. +# +# Les PNG du catalogue Xcode sont des *sorties* : les retoucher à la main condamne le +# prochain changement de teinte à de la peinture pixel. La source est le SVG, et cette +# commande est la seule façon de produire les PNG. +# +# ./tools/brand/render-ios-assets.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BRAND="$ROOT/tools/brand" +ASSETS="$ROOT/apps/ios/Ghostpass/Assets.xcassets" + +command -v rsvg-convert >/dev/null || { + echo "rsvg-convert introuvable — brew install librsvg" >&2 + exit 1 +} + +# LogoMark : la silhouette pleine, sur fond transparent. 64 pt, donc 64/128/192 px. +for facteur in 1 2 3; do + taille=$((64 * facteur)) + rsvg-convert -w "$taille" -h "$taille" \ + "$BRAND/ghostpass-icon.svg" \ + -o "$ASSETS/LogoMark.imageset/LogoMark@${facteur}x.png" +done + +# AppIcon : 1024 px, et **sans canal alpha** — l'App Store refuse une icône transparente. +# On aplatit donc sur du blanc, comme le faisait l'icône précédente. +rsvg-convert -w 1024 -h 1024 -b white \ + "$BRAND/ghostpass-icon.svg" \ + -o "$ASSETS/AppIcon.appiconset/AppIcon-1024.png" + +# Web : le favicon et la marque de l'en-tête sont le même fichier, servi tel quel. Un SVG +# tient à 16 px comme à 512, et évite d'entretenir six tailles de PNG. +mkdir -p "$ROOT/apps/web/public" +cp "$BRAND/ghostpass-icon.svg" "$ROOT/apps/web/public/favicon.svg" + +echo "Assets rendus depuis tools/brand/ghostpass-icon.svg" diff --git a/tools/ci/README.md b/tools/ci/README.md new file mode 100644 index 00000000..a9e1d228 --- /dev/null +++ b/tools/ci/README.md @@ -0,0 +1,180 @@ +# Brancher un runner macOS sur la forge + +Les tests iOS réclament Xcode et un simulateur : ils ne tournent que sur macOS, et la +forge n'héberge que des runners Linux. Le job `ios` de `.gitea/workflows/ci.yml` attend +donc un runner étiqueté `macos`. + +Deux situations, et elles ne se configurent pas pareil : un **poste personnel qu'on prête +en dépannage**, et une **machine dédiée** qui prendra le relais. + +## Prérequis, dans les deux cas + +Xcode (avec un runtime de simulateur iOS), `xcodegen`, `rustup`, Node et `npm`. Le job les +vérifie au démarrage et s'arrête net, avec un message clair, s'il en manque un. + +```sh +brew install gitea-runner xcodegen +xcodebuild -downloadPlatform iOS # si aucun runtime de simulateur n'est installé +``` + +Le jeton d'enregistrement s'obtient dans la forge : **Settings > Actions > Runners > +Create new runner**, au niveau du dépôt ou de l'organisation pour le partager entre les +projets de la suite. + +> **La portée du jeton décide de tout, et l'erreur est silencieuse.** Les pages +> *Paramètres **utilisateur** > Actions > Exécuteurs* et *Paramètres du **dépôt** > +> Actions > Exécuteurs* se ressemblent et délivrent chacune un jeton. Un runner +> enregistré avec le premier ne recevra jamais de tâche d'un dépôt appartenant à une +> organisation : `register` répond « registered successfully », le daemon annonce son +> étiquette, et le job attend indéfiniment un exécuteur que le dépôt ne voit pas. On l'a +> payé deux fois. L'URL sans ambiguïté : +> `https://git.stackops.ch/stackops/ghostpass/settings/actions/runners`, ou +> `.../org/stackops/settings/actions/runners` pour toute l'organisation. +> +> Pour vérifier plutôt que d'espérer, une fois enregistré : +> +> ```sh +> curl -sS -H "Authorization: token " \ +> https://git.stackops.ch/api/v1/repos/stackops/ghostpass/actions/runners | jq . +> ``` +> +> Une liste vide veut dire que le runner est ailleurs. (L'exécuteur Linux partagé n'y +> figure pas : il est de type *Global*, la page web agrège les portées, l'API non.) + +`register` écrit un fichier `.runner` — son identité auprès de la forge — **dans le +répertoire courant**, et `daemon` le cherche au même endroit. D'où un dossier à soi, +plutôt qu'un fichier égaré dans le dépôt ou au fond du dossier personnel : + +```sh +mkdir -p ~/.gitea-runner && cd ~/.gitea-runner +gitea-runner register --no-interactive \ + --instance https://git.stackops.ch \ + --token \ + --name "$(scutil --get ComputerName)" \ + --labels 'macos:host' +``` + +`macos:host` — le suffixe compte. Il fait exécuter les tâches **directement sur la +machine** plutôt que dans un conteneur : macOS n'en a pas, et le job a besoin de l'Xcode +du poste, pas d'une image. + +**Une tâche à la fois.** `run-ios-tests.sh` prend le port 3111 en dur, pilote un +simulateur nommé et écrit dans `apps/ios/TestResults/` : deux jobs iOS concurrents sur la +même machine se marchent dessus, et le second meurt sur « Le port 3111 est déjà occupé ». +Or un même commit en déclenche deux — celui du `push` et celui de la pull request. Il faut +donc borner le runner, dans `~/.gitea-runner/config.yaml` : + +```yaml +runner: + capacity: 1 +``` + +## En dépannage, sur un poste personnel + +**Pas de service qui démarre tout seul.** Un portable se ferme, voyage, change de réseau ; +un runner lancé à la session travaillerait sans qu'on le sache, et resterait injoignable la +moitié du temps. On le lance quand on veut qu'une pull request soit vérifiée, on l'arrête +ensuite : + +```sh +cd ~/.gitea-runner && gitea-runner daemon # au premier plan : on voit les tâches + # arriver, Ctrl-C pour rendre la machine +``` + +**Le job hérite du `PATH` du daemon, pas de celui du shell interactif.** `rustup` +s'installe dans `~/.cargo/bin` et s'ajoute au `PATH` depuis `~/.zprofile` : un daemon +lancé par `nohup`, `launchd` ou tout ce qui ne charge pas le profil ne verra donc pas +`cargo`, et le job s'arrêtera net sur « cargo introuvable sur ce runner » — après avoir +trouvé `xcodebuild` et `xcodegen`, qui vivent dans `/opt/homebrew/bin`. En arrière-plan, +on le lui donne explicitement : + +```sh +cd ~/.gitea-runner && PATH="$HOME/.cargo/bin:$PATH" nohup gitea-runner daemon \ + >> ~/.gitea-runner/logs/daemon.log 2>&1 & +``` + +**La machine ne porte qu'une suite à la fois.** `run-ios-tests.sh` prend le port 3111 en +dur, crée un simulateur et écrit dans `apps/ios/TestResults/` : deux exécutions +concurrentes se marchent dessus, et celle qui perd la course meurt sur « Le port 3111 est +déjà occupé » — un échec qui ne dit rien du code. + +**Et ne jamais tuer par motif ce qui décrit aussi les processus du runner.** Le harnais +lance son backend par `npm start`, c'est-à-dire `tsx src/index.ts`. Un `pkill -f "tsx +src/index.ts"` destiné à son propre serveur de test emporte donc aussi celui du job en +cours — les tests suivants échouent alors sur « le coffre ne s'est pas ouvert — serveur +injoignable », un message qui accuse le code d'un dégât causé par le ménage. Payé une +fois : viser le PID retenu au démarrage, ou le port, jamais une chaîne que le runner +partage. + +Le runner ne prévient pas avant de démarrer un job, et rien ne verrouille la machine entre +lui et un lancement à la main. Vérifier que le poste est libre avant de lancer ne suffit +donc pas : c'est une course, et un job CI peut arriver dans l'intervalle. En pratique, on +attend le verdict d'un push avant de relancer une suite locale, plutôt que de courir à +côté du runner. + +**Pour voir ce qu'un job a fait**, activer la conservation des logs dans +`~/.gitea-runner/config.yaml` — le journal du daemon ne contient que « tâche reçue », et +sans ça un échec ne laisse qu'un silence de deux minutes : + +```yaml +log: + job: + dir: "/Users//.gitea-runner/logs/jobs" +``` + +Et l'interrupteur suit le même rythme — mais il ne sert qu'à *couper*. `MACOS_RUNNER` à +`false` écarte le job iOS ; dans tous les autres cas, y compris si la variable n'existe +pas, il tourne. On le passe donc à `false` quand le portable reste fermé un moment, pour +qu'une pull request n'attende pas un runner qui ne viendra pas : Gitea n'échoue pas, il +attend, et la pull request resterait sans verdict. + +Le sens a été inversé après coup, et la raison mérite d'être dite. L'interrupteur exigeait +d'abord `MACOS_RUNNER == 'true'`. Une variable qui ne se résout pas rend alors une chaîne +vide, le job disparaît du rapport sans un mot, et on croit avoir une CI iOS pendant des +heures sans en avoir. Une file d'attente se remarque ; une absence, non. + +**La portée compte, et c'est exactement ce qui nous est arrivé.** Une variable posée dans +*Paramètres utilisateur > Actions > Variables* ne s'applique qu'aux dépôts de cet +utilisateur. `ghostpass` appartient à l'organisation `stackops` : il faut la poser sur le +dépôt (**Paramètres du dépôt > Actions > Variables**) ou sur l'organisation. Le job +`Tests iOS — activés ?` affiche à chaque exécution la valeur qu'il voit, précisément pour +qu'on n'ait plus à le deviner. + +Un point à ne pas perdre de vue : un runner self-hosted **exécute le code des pull requests +sur la machine qui l'héberge**. Sur un dépôt privé entre gens de confiance, c'est le +fonctionnement normal. Sur un poste personnel, cela reste une raison de plus de ne +l'allumer que le temps utile — et de ne pas ouvrir le dépôt aux contributions extérieures +tant qu'il est branché. + +## Sur la machine dédiée + +Une fois le Mac mini en place, c'est l'inverse : il est fait pour attendre du travail. + +```sh +brew services start gitea-runner # démarre à la session et se relance tout seul +``` + +`MACOS_RUNNER` n'a alors plus lieu d'exister : sans elle, le job tourne. + +Le workflow n'a **rien à changer** au passage de l'un à l'autre : les deux machines +portent la même étiquette `macos`, seul le nom diffère. Il suffit d'enregistrer le mini, +de vérifier qu'il apparaît en ligne, puis de retirer le portable. + +## Retirer un runner + +À faire quand le portable rend son tablier, pour ne pas laisser un runner fantôme que la +forge croira joignable : + +```sh +brew services stop gitea-runner 2>/dev/null || true +gitea-runner --config /opt/homebrew/etc/gitea-runner/config.yaml daemon --once 2>/dev/null || true +rm -rf ~/.gitea-runner # son identité auprès de la forge +``` + +Puis le supprimer côté forge : **Settings > Actions > Runners**, bouton de suppression en +face de son nom. Tant qu'il y figure, Gitea peut lui confier des tâches. + +## Vérifier qu'il répond + +Il apparaît dans **Settings > Actions > Runners**, en ligne, avec son étiquette. En cas de +doute, `gitea-runner daemon` au premier plan montre les tâches qu'il reçoit. diff --git a/tools/ios/build-xcframework.sh b/tools/ios/build-xcframework.sh new file mode 100755 index 00000000..5c33e582 --- /dev/null +++ b/tools/ios/build-xcframework.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Construit le cœur crypto en XCFramework consommable par Xcode, bindings Swift compris. +# +# Sortie : apps/ios/GhostpassCrypto.xcframework + apps/ios/Generated/*.swift +# Prérequis : macOS avec Xcode (pour `xcodebuild -create-xcframework` et `lipo`). +# +# Les bindings Swift sont générés à partir de la bibliothèque *déjà compilée* +# (`--library`) et non d'un fichier UDL : c'est ce qui garantit que la surface Swift +# décrit le binaire réellement embarqué, et pas une déclaration qui aurait divergé. +set -euo pipefail + +CRATE=ghostpass-crypto-ffi +LIB=libghostpass_crypto_ffi.a +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT="$ROOT/apps/ios" +BUILD="$ROOT/target" + +cd "$ROOT" + +for target in aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios; do + rustup target add "$target" >/dev/null + # `--lib` seulement : le binaire `uniffi-bindgen` est un outil d'hôte, il n'a + # rien à faire dans une compilation croisée vers iOS. + cargo build --release --lib -p "$CRATE" --target "$target" +done + +# Le simulateur doit accepter les deux architectures (Apple Silicon et Intel), +# et un XCFramework n'admet qu'une tranche par plateforme : d'où le `lipo`. +SIM="$BUILD/ios-simulator" +mkdir -p "$SIM" +lipo -create \ + "$BUILD/aarch64-apple-ios-sim/release/$LIB" \ + "$BUILD/x86_64-apple-ios/release/$LIB" \ + -output "$SIM/$LIB" + +rm -rf "$OUT/Generated" "$OUT/Headers" "$OUT/GhostpassCrypto.xcframework" +mkdir -p "$OUT/Generated" "$OUT/Headers" + +cargo run --release --bin uniffi-bindgen -- generate \ + --library "$BUILD/aarch64-apple-ios/release/$LIB" \ + --language swift \ + --out-dir "$OUT/Generated" + +# Xcode exige que la carte de modules s'appelle `module.modulemap` et voisine les en-têtes. +mv "$OUT/Generated"/*.h "$OUT/Headers/" +mv "$OUT/Generated"/*.modulemap "$OUT/Headers/module.modulemap" + +xcodebuild -create-xcframework \ + -library "$BUILD/aarch64-apple-ios/release/$LIB" -headers "$OUT/Headers" \ + -library "$SIM/$LIB" -headers "$OUT/Headers" \ + -output "$OUT/GhostpassCrypto.xcframework" + +echo "XCFramework : $OUT/GhostpassCrypto.xcframework" +echo "Bindings Swift : $OUT/Generated" diff --git a/tools/ios/make-brand-assets.sh b/tools/ios/make-brand-assets.sh new file mode 100755 index 00000000..68a387b9 --- /dev/null +++ b/tools/ios/make-brand-assets.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Produit les images de marque de l'application iOS à partir du logo de la suite : +# l'icône de l'application, et la marque affichée dans l'interface. +# +# La source est `assets/logo/favicon.svg` : la variante **remplie** du logo, celle que +# ghostboard emploie comme icône. Le logo au trait ne convient pas — son trait fait 4 % +# de la hauteur et disparaît aux petites tailles. +# +# Deux écarts avec la source, et deux seulement : +# — le cadrage est recentré sur le tracé. Le viewBox d'origine est aligné en haut : le +# fantôme occupe y 9..496 dans un carré de 548, soit 9 px de marge au-dessus contre +# 52 en dessous. Sous le masque arrondi d'iOS, il frôlerait le bord. +# — le fond est aplati en blanc : une icône d'application ne peut pas être transparente, +# là où le favicon de la suite l'est. +# +# Le fichier source n'est pas modifié : il est généré par tools/brand/ghost_suite.py et +# porte la mention « ne pas éditer à la main ». +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SOURCE="$ROOT/assets/logo/favicon.svg" +ASSETS="$ROOT/apps/ios/Ghostpass/Assets.xcassets" +CIBLE="$ASSETS/AppIcon.appiconset/AppIcon-1024.png" +MARQUE="$ASSETS/LogoMark.imageset" +MARGE=0.10 + +command -v rsvg-convert >/dev/null || { echo "rsvg-convert manquant : brew install librsvg" >&2; exit 1; } +[[ -f "$SOURCE" ]] || { echo "Logo introuvable : $SOURCE" >&2; exit 1; } + +TEMPO="$(mktemp -d)" +trap 'rm -rf "$TEMPO"' EXIT + +SOURCE="$SOURCE" MARGE="$MARGE" python3 - "$TEMPO/icone.svg" <<'PY' +import os, re, sys + +svg = open(os.environ["SOURCE"]).read() +marge = float(os.environ["MARGE"]) + +# Boîte du tracé : la silhouette n'emploie que M et C, donc les coordonnées alternent en +# x et y, et les courbes restent dans l'enveloppe de leurs points de contrôle. +trace = re.search(r']*\sd="([^"]*)"', svg, re.S).group(1) +silhouette = trace[: trace.index("Z") + 1] +valeurs = [float(n) for n in re.findall(r'-?\d+\.?\d*', silhouette)] +xs, ys = valeurs[0::2], valeurs[1::2] +cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2 +cote = max(max(xs) - min(xs), max(ys) - min(ys)) / (1 - 2 * marge) + +recadre = f'viewBox="{cx - cote / 2:.1f} {cy - cote / 2:.1f} {cote:.1f} {cote:.1f}"' +open(sys.argv[1], "w").write(re.sub(r'viewBox="[^"]*"', recadre, svg, count=1)) +print(f" tracé {min(xs):.0f}..{max(xs):.0f} × {min(ys):.0f}..{max(ys):.0f} → {recadre}") +PY + +rsvg-convert -w 1024 -h 1024 -b white "$TEMPO/icone.svg" -o "$CIBLE" +echo " icône écrite : ${CIBLE#$ROOT/}" + +# La marque affichée dans l'interface : même silhouette, mais sur fond transparent — elle +# se pose sur la nuit de l'application, pas sur un carré blanc. +mkdir -p "$MARQUE" +for facteur in 1 2 3; do + rsvg-convert -w $((64 * facteur)) -h $((64 * facteur)) "$TEMPO/icone.svg" \ + -o "$MARQUE/LogoMark@${facteur}x.png" +done +cat > "$MARQUE/Contents.json" <<'JSON' +{ + "images" : [ + { "filename" : "LogoMark@1x.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "LogoMark@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "LogoMark@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} +JSON +echo " marque écrite : ${MARQUE#$ROOT/}" diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh new file mode 100755 index 00000000..85a37d2d --- /dev/null +++ b/tools/ios/run-ios-tests.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +# Fait tourner les tests iOS : contrat (rapides, hermétiques) puis parcours de bout en +# bout contre un vrai serveur GhostPass, amorcé ici même. +# +# Tout ce que le script démarre, il l'arrête : serveur, simulateur, base temporaire. +# Le coffre de test contient délibérément l'item de registre "\0gp:folders", que +# l'application doit masquer — c'est la régression que ce parcours sert à détecter. +# +# Effet de bord assumé : désactive la synchronisation du presse-papiers du Simulator +# (réglage global, non restauré). Sans cela, chaque prise de focus dans un champ texte +# fige l'application une minute. Pour revenir en arrière : +# defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool true +# +# Usage : ./tools/ios/run-ios-tests.sh [--unit-only] +# GHOSTPASS_KEEP_RESULTS=1 conserve aussi les rapports d'un run réussi (captures +# d'écran comprises), pour inspecter ce qu'a vu le test. +set -euo pipefail + +# `rustup` et `cargo` vivent dans ~/.cargo/bin, ajouté au PATH par le profil du shell — que +# ni un daemon lancé par nohup ni certains shells non interactifs ne chargent. Plutôt que de +# demander à chacun d'exporter le PATH avant d'appeler ce script, on le complète ici. +if ! command -v rustup >/dev/null && [[ -x "$HOME/.cargo/bin/rustup" ]]; then + PATH="$HOME/.cargo/bin:$PATH" + export PATH +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IOS="$ROOT/apps/ios" +# Figé : les tests portent la même valeur par défaut (voir VaultFlowTests). +PORT=3111 +EMAIL="clara@ghostpass.test" +PASSWORD="correct horse battery staple" +# Un second compte, sans coffre : c'est le contact de confiance de l'accès d'urgence. +# Sceller une clé vers quelqu'un suppose que ce quelqu'un existe déjà côté serveur — sa +# clé publique est la seule chose qui protège la nôtre. +CONTACT_EMAIL="kevin@ghostpass.test" +CONTACT_PASSWORD="un tout autre mot de passe de test" +SERVER_URL="http://127.0.0.1:$PORT" +UNIT_ONLY=false +[[ "${1:-}" == "--unit-only" ]] && UNIT_ONLY=true + +WORK="$(mktemp -d)" +# Les rapports d'un run raté survivent au nettoyage : sans eux, il ne reste qu'un nom de +# test et aucune trace de ce que montrait l'écran. La CI les publie en artefact. +RESULTS="$ROOT/apps/ios/TestResults" +# Les produits de compilation, eux, survivent d'un run à l'autre : les remettre dans un +# répertoire temporaire ferait tout recompiler à chaque fois — quatre minutes pour rien +# en local. La CI part d'une machine neuve, cela ne change rien pour elle. +DERIVED="$ROOT/apps/ios/.build" +SERVER_PID="" +BIO_PID="" +PAGE_PID="" +DEVICE="" + +cleanup() { + local code=$? + [[ -n "$BIO_PID" ]] && kill "$BIO_PID" 2>/dev/null || true + [[ -n "$PAGE_PID" ]] && # Le remplissage se saute encore sur certaines versions d'iOS ; le test joint alors l'écran +# qu'il a vu. On le remonte ici, sans quoi il resterait dans un journal que personne n'ouvre. +xcrun simctl spawn "$DEVICE" log show --last 5m --style compact \ + --predicate 'process == "GhostpassUITests-Runner"' 2>/dev/null | + grep -a "GP-AUTOFILL" | head -40 >&2 || true + +kill "$PAGE_PID" 2>/dev/null || true + [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true + # `npm start` lance tsx dans un processus fils : tuer le sous-shell le laisserait + # orphelin, à écouter le port, et le run suivant se heurterait à son compte déjà créé. + local lingering + lingering="$(lsof -ti "tcp:$PORT" 2>/dev/null || true)" + [[ -n "$lingering" ]] && kill $lingering 2>/dev/null || true + if [[ $code -ne 0 || "${GHOSTPASS_KEEP_RESULTS:-}" == "1" ]]; then + mkdir -p "$RESULTS" + for bundle in "$WORK"/*.xcresult; do + # `cp -R` sur une destination qui existe déjà copie *dedans* : le rapport du run + # précédent avalerait le nouveau, qu'on chercherait ensuite en vain. + [[ -e "$bundle" ]] || continue + rm -rf "$RESULTS/$(basename "$bundle")" + cp -R "$bundle" "$RESULTS/" 2>/dev/null || true + done + [[ -e "$WORK/server.log" ]] && cp "$WORK/server.log" "$RESULTS/" 2>/dev/null || true + echo "Rapports conservés dans apps/ios/TestResults/" >&2 + fi + if [[ -n "$DEVICE" ]]; then + xcrun simctl shutdown "$DEVICE" >/dev/null 2>&1 || true + xcrun simctl delete "$DEVICE" >/dev/null 2>&1 || true + fi + rm -rf "$WORK" + exit $code +} +trap cleanup EXIT INT TERM + +say() { printf '\n\033[1m▸ %s\033[0m\n' "$*"; } + +# ── Simulateur ──────────────────────────────────────────────────────────────── +# Un simulateur neuf, créé pour ce run et détruit à la fin. C'est le seul moyen +# d'obtenir un trousseau vierge : `simctl uninstall` laisse les éléments Keychain +# derrière lui, et l'app hériterait du choix biométrique du run précédent. Au passage, +# on ne touche à aucun simulateur existant. +read -r DEVTYPE RUNTIME < <(xcrun simctl list -j devicetypes runtimes | python3 -c ' +import json, sys +cat = json.load(sys.stdin) +runtimes = [r for r in cat["runtimes"] if r.get("isAvailable") and r["platform"] == "iOS"] +if not runtimes: + sys.exit("aucun runtime iOS installé") +runtime = sorted(runtimes, key=lambda r: r["version"])[-1] +supported = set(runtime.get("supportedDeviceTypes", []) and + [d["identifier"] for d in runtime["supportedDeviceTypes"]]) +phones = [d for d in cat["devicetypes"] + if "iPhone" in d["name"] and (not supported or d["identifier"] in supported)] +if not phones: + sys.exit("aucun type iPhone disponible") +print(phones[-1]["identifier"], runtime["identifier"]) +') +[[ -n "${DEVTYPE:-}" && -n "${RUNTIME:-}" ]] || { + echo "Impossible de choisir un simulateur. Installez un runtime iOS : xcodebuild -downloadPlatform iOS" >&2 + exit 1 +} + +DEVICE="$(xcrun simctl create "ghostpass-tests-$$" "$DEVTYPE" "$RUNTIME")" +say "Simulateur éphémère $DEVICE ($DEVTYPE)" +xcrun simctl boot "$DEVICE" >/dev/null 2>&1 || true +# Par défaut le simulateur reste invisible : `xcodebuild` le pilote sans interface, ce qui +# est plus rapide et convient à la CI. Mais aucun clavier logiciel n'y apparaît, et la +# barre de remplissage automatique vit dans ce clavier — `test04Remplissage` s'y saute +# donc toujours. GHOSTPASS_SIMULATOR_VISIBLE=1 demande à Simulator.app d'afficher **cet** +# appareil : ouvrir l'application sans préciser l'identifiant montre le dernier utilisé, +# pas l'éphémère qu'on vient de créer. +if [[ "${GHOSTPASS_SIMULATOR_VISIBLE:-}" == "1" ]]; then + say "Simulateur visible : $DEVICE" + open -a Simulator --args -CurrentDeviceUDID "$DEVICE" + sleep 8 +fi +# `simctl bootstatus -b` peut ne jamais rendre la main sur un simulateur qu'on vient de +# créer — en intégration continue, le job tournerait alors jusqu'à son propre délai. +# On interroge nous-mêmes, avec une borne. +booted=false +for _ in $(seq 1 90); do + if xcrun simctl list devices | grep -q "$DEVICE.*Booted"; then + booted=true + break + fi + sleep 1 +done +$booted || { echo "Le simulateur n'a pas démarré en 90 s." >&2; exit 1; } + +# La saisie de texte gèle une minute si le simulateur tente de synchroniser son +# presse-papiers avec l'hôte : le clavier interroge le pasteboard à chaque prise de focus. +defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false + +# L'application existe en français et en anglais, et suit la langue de l'appareil tant +# que l'utilisateur n'en choisit pas une. Les tests, eux, attendent des libellés précis : +# on fixe donc la langue du simulateur plutôt que d'hériter de celle du poste, sur lequel +# la même suite passerait ou échouerait selon les réglages de son propriétaire. Le +# réglage vaut aussi pour Safari, qui héberge l'extension de remplissage. +xcrun simctl spawn "$DEVICE" defaults write .GlobalPreferences AppleLanguages -array fr +xcrun simctl spawn "$DEVICE" defaults write .GlobalPreferences AppleLocale -string fr_CH + +# Le remplissage automatique vit dans la barre d'accessoires du clavier : sans clavier +# logiciel, il n'y a pas de barre. Ce réglage demande au simulateur de ne pas se croire +# relié à un clavier matériel — mais il est lu par l'application Simulator, qui ne tourne +# pas dans une exécution sans interface. On le pose quand même, pour les lancements depuis +# Xcode ; `test04Remplissage` se saute en le disant quand aucun clavier ne paraît. +defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false 2>/dev/null || true + +# ── Biométrie simulée ───────────────────────────────────────────────────────── +# Sans inscription, `test02Biometrie` se met en skip plutôt que de passer par hasard. +BIOMETRICS=0 +if xcrun simctl spawn "$DEVICE" notifyutil -s com.apple.BiometricKit.enrollmentChanged 1 2>>"$WORK/bio.log" && + xcrun simctl spawn "$DEVICE" notifyutil -p com.apple.BiometricKit.enrollmentChanged 2>>"$WORK/bio.log"; then + BIOMETRICS=1 + # Le panneau Face ID attend une correspondance à chaque demande : on en envoie en + # continu. L'inscription, elle, n'est posée qu'une fois — la reposter en boucle fait + # osciller l'état que lit l'application. + (while true; do + xcrun simctl spawn "$DEVICE" notifyutil -p com.apple.BiometricKit_Sim.pearl.match >/dev/null 2>&1 || true + sleep 2 + done) & + BIO_PID=$! + say "Biométrie simulée : active" +else + say "Biométrie simulée : indisponible ($(tail -1 "$WORK/bio.log" 2>/dev/null)), test ignoré" +fi + + +# ── Cœur crypto + projet ────────────────────────────────────────────────────── +say "Construction de l'XCFramework et des bindings" +"$ROOT/tools/ios/build-xcframework.sh" >"$WORK/xcframework.log" 2>&1 || + { echo "Échec du build XCFramework :" >&2; tail -30 "$WORK/xcframework.log" >&2; exit 1; } + +say "Génération du projet Xcode" +(cd "$IOS" && xcodegen generate >/dev/null) + +run_tests() { + local only="$1"; shift + # Un identifiant de test contient des « / » : tels quels, ils feraient du rapport un + # sous-dossier, que la conservation en cas d'échec ne ramasserait pas. + local nom="${only//\//-}" + xcodebuild -project "$IOS/Ghostpass.xcodeproj" -scheme Ghostpass \ + -sdk iphonesimulator -destination "platform=iOS Simulator,id=$DEVICE" \ + -derivedDataPath "$DERIVED" -resultBundlePath "$WORK/$nom.xcresult" \ + -only-testing:"$only" "$@" test +} + +# ── Tests de contrat ────────────────────────────────────────────────────────── +say "Tests de contrat" +run_tests GhostpassTests + +if $UNIT_ONLY; then + say "Tests de contrat : OK (parcours de bout en bout ignoré)" + exit 0 +fi + +# ── Serveur de test ─────────────────────────────────────────────────────────── +if lsof -ti "tcp:$PORT" >/dev/null 2>&1; then + echo "Le port $PORT est déjà occupé — un run précédent a-t-il survécu ?" >&2 + exit 1 +fi + +say "Démarrage du serveur GhostPass (SQLite jetable, port $PORT)" +[[ -d "$ROOT/apps/server/node_modules" ]] || (cd "$ROOT/apps/server" && npm ci >/dev/null) +(cd "$ROOT/apps/server" && DB_PATH="$WORK/ghostpass.db" PORT="$PORT" npm start >"$WORK/server.log" 2>&1) & +SERVER_PID=$! + +ready=false +for _ in $(seq 1 60); do + if curl -sS -o /dev/null --max-time 2 "$SERVER_URL/api/auth/prelogin" -X POST \ + -H 'Content-Type: application/json' -d '{"email":"probe@example.com"}'; then + ready=true + break + fi + sleep 1 +done +$ready || { echo "Le serveur n'a pas démarré :" >&2; tail -20 "$WORK/server.log" >&2; exit 1; } + +say "Amorçage du coffre de test (compte + item de registre gp:folders)" +cargo run -q -p ghostpass-crypto-ffi --example seed-vault -- "$EMAIL" "$PASSWORD" >"$WORK/seed.json" +SERVER_URL="$SERVER_URL" python3 - "$WORK/seed.json" <<'PY' +import json, os, sys, urllib.request +base = os.environ["SERVER_URL"] +seed = json.load(open(sys.argv[1])) + +def post(path, body, token=None): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = "Bearer " + token + req = urllib.request.Request(base + path, data=json.dumps(body).encode(), headers=headers) + with urllib.request.urlopen(req) as r: + return json.loads(r.read() or b"{}") + +token = post("/api/auth/register", seed["registration"])["token"] +for item in seed["items"]: + post("/api/vault/items", item, token) +print(f" compte créé, {len(seed['items'])} items déposés") +PY + +say "Amorçage du contact de confiance (compte seul, sans coffre)" +cargo run -q -p ghostpass-crypto-ffi --example seed-vault -- \ + "$CONTACT_EMAIL" "$CONTACT_PASSWORD" >"$WORK/contact.json" +SERVER_URL="$SERVER_URL" python3 - "$WORK/contact.json" <<'CONTACT' +import json, os, sys, urllib.request +base = os.environ["SERVER_URL"] +seed = json.load(open(sys.argv[1])) +req = urllib.request.Request( + base + "/api/auth/register", + data=json.dumps(seed["registration"]).encode(), + headers={"Content-Type": "application/json"}) +with urllib.request.urlopen(req) as r: + r.read() +print(" contact créé") +CONTACT + +# ── Parcours de bout en bout ────────────────────────────────────────────────── +say "Parcours de bout en bout" +# Pas d'environnement à passer : `xcodebuild` n'en propage aucun jusqu'au processus de +# test. Les tests connaissent ces valeurs par défaut ; c'est le contrat entre eux et ce +# script — d'où le port et le compte figés plus haut. +if ! run_tests GhostpassUITests/VaultFlowTests/test01ParcoursComplet \ + -only-testing:GhostpassUITests/VaultFlowTests/test02Biometrie \ + -only-testing:GhostpassUITests/VaultFlowTests/test05Preferences \ + -only-testing:GhostpassUITests/VaultFlowTests/test06Recuperation \ + -only-testing:GhostpassUITests/VaultFlowTests/test07Urgence; then + echo "--- journal de l'application ---" >&2 + xcrun simctl spawn "$DEVICE" log show --last 15m --style compact \ + --predicate 'process == "Ghostpass"' 2>/dev/null | grep -a "GP-" | tail -25 >&2 || + echo "(journal indisponible)" >&2 + exit 1 +fi + +# ── Hors ligne ──────────────────────────────────────────────────────────────── +# Le coffre doit s'ouvrir sans serveur. On coupe pour de bon : simuler l'absence de +# réseau autrement reviendrait à éprouver le simulacre plutôt que l'application. +say "Coupure du serveur, puis réouverture hors ligne" +kill "$SERVER_PID" 2>/dev/null || true +lingering="$(lsof -ti "tcp:$PORT" 2>/dev/null || true)" +[[ -n "$lingering" ]] && kill $lingering 2>/dev/null || true +SERVER_PID="" +sleep 2 + +if ! run_tests GhostpassUITests/VaultFlowTests/test03HorsLigne; then + echo "--- journal du serveur ---" >&2 + tail -20 "$WORK/server.log" >&2 || true + exit 1 +fi + +# ── Remplissage automatique ─────────────────────────────────────────────────── +# L'extension n'est joignable que depuis un vrai champ de saisie : on sert une page de +# connexion, on l'active auprès du système, et Safari fait le reste. Le coffre déposé par +# le parcours précédent contient un identifiant pour cette page. +say "Remplissage automatique dans Safari" +(cd "$ROOT/tools/ios/testpage" && python3 -m http.server 8099 --bind 127.0.0.1 >/dev/null 2>&1) & +PAGE_PID=$! + +# `pluginkit -e use` enregistre l'extension, mais iOS exige en plus qu'elle soit cochée +# dans Réglages > Général > Saisie automatique — un geste qui ne se scripte pas. Cette +# pause laisse le faire à la main, simulateur visible, avant que le test ne s'exécute. +# Une pause *minutée*, et non une attente de touche : cette commande finit souvent en +# arrière-plan, où `read &2 + echo "" >&2 + echo " 1. Cliquer dans la fenêtre du simulateur, puis Cmd-K — « Toggle Software" >&2 + echo " Keyboard ». C'est le blocage principal : sous automatisation, iOS croit" >&2 + echo " qu'un clavier matériel est branché et n'affiche jamais le clavier logiciel." >&2 + echo " Or la barre de remplissage vit dans ce clavier." >&2 + echo "" >&2 + echo " 2. Réglages > Général > Saisie automatique : activer, et cocher GhostPass." >&2 + echo " Ce réglage ne s'écrit pas par script — il vit dans un magasin système que" >&2 + echo " \`defaults\` n'atteint pas. Il n'est utile qu'une fois le clavier obtenu :" >&2 + echo " le test contrôle le clavier avant le fournisseur." >&2 + sleep "$GHOSTPASS_PAUSE_REMPLISSAGE" +fi + +if xcrun simctl spawn "$DEVICE" pluginkit -e use -i ch.stackops.ghostpass.autofill 2>/dev/null; then + if ! run_tests GhostpassUITests/AutoFillSafariTests/test04Remplissage; then + kill "$PAGE_PID" 2>/dev/null || true + exit 1 + fi +else + say "Extension non activable sur ce simulateur : remplissage non vérifié" +fi +kill "$PAGE_PID" 2>/dev/null || true + +say "Tous les tests iOS sont passés" diff --git a/tools/ios/testpage/index.html b/tools/ios/testpage/index.html new file mode 100644 index 00000000..3a1b8332 --- /dev/null +++ b/tools/ios/testpage/index.html @@ -0,0 +1,25 @@ + + + + + + Connexion — page de test + + + +

Connexion

+
+ + + +
+ +