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()}
-