From 9725f743551d8b1516a27144077065dc3c954c31 Mon Sep 17 00:00:00 2001 From: Clara Vanacker Date: Tue, 25 Aug 2026 14:56:19 +0200 Subject: [PATCH 01/50] feat(ffi): expose the crypto core to mobile through UniFFI The iOS app needs the same guarantee as the web client: keys are derived and used inside Rust, and only ciphertext crosses the language boundary. This binding mirrors ghostpass-crypto-wasm one for one -- same names, same JSON payloads -- so a protocol change lands on both bindings or neither. Scope is the account lifecycle, the personal vault and passkey unlock, which is what the first app release exposes. Org sharing and emergency access already exist in the core and in the wasm binding; they stay out until the app has a screen for them. Swift bindings are generated from the compiled library rather than from a UDL file, so the generated API always describes the binary that ships. The XCFramework script needs macOS and is therefore unproven until it runs on a CI runner; everything else here is covered by cargo test. --- Cargo.lock | 565 +++++++++++++++++- crates/ghostpass-crypto-ffi/Cargo.toml | 24 + .../src/bin/uniffi-bindgen.rs | 4 + crates/ghostpass-crypto-ffi/src/lib.rs | 219 +++++++ crates/ghostpass-crypto-ffi/tests/facade.rs | 127 ++++ tools/ios/build-xcframework.sh | 54 ++ 6 files changed, 987 insertions(+), 6 deletions(-) create mode 100644 crates/ghostpass-crypto-ffi/Cargo.toml create mode 100644 crates/ghostpass-crypto-ffi/src/bin/uniffi-bindgen.rs create mode 100644 crates/ghostpass-crypto-ffi/src/lib.rs create mode 100644 crates/ghostpass-crypto-ffi/tests/facade.rs create mode 100755 tools/ios/build-xcframework.sh diff --git a/Cargo.lock b/Cargo.lock index ff19e5cc..b6c45626 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,59 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ghostpass-crypto-ffi" +version = "0.0.0" +dependencies = [ + "base64", + "ghostpass-crypto", + "serde_json", + "thiserror", + "uniffi", +] + [[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 +593,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 +635,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 +692,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 +739,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 +769,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 +797,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 +854,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -548,12 +881,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 +928,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 +978,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 +1002,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 +1177,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -670,6 +1190,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/crates/ghostpass-crypto-ffi/Cargo.toml b/crates/ghostpass-crypto-ffi/Cargo.toml new file mode 100644 index 00000000..e3337d21 --- /dev/null +++ b/crates/ghostpass-crypto-ffi/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ghostpass-crypto-ffi" +version = "0.0.0" +edition.workspace = true +license = "MIT" +publish = false +description = "Binding UniFFI (iOS, Android) du cœur crypto GhostPass" + +[lib] +# `staticlib` : c'est cette forme que l'XCFramework iOS embarque. +# `lib` : nécessaire au binaire `uniffi-bindgen` et aux tests Rust. +crate-type = ["lib", "staticlib", "cdylib"] +name = "ghostpass_crypto_ffi" + +[dependencies] +ghostpass-crypto = { path = "../ghostpass-crypto" } +uniffi = { version = "0.29", features = ["cli"] } +serde_json = "1.0.150" +base64 = "0.22.1" +thiserror = "2.0.18" + +[[bin]] +name = "uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" diff --git a/crates/ghostpass-crypto-ffi/src/bin/uniffi-bindgen.rs b/crates/ghostpass-crypto-ffi/src/bin/uniffi-bindgen.rs new file mode 100644 index 00000000..886ba917 --- /dev/null +++ b/crates/ghostpass-crypto-ffi/src/bin/uniffi-bindgen.rs @@ -0,0 +1,4 @@ +//! Générateur des bindings Swift/Kotlin. Appelé par `tools/ios/build-xcframework.sh`. +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/crates/ghostpass-crypto-ffi/src/lib.rs b/crates/ghostpass-crypto-ffi/src/lib.rs new file mode 100644 index 00000000..ee8fb87a --- /dev/null +++ b/crates/ghostpass-crypto-ffi/src/lib.rs @@ -0,0 +1,219 @@ +//! Binding UniFFI du cœur crypto, pour les applications mobiles natives (iOS, Android). +//! +//! C'est le pendant exact de `ghostpass-crypto-wasm` : même découpage, mêmes noms, mêmes +//! échanges en JSON. Tout le chiffrement reste **dans le cœur Rust** ; Swift ne manipule +//! qu'un objet `Account` opaque et des données déjà chiffrées. Aucune clé en clair ne +//! traverse la frontière FFI, donc rien de sensible ne peut atterrir dans un log Swift, +//! un crash report ou une capture d'écran de débogueur. +//! +//! Périmètre : cycle de vie du compte, coffre personnel et passkey — ce dont l'application +//! v1 a besoin. Le partage d'organisation et l'accès d'urgence existent dans le cœur et +//! dans le binding WASM ; ils seront ajoutés ici quand l'application les exposera, pas avant. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use ghostpass_crypto::{keys, vault, EncString, EncryptedItem, KdfParams, VaultItem}; +use std::sync::Arc; + +uniffi::setup_scaffolding!(); + +/// Erreur unique côté FFI : le détail cryptographique ne doit pas fuiter dans un type +/// structuré que l'appelant serait tenté d'inspecter — un message suffit à diagnostiquer. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum GhostpassError { + #[error("{message}")] + Crypto { message: String }, +} + +fn err(e: E) -> GhostpassError { + GhostpassError::Crypto { + message: e.to_string(), + } +} + +/// Décode une clé symétrique de 32 octets depuis sa représentation base64. +fn decode_key_32(b64: &str) -> Result<[u8; 32], GhostpassError> { + let bytes = STANDARD.decode(b64).map_err(err)?; + bytes.try_into().map_err(|_| GhostpassError::Crypto { + message: "clé de 32 octets invalide".to_string(), + }) +} + +/// Paramètres KDF par défaut (JSON), à stocker avec le compte côté serveur. +#[uniffi::export] +pub fn default_kdf_params() -> Result { + serde_json::to_string(&KdfParams::default()).map_err(err) +} + +/// Hash d'authentification (base64) à envoyer au serveur lors d'une connexion. +#[uniffi::export] +pub fn master_password_hash( + password: String, + email: String, + kdf_params_json: String, +) -> Result { + let params: KdfParams = serde_json::from_str(&kdf_params_json).map_err(err)?; + params.ensure_strong().map_err(err)?; + keys::master_password_hash(password.as_bytes(), &email, params).map_err(err) +} + +/// Crée un compte : génère les clés et le blob chiffré d'inscription. +#[uniffi::export] +pub fn register(password: String, email: String) -> Result, GhostpassError> { + let (account_keys, blob) = + keys::register(password.as_bytes(), &email, KdfParams::default()).map_err(err)?; + let blob_json = serde_json::to_string(&blob).map_err(err)?; + Ok(Arc::new(Registration { + account: Arc::new(Account { keys: account_keys }), + blob_json, + })) +} + +/// Récupère un compte via sa clé de récupération et réinitialise le mot de passe maître. +#[uniffi::export] +pub fn recover( + recovery_key: String, + email: String, + new_password: String, + kdf_params_json: String, + encrypted_user_key_recovery: String, + encrypted_private_key: String, +) -> Result, GhostpassError> { + let params: KdfParams = serde_json::from_str(&kdf_params_json).map_err(err)?; + params.ensure_strong().map_err(err)?; + let euk_rec: EncString = encrypted_user_key_recovery.parse().map_err(err)?; + let epk: EncString = encrypted_private_key.parse().map_err(err)?; + let (account_keys, reset) = keys::recover( + &recovery_key, + &email, + new_password.as_bytes(), + params, + &euk_rec, + &epk, + ) + .map_err(err)?; + let reset_json = serde_json::to_string(&reset).map_err(err)?; + Ok(Arc::new(RecoveryResult { + account: Arc::new(Account { keys: account_keys }), + reset_json, + })) +} + +/// Résultat d'une inscription : le `blob` (JSON à envoyer au serveur) et l'`Account` (les clés). +#[derive(uniffi::Object)] +pub struct Registration { + account: Arc, + blob_json: String, +} + +#[uniffi::export] +impl Registration { + /// JSON du `RegistrationBlob` à transmettre au serveur (qui ne peut rien en déchiffrer). + pub fn blob(&self) -> String { + self.blob_json.clone() + } + + /// Le compte déverrouillé issu de l'inscription. + pub fn account(&self) -> Arc { + Arc::clone(&self.account) + } +} + +/// Résultat d'une récupération : le `reset` (JSON à envoyer au serveur) + l'`Account` rouvert. +#[derive(uniffi::Object)] +pub struct RecoveryResult { + account: Arc, + reset_json: String, +} + +#[uniffi::export] +impl RecoveryResult { + /// JSON du `ResetBlob` (nouveau hash d'auth, preuve de récupération, nouvelle USK enveloppée). + pub fn reset(&self) -> String { + self.reset_json.clone() + } + + pub fn account(&self) -> Arc { + Arc::clone(&self.account) + } +} + +/// Compte déverrouillé : détient les clés côté Rust (jamais exposées en clair à Swift). +#[derive(uniffi::Object)] +pub struct Account { + keys: keys::AccountKeys, +} + +#[uniffi::export] +impl Account { + /// Déverrouille un compte existant à partir des blobs chiffrés (format `EncString`). + #[uniffi::constructor] + pub fn unlock( + password: String, + email: String, + kdf_params_json: String, + encrypted_user_key: String, + encrypted_private_key: String, + ) -> Result, GhostpassError> { + let params: KdfParams = serde_json::from_str(&kdf_params_json).map_err(err)?; + params.ensure_strong().map_err(err)?; + let euk = encrypted_user_key.parse().map_err(err)?; + let epk = encrypted_private_key.parse().map_err(err)?; + let account_keys = + keys::unlock(password.as_bytes(), &email, params, &euk, &epk).map_err(err)?; + Ok(Arc::new(Account { keys: account_keys })) + } + + /// Déverrouille un compte SANS mot de passe à partir du secret PRF (base64) de la passkey, + /// de l'USK enveloppée par PRF et de la clé privée chiffrée. + #[uniffi::constructor] + pub fn with_passkey( + prf_secret_b64: String, + prf_wrapped_user_key: String, + encrypted_private_key: String, + ) -> Result, GhostpassError> { + let prf = decode_key_32(&prf_secret_b64)?; + let wrapped: EncString = prf_wrapped_user_key.parse().map_err(err)?; + let epk: EncString = encrypted_private_key.parse().map_err(err)?; + let account_keys = keys::unlock_with_passkey(&prf, &wrapped, &epk).map_err(err)?; + Ok(Arc::new(Account { keys: account_keys })) + } + + /// Clé publique de partage (base64), à publier côté serveur pour recevoir des secrets partagés. + pub fn public_key(&self) -> String { + STANDARD.encode(self.keys.public_key.as_bytes()) + } + + /// Chiffre un item de coffre (JSON `VaultItem`) avec l'USK. Renvoie un JSON `EncryptedItem`. + pub fn encrypt_item(&self, item_json: String) -> Result { + let item: VaultItem = serde_json::from_str(&item_json).map_err(err)?; + let enc = vault::encrypt_item(&self.keys.user_key, &item).map_err(err)?; + serde_json::to_string(&enc).map_err(err) + } + + /// Déchiffre un JSON `EncryptedItem` avec l'USK. Renvoie le JSON `VaultItem`. + pub fn decrypt_item(&self, encrypted_item_json: String) -> Result { + let enc: EncryptedItem = serde_json::from_str(&encrypted_item_json).map_err(err)?; + let item = vault::decrypt_item(&self.keys.user_key, &enc).map_err(err)?; + serde_json::to_string(&item).map_err(err) + } + + /// Crée un kit de récupération. Renvoie un JSON + /// `{ recovery_key, recovery_auth_hash, encrypted_user_key_recovery }`. + /// `recovery_key` est à AFFICHER à l'utilisateur ; les deux autres champs partent au serveur. + pub fn create_recovery(&self) -> Result { + let artifacts = keys::create_recovery(&self.keys.user_key).map_err(err)?; + serde_json::to_string(&artifacts).map_err(err) + } + + /// Enveloppe l'USK avec le secret PRF (base64) d'une passkey, pour le déverrouillage sans + /// mot de passe. Renvoie l'`EncString` à stocker côté serveur. + pub fn wrap_user_key_for_passkey( + &self, + prf_secret_b64: String, + ) -> Result { + let prf = decode_key_32(&prf_secret_b64)?; + let wrapped = keys::wrap_user_key_for_passkey(&self.keys.user_key, &prf).map_err(err)?; + Ok(wrapped.to_string()) + } +} diff --git a/crates/ghostpass-crypto-ffi/tests/facade.rs b/crates/ghostpass-crypto-ffi/tests/facade.rs new file mode 100644 index 00000000..64eb0d4b --- /dev/null +++ b/crates/ghostpass-crypto-ffi/tests/facade.rs @@ -0,0 +1,127 @@ +//! Ces tests exercent la façade FFI exactement comme l'application iOS l'appellera : +//! par les mêmes fonctions publiques, avec les mêmes chaînes JSON. Ils tournent sur +//! n'importe quelle machine, donc le cœur mobile reste prouvable sans Mac. + +use ghostpass_crypto_ffi::{default_kdf_params, master_password_hash, recover, register, Account}; +use serde_json::Value; + +const EMAIL: &str = "clara@stackops.ch"; +const PASSWORD: &str = "correct horse battery staple"; + +fn item_json() -> String { + serde_json::json!({ + "name": "Serveur de test", + "notes": "note privée", + "folder": "Travail/Serveurs", + "data": { + "kind": "Login", + "data": { + "username": "clara", + "password": "s3cr3t", + "uris": ["https://exemple.test"], + "totp": null, + "password_history": [] + } + } + }) + .to_string() +} + +/// Le blob d'inscription, tel que le serveur le renverra à la connexion suivante. +fn blob_fields(blob: &str) -> (String, String, String) { + let v: Value = serde_json::from_str(blob).unwrap(); + ( + v["kdf_params"].to_string(), + v["encrypted_user_key"].as_str().unwrap().to_string(), + v["encrypted_private_key"].as_str().unwrap().to_string(), + ) +} + +#[test] +fn inscription_puis_deverrouillage_rouvre_le_meme_coffre() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let enc = reg.account().encrypt_item(item_json()).unwrap(); + + let (kdf, euk, epk) = blob_fields(®.blob()); + let account = Account::unlock(PASSWORD.into(), EMAIL.into(), kdf, euk, epk).unwrap(); + + let clair: Value = serde_json::from_str(&account.decrypt_item(enc).unwrap()).unwrap(); + assert_eq!(clair["name"], "Serveur de test"); + assert_eq!(clair["data"]["data"]["password"], "s3cr3t"); + assert_eq!(clair["folder"], "Travail/Serveurs"); + assert_eq!(account.public_key(), reg.account().public_key()); +} + +#[test] +fn le_hash_envoye_au_serveur_est_celui_de_l_inscription() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let v: Value = serde_json::from_str(®.blob()).unwrap(); + let attendu = v["master_password_hash"].as_str().unwrap(); + + let calcule = + master_password_hash(PASSWORD.into(), EMAIL.into(), v["kdf_params"].to_string()).unwrap(); + assert_eq!(calcule, attendu, "le login serait refusé par le serveur"); +} + +#[test] +fn un_mauvais_mot_de_passe_ne_deverrouille_pas() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let (kdf, euk, epk) = blob_fields(®.blob()); + assert!(Account::unlock("mauvais".into(), EMAIL.into(), kdf, euk, epk).is_err()); +} + +#[test] +fn les_parametres_kdf_par_defaut_sont_acceptes_comme_forts() { + let params = default_kdf_params().unwrap(); + let v: Value = serde_json::from_str(¶ms).unwrap(); + assert!(v["mem_cost_kib"].as_u64().unwrap() >= 64 * 1024); + // `unlock` refuse des paramètres faibles : s'il va jusqu'à l'erreur de déchiffrement, + // c'est que le contrôle de robustesse a laissé passer les paramètres par défaut. + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let (_, euk, epk) = blob_fields(®.blob()); + assert!(Account::unlock(PASSWORD.into(), EMAIL.into(), params, euk, epk).is_ok()); +} + +#[test] +fn la_passkey_rouvre_le_coffre_sans_mot_de_passe() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let account = reg.account(); + let enc = account.encrypt_item(item_json()).unwrap(); + + // Le secret PRF vient de WebAuthn côté iOS ; ici on en simule un. + let prf = base64_secret(); + let wrapped = account.wrap_user_key_for_passkey(prf.clone()).unwrap(); + let (_, _, epk) = blob_fields(®.blob()); + + let sans_mdp = Account::with_passkey(prf, wrapped, epk).unwrap(); + let clair: Value = serde_json::from_str(&sans_mdp.decrypt_item(enc).unwrap()).unwrap(); + assert_eq!(clair["data"]["data"]["username"], "clara"); +} + +#[test] +fn la_cle_de_recuperation_rouvre_le_coffre_et_change_le_mot_de_passe() { + let reg = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let enc = reg.account().encrypt_item(item_json()).unwrap(); + let kit: Value = serde_json::from_str(®.account().create_recovery().unwrap()).unwrap(); + + let (kdf, _, epk) = blob_fields(®.blob()); + let res = recover( + kit["recovery_key"].as_str().unwrap().into(), + EMAIL.into(), + "nouveau mot de passe maître".into(), + kdf, + kit["encrypted_user_key_recovery"].as_str().unwrap().into(), + epk, + ) + .unwrap(); + + let clair: Value = serde_json::from_str(&res.account().decrypt_item(enc).unwrap()).unwrap(); + assert_eq!(clair["name"], "Serveur de test"); + assert!(!res.reset().is_empty()); +} + +/// 32 octets déterministes en base64, pour tenir lieu de secret PRF dans les tests. +fn base64_secret() -> String { + use base64::{engine::general_purpose::STANDARD, Engine}; + STANDARD.encode([7u8; 32]) +} diff --git a/tools/ios/build-xcframework.sh b/tools/ios/build-xcframework.sh new file mode 100755 index 00000000..5c33e582 --- /dev/null +++ b/tools/ios/build-xcframework.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Construit le cœur crypto en XCFramework consommable par Xcode, bindings Swift compris. +# +# Sortie : apps/ios/GhostpassCrypto.xcframework + apps/ios/Generated/*.swift +# Prérequis : macOS avec Xcode (pour `xcodebuild -create-xcframework` et `lipo`). +# +# Les bindings Swift sont générés à partir de la bibliothèque *déjà compilée* +# (`--library`) et non d'un fichier UDL : c'est ce qui garantit que la surface Swift +# décrit le binaire réellement embarqué, et pas une déclaration qui aurait divergé. +set -euo pipefail + +CRATE=ghostpass-crypto-ffi +LIB=libghostpass_crypto_ffi.a +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT="$ROOT/apps/ios" +BUILD="$ROOT/target" + +cd "$ROOT" + +for target in aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios; do + rustup target add "$target" >/dev/null + # `--lib` seulement : le binaire `uniffi-bindgen` est un outil d'hôte, il n'a + # rien à faire dans une compilation croisée vers iOS. + cargo build --release --lib -p "$CRATE" --target "$target" +done + +# Le simulateur doit accepter les deux architectures (Apple Silicon et Intel), +# et un XCFramework n'admet qu'une tranche par plateforme : d'où le `lipo`. +SIM="$BUILD/ios-simulator" +mkdir -p "$SIM" +lipo -create \ + "$BUILD/aarch64-apple-ios-sim/release/$LIB" \ + "$BUILD/x86_64-apple-ios/release/$LIB" \ + -output "$SIM/$LIB" + +rm -rf "$OUT/Generated" "$OUT/Headers" "$OUT/GhostpassCrypto.xcframework" +mkdir -p "$OUT/Generated" "$OUT/Headers" + +cargo run --release --bin uniffi-bindgen -- generate \ + --library "$BUILD/aarch64-apple-ios/release/$LIB" \ + --language swift \ + --out-dir "$OUT/Generated" + +# Xcode exige que la carte de modules s'appelle `module.modulemap` et voisine les en-têtes. +mv "$OUT/Generated"/*.h "$OUT/Headers/" +mv "$OUT/Generated"/*.modulemap "$OUT/Headers/module.modulemap" + +xcodebuild -create-xcframework \ + -library "$BUILD/aarch64-apple-ios/release/$LIB" -headers "$OUT/Headers" \ + -library "$SIM/$LIB" -headers "$OUT/Headers" \ + -output "$OUT/GhostpassCrypto.xcframework" + +echo "XCFramework : $OUT/GhostpassCrypto.xcframework" +echo "Bindings Swift : $OUT/Generated" From b59e40c9349e5640c2db0ad50d567611e5f331a4 Mon Sep 17 00:00:00 2001 From: Clara Vanacker Date: Tue, 25 Aug 2026 15:54:02 +0200 Subject: [PATCH 02/50] feat(ios): SwiftUI app over the UniFFI core First screens of the native app: unlock, vault list, item detail and edit, with create, update and soft-delete against the existing server routes. No crypto is reimplemented in Swift -- the app calls the same Rust core as the web client, so the two cannot drift apart on the protocol. The Xcode project is described in project.yml and generated by XcodeGen. A .xcodeproj cannot be reviewed or merged by two people; a YAML file can, and nothing generated is committed. Three details that are easy to get wrong and are therefore pinned down: KDF params travel verbatim from server to core (re-encoding an integer as 65536.0 makes serde reject it), the "\0gp:folders" registry item is hidden from the list as it is on the web, and backgrounding the app releases the keys rather than merely hiding the screen. --- .gitignore | 6 + apps/ios/Ghostpass/GhostpassApp.swift | 25 +++ apps/ios/Ghostpass/Model/JSONValue.swift | 61 ++++++ apps/ios/Ghostpass/Model/VaultItem.swift | 104 ++++++++++ apps/ios/Ghostpass/Services/APIClient.swift | 149 ++++++++++++++ apps/ios/Ghostpass/Services/Keychain.swift | 58 ++++++ apps/ios/Ghostpass/Services/VaultStore.swift | 194 ++++++++++++++++++ apps/ios/Ghostpass/Views/ItemDetailView.swift | 99 +++++++++ apps/ios/Ghostpass/Views/ItemEditView.swift | 166 +++++++++++++++ apps/ios/Ghostpass/Views/UnlockView.swift | 100 +++++++++ apps/ios/Ghostpass/Views/VaultListView.swift | 113 ++++++++++ apps/ios/README.md | 58 ++++++ apps/ios/project.yml | 39 ++++ 13 files changed, 1172 insertions(+) create mode 100644 apps/ios/Ghostpass/GhostpassApp.swift create mode 100644 apps/ios/Ghostpass/Model/JSONValue.swift create mode 100644 apps/ios/Ghostpass/Model/VaultItem.swift create mode 100644 apps/ios/Ghostpass/Services/APIClient.swift create mode 100644 apps/ios/Ghostpass/Services/Keychain.swift create mode 100644 apps/ios/Ghostpass/Services/VaultStore.swift create mode 100644 apps/ios/Ghostpass/Views/ItemDetailView.swift create mode 100644 apps/ios/Ghostpass/Views/ItemEditView.swift create mode 100644 apps/ios/Ghostpass/Views/UnlockView.swift create mode 100644 apps/ios/Ghostpass/Views/VaultListView.swift create mode 100644 apps/ios/README.md create mode 100644 apps/ios/project.yml diff --git a/.gitignore b/.gitignore index 20511ab0..7f990b31 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ 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/ diff --git a/apps/ios/Ghostpass/GhostpassApp.swift b/apps/ios/Ghostpass/GhostpassApp.swift new file mode 100644 index 00000000..1c7bac2a --- /dev/null +++ b/apps/ios/Ghostpass/GhostpassApp.swift @@ -0,0 +1,25 @@ +import SwiftUI + +@main +struct GhostpassApp: App { + @StateObject private var store = VaultStore() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + Group { + if store.isUnlocked { + VaultListView() + } else { + UnlockView() + } + } + .environmentObject(store) + } + .onChange(of: scenePhase) { _, phase in + // Passer en arrière-plan relâche les clés. Un coffre qui reste ouvert + // pendant que le téléphone circule n'est plus un coffre. + if phase == .background { store.lock() } + } + } +} diff --git a/apps/ios/Ghostpass/Model/JSONValue.swift b/apps/ios/Ghostpass/Model/JSONValue.swift new file mode 100644 index 00000000..3097474c --- /dev/null +++ b/apps/ios/Ghostpass/Model/JSONValue.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Valeur JSON quelconque, capable de se réémettre à l'identique. +/// +/// Les paramètres KDF font l'aller-retour serveur → app → cœur Rust sans être +/// interprétés. Les décoder en `Double` les ramènerait en `65536.0`, que `serde` +/// refuse pour un `u32` : d'où la distinction explicite entre entier et flottant. +enum JSONValue: Codable, Equatable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "valeur JSON non reconnue") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .int(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } + + /// Représentation textuelle, telle qu'attendue par le cœur Rust. + var jsonString: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(self), + let text = String(data: data, encoding: .utf8) + else { return "{}" } + return text + } +} diff --git a/apps/ios/Ghostpass/Model/VaultItem.swift b/apps/ios/Ghostpass/Model/VaultItem.swift new file mode 100644 index 00000000..39bfb37c --- /dev/null +++ b/apps/ios/Ghostpass/Model/VaultItem.swift @@ -0,0 +1,104 @@ +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, + reason: "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 + var updatedAt: String? + + var login: Login? { + if case .login(let l) = item.data { return l } + return nil + } +} + +enum VaultConstants { + /// L'item qui stocke l'arborescence des dossiers porte un nom commençant par un + /// octet NUL : il est invisible dans l'UI web, il doit l'être ici aussi. Un nom + /// choisi par l'utilisateur ne peut pas entrer en collision avec celui-là. + static let foldersItemName = "\u{0}gp:folders" +} diff --git a/apps/ios/Ghostpass/Services/APIClient.swift b/apps/ios/Ghostpass/Services/APIClient.swift new file mode 100644 index 00000000..41979749 --- /dev/null +++ b/apps/ios/Ghostpass/Services/APIClient.swift @@ -0,0 +1,149 @@ +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 renvoie l'objet KDF ; le cœur Rust l'attend en JSON, on le + /// garde donc sous forme brute plutôt que de le décoder puis de le ré-encoder. + let kdfParams: JSONValue +} + +struct LoginResponse: Decodable { + let token: String + let kdfParams: JSONValue + let encryptedUserKey: String + let encryptedPrivateKey: String +} + +struct EncryptedItemDTO: Codable, Identifiable { + let id: String + let encryptedKey: String + let encryptedData: String + var updatedAt: String? + var deletedAt: String? +} + +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? +} + +/// 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) + } + + // ─── 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) + } +} diff --git a/apps/ios/Ghostpass/Services/Keychain.swift b/apps/ios/Ghostpass/Services/Keychain.swift new file mode 100644 index 00000000..342732c2 --- /dev/null +++ b/apps/ios/Ghostpass/Services/Keychain.swift @@ -0,0 +1,58 @@ +import Foundation +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) + } + + 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 { + static let serverURL = "serverURL" + static let email = "email" + static let token = "sessionToken" + static let kdfParams = "kdfParams" + static let encryptedUserKey = "encryptedUserKey" + static let encryptedPrivateKey = "encryptedPrivateKey" + } +} diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift new file mode 100644 index 00000000..3d76a778 --- /dev/null +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -0,0 +1,194 @@ +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? + + private var account: Account? + private var token: String? + private var api: APIClient? + + var hasSavedSession: Bool { + Keychain.get(Keychain.Key.token) != nil && Keychain.get(Keychain.Key.email) != nil + } + + var savedEmail: String { Keychain.get(Keychain.Key.email) ?? "" } + var savedServer: String { Keychain.get(Keychain.Key.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.jsonString + 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.jsonString, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey) + + account = unlocked + token = session.token + api = client + isUnlocked = true + errorMessage = nil + + Keychain.set(server, for: Keychain.Key.serverURL) + Keychain.set(email, for: Keychain.Key.email) + Keychain.set(session.token, for: Keychain.Key.token) + Keychain.set(session.kdfParams.jsonString, for: Keychain.Key.kdfParams) + Keychain.set(session.encryptedUserKey, for: Keychain.Key.encryptedUserKey) + Keychain.set(session.encryptedPrivateKey, for: Keychain.Key.encryptedPrivateKey) + + 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 email = Keychain.get(Keychain.Key.email), + let kdf = Keychain.get(Keychain.Key.kdfParams), + let euk = Keychain.get(Keychain.Key.encryptedUserKey), + let epk = Keychain.get(Keychain.Key.encryptedPrivateKey), + let server = Keychain.get(Keychain.Key.serverURL), + let url = URL(string: server) + else { + errorMessage = "Aucune session enregistrée sur cet appareil." + return + } + isBusy = true + defer { isBusy = false } + do { + account = try Account.unlock( + password: password, email: email, kdfParamsJson: kdf, + encryptedUserKey: euk, encryptedPrivateKey: epk) + token = Keychain.get(Keychain.Key.token) + api = APIClient(baseURL: url) + isUnlocked = true + errorMessage = nil + await refresh() + } catch { + errorMessage = "Mot de passe maître incorrect." + } + } + + /// Relâche les clés. Les blobs chiffrés restent, pour permettre une réouverture. + func lock() { + account = nil + entries = [] + isUnlocked = false + } + + /// 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 + for key in [ + Keychain.Key.token, Keychain.Key.kdfParams, Keychain.Key.encryptedUserKey, + Keychain.Key.encryptedPrivateKey, + ] { + Keychain.remove(key) + } + } + + // ─── Coffre ─── + + func refresh() async { + guard let api, let token, let account else { return } + do { + let dtos = try await api.listItems(token: token) + entries = dtos.compactMap { dto in + guard let item = try? decrypt(dto, with: account) else { return nil } + // L'item de registre des dossiers est un détail d'implémentation partagé + // avec la web app : il n'a rien à faire dans la liste. + guard item.name != VaultConstants.foldersItemName else { return nil } + return VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt) + } + .sorted { $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + 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 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 { + 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 } + } catch { + errorMessage = error.localizedDescription + } + } + + // ─── Passage de frontière ─── + + /// 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. + private 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)) + } + + private 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) + } +} diff --git a/apps/ios/Ghostpass/Views/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift new file mode 100644 index 00000000..5f7a9a99 --- /dev/null +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct ItemDetailView: View { + let entry: VaultEntry + var onEdit: () -> Void + + @State private var revealed = false + + var body: some View { + Form { + switch entry.item.data { + case .login(let login): + Section("Identifiants") { + CopyRow(label: "Nom d'utilisateur", value: login.username) + SecretRow(label: "Mot de passe", value: login.password, revealed: $revealed) + } + if !login.uris.isEmpty { + Section("Adresses") { + ForEach(login.uris, id: \.self) { uri in + Text(uri).font(.callout).textSelection(.enabled) + } + } + } + case .secureNote(let note): + Section("Note") { + Text(note.content).textSelection(.enabled) + } + case .card(let card): + Section("Carte") { + CopyRow(label: "Titulaire", value: card.cardholder) + SecretRow(label: "Numéro", value: card.number, revealed: $revealed) + LabeledContent("Expiration", value: "\(card.expMonth)/\(card.expYear)") + } + } + + if let notes = entry.item.notes, !notes.isEmpty { + Section("Notes") { Text(notes).textSelection(.enabled) } + } + if let folder = entry.item.folder, !folder.isEmpty { + Section("Dossier") { Text(folder).foregroundStyle(.secondary) } + } + } + .navigationTitle(entry.item.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button("Modifier", action: onEdit) + } + } +} + +private struct CopyRow: View { + let label: String + let value: String + + var body: some View { + HStack { + LabeledContent(label, value: value) + Spacer() + Button { + UIPasteboard.general.string = value + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + } + } +} + +/// Un secret ne s'affiche que sur demande explicite, et la copie ne l'affiche pas : +/// l'épaule du voisin est un modèle de menace plus courant que l'attaquant distant. +private struct SecretRow: View { + let label: String + let value: String + @Binding var revealed: Bool + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(.secondary) + Text(revealed ? value : String(repeating: "•", count: max(value.count, 8))) + .font(.system(.body, design: .monospaced)) + .textSelection(revealed ? .enabled : .disabled) + } + Spacer() + Button { + revealed.toggle() + } label: { + Image(systemName: revealed ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + Button { + UIPasteboard.general.string = value + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + } + } +} diff --git a/apps/ios/Ghostpass/Views/ItemEditView.swift b/apps/ios/Ghostpass/Views/ItemEditView.swift new file mode 100644 index 00000000..9f3d5d80 --- /dev/null +++ b/apps/ios/Ghostpass/Views/ItemEditView.swift @@ -0,0 +1,166 @@ +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 + + @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 noteContent = "" + @State private var cardholder = "" + @State private var cardNumber = "" + @State private var expMonth = "" + @State private var expYear = "" + @State private var cardCode = "" + + enum Kind: String, CaseIterable, Identifiable { + case login = "Identifiant" + case note = "Note" + case card = "Carte" + var id: String { rawValue } + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Nom", text: $name) + if isNew { + Picker("Type", selection: $kind) { + ForEach(Kind.allCases) { Text($0.rawValue).tag($0) } + } + } + } + + switch kind { + case .login: + Section("Identifiants") { + TextField("Nom d'utilisateur", text: $username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Mot de passe", text: $password) + TextField("Adresse du site", text: $uri) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + } + case .note: + Section("Contenu") { + TextEditor(text: $noteContent).frame(minHeight: 140) + } + case .card: + Section("Carte") { + TextField("Titulaire", text: $cardholder) + TextField("Numéro", text: $cardNumber).keyboardType(.numberPad) + HStack { + TextField("MM", text: $expMonth).keyboardType(.numberPad) + TextField("AAAA", text: $expYear).keyboardType(.numberPad) + } + SecureField("Cryptogramme", text: $cardCode) + } + } + + Section("Classement") { + TextField("Dossier (ex. Travail/Serveurs)", text: $folder) + TextField("Notes", text: $notes, axis: .vertical).lineLimit(2...6) + } + } + .navigationTitle(isNew ? "Nouvel élément" : "Modifier") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Enregistrer") { + Task { + await store.save(build(), id: existingID) + dismiss() + } + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear(perform: load) + } + } + + 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 ?? "" + 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], totp: nil, + 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. + 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 login.passwordHistory + } + return [login.password] + login.passwordHistory + } +} diff --git a/apps/ios/Ghostpass/Views/UnlockView.swift b/apps/ios/Ghostpass/Views/UnlockView.swift new file mode 100644 index 00000000..4a3b740d --- /dev/null +++ b/apps/ios/Ghostpass/Views/UnlockView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +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 + + var body: some View { + NavigationStack { + Form { + if useSavedSession { + Section("Coffre") { + LabeledContent("Compte", value: store.savedEmail) + SecureField("Mot de passe maître", text: $password) + .textContentType(.password) + } + } else { + Section("Serveur") { + TextField("https://ghostpass.stackops.ch", text: $server) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + } + Section("Compte") { + TextField("Adresse e-mail", text: $email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + SecureField("Mot de passe maître", text: $password) + .textContentType(.password) + if needsTotp { + TextField("Code à 6 chiffres", text: $totpCode) + .keyboardType(.numberPad) + } + } + } + + if let message = store.errorMessage { + Section { + Text(message).foregroundStyle(.red) + } + } + + Section { + Button(action: submit) { + if store.isBusy { + ProgressView() + } else { + Text(useSavedSession ? "Déverrouiller" : "Se connecter") + } + } + .disabled(store.isBusy || password.isEmpty) + + if store.hasSavedSession { + Button(useSavedSession ? "Utiliser un autre compte" : "Coffre enregistré") { + useSavedSession.toggle() + password = "" + } + .font(.footnote) + } + } + } + .navigationTitle("GhostPass") + .onAppear { + if store.hasSavedSession { + useSavedSession = true + } else { + server = store.savedServer + email = store.savedEmail + } + } + } + } + + 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..9d99a781 --- /dev/null +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct VaultListView: View { + @EnvironmentObject private var store: VaultStore + @State private var search = "" + @State private var editing: EditTarget? + + private var visible: [VaultEntry] { + guard !search.isEmpty else { return store.entries } + return store.entries.filter { entry in + entry.item.name.localizedCaseInsensitiveContains(search) + || (entry.login?.username.localizedCaseInsensitiveContains(search) ?? false) + || (entry.login?.uris.contains { $0.localizedCaseInsensitiveContains(search) } ?? false) + } + } + + var body: some View { + NavigationStack { + List { + ForEach(visible) { entry in + NavigationLink(value: entry) { + VaultRow(entry: entry) + } + .swipeActions { + Button("Supprimer", role: .destructive) { + Task { await store.delete(entry) } + } + } + } + } + .navigationDestination(for: VaultEntry.self) { entry in + ItemDetailView(entry: entry) { editing = .existing(entry) } + } + .searchable(text: $search, prompt: "Rechercher") + .overlay { + if store.entries.isEmpty { + ContentUnavailableView( + "Coffre vide", systemImage: "lock", + description: Text("Ajoutez un identifiant avec le bouton +.")) + } + } + .refreshable { await store.refresh() } + .navigationTitle("Coffre") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Verrouiller") { store.lock() } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + editing = .new + } label: { + Image(systemName: "plus") + } + } + } + .sheet(item: $editing) { target in + ItemEditView(target: target) + .environmentObject(store) + } + .alert( + "Erreur", isPresented: .constant(store.errorMessage != nil), + actions: { Button("OK") { store.errorMessage = nil } }, + message: { Text(store.errorMessage ?? "") }) + } + } +} + +/// 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 + } + } +} + +private struct VaultRow: View { + let entry: VaultEntry + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .frame(width: 28) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(entry.item.name).font(.body) + if let subtitle, !subtitle.isEmpty { + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + } + } + } + + 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/README.md b/apps/ios/README.md new file mode 100644 index 00000000..4e52a8ce --- /dev/null +++ b/apps/ios/README.md @@ -0,0 +1,58 @@ +# 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. + +## 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/VaultStore.swift` | État de l'app, passage de frontière chiffré/clair | +| `Ghostpass/Views/` | SwiftUI : déverrouillage, liste, détail, édition | + +## Ce qui n'y est pas encore + +- **Extension AutoFill** : cible séparée, à ajouter une fois l'app validée sur appareil. +- **Passkey (WebAuthn PRF)** : le binding l'expose (`Account.withPasskey`), l'app ne + l'utilise pas encore. +- **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. + +## Points de vigilance + +- L'item nommé `"\0gp:folders"` est un **registre interne** partagé avec la web app. + Il est filtré de la liste ; l'afficher serait une régression visible. +- Passer l'app en arrière-plan **relâche les clés** (`VaultStore.lock()`). C'est + volontaire : un coffre ouvert dans un téléphone qui circule n'est plus un coffre. +- Les paramètres KDF transitent **verbatim** du serveur au cœur Rust. Les décoder puis + les ré-encoder produirait `65536.0` pour un `u32`, que `serde` refuse — d'où `JSONValue`. diff --git a/apps/ios/project.yml b/apps/ios/project.yml new file mode 100644 index 00000000..7515b93e --- /dev/null +++ b/apps/ios/project.yml @@ -0,0 +1,39 @@ +# 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 + +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 + +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 + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ch.stackops.ghostpass + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: GhostPass + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait + OTHER_LDFLAGS: -lc++ From ff65dc7713284fecc013bc67b9c21360b028ace5 Mon Sep 17 00:00:00 2001 From: claravnk Date: Tue, 25 Aug 2026 18:12:20 +0200 Subject: [PATCH 03/50] =?UTF-8?q?feat(ios):=20rendre=20l'app=20fonctionnel?= =?UTF-8?q?le,=20ajouter=20Face=20ID=20et=20l'ic=C3=B4ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le pipeline (build-xcframework.sh, xcodegen, simulateur) n'avait jamais tourné. Il fonctionne tel quel une fois Xcode installé. En revanche trois erreurs de compilation Swift et deux divergences avec le contrat du serveur bloquaient l'application. Corrections - `kdfParams` est une *chaîne* contenant du JSON, pas un objet JSON : le serveur stocke `kdf_params` en colonne TEXT et la renvoie verbatim. La décoder pour la ré-encoder via `JSONValue` produisait une chaîne doublement échappée, que serde rejette (« invalid type: string, expected struct KdfParams ») — toute connexion échouait dès le calcul du hash d'authentification. `JSONValue` n'avait dès lors plus d'usage. La web app, elle, passait déjà la chaîne brute. - `createdAt`/`updatedAt`/`deletedAt` sont des `INTEGER` (millisecondes depuis l'epoch), pas des `String` : le décodage de la liste échouait en bloc, donc le coffre restait vide et affichait « Réponse inattendue du serveur ». - `DecodingError.dataCorruptedError` prend `debugDescription:`, pas `reason:`. - `.textSelection(.enabled)` et `.disabled` sont deux types distincts : un ternaire ne les unifie pas. - `ItemDetailView` gardait la copie de l'entrée reçue à la navigation et continuait d'afficher l'ancien contenu après une modification — l'utilisateur croyait son enregistrement perdu. Face ID Proposé après un déverrouillage réussi, jamais imposé. Le mot de passe maître part au trousseau sous `.biometryCurrentSet` + `WhenPasscodeSetThisDeviceOnly` : rien ne sort par sauvegarde, et enrôler un nouveau visage invalide l'entrée. Le déverrouillage reste une dérivation Argon2id faite par le cœur Rust — aucune crypto en Swift, la biométrie n'ouvre que le tiroir où dort le mot de passe. Icône Dérivée de assets/logo/ghostpass.svg, posée sur le fond sombre du produit (#15171C, la même variable `--canvas` que la web app). Parcours vérifié dans le simulateur contre un serveur local : connexion, liste, création, modification (mot de passe préservé), suppression, verrouillage, réouverture au seul mot de passe maître, puis réouverture par Face ID. L'item de registre "\0gp:folders" n'apparaît à aucun moment. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 39261 bytes .../AppIcon.appiconset/Contents.json | 14 +++ .../Ghostpass/Assets.xcassets/Contents.json | 6 ++ apps/ios/Ghostpass/Model/JSONValue.swift | 61 ------------- apps/ios/Ghostpass/Model/VaultItem.swift | 5 +- apps/ios/Ghostpass/Services/APIClient.swift | 18 ++-- apps/ios/Ghostpass/Services/Biometrics.swift | 28 ++++++ apps/ios/Ghostpass/Services/Keychain.swift | 51 +++++++++++ apps/ios/Ghostpass/Services/VaultStore.swift | 86 +++++++++++++++++- apps/ios/Ghostpass/Views/ItemDetailView.swift | 28 ++++-- apps/ios/Ghostpass/Views/UnlockView.swift | 12 +++ apps/ios/Ghostpass/Views/VaultListView.swift | 19 +++- apps/ios/README.md | 18 +++- apps/ios/project.yml | 6 ++ 14 files changed, 268 insertions(+), 84 deletions(-) create mode 100644 apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png create mode 100644 apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/ios/Ghostpass/Assets.xcassets/Contents.json delete mode 100644 apps/ios/Ghostpass/Model/JSONValue.swift create mode 100644 apps/ios/Ghostpass/Services/Biometrics.swift 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 0000000000000000000000000000000000000000..d6c9123437838a0c90c644f84244a8f490fa77f4 GIT binary patch literal 39261 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 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, @@ -54,5 +99,11 @@ enum Keychain { static let kdfParams = "kdfParams" static let encryptedUserKey = "encryptedUserKey" static let encryptedPrivateKey = "encryptedPrivateKey" + /// 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/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 3d76a778..4cd51c43 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -11,10 +11,16 @@ final class VaultStore: ObservableObject { @Published private(set) var isUnlocked = false @Published private(set) var isBusy = false @Published var errorMessage: 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 { Keychain.get(Keychain.Key.token) != nil && Keychain.get(Keychain.Key.email) != nil @@ -37,7 +43,7 @@ final class VaultStore: ObservableObject { defer { isBusy = false } do { let client = APIClient(baseURL: url) - let kdf = try await client.prelogin(email: email).kdfParams.jsonString + 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( @@ -46,7 +52,7 @@ final class VaultStore: ObservableObject { let unlocked = try Account.unlock( password: password, email: email, - kdfParamsJson: session.kdfParams.jsonString, + kdfParamsJson: session.kdfParams, encryptedUserKey: session.encryptedUserKey, encryptedPrivateKey: session.encryptedPrivateKey) @@ -59,10 +65,11 @@ final class VaultStore: ObservableObject { Keychain.set(server, for: Keychain.Key.serverURL) Keychain.set(email, for: Keychain.Key.email) Keychain.set(session.token, for: Keychain.Key.token) - Keychain.set(session.kdfParams.jsonString, for: Keychain.Key.kdfParams) + Keychain.set(session.kdfParams, for: Keychain.Key.kdfParams) Keychain.set(session.encryptedUserKey, for: Keychain.Key.encryptedUserKey) Keychain.set(session.encryptedPrivateKey, for: Keychain.Key.encryptedPrivateKey) + proposeBiometricsIfPossible(password) await refresh() } catch { errorMessage = error.localizedDescription @@ -93,6 +100,7 @@ final class VaultStore: ObservableObject { api = APIClient(baseURL: url) isUnlocked = true errorMessage = nil + proposeBiometricsIfPossible(password) await refresh() } catch { errorMessage = "Mot de passe maître incorrect." @@ -104,6 +112,8 @@ final class VaultStore: ObservableObject { account = nil entries = [] isUnlocked = false + pendingPassword = nil + offersBiometricEnrollment = false } /// Déconnexion : révoque la session côté serveur et efface tout localement. @@ -116,12 +126,80 @@ final class VaultStore: ObservableObject { api = nil for key in [ Keychain.Key.token, Keychain.Key.kdfParams, Keychain.Key.encryptedUserKey, - Keychain.Key.encryptedPrivateKey, + Keychain.Key.encryptedPrivateKey, Keychain.Key.masterPassword, + Keychain.Key.biometricsEnabled, ] { Keychain.remove(key) } } + // ─── 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 } + + /// 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 + 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 = "\(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 + } + + /// L'utilisateur accepte : le mot de passe part au trousseau, sous garde biométrique. + func enableBiometrics() { + defer { pendingPassword = nil; offersBiometricEnrollment = false } + guard let password = pendingPassword else { return } + let status = Keychain.setBiometric(password, for: Keychain.Key.masterPassword) + if status == errSecSuccess { + Keychain.set("1", for: Keychain.Key.biometricsEnabled) + } else { + NSLog("GP-KEYCHAIN setBiometric a échoué : OSStatus %d", status) + errorMessage = "\(Biometrics.label) n'a pas pu être activé (code \(status))." + } + } + + /// L'utilisateur refuse : on oublie le mot de passe, et on ne reposera pas la question + /// à chaque ouverture — le drapeau distingue « refusé » de « jamais proposé ». + func declineBiometrics() { + 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 ─── func refresh() async { diff --git a/apps/ios/Ghostpass/Views/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift index 5f7a9a99..4b6ff171 100644 --- a/apps/ios/Ghostpass/Views/ItemDetailView.swift +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -4,11 +4,17 @@ struct ItemDetailView: View { let entry: VaultEntry var onEdit: () -> Void + @EnvironmentObject private var store: VaultStore @State private var revealed = false + /// 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 { store.entries.first { $0.id == entry.id } ?? entry } + var body: some View { Form { - switch entry.item.data { + switch live.item.data { case .login(let login): Section("Identifiants") { CopyRow(label: "Nom d'utilisateur", value: login.username) @@ -33,14 +39,14 @@ struct ItemDetailView: View { } } - if let notes = entry.item.notes, !notes.isEmpty { + if let notes = live.item.notes, !notes.isEmpty { Section("Notes") { Text(notes).textSelection(.enabled) } } - if let folder = entry.item.folder, !folder.isEmpty { + if let folder = live.item.folder, !folder.isEmpty { Section("Dossier") { Text(folder).foregroundStyle(.secondary) } } } - .navigationTitle(entry.item.name) + .navigationTitle(live.item.name) .navigationBarTitleDisplayMode(.inline) .toolbar { Button("Modifier", action: onEdit) @@ -77,9 +83,17 @@ private struct SecretRow: View { HStack { VStack(alignment: .leading, spacing: 2) { Text(label).font(.caption).foregroundStyle(.secondary) - Text(revealed ? value : String(repeating: "•", count: max(value.count, 8))) - .font(.system(.body, design: .monospaced)) - .textSelection(revealed ? .enabled : .disabled) + // `.enabled` et `.disabled` sont deux types distincts : un ternaire ne les + // unifie pas. Le modificateur ne porte donc que sur le cas révélé — masqué, + // il n'y aurait de toute façon que des puces à sélectionner. + Group { + if revealed { + Text(value).textSelection(.enabled) + } else { + Text(String(repeating: "•", count: max(value.count, 8))) + } + } + .font(.system(.body, design: .monospaced)) } Spacer() Button { diff --git a/apps/ios/Ghostpass/Views/UnlockView.swift b/apps/ios/Ghostpass/Views/UnlockView.swift index 4a3b740d..df6a9ac8 100644 --- a/apps/ios/Ghostpass/Views/UnlockView.swift +++ b/apps/ios/Ghostpass/Views/UnlockView.swift @@ -57,6 +57,13 @@ struct UnlockView: View { } .disabled(store.isBusy || password.isEmpty) + if useSavedSession && store.canUnlockWithBiometrics { + Button("Déverrouiller avec \(store.biometryLabel)") { + Task { await store.unlockWithBiometrics() } + } + .disabled(store.isBusy) + } + if store.hasSavedSession { Button(useSavedSession ? "Utiliser un autre compte" : "Coffre enregistré") { useSavedSession.toggle() @@ -70,6 +77,11 @@ struct UnlockView: View { .onAppear { if store.hasSavedSession { useSavedSession = true + // Une session enregistrée + 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 diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 9d99a781..ef9aca20 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -29,7 +29,12 @@ struct VaultListView: View { } } .navigationDestination(for: VaultEntry.self) { entry in - ItemDetailView(entry: entry) { editing = .existing(entry) } + // 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) { + editing = .existing(store.entries.first { $0.id == entry.id } ?? entry) + } } .searchable(text: $search, prompt: "Rechercher") .overlay { @@ -61,6 +66,18 @@ struct VaultListView: View { "Erreur", isPresented: .constant(store.errorMessage != nil), actions: { Button("OK") { store.errorMessage = nil } }, message: { Text(store.errorMessage ?? "") }) + .alert( + "Utiliser \(store.biometryLabel) ?", + isPresented: $store.offersBiometricEnrollment, + actions: { + Button("Activer") { store.enableBiometrics() } + Button("Plus tard", role: .cancel) { store.declineBiometrics() } + }, + message: { + Text( + "Votre mot de passe maître sera conservé dans le trousseau de cet " + + "appareil, relisible par \(store.biometryLabel) seul.") + }) } } } diff --git a/apps/ios/README.md b/apps/ios/README.md index 4e52a8ce..b58b47eb 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -37,6 +37,8 @@ du projet vit dans `project.yml`, qui se relit et se fusionne — contrairement | `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/Assets.xcassets` | Icône, dérivée de `assets/logo/ghostpass.svg` | | `Ghostpass/Services/VaultStore.swift` | État de l'app, passage de frontière chiffré/clair | | `Ghostpass/Views/` | SwiftUI : déverrouillage, liste, détail, édition | @@ -44,7 +46,8 @@ du projet vit dans `project.yml`, qui se relit et se fusionne — contrairement - **Extension AutoFill** : cible séparée, à ajouter une fois l'app validée sur appareil. - **Passkey (WebAuthn PRF)** : le binding l'expose (`Account.withPasskey`), l'app ne - l'utilise pas encore. + 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. @@ -54,5 +57,14 @@ du projet vit dans `project.yml`, qui se relit et se fusionne — contrairement Il est filtré de la liste ; l'afficher serait une régression visible. - Passer l'app en arrière-plan **relâche les clés** (`VaultStore.lock()`). C'est volontaire : un coffre ouvert dans un téléphone qui circule n'est plus un coffre. -- Les paramètres KDF transitent **verbatim** du serveur au cœur Rust. Les décoder puis - les ré-encoder produirait `65536.0` pour un `u32`, que `serde` refuse — d'où `JSONValue`. +- **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. +- 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/project.yml b/apps/ios/project.yml index 7515b93e..aa034736 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -33,7 +33,13 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: ch.stackops.ghostpass GENERATE_INFOPLIST_FILE: YES + # Icône dérivée du logo du produit (assets/logo/ghostpass.svg). + 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++ + From b30d8113ca0c296b7276cc5afbc7040b0510f5a5 Mon Sep 17 00:00:00 2001 From: claravnk Date: Tue, 25 Aug 2026 21:33:14 +0200 Subject: [PATCH 04/50] test(ios): suite de tests iOS et cible CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux niveaux, parce qu'ils n'attrapent pas les mêmes choses. `Tests/` — contrat, dix tests, moins d'une seconde, ni réseau ni interface : décodage des réponses du serveur, aller-retour d'un `VaultItem` à travers le vrai binding Rust, nom exact du registre `gp:folders` et son filtrage, refus d'activer la biométrie sur un mot de passe faux. Les deux régressions qui rendaient l'app inutilisable étaient précisément de cette nature : elles sont désormais tenues sans qu'il faille lancer un simulateur. `UITests/` — le parcours réel contre un serveur GhostPass : 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. `tools/ios/run-ios-tests.sh` orchestre le tout et ne laisse rien derrière lui. Il crée un **simulateur éphémère** : le trousseau d'un simulateur survit à la désinstallation, et sans cela un run hériterait du choix biométrique du précédent. Le coffre de test est amorcé par `seed-vault`, qui chiffre avec le même binding que l'application — dont un vrai item de registre `gp:folders`. Corrections que l'écriture des tests a mises au jour : - Après un « Plus tard », Face ID devenait **définitivement inactivable** : aucun réglage ne permettait d'y revenir. Le coffre a maintenant un menu qui active ou retire le déverrouillage biométrique — et qui expose la déconnexion, jusqu'ici présente dans le store mais atteignable par aucun bouton. - 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. Sans quoi une faute de frappe enfermait l'utilisateur derrière un secret qui n'ouvre rien. - Deux `.sheet` sur la même vue se marchent dessus en SwiftUI : c'est la première déclarée qui cesse de s'ouvrir. Elles passent par une destination unique. - Une alerte présentée depuis un menu qui se referme se perd un cycle sur deux ; l'activation biométrique est devenue une feuille. - Les champs et boutons portent des `accessibilityIdentifier` : les adresser par position ou par libellé français rendait les tests fragiles au premier changement de mise en page. Limite assumée : l'inscription biométrique simulée 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é. Sur trois exécutions consécutives, jamais rouge ; le chemin biométrique complet est exercé quand le simulateur coopère. Le job CI tourne sur `macos-latest` — Xcode et le simulateur n'existent que là — après le job `crypto`, et publie les rapports en artefact lorsqu'il échoue : sans eux, un échec ne laisse qu'un nom de test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .github/workflows/ci.yml | 39 ++++ .gitignore | 3 + apps/ios/Ghostpass/Services/VaultStore.swift | 84 +++++-- .../Ghostpass/Views/BiometricSetupView.swift | 54 +++++ apps/ios/Ghostpass/Views/ItemDetailView.swift | 2 + apps/ios/Ghostpass/Views/ItemEditView.swift | 6 + apps/ios/Ghostpass/Views/UnlockView.swift | 7 + apps/ios/Ghostpass/Views/VaultListView.swift | 65 +++++- apps/ios/README.md | 34 ++- apps/ios/Tests/ContractTests.swift | 201 ++++++++++++++++ apps/ios/UITests/VaultFlowTests.swift | 220 ++++++++++++++++++ apps/ios/project.yml | 38 +++ .../examples/seed-vault.rs | 76 ++++++ tools/ios/run-ios-tests.sh | 198 ++++++++++++++++ 14 files changed, 1004 insertions(+), 23 deletions(-) create mode 100644 apps/ios/Ghostpass/Views/BiometricSetupView.swift create mode 100644 apps/ios/Tests/ContractTests.swift create mode 100644 apps/ios/UITests/VaultFlowTests.swift create mode 100644 crates/ghostpass-crypto-ffi/examples/seed-vault.rs create mode 100755 tools/ios/run-ios-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 891db61f..3324c807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,3 +78,42 @@ jobs: - name: Build working-directory: apps/web run: npm run build + + ios: + name: App iOS (SwiftUI + UniFFI) + # Xcode et le simulateur n'existent que sur macOS. Attention : sur un dépôt privé, + # les minutes macOS comptent dix fois — d'où un job qui ne tourne pas pour rien. + runs-on: macos-latest + # 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 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: apps/server/package-lock.json + - name: XcodeGen + run: brew install xcodegen + - 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), puis déroule les deux suites. + - 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 7f990b31..b9042963 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ 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/ diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 4cd51c43..80132d86 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -144,9 +144,18 @@ final class VaultStore: ObservableObject { var biometryLabel: String { Biometrics.label } + /// 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 { + NSLog("GP-BIO tentative flag=%@ dispo=%@", + Keychain.get(Keychain.Key.biometricsEnabled) ?? "(absent)", + Biometrics.isAvailable ? "oui" : "non") guard Keychain.get(Keychain.Key.biometricsEnabled) == "1" else { return } isBusy = true let prompt = "Déverrouiller votre coffre GhostPass" @@ -155,6 +164,7 @@ final class VaultStore: ObservableObject { 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. @@ -173,21 +183,53 @@ final class VaultStore: ObservableObject { offersBiometricEnrollment = true } - /// L'utilisateur accepte : le mot de passe part au trousseau, sous garde biométrique. - func enableBiometrics() { + /// 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 email = Keychain.get(Keychain.Key.email), + let kdf = Keychain.get(Keychain.Key.kdfParams), + let euk = Keychain.get(Keychain.Key.encryptedUserKey), + let epk = Keychain.get(Keychain.Key.encryptedPrivateKey) + else { + errorMessage = "Aucune session enregistrée sur cet appareil." + return false + } + do { + _ = try Account.unlock( + password: password, email: email, kdfParamsJson: kdf, + encryptedUserKey: euk, encryptedPrivateKey: epk) + } catch { + errorMessage = "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) - if status == errSecSuccess { - Keychain.set("1", for: Keychain.Key.biometricsEnabled) - } else { - NSLog("GP-KEYCHAIN setBiometric a échoué : OSStatus %d", status) + NSLog("GP-BIO store status=%d", status) + guard status == errSecSuccess else { errorMessage = "\(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 reposera pas la question - /// à chaque ouverture — le drapeau distingue « refusé » de « jamais proposé ». + /// 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() { pendingPassword = nil offersBiometricEnrollment = false @@ -207,10 +249,8 @@ final class VaultStore: ObservableObject { do { let dtos = try await api.listItems(token: token) entries = dtos.compactMap { dto in - guard let item = try? decrypt(dto, with: account) else { return nil } - // L'item de registre des dossiers est un détail d'implémentation partagé - // avec la web app : il n'a rien à faire dans la liste. - guard item.name != VaultConstants.foldersItemName else { return nil } + guard let item = try? Self.decrypt(dto, with: account) else { return nil } + guard !Self.isRegistry(item) else { return nil } return VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt) } .sorted { $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending } @@ -225,7 +265,7 @@ final class VaultStore: ObservableObject { isBusy = true defer { isBusy = false } do { - let (key, data) = try encrypt(item, with: account) + let (key, data) = try Self.encrypt(item, with: account) if let id { _ = try await api.updateItem( token: token, id: id, encryptedKey: key, encryptedData: data) @@ -249,17 +289,31 @@ final class VaultStore: ObservableObject { } // ─── 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 == VaultConstants.foldersItemName + } /// 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. - private func decrypt(_ dto: EncryptedItemDTO, with account: Account) throws -> VaultItem { + 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)) } - private func encrypt(_ item: VaultItem, with account: Account) throws -> (String, String) { + 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( diff --git a/apps/ios/Ghostpass/Views/BiometricSetupView.swift b/apps/ios/Ghostpass/Views/BiometricSetupView.swift new file mode 100644 index 00000000..069b643e --- /dev/null +++ b/apps/ios/Ghostpass/Views/BiometricSetupView.swift @@ -0,0 +1,54 @@ +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 { + Form { + Section { + SecureField("Mot de passe maître", text: $password) + .textContentType(.password) + .accessibilityIdentifier("field.masterConfirm") + } footer: { + 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.") + } + + if let message = store.errorMessage { + Section { + Text(message).foregroundStyle(.red) + } + } + } + .navigationTitle("Activer \(store.biometryLabel)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { + store.errorMessage = nil + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + 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 = "" + } + .disabled(password.isEmpty) + .accessibilityIdentifier("button.confirmBiometric") + } + } + } + } +} diff --git a/apps/ios/Ghostpass/Views/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift index 4b6ff171..bbe4c994 100644 --- a/apps/ios/Ghostpass/Views/ItemDetailView.swift +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -50,6 +50,7 @@ struct ItemDetailView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { Button("Modifier", action: onEdit) + .accessibilityIdentifier("button.edit") } } } @@ -102,6 +103,7 @@ private struct SecretRow: View { Image(systemName: revealed ? "eye.slash" : "eye") } .buttonStyle(.borderless) + .accessibilityIdentifier("button.reveal") Button { UIPasteboard.general.string = value } label: { diff --git a/apps/ios/Ghostpass/Views/ItemEditView.swift b/apps/ios/Ghostpass/Views/ItemEditView.swift index 9f3d5d80..6ee30763 100644 --- a/apps/ios/Ghostpass/Views/ItemEditView.swift +++ b/apps/ios/Ghostpass/Views/ItemEditView.swift @@ -35,6 +35,7 @@ struct ItemEditView: View { Form { Section { TextField("Nom", text: $name) + .accessibilityIdentifier("field.name") if isNew { Picker("Type", selection: $kind) { ForEach(Kind.allCases) { Text($0.rawValue).tag($0) } @@ -46,10 +47,13 @@ struct ItemEditView: View { case .login: Section("Identifiants") { TextField("Nom d'utilisateur", text: $username) + .accessibilityIdentifier("field.username") .textInputAutocapitalization(.never) .autocorrectionDisabled() SecureField("Mot de passe", text: $password) + .accessibilityIdentifier("field.password") TextField("Adresse du site", text: $uri) + .accessibilityIdentifier("field.uri") .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) @@ -80,6 +84,7 @@ struct ItemEditView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Annuler") { dismiss() } + .accessibilityIdentifier("button.cancel") } ToolbarItem(placement: .confirmationAction) { Button("Enregistrer") { @@ -89,6 +94,7 @@ struct ItemEditView: View { } } .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .accessibilityIdentifier("button.save") } } .onAppear(perform: load) diff --git a/apps/ios/Ghostpass/Views/UnlockView.swift b/apps/ios/Ghostpass/Views/UnlockView.swift index df6a9ac8..6beeff9b 100644 --- a/apps/ios/Ghostpass/Views/UnlockView.swift +++ b/apps/ios/Ghostpass/Views/UnlockView.swift @@ -19,21 +19,25 @@ struct UnlockView: View { LabeledContent("Compte", value: store.savedEmail) SecureField("Mot de passe maître", text: $password) .textContentType(.password) + .accessibilityIdentifier("field.master") } } else { Section("Serveur") { TextField("https://ghostpass.stackops.ch", text: $server) + .accessibilityIdentifier("field.server") .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) } Section("Compte") { TextField("Adresse e-mail", text: $email) + .accessibilityIdentifier("field.email") .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.emailAddress) SecureField("Mot de passe maître", text: $password) .textContentType(.password) + .accessibilityIdentifier("field.master") if needsTotp { TextField("Code à 6 chiffres", text: $totpCode) .keyboardType(.numberPad) @@ -56,12 +60,14 @@ struct UnlockView: View { } } .disabled(store.isBusy || password.isEmpty) + .accessibilityIdentifier("button.submit") if useSavedSession && store.canUnlockWithBiometrics { Button("Déverrouiller avec \(store.biometryLabel)") { Task { await store.unlockWithBiometrics() } } .disabled(store.isBusy) + .accessibilityIdentifier("button.biometric") } if store.hasSavedSession { @@ -70,6 +76,7 @@ struct UnlockView: View { password = "" } .font(.footnote) + .accessibilityIdentifier("button.switchAccount") } } } diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index ef9aca20..e205cf8c 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -3,7 +3,9 @@ import SwiftUI struct VaultListView: View { @EnvironmentObject private var store: VaultStore @State private var search = "" - @State private var editing: EditTarget? + /// 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? private var visible: [VaultEntry] { guard !search.isEmpty else { return store.entries } @@ -33,7 +35,7 @@ struct VaultListView: View { // capturée à la navigation : sinon une seconde modification rouvrirait // le formulaire avec le contenu d'avant la première. ItemDetailView(entry: entry) { - editing = .existing(store.entries.first { $0.id == entry.id } ?? entry) + sheet = .editItem(store.entries.first { $0.id == entry.id } ?? entry) } } .searchable(text: $search, prompt: "Rechercher") @@ -49,18 +51,50 @@ struct VaultListView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Verrouiller") { store.lock() } + .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("Se déconnecter", role: .destructive) { + Task { await store.signOut() } + } + .accessibilityIdentifier("button.signOut") + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityIdentifier("button.settings") } ToolbarItem(placement: .topBarTrailing) { Button { - editing = .new + sheet = .newItem } label: { Image(systemName: "plus") } + .accessibilityIdentifier("button.add") } } - .sheet(item: $editing) { target in - ItemEditView(target: target) - .environmentObject(store) + .sheet(item: $sheet) { 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) + } } .alert( "Erreur", isPresented: .constant(store.errorMessage != nil), @@ -70,8 +104,10 @@ struct VaultListView: View { "Utiliser \(store.biometryLabel) ?", isPresented: $store.offersBiometricEnrollment, actions: { - Button("Activer") { store.enableBiometrics() } + Button("Activer") { store.acceptOfferedBiometrics() } + .accessibilityIdentifier("button.acceptBiometric") Button("Plus tard", role: .cancel) { store.declineBiometrics() } + .accessibilityIdentifier("button.laterBiometric") }, message: { Text( @@ -82,6 +118,21 @@ struct VaultListView: View { } } +/// Ce que la liste peut présenter par-dessus elle. +enum VaultSheet: Identifiable { + case newItem + case editItem(VaultEntry) + case biometrics + + var id: String { + switch self { + case .newItem: return "new" + case .editItem(let entry): return entry.id + case .biometrics: return "biometrics" + } + } +} + /// Cible d'édition : soit un item existant, soit une création. enum EditTarget: Identifiable { case new diff --git a/apps/ios/README.md b/apps/ios/README.md index b58b47eb..b6f5f62a 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -30,6 +30,35 @@ Rien de ce que produisent ces trois commandes n'est versionné : `Ghostpass.xcod 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 +``` + +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. | + +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. + +**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 | @@ -63,7 +92,10 @@ du projet vit dans `project.yml`, qui se relit et se fusionne — contrairement 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. + 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. - 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, diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift new file mode 100644 index 00000000..0109e197 --- /dev/null +++ b/apps/ios/Tests/ContractTests.swift @@ -0,0 +1,201 @@ +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.serverURL, Keychain.Key.email, Keychain.Key.token, + Keychain.Key.kdfParams, Keychain.Key.encryptedUserKey, + Keychain.Key.encryptedPrivateKey, Keychain.Key.masterPassword, + Keychain.Key.biometricsEnabled, + ] { + Keychain.remove(clef) + } + } + + // ─── 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. + Keychain.set(mail, for: Keychain.Key.email) + Keychain.set( + String(decoding: try JSONSerialization.data(withJSONObject: champs["kdf_params"]!), as: UTF8.self), + for: Keychain.Key.kdfParams) + Keychain.set(try XCTUnwrap(champs["encrypted_user_key"] as? String), for: Keychain.Key.encryptedUserKey) + Keychain.set(try XCTUnwrap(champs["encrypted_private_key"] as? String), for: Keychain.Key.encryptedPrivateKey) + 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) + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift new file mode 100644 index 00000000..4bb2c161 --- /dev/null +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -0,0 +1,220 @@ +import LocalAuthentication +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" } + + 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") + 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") + } + + private func aucuneLigneFantome(_ app: XCUIApplication, _ contexte: String) { + let fantome = app.staticTexts.containing( + NSPredicate(format: "label CONTAINS[c] %@", "gp:folders")) + XCTAssertEqual(fantome.count, 0, "ligne fantôme gp:folders visible (\(contexte))") + } + + func test01ParcoursComplet() throws { + let app = XCUIApplication() + app.launch() + + // 1. Connexion + seConnecter(app) + // Sur un simulateur où une biométrie est inscrite, l'app propose de l'activer. + // On écarte la proposition explicitement : la subir ferait échouer les taps + // suivants pour une raison sans rapport avec ce que ce test vérifie. + let plusTard = app.buttons["button.laterBiometric"].firstMatch + if plusTard.waitForExistence(timeout: 5) { plusTard.tap() } + 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)) + plus.tap() + if !app.buttons["button.cancel"].waitForExistence(timeout: 10) { + plus.tap() // la feuille rate parfois le premier tap juste après le chargement + XCTAssertTrue( + app.buttons["button.cancel"].waitForExistence(timeout: 30), + "la feuille de création ne s'ouvre pas") + } + remplir(app, "field.name", "Forgejo") + remplir(app, "field.username", "clara") + remplir(app, "field.password", "s3cret-initial") + remplir(app, "field.uri", "https://git.stackops.ch") + app.buttons["button.save"].tap() + XCTAssertTrue( + app.staticTexts["Forgejo"].waitForExistence(timeout: 60), + "l'item créé n'apparaît pas dans la liste") + + // 4. Modification + app.buttons["Forgejo, clara"].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 pas emporter le mot de passe qu'on n'a pas touché + app.buttons["button.reveal"].tap() + XCTAssertTrue( + app.staticTexts["s3cret-initial"].waitForExistence(timeout: 30), + "le mot de passe a été écrasé par l'édition") + shot(app, "2-detail") + + // 5. Suppression + if app.navigationBars.buttons["Coffre"].exists { app.navigationBars.buttons["Coffre"].tap() } + let ligne = app.buttons["Forgejo prod, clara"] + 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) + + // 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 ». + let reglages = app.buttons["button.settings"] + XCTAssertTrue(reglages.waitForExistence(timeout: 30), "menu des réglages absent") + reglages.tap() + + 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 ne propose rien : l'application ne voit aucune biométrie, alors même + // que le runner en voyait une. L'inscription simulée du simulateur ne survit + // pas toujours au recyclage que fait xcodebuild entre deux invocations. 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") + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index aa034736..7fd1191a 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -43,3 +43,41 @@ targets: Face ID déverrouille votre coffre sans ressaisir le mot de passe maître. 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/crates/ghostpass-crypto-ffi/examples/seed-vault.rs b/crates/ghostpass-crypto-ffi/examples/seed-vault.rs new file mode 100644 index 00000000..556540f1 --- /dev/null +++ b/crates/ghostpass-crypto-ffi/examples/seed-vault.rs @@ -0,0 +1,76 @@ +//! Prépare le coffre de test consommé par les tests iOS de bout en bout. +//! +//! N'effectue aucun appel réseau : imprime sur la sortie standard le JSON d'inscription +//! et les items déjà chiffrés, que `tools/ios/run-ios-tests.sh` transmet au serveur. +//! Le chiffrement passe par le **même binding** que l'application, ce qui garantit que +//! le coffre amorcé est exactement celui qu'elle saura rouvrir. +//! +//! Usage : `cargo run -p ghostpass-crypto-ffi --example seed-vault -- ` + +use ghostpass_crypto_ffi::{register, Account}; +use std::sync::Arc; + +/// Un `VaultItem` en clair, tel que le sérialise serde côté cœur. +fn vault_item(name: &str, data: &str) -> String { + format!( + r#"{{"name":{},"notes":null,"folder":null,"data":{}}}"#, + serde_json::to_string(name).expect("un nom est toujours sérialisable"), + data + ) +} + +/// Chiffre un item et le présente sous les noms de champs qu'attend l'API HTTP. +fn encrypted(account: &Account, item: String) -> serde_json::Value { + let enc: serde_json::Value = serde_json::from_str( + &account + .encrypt_item(item) + .expect("le chiffrement ne doit pas échouer"), + ) + .expect("le cœur renvoie du JSON"); + serde_json::json!({ + "encryptedKey": enc["encrypted_key"], + "encryptedData": enc["encrypted_data"], + }) +} + +fn main() { + let mut args = std::env::args().skip(1); + let (email, password) = match (args.next(), args.next()) { + (Some(e), Some(p)) => (e, p), + _ => { + eprintln!("usage : seed-vault "); + std::process::exit(2); + } + }; + + let registration = register(password, email.clone()).expect("inscription"); + let blob: serde_json::Value = + serde_json::from_str(®istration.blob()).expect("le blob est du JSON"); + let account: Arc = registration.account(); + + // L'item de registre des dossiers, écrit exactement comme le fait la web app : un + // `SecureNote` dont le nom commence par un octet NUL. L'application doit le filtrer ; + // le voir apparaître dans la liste est la régression que ce coffre sert à détecter. + let folders = vault_item( + "\u{0}gp:folders", + r#"{"kind":"SecureNote","data":{"content":"[\"Travail/Serveurs\"]"}}"#, + ); + let demo = vault_item( + "GitHub", + r#"{"kind":"Login","data":{"username":"clara","password":"hunter2","uris":["https://github.com"],"totp":null,"password_history":[]}}"#, + ); + + let out = serde_json::json!({ + "registration": { + "email": email, + "masterPasswordHash": blob["master_password_hash"], + // Le serveur stocke les paramètres KDF en TEXT : on lui envoie une chaîne. + "kdfParams": serde_json::to_string(&blob["kdf_params"]).expect("kdf sérialisable"), + "encryptedUserKey": blob["encrypted_user_key"], + "encryptedPrivateKey": blob["encrypted_private_key"], + "publicKey": account.public_key(), + }, + "items": [encrypted(&account, folders), encrypted(&account, demo)], + }); + println!("{out}"); +} diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh new file mode 100755 index 00000000..5128bfcc --- /dev/null +++ b/tools/ios/run-ios-tests.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Fait tourner les tests iOS : contrat (rapides, hermétiques) puis parcours de bout en +# bout contre un vrai serveur GhostPass, amorcé ici même. +# +# Tout ce que le script démarre, il l'arrête : serveur, simulateur, base temporaire. +# Le coffre de test contient délibérément l'item de registre "\0gp:folders", que +# l'application doit masquer — c'est la régression que ce parcours sert à détecter. +# +# Effet de bord assumé : désactive la synchronisation du presse-papiers du Simulator +# (réglage global, non restauré). Sans cela, chaque prise de focus dans un champ texte +# fige l'application une minute. Pour revenir en arrière : +# defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool true +# +# Usage : ./tools/ios/run-ios-tests.sh [--unit-only] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IOS="$ROOT/apps/ios" +# Figé : les tests portent la même valeur par défaut (voir VaultFlowTests). +PORT=3111 +EMAIL="clara@ghostpass.test" +PASSWORD="correct horse battery staple" +SERVER_URL="http://127.0.0.1:$PORT" +UNIT_ONLY=false +[[ "${1:-}" == "--unit-only" ]] && UNIT_ONLY=true + +WORK="$(mktemp -d)" +# Les rapports d'un run raté survivent au nettoyage : sans eux, il ne reste qu'un nom de +# test et aucune trace de ce que montrait l'écran. La CI les publie en artefact. +RESULTS="$ROOT/apps/ios/TestResults" +SERVER_PID="" +BIO_PID="" +DEVICE="" + +cleanup() { + local code=$? + [[ -n "$BIO_PID" ]] && kill "$BIO_PID" 2>/dev/null || true + [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true + # `npm start` lance tsx dans un processus fils : tuer le sous-shell le laisserait + # orphelin, à écouter le port, et le run suivant se heurterait à son compte déjà créé. + local lingering + lingering="$(lsof -ti "tcp:$PORT" 2>/dev/null || true)" + [[ -n "$lingering" ]] && kill $lingering 2>/dev/null || true + if [[ $code -ne 0 ]]; then + mkdir -p "$RESULTS" + for bundle in "$WORK"/*.xcresult; do + [[ -e "$bundle" ]] && cp -R "$bundle" "$RESULTS/" 2>/dev/null || true + done + [[ -e "$WORK/server.log" ]] && cp "$WORK/server.log" "$RESULTS/" 2>/dev/null || true + echo "Rapports conservés dans apps/ios/TestResults/" >&2 + fi + if [[ -n "$DEVICE" ]]; then + xcrun simctl shutdown "$DEVICE" >/dev/null 2>&1 || true + xcrun simctl delete "$DEVICE" >/dev/null 2>&1 || true + fi + rm -rf "$WORK" + exit $code +} +trap cleanup EXIT INT TERM + +say() { printf '\n\033[1m▸ %s\033[0m\n' "$*"; } + +# ── Simulateur ──────────────────────────────────────────────────────────────── +# Un simulateur neuf, créé pour ce run et détruit à la fin. C'est le seul moyen +# d'obtenir un trousseau vierge : `simctl uninstall` laisse les éléments Keychain +# derrière lui, et l'app hériterait du choix biométrique du run précédent. Au passage, +# on ne touche à aucun simulateur existant. +read -r DEVTYPE RUNTIME < <(xcrun simctl list -j devicetypes runtimes | python3 -c ' +import json, sys +cat = json.load(sys.stdin) +runtimes = [r for r in cat["runtimes"] if r.get("isAvailable") and r["platform"] == "iOS"] +if not runtimes: + sys.exit("aucun runtime iOS installé") +runtime = sorted(runtimes, key=lambda r: r["version"])[-1] +supported = set(runtime.get("supportedDeviceTypes", []) and + [d["identifier"] for d in runtime["supportedDeviceTypes"]]) +phones = [d for d in cat["devicetypes"] + if "iPhone" in d["name"] and (not supported or d["identifier"] in supported)] +if not phones: + sys.exit("aucun type iPhone disponible") +print(phones[-1]["identifier"], runtime["identifier"]) +') +[[ -n "${DEVTYPE:-}" && -n "${RUNTIME:-}" ]] || { + echo "Impossible de choisir un simulateur. Installez un runtime iOS : xcodebuild -downloadPlatform iOS" >&2 + exit 1 +} + +DEVICE="$(xcrun simctl create "ghostpass-tests-$$" "$DEVTYPE" "$RUNTIME")" +say "Simulateur éphémère $DEVICE ($DEVTYPE)" +xcrun simctl boot "$DEVICE" >/dev/null 2>&1 || true +xcrun simctl bootstatus "$DEVICE" -b >/dev/null 2>&1 || true + +# La saisie de texte gèle une minute si le simulateur tente de synchroniser son +# presse-papiers avec l'hôte : le clavier interroge le pasteboard à chaque prise de focus. +defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false + +# ── Biométrie simulée ───────────────────────────────────────────────────────── +# Sans inscription, `test02Biometrie` se met en skip plutôt que de passer par hasard. +BIOMETRICS=0 +if xcrun simctl spawn "$DEVICE" notifyutil -s com.apple.BiometricKit.enrollmentChanged 1 2>>"$WORK/bio.log" && + xcrun simctl spawn "$DEVICE" notifyutil -p com.apple.BiometricKit.enrollmentChanged 2>>"$WORK/bio.log"; then + BIOMETRICS=1 + # Le panneau Face ID attend une correspondance à chaque demande : on en envoie en + # continu. L'inscription, elle, n'est posée qu'une fois — la reposter en boucle fait + # osciller l'état que lit l'application. + (while true; do + xcrun simctl spawn "$DEVICE" notifyutil -p com.apple.BiometricKit_Sim.pearl.match >/dev/null 2>&1 || true + sleep 2 + done) & + BIO_PID=$! + say "Biométrie simulée : active" +else + say "Biométrie simulée : indisponible ($(tail -1 "$WORK/bio.log" 2>/dev/null)), test ignoré" +fi + + +# ── Cœur crypto + projet ────────────────────────────────────────────────────── +say "Construction de l'XCFramework et des bindings" +"$ROOT/tools/ios/build-xcframework.sh" >"$WORK/xcframework.log" 2>&1 || + { echo "Échec du build XCFramework :" >&2; tail -30 "$WORK/xcframework.log" >&2; exit 1; } + +say "Génération du projet Xcode" +(cd "$IOS" && xcodegen generate >/dev/null) + +run_tests() { + local only="$1"; shift + xcodebuild -project "$IOS/Ghostpass.xcodeproj" -scheme Ghostpass \ + -sdk iphonesimulator -destination "platform=iOS Simulator,id=$DEVICE" \ + -derivedDataPath "$WORK/dd" -resultBundlePath "$WORK/$only.xcresult" \ + -only-testing:"$only" "$@" test +} + +# ── Tests de contrat ────────────────────────────────────────────────────────── +say "Tests de contrat" +run_tests GhostpassTests + +if $UNIT_ONLY; then + say "Tests de contrat : OK (parcours de bout en bout ignoré)" + exit 0 +fi + +# ── Serveur de test ─────────────────────────────────────────────────────────── +if lsof -ti "tcp:$PORT" >/dev/null 2>&1; then + echo "Le port $PORT est déjà occupé — un run précédent a-t-il survécu ?" >&2 + exit 1 +fi + +say "Démarrage du serveur GhostPass (SQLite jetable, port $PORT)" +[[ -d "$ROOT/apps/server/node_modules" ]] || (cd "$ROOT/apps/server" && npm ci >/dev/null) +(cd "$ROOT/apps/server" && DB_PATH="$WORK/ghostpass.db" PORT="$PORT" npm start >"$WORK/server.log" 2>&1) & +SERVER_PID=$! + +ready=false +for _ in $(seq 1 60); do + if curl -sS -o /dev/null --max-time 2 "$SERVER_URL/api/auth/prelogin" -X POST \ + -H 'Content-Type: application/json' -d '{"email":"probe@example.com"}'; then + ready=true + break + fi + sleep 1 +done +$ready || { echo "Le serveur n'a pas démarré :" >&2; tail -20 "$WORK/server.log" >&2; exit 1; } + +say "Amorçage du coffre de test (compte + item de registre gp:folders)" +cargo run -q -p ghostpass-crypto-ffi --example seed-vault -- "$EMAIL" "$PASSWORD" >"$WORK/seed.json" +SERVER_URL="$SERVER_URL" python3 - "$WORK/seed.json" <<'PY' +import json, os, sys, urllib.request +base = os.environ["SERVER_URL"] +seed = json.load(open(sys.argv[1])) + +def post(path, body, token=None): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = "Bearer " + token + req = urllib.request.Request(base + path, data=json.dumps(body).encode(), headers=headers) + with urllib.request.urlopen(req) as r: + return json.loads(r.read() or b"{}") + +token = post("/api/auth/register", seed["registration"])["token"] +for item in seed["items"]: + post("/api/vault/items", item, token) +print(f" compte créé, {len(seed['items'])} items déposés") +PY + +# ── Parcours de bout en bout ────────────────────────────────────────────────── +say "Parcours de bout en bout" +# Pas d'environnement à passer : `xcodebuild` n'en propage aucun jusqu'au processus de +# test. Les tests connaissent ces valeurs par défaut ; c'est le contrat entre eux et ce +# script — d'où le port et le compte figés plus haut. +if ! run_tests GhostpassUITests; then + echo "--- journal de l'application ---" >&2 + xcrun simctl spawn "$DEVICE" log show --last 15m --style compact \ + --predicate 'process == "Ghostpass"' 2>/dev/null | grep -a "GP-" | tail -25 >&2 || + echo "(journal indisponible)" >&2 + exit 1 +fi + +say "Tous les tests iOS sont passés" From bd9043de7199a2ac3aadd35c8392c93af532e92f Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 09:04:23 +0200 Subject: [PATCH 05/50] =?UTF-8?q?feat(ios):=20coffre=20hors=20ligne,=20g?= =?UTF-8?q?=C3=A9n=C3=A9rateur,=20codes=20TOTP,=20presse-papiers=20qui=20e?= =?UTF-8?q?xpire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les quatre manques qui se voyaient le plus à l'usage. **Hors ligne.** `refresh()` interrogeait toujours le serveur : sans réseau, le coffre s'affichait vide — ce qui ressemble à s'y méprendre à un coffre qu'on aurait perdu. `VaultCache` conserve désormais sur l'appareil les blobs **tels que le serveur les stocke**, déjà chiffrés et inutilisables sans l'USK, sous `.completeFileProtection`. La copie locale s'affiche d'abord, le serveur reprend la main dès qu'il répond, un bandeau signale l'écart. Écrire, en revanche, suppose toujours le serveur : faute de file d'attente, une modification hors ligne est refusée avec un message explicite plutôt qu'acceptée en apparence puis perdue. Se déconnecter efface la copie. **Générateur.** Transposition fidèle de `apps/web/src/lib/generator.ts` : mêmes jeux, mêmes défauts, tirage sans biais par rejet, au moins un caractère de chaque jeu demandé, mélange de Fisher-Yates pour ne pas figer leur position. **TOTP.** Transposition de `apps/web/src/lib/totp.ts`, validée sur les vecteurs de la RFC 6238. L'écran de détail affiche le code et son compte à rebours. Ni l'un ni l'autre n'existe dans le cœur Rust, et la web app les fait déjà 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é, et aucun secret ne quitte l'appareil. **Presse-papiers.** Toute copie de secret expire après trente secondes. 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 reste jusqu'à la copie suivante est un mot de passe posé sur la table. Au passage, un bug de perte de données : `ItemEditView` ne chargeait ni ne réécrivait le champ `totp`. **Toute modification d'un identifiant effaçait son second facteur**, sans rien signaler. Corrigé, et tenu par le parcours de test. Trois défauts du script de test, dont deux auraient immobilisé la CI : - `simctl bootstatus -b` peut ne jamais rendre la main sur un simulateur qu'on vient de créer — le job aurait tourné jusqu'à son propre délai. L'attente est bornée. - Les rapports des tests UI n'étaient pas conservés : leur nom contient des « / », ils atterrissaient dans des sous-dossiers que la copie ignorait. Un échec en CI n'aurait laissé aucune trace. - Les produits de compilation vivaient dans un répertoire temporaire : tout était recompilé à chaque run, quatre minutes pour rien en local. Le parcours de test gagne une phase : le serveur est **réellement coupé**, puis le coffre doit se rouvrir au seul mot de passe maître et afficher son contenu. Simuler l'absence de réseau autrement reviendrait à éprouver le simulacre plutôt que l'application. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitignore | 1 + apps/ios/Ghostpass/Services/Clipboard.swift | 19 +++ .../Services/PasswordGenerator.swift | 73 +++++++++ apps/ios/Ghostpass/Services/Totp.swift | 111 +++++++++++++ apps/ios/Ghostpass/Services/VaultCache.swift | 41 +++++ apps/ios/Ghostpass/Services/VaultStore.swift | 49 +++++- apps/ios/Ghostpass/Views/ItemDetailView.swift | 34 +++- apps/ios/Ghostpass/Views/ItemEditView.swift | 35 ++++- .../Views/PasswordGeneratorView.swift | 77 +++++++++ apps/ios/Ghostpass/Views/VaultListView.swift | 9 ++ apps/ios/README.md | 26 ++++ apps/ios/Tests/ContractTests.swift | 146 ++++++++++++++++++ apps/ios/UITests/VaultFlowTests.swift | 54 ++++++- tools/ios/run-ios-tests.sh | 45 +++++- 14 files changed, 702 insertions(+), 18 deletions(-) create mode 100644 apps/ios/Ghostpass/Services/Clipboard.swift create mode 100644 apps/ios/Ghostpass/Services/PasswordGenerator.swift create mode 100644 apps/ios/Ghostpass/Services/Totp.swift create mode 100644 apps/ios/Ghostpass/Services/VaultCache.swift create mode 100644 apps/ios/Ghostpass/Views/PasswordGeneratorView.swift diff --git a/.gitignore b/.gitignore index b9042963..8b6b1df3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ apps/ios/GhostpassCrypto.xcframework/ # Rapports de tests iOS (conservés en cas d'échec, régénérables) apps/ios/TestResults/ +apps/ios/.build/ 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/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/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..555e42f9 --- /dev/null +++ b/apps/ios/Ghostpass/Services/VaultCache.swift @@ -0,0 +1,41 @@ +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" + + private static var url: URL? { + guard + let directory = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first + 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 index 80132d86..5f97260a 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -11,6 +11,8 @@ final class VaultStore: ObservableObject { @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 /// 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 @@ -112,6 +114,7 @@ final class VaultStore: ObservableObject { account = nil entries = [] isUnlocked = false + isOffline = false pendingPassword = nil offersBiometricEnrollment = false } @@ -124,6 +127,7 @@ final class VaultStore: ObservableObject { lock() token = nil api = nil + VaultCache.clear() for key in [ Keychain.Key.token, Keychain.Key.kdfParams, Keychain.Key.encryptedUserKey, Keychain.Key.encryptedPrivateKey, Keychain.Key.masterPassword, @@ -244,22 +248,42 @@ final class VaultStore: ObservableObject { // ─── 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 api, let token, let account else { return } + guard let account else { return } + if entries.isEmpty, let caches = VaultCache.load() { + entries = Self.entries(from: caches, with: account) + isOffline = true + } + guard let api, let token else { return } do { let dtos = try await api.listItems(token: token) - entries = dtos.compactMap { dto in - guard let item = try? Self.decrypt(dto, with: account) else { return nil } - guard !Self.isRegistry(item) else { return nil } - return VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt) - } - .sorted { $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending } + VaultCache.save(dtos) + entries = Self.entries(from: dtos, with: account) + isOffline = false errorMessage = nil } catch { - errorMessage = error.localizedDescription + // 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] + { + dtos.compactMap { dto in + guard let item = try? decrypt(dto, with: account), !isRegistry(item) else { return nil } + return VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt) + } + .sorted { $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending } + } + func save(_ item: VaultItem, id: String?) async { guard let api, let token, let account else { return } isBusy = true @@ -273,6 +297,10 @@ final class VaultStore: ObservableObject { _ = 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 = "Serveur injoignable : la modification n'a pas été enregistrée." } catch { errorMessage = error.localizedDescription } @@ -283,6 +311,11 @@ final class VaultStore: ObservableObject { 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 = "Serveur injoignable : la suppression n'a pas été enregistrée." } catch { errorMessage = error.localizedDescription } diff --git a/apps/ios/Ghostpass/Views/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift index bbe4c994..d92d5047 100644 --- a/apps/ios/Ghostpass/Views/ItemDetailView.swift +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -20,6 +20,36 @@ struct ItemDetailView: View { CopyRow(label: "Nom d'utilisateur", value: login.username) SecretRow(label: "Mot de passe", value: login.password, revealed: $revealed) } + if let config = login.totp.flatMap(Totp.parse) { + Section("Code à usage unique") { + // `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 { + Text(otp.code) + .font(.system(.title2, design: .monospaced)) + .accessibilityIdentifier("text.totp") + Spacer() + Text("\(otp.remaining) s") + .font(.footnote) + .foregroundStyle(otp.remaining <= 5 ? .red : .secondary) + .monospacedDigit() + Button { + Clipboard.copy(otp.code) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + } + } else { + Text("Clé de vérification illisible.") + .foregroundStyle(.secondary) + } + } + } + } if !login.uris.isEmpty { Section("Adresses") { ForEach(login.uris, id: \.self) { uri in @@ -64,7 +94,7 @@ private struct CopyRow: View { LabeledContent(label, value: value) Spacer() Button { - UIPasteboard.general.string = value + Clipboard.copy(value) } label: { Image(systemName: "doc.on.doc") } @@ -105,7 +135,7 @@ private struct SecretRow: View { .buttonStyle(.borderless) .accessibilityIdentifier("button.reveal") Button { - UIPasteboard.general.string = value + Clipboard.copy(value) } label: { Image(systemName: "doc.on.doc") } diff --git a/apps/ios/Ghostpass/Views/ItemEditView.swift b/apps/ios/Ghostpass/Views/ItemEditView.swift index 6ee30763..0f488231 100644 --- a/apps/ios/Ghostpass/Views/ItemEditView.swift +++ b/apps/ios/Ghostpass/Views/ItemEditView.swift @@ -16,6 +16,8 @@ struct ItemEditView: View { @State private var username = "" @State private var password = "" @State private var uri = "" + @State private var totp = "" + @State private var generating = false @State private var noteContent = "" @State private var cardholder = "" @State private var cardNumber = "" @@ -50,14 +52,35 @@ struct ItemEditView: View { .accessibilityIdentifier("field.username") .textInputAutocapitalization(.never) .autocorrectionDisabled() - SecureField("Mot de passe", text: $password) - .accessibilityIdentifier("field.password") + HStack { + SecureField("Mot de passe", text: $password) + .accessibilityIdentifier("field.password") + Button { + generating = true + } label: { + Image(systemName: "dice") + } + .buttonStyle(.borderless) + .accessibilityIdentifier("button.generate") + } TextField("Adresse du site", text: $uri) .accessibilityIdentifier("field.uri") .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) } + Section { + TextField("Clé ou URI otpauth://", text: $totp) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("field.totp") + } header: { + Text("Code à usage unique") + } footer: { + Text( + "La clé reste chiffrée dans l'item ; les codes sont calculés " + + "sur l'appareil.") + } case .note: Section("Contenu") { TextEditor(text: $noteContent).frame(minHeight: 140) @@ -98,6 +121,9 @@ struct ItemEditView: View { } } .onAppear(perform: load) + .sheet(isPresented: $generating) { + PasswordGeneratorView { password = $0 } + } } } @@ -122,6 +148,7 @@ struct ItemEditView: View { username = login.username password = login.password uri = login.uris.first ?? "" + totp = login.totp ?? "" case .secureNote(let note): kind = .note noteContent = note.content @@ -142,7 +169,9 @@ struct ItemEditView: View { data = .login( Login( username: username, password: password, - uris: uri.isEmpty ? [] : [uri], totp: nil, + 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)) diff --git a/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift b/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift new file mode 100644 index 00000000..7a45f3cc --- /dev/null +++ b/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +/// Génération d'un mot de passe, présentée depuis le formulaire d'édition. +/// +/// Le mot de passe n'est proposé que ; 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 { + Form { + Section { + Text(password.isEmpty ? " " : password) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("text.generated") + Button("Régénérer", systemImage: "arrow.clockwise") { regenerate() } + .accessibilityIdentifier("button.regenerate") + } + + Section("Longueur : \(options.length)") { + // Pas moins de 8 : en dessous, la longueur ne protège plus de rien. + Slider(value: lengthBinding, in: 8...64, step: 1) + .accessibilityIdentifier("slider.length") + } + + Section("Caractères") { + Toggle("Minuscules", isOn: binding(\.lowercase)) + Toggle("Majuscules", isOn: binding(\.uppercase)) + Toggle("Chiffres", isOn: binding(\.digits)) + Toggle("Symboles", isOn: binding(\.symbols)) + } + } + .navigationTitle("Générer") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Utiliser") { + onUse(password) + dismiss() + } + .disabled(password.isEmpty) + .accessibilityIdentifier("button.usePassword") + } + } + .onAppear(perform: regenerate) + } + } + + 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/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index e205cf8c..3a56f584 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -19,6 +19,15 @@ struct VaultListView: View { var body: some View { NavigationStack { List { + if store.isOffline { + Label( + "Hors ligne — coffre affiché depuis cet appareil", + systemImage: "wifi.slash" + ) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityIdentifier("banner.offline") + } ForEach(visible) { entry in NavigationLink(value: entry) { VaultRow(entry: entry) diff --git a/apps/ios/README.md b/apps/ios/README.md index b6f5f62a..c181ee6b 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -67,10 +67,25 @@ passe faux, et le fait qu'aucune session n'autorise l'activation. | `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/Assets.xcassets` | Icône, dérivée de `assets/logo/ghostpass.svg` | | `Ghostpass/Services/VaultStore.swift` | État de l'app, passage de frontière chiffré/clair | | `Ghostpass/Views/` | SwiftUI : déverrouillage, liste, détail, édition | +## 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. + ## Ce qui n'y est pas encore - **Extension AutoFill** : cible séparée, à ajouter une fois l'app validée sur appareil. @@ -79,6 +94,9 @@ passe faux, et le fait qu'aucune session n'autorise l'activation. *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. ## Points de vigilance @@ -96,6 +114,14 @@ passe faux, et le fait qu'aucune session n'autorise l'activation. 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, diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 0109e197..7b3910d1 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -199,3 +199,149 @@ final class ContractTests: XCTestCase { XCTAssertFalse(store.isBiometricEnabled) } } + +/// 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)) + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index 4bb2c161..9abeaebd 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -97,8 +97,22 @@ final class VaultFlowTests: XCTestCase { } 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), @@ -115,11 +129,19 @@ final class VaultFlowTests: XCTestCase { app.staticTexts["Forgejo prod"].waitForExistence(timeout: 60), "l'écran de détail garde l'ancien contenu après modification") - // 4b. Une modification ne doit pas emporter le mot de passe qu'on n'a pas touché + // 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") // 5. Suppression @@ -217,4 +239,34 @@ final class VaultFlowTests: XCTestCase { "la biométrie n'a pas rouvert le coffre") shot(app, "4-biometrie") } + + /// 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") + } } diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh index 5128bfcc..1d47f036 100755 --- a/tools/ios/run-ios-tests.sh +++ b/tools/ios/run-ios-tests.sh @@ -12,6 +12,8 @@ # defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool true # # Usage : ./tools/ios/run-ios-tests.sh [--unit-only] +# GHOSTPASS_KEEP_RESULTS=1 conserve aussi les rapports d'un run réussi (captures +# d'écran comprises), pour inspecter ce qu'a vu le test. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -28,6 +30,10 @@ WORK="$(mktemp -d)" # Les rapports d'un run raté survivent au nettoyage : sans eux, il ne reste qu'un nom de # test et aucune trace de ce que montrait l'écran. La CI les publie en artefact. RESULTS="$ROOT/apps/ios/TestResults" +# Les produits de compilation, eux, survivent d'un run à l'autre : les remettre dans un +# répertoire temporaire ferait tout recompiler à chaque fois — quatre minutes pour rien +# en local. La CI part d'une machine neuve, cela ne change rien pour elle. +DERIVED="$ROOT/apps/ios/.build" SERVER_PID="" BIO_PID="" DEVICE="" @@ -41,7 +47,7 @@ cleanup() { local lingering lingering="$(lsof -ti "tcp:$PORT" 2>/dev/null || true)" [[ -n "$lingering" ]] && kill $lingering 2>/dev/null || true - if [[ $code -ne 0 ]]; then + if [[ $code -ne 0 || "${GHOSTPASS_KEEP_RESULTS:-}" == "1" ]]; then mkdir -p "$RESULTS" for bundle in "$WORK"/*.xcresult; do [[ -e "$bundle" ]] && cp -R "$bundle" "$RESULTS/" 2>/dev/null || true @@ -88,7 +94,18 @@ print(phones[-1]["identifier"], runtime["identifier"]) DEVICE="$(xcrun simctl create "ghostpass-tests-$$" "$DEVTYPE" "$RUNTIME")" say "Simulateur éphémère $DEVICE ($DEVTYPE)" xcrun simctl boot "$DEVICE" >/dev/null 2>&1 || true -xcrun simctl bootstatus "$DEVICE" -b >/dev/null 2>&1 || true +# `simctl bootstatus -b` peut ne jamais rendre la main sur un simulateur qu'on vient de +# créer — en intégration continue, le job tournerait alors jusqu'à son propre délai. +# On interroge nous-mêmes, avec une borne. +booted=false +for _ in $(seq 1 90); do + if xcrun simctl list devices | grep -q "$DEVICE.*Booted"; then + booted=true + break + fi + sleep 1 +done +$booted || { echo "Le simulateur n'a pas démarré en 90 s." >&2; exit 1; } # La saisie de texte gèle une minute si le simulateur tente de synchroniser son # presse-papiers avec l'hôte : le clavier interroge le pasteboard à chaque prise de focus. @@ -124,9 +141,12 @@ say "Génération du projet Xcode" run_tests() { local only="$1"; shift + # Un identifiant de test contient des « / » : tels quels, ils feraient du rapport un + # sous-dossier, que la conservation en cas d'échec ne ramasserait pas. + local nom="${only//\//-}" xcodebuild -project "$IOS/Ghostpass.xcodeproj" -scheme Ghostpass \ -sdk iphonesimulator -destination "platform=iOS Simulator,id=$DEVICE" \ - -derivedDataPath "$WORK/dd" -resultBundlePath "$WORK/$only.xcresult" \ + -derivedDataPath "$DERIVED" -resultBundlePath "$WORK/$nom.xcresult" \ -only-testing:"$only" "$@" test } @@ -187,7 +207,8 @@ say "Parcours de bout en bout" # Pas d'environnement à passer : `xcodebuild` n'en propage aucun jusqu'au processus de # test. Les tests connaissent ces valeurs par défaut ; c'est le contrat entre eux et ce # script — d'où le port et le compte figés plus haut. -if ! run_tests GhostpassUITests; then +if ! run_tests GhostpassUITests/VaultFlowTests/test01ParcoursComplet \ + -only-testing:GhostpassUITests/VaultFlowTests/test02Biometrie; then echo "--- journal de l'application ---" >&2 xcrun simctl spawn "$DEVICE" log show --last 15m --style compact \ --predicate 'process == "Ghostpass"' 2>/dev/null | grep -a "GP-" | tail -25 >&2 || @@ -195,4 +216,20 @@ if ! run_tests GhostpassUITests; then exit 1 fi +# ── Hors ligne ──────────────────────────────────────────────────────────────── +# Le coffre doit s'ouvrir sans serveur. On coupe pour de bon : simuler l'absence de +# réseau autrement reviendrait à éprouver le simulacre plutôt que l'application. +say "Coupure du serveur, puis réouverture hors ligne" +kill "$SERVER_PID" 2>/dev/null || true +lingering="$(lsof -ti "tcp:$PORT" 2>/dev/null || true)" +[[ -n "$lingering" ]] && kill $lingering 2>/dev/null || true +SERVER_PID="" +sleep 2 + +if ! run_tests GhostpassUITests/VaultFlowTests/test03HorsLigne; then + echo "--- journal du serveur ---" >&2 + tail -20 "$WORK/server.log" >&2 || true + exit 1 +fi + say "Tous les tests iOS sont passés" From b7bf55cdd5bb8e745725cbdf640bf7edc0e46d1b Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 09:32:34 +0200 Subject: [PATCH 06/50] feat(ios): extension de remplissage automatique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le chaînon qui manquait pour que GhostPass serve au quotidien : jusqu'ici, remplir un formulaire imposait d'ouvrir l'application, chercher, copier, revenir. `GhostpassAutoFill` est une cible à part, lancée par iOS dans son propre processus au moment où un champ réclame un identifiant. Elle ne voit de l'application que ce qui a été déposé dans le groupe `group.ch.stackops.ghostpass`, et **ne parle jamais au serveur** : un remplissage doit aboutir en quelques secondes, réseau ou pas — d'où le cache local posé au chantier précédent, dont c'était le vrai motif. Elle réclame le mot de passe maître (ou Face ID), déchiffre la copie locale, et présente en tête les identifiants du site en cours. Le rapprochement site ↔ adresses vit dans `SiteMatching`, partagé avec l'application : une seule règle des deux côtés, sinon les suggestions dépendraient de l'endroit d'où l'on regarde. Le point de séparation compte — `notexample.com` ne doit pas passer pour un sous-domaine d'`example.com`, sans quoi le coffre livrerait ses identifiants à un voisin. `CredentialIdentities` inscrit les identifiants auprès d'iOS pour qu'il propose GhostPass **au-dessus du clavier** plutôt qu'au fond d'un menu. Seuls le nom d'utilisateur et le domaine y sont déposés ; le mot de passe n'est jamais transmis, iOS rappelle l'extension pour l'obtenir. Au passage, la session change de place. Le jeton reste au trousseau — c'est lui qui ouvre le compte côté serveur. Les blobs chiffrés, eux, passent dans le conteneur partagé : le serveur les détient déjà, ils n'ouvrent rien sans la dérivation Argon2id, et l'extension en a besoin. Une seule source de vérité plutôt qu'une copie de part et d'autre. Deux pièges d'empaquetage, tous deux silencieux : - XcodeGen régénérait l'Info.plist de l'extension par-dessus le fichier écrit à la main, effaçant le dictionnaire `NSExtension` — iOS refusait alors d'installer l'application entière, sans dire pourquoi. - Un Info.plist manuel doit porter lui-même son `CFBundleIdentifier` : sans lui, l'extension sort sans identité et l'installation échoue sur un message parlant de préfixe de bundle. Vérifié dans le simulateur, de bout en bout : page de connexion servie en local, champ touché, « Mots de passe » au clavier, GhostPass proposé parmi les fournisseurs, déverrouillage, identifiant du site présenté sous « Pour ce site », formulaire rempli. Le script de test ajoute cette phase, et le coffre amorcé porte un identifiant pour la page. Reste à régler sur appareil réel, et non vérifiable ici : le groupe d'applications et l'entitlement AutoFill demandent une équipe de développement, et le trousseau n'est pas partagé entre l'application et son extension — il faudra un `keychain-access-groups` commun pour que Face ID serve aussi au remplissage. Sans lui, l'extension demandera le mot de passe maître, ce qui reste fonctionnel. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .github/workflows/ci.yml | 3 +- apps/ios/Ghostpass/Ghostpass.entitlements | 16 ++ .../Services/CredentialIdentities.swift | 41 +++++ apps/ios/Ghostpass/Services/Keychain.swift | 8 +- apps/ios/Ghostpass/Services/SharedStore.swift | 59 ++++++ .../ios/Ghostpass/Services/SiteMatching.swift | 39 ++++ apps/ios/Ghostpass/Services/VaultCache.swift | 8 +- apps/ios/Ghostpass/Services/VaultStore.swift | 56 +++--- .../ios/GhostpassAutoFill/AutoFillStore.swift | 109 ++++++++++++ apps/ios/GhostpassAutoFill/AutoFillView.swift | 111 ++++++++++++ .../CredentialProviderViewController.swift | 62 +++++++ .../GhostpassAutoFill.entitlements | 12 ++ apps/ios/GhostpassAutoFill/Info.plist | 50 ++++++ apps/ios/README.md | 30 +++- apps/ios/Tests/ContractTests.swift | 168 ++++++++++++++++-- apps/ios/UITests/AutoFillSafariTests.swift | 92 ++++++++++ apps/ios/project.yml | 27 +++ .../examples/seed-vault.rs | 9 +- tools/ios/run-ios-tests.sh | 20 +++ tools/ios/testpage/index.html | 25 +++ 20 files changed, 888 insertions(+), 57 deletions(-) create mode 100644 apps/ios/Ghostpass/Ghostpass.entitlements create mode 100644 apps/ios/Ghostpass/Services/CredentialIdentities.swift create mode 100644 apps/ios/Ghostpass/Services/SharedStore.swift create mode 100644 apps/ios/Ghostpass/Services/SiteMatching.swift create mode 100644 apps/ios/GhostpassAutoFill/AutoFillStore.swift create mode 100644 apps/ios/GhostpassAutoFill/AutoFillView.swift create mode 100644 apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift create mode 100644 apps/ios/GhostpassAutoFill/GhostpassAutoFill.entitlements create mode 100644 apps/ios/GhostpassAutoFill/Info.plist create mode 100644 apps/ios/UITests/AutoFillSafariTests.swift create mode 100644 tools/ios/testpage/index.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3324c807..20c01879 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,8 @@ jobs: 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), puis déroule les deux suites. + # 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 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/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/Keychain.swift b/apps/ios/Ghostpass/Services/Keychain.swift index 22a0e1b6..231aadde 100644 --- a/apps/ios/Ghostpass/Services/Keychain.swift +++ b/apps/ios/Ghostpass/Services/Keychain.swift @@ -93,12 +93,10 @@ enum Keychain { } enum Key { - static let serverURL = "serverURL" - static let email = "email" + /// 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" - static let kdfParams = "kdfParams" - static let encryptedUserKey = "encryptedUserKey" - static let encryptedPrivateKey = "encryptedPrivateKey" /// 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" 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/VaultCache.swift b/apps/ios/Ghostpass/Services/VaultCache.swift index 555e42f9..35e63bfc 100644 --- a/apps/ios/Ghostpass/Services/VaultCache.swift +++ b/apps/ios/Ghostpass/Services/VaultCache.swift @@ -11,12 +11,10 @@ import Foundation 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 = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask - ).first - else { return nil } + guard let directory = SharedStore.container else { return nil } try? FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true) return directory.appendingPathComponent(fileName) diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 5f97260a..301a6c37 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -24,12 +24,10 @@ final class VaultStore: ObservableObject { /// au-delà : `enableBiometrics` et `declineBiometrics` l'effacent tous les deux. private var pendingPassword: String? - var hasSavedSession: Bool { - Keychain.get(Keychain.Key.token) != nil && Keychain.get(Keychain.Key.email) != nil - } + var hasSavedSession: Bool { SharedStore.load() != nil } - var savedEmail: String { Keychain.get(Keychain.Key.email) ?? "" } - var savedServer: String { Keychain.get(Keychain.Key.serverURL) ?? "" } + var savedEmail: String { SharedStore.load()?.email ?? "" } + var savedServer: String { SharedStore.load()?.serverURL ?? "" } // ─── Session ─── @@ -64,12 +62,15 @@ final class VaultStore: ObservableObject { isUnlocked = true errorMessage = nil - Keychain.set(server, for: Keychain.Key.serverURL) - Keychain.set(email, for: Keychain.Key.email) + // 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) - Keychain.set(session.kdfParams, for: Keychain.Key.kdfParams) - Keychain.set(session.encryptedUserKey, for: Keychain.Key.encryptedUserKey) - Keychain.set(session.encryptedPrivateKey, for: Keychain.Key.encryptedPrivateKey) + SharedStore.save( + SharedStore.Session( + serverURL: server, email: email, kdfParams: session.kdfParams, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey)) proposeBiometricsIfPossible(password) await refresh() @@ -82,13 +83,7 @@ final class VaultStore: ObservableObject { /// 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 email = Keychain.get(Keychain.Key.email), - let kdf = Keychain.get(Keychain.Key.kdfParams), - let euk = Keychain.get(Keychain.Key.encryptedUserKey), - let epk = Keychain.get(Keychain.Key.encryptedPrivateKey), - let server = Keychain.get(Keychain.Key.serverURL), - let url = URL(string: server) - else { + guard let session = SharedStore.load(), let url = URL(string: session.serverURL) else { errorMessage = "Aucune session enregistrée sur cet appareil." return } @@ -96,8 +91,9 @@ final class VaultStore: ObservableObject { defer { isBusy = false } do { account = try Account.unlock( - password: password, email: email, kdfParamsJson: kdf, - encryptedUserKey: euk, encryptedPrivateKey: epk) + 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 @@ -129,12 +125,12 @@ final class VaultStore: ObservableObject { api = nil VaultCache.clear() for key in [ - Keychain.Key.token, Keychain.Key.kdfParams, Keychain.Key.encryptedUserKey, - Keychain.Key.encryptedPrivateKey, Keychain.Key.masterPassword, - Keychain.Key.biometricsEnabled, + Keychain.Key.token, Keychain.Key.masterPassword, Keychain.Key.biometricsEnabled, ] { Keychain.remove(key) } + SharedStore.clear() + await CredentialIdentities.clear() } // ─── Biométrie ─── @@ -157,9 +153,6 @@ final class VaultStore: ObservableObject { /// 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 { - NSLog("GP-BIO tentative flag=%@ dispo=%@", - Keychain.get(Keychain.Key.biometricsEnabled) ?? "(absent)", - Biometrics.isAvailable ? "oui" : "non") guard Keychain.get(Keychain.Key.biometricsEnabled) == "1" else { return } isBusy = true let prompt = "Déverrouiller votre coffre GhostPass" @@ -200,18 +193,15 @@ final class VaultStore: ObservableObject { /// 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 email = Keychain.get(Keychain.Key.email), - let kdf = Keychain.get(Keychain.Key.kdfParams), - let euk = Keychain.get(Keychain.Key.encryptedUserKey), - let epk = Keychain.get(Keychain.Key.encryptedPrivateKey) - else { + guard let session = SharedStore.load() else { errorMessage = "Aucune session enregistrée sur cet appareil." return false } do { _ = try Account.unlock( - password: password, email: email, kdfParamsJson: kdf, - encryptedUserKey: euk, encryptedPrivateKey: epk) + password: password, email: session.email, kdfParamsJson: session.kdfParams, + encryptedUserKey: session.encryptedUserKey, + encryptedPrivateKey: session.encryptedPrivateKey) } catch { errorMessage = "Mot de passe maître incorrect." return false @@ -222,7 +212,6 @@ final class VaultStore: ObservableObject { @discardableResult private func store(_ password: String) -> Bool { let status = Keychain.setBiometric(password, for: Keychain.Key.masterPassword) - NSLog("GP-BIO store status=%d", status) guard status == errSecSuccess else { errorMessage = "\(Biometrics.label) n'a pas pu être activé (code \(status))." return false @@ -264,6 +253,7 @@ final class VaultStore: ObservableObject { entries = Self.entries(from: dtos, with: 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. diff --git a/apps/ios/GhostpassAutoFill/AutoFillStore.swift b/apps/ios/GhostpassAutoFill/AutoFillStore.swift new file mode 100644 index 00000000..d3257393 --- /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 = "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 = "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 = "\(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..64d848e5 --- /dev/null +++ b/apps/ios/GhostpassAutoFill/AutoFillView.swift @@ -0,0 +1,111 @@ +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 { + Group { + if store.isUnlocked { + liste + } else { + deverrouillage + } + } + .navigationTitle("GhostPass") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuler") { store.cancel() } + .accessibilityIdentifier("button.cancel") + } + } + } + } + + private var deverrouillage: some View { + Form { + if store.hasSession { + Section("Coffre") { + LabeledContent("Compte", value: store.account) + SecureField("Mot de passe maître", text: $password) + .textContentType(.password) + .accessibilityIdentifier("field.master") + } + Section { + Button("Déverrouiller") { + Task { + await store.unlock(password: password) + password = "" + } + } + .disabled(store.isBusy || password.isEmpty) + .accessibilityIdentifier("button.submit") + + if store.canUseBiometrics { + Button("Déverrouiller avec \(store.biometryLabel)") { + Task { await store.unlockWithBiometrics() } + } + .disabled(store.isBusy) + .accessibilityIdentifier("button.biometric") + } + } + } 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( + "Coffre indisponible", systemImage: "lock", + description: Text("Ouvrez GhostPass et connectez-vous une fois.")) + } + + if let message = store.errorMessage { + Section { Text(message).foregroundStyle(.red) } + } + } + .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("Pour ce site") { + ForEach(store.suggested) { ligne($0) } + } + } + if !store.others.isEmpty { + Section(store.suggested.isEmpty ? "Coffre" : "Autres identifiants") { + ForEach(store.others) { ligne($0) } + } + } + } + .overlay { + if store.entries.isEmpty { + ContentUnavailableView( + "Aucun identifiant", systemImage: "key", + description: Text(store.errorMessage ?? "Le coffre local ne contient rien.")) + } + } + } + + private func ligne(_ entry: VaultEntry) -> some View { + Button { + store.pick(entry) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(entry.item.name).foregroundStyle(.primary) + if let login = entry.login, !login.username.isEmpty { + Text(login.username).font(.caption).foregroundStyle(.secondary) + } + } + } + } +} diff --git a/apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift b/apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift new file mode 100644 index 00000000..6df6a173 --- /dev/null +++ b/apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift @@ -0,0 +1,62 @@ +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)) + } + + let root = UIHostingController(rootView: AutoFillView().environmentObject(store)) + 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/README.md b/apps/ios/README.md index c181ee6b..5bdafabf 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -46,7 +46,7 @@ amorce un compte de test, puis nettoie tout. En cas d'échec, les rapports reste | 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. | +| `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 @@ -71,6 +71,10 @@ passe faux, et le fait qu'aucune session n'autorise l'activation. | `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, dérivée de `assets/logo/ghostpass.svg` | | `Ghostpass/Services/VaultStore.swift` | État de l'app, passage de frontière chiffré/clair | | `Ghostpass/Views/` | SwiftUI : déverrouillage, liste, détail, édition | @@ -86,9 +90,31 @@ En revanche **écrire suppose le serveur** : il n'y a pas de file d'attente hors 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 -- **Extension AutoFill** : cible séparée, à ajouter une fois l'app validée sur appareil. - **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. diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 7b3910d1..6e326b46 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -152,13 +152,11 @@ final class ContractTests: XCTestCase { /// hérite de l'état laissé par le précédent. private func viderLeTrousseau() { for clef in [ - Keychain.Key.serverURL, Keychain.Key.email, Keychain.Key.token, - Keychain.Key.kdfParams, Keychain.Key.encryptedUserKey, - Keychain.Key.encryptedPrivateKey, Keychain.Key.masterPassword, - Keychain.Key.biometricsEnabled, + Keychain.Key.token, Keychain.Key.masterPassword, Keychain.Key.biometricsEnabled, ] { Keychain.remove(clef) } + SharedStore.clear() } // ─── Déverrouillage biométrique ─── @@ -177,12 +175,14 @@ final class ContractTests: XCTestCase { let champs = try XCTUnwrap(blob as? [String: Any]) // Une session telle que l'app en dépose une après une connexion réussie. - Keychain.set(mail, for: Keychain.Key.email) - Keychain.set( - String(decoding: try JSONSerialization.data(withJSONObject: champs["kdf_params"]!), as: UTF8.self), - for: Keychain.Key.kdfParams) - Keychain.set(try XCTUnwrap(champs["encrypted_user_key"] as? String), for: Keychain.Key.encryptedUserKey) - Keychain.set(try XCTUnwrap(champs["encrypted_private_key"] as? String), for: Keychain.Key.encryptedPrivateKey) + 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") @@ -200,6 +200,33 @@ final class ContractTests: XCTestCase { } } +/// 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 = [ @@ -345,3 +372,124 @@ final class GeneratorAndTotpTests: XCTestCase { 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) + } +} diff --git a/apps/ios/UITests/AutoFillSafariTests.swift b/apps/ios/UITests/AutoFillSafariTests.swift new file mode 100644 index 00000000..2c3d15cc --- /dev/null +++ b/apps/ios/UITests/AutoFillSafariTests.swift @@ -0,0 +1,92 @@ +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) + + // Un tap par coordonnée : dans une vue web, le champ existe dans l'arbre + // d'accessibilité sans toujours être atteignable par un tap ordinaire. + safari.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.23)).tap() + sleep(4) + try XCTSkipUnless(safari.keyboards.count > 0, "le clavier ne s'est pas ouvert sur la page") + + let motsDePasse = safari.buttons["Mots de passe"].firstMatch + try XCTSkipUnless( + motsDePasse.waitForExistence(timeout: 20), + "le clavier ne propose pas « Mots de passe » sur cette version d'iOS") + 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/project.yml b/apps/ios/project.yml index 7fd1191a..9e115b7f 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -29,9 +29,12 @@ targets: # 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 dérivée du logo du produit (assets/logo/ghostpass.svg). ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon @@ -43,6 +46,30 @@ targets: 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: 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: diff --git a/crates/ghostpass-crypto-ffi/examples/seed-vault.rs b/crates/ghostpass-crypto-ffi/examples/seed-vault.rs index 556540f1..effeb149 100644 --- a/crates/ghostpass-crypto-ffi/examples/seed-vault.rs +++ b/crates/ghostpass-crypto-ffi/examples/seed-vault.rs @@ -60,6 +60,13 @@ fn main() { r#"{"kind":"Login","data":{"username":"clara","password":"hunter2","uris":["https://github.com"],"totp":null,"password_history":[]}}"#, ); + // Un identifiant pointant sur la page de test locale : c'est lui qui doit remonter + // en tête quand le remplissage est demandé depuis cette page. + let local = vault_item( + "Site local", + r#"{"kind":"Login","data":{"username":"clara","password":"local-s3cret","uris":["http://127.0.0.1:8099"],"totp":null,"password_history":[]}}"#, + ); + let out = serde_json::json!({ "registration": { "email": email, @@ -70,7 +77,7 @@ fn main() { "encryptedPrivateKey": blob["encrypted_private_key"], "publicKey": account.public_key(), }, - "items": [encrypted(&account, folders), encrypted(&account, demo)], + "items": [encrypted(&account, folders), encrypted(&account, demo), encrypted(&account, local)], }); println!("{out}"); } diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh index 1d47f036..90ba7fa3 100755 --- a/tools/ios/run-ios-tests.sh +++ b/tools/ios/run-ios-tests.sh @@ -36,11 +36,13 @@ RESULTS="$ROOT/apps/ios/TestResults" DERIVED="$ROOT/apps/ios/.build" SERVER_PID="" BIO_PID="" +PAGE_PID="" DEVICE="" cleanup() { local code=$? [[ -n "$BIO_PID" ]] && kill "$BIO_PID" 2>/dev/null || true + [[ -n "$PAGE_PID" ]] && kill "$PAGE_PID" 2>/dev/null || true [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true # `npm start` lance tsx dans un processus fils : tuer le sous-shell le laisserait # orphelin, à écouter le port, et le run suivant se heurterait à son compte déjà créé. @@ -232,4 +234,22 @@ if ! run_tests GhostpassUITests/VaultFlowTests/test03HorsLigne; then exit 1 fi +# ── Remplissage automatique ─────────────────────────────────────────────────── +# L'extension n'est joignable que depuis un vrai champ de saisie : on sert une page de +# connexion, on l'active auprès du système, et Safari fait le reste. Le coffre déposé par +# le parcours précédent contient un identifiant pour cette page. +say "Remplissage automatique dans Safari" +(cd "$ROOT/tools/ios/testpage" && python3 -m http.server 8099 --bind 127.0.0.1 >/dev/null 2>&1) & +PAGE_PID=$! + +if xcrun simctl spawn "$DEVICE" pluginkit -e use -i ch.stackops.ghostpass.autofill 2>/dev/null; then + if ! run_tests GhostpassUITests/AutoFillSafariTests/test04Remplissage; then + kill "$PAGE_PID" 2>/dev/null || true + exit 1 + fi +else + say "Extension non activable sur ce simulateur : remplissage non vérifié" +fi +kill "$PAGE_PID" 2>/dev/null || true + say "Tous les tests iOS sont passés" diff --git a/tools/ios/testpage/index.html b/tools/ios/testpage/index.html new file mode 100644 index 00000000..3a1b8332 --- /dev/null +++ b/tools/ios/testpage/index.html @@ -0,0 +1,25 @@ + + + + + + Connexion — page de test + + + +

Connexion

+
+ + + +
+ + From c69ebca4cd0fa423a76e375ca9535c121b13550c Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 09:43:16 +0200 Subject: [PATCH 07/50] =?UTF-8?q?fix(ios):=20l'ic=C3=B4ne=20reprend=20la?= =?UTF-8?q?=20marque=20de=20GhostPass,=20pas=20celle=20de=20ghost-suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assets/logo/ghostpass.svg` porte un nom trompeur : c'est le fantôme de **ghost-suite**, ajouté par le commit « feat(brand): add ghost-suite logo », et non la marque du produit. GhostPass, lui, se présente partout avec le cadenas de `logoMark()` sur le dégradé d'accent — c'est ce que montrent les captures de `docs/screenshots/` et ce que porte la barre de la web app. L'icône reprend donc ce cadenas, aux mêmes couleurs que le thème clair (`--accent` #2E5CC5 → `--accent-2` #244A9E, dégradé à 160° transposé en coordonnées SVG). Le glyphe est repris tel quel de `App.svelte` : même tracé, même épaisseur relative. Vérifiée sur l'écran d'accueil du simulateur. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../AppIcon.appiconset/AppIcon-1024.png | Bin 39261 -> 47475 bytes apps/ios/README.md | 8 +++++++- apps/ios/project.yml | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index d6c9123437838a0c90c644f84244a8f490fa77f4..9d72f63d727f7005d73c9343704c62bd2ebf1e4a 100644 GIT binary patch literal 47475 zcmaf5cRZEt|35}X5>iRVN!mk6LdKDVM0Ofhv*Yz3i&$!;7%UAojD(CX`%TXv4=TS9fT@;EL{+AiW z#t8q6Vhft!9~Sdts>&!D@?T70;$swQ1L~;qA$^C4p-B5s<7T^QV&t>1K$XI0Cftl3 z4~xx;-AnvA#nus@G8;Yq;vZR_iD{7*%j}+x4=t|=>DuH!vH5^6Z`Ccq){k@qy$cBCzF=vDn`f)iFNYTZ!@`wZfcErI+wO49zyMd~*#ArY^K zqfMz|#WH-c_teBwi=G-}MEjB}QtpMJb3)lY7%v7n)x>{Z6M93Oqkm+_9LkGZ=_${s zq%S@o({0NSig1Vd7S^m7>c;!awRCG0KYm4VE2(We=c0LA8_N))tH{1RmgubLS0jqG zE=A8um9V&TD=pvH&9SNwo!}L;Iz?Zrmb~FYJke7xk0@ovq{t>R`*3U-dnu`Vx9Ba3 z#%OgrlaYoKMtZCO#~^@GVyn65o^f>2&2LKL5P^u|YaH>xJw9WorY<-}EJfa}i@e)w zjw2_CQC*F_u${?VLwMFxuOtx5dkw`V5--26Xv0U+S@@eOS$_V*g$p0{t)98f+({XG z!jtc|#i575Ahx9tEqDmu?#_u4@!Bua@g{6()8B-~_)h6Nq%3|{<>6TAidZV6*mL() zx=IA1`?|-f)DAVdajV<#>vHLRCZRtmdpE~>iBg5RB|ec#bUG-UUqD#bHg@0P|JQb( zsk)vUb;9mL{`ic0^qU5u>LiAD(v8bnT&9c5e~JgY z2l>;gzwY#;^f(u{FKi`V={E@q`_DKl)~XR$t8NqX-IEbPb%}Q0nKzeTmgGOz;|0Im zw1p9|1w;sV#Ku&~C9y=lJIG%cK|CT&rp3=!Yb&XPZ*g0USEB_v;uOR;W`bUVyg>|3 z7!CIAojxD~zezXiT@JYWVTfoqAr>Z5fWhxGZ>eVvAj=n5fK!#}L}wvR#qQ&d{IYT` zEQScWuHnI2|DZfC1C8klA{ram)1kY3o5*I6f{(ak510QP9W+lMvJV^4E`t?i^WHdV zT@L=F0^Yc`pxsx!0)GBz4ga3Abo(q3#exLiqo)Gtc(KlZPM-yIqvph1aPK2Y{Bu1s zIHQq<|BUBhLWVG+?>+lWDmMJFTbm|UWVZ+DyM3)xnYzx4San8aXzu6l8*U6nHXP6X z=a<2_t(#QE5Y=)1<&%ae#8CVU#s6kTTxy5`h93Ek4>BD?wtBVNfjy3Ho^*eyu^(gg zj#IdImPye^=r12sMNnh3+F_07pZ=+Xw%+A8hf-00hB%D=xq^F#7X0vcE0S%}w~z@8 zgjbmT`B`vN_^R6es&k;$6^teoKmK7!q96Bdfvvt4?KW7UL|JB}&H$hTh9A$Wj}KJ#!Gr_gb7-4uggkg^BgkM8Jqu=_AXJfl>H_ddoL zc|cP4&_Fzw|1yGoQgY?0VmcTpuij@;I=@FcWFTPVKer(e+myHlbj@?y-BA3WhOOpB zq_V&tlh@Lh_`7Y(3@{5WY8vMXyylIV?per2 z1*Z;ejH1JnnjoUYBy4j?*1$qodMrxqXdne9&F>)|(eixIp$8=YId_Y*O{{x$SBa~T z3juNCSNsn%;OV3XidEcyDg&@;rK;=0B%66bu-~TJcE+6UiNb}rjEKq!vuaf#J#yaL z&~4y{=h9mMmIYUH4}A1ca-eZJ*h7!ve+y#eM^&mOEOe9nU$qfiL)Xk2rPk$sm>D=EI|rU%Qx1TDdN{WKjy5f2&-vy*qKhcxO2sVhKaNy32K~(hI()FF0qpHy zZ%Tg$qwbg1J?N|x2T;m9jEEc||C$H<1tq+M5bxIpOXv~pzPvn^YGC><1>(>W#64ufY)NRgiqu|rB-0J{7Aw|l+>aisGt^8*s7}1K#W=ZCNSb2_zr5- zzw`j9RBXa}4pnj>8~0;Q6k;X+V!)}R(7_-aH@0A+v(b1|1}UZ}tTh<6FjUQKy}5~tT7WCT+*uMy936Ha{mUsD(}}XVcFQV%$wfboD*BRv+sBU-MaQP)1}R%FxN%F+ zBNU#Xt@q##tW5tWf}hogmi%O?M4Jal$>!529;8@vXJ&4lTdJ8}k#*3?AsSbVJvw~= zNekM)e$hOIJg0c)Z#Lrsn`BZ^zl{&@(w;(la4Nd|%W&p{!Y;uvRi)QR2EfgU2z)+M z4U3tZFQ&ZV1$RWTns3%Bdea|bV8~V|L|ZK6O0Q)HM6;4ct;@m2@PwT3N9ZU{MaO?F z=yD4wvNeyn)=zVa&pvC<2kqp|8!<+WVqDo9RILK3=?l8L;sjlREau=0gs zY^mUiC5oQytTxq4TZS4#0_Q!^MbDy}^(|Fb<+W=d6%|FW*0F5ZyzkbE zf0evT_I08FH)eVbRlxRT|59pq*}h5joKEM04sS>q$hjO`bT(Gl?XB4)w!UXCEf<+s72YQv({FIG^8396-~fw+Xz9Et?1yPWRs zn)5ZlFAMwl|`}K|Lh-O;9nMx#H;v~S2^ANgyM*0P|?!M$7-Z9 z07%u8Tno}aM9{h)$f8*3zgQjos?wJX%N-xhKf|I}obX$Hl^>@7tr#wV69~!v`N>_K zjXLT)Nu2IJLS?@zaK0R{?%{%R0P&xK_Tba3Cz(J5Kj>`=dU^uM2N>sq2R4vAJwBqU zzli@gJfR9Pa^D0{4B;w&)YL2gSdAu+x&Z3`)miG>mBzmW^hhaqz&8xNSn>W6QGoY7 z5O;9tV(^+zihf6KR3VPhOA5s(wsy(&=-K<2D&%akBMJAswY+^cR*Jr2cz7f8&uWxj zd#h3*MUDZV0dI&nxQeG4Rh^^zXHR7b4sPT3u(89ccP{&P!-1Z}AsFR^Q%<>#Vm8{f zWZOtR4(@@3vsTSDT+u6ej`YMl0_|7)yNk92Pw=$OK(%TuJQsZXKdLBb{6Oq^>eDd4 z>|bdcy;^1kn<`Tz7#>XqyS{&9NoT|UGl0=|yzl|cNDLg<^UP=8wz&V0P@KZ=5>nr2 z#kn~luOJDOUq>wfp%x&?e4F%79Wp@0qI?IrMNb6{3BwsJNa^(}`h-`J>MWkowEe#{+vQ zoHmjuq$gIy`;R`{AC|6CoTN1ht>#405s0q8+HqCac$0m} zcFXQU`7&CKW&x{Ef|l>cy#KW_ojwZZqxC4n8hnN5bBm33FlQvQM+y^Icp4Ze+g!MNl_DX8^^a0+(cEJkv7Y#UNS({w)aE;@qCglF zqczY04G#FA^Z;*^5~pG_(fD7ziA7BRLD~E$5pe(iH*Q5hv$qRh`&Y9DnkDo&i&@23 zfT`TK@s@-EleXP+q$->5-2PrXJ_grUb=?tTy!gnk#BLrFQZOqfsXLBdLNV*_i22Q& z778X10QcSji)NC5-(^&a*xP~MdPj&^Soc5)&4@hJK@orQF*DpaeNG)I%l_HFxi$oc zP3-=2YVoOgP6o!?|FR6Xk6yEy#rz*_Auhtvz~7YBKyod=hMF|m^`{{t-J9Rn!SMA1 z8>f59&I)?ZeyN31S84f)6xfjzp#JVEO8@VRXbbv?`jLB*eo_F~13gHyC1YtIaqzxm zXt2%v<>g4Y`%@olZ@n5(+K);}U;VouA=#Kf{!zdsK*R{2uzUtk8U1*1p^dp{vw>

zkaJFmn)B@}dN2dbouuv!63{bYDqien{@WrmUxo+nQ@?`x@BLrVV4%N$;phh08*?)~ zU^SP&NQZERobLW=X3^omtjQaXzI)F?1wi%BCg$&cx%QTxdZiC1>OZZZ`rauwCdI$W z-K!z=xg^94IIQ}M36^b;zrfCk`gh*~Oo$$2b`2#w_3K;@nfUxM<5JV&Cq)g3qVnQ{ zb;~Y6b^~XWV18&YoB7B;uXeAL(71aazC)I=(8T^D1recdpT$7@@x2dUP^;gi4w&~c z|JKO;hYSoV-&Q18R-734gj(PN{HBaw4U2hWRMM)w89evf!j~`#Is+B8y4UWrxc{du z!UG0FIV|o23n$y~&Ztm{#N#LT=3CYIMe*xx(Fkvqe}i*AjU~RWX%nTjebq^AZtd|k zuFD-s_b-Y%-rrgr7+YddR+md*4QP&(z4fAq*wKM!@^qf&8j+#WqFbGBk{+wR9a;C~ z4e?Shzs_QCC~I4-B5pxgKh>VuVz`WMN=Tlu%DgTN|H?*(_O61$(Kop}v`rR!qPrf%(w8erkH*n#h}04i`YWxtk4ZpS-kNZjgm#!L9<@&3+?O4`9<3m$JFtxxWS3&j zBL5;I&9RO1##+N=boX1xk>!GKm|OKHAKQ{~Msj$_pt0_>q~r(oLgtl1!g;hnN_IQ0 z-=9Rp7rE6Jd3x?vXed2#&6=$#CnYSJyQu#Dr<*sgF?Yx*q6RWa;R^@1+K=RQQ{}7f4%~XHJ5`pbqAs*q6p70g zXQd3z?{buG|Bgz^J2~q?Km3JaQ`xnziJdP!(y`{$tEd}&wNzh6?4kT?EdDFna?cW` zvI#TP-QUsejyVH1NHYwAhkmwns3(6tUllU#IvRC^>8@G(Pe_yJ0*QH8gXC0xdu{M%#I621gnNi5Tag?>F3;s z301KfiQSHTwXR=Zt$krE)0{(;(#gn|l^U82KwqP@hrbn4*r#tNbNP3RJOZ`H+djJ% zR>uurf8>hjnlH4NAOuW3<~#26v0`f>`71}l^q|Am=oIO_)|S`D4qWxFKEcWziSi=4y40kArej;)q!&Y5(KpdN z-Q+;;^^B9$+E*9GSY6(m<(S~8(~+h*x4K=!r$gTUl1M@*NF)Vfqm;5b7~3xwbQ~`| zYUMgR-XA3uGJy6FAh?ZdQO_U#alm~5sn6IA#4F&iFE$fm^5J+d1ENhcb+uYvhLl~Hpqlz z?1I?o(wG8ih(LGT>G|@KU;V)+Px!S=qB^yj7lqU#$1oHAlK1#hc?YPq)?|b^ktz`T_6lRI?&yCJe*pS9jSmlVU^tcFzaicMTaZ)22qGesF|qI zjp7F6Mru*Wi<0hLu7^GcnIAR9m%1R7PnAPMFAAr6-(#rZsfii`VsAFA^4R`3&(x-d zjE|~2LuiYf_GXNi6NyJEY^1bk)jYdyn##hn9u;waYV}gt-LL zH0m4H6)kEIYsSF;!}lnGsBJH@9+h*yV!pAd>_$L$wl+7?Mh;)xBkya0xf?2<)_;Vr z-V}G47WbXy*`B(s7X|`+bnZ+pI~TJRC{glFsx-q97SxFP*Wc|#S%+X>*L-CJfMD7; zJ3Z>l_caOIo_T|I_)Kno>d=<&m-Xr^DYb>FjxG_RHkxL7N&r>9vtr467&FE!FmLix zyLqjQU7I=hCfeL;byJ(`OeJh*Zy#-Ge`~RA_>5OyaVc-3@1h$y;k=m%Ru8E^I7XoT z2ZSwAo{nl#eT*6Fkp_GPGU#?$I&rH!Se*k^EzwXuL0 zVBJ@vM{BRDH!d>MjU8YUyx*@$9w6Pw zgGsWNMnZw9iLnPiAFqUzS^SNj_S|Varu5F$i*93Fbx|^QN>QKK4mNJJr+r|JzE$!x zj32`_(R)E~(rul7#ja7wLwdF3bF$-B&px7xp`64>ClsJ}H%l*f#2Hc|Z9^jD2PX(7 z-@iA8S*$VlDvGMxQ$TppFXw7`FFqS zE8V~f82ecALe+U;Z+S%-VgEbNuk7g`01?loFoS8f?zUqS4aveT@Utvxl z5n>kgD9e>M-=1rQzq&~KXlmJd#%}kGQMr;I6esO9kMV@q)eyAaLO}%tIn|8=fmlyI zQ=3oT?KW3fvCVun$J6@6Or)IqqeO`cuQLWtzdMYyrS9%|RSG58wic4WrK?bF0VjwY zm{dtp%^50?iFP9K%aOL`CO5ZA)JIXOyS;@SCKa3Cc-^4~C^&WC3M{}Knrmk^lBDw~ z=||NmlT@B4*Sewl0$a}E=+*IYCrCZtTLcff45?-hC*?iqf+`Xg3^WAuR2o}X`&!Y? z-TsWY509JdA3QpZ$gdpacUf@OUaa?wV?xhuNeKm#?9~}P+$J{T$Tt|*mc08dA5!uj zZghwW6*swg%#>+jXO;9Xdx7TvQL|*aAocJ8Pp$~wDZs?c$@bUDISxpIwKVRY3VyIH z9{g7e|7OH*jb5tQrkznO(Q13N0-ZPpmJqYI zApEjI8Z!ZXwj`qHD!#0J`bcSGOXI8|LDuT0QgDG7OJVGQ2okkGJc4cZAX_cKF&Wmy-B; zoO2uV4W^R|@CwHMm}G zJKm}Mz@D7Zc&UO*r|bqv(V=4n(W=xVA9-JCKUd$kR_Ot}ry2En(oIyAI{JFlE$Ii8 z$lBc$(Fkm%pMAj<#~}mX>uyB5oqLP~W*;1Cbi(>|J2+u=K+gjdb@RS{WfkMg$1G8+ zZnXyy->2@}+ih;%$Kto&pRxUOS}9326q8DPg)1|iIbzgEYOyN|R ztkg5y6Rm5vrd%v>cjS0csum|Di3+%6%C%@I9S&H}^u1hLR6^7}%}J`U2TSq`>rx_Se`lD1i?U+VdW@?%;1HmCLxDBT=yevf^|72;II z_aQRJviY^GR=8W|w=mm_(`$_lG4_^UY^PK$h1b8EjNn1KtATR|SFMu=@{({zLHnDa z>0O+P;}tP;We;{$7{+$9f9 z%=xjN`=39!e3r5O9Jx=kGNUTx?j9pj2Orhd;vN@J79ouVXn?VWbwE7)xgG`M1q*D0 zmk==;k4H{(q1~Q-AW{kjJqnR3*iP#D_vxO_y#ecPHuirQDkR^|4KdwIsC}Gx*Trb_ z3eM&XYV`-$O=DL(bTN|d!K?(A^+5F8%cASh{z5qA$XSHP`Z22`SkwL%1?Lj-skax7 zgUQTI2?&#*zIC0+^gJt^*&}3HWcF#N+xgDm`UuR2FS4&KzaALPqD?d&_V%u%NUkXX8a>fsndM|1pJylo}F!zue2hkQ|nE~TNCRu_vT0%WtdKN zMrR8%+xW*RJZ<2Gj;rtw3VYM%f)Q1@an*&qxYm)z)@Ce3qb5%n?uyVqD)}*~rH#{m zD5o!#E4}?Sro3#KgCBQOr0veN)41W_ozPeic3~wJ_fayOXb(KAp5iVaCbU4Vi*(@= zshukaW&5MX=5BL+WJwM=1CG0pKWq5SWc$v34%venBIk-9^R0((IMheex}uAws_W!c zIpiM(38D3>MqE{KwvBHGt=eyVdQk+m_6Y+$<_pJ%`Q;9{e0R-Pr2@A@KIxqrfe7tP zy)zTv&>!jce|EAOyMHI&6m}}0USRh6n}>3vKRoJPeo~(?VRUb`npQ!gMTt&5k#_P6 zI`*l$^g(HI7LhvHndaekDW4O{7<#Ua>ZhcaN53DA$nP82z_tH&(@wEB=?@VakQ0ra z+GAgDw4ZOH)}g(mq~6W3OqDu~|Dd&cM$%;C0r|r`0hY2ihl$xkU+r(UZEtkmf||CA zjC^x`fvdl zXg$bSh*^mNn7nCk1htUfme|5b@^qUj zo)&^5zrQLaEVynQdy`l9mYkxH_ew5T+LK|Tm4bEM<;Ja3c5ASTIuV)X1BN)_k5OwM z&bT>NMFSPES0^lL9IE_1*-d$jka59b`mjUe{SXDB-0Nrx1)5*&)5+fn@8=Xi6>=^@ zTvqZkZ_t*fpjq0xG0AEfgcAjanJ+@bZ0BRzT)*i;tTIV!XO<*tV@3`n;=m*Z0*2d# ziQ7p#?qy6Dq(Ms0f9(Uw;JyY>(nqxZ@{; zI3XO{R0_%vS}bVihmmw^9gax+(J|^w>*B+bOu4eZ>+xu@ozkjxYNLE;5uT%9@+Yc4 z4iO%03g9zx_!MgScJlu2t5U}fYtNT{+=&dRMXlM)|70t^HyXi|=T z&p7=tmNsNxPc6xI>3U0>{mENH+tn*0^2|rM2+%^xZRm3w$^V40n$E#1Z`z$^mpfrM zEM`ZEj!mla!z!-jA4M{@*%Dn-aqg(SpQOhLf`wSQ)DSJlX&KvhCeN?Em3N$Jp^o-_ z|4gu?vToP1-EQ}~Kb>{jh!Pl{je+K^(mk86juY?5t{0cT&Dwi#TE;483q^fdX%I2)`Fd3mLQ4WQ(1En`$yE%cJEC`&+n-RhM3K05vCeLhQ5yM&FW7K|IzE%_ALGGHs@= z3^!Ku9rmwWYpbNpQ}Olf;Apsl+o11TbrGRA-wX_Q?H)zxS|&fzS@SB1xYIW*nwS63 zr!9o=&(>oCC_SQU;RjPi6x1-wiqeh|>P!8$^O`sWb`h!V)Sf^om!T(Bw^pz@9M{K% zmmsem#5hfK%dVlx6(18b-fMfszx~GDx4}n~@+>&!ZQUBMrNHz?&LvLwxt+oO@uBXy zn!^{Swe%c&Oyx5QU39dvy<*GdhVb^2570Qf_iwKRB$Y&m1`y=P;n4+lU_mJyHAl7V zt5WxziI^)Zv7eB3+J_`mj<_;7p64lQy=|2I_Vx695?;-(mq@x%mS-#`i)8wqwsok!@Mjj(C1Z}zl@}~Rw36pIrl;4L!4pT)N|Hj(Ix7y24A@QfIaYDB4#$`oYF5L@@ zzCbWU=R^`yxmxaObE2rhHg%h;pr3L@>J|3sJ5lV%7bXhlr?R#i7zTPHC`iCjtzR^2 z$cvw;6r9b*QNL9ZsB$*#Ywk*%(2&3Wb+tu)GQ)g6qf*!|BlRfLfyt~_eX}!0|0dX3 zk6&^l$u5X=?_#PPwSDqS2^YCy&wJeF{nyVkki}JLwuK`KGluq<*HemK?`}`nL`u1= zoos#mn?%=av@q|pL}?hmxPfH|7NGNbV<)F>7Il<5!WzRMS;LRz%5tp`+-b{m{!G~Y z*LVz7bmdAx=ay)^<4m=-V?}WJ8a~J{x4qQWFQl%Xa@#CE(dg2X5!K_$#NP6xrw3YI z{}k<=et8QMtf^tM!MVOh+G}cO+UCOHfUdS$rQ-zj2c`hwnV}E@*O?Q{TeJp>hFn}V zt*+{~W&Tsb{c*;XL7eW%JA-fbaNS96tEFDv5kOuuF@EtwMxIx#3!(2iZOk(*7Tw&$ z+n7P!KKssIiZ=RrxZ?f2+V|M=XPY-OAF3BR28X)+6n{Ly_vdxg3zO}|;mjZf8H<5Q zH_6_&<|RW#kTn_@A35-rpfDbY%av;F_PF*u;59yaN~vjZq>M%kktGUl*EMp9x;5bE zDB+x%zER4_^4mNhYa{ruJn)+M8A()v*ykLa`#b3803%;r3}Dz%lv$ zQtaC-$>$EVOHr-%le{!*QxDc8_tPJ?M=z<9hcit%syo4U%@LeWH3{wspUeraGtPyj(l+wRb{Wo z5+Z!SXDC?9g5B1s#sUjixU@Q0{ouHFX_Msz+WufwYUr+r#>By48kgX~>7Q7mXyPkP zd2xy=wPZGK4?dq@_K;AkZ2qC9ac%8LYTmu+AGsFuWzR>_{mP-ePR~(Rs}^mwIVe1! zA9MKY7%iGQ$n@R*Vx**0X7JBKTmeU>1u+n68?(z?=%~{ljNLqHw{MV_x*n?@d~VnB zBw~v^_KsRdXsjFWOAUHZ3v}4g*PC)7200MLVX?=a)W3WgoNhxv7gpj-vQM>SusGwF zJxg%^n^9(soUCRsYpp1CDPmGtuj*kn_=!{@zNN1qtT0h5(&+l3FfhOo^m_GIs%z~f zCNSP^pRJ!LXI@_YvR9&9`3 zZRytAQL;}UYp5d$72+mCQJjib3Ep`}fiM;*k#L+~sAp;N?RO?4_KMXa7vM!?b2ERx{n;cZ zN@pO>yH6B5wTU30mh)EsR@Iejs7m8qwf(a64nt)lsUlFM?`z_5Li87dTZayd&TD*GZZ>YQG;Wg9H7mTHB{W&Zg!eR`ayb)V1Rl*keS%J&7GxP>(Fx zie<~2T#<%XxZD{>onYJguQQ!#vd}z4MTcslb?nv%f42H&UnV45x<+4&FMaprJNC9XN`YdT4z;(79?kU1Yh28Eo!> zl{y{Y^CsPHFyD8C2D$9PE^?EFPSIqVYKmCwCb@k6pHX6t5B6Z4^?U<_{p;$!@$a;u zt#eOliDoqA-4DH#R}gSo>qyPs6C(0)5iA>i-hJpcWm8Nl8cs}>8?V|z7IOZaqKoYj zUkB_6HvmQhL!PxX%gPDrsu0I39)p%H=0s{-?{40Que3gl#WzgM9bSONj13oh5eXVv zPkn-MUEc_CJ15*zn+e?$D!Vz0vBtP5sLGoSm_^cvAjg?lC^ZDyZ zI}eVd>uLBI2gm2k=cdLJ67NQ}pywm^q?=dhc_~y{A-Z!w5(Nj0+v!fMUiYP|Q|r|J z-0~w#aDZ%T|M(f2ra4Fv_wTNN_8sKt-8)QNj-QenZ_=Kcd;Gg6Gp>78u;9tQUtk*c zMboGNYNP;LKfRR;EO9*)c~M{NX85>9*iUL7jmSEe^|S>Ie8Wo$x@+d@S+);m>gA#@4O4kFmSlY+8z+qr%zt89XugFi z&G<8zSDcxNJjAcn<8I=QnuKcwZVEmt#Fdohx{9Q14>>c^W^BhE^%al}Fs4o?It$L2;X}E{1q$e3W#HlRr-hDVy!+#hhQXHzd7`1?sqKf zhS_`;>j%8_Qc~XzPw*Q~$nrMbn=eK7WH}EfG-tB8%8eVbRVrbRwmUz#2bsK_n{~hN zPYerp{^2_MONG8U;*M$D=1t#y!s6|RwiE>CwC4c{hVW{=Gn!i&$J!ZkY!BDknmb|j zN1PPrq!>23vc^F8lgU4|1?uN`O**cL+gBZ+8J~H&-u+se=V^rGTulm2!t`+-Tv-7c zs{|^6d}MFBPAEwjZ%W;`C^$BA(4|30$64SiP1Zaw)DnY3wk1$ID-yTU;bglVS>loK zTF&`oqI^bR+fFII%@_Tg*?ZA6+5Q99#4_lT6Id0O$*mq1F5ow7J9mqKSwGG*Vdc}B zwn80R&aWRoWZr6Y=0#;~aDJQ`F@|~mZ36}E{NFXxxT33D!#-hA#lc-ZRM`WS^2T%P zmq&#P2@Uo6%Y`!YO`g-R2S0_ih35?7RZFEMrgX%n^JI0?J z9tx$&yIwtO>y9rLvW!&bm;E*q1(CmV@8e z@$f+*A7ezDKKwVGZE0J~JVNsb5B%oLEP!5a$1MGAS6JE| ze_EseTo8w<>o=h4S?o>2SU`t&J{Pvyb3rCokGgShO>z5jQK&MiW>mFLSeiUkb@gvF zDu3-Z#;F+9eU|p7FDXC}7;yS_?85sdYXqmpw9yk7)TT`(u$xrzV|8iS3@@Dy$I~=4 z4b@=E51Gt15Ynr_vUZF`hOI}{_a+cIl&SV8vK5m$9Mo&g>}lS#C<>d}Z675MpzacU z_eUrE9KQt2`~;v{`&wBG`xrYn7Eqoc)N{X}{2jZr625iqxcqf%BS{{im9Cetv^>J; z-Yl@OH*tHu95~prLhOpibMd%uLtimp=Fl<&o{AmWU-o!Vi!wPCV>ubxHTwl)h_e23 z#d?5&52>5Kj&KH!HwGCCpYQFvGZ0-VvwMICewCMrJ~L-k#r`Vr9iAx;$FyOdg~}^V6udN{Da@nsW2D+uaP`|& z$)Nl1LQ|O!KN$z83S=fVnB)nTf}W!WG>L$~JFx-Fd};LL5!0}rl(VB%a!wjfOb@$M&tDtf za{M$LXI+8QSp!b@84g~UkcUx(Jzivjt` z$R1!_K?h?qU&6LU)-`tB!GrNwRx=0b&l3RyXm>e&@4L@U!|M7HeJ5`-Ap=!cG_L2y zNus`qX&hgq9oznBu@TL$3Ec}FhVCRTxdAYOubl4RI39BXB}5;)YBlr>{mO41cw|$K zW=R#F>IWQXz+NZQyUrN)<foHYML2ZhcoS+m{rDjx&QTVNY38fuwt$M}NI0mSI z&1H_OH!m7lJoCFkyvnUcSjmEJLh%G8IEXiv4oQUjdtk6bnNQ{ICp8UG_$;VK$0h_W z5K*E4AkDAiD~vMd+8c#VZ0nRv5uF;fZqUK0J?|CEv~*m95=8d zk{AHZj+e^oD8MshaS<_QWj2hH`9SjPjqqhe>SvzL1#G}UFctkp)m~PKVLBnQ&VQ!N zOmd4loVB+rUPCDH$V|S2xsuT`A8t2&xGc%pU3R=5x!7eaW1R=5`ywFFr!u8wzQyiX zXl}yHBw{#+Hpy_h!wBY%5#LgGodt%_S>$uOAyw4J164c_ zP9n|TkvetW82&;p<(jl6l7N0QYF;y|R8(Ad#aziic<26P`d9|}IWLZ3X?g>agoVHV zGE}y}XObv5Bsts4s{lKq)7PDWcg02bE9Dwa+n#piuLzw2j1(Ef(H~v|jKwA)T?An! z-O_@Z7C(>N#nNo@k)+48Dipa{q})^o6KXX^>#fzxI=Kdcyg3_16$gY><9Pk&|w3a?M%w^mmAM!nJ1S)BMf1!EDkKF zq~H7rf1h-_3IY)X$O{ydxXNU<=%xrkwR^YcgOvMf1SvM;IwR|zfK@e~TQ9t0DMtL1 zh)nUp7-?+(L7VdS4fOl5XgQd(j}BOnAa`FJTu4b54Irl-f}50pz5F&5uJf8VOkmJf zRR@a!JvI<;#feN}fR)%(UIWXYzxV*82Ll8J=Q}>y3jL(8bOyprSpH<4jgHj<6EppG zYaCZYx#FOe=XQv-i(+Aq1h9aE99qcGbHup>~EvSRPq7T{&>O(B?

8 zItl+5l@s9`B54d_G_Z`W0-VQd7I!JH+um9af)1*y(1}v{+gHq&hID#m5|{cWE}KG} zM+5+$Jc4L(3J#ap-Y@yhbzqe!R{jV{9ET@3JCmr25KoREz#z(KXnZt|V zR=6njJl2F?`6_DD9WfsI#PvAXLNX%^av_k3K#B0I>P*4-wtR+%AvvoNvYMW1*ut7T zd8X`VW=EO)i(#t58q|K2)4iiyWRVpPlE$&h;;k4soYe@bFpehRbse>T)5>3iP@?(C ztndh|Pi4BHlrEfVeESA{rkEW!-F7XKn&X#4!USVaUV-wjJ1Fzk{8X%7@6Fn4sQsJ8 z5Hy5oPneBJW&^z;cz2Eo(mpR=QCfn{ge2|?q+<*)V5<0e27L=Tp^nQ`tgfo z-YwI&rlxKl9~zGM)ZW(8C^KHwakZvjbB_tn4M_#@qx-DfYHCW*wjI?Cv1L^&g@nJ^ zTaPw$S}=KguM|>vYfA_$Q@xud=Xa+KGS))`E7jpiLu_`(;Y*&dZTiq==(0Wy!l}%c z*v?j8;9bi(o~7NpLz(EGv}?L@NH~J~nZGM5E868I%`;%6J!`lj?)%E?ty#3rG}gO! z!%eJTxJe$W`M^WV!enf`_eAy+Y8yu+-=ei>?btG97is%#a-uHOv2K8WC!c0iLmN;0 zE+w`ds;u5p*YNi55X+M7mGN~Ctc<~6?r zhbiPe2(%}g&nZ9oB4h@EY;JF_GsiCCxNd!VM*C}GrRh+C1?6#e>#GTrz64@>V3r&v zmPzf*yTG{-(Vlb0!nqZkT4rdpAN#FmM0**JlG;WR7bkV} z$#qj4^<(%5UZ-X9PW8O~!ioF_2u}3rTbMP%0PEFdY%@oCjzb9$-D8(;^m-U{?W@}l zTn2V>u*J%8($~|VGA<2wwQNK^6-iVGd-&|=@!1J`x=z7vF5I+${3!9mcB-})x9+O!4D2|8Hdunj z&nZs^&%1&mA;9Yyggp992+{+OJ(}MqxO6;7t%ufMbsUK0YwNA{bS<~B5TblRHu?E3 z2Z(|t=7%WDns-1t0XfuhApqF=%ATWbI7&(|96^d-q_B(RAw6Tqvd>zS?mfgI%<0lH z+j)Ose)3(W-_JB7@2)a9EescY1frjjTS)=VjV99{E&j@Lg6hS6z3IvugJX#WGoOxV zjmYPnohVWaPzPwmn~Hxbf*3PD@VI4^@qM4xTu1nEoY{xkmt4o#M1rjc3-cAI_BaQ# z2`Xd2S$g;aKw917!n(A}$Iyhe!d>aT=ytK~kt44sMq0#6zm)uy*K{^wvyjl6?*!+4 z-Gr5|5NKisjA+rn+DlK-=%9oZ0Yc8~gncyS(=onV)?Lxuw~wf*4*yK7KS-QBg~`8K zRpMb$#MKW07_^?ev&EnV$=W)Sv~++OK0#2pIg+yZ@%uBkLG$NJhJXW+b*Ze+HT*Hw zaW7;YPPm&JrC=lxvPy;@Fh(%EW+!I)%teiX!5wxYN^Hpyz;50tW+DyTy$852GA`!A zi+1WJ38?=IV@pD1UlBP7^8N3Qt!+)&uVCMz8rN5!MM)*+~)p32`!kG z2#LzOIiQEVJG0~9^q}|ZL)*g&#Dk56HQyw}$Fmd%5e-d|aSu1_nnS|!_!Gcuuf$-< zR4upWl^K)SVh)A& zlqz(XWo>sj*#0w`TWS5xrYj*)5*Zhj`a#?}cDsjTh!8#RK}SXGsRSgQV8+%>TUX(T zc?MS4X7!?WE)g%k{-MSTx9silH?wnuf%!DQIme^^VafE0f)Ma-FeS=9eT1u5+hSFO;A1|FyfCup2iK-z?>oNFfRUz4)ceA(|)9XITtwjy@}E2xwe|N6psIj_|V z4MIIR`Cx~oiqp($xDM(;2WQ}%sJXs~tB1NnRjMIO;_BJ0@#4N+^Ww5RZt9)hBY$$| z_CCtN^oMZyl{Q?GeW`6>Pb&&dE+@!DY9%wZUjcXP+I;bWc9^;0D( z4~bIiT2N}aR`a7$?WT8bFG~T&FxsVYpswz8^BpjceZVv1DsZ-wP*9i zU8AE_0;uf*$i&!Kx!(XqZhVhA<#~vhbpLC9gAkCwjNI_+Fu>lVfgSH+iVZb1O)sbU zcoai3=HR*C^!2brPdstjo`%neI&CNJ#ch6vXk>?kJ8FO)+_`vG8*bk&rEKYl7`>}T z1Ihe$+8(oo2ptqP81 z^Oba{OJA(ib49T_1!pL%nr5Vziom%&;i^+(Wvwb%qx;r@Q#;8Ykt7Zph$K05+HFp-xuKA0H)*}FJpbwbwn8vPsF+a}+ zSyeN~pC-2bdT6{`kJ&z+#MYfl^~kC~{ylQ^!hGvBuxJr~R)8sZ_RW=?FiCwFzTCNZ zz;th0Ptxe0CUmc5SLggi>utEZ{Jas@blKj}eqdj;?CA66wauR^Ig6c-zp!l>@bb8= zqtjG#@AttrJk*F#cUcc3;18(UTF}8;AGuVsf9i~Vx{YcFIPqs%va zBcXJg_|};#J_m(lKAfi7%sH1=u;<>EhpM|0^}pcmL<_8e7b>H6tbC@j=t{4&hK8j| z(rp;AeAT-yD`Zti*Q=n^otNT6AEXsOm@Am2%RQ1xZPvZ;+&Fg{Hx$2myI=O_X-l(H z&vbVI&ppq5ET$rj-5VJzgsa|Y*Vg;yQpUDJA$K z^}fAQ9(T|BVr&IkV?5rpA0 zk0%@P$Mo5`i*)jpRF2F!5wt<`WB0cJo9dx~6z5te`dzlD=wXw65^(zIz+MMoK*ymU zzBK2aqz6zb;TWYW_d#XRCTH5`jS??cFBb-b`|CUwY(F{-JT>JAqD=nw_>XbJ5pCPC z@i?DtvcGO{J-}j;29&fKXs(-NuH;;&+GqR#a5-3`hxB%J&kVFDwm4Fhfzyv|fke0E zYdB^2=IPa4J;j^{PQ$t1cG!*%K@xTi2Ok%JS!ntkgT}Upj-&CC7iU^S)4-oGh1AZVmiQN~4UrmB+=_&1KDgGk$?YhQ>WP&Z1*=!OOXwx&clXe(W<= zyKwU9E=3a)IT>6niKoc!*WP^TQOUb@81V_C>*hal+{b%=S?tLlP`U(VUCi(eCcoX@ zDdRn$`hcM4!H_9zYwvlL6xsEVYT*d(6@x7@+Ns^#Xj zZ08OsnTBWrr}KgswS}V&714|0;(j}zvCpHY<)%31IUPHa?7Cv3`AEo`TJ4}iLZ*Us z)bPGkYUZio!8k7{HIH1Fb-(hpE3XWfc6J;0d&Pd}OGmI|d^Yx|47SS4Jl$A4dV`fV z#8HPsbF_+6^IKAb>3{t7+VO#tZ1W7}euw=2c(Vlzl{ccHGZw|NayIx!u_Sk#$&Vt( zy#n|%qld`*Rs=k-V`t}Me8l+*hs~ch?bGbnAIp8@E=uIolgAn^)%M+-{RvJ#Eo3>P z?j;hPDb+!tKR>#dW&?E`a2L1dyeg|nez|@W*gN^V~~Trc}ZJ53j$3`cK_y_z+W9 zSW=C+tUmnAQaU;@T_;DWCvV5y(|V8gUzmT^)q(z44hWUPGRV4CWW z+Nz+Em#Y<&Gnk|7xa0vJ6Fj{IhfWK`VBxwD7gP_&K$1UA&sDlMJNN%nEf4{uMr{AD`sg&VBl7f0oS~r>c z{>^Y>=82KZ+=IBNvnA*h6DEyuAa>Ci^=7p20SdXSQG4-7ds^JV0*RCv-2Rth_o(|Y z`kbvkI2Z#MRH}bK&%0(RytOvZmSye!wu&`(=hxy9Ym!04+b|PwW8Mynh|5;kDET;t zSIkIve4}9Ubth%I+FS-Z_}yx!`*#?~#ZeqX<9t#oZp?bfb1A#(0Y+UC(~!QKN^~8Z zNu$@*rN7bVHP$ZGa{2oXRq(M@PEb&oBvY5$e)x7uQ1L>9$MkDO6N{VSerY1@JsqKh z6cw{A8!LNr(-_>bzPa*Jm@IeZ+moIUSQT>u6*!Ed4}T6{Q1Dz(c)zrE{cfvu-5c^m zIzHWF`O@|-7TF9rp1rI-L*-^nX!}YNhvLja|Dz*i^O|kbKHvufIw#3fqa~2^x#I#W zk(a!<$|1SE>y`2Yja_BG^mR*#&0!QSfI%Q@&EKr_M2?r!6zQ{^#^@LaM@|S(-a~Ns zXi3)9T$5NU(84qH26durB%Uz_?S- zXwzg4u{>yIAq7Ugf(ZDs&Tsoa-8wT_hyaG~VHnAq_(S@(Fox6_WgHW`SY(f3n)v=x z-W3J?2Bapk&Gal|>0|)?i(F_$ioB<7UqVUS!bkPy6Odj4!F3fQ>N9Hp)xH3Mtq7Wz z#x$QUl76`o!pLIrdorb0RxrvbVI~HL`-l-zMtB|b69R|u1CkmoYuW4uO~d{KR(hR> zSnIGjWi*s0uxRnDeMuisMRc!m#xHRw%GP3w3h!%Y#C^Q&q<4qupnP6wn<1dqM))j+ z{V7L=oRI^MfE2aE70yc>!q0~337pv1@qf*#0T*5`OyE`jVlqugQA;g!uvT_q9NmFY z81u4sO|;QYl7gF3hIsSlG`$0wozDpP0fPfmrFe#6yXH-N!sa9RB=#q*71}YC?4T}7 zOW<^j+GzvKs7xWco;RTp9uOnlqNDHZl6a{V|NULq^uIP*j2Pi-|I9U(9&&A<>fu+P zM6(JvvzGXE-KY0;1aalO_M!tz0w6FK@7v0!J4h^LCE4gmhshK61L(hU^m=SsZ2pds zsJ^9lZKg}4NH@7+&GP*IGSXF_23;?;0~w(1~GmMI`q!j^kC8nDLB z-s^g7{FE@JyX0dn$$^>X>wY7wJt z-$E?gPY6Dtu}hRc*UD~V!+_iuV%Yhjn|+>hXTgBn1*)6;hs{5Q?tEcHa^u;n=IB`= z{J~H)5;4H5k39iXmuo}kB)4;t)9&Oz1%j%Hd}_*Zf4&v6bZOq>!PD&s#kQ|SHS<3aFMof5YF3BBoJWVh+FQ;wKrl(1V3@H~22&{iac#^?foelJ>4Khs;~Lh1-8-Ww#n4ZJ>j z0u2TcXyLz{0f=5SlBMC>L0lAZ?Q4WE}PP=7vLRQ zzjz;UjynU*2W;#FfPV1#7mVkiyOjbMOMs$cOU255HRK=7c)>&v&9U=76z!J~}!@ zRA&zX@B`Ns!&z|bWF-cgSJO}w9$Vici;;|xEejC=qm?uG2lAj;27_LjsKs!G839`< ztSIBm+(*k5EnbnoDi@<;g`Y>DViRl#Bu)EPAO3l(PL4Qf6^is?)^#$|kL#&90;6>$ zUmins`zM|S4L{CLg#z(?=c7Q`Su5qRBZr^7Spf)6T)S>2hV85wAJls?^VL3%?#_~l zN26@6Tf@~^x$@}j!a#yBrsz67n$q}}08Uz?Lw#e;V+jA63r%7H*R)^&^jw`(BILCO z@!UafW3KlK@N)18Al21}+p!~+Xg@4h{^*8&U7sct@WP6bkAZ;(;MSyS;mLnLhoKZx zv8Oe7)rRpQi?_uMUxSBQ+Ppi+A3J1dMq z0VL+q1XJU&1!7HTb(M}XGL0lFjb{Rbq7)`Qiv*(IzXIbzfx18fpqQXHe47Lj{t4fD zF$F?X_)chYOa{h&39s}OS22AZqH5!P4+S$}djUL~tb5mD&mS=B0HzX5^8UxF;KI_E z1PX71s@XIcGev<(&h82#fMvoz_;o!2(!{-;fa@(;uaHV$>bC3X=xhhxMjVWehEYs> zjgzX}hJh?#pyX%QX}I~)_$M$rLs?`q(`cpOI5%RFu!7**2oVa3kpd8h}nadBc$*fQC^<(yAMK0jGxE=3Bydu&0kOIz4VsjO73qCudM{nV2-ijne;}wd_C#M_svOT^Bte+fy zQ}JU@^&#JR;|pL(PTRh+44N84Ay`L z^>BHNbjwC}5{zV7OQsAIp%G4lP^fvEg%E<6FO9gUoGoF*?N>N9@^br1pt#J@ z@o2F+`HaOX8A9q>Pv9GfFr<9$`pDfqE=F9x;fy(Myc1TL&qmePSUMnf%x6jcDz{&9 zfy(bU`d57(?)sXJwb%o0QT;1hk-Cmu-`TSj2D!?lgZr+A4tfGhN@^>EoFIOeM$c{u zz3|&A{6=3)Sw-TTQ~B>j5l{IAirndgo?87U6bHFMq~c6(g76a-E?Y-0h9ubRXCjKk zh0inMPEUBw)89NuQwgBOUsWPWeST5&@D>bJQu~-)(=xu8rns?f0mHiK-c&H=xh(MR zj%qL};UZo9D@#j(uaQvlY-aCP< zL%{rjCb9DxmGuW`5VYHQDq-vMHNdNmEGDQz)^y^un+or4o z=HjC3K=({AAPg>O4;7R}r214qXCKX^kebQsn7M83eJ)j&;18Fo$*Y%91#pnJBU3e$ zIDj^uZ&-Pt+n3AaX1)kWof)CMGVo8n1_^80@GoL;W-E>F(e9_&2fq{q@n#a$ zr|Y|SU!uilW;aHTs@JVB-@zbWY(F7{#Es2viXl*lvJu+5!Bm@UQUGb%ZH3*;#%!pa zh{!rc`3$tG*l?3Fy6G*!!*=f)_ki7&ItcRrEVs@}n%=?v;E~ z45`L|JX1Pxk@zE+@=?Awv(o8h5`$x|+;I8Ua-e#^PRdgV7Xp$C1kwxccHE%FFWH$d z%lqEWft}d?&^j@g-9bMwot7v5on75K&+(fQZ|`9-Bs}~e(2xlD4#D_8qC!v^!rK=T zzN*W>*E+3TZf>+-5I2k=b?qn0p0bo?%{5#wb!$;$cgfqZi$SdDWxjU9+~Dwbn6}or ztidYfPz{S~246}fod`zeqgB_9K%0O!wm1m!$tV@-`TpuAAL4d+bFXj)!=(( z5YnxKyln@?5X=O{;-&g6! zwyF@4+<|j0UA|T46bzS2vr#DgV4UT_V0_qlHBt2l%!@8k*^h8#Jj&us%~3h8Qmn0G?=VS73|MR{(0?7Ayp-C?0L zci!bbD5HtHZihZ-o3xYqjKphjMO3J28E;M)ydI4##k^$XHykkVJYoQ}2ouw64z|~Z zuUIOfo4}I>{o%91SBVD#_%nQ{3X+pp1CC7ZJ%jfw6{(F#?jc@MgtoZq$_N(dqJ;g2 z+Lb--q{JNxsX-Rs*wuCtC11%6box7R2B<5Xx^YS*<2H0dR*;&hUzSW-kQfKM)zLxE zbvr=JhWTf}3*@8kGz;(1n1{ajWpJF3@LC?^&Con0T+Nou{Ri!D}1Y`1Yk4P5+mb!>AQ_+0BJ=vFK<;)V&^Bey#_;A>o!@P{1MHV_I! z{0mD=-NEr{@Kz1#qRY~A;~idzI@^Evihv(4P!-?FP`ST;(zgkN_Neg~c1U@x>$yU8ebYrBseBA|2iW~d6uBBn-hh_PSq#NG#qUgjQip{V` z-`MqgZYLJQib!9A@hO--KFt=sV{CY}gK;(SDi}?b=*k1Hd=%TdL3g1Xmm&{ zHZPWE`am#7gUnCe_Fu$^3=8!e4tW)5ABHK?cmlGt=Ow|2>nN_e%xqz15a~-5-Uu@` ze1~Fy0Zv+rwo%{qBepL_a~E4&_4{H3>)GXzpCN`Mq^VelfTsklgzSLtPp^imYC>rh zvd%ksu}d>%YT4kKgJ)6*$<;RVSrlruf;cU2yPmR*nQ8;EShZ27u{>;l4Sbp6mn%)~ zC;Q&Ui|(7*^jb*3gIXpnnnh514>Im!CY4%(j7&jORf zd|j{2XGwkAr4(xBc0KohE&{Vb>8oMRuz1Wb>xYv$_(n78z$sKh%o zVuNB!+dm%;;Q~|C$Tj#Yb^|uB4-Kmt|Ay-*ge>nB)$FBPU-?34^kBB;kqUKmWKw#I zN*tT=0BOmu7ein#?#eL@zI>PCZ-;Q^y4TZZg@;2%nCYDV3F5V<{T4Xm9&>r75f|Lc z6*i4-Elw*CudKM{7%s0sp>AJd(J8nY3uA_%1~{Lcr}(k|DCi;uo~VaDvB2?UCUv{A zc?4YfOFMipQ>9y-eD`KHPg6@5HmAv~@W44cv8z~+g$xuzGJZ};`hq1cZvKPZ zxyhklFP%e?GJ7mN{Bn~oDNGLawX%V?Og6N(FD_=oy=?H$5?TZn{aaRIR#kD0WD~Ba z$X$9csvkm?DnLKJp5!#Mws_0c94I}PS2YiZ1`#n)qV!yO^lDM|7Zg~*G zG?oQ#)6mB0WxAkw5@sT)VTy0x%x=}x0wRcyS$<4$+C{`0Gj0erE0WzcA#Q#3fF@Z{ zrT1BLNp7;MKiKwP7G2IqsBf4>Mi>8<7Kc2(zNZi3Y3xi2b)cxfKqvc?^9jY4I?XKD z%k8TzA{IZo6%>ER{eY{1c}!E}X#A&=#y)=HAq>WdI!)NC)fSy!N3&+$uDP4%i)M5b zl1R9G0C|0`Rzh5EJj+r_`bcI1XiF}LjQkq=1d{;^PX8HIEZ zt1IkKgPWV>C&+Wvd{2^iEpwCXQvkIC%LPkJlt|+R7vEun0EK!uvtuBNh;5gT(C%HD zYrKnLWVQ&_1(^Y8zXQy<7;woHMI3P6nr?)Pv-%HzM3oyhRlE#MA>o>v_?(aZ^zDtv zVMMF8i8mh@e^$g#*9YQf%U~}Kvz$dQF`i%1VO|^a#$_7Px-`wYv+7~Yrs-lDR2H7o!|nH zo5ODT4RT$eun6>Rr?OP^9FX48*FT(rtZ5KV%ISp z`Uv^MH$ktejH=5mL6EUa;^k5=w$4{b4O4^Gd1F1M<6boCnoNPsLnoABOv4H@W&j#j zvc5W-UUD|Hm_0+)Od69`gEz?_L(psZ?gP}1&RM%Pd&xIQz&@vJREQz10S}TD8W=Yg zQZR}`o@z$>6Iw*apO-k_MM-!2#F8$->?U4^BQlfq`opL|z|TbO@KqXp9dcfBwN%~QG_g#K=yM7392fWijRSt0|8SADbOKvoQODf_U*zIh zQEQC2;+WH#)&B*$2IHcF)W%pz9+^g1;RD~u5l<7{=0JpE8>{56oIsTuT=NxcQN#cj z%ukMPEXK0bU|UpDPz2mn;(?DG zkd`{H4dYRRaE>+`|6U#WxGHp;cDSy5Eg`q_gJb@8HL(FflGzR3^zZfNSt@$ZO)9)M zCXI&XXxpD2sC~!mrFjhS?j{yT=`G6&m5#_B^h7G2vNU+NzoA9Ud(A;_reM0gzA7`N zKb?`+YwMOcfB4#pgxNB9q4Gs7cV%UfAze51Yv0&8&4bA6{?`Y!mn0voa!Yko)Rt%S zX4ExFkT)`MKtm1f1>Pl(Dgkj_ka{{?;OR_Eb&rJ-mk;lIc#$ zRpM2}%dq-1Q_DvM>hcZ!AKBQmpbR{^xKt%S+T8zqvr4>2enR+#W7U#Yh1|r>m1>?R zKD0dW}L}K{rlywwHyS;_07S4K)i#FnxOK zqYVw`P`5GeD%7&QUF%2maVLVbyGSWLIz8LM#Fj{|%`nTpUnc%|e}pzFhhX^t4&5M} z)Y7N#(QhEA(%@MdRsH8my3L=$CuPqTp6&>}BBRnWD+k9(Eg4Zqu3!TBl9j(Uq_4Nt zrURK1!(!_n`WDZUao1#N4_A~L|Fyv^$3Mg)<4Z>B2R4noDfW2(-EvUcE4H6DXXE|k z2AU-wrkdx3&i;|q>e)$;bF>=!Y^CJJNSyC@VcP;xBzgI%p6_KdYuM!hXW4x<=h@o~ zZJ%@nsc6J9_xL+3COi|xDOHnGEvzy@`EPwOOY3hw?N4p{Xp_qM6rK3(QNwbzySwlf z(95arI$I@}zJNmiQSx#N1L|2Fr$!qgA+Om)m*VImmjk{=4y0zfHK1*&X;x#?ppwMf z=a+=rckLymPX zZs1?>rty9JMbiMunPu$Si^JzCb2ngH7#yVEMQ|_Js9S+UcDHBO2J=L`f}9iXUtARH zXZ%(wfgPT$iCpdAa1G}Vc5<}ki^_-GSRMgd!>_OKUU!wzJ+n*5PJ<7KO=+yCeM&B) zP0Ztp;D)TF@`m#z{9VhG3Lm&ho?;OOhbdxGW46dV!W-iu&)87u1?Gpl&R!MVY-7|S zZ*nW0LgIuL%IjOitGf*sA>pqFa>*ls0~)+RgVjwpO6$#=yY3o&#d?4X%+uO&q*?Bc` z!Ljbv^R>hgXX8&L%j1J@5syO~qANSKF7+;#miQ3QT)hdumS;W~`znOzl9!eLdU$ri zm+m;7R z^TS-;IlqwT-gr&^I?wO{mx_Gg+QeC>&{XEFEjpI{p@>p1u;?IzMUHt)WU%^NepGid z6GKPUzgBN-Y%(V?nJVGDyO@5~wcjnjGNGTt-XksK)ZTZeWi^U(u75;!?YR?Q86slb^ra|f~#j$A#IZ3y12W|ue zCFq+f%ixpyv9{kTN)Lm~enfZaD3xjNNWWA$zr1FB!(|&<-ujcOE3)%nOF4RJd2}yj z%OW4zI3L1IMcjDbi0Xl+)gHZp9_2I_Vzr1!M-*vzto&lm{cfW3usWss&v0$m_k86= zTZh3G34>ZHgvKb(wrhrrQz9v8=Zm%l_zmQ|Pj*#GdCD?YJ;NZr0BNV*%!)EnbZ(B1 z&CzIW?_0}t8iQA5%RXVLc)o&&IQ!zXoys^9F}l=34$hrsyvM?=E`9`Vz-by>jQN^#_(2{^p9K;MQ)NSl-;R^^M!r! zLMi=pn_Fdsl_!}?H*rEWl*E=@t^)lo+Fwm+sM2B&;cAlW>%=llE8Zwl+I3BbnWxFU zzEsC7H&RF?1c@8-AA0nktMuN8+33Tt(1elNwOx^NQ zNR8F2H`ubNxJA#`WrxwMr|XkbkE(fIvZte6q_7^gB)6qptFeeS^hmy%w7Wf_yt|TJ zaY;v|r6sm&C9m(E@BI^fk}bzsoaExP_g%OJro#eJ4JIQjwBPLav3!qnEwjOKpOj

uhq75ql^63PRPaf8Z9^o@5{)zbat+TBf^ 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 E58XAnt^fc4 literal 39261 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 Date: Wed, 26 Aug 2026 10:11:40 +0200 Subject: [PATCH 08/50] =?UTF-8?q?fix(ios):=20l'ic=C3=B4ne=20suit=20la=20ch?= =?UTF-8?q?arte=20ghost-suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Troisième essai, et cette fois sur pièces. La marque des produits de la suite n'est ni le cadenas de la web app, ni le fantôme de `assets/logo/ghostpass.svg` : c'est une silhouette de fantôme commune à toute la suite, que distinguent seulement trois teintes et un glyphe intérieur. ghostmail porte une enveloppe sur du jaune, ghostcal un calendrier sur du cyan, ghostlink un maillon sur du rose — GhostPass porte une clé. Les sources des dépôts frères le disent explicitement : « Généré par tools/brand/ghost_suite.py — ne pas éditer à la main. La silhouette, les tirets et l'épaisseur sont communs à toute la suite ; seuls le glyphe intérieur et les trois teintes distinguent . » Et il y a deux variantes, ce que `ghostlink.svg` explique de lui-même : « Ce n'est pas le logo réduit : c'est sa silhouette, remplie. Le trait du logo fait 4 % de la hauteur et disparaît à 16 px ; la silhouette, elle, tient. » C'est cette variante-là qu'il faut pour une icône d'application — l'autre est le logo d'en-tête. `assets/logo/ghostpass-icon.svg` reprend donc la silhouette **telle quelle** depuis ghostlink, sans les tirets détachés, avec une clé creusée en `evenodd` comme les autres glyphes. L'icône iOS en dérive, aplatie sur blanc — une icône d'application ne peut pas être transparente, là où l'`apple-touch-icon` de la suite l'est. Réserve : le générateur n'est pas sur la forge et les trois teintes officielles de GhostPass sont inconnues. Le bleu retenu est une proposition, à remplacer par la sortie de `ghost_suite.py`. Tout le reste — silhouette, épaisseur, structure du dégradé, découpe du glyphe — est repris à l'identique et non redessiné. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../AppIcon.appiconset/AppIcon-1024.png | Bin 47475 -> 86514 bytes apps/ios/README.md | 16 +++++++++++----- apps/ios/project.yml | 6 +++--- assets/logo/ghostpass-icon.svg | 16 ++++++++++++++++ 4 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 assets/logo/ghostpass-icon.svg diff --git a/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index 9d72f63d727f7005d73c9343704c62bd2ebf1e4a..16abcc074a69d24ac7144b22e73441a13c6795af 100644 GIT binary patch literal 86514 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 literal 47475 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

uhq75ql^63PRPaf8Z9^o@5{)zbat+TBf^ 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 E58XAnt^fc4 diff --git a/apps/ios/README.md b/apps/ios/README.md index 918106df..d9fac91d 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -126,11 +126,17 @@ aussi côté remplissage. Sans lui, l'extension demandera le mot de passe maîtr ## Points de vigilance -- **L'icône reprend la marque du produit**, pas celle de la suite : le cadenas de - `logoMark()` (`apps/web/src/App.svelte`) sur le dégradé `--accent` → `--accent-2` du - thème clair, tel que l'affiche la web app. `assets/logo/ghostpass.svg` porte un nom - trompeur — c'est le fantôme de **ghost-suite**, ajouté par le commit - « feat(brand): add ghost-suite logo », et non la marque de GhostPass. +- **L'icône suit la charte ghost-suite**, comme ghostmail, ghostcal ou ghostlink : une + silhouette de fantôme commune à toute la suite, que distinguent seulement trois teintes + et un glyphe intérieur — ici une clé. C'est la variante **remplie** + (`assets/logo/ghostpass-icon.svg`) et non le logo au trait : ce dernier fait 4 % de la + hauteur en épaisseur et disparaît à 16 px, comme l'explique `ghostlink.svg`. + Les logos de la suite sont **générés** par `tools/brand/ghost_suite.py` (« ne pas éditer + à la main ») ; ce fichier en reprend fidèlement la sortie, faute d'accès au générateur. + À régénérer dès qu'il sera disponible — **les trois teintes restent à confirmer**. +- `assets/logo/ghostpass.svg` porte un nom trompeur : c'est un fantôme au trait ajouté par + le commit « feat(brand): add ghost-suite logo », hors charte actuelle. Ne pas s'en servir + comme marque du produit. - L'item nommé `"\0gp:folders"` est un **registre interne** partagé avec la web app. Il est filtré de la liste ; l'afficher serait une régression visible. diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 6db4ea79..8a53ac57 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -36,9 +36,9 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: ch.stackops.ghostpass CODE_SIGN_ENTITLEMENTS: Ghostpass/Ghostpass.entitlements GENERATE_INFOPLIST_FILE: YES - # Icône reprenant la marque de GhostPass : le cadenas de `logoMark()` sur le - # dégradé d'accent de la web app. À ne pas confondre avec - # assets/logo/ghostpass.svg, qui est le logo de ghost-suite, pas du produit. + # 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 diff --git a/assets/logo/ghostpass-icon.svg b/assets/logo/ghostpass-icon.svg new file mode 100644 index 00000000..0f018e3f --- /dev/null +++ b/assets/logo/ghostpass-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + From de2e78a641e6ce195ea5f9bc2bf9ee0d8a5de76b Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 10:54:25 +0200 Subject: [PATCH 09/50] =?UTF-8?q?fix(ios):=20teintes=20d'origine=20et=20ca?= =?UTF-8?q?drage=20centr=C3=A9=20pour=20l'ic=C3=B4ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux retouches sur la déclinaison GhostPass de la charte. Les trois teintes ne sont plus une proposition : ce sont celles du logo d'origine, `#6EA7F7` → `#5394F5` → `#2E5CC5`, reprises dans la structure de dégradé de la suite. Le cadrage est recentré sur le tracé. Le viewBox de la variante icône est aligné en haut chez ghostlink, ce que j'avais repris tel quel : le fantôme occupe alors y 9..496 dans un carré de 548, soit 9 px de marge au-dessus contre 52 en dessous — il frôle le bord. Le nouveau cadrage part du centre du tracé et lui laisse 10 % de marge de chaque côté, ce dont une icône a besoin sous le masque arrondi d'iOS. Vérifié sur l'écran d'accueil du simulateur. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../AppIcon.appiconset/AppIcon-1024.png | Bin 86514 -> 68605 bytes apps/ios/README.md | 8 +++++--- assets/logo/ghostpass-icon.svg | 12 +++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index 16abcc074a69d24ac7144b22e73441a13c6795af..2927b743c0a2cb49821346a1769170c4610a9466 100644 GIT binary patch literal 68605 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 literal 86514 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 diff --git a/apps/ios/README.md b/apps/ios/README.md index d9fac91d..1ac08a78 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -131,9 +131,11 @@ aussi côté remplissage. Sans lui, l'extension demandera le mot de passe maîtr et un glyphe intérieur — ici une clé. C'est la variante **remplie** (`assets/logo/ghostpass-icon.svg`) et non le logo au trait : ce dernier fait 4 % de la hauteur en épaisseur et disparaît à 16 px, comme l'explique `ghostlink.svg`. - Les logos de la suite sont **générés** par `tools/brand/ghost_suite.py` (« ne pas éditer - à la main ») ; ce fichier en reprend fidèlement la sortie, faute d'accès au générateur. - À régénérer dès qu'il sera disponible — **les trois teintes restent à confirmer**. + Les teintes sont celles du logo d'origine (`#6EA7F7` → `#5394F5` → `#2E5CC5`), et le + cadrage est recentré sur le tracé : aligné en haut comme chez ghostlink, le fantôme + frôlerait le bord supérieur — 9 px au-dessus contre 52 en dessous. Les logos de la suite + sont **générés** par `tools/brand/ghost_suite.py` (« ne pas éditer à la main ») ; ce + fichier en reprend la sortie, faute d'accès au générateur. - `assets/logo/ghostpass.svg` porte un nom trompeur : c'est un fantôme au trait ajouté par le commit « feat(brand): add ghost-suite logo », hors charte actuelle. Ne pas s'en servir comme marque du produit. diff --git a/assets/logo/ghostpass-icon.svg b/assets/logo/ghostpass-icon.svg index 0f018e3f..9b87868e 100644 --- a/assets/logo/ghostpass-icon.svg +++ b/assets/logo/ghostpass-icon.svg @@ -1,14 +1,16 @@ - + + distinguent ghostpass. Le cadrage, lui, est recentré sur le tracé : aligné en haut + comme chez ghostlink, le fantôme frôlerait le bord supérieur. + À régénérer par tools/brand/ghost_suite.py. --> - - - + + + Date: Wed, 26 Aug 2026 11:06:45 +0200 Subject: [PATCH 10/50] =?UTF-8?q?fix(ios):=20l'ic=C3=B4ne=20vient=20du=20l?= =?UTF-8?q?ogo=20officiel,=20repris=20de=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le logo officiel existait, et il était sous la main : `assets/logo/` a été mis à jour sur main par « feat(brand): join the suite's shared silhouette » (#33), après la création de cette branche. `feat/ios-app` en portait donc une version périmée — le fantôme bleu au trait — pendant que main avait déjà `favicon.svg`, `ghostpass.png` et `icon-512.png` aux teintes de la suite. GhostPass est **violet** : #B79CFF → #7B4DFF → #3E1DA8. Ni le bleu de la version périmée, ni le cadenas de la web app. Les quatre fichiers sont repris de main tels quels. L'icône dérive de `assets/logo/favicon.svg`, la variante remplie — celle que ghostboard emploie comme icône, et la seule qui tienne aux petites tailles. `tools/ios/make-app-icon.sh` la régénère et n'écarte de la source que sur deux points, tous deux justifiés dans le script : le cadrage recentré sur le tracé, et le fond aplati en blanc faute de pouvoir laisser une icône transparente. Rien n'est redessiné : la reconstruction que j'avais faite à partir de ghostlink est supprimée, elle n'a plus lieu d'être. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../AppIcon.appiconset/AppIcon-1024.png | Bin 68605 -> 80736 bytes apps/ios/README.md | 29 ++++------ .../logo/{ghostpass-icon.svg => favicon.svg} | 21 ++++---- assets/logo/ghostpass.png | Bin 0 -> 47426 bytes assets/logo/ghostpass.svg | 29 ++++++---- assets/logo/icon-512.png | Bin 0 -> 41044 bytes tools/ios/make-app-icon.sh | 51 ++++++++++++++++++ 7 files changed, 91 insertions(+), 39 deletions(-) rename assets/logo/{ghostpass-icon.svg => favicon.svg} (69%) create mode 100644 assets/logo/ghostpass.png create mode 100644 assets/logo/icon-512.png create mode 100755 tools/ios/make-app-icon.sh diff --git a/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index 2927b743c0a2cb49821346a1769170c4610a9466..6265f1d23d9c04f45abd77ceca96ce64d96769e9 100644 GIT binary patch literal 80736 zcmeEucTkgC_wE}aU;$AOX%g@NqN38J2BM%y(V!?OT|kN`D4~WHY>3hkr3R4Rl_I?< z5|G}N-XWAA5PG}&#dFU6zB_m3&fLF$qcd2No&D~;)_T@@*0bWHc|(QqAm>2{f*4h= zDc^#iec+G#p#3!9$8XQl4)BA{L|sK0+J%3`mL-Qk5HF;veCf7x)NGkmoex zg3k*%o~sK!uf=@nDf5A6`(C@)G+4&AT1w!FljXQ^mBHTCDB1j(&6OgDhtkXPiUik{ zou&cX7`>8d*9wExj%&gE@Gc>Oqlj)k{09g+&BU_T@>f{R`{rGda(&bK{6`w2LV1t$>)+ow z&j!VY8-v%!9@oH^t?wJ^)-2JiB~^ZJu0gCVD0wu zeRjv%LBH&lMOIFu)N!FnPxb==2OtruFTd)od^!lSJR6cM`Bcr~hSlObb8`u~%+v#o zO_t`m?JN=Dt+T#Kg2e-3k|)pWyO>)@iu0+~MV-Q0o9q72Si>zXA63z?n0hU7F>4{$ z-1udU&O^5%?WkndNvm-e-6CBf9qaEdx}VjAbmVIGL6)5CVB?>{i5;Jx%X(ZB^q;D} zA1$4sr6Te4DlWH6PUY5a^V>S>C1*%zR^OM~q3f*g!%ogj1*7zCa%sNoyj!MWa_5Zw z&K1saI)2FV9K6~!amhFqZwLDde^g8BwyYyYFd%K!u2qHH?_2lyw1-e-wjgWiL%%MA z3`w@BEyg9qjG{_WVF<%m3ErF+ZFYmiG}D6O9?0Z#b)XV%@XSwTU6^e0&rT`sYdi_bT1 z?nF1PfWK_%IWIE>%Bf8hux1XX)MvFn=(hUC%E{5F13{V){EYX9)=s)_WjU6nyYrk0 zKeVs24f*r)cn_g!TNP8BENH&2X0UOKptF~ceZu!jI86Qbobc7nvmA6sHl>1 zyxq6=epaLT#T(`ZPmxA{?So!=A%K-KB>P+S&TI@xau2ZhriRj|M53@w{Tv3vbC@&l z{m41ji>FT+JT0qvADII|i>fy~zi3sxbQ~g}M8F%C$2&ybKi!*`&kj+Uxr4X0Z|Z-9ju?!%o39OCitWqFXWJN{ zg+4*RYhoQ6nx^6l^2e{t4V-d_O0XVBOeZCqFtxCV7WR5I)kM85UQ>Hqtfe!M@W9kx ziUu+UAMo(|XC!|9LD_2XsT-2Klc-qKdK;zq7vYUOAz^{Fc;k5glY%aTS!ZD=j|%`R zX1PJbdXo#kdS$M6p#SK#eJUqJ4Nhj(6}93P?iBA_FmMUE>XO6`?W+W9->&=o96!VH z*!@mB%W&*PTUv>elN1#!<+S>*gVObB`NhLF22S4E8Fs!9bRM=0O-9#(nat$;?eo!c zjNNA^ZNi8d6t5ma&yFfaFuvY?xv`k+VNmIQ32H&W4}9C)B5%&2Z0Cjl?2ILM-PY&G zzij9q+Bf+Tw_2i7765k&{Cg%GTd~Da$IlCzR(?Fvt*1q`^@do}Mk@ zo;qP#@6agdFq-VJbH%?EGG>SEjp0qvy~nNwwhDSej5)_xYICmmg&4_7R3@;3t>2xG`P!f!ZZ5>u|1LQj;mLRX>%{a__nqZLU8m zyj;+iU7x+`cK}k_d-QAm+$}Q|Et}bvj;GkQ(t8bhl9CV06rf#v^bi(&T}EkolzeNMdefGBL_5CCwUij)45?aQXklEnZ0FX!IKj>V zaUKGvYn(dUpVg???eA19o|%Tm6c?RFbH0k=Ld)PX^)qMYJN#n0)((<}pcV{#Qto0> z>6wws8!WP=PM<|(Cs$r}W>F2*(Tw#)u76GEvhrH3s{vcGDfG5DfMhrc--l&W(oXiy zZPMWf^bGbwvDz|D->7B|OdWI*J;-aEJl%o{iFyk0q3PVCNQ_b?_zYzcx%gGXHX+5i zHGGViZAEJD945b^s5OMA$6Ju&8y&>J2G)hx+CzF4pIubUYA3mFOiv(a-2Da)g0nOr zq6!i`9#z^Icz&=ckD)(+lt2NSCRBC27;wI2E1y``-bPTL&;J2dR8&47Q^28BP6SS{ z(7xp(g2{z%cVBpTn_TluC&_-o%-yzvBaf&%-uE`zL8z=-32Q|ho3*JtA5%pKNif6C zLMPsi-{dIUz+I1;wdB@j8VZ!qqg(@&`c2xmAZu1>fTrk87_+d!0`i|HPZJkgH8IDn1C&Yk#~I$54Dcj%*E zQ;~SjD+i(2br~m-@{4+iU=ZT5?w4kyWRsADvz6f4U1gr$ZQj+?lT!C$xj&~i@7Gww zl_Z$%8hg=r4aAmo+txWIt7Tb|FQj<^T$&{xIIB_BvPG@ySk5577097ASLAl4oeg!& z`}hdrDk+bAxPOP&h-IIqFnHz`*)J7|7O2(*rQlbJlNVoQ=#ky)@SM>&ws=9OcWPf~ zw4<|oc{=h7WX<;xZo&I`EA6O>Rs0w*@SZN{fCH1HCYNS9Fy?io+rhYQ&1M0e8HzXT z$Epb9O89|U8ycs_W2QSv5^}jef4=FpebpIqxBWA#rV^7#v4)g~Zp>CjO%3-FO=Trm zzVdgjr?pFVIl6FJoq@jcsGOna-zZ)a)`jvt!i$T}O6;mhcz+7)OGpmW`%<%{#ew2GB(x==?+-h=?jN zLEq~~<|CA55m&D|%vd*06TvnGOWHk-B0JUpDYM*Zs?P+wW-NvlHprIXaV3ZP!=f z=O_oyfq5P-ov5<=T)~5MRJWkbRc~2BcJd1{vXE6%or#ycc)fVnn$w69#tHoeq5wwg zSAl`zhLfy=MqB0Hw(7e^18=8i5`eQdcEvx_8KGsH@v^mCmL2SXhTz=7&#Yb@zolqp zUL0?A*Y?eRd$9i10HH}ob}h)`W~U#6Fj=tWBY8JpcjK)DZd&vJt{5(Wh#PJF7kGhBli0sHVk9cge*`taE@40v8q++=CgP)*}q62Z&kAKt~=UT-q^{0FG< z-0Lr=?-g8P^3a-$o)2S#z5=_7rSRkME3WwQ&+8v<)~Uq*_oWjOjsiHLuSi|y0?vei zKy>Pes1x5F5Whs_!o|nL7{s$5E-9*S;}KZnA}AVe})wN8GQ3)8Kj-Nxs$}D zJN0AVENyg~3=)VKqki@I#=4@Jbkh0Mlu+ky{l!afDRRFc3^TaS>8ruOMaZEluCkmX zh_mf1Q(#kmMEpdbLp!b$*jBehaf9d?=-O9cTbW_Rws_AJCc}`k6$j#6Q)<9IVv{K{ z1(F8W)#sO6v_k0%#~!*^Wdke&Mko(c6Se(qb zpSVhwRh`d9J~Y^tmhaDj(1k7e>_ler@>3DrynRW>N|mLs=q0tJ_*d{SHx zVxCzbBsp}C{zu}`5h^YA(X}GY+IFdtR%8}-PzJ}c?D=h>>(@K}nxsryc@aVAhm(#b znF5ttbwUe7qt+xjZWaWC9E?QLJmsl8Tjl1-^m>F0mx-fsB*D~HJ@~Bm3<2QLu&xgu z-v-sFT~J{>Y`<{|h?$kf>&|b>H%lU-iJte`Baie2{}3u;Dxpt#iwbp3=$xyy^(Nl_ z-EY*j>?CuFW-s{`!K~PH8*SmPV;v>C3aA4#Fy#52>@8(&*@}{5Hwp-KUj0_QV?#Oz zDgChQ1oe!iOrhT3cC`J9@R^`soMJe?e+vKP9S>_KtkQDW{kJjBlL1v>#<<^&+zTl5 zben0-SbjMEglK-JsoMa$pJSm&iGPy1cK9UUS#_-AU1vxIMHs++BcWI?Zm0Ux6m0uk^+yQ(*9&MxQo?Ts1p1?(Us(}#Ee{$#u zbgUsfWaI6w>&eXz?kvwZ+BpnLdTpl@1fNcyS#ac?EEAaYXgDO^%jqDe$(%|m=RS>d zi%+#6h}qIFPStr>T(ut*TIeKEck2DmK(7t~vpQDSkhimFM6TT-9$vaS))&@fDR)KG z*7plFO!N%eT*E^1c2jiM=(VPgHdRd12b}o*!fSve_?4 zMXMQ7DrL?!EZYbhaA^{ytvmud$JcAj+ha_57LmtX5_QZb?$ck$gnoq{Shu63jPXf7 zJStNF;%ezht~8Qt+s7}b7_d2cQLdkJEBQQMQ*H5uI!S2}$HbxQTG<#%(c4Q;L(P29wb(mKVB9iy+wCrGEu zRwb0l;D6a;9@L>?^LoAHy#qIld?G!w&O1YvjPRAbDj;^LD>fD89R4!Ic8~BVkN(=s z)3NrOgGVBg1p{%(j|nn&%$lMtkPTnd(3Qr^=YB%sr}~U1`+Qj2InXDB7V=|eo;Uh0 zAR?H6-yIx^FIfJ`BV2mA2;zuwQD)^Wk@pdv^gsLh>Uba7nWWN@*6X!F<+q7E7K*c| z*294a`wHNtc}Df`l6Y#NylQjM%8z(J36RzxMmXkiM!`v5*yMl^k zKnKYQ%7a6E%8N7@BA}Ff!F%&1bBJnmZUv80axuKC^AZe40u8>tM zcn~FQ6|q`=G1KVY?WV_5b<;N8YWCIkVsgnA<#M|(%^{@&;E*>2TH`$v&^+(MT#nr$ zG@N}MA?iMTlVh-k<<61EY4jrPluG&!U8X&R+Nj=7h@_ekI~%!A`b?T(#LL@q#`bTE2p3PETH_@uSxR_V6BxwNo_d{3+GSGwmMV{_1i2gog%f=utwsV;C-g1h=ED>7H< zERTU3d2J42cpVQ_--do3aV*@WkQzo?+ZQF5#`^>Ld?pdNj4jLF z_(mgv05M4Ucdp`s`}wP-^tJxNpP+m11P^>D<~tZDFH(35?T%+Ixc=rxk@!ev(MLNQ zC9JlSxVFh!|4qC;kv}M5)SC(t34ELAIm{fmLBa=6K1s#+2)#ZY^0$is@0vOA#c}nR z0pWI2MH`i00WB}`GwSSgt<)n7R_PUEEqCm*P|L%bv0$I?UCi&YFHCu$m$@68!df8* z*zqnnmc}QSi|^(ZKt)d;_?>1?>rBJ^O&r=jRMo<_#W2=7NvM7QJktP_p6$IDna3 zT%HIu92ny}J9*e&HUaN!{0WVHOr;Lj9Qw^qJKnbvH2-w8s*6G#(H@zIv09}EG}uGf zT^F5)Rq*rQb&qd-o%e4uO+K9@`1!waM(A}<82&4_4_{Jlg-ro6bPmxFI@MJ47@bkQ z6)1yVMRUtdu`|Ycfc%8FmZV3eI|IQN4M<;dVJD{gs~RLp*Dc##p-q{(Of;|ILT zO__q^Aq!W6F~|}w(bguy!1BA&7q`QBXIroyW1 zr&03{di?7eHwhYkEL%De#OmZfVIaVb@8V3YZKaw7{pl8Dn9%2U;@_wV#Le<%f0_;? z@L79Nm#c%D=*Als_RCrX-*X`H){MP8zSY3)I`Kvx!+{5|9!tbDMc)-AuXzFQe;kSj zBvP1AD>&aK1^^LAG`-WbJCOluL;xQ%kYC}kpCu&B3fV8yRxmIBJfr4CtPWyWs2rkM z9@}QQjvZ-X8CD(Xx4l}rECfO@BVZH0JqZNkP5wUdO@Qj1HV_M{zounQZ zkmC;qzNJ+R2^J5yd@%4H?M?AhaswYmud*sX7x%_d?xOv9aXoElj5mPDMQYf^N{hrM z?)6uUQ&z7He9Se}0!$10>a0`1HQ7iFa7l_M>UcgZW7}iojEPDm%S(!l(<0(=7>#1Z zQ-8a~bKx}yKX$j!!Jhth3>;>N$rte(Pey5-nQ;O&Q5+3n`d%`b{pjti>gai^?oIzy zn%j)P4*9KYZUE{aBK)Aeg+W|=7WM5y<2g*vnKxuryGhR1f~v)a;i9Q1@g#MuhCf#a z3+37g(dcc567G~oOvb2nN>pzbK=M{!RkG^B59~L=TZA4E`XN<3WwWQGEcRK93`QJ{ z5v9GB&ik+?>d?4X)i_zS-A1LU5&1mwV{*M8DZ9Fzv^=5JRRX2K%tTLxQnPzS3ym2C zI!fEBi-wU8;J>+sBbJfMAB2BH zpf?3a`CVE?Z?q&Y95bpF`kXz9W4{d_yEg8pPdUN>VbVeMAiQ%&Tg8aB*7t3?Lt?<$*T+Xy8=l3; z$pACVUET+<|F1)V{zt#BjdMlEFRTIKOBs`Vb!vejI;* zqR=F8n9iKZgRE+P!j5wqeSI0?zF6s7*S?ool_Uy%)<93FQKLB~Ca5C;p$+$fphN&~ zmS+OCeNJIX24ugx;w*Lzq5M$of($y^HGzy#o@_tEG}UlC%}is6TlUU^jWQ!cQ@> z9{7__&b9G*6Q8Jzye7|PgLc3bJOLzp;TCkJuj#U@L4*hoab(B94QgE#vE-O-66L+x zL5~TjkqUi3#+&&pg(H`}VGff>J^*t3q;y5GNppd&%UGMA)C5CUd831vU+`N=6C{VB zQQwtkhXTzb!w;+jgc!O?J~Ql9<-;1%HriYi%2P7NXXIz-^WeYz02ef8yCQEx8+!`` z=sA;-cSH~VpIo6huFv;$8dIWNZ(`90Jyb0ghg(b!NuhlbNx-p1LBjEEH!USf59wk2 zr1;nW#rEr>CqiQu9~q~>Hs-j{;bcFdY={{-_d0F&SxkItaxMxLD~29EA(h74Ly~%N zRX_>A++O(=87sJbUly&Z(G;C*VI{L_;wGnUvf}+&0&8cM{iPyYm?l;pZ1wy|i%r=I zPL-97SNuX&SEzy-R+LzMZmlRJHzi6=5Bn*4A@|l2W@z7GO zQBU}AK1mV{uf@N&mc{N(++=xB@mNEAAdQ*XrH*WiBeWGwmU?SPTnKq6QnoKW9ha%Z zZS2XvL8CLP+D8jNS+qq)r~#(3<@kW>J)Uw79fHPRhd$oa>6%>&KI9?UmCKJEss97w zPO<|FMn| z9%ZdJWt^UL0N0OQ<_B^8^r=a0kBp^SsY$C0Vz9PJLwJ?1s-&6sOZr;h^)-XJ!TbC7 zv4ai(VR&-vD0=s~y(wsE)fOEB@3f9GKelzS&D1l{{M0)+ojgIGx}xXtuXgW%%f6kR zN#MVQ9Z+nA)8H%cy@0a-Wk($7!JO6^Ne@^Ejnx$0Ti3_d`r*g-v=zixr_rGE^51>| ziaox`39l-@j@%=TW-(vND1r1MBH&t;ns&JHo4;PNCAvatOI-?#@~Ad$$4T31RC z99JE1zr>Qv{S~4Ezl_7c?uN^Ifa&K11*7Yj?Q9$XX;}x#)96gWpFRDf7hWH}0qUsa zM_3cox~PnkbI*`|MztH-KZ$rFnaI2=ZDD?EUY?5q$$lCfaNL7>^`89Bvuojx1uRe# z-v@3US%wRCB4yUApH@*T_`KU|d5mtrC;&HGAvGyE|E({Bhi||8Q%v2ocy$GPD>t-o znKfF061WfU1`&q&!`nkV2NuNGCr_`**ikNd3i<%I7f*u7>{&_pXGDGxwGV;|<9%O< z9yp49=_udB%{fe$e4>X=_GrUU^IlHXco(FRf^D|H51GYp? z)~%6cGqx=|`qyn*8WZ=AZ}t7x0Yr#KD_(_Z7V1;EKn|8ij(ErkRPVrAOtXISdF}@l zCB_Rlhh;Q}f|~(}Nrnm?I|$Bpe|UJ8_~4u~`WT8=4||ff_9a~0_83(OD<+A{o+UB* z>ju%ER>GP!ad)stw=oNy;x<|gvTbU=i>A#`7Mk=&i~qb|rL&$HEb(E=&nOJH*D*2P z??M)o^(ZW#Mr?C$xgw*91km`xskV zG9lEmNh_9KB*2XnUKNAm0-(uN#_Uv@u2jOUwx?MUd89$?T2ic?zIK9b^T{QNRR?W1 zuYVoJ5g`U=TV^v0v%&|%CEBJPI|pCZ9{CCaMI9@9l=ZxKPeFehqSc3Rvm;GABz|$?PQ$%4tfOXY!s6Z;Dakq8xX5OA&uW?ENWly4@kLFfLtc4 zf&|V>{5eO6zY}Pkj&ik2nF(C@O~*v{3hqEN9Ecv@y`isz@5~uu%N8sd`XGmSM>LUF zwL!FrHkxSs8(lVE&!$jLNU8n9)a1MWlU)`b!97V{@th4~Gq!^F=@q}Zypmfrz@G#C zvSbR(Oa|yWQuxyX6hy%R-`5bZx&H@sp`r^@7kcH)^&O^J)qO_4J_;^yU#3N^+oB)) z(|)=Q2Y|}<0J9Ifa%VPO4>Ud-thA%-L9M{_XT@4j1Ou-1gydE>AZb`##^{${$hE0> zfV}mt&xAa(9uPO&7Ig5z0}iiRt#32#@ch$;{h#)Mb^Ru%Jj#}Rvmp-LgF7A*H}O@? zEkuxEw}n&#Cds$%5S1S!!EK}a#ga2?knA9EOw|^-7(eN!mTo=+?H9OpY$Ayd@bKWh@;nhg%UqilI-;ddfc^5Kxk&i-l|p8m)^@T^G6>?%Rg+S-P=jy zM6E~Jx`sYN%;y|t!T{)*YLiKx4$@8>Ul^F6Zv$K-ck-+^wk}nrM-c5b_O$>F3xN(dYup{e z2kOJLgrV*e=%>VlY~Y$h|+1 z*Ytld7+x-fo#jH$2LOZs)@3QLhR4l^7LH#TyoophsPEX+6LKk1$Yx*PAM#_(@*8I{ z=4O}8tAxcSvu;)iFRB?yp)X;Xn~GS3lEjO{=;^%W=p4qFUJ@1dEXmbe#8hmgH;)<1 z(+z^dD6$%^B+f34?><4~%)E5@98g!(K04XRZJC}2g&X{?=jMg8Q2qjl9A++h>K|5f z!Cl$$wv#_sBA{u%A)hy<_LMU>A`Vg^w9wNTA^$4G2#B{5!GJ!j3E|EL++{ce$gR3e z4QEY*#X@lyNpxspZqRtQ%H}>U#@g|rFL*B?gmP95P1ZY z^07hkyWfZ^yT|Vm@}3{x<+(#>_|0!#Wos1L|gA2 zaX)OA_WxvJB?>~Ks#JwY(8dK$(m2|nTDGICtx^aIF#DI)`uF}kzEIwfg(avCBz2gx zNxq_|oXk-#kMX+J)OCeeBfrki9>^M;!~CH*{!snreD%JnNtglz`Zj*UFo?)x*xrz5 zdY8by_aTWfmUbpXw2Hf-4X@AlBi|*1;LU}{+ff+MfYSRyKgA*VFAIL&z(+L!1Subv zgztdVU{}>**;q5M#t^Q7r5H`|YQl=LyGt10FlLt|=F@ay1s4qN`7Pws_!PILFPYei zt}Y;NHj!kS`11(6AIy3U6}u}MOrR{_ve3L-ajvHyWQrbyrsD5s{m9K=Keh+{$c6D(3H;PUuz zloWBKkSHr|$dp@gjGeI(zA#lF>3PYcrZ(AErD0hoQvd)+QUx0&cxKL#ch9X>OJVjF z7!ZpY|OC?mBYaBaEP(5Kh1yD!T01jGY8 z{?x*V(Xzzy45F>kU;P*p!x#D#Z~7Ds?AkT;zvD&RfBB8KjlhklHX_rB=g=BJ7l~Ks zUYdZH^HP&HNOD;tnHx8PeLd63swUK9qpEg*w6HpE3S!tJG+=8{gDPe!4wE2P55Bg? zkf+TFwVp7pLk%G*G|xu%R%7#c*W3T}!TIuBj-*Al7+GehtS3;uc=hd(;1bv$Sph@@rQ{JFe7OXSU7o~u&UoeE| z!C9IAUW|{3ep)Rf&a@ZDYR39p@HFno^BB$^U}%k@e?TQV+`9y(GVpefF>BH2s^t>y z+M{MS^or?4fM;90dik`sgn6?qcl$Ak3vmJtla+lLhMt#YBM)4e=zD%)Z9UuU>LW5L zRvrykC+#6Pi>W$5JqHH>3zmL(2^DMehY>ej28RrK9yoJpQk;S8)1P=Y0-#PYNax)< z(b73@~>FgA}DnD+i>b zTVl-&HAl9ys{?-SBfxSi2|}YHwxDDBbL_wV@Q(PMCQ{XlRuM-q$3t>+ly&Kx<*^<} zd_o_L#$3RHl3G-3(!Z@eE41)P;r4YEc>)lC#8RT9qqgU4U-lm!D{8YSZF&Q;?2zg;?;&2}00?dfhpnc|b-d_WXn_5F5c34iN>1617 zS#=E&=-|d5s26baw|MWt2Kk0(MzP=y%%)OR5Oz0TgG#zBgxg4o>Btv^xyA<~pyW~n zTUtMcTKen6qE@pX#ag^l&>nFtKeYcn(2vAYN*5B2wsb@uX?W{C^dq9n^!OH_F@FA2 z;lU&a-}%obvhg5Sc=@b+2tF)cSV5%zrp^1hAZ%)+-(<-&|)u-Lj>()Bmow z9L>+MtKA+_J@px_8f^3A+N_V0pv6rYr!Pd%2MCzw%ZV?>^__;FK8J^0zgBHl0b~;D zZ6Q)BHHD0Na3<<7H5-|_H|(zU>Ne>O2I~XSM4Z zmLf2mT%xTw+46_&c#NJ1;vrhcs(1_pO6vREd;ENne*xymKwT%>Fa~O9^y|>I*4~K7 z#%@F)JvhVi*KD7*2r@5-Zkw!hbZ6uYCIvw_Oi$!4@#7rh{b^ZOSY|BaaWCt@Uu*94KMcSKT()M`CW1L?BsEw)~I9* zOpW`CKcDHI2IR8?TOqTPO>4vL9V&DQL~F_M>Z5bLK{gs}TjzAQD~C!~ov3Vgf6rLG zZfmz>iiIg}KwKz;Td^POd+DxZiKk$;G&8Eht>$_n6s0gQH++5F7zapo6nh^pTJ;?Y zWN)cN{##6-@tOQ^TJ2nWh`slmTXWnIUZAwVT>n)pGoNRsT@W zv+J`tXrCfxmNTs+y%T8=9)!h?pn!n`_R@zuj(>Awd3NtNZmbVn=vf$D%0a)x6lFDB zbk|3Q8%g~?Jf(k7O&+K3?I_2Oum`zk3b*P$U{_U0xq6EiP*^LjNG`0;Ds5%Iq6Th z1t#cOxVOvaRRIeJSK}wM*Y|@8LN5*#%Z{G-u5fDdB9#g}9)DbpJ`hK*l39y<7nd9u z$_|*YCWb7<<87MR)g#n*pO~GDAzmUppoNJrm{<3a5ZlceCE3t$8#Go)IaLdCel~fS zJ~{iKHCvE7W)(E2mHCQ9qesWKH*+R_yl&opJt4=Z+F$96j!RDJ{4N{iQraD zSQ7qmho%Ki#~f!BHlL6vLS>ihRCt#(Ef#M|G4alr@Pxn&Oif+kPqq(k?7QU|LhiC2 z&S*k{S#?`{9%F4w{E+=y{8!PpRX*>sRmgmbt{rVUBU55vYUu3+Fx;f;8rPN8-LtHl z--Wj&q>9TvRR)7yV@J9Bv%k4IIAiQs`jJkaOmd~s>@KMjRE*Jrilz=_q zJ%^Kt&r%eZrltXZ_U6p%i3gYOakH{*{aA*ccZu)b2gwHbSI`+BJ_)cqR}ZlhRk9~C z3&07*-T>U2nevQqYmK!7Ilp-AtihAdS3htlt=}tGW6Ny2^1*4k#fML}P~Ek_dRIj@ zPjJ;s3nhh`vTjv7_anrN@iPk&3W9y*d&i5F`B~?F_hKe<)Q|GCghP)UdJlfeqH_Me zzJJ>cRV5=kV#c}!&Z#9t?t@Oi@sNNjR$jK3g*cn9TL~qhi6+z^H!6tK)wF^y+B5F)*2@s8o#cy z5`HqBJai#iCiRZB9W7)p5?r{`Mh~ft0Kt>@uAVmOT2uN~7sY%j&5DAg`m!(xs|5(p z>T4K2(l#n?jIfTyi>X(vKsM;B#{E>PvNHF&0a`&7**U7XC|so0-5+PI0A#<{FFn9b z7dqMi3fFR#{$4N5m1vH|fx*^hHT@x=HfMB_Gp7%%J`7>{@0RWgerLA1JazW03_ys- zm2Eckz~b8$@_$izKBn7nn%=&?NUA?QlF~V5<-V2##(U5__HNdJla9R2;V0*}xwnD9jc*m<1XK z{MURD<-x!Uks%=E`bo_m1)Zh`D70>R>47+l4F#KuRvE4#z<|^=ced*@1$OaSDUS-- ze=`8HBgIsf_ocF*N96n6Vr$^OG(;RX!)F$?v~KINvL~5qV<01I?b2c%jY7Z zJ99>M#P+@7vO&V>6wES$XeDMtB%^A!JPEIp{Xq+#sZb=2t2+%OrHkP(l;o^4t!j-`nL)j%3 zSQ|1&5P({6zUjOXaUgnN3TRApMryEAT1@2PUP-ZYOk*cFIwfN|!0)(z{OFm-u8)U> z_btL%Hyz`;T3;?B5;FV=BJNRJ#dO8Z3jX!9fHWho%q;xFd?rl-YHwJIUek%mpA+>U zb`wIObp|tBD?X}cIW`QKfr?@xhP?ejQc9{6MumI*sjm(Rw`cE2`I8}dRo1tj(2$Ep z>P7{vV*hOKLCY+nf{n7a8us`0p=|&SylADZ$|3dac6Xyr)^5F!!?f1jGgY?JO~TVa z*;n>K!pSj2rHxs~WJiIJP%Hi|So`B>gXRSCdBALE^_filXgM|lKM3rZKlAfv9^iPi z9s+eb5E|n5%ytg5j_d#>WdQLn?U42Pc>i$EXU;qW~ zWq+2CBJ%fa#$}Z?M(oG*(d^UYnlN9+dg_U5p}DX0zc@l+2W|G3)sG%B=?1tb&Z7i{ zFQyCkcPXPqgMBNB6yLSqNBDqNbIQbjOKmwr8tdeK)5$HamVWi(I|IGtS)=JGuk{a) z^?jaq=aO%@ugv)0i(UQ%a$>d>o8})oclxgHANGfpsHCI{6DqZc=}1&O-wo}m?3BnO zVgtt+xTmx+)H$gn!F!8y7(Q*#pf6);i9_f#KK@4wFjV8ZTX<7L~z;e%M z&sO1td3_;w%1b5&!>xm-_NE6MPR<{I68jE!=rhG41`odrav5vtB+W^=j7m(S{i3R_2#4@1pnQ2c z`s+G6`nkSOGInYT0m7}MLLTw{`i3ZBB4h(eU|{~`G%FyJy{H#zzrtVj>3g8^%J~=E zqx(Rj2^6mOrP~MxKuj$5a033QE#z*D6V~bK6Uq@EzEg>$!}Z;;Ey-mhKk*j1+x^5?X&U-e>m@XT8S}>NzvYSEfnA z9|q%OOVy(12& zIWXjqeg`2oI`|MD%nmvhgx{yZF}RPd`m#8TIV~6JjMSzmEJuadt zM(y7!5*Ju+9k6&+DkE0&ctkBKyvvK*U-a(}jKr$DKR}sl{Gbc_TO%_Tn(zC}$QSUx z*_mw(e1CV8{?zKd5RQN-92`mkwOZ&T*?KpV;_-7;ARzF2e!;Zu7GLJzX~uE2b2kET z0+fCV`;i_vDZzt6(VeGpw=eVnYcx*C;#xa$tgMJHp$>l#X7`$-ar6@nkwUOu>s&E( z&mV3qeGk#MFDhTg+OvAjmzaUkx$a$8gC*6}chi_4RHgjlA&1ddC{>>o&qs{3nLuW! zi5A|G?q|M3m>tE^0Of^hDM}?!=CP*bp4!VbbuWw<8CPW|p9JN@AIc980eMYVJ7B!n zD~MonD|^>8v#dq);EkFOGxfWnl%2M%dQd6=1=4dvAZfC&^cboNkR-(*Fkrfl@CJ-G zNfCco2K*?R(@3$1ilyJ*3yGVHCZyQzzvHO#omMe7GY;P;<8=EH(5i^xY+MvSe)ZqF zhAwcsYP3nOTmB;oWy0Rt&oqM@Y?(^w$O--F&BgS+scGuBAxXN$1Rx}9?)qc|v6cdQ z19v*4Uc8b663$e^0;E6?UTu*d)K223Y&}#ibwnfyb?lc|9y=Tabf+-+aTk9}v44}* zeZkh+K3oY^jazbm%QJsOo4z6RZgu#5MNfK$B4BQ3&Fj^J7w!5|o>?A%bb-Eilq&yRNGWU36deFcv>g%m$ zhJT3n&HmnTk@j9Zq9ue z-~f|7^3I>fu9U3@^iX5f#NJ30$vA;;WZAQa@|@%1d;zLtpAW-?5_)-L*O4_oVELR(?LMCHe_c2GxQ%k)Z^m?h#ZR(wd@5% zp`%DhX75yL70!r(iIOudNPA#!ppzdI)j8Me3?u@XB)EMBNasgf#)n0qRjw0~t=vW} zWqqqgXLJTXdV45wa!iNb%>(t*80h_^1mjjM>BQrnzD>dPHF?mf3jeU6c8-M^;7w~^nqWB*M#+v5pES?de{I)>3RVja(zW+ z&{6B-r(c$or|3~WjOqE@iQ-VZ3;WzuT)+P{c<6)h;~I0cJadEm37`O^0FBXT8w=pO z8cgLhL!F((SfxdYySRBP3rHJj{QHI;qqYwO8LUF=1C5nIXwRD&=9-TpK;lcM0 z2zg?`5FR^+nwfog`I^zg<)T4#rF7)W3}sTd7dNyYsPa;LLrAiQW~|EMwdcXbzdZ?> za2+hBAwVQat4In}IJ+)VQ~yKW1jwEM?!zic^{$X>ZAuk!>e&vy2NH81`ju0i@*X`J z&R=TKtX1>19}Ue`c7%BM0W%c?UtVE#wiWrBo=#7T^&a2@0me3kyt4y>fsnS4=JTsd zp}YAL!Rhw9*$zPBwtyLYl3g95BI$6-cAw>W_!y-f&ZT!Yyuh2}xVqIH0laZcaw_0l z)cf!0#@kVVxHh16FP3~hT|MWacOX@9JV1M&=Jtt*5pXOOgD5ciGDx0CD6h) z*O&~riKiP^-S`2wiPNRQ6Y%UcvG!c)-r4DLqxQs@PXqgxjmICl@95o{R@Vii8e@$? zqBa7cVD?Z)<$+0Z%!4Qel+F?ELi#51 zlmRlUN^Jn|G_a;$=pfpg0mPy2wSOc?%z&$}O$J2m&Y#H?J21Gi^@HMa zmn-Yj*I18pMybv2d@YlEouJ!QYVz*4#M=+wZxI9GDOB_+emMD+D?xtm;qA#li%)Ox zd&!9G_Y>%PUGTs|fyPp%4WQ}U=py`uLgiI=0byvmp|fDBNAFvIrjd_efb{K4^_3vh z*Howavo(?4or(iGl72KGC}DYPB)3kZZvWwT%F|~mL&6emfR~1e+hzfgo~S|+?=<^` zKzsa`*-KAgI*5@Xrezr^lO_FG9UHoCyKRa!G0Oj31A~|(t?*z)DC=F*<+XIibfB4RnRGTll-o;t_zalCh#XVrB>b*BO+8b8^q>5FDH$;f`fqf}lU?+A_-Lh?BY{97JYk;x z{^mix_~NAxlb#ngOYoU4X?Wu&V%e+s93chLA-)AB}4ykK}lH-|nj)X_WJ&Hzs%YJfL0m@UbsR-Hu36x?Fl1d1oq zQsBp;c(b2`ql4LZZixkR{S?39c&eineD6c%(pJ7DOBk`k+je@?DSxSKJwxibGG}XF z24Ei-^ZY5`J4z;8wKLkrBkjliiYYgu3%;CdeGXvH#ud}N`()<LEiA}le9t2R`teN{SwnWiC~xd zLRbBPig7+Mrq(KbXD^{>vy;W-s{=)`+n|CvS%7E)2KAWW^yAvr>lfZBydgT~Vt)rH zh2b>$e`pNY?mgEty!pI>A$)O88d;@JO6lH4vO830P-kilUR{T$%NHyaRok=_%( z)e^{*Y;mDdb*a+*2} z2QD=MQ>xmzwxiQWFOFU~$9EjX*4!!mxz*7yiW&uL{yYa)9o9`eJ)b6*2nNFu9qv*4 zvqu?9sE*f)nlu^KVkMZ?!7Hpm1yJSmDh@%^r2dJJ6GH%1#s+JOOzRDo&W{h?`t1R9 z$1fv~H9&y&X7;wo)>kF7_Xf)^c~71aga|W2&Dg!W2!i7Y;sM;T&LVp?w^=#Uel>+B zfL&HXE}d-0cVT|X_}@>F=Vz$*pIkf_6>_Z$u`H`Ul1{OmPQ_;6)b(e z>DiF2f0JlxMt?H)DS02uW%Tg!aXpbw+l9MRf29K!{*Q+sFp^j|>}vi&v#3%H z4?E2O4OD}uNSbfvK`U!BU$nu@04z(v!5h#L@i|>jEo3h!RFZ{$KHreIePiS8T>O(w z(8oCaZYkaBT030UsyRfhW+|`^&gjTa2UOzbzxyBn-Se>NMHhmg>g;P)JbYj%k;__2 zvJ;ECf`%6nEgAO9&Zfe_etMw@Y;J2E6ejGl1DhA7F z-M){s*rek=CFJM#R8#rDPzcj((Im_~Tp{(}?O_tfx;E~E&x~-Yw$=jDG3##Ebm~Z= zY34&@&q-InYo_b8q&W6*%qdvlz$oDM;H%Y{t-7J>f~E2)Ox8up7e=SB-&Jd4xg6&7U$p;)^b1gGi|2J%J~VKpVlZnfH6=5d%MJiw9*z3V#Ja70@;PI{;U0>&`MLy*KmurD zQ-vB|3mOB{nrsZj)Q1eDEEZm+epTRhL0#=@6HhpkHOhxSwPnfpj;9IF1_=A^?zjqX z)9?2tv*LYrw1cZ{p<6vgC$SfYwZI4EDG0BAUBcs)tJThoDz65TK=C@+mb znp;~n)br`$fk};T_oKo6ulIRhuJ>@=w>5Xr?a}L!-BJY`%rxX zQoSwKKc0?z9r^IOcf$~}ef^E|%|$ENw}-VO_M$Dc>u))q8h(0Pd zB=2mRLSa@_Ic=y5y)66=LzRTJ`ZyIL_;1$51-I7Hg;5KGu=@8v#2-w}mmvPn{@FUx z5jH$z`owi#eH>7Uji8~v6rE5pf*hQce)VE6?%{o@&+agtoFrrtp@Xr_2@J!M9d(1( zJ%%)dm(l#*Gn&I~nFqmGfJBd(koFYlGPiw@8t;M-|7v$C{%pAZ@yl;@e1m&j>Z#$w znniX8TXk)HXKZbh%uKCXpT$=~>q;g1#>07NsQvFj83-W{ih^=)dR8VB=Hlg-eD}(o zkazt|mtWr7fE$~OUEQM9UF{1xaJfWG5h1{w1ysrb85~@~ZFxcEq%IAd@7d{sXD~ai zR}r@^S%{kf8$|dsQdYmuva}!2HCMdZ}$JN`-)rw5L)vW-@HgKuAErVJcP~@m6&s~dN z)f#dCcML=QU{-r}x|_X!Is#^7Kxo_kiv{5+Xp{ru>V?0xp2H(5-w^;(l<)-IebK<& zkE84DspNo)Ev2>(4}0wq-3Efd0F%!n@wwp@O>7ptMKm>L;#pUik#TPP{&MH#>AWfb z2r7^izU$$hh~227Hh0x^1*sRrE%jro(f}MPow{;5a0PtgA2uP0yyefp5_X+?@+Vt# zOKzS-e<91aK5eENHJ9Z=b^$!qk_#-i*%NKGO>I*S>Z9UR8va~vJrArR{NM}95!ECM z3EMmBp!xr{tkDbq-7}PigW3n7D!|!3ExN3rHbBqG2Md+h2_eL1;nW{ORYchPwjVU( zvRlUvadV0az>EO-O5u~9Uf~m%6#@gmZ|RC;@FJF^UdFl~OAAhlQ6#ccN}mCN(|)98 z6U-X$;Yp4E$Jr{jcfkzKiku~H?3JA}3V{o4u%0ZFG&FQ;luvp_pp5960#*1oQy*Vw z7v#tC@q0%~%(;_@A>IQ-j@Y6iN$RuaXo&XldezGAkTL3aND^MehE9VG=p2OB!>2H1 z5ShWJ`WX#e8~!gdJiQChnSuy$QbW!_Us7L@(0LZ*O?ot;RG^E6)KjAercEXS$64(4 zlkr@R@5w=rN{gJ1o;-wG(CW$gTeK8y@S{};?gVPkKUd;?L(TQb(R^PWCL&H2ZOLs^ zvOEj$>urD%U_9fr5&w-9gl=ArDwJ}(QGIEtp+KppuO*ed>j3mDIS$~;^Wi_TBFqAF zfwPS1ok=nEpzTbX!z>Eox;QP~1Z2!*g(mzbpb7Y+fz?W@=1WrN22S99>$9^SdF@~; zF1}Z98+*!#-7^gi7zq&4Zf0uQQWAp^Z^!8i3YCW!6u`m*cg#fy7K-{^)FsRSS21&X z7%D*ICya|o+Har7VA04V38?{Q0w_yz`DYbd^AWdk???UeicTqU!eY7IJ?=i2G=#OM zunj>ZH-$<2|BIN`BK40&*~Rv^0YJz+YS3ofBD%eL$_nEQbUSHOaauDT#YEDvw@{7- z^f{IjOsV7^bJf-gR^!^3zcYq01x6tWjulJH|~g&tnU5SEWPaPv>h z={Zf~Pqe^z-trl6WZB|TUaa}*%&C6xw&2)_6^up6_0NfqAzag=zq2|&A%AVo z{aI>{Bh4pV%W5fbU{acyv1#h*Fim4I3H~OyWH}W@CK|}`RBHIEE*WEvYm$gpbZ@@F z18!ff)sp%ENPVZHtFwx{$K8|_@Q~n{Bp^|oxiDHIHPQq5s{{kez`Fsi(*`TysdQvD zaIIHhMbbQ|c>9$ww88l+BZIjl$W=$o7K;uBkW)}=o@6CuLrDQD-3P`IAm!z;oLa47 zqhkR$qRZ4{$T=~=PR0&prNsUxAo+}!h^JQjj%L5Xx89KZaSUNa0ySa)=dVsf4OK--Vx@AU73c~lPwBS%xtKqxjS zWH@cH;<+HrTowl%{wMzBAx3E4XqF__H4L*uC$Vm;ia6(BtSh(9snO|IJ(pDgT`K zB(-3h6g+#B7}|EH@mEU=MP&}Zs#CcP3>g;C%oFek};00DMPN)M>u?(|R)z|gFP zj6ygTJL(a;w(wrpW%jiba3h>&)=ho;0#kRqG+UL()C`#F;4tb3TsZ6*lMGMu@DU|? ziv(OWpH%4G2eo*@0|W}uWZKtTKh%djK%J>^9w>;o_irkYw4J6QUrU_!+VWc{_XjT= z+jrFL-S(Ssxa++?znp;(!VI-)t4$}ZYxe5?*z=Bp(E2HwD1jc1xdwk?r}Y|tN>pD zBsaEo(xf`pz3-?$yUPFh>G~zh1KwB;^a+Fz8d7W1EDtWyLmGo`z1!fJBkaEkBfo*M zPTeEGRUI-Vm+E5>=-*rbPg5x99o2;9wuEopu9McEf@Np*zSKCo>O(-oy^$k+yP7s8 z;$Yst)L%@~czU@KH|HcWl-82BUiIba?0r0t^$;>F>mfX(?O)^WoJ6Vn{QnChuHd1T zP1&yl2+0x6x*AbngyfrSXLHw4o~F>S?QioN7Ws>EJ3gb62fw(?Z-0gdv(YCG-%-zU zq)G@^$XS+?L`vE!_S_RmKca=A0XdO1xvvnM%JN%peHQaw$L1e|iy6et@ZoBYls16GN|eFg#nK&-M-BwdFOSfO+mSD(F^nhKHIC>>UbBl zeD=w#$GaElp2PFk1?sV_V+@}yzwk0N?*7$1`Y$c6@E4?1xWGsACwew_-P|7)PHFa9 z;HeC#!v_tFX#k<{YcULg43iNrG}sWJIBA;4J{fgmn!Az*Ml!b-#g>?9&*esl@)83x zMPSleAG6bzuggpR6&FBI;1N9Ut5KH*iZoq`+;h;@&GM!d8a;#Np*8HKlUmf?Ft}UP^u3}exHms zWEaAo|OLFreUmSO0qcf)*Ikz8K_@*HY}@y-RC;E7eY?R=S*;#%xyJ zWEkD$X;5S>CcCE&-2VOd;Ul!*_0>7CQ`TWldOn79OdX*olC;$6_+0_#{EC|=&c((ofZjVtDoGa59kGQC#BOjv zAAr6KYw}znm>&x3FBfTo0D}bT)Xjm3#l2NnGKjM~y)VArGI-X2?5BeFX1bA15sGn?%Cu{7lo7hY%j_rRz60>WTjBPx zUb(e3U*MXpq&q0qT{N<};`B}yR0(>{g0R5hYiN>F%bhITJ#oXU(0vx8WxaJpfY_br zV|O$oqMgFM!l-pKYB!sgxEXSKA$(8oy`8>swC&_CrxU!cpJR8Y49pZ4EfM!BnpE`= z?60kUrh8aV1gOXW%7G;E!?qzBNCY6iV&uM>Gh3kV{QBK36lNnFaE}yJQ(EZeBj>}fi_JX`+dbP;hFfNE*+wCOcH0lT`2KW zQb`ob!(Lka^_~%R#|`nWw^b>g&xJMs&BwH5|I}m}_D_n84cS!-!sX|BCxb0xj&wdq zun`y-@t_qJ_&|gJSHC0Ou0Kv;;TRu=N#oHsLiZJ1VF%+uhnVH5@lIeu&C!`_XP<6` zDjLJ#YGM5Ss@Y1|JTL6(UFWU7lKjDM*NYivo2{`_Te6f#Xf!K2NRlIqP#N@(=ComU zpmguY780@SQ6$@cdNl>v8GZ-4AUf@<5bBGLdB+Bk-O zOOo&lU@*9;;#tr*0pP1(p`$;lU!TAoTYI?<7*ie$FDc)e;ql&RL6g07O69 zH7S**802(qMufhehcf_5OrP0~GFz3Wm7-34wX-_8$`v*4<=M07<#3%{XO|xFEp63m zo-PNop5-{P`%tLIxq(loUMmS#EbV2x<4qCt z!{-^-XeuxIQdZjFbv?R5k5aGhX|&Sjcz+`4m8in%N4(~6DaDgO-?S;*NOHnkdVzInw#g1-CA`B3Z|DC7?z&t-_dzUdtu( zM|br*^6?Po z(^z=hQJOmkn&Ty33{UXsPPgCAkZG?0x+COLgkYBb%235FqcsVKy>*RJKOPd%tv3FF ze45H(l;3fb|EPjCj(7WLhsg1waqM1+I=+0^@#;rjdfe=KN1Bt#IWS@2^62C56%>um zpBh~xT+vQ_+8<7AjBZahD$_92vFCoDKjuWUoKgYdJ9ASb%uZC-FT1Rk3cYT>Z!Sd| z@M!CeZoeEb74aYV&U7~Q98WPQhoC3N$246 z`84cf9o&(!Zy>s@3C{F0f)*I}6>`Sm3juFY?I$L^S6zHLZTH5$KV38()bQH&UR0i2 z&mb*)$D9aNbQn;?nw|Sob7!(EtTp}JoZx`dow2p_O#eQF%A+QABIs_Rr)K7G(o|Ku zp#vkmwm`ADK0gY5`4=@}3BaEgooDV`tI^Sz3IsIgwncCAd5gdN{v=iT1Dsn1nqobR zN^~Zc5IA0bCZEjzhrZ((wi=`{v@Ev3S;wYJkv377`@31yx%`zlo{epv2{EY{`C1{^ zWe@j6<|)oUztql`h@*CNJR>uQ|D58kI>`i?`+LsSQ)HY3m~-KGcD%qjIMrTRsGs~! zfA#5<|8gsPcPmYd{Cm=mu6sV4o+UY#y_UxW-4IL=U;|3&a+on1_q`slmMW?a9vj7I z*y_m30xH-hR)-$l*C-~6jNFUIjTKF^f*qYk1+N~j$Gzy|_^BKiL=g{zy0lg$$isvF z1&j{lM$I1-bfSS=VvFrVtF$PgH;=rC2jWnJyov}sKZh+RE(Yk6RONZMsqlk(WL^&C zHt+nMUJXXm4vTz;uS63)EkV9I^NJS5t#HoJVJ8y~S8e=BiH8DGMMD8S?;*)n){L;- zre3`O7OoTr30wPvgagw5W0g|7kqPJNn=Qq=y$&33%%GwF8nqaKB!1lf+NOelq*%r4 zGBb0x!1KVE)mxMsO|j&fiVC$DngOB`^>rbCu0o$d9wM zFl~^KE3;)?{Q(-PjRoBB{RWU20?Z4*X_tXBx2}2KR#M(EKiEOBOKuZiy?Z2ebKAk~-CJ6e7BUSw+y@@YV8lfW3LnDl$|OG=7`G_I~W&nc|n1X3lcC@x=}C;M+9 z33`juA#PB=L9KqTi$Uc7^WIir`ygU2v@SNY3s!?|+I#96^%P5Zqck`mMZ3~fm)HWX z0zC-OKQQ`S|M(2a(JjcBQSMOrk87QV;4?C>yW37D=Z{N(L^5Ui1atmRl>pB50i^ef z+1tNNFiC1+5D;fqu0fy7y>{tD)1E^VZdGAd1TSHbvZF64dwHAkiA!!J=%~Q|l2rBL zT&Jaye(f>~Ln60e*ClYj%ka8W&JKxqhrd^=NmHBshsED|9mgLLz#kk?$eYJkT!X|Q zbL#hfi{Eq=DJ81>ck&xIW@*57i5m{ECKl2>+hVC70v6XN`d>PgzwfsvS4{M;Q=sA}m(s_>7oGabe`&kk8ncdqzPaVz^rKcG2j%!9!RgjQ_>KIYqr^d;6y7DD z!Ur6h4Z6$t6^B>~b{Z8Kdh3^y-yR; zsIh-gy*eSbK7rY8aI5=LmlFY*Bo5}`!Hu3Ymxd`$sEr%#nnO!l;N>I;9V$R`JQy~fi z@2&lS0nJO%#N2~`9M=%7R*vy*NTm`9`QS5L+DPR;we_q2)&Zb{>2 zCFR6{(;JG zI~AX!PcZ3i+nDw*3f$Ol38m3lzMBC+W3Rg(>c(#1UqAc4sj}myLOZzYbj2p`3w?~p zcTxh|W9qAr2KshfWlHiv0Rh34tNFNQG^zF|7JtpDyv>D917=fshGKK!0pWF{&n>V0 z5N7hZG#wOEC!Pji(rMRWledu2uJ06d<>$+ZR3zTS8Nn2VTs>@Px!?s|s-DjjL~jGQ zB$p``E(|XR3KJ#fUiFZ~dYwg>%tPd=y;tG|tky1FGFsh`X!T{7JTFi{-!UGnMqSzZ zR#k@DY>u1E9K8dReYG$TuiR#p%JVFipp$~iR@^M;vE2>Cx-y~cnm-7JU70#r86l- z1}Y&}^0dRuzx)Kk+_00yZb<0<+hHZ@@$8TEICL}I?}FQ1w)&FlV>)jUpP4X-^A^&G zjXAI0+F2wJ*If=fZiZBLvG_Jrvmd`@C>~~MrW3#xKS!F0e8+~!JU84^Xt2lB zQSp-e_RR*R=}+`7P@09Uct7lEbU{6w>sj;puP1C;@1&evy_CeGR9=6loN@N|I>HMr z)IYbrR2)Bi8*sRi^a%+rgD8BCn}Zon83L}Q^$5dostoNPMZ5u-UMY;Yd7p+o`V4&0 z7OW5RXSu0<4!AA?Nm6p9fO5sCfr-)#$u`)+VVY(2nAU!O7JnWWnIGhpO-P>K`+z}_ z9!x8oMlNj<^T`(ld}oL)*^bLU4;1N$K*~TpV7m$c_QV13zs-)dJMER*2G;Cwv>C#H z9D&tiUp7H+Apyc^4;~=Y8TJA2!jMrZ^%6k051&i~IMB)<(44V?cOX9#$bqY!ZRtBL z@T~>@jXQ-+IO6oS@aV&E!3AZbI}FH5@5wcTgP@m@H;c7awL^q92HuL|XUUH1q>?|x z_-WjE5`Ok3;fiAdK}*dLYR2E`PWoQ{{jZ0zu<4LtEQC50Rhl`d__;(JguE|x2>@lE zj!1nxX7&`)QzOSUBGt~&3A`tpOL>N}0T6*QV!MTA32#PQiST`%=Eb@YjlMAP_;R7J zBh&SZ0zcumum^7pB<~kqN9zePK|;hwjCS11r`qoKw25?EsML03*6rMhIi(FH;yMjc zq}57PvQQbbo3j|2b4ll6O{8Og_QE zDUg6wZo8-cD~a)&^JTnWy^5DjXsZk2mw$a3YtO61F-T%(&Yo0nGF;iQlZ3Xoi<6(k zB|0!6rDOAkwgmp6#%lnCLyW@QP zYNOzat^}usuD=%{e+ra2-KP-h5)o7;Ot=gn3kr8N-bgnf4$`B=g9utu>~A^}WYv(& zV@vMnu6L&FB$gfNCdhoA;z)3ehy3>7_EVmuM1(~AT5*_7c>0U508}q zBbNPcSrAq^Cn2}!NO$oyXU{Ne6=wFT>qQ(Sf5bnyuH%)|WPth(gJ5$xxj@n;v%gTc zj2wzmR{1I{HV7#GE@!S+2WwY8-$m&-R&P617gw<9-(Xd5w+%YkW4m%|Yyu1lAe*?= zWOY-Ae5j-9xI*5!`EmmceHiZZMmfiL=%3)6=!Qwd+fG5i!yNh zxvG#qOFgB$z<4n1G!6aDx8_eAcW)b3_URUXXO~hKpyX|#>0=z!9K8moK&b zzU}xjGa(I@V`-EM<~c$tvCFyPaJ-f|S}%tJp>*bkKvSC}N?rQBLPhzpab%-YU(qjZjgM0Av)Y^si+l8sW*FWgA~j%A zmlVlEeDK$K0t55UQTbq<8c1c!uWm#JHW-~3joKgI1q=NCV(NjBB31Zo6sw~ZVhX?A z{K{d&FBnybEV)EJsunxr84bPRzYM31t@LWx(P`4G{Q7w>l2U?EI$g2bY;oqbZ@~n> z8F6?qH9oZ&>jChfe)NGT=u;q*7coH#lD(;}67~EC3DY@|aId}mPH?Ny-Z#R{zm6F% zaJbpNJkTh=XrDMp+qMB z)8=ZO`Jz$Lb-oL&KU&X~jxmvZRdEpuSDhVPUmGXx#Ws$O)z=MjQ*&{-nCgOE<>?)T?BbaU20hT?qVZ z0aY(Fc+@Z1o1t=nGz67*tF%cD^d=c)Z2X!3bhb==$>FEt7K_l2GznJU(`m=$&nweD zu(=bZNvE`JU-OBXNHn|XX>lgB^RSS$yf&r}-5%wJ_Sp;${naI3DBlv1BZ> zX`a=vIrLwA^C`r>;olCY{@Q>4YvzQ&ec82}vO!w!f_~)HzQa+%jBBaau9M@Aih6vm zE6zc64yi>fwY}}I)rwm0i{(W-I3e4=mpM84MES^@7~g%}q{06a*DPD2gE0ODm7%83 zwn|L7Ijg7$Kl=Q{+J5i=epBJ{bpXxTW+?PdHYg{|_oK&R6 zUaEE07j^?Y0)s11gv@`aXL#0DZDMPOMt>NSqd#ml9Eb#0d5*vvjf-X!lt{6bAdEUA zo|Gnw&y=X^hwuIb>cMb{hi_k)-vpR9*MFvfsi2nE7~1QdpXdn}Y1AKp-2vW%-!9rp zfBf1G{q50+p8J8i`+$hufiPq#P)mA z?VvZv|6Axc1yIm&jY|5qh%W9(v_3kC>)2QbB^NQn4@Vj7q5xdXL#-b zNEEVTEv)j#`t;L`w?FW+Gr`2=9$R;Vb2MZZN2*(!V1t}1mp07>B>0GV_W6sNyu!&i zB=HjKBo|RaO1v1d^|@k8*r)bU|#? zR8X>bX6`-7!pzX2W5%K-{G%JP$&bet+a}3o#`EJqy+BW2>(c$DAU3{EtDr``z$;P5 zBOq@FW~lJR=<~|t?NQJ2(Fy~=OFCW|mr~S+p!>cZI+d}Opn0yWrg1BGlNlT*fW5{7 z`h*FfzzwR;X>-t0PH+IVf4}R&keG?Tu;j|NP{>=h zOC|VCPhAE6&B!Cvi~_jFmY2Yfwn^=Ihsiu+e1jxswGLjYw zhj#)2p`+CCL|E8S1(!O~EIo2G`qxjrCb@f!_S9;^&0Q&f&g-bjH$}Gu26sgHqQH1CA075jS;VE2& zEs}np^(KY`dC|_Z*`V-gsiuB(_PJ7p1<@phOKpGDF=q_$;lD}X@_git#quMkGCO=}iG0CBJmm$*VUjPy z()*cUp91^F@CcEgHSu2O<~QUzX#PxAV&m~I%Rma>;^)tn`?dQGNnJ`o5zW@9IpcL- zzku45dS5K^nGdw$&BAwmbmRBU6x#em@|B-mMIR}Xgrb?k3Cu}~WS20ov))NHxxubg zy>}`wp%UJ1BrR`Z+aWfv^}Q!*;N%)ws(x$bzyj%|wBzo$PXK`lG-mKqmxDhGG8g(G z6?#jNB>EkSb03BOx}(p%%%*9cc*)dVVrb9KbrVh-C%t!arD<`)D`B0N&2&)0Jk~tT zU9~piWbq@y%_9FYNLMN)%2WiBjMC1qG3vQf5mE-!aB@LO-d_^&By^pZEqocnp8}Yy z34z5wd?_D3I$2Ks3Y~^TcsW^)Rl!9o*%(=Q3`cXT4ebddG>gvlCa+5~(gBK+)WW>r zgXi*=+#>y_Mb3szku*6;k-GpD*`;UG<4X=UTRw2qjvLl95Vj)ljne;VXYDaDC%arr{@DKung*=y_|bCWv}1-0W`H!kFC?Z2QToijpNCM= z97{*su<`cR@nO$&GBHsrX4_9~-mrt`LIuhJJWgT`0+Ml0nC(2_`d%_>f-4l<7iI$E zz3p`iJ&`p5ZL#$^`0dW{!^RIAfiM}uk3ki@>#+AYyyc4?{=v(qOh6|3t_?jLSlduu zo(!kC#B}Os+oGZ0uhYraJC@R1heukwv(6YL9>xwbfgyPu&)bSbM6UL$yx1rRc3^ z15v9s0>CUSuMtV2rnMeT&{F!1C0y&3@gB~f0_CYqYsU&KR% zeJ7t8`#^RwJ#n~yVSg=s=!TVWC35AzzCK!o4YWPgD?@!}57o-5ZdNGW4|Uo;<1oEz zTuzjYMz+=TX4JC}-kAta!tZ`aEcl-L63b(h{bv>Y@b!)X5CIzxzy!n+yczbJ7HWEp zv#x#^^fFn4aN_!KLP?kORSKmQ{3+6`_*W|G7Naz#y6IxKDnss_+@R%0t(g-q66Kt1GJukuL7@9}XFR{C=1k1vCOpSkb1Cpk$90e9@oLX}%T7+#FF zp^_v(ViR7Y!QrpgESsy}%+L`Lye2O?-bdY%BjVT7_a6ayBqa)%Lj8(IPmd@C0e+^* zEh9{0EEm!OWZFc3ibciZ4Nz)_BmP-d~!_NZ(u) zYN!t0lc|w%p$#FO+%Tx4n?xgcb#x21VNi{j@r$Zt4oa#IZGge={NC0d<4Yq{dJ--V z5I1f7(U04QKQcd09Gh~ab8`FpJ&(sLI8cxpizq1G5)^h> znF)qUQ(&+SftqmqW8JK?IrUkWW}>jMX1iq^TZC*?p=+8=wbbD(2FSHIR-lCEOevHt zMjfE@ccom5GmDRH|L=z8$X=Bhas$;S6@b1aHO##rLI)l*Mt2)g`ifaEd~N*zY$B4R zbf^hXjk{!gd##QcZ+R~cM<@f{%UZo($<&}Qm^(xk5){OqOFIN8FUSopXc7MJeRo*k zk4mXz(zkDfhzW@ki`vR%_UTGS;`PzPB4c=PGNz@GH51&+GG#LyLn0sBl7(JvPHPZ< ztRyk37p{Z%@*T?1(d2uOF%VQ#*Z}3~!^evJow6*yf3_mOw z+0PGA00^?e?f;7dJ*FnFIP3WES&H)z2d;Dwh9)48F9t#sMP67@f7y5y*$DN2Qqa8! zYeRiS0TV?Ht2Qh$(QT<#EQAD`Q7FFdsP1($U7G>^_2UR-8PF(Pve1w2X{=^B^P3Jy zQ}IBy32Qm#ic76Em~S~k1Uiww%Nqc1$S-d7^Tvd9T>kWzW@1?es)~%tPYN=cf5aa^ zZ5a0H5-YD$WPEb+JiKt7F1G+;nGP>aCK7@J?5LJs_7kx1{z3sV6|XT-Ys}iq<~y#O zm#Mqpp`s}6`L~vdKW3F62!%m#9OOC0JhUU+a2cI_M1=Q!#|`R)^f`sWYjQ@j8E&#x zB5n#GfIav+*9u@W1&_*-F1;y(k4G3|(E(nF4q-3XIk{JYaBz8=g)siscNvJeixwo6 z01e=hdEQ%!*i%Zs%Uzsdqj4}lmP7KCpqYNsL1F!-l5=Us18Qnfn&t0i;JDuU3Sl=B zYAMTcZJ8)23$%u=jO{A>lsdMpA}L_Xyu)pw zEZh}WXI4;Pk?lllGfWXFyS#*#fO#RjY^pu;+*)R5cj$+$eYf8S;e}RifduDfgN(;? z?0GQ&<2O=4fSNN2q`BvCj78fDYx@E6^|9sR!UEt6LfzzqehP7ZLp`7vR|uq1#!5mq zp%BEjWltNBv8GY{CR_D?9Az#u(?E^0AUq_aQj7{x4@$wd6vi)e z@C?xOx4~TMq#xL2=p(FCgKXL$`|m@YDvz}!Yd0nX|@z_Cbtd-;C?qU;kO zAbLwi1|&+0CjG$ao&(+gm)&-1eC z$aYc*DT{9O=Y)E?(^-c7IR1uECZ5hO?UCRC=KTL+A)Ykk10fz&up(u_{bM>rIx&H| zDDR>HT_q{cP)}=HAxhNjADNqFCjYnLwltG9?$=@zN>IygO9oAG>*A9w^!ZH+{dR(W z(~}LN`|o2s`V&q6D$t@}8#fzRxV=t!&s3@_fE^ymY)mXm5btjMy=@x{ZT34e#>&x+ z0JZ?7;I6z-!8*^^*M2ADTY-PMk9!wUtYh4o;&Zlp^hadsVvyi4gdYeLdz5&1>`SRb zf1|$pz6y(M6U0Mw)Jk5sb$a0#RAM1SEkH+&Y`u*yE%#agqK*AZA#_@+Nrj$QHYz9gzBwCbfPcmAewffC9C>`)|J*X5lg=vzv z*&*vXCcmsKbl03P;WA2lP3&7wz2mh!moznjJRVSmniJu9?}lpc`!c~A`G}K->5c_0 zzKZi_t2R}uof9M6+UF@6Pl{-(+8_ECY!H{bS(vT`V(Z?-&{%@RdKD;n(;6qXrV9~) zgi1NoY1wT}9ANUD%bZv$!OYxN0!i$ZP1&%L0j{f8R#fqTlUCCdNY+e|>v?#=kZnj(R|4VV0mOO*_La6w&U2S%Nw} zl3z>`+76I`|9@$kAHuPONEmqFnrn>3UqKd03)z4?6W!9JOv;#%h{qlUtN?C5>VI# zAz*`6v%hPenMu?tO#Br*-}47}##OKWjn^X?v#J&3CA+SdOSng3vRJ)OA z*bE}g6Eit-A>!l=B2Egc;DC5@J~{cjVUsV=SRppkEr{}LS_85WB~(KNG^!A-$w6FP ziPFoR=qoNb{ntqGDgKY^#VkN4tR+U&e)&t0X>+w2RHPFLhkL!Zn$9B&ToVq|Y%Utd zK&Mc6Y)9T$mSCO618S0F3;^^D#aWTBmjL$pw|D+%w)&(FG)t*mXd(FKv!wL;|7xd! zn{A<1xh&cS{H^|}hi&`u3$bmS#~qSkA)Pk)VXBQD(nxwa}tHIV2P`X4ny*APHX*D~HYf!paQE>B&+WfFV_fqn@MWg(f87ru-8W z{QtOgf|2ZQl`6K)SGT1Up40tPn-~7(F)~9Hr?h@s7861Q3Mn zz_V%Rg*?n}Pyiv1^Ci|duw{xHcY?$ZCi3p#s$kK{Cu#I=ZL9|>S7G70dXGjyzy6rL zkr?XyJUb;3)vC zLO|E@yzC>e-QyTi@)WRRKOX|O3tBO z;HnnxC!>K?kfnE=)uQku2EIUG9)1^dz7SBxetn$<{&3yVi_VKjrEEfdiRiHkRT2Z= z7b0XI?D3F?vr!b0csY8i!^Hp8hX|dwh+|-gwVr?B3usa)Iz25Iusts{650 zeGpvP85;k7Ze8_J<5~!U7H_~O&*K^Hk#BDN>Ao3YDPp4uF>lX!2JB*<=TGn&wKCiZ z@QQCOZ?a@PN)5RI5Zi*tcD##?f&yu?4bMb|I%8kxX%B&tSX?PEdo57AU#!R*?rFZ7 zg(Ue>XTR$_*(H5}^WyuwSO*Blv%if4)Wd+NA)Q7U{d9otwvWUErmUC5q74R!<{*T} znl}B>*Ry&A6Njw71`LRwJ0JiLh%x`K*%=TxWQTuF|8K<#Rrl=Mf2G}=UYxJLZV?Nh zM9s`%@W*^d{*0em=>Jw9SRrW4u6jCovhQDGwr8plJJnD1K#Zt#|F(#4%x+`zVCLkrBu+-{HeZ-{&`9KX&MqwY6 zmY*lK;rK5f+TY@-A_M-JKyyA@5?7xS@<;tOdW3MlL-RtO)H*E~2b6~|ycqZwL9{Fb zdb7}PKooxdU}TS40Ue5=#;cTCRvMcGek+I<|cZfc*o`iQ%EYFA=7YM;9qg@LvHwwNigt0ppj~fLVzx{@5dSf=|K-T+b;M7 zv1#<6#oB=gQmx>N0~1hThaiZ%H%pz;eN^r)P9NS{+>d-CeFCju!pU%V%>MS518ry( zPTHK~v9=1!cxD_Bd!%8Rt)GJq8QDPyv8n*2nowGo$Pu7k1zJYH$yzup5T05JtEiD7 zY7JAZ1Sd*w4{s6&fM2>G>@{T6yHoTtK!5+RQG~#N)qAi?^2Y=(W`6GVG$sJjIsHgA zj6%h?CCh6u5_RrWAy^MLez&0~^_8Km`pZ)B=d8&xK18`8kOwwEjkUMM2P)?oqh5e~ z!o_oF_7S2mI2XRbp!;vv_HLFe@~bYtvz?)jV?>Dk&oe6keZ#`j5I(pIHiZ))rK|am zF>oDkz)$J|jR18Y(sUv}Y<}s67q+2?b29c7KmXHofkrqKhy1Pq2ke2R_cO5{l>wfwFQS9$u}4C#i5JxJA@91kotHu6$B2a}3w!l4-1uB@LmO&L>NvD?qoC zzQYcWVQgh9f#xhBit$b_6a!?KrBdtAyr`;-0tP%l4G1UkWoFkoP(L(3t;B0W(6)?E?d2lU3Rtq0@Wz81 zXIPhpLHVFGKaPW+HH;L(Xklk*c$8v18SE9c#p(GEzvIRIOv{Qr_h}7zVBHUb?dJ%x zS3jjOTs6>f-sxEoT;}H4eS%B!0i?4&^S4`(udJ9zF03j6$BGqV-mL#TNOylLVP~$NPA>E&+<7XDGuurls}jG5$ff(?w_wqOd|t5UZxDENcs|F7lkp6` zpDSl;)fje!Rv2ot@NxVxwRa`{7y$D)vbCSG;2C;)Vx4(lm3MZc0;mF0Pxw>-_*T9# ziFy6=czezvChZkO&mTAkUxxzw8ogJ%K+pbQB=^HQ8|E*UR&)(vY?NJvfwsHRwlGey& z=YBCaK+ur)Rcy)e+aOwl$fY1q!#MnW#GI~D`}$YBxB=9K(AE^NzBm5Tk{uucmHsyt zl6|AATvyO%ONg)_*Kdmq|IYV3)^R3p?WUIy?0NVmWu0){`5pYIWEK`rHv;i|AVY%( zKNQ%#5U2)vdItiNuRJ>^s?3b_Wgsm!^z!QVy-CFJS{k%u6Gh8Gor2A&T?4Wn27v_s zmNgG34-1=b#ncQBv7|Y%j{HGy!i;Mv+BymOq{ooiBju*l_@SoQMV&50_RVe!jRyQ%p zjPIEUdu`Ire0T@68|l)2M_c<_ap>$Dfgt3ElY`%3QS(;Z`gaPx=oz*CGKeFP=Vtli@k^Sh6w9h;aR91JcD-fCP5ZK;(h!MQ6LLTG54w2kct*V{hyV( zG2uG!yJWzav}z$iEgj*DJC0YbUuVj7y!D|+R9JYz1s9J1Pkc#DHllf%vpdw&4dAF_}zRD*C>%+jThSh~91s$6zx(8ZrnxgnKxNFUZMAM6@v!ph~;{SPa;#c*2}A`zU=9#@(JTHT6=LFukV| zOVD^g_?&OeES#8bdq(A^CZ;GH3H#54Fy$t3r*#*vnRu*{_WA&*kk6xojw9GLi1R+N zormAh%9}vwO0rlQ_pN)f{o9c#A?#h1@$9`ut0ytw*bJ1zK!DNaJD+s0B-p1S8VPFo zBxITg50d~@NUKk!!aQO_t<>%oW}g(H7C6POa^rH7--cVBF`F!tCy&pPZ?r3w5`8+7 zwFp>=`Zx2zRgYZK9fZePgRtLM1p}_3HhfOhCtEjzeDOoi-`oPUC^2E&KX(gqoriUn(8-W<7v186_t&|WHyT7IdthNP=tTlx_gwV zlB#@D4_-@)l&w9=VSn~}0BYNvpbfp5$(5-4H64LXRtSn}!5zkk_#N-b5GZ8cwLG{i zjXt2E^AKXa8eM|p3u{dgv}yMISXAz$du&MUUo8vuHVv6R>@Xs;U40VIt=02jSBwz1 z3mTW_;t!$+)ld@NP@6R@HP1cD_8e0>=>a_aNl!GA{E?C$n8IMv2({z>_2e+8(Q&xm`jBsLY#BoMy5H z-~c4Eml80K7`9f^J#Y+CgrP|R#PrKw1##L!f2w`S-gg#a=uk{!Gulo2%$ulzN=u=2%=-w9!05zZ$ zy=^Ozz*R!`5%X!KwMhuODjEfEl>O`WGmk&P0{FYs_+*4vjQ|NH&zETKfz`d%G&^NE zlBuUtZw<6+OlRN6IEV1J+THXPh;ZVvSnukx z@_$C5Rq5#P3|a~o?8^VJLWh8vX^esbD7eu};D#B(OCZRwtEtv|7;AS)ebb{RBa%6CMdSU+wH=E&F+>8-y;?&WE>Y z&Xky|s|-)*?%+4o4nn*}nMYN4$Tr+*;5MUe815QbRK zZLSDci>~W6oE|bx)DJ@cNtX^RY`+XgVMOv4zrz7GV11*ltRkora%$Qx#9HQ&fiUcst z>is#X8(RjLc#p|@d77>NU8DSwMD}M`mV-WS9N*TVxZ^VS%b%^bTWjJ*kO=~a_aA4g zHDpQMjG)~^j}M;-rQdcakp^a2)Jfhr?N6D2WCVL;S^Qh*L=b|R2gj_ZBXG%;>ut)- zIB(H`eo9bFT`tek;r8C_$c3)e>Lwim_$T|in@AFpWGmjbRU&UGp66XRC%hf)x^27e z!1k0dfskJXM-U(-26W9-Z3e{8$g{B#)JRjTDG0g?5?Iw`QK=3AndHoN+!$0S~SsxdilwXoJPfcSB z_yZI)8~#4v%~l!>NB3OMi$fV25e3RK$>NTV)JQt=0)by6ft#8E#!o}PKsY!P(7Xmv zhU|WP0cZ>NeOOuqeJ=zM#UHou^c&Z~M>*h1vh9h^$w%LnxpNz=?E4qCkE?f!@{%u9 zXmQDE!dU9dM`<;_%nNM|NJ(QJDa5!a%O60j$`*0xV%<(fO!nc;ehda4zHoE^>9(Z# z{vz1RNy$>xS&b zt+@*I3i%|t9+c}u%@Mp~Glrcft`FabSe2Fa|jO5w^?+-7y z?<7cK9z=e$z*{ZmJ2{?5v&(`51s+KW20>&t2!I~0_5>JG>)*?;rCIMT%B(@(#1X=5 zQJ7O1K=CIEJt5`Y*-SKcyg-v<6m?6iFj)5gSIGPiH~>!fIPw#AiL_2D)lNn4&%tQN zMMSNy)^Vb#3|?%E4TipmjUV&lb~NSdeNtd54_z`f`Vq5rvsrn{E!fzU6SaExS@~Rm zkj_m^2sqkon?5yM^1Q4jx>dU59iE?gBx(Og4KarVkCt#$0AUPZgLD^KNYv`3jo8(e zH(4XGdyJGBvCcVpQ1>Ml)Kce#PAVsWuY-l@e6%8udlM4|9wJo;E+gN2bhKXM&%|WK zEoUjRMU!kMrH$rJo%|H7Gk*cpK77($D&qvTa0=oHvFodbX}&+|aI-Qqyb8mHOP-^T z2Q1VHWNU9bn8VZ?%QM0N>4ay$R9-OV$AsxW>BOad;_Ko5)*l{CsLPIyBXVd4TWo}& zWZNr)RE!`;Hd2=rc{Xo-XSA!=?^$#Da5J)L2;vk{5-UjImD%_d!MK2$fph&0s&qkZV$9}N^Rhhj~|pyzov9uzN^?C_|FQX87PbA4jLsmgP3 zJaXU)c+y12XfYfUXrZSJ8}A^!V*P}mD{`D$sBr!auv~ZbJmUUB;%{kB|Cm9#6UAr> zQ-P|h>rLav6Aa3;D;>1wDx54AvR&d9q>4P^`7Lj$xx<@P(l&Cq0C=S$<6D(G%{ z_-lfUWVVPfH0#J4_>J7tDpUZs)^}+_MoB&&n5AI-sQK$bjvTaX@C6)hl{cD?d>!UW z8u5{FT1D}3PKHB<@h2c471Q=Zer!=-+1RPyBGB!Qw-vUSofmrVbR)q9oN2n2I5=*= zSE%y$n}SgPfae*1f4;5FV=u=965`-b^YjFe?-Xx?FuR+cjD<5G1|%Dq1++%jPZS4K zAiioSQY>&5bR%aw=?K=FxW_8g00s=8Kw3($wTqH+fPTu#iMoF>mQM)Nu_@PlZCHC( z*c;C6QcxtHM$9Vl3?1$&#KtR77t(ileph8aNKk{)_t}t1%*G>{J*f;Pr}K{P9Dz7g z%7L=1kTxgC+A2Y>eYyoy%|W*MIi}o>>fcS!diD4gMwKl(Sx@cEjT@#_+{|mC!jbU6 zJ-1mGT;O&dsoVg5F(3>n)*k>Wn4K8^dF|(7@4@XfAt7vf(Ef7$I=45BG6ef}iB??j*EpavHn@NKI@3uMdo8JU5P-1k`BHR?XW zOe1O4P-QVcb8sxR*pLBJlja9_ouw!kU}$ATx-gLe$b+g(v452cC+ta<#d@3h{M%UP z5ZXQfDEtjumfv;73%J#@<4aTh8c`mbD4j<0y8rxH-~Y-f{BPm`fNK&kTK6~(BS1r< z*>m?UO>I8XrhEL&!hBSWM82!j9p4=ixdg$1G=7=&t>Lp?ZZz_@c{*mEx(a+mZXkrd z`OM*sYUKWg z8!GRYbQ0&P`XM1Wu;${i9Rf1sMRt*O=JA9M5h<|`Z8UEpXlkUWk&bPM z6=(LCh4VK;t*Sk-m!T*tP+`Tl{y5Tn7^roQ$?8(l<)9nAJ?azMb=mN}2Ts-8 z1uhOscXC!$!_3%dUr!ftIDiP~frrGFK#A5qQ(#B6!kfqe@t|@A%}WvAqXJp`$?8iZ zv^(z>qh8)1x&49%gtv}B+0(whHqU$nW@|grDTMZ0+L*%4w-;a7cGLGm#spIWgT5bY zf#N(9u?KLzKuL`|n{Q^eLcveb+ZZdm_=^w0D+VAzbjND+mE1NC>O}ABCP9}r}>aKGa!|+Doyp5-~_w@(?4wMd6 z+ds>BJwCvPVJ|xgD69IX~6F*yv>u^G4l|5eNP0s2lzJCN+ zJ2>6+5AxkN4)a3*{Xn<99R*W`TZ>7A1U^2wEWhU6w6cO3^zh1f+VQ=gzxpnkH?Nl`FEF2 zKS$b>=h6w4-#M~h4zIqyqhFOPaT!#mo1{Cp=(BT>nS3)3mP|9<$-n47OJI8zNe@H1 zu{gQ=N0*gpZTwlBP5Ph04^8;SrXC`&o+{o8y*b13P)^-^-*pJW>(E|OfYSKnj^gEO zyjeX6F=>*6LfUG2Bejo*!$0Pb#U=rIO+G8>|1Gb&sKzh2yQUXR_~Pwci2LXB`{jCvI#jNWozPq z^EukW#Th~qSc`}3IShQK7}CY2@^E9A!(KaQs?+TkFt8&f{X);qqkfXVE+qIjDxi6 zm*^Vx%a$fME6mm}IXlkP!xoY0{{DzB%n78$KH$6dpRZSR8QD1PZ;Sk%*}GUv@n;zs z0OEwxIp8e7%QB+xW$&B;B?{J)xo}?S%7WVSo#-~3@Ba!o{>GvASxOoS8aD`P^MPj+ z3-8xX&=nW_;%m*eS^3>k>h z(x)d`-13A^IIl1r@F*%jih|KfO9bai#fL@Nxc#+eZv%s>)jF>Ax`eOKx*;X^3%K+K z*UBYlk?`uQ8h~F>?yz*mDX1mig}#33g1iFI6Gft2g&Un~$=qsmwCS4dvjU>!;1Mae zdop4UoFi`)0yX~1sIfvuE}o^7f&l)uUcdOjzr1DtPGQ(#RO*?I-lO*MyqMDNh^ z^_}?m;}tR&-}XbLS)FgDUA2ia3K+=qHZCCMV`F%HDI2hh?(oD}NKzW)_aF>OeBJf_ zClVzk8Q;2-nS&lpy4wL6l+Z@h+dFrAt@tN=R0+I+`IIEZ)&oEuaACav z;I2fnm?hquUm4q}<~Ovk8&Tnjz7+DPKkfS@F8G#Oghu%RueZGV0i z67Eb#Fn9$Qn;a6t{E3_=H1xYLZ>at8w}avte|CbaA+l~`ceymg+R9=`>KmLl#geV1 zown)Tstp=sOyI~B71iD|dLJcJbCU%M&+~QS?vCG&bwoQygyq{am}i*hk!97!SW+~{ zuge&45Q5@DP~j5YcYp8PV1WXydx$|pYj=%;2Na5ckqd5h1D5u8d`6u`>DQW5UB{&JaZ!qN?Xs93F(B^^@7tL$z4v&D@0nnUj zBR6Ls{EIwJ7{4ZudnBQ&+$!4pKR}7YLU{QiI_KRx@xybu@>Y(%Z`tmG5{n6qK$Qu6 zWr~a|iwG8AA+Liwrt&9lPPqG3!mu3Hy9TW21Q27@OP{tRjL95s8wOazv8`({$G??s zin7uHk%z*64CD|CcfRx5A6hy*R08w;nQv+574WP*Y!K)gqY(>Q#{@P~_+X;HVMrd>FKTbIxo$R~222d= zJ|WopzL3`{DEt);bWUa)F4m4d?{jhB&kqs^4Ys)Y+c32G)~Wkx<8IDSFT@-mz#*eQ z0S(Dl^W{EDe)560dHM$-$7;7@JFqPAg4RweJu5y(Vs9T4qyyoOmzQ`N1SFb!91fr@ zCms-Pz)K2d%PzKe=#ajWx$_8k8AcZXK{;^n)8^p=w&YIO{1;;uLv;F7O@D0ywnFV% zG%vAd>>dk9CLW%Dlx$G*(rj_vsujzI)o#`LNzR!LAuQf&p&Eo}&&IWd&Au}aSE+jH zu!iJB?H>zrq=oI9!*9td9sKPX(zjx4hJGy14efiVVRQKg>mABLmzs|8$4OSLqy!=e zVzlgQ;2_zAX8hma2cPA!8v66ra(kCB_R-9B#NzhWsvt`7Akal{8Qo3)Rz6^^15wYX zkQI0Ta*(k`yPXd6hYF^Ws(}F9Oj}a1N+wz^>N?>^tK!(<2`Pz*vw3QB>+z}Ttkb_1 z*vumY@JCc3Hao5X`C%b&3dwU`=$yr5BG6lk-HTMVN}l$^pg^+@u%G-0Lj2i;x43?c zD^g-yGorM~`obNawrxSkFG}3Pt#RJp6EqkOLzJP7<_~<}UE^=l76j(o0NQthu)FCm z#Dsrcz@_iZX*a_ataZdW5@e(Rxf1nj(eDN4{m%07K1$11R=b+=r?qqY0nq4ADxlV{ zcS?{vZ|4LiEfzr#636f9RBp{ZcFgB^;Ef(3Pk`Do-&d{3_ntyM>vwpk=j3SOX3n4? zoPl~Q)AyW^r)2Nw?&?F}^0BePNzVfw;K;YDzT9Z{!mnZ#Vfo*Hf7Ofg;?j@S0#B_# zEYFK1SHFYt6*QaS-O?<@lU*(4VbnR{n7|ZqxsD_$mjqxDpkiS813qBvP&+wz%v5zb=O_a z*f5H3k>0B{`n$HR#$U_7nrZ;UHlyc%m%tR%@)7J@Mu1jYi~j*GYM+FULFI}Ued~xD z8$f8<)^RU+`@X_7*hc@Y5ZDUev`Y}071~y-`D>Lm4wdEpuXknQ! zyhae3Llq4MzTZyYcizi(Jq>oqb=BN zj_}=v)>!Jb)nJ z2nG=8=|?{~1`}b%{APcFMLvfwGNHh3yx*|$N{^NfG`%SfY0gt4vLmS}lDp8~hWxc@ zZ|&}%QY zq)B*wj!|?6zTU{Br#*h3rAwNVL;=>Dqfj*W_#vMSQ=PL($?O2S#T>FPTyFo)2(JcM zHlT1~43OC&5A3Ho*2P7rynl106$`yklfd2=& zeAP+&fKyoG?GkWWG;-7}JlU(euxY)pN%fD3Q1@BXz7Adi1Fce^JMXIuygRS5i1(LD zHu#}tWxffie+YCXiw50|=GtM~5y&%PhlT{0qA5r({p8{i&7jDGY5r5>UA7Nh+2>h& zMN;em4Mb4S#wy?EnFu3OhXYpW%`rj2ii;w4%uOg2#SH&fC|Gt# zu?fV-!~21XQ~Sd~2;-kT!YK*QBRs!~m~eErW|$CfSXgE1+!iw^Y?q1%XpFupp8v4# zzr)Z?JhB1z^`Tr%I;iH}RWZoY>_4eg!-;zSp@y8-A$9Y1Dir?N5n9k+zL|4G55>qq zuJ(IIgZUJLtQd$R*&v^#vz#r#;t&VL0ZQ3MY4Ecxk}oyr@SMqtjidNurJ8uvV@gSW z*-5K-$A#xolY3+uF2vaL63jEQ9uRQAFMHwLmL#w%QOfCLg~i2m;eB>h(8UsPTMWI{7M z!;px}o`pVCCA5HzB3~OT+ERBy}NaxJTCk-=aTg3V6PPxl|R@x$?$5Hbq{Do6>R0= z-ZY}}$zKV1^CZ2eEh91zXn6sf6Cuu|DfEowqJvypx|##^-cl$ONWYEANt7@3B*CKA zuF+-!h`{Qqokve1TaxPNXvTX1EA^2=iV;_FGOV{tF!-~!$}xzYPdx|1Y@)zePMoyz zkvU|7d58Z1S%qQqLTDT+`5-*4?!)&7-$(BR8R%$JTw4&**ls#+lQ9bzP@za;@PX7f z`+XS8Rj(2lP6VilXlQKQO@Ta~>4}*#Z-QEL%}2k5`IuZwG+&F6p0{rJ3-OyXzVxfT(8N zPiG_7zx4X*O_bW?r|V%@PH5}F@hy94z<=@j#(AcG{qXW^T?n}G!4wXEgjv^`H-$%& zI?ODkUx`{@;>RLuiZ>^Kv~yYfUFmgY?=H^=(DmOE!Zgm{O$RJOXuU?jcsavc)C!VL z^8Er=!sR%!3ZP80JfE;1XydiwA{UF4G}1=@x)&;lB1FRVQFEU@^UgU%I~Dj!jN43B zFSOy-2wL)ntk`4ZFs7Ydw)VB=k6|gdm2#8-`Atp&r6k5HIc)BG=UrK*4-*Z||>(gs_)Nx<3(V1CzsQ$+l9c z(titI`KHT$B@IZ3ScQU4Ayx>1m;&Xt9S}uRos9Z{Wm>x%&1-5?i-OKGw@^}Cxqz+^q;^efhkLGvHC@AYheSWjLLr<{-Izk zp%$mzHSR{m2KiTM7!3ni79g!ZB`|1$`fXDisAti;}K%1wqX;)TXg=>^(u$92&W$KI>3mqL=ON)^YtVY zu$dd~A;NuM+#{ckV00+_#MHb&*H)ReymgQu#o_t0Dk2&>j*p1xIQ0(WS%$y(D0=)QuI4!j8 z9bCBgaCE$g>_}^X%%&gocv)C?CN9lIz|I zj%1-9B4`loe&cJyHf0p1+&#|KoKr8K>rvKTOfNc~NPs7$oj9cQzhvyDpSW&)U4 z40wcH;&{TLmuIU3IW(NbakUt%0T-fAc8EdiB%euchw19iPkGg@AIw;bEzAs(dfdX0cgi_fEvW}c)6|9yN-pghSK=j*5AhOjaa4x1T=LX%*OYG~X{qJN?w?5`U+Mz;ufz?O!5>o{=byL;2(ViCXo z;1epJASojYAG2rz_4CK!$0_42*?}7!0$!$kI_R2zRg&LRkwsGs* zYys)FiL^npeSEnYKrJu5K#19``XW*Gc96w4ff^k<=Y z@r%b~&J+s~kR{S7UO^P9$gpBl4{cYfl<~hhK-k@S$pv#rr55df6#?b~m_(qfqYtKz zyFK`YJ263H3uh0h{PR)=5;2o5EB+K z@Ji%KMQ>=9^nWoDD758FQQ%fVcq#pz;mi2o{7?l$;7!kGRn0oLulLakDG6aKBU1KR zuZ3K1(&ECEul{e=Z9n?pvIz>mD_pJxK*deuh8LW3JM->D?WdK*Qs@z3UQc)L@Fc4I zPJe0G{7)f~8c&vT>ep4Ep(lC&ZF8kY^a8$ZF?EhQi+awFcJ{C$IB5Ozsy8Q6hM}X( zs)LXIvO2jiU|-HY1*y>GwBLH|A6e$Ooy zMIF>kG^xE8sPSzc3=IqJ_Sg6;CpMQ`1H0jVF@DK9*(LcLIAhJ$rctePS#%p@xwCo~_usXg zBs}~)ZwC+T5t%R4YNh*X}(feJJJ<=8vmE#+qiSIMWfo1h!2R7zAT z-=F8ZF`x#L-2?N|Q*pTfRZ(!(K{Kq(x#m|b5dC0oXn}#eeZEgm2U^SBnxYv2ZOvu( z8k9F2HPu=ofF%2LRQw0K@8KQvKP?4QZsdbf0(lH?EbuQU>R$GYu%i?)M>b@dV{EW45pjB*c#_vv4qbsOfgmVcv@dS05AEdxFPvsbEoUe!!tzb ziP)&8lFcxuxILrWY!@~Sx(!|;*E(Xb5sHm0|{X{K1 ztU~esF`hISwa7SkV5ER1w2vV5-+Sk`&a+2Tvmc}!AoOsu{wFsK9T6gbW5vd|a4VdbAVxdl!d5=o?}vi^U@r5Craj z-?y~cLeZPLStJ3D9!i6)Y{A8G&Fh)YioL-+h5Qs|BzK+$g4*0{&K)Cqwka^|y8(`7 z4s^MDDap}xBmjc>W;K4~TSq0RqV)TUY{n-@Q2|gX+t)V7DRQaEJHgj><-fA{*#te~ zZd`W=h3RoXS@5FJq2jpbq6b?XBU#v2=@=ufEMECI`5i<5rD@byiz-qQOpf5}<+IRw z){4aedQ)7%=U!^q^UM8^D9l@K1#Iwc_?m*kcDw-f+5e? zgO;K>3Ba3IoE)ReJ;m<7gqW}kA&k%y&#)M{_Q=S80j>zRsuqx_3=Y{8b@HgvQ@kx+ z_a}<{?D%IO^R&}+1J(9dWKEJ?&1`O^b2iJ|nQs>oDNJuw+2C(6omO+P8ZGGjmBSQh zGx}I&;?U{eFV)e=Kvt9nS>z(t~Xy?aQmlct#)G(SX{)I!bYlz5g*?{0h|oW%ZdGRebVDRFAcfSSEA(kw_JCy=*(gkH3~LIeHgr>4P3$#6QnnHGYUor+$hP}h&l8}U4V z4=E&6T(1o-@B(3qt@{>eEes4)uI>ZzgoXrS_?w5ki}Bz3;|YM8M%$*A zzw|o_;TL7pmaloM(vTb1o6XLG^Ic95 zDWfWymr+OuOdv%`i>0tyO#`sXD4>5`pKD!o1CRxtCRzk?%kF9%sQdrwSoxRpLDDCb zF^8)wq{$qToh%(BJNtK*##EP03g@YG`83u9ukbFg*ES{;zBbztJl}aqB;eHiQ8Vpn zs)r3e5r;%yc0hIY9&m<;c*}X(Tj>yf4yDz!rD1D1e|C{*uZjSfOs1ik#rfs68y>yD=+ag5RRc~|r@HiO{2Rx{IYYc<1kCnahPjQQ5iyD^f#b`hNE;qY7h;^K8ZGc@G|K+;^eg}B{^{mTurw0sv|$Xn`ezx&4w{klY5P%39giykUYH)DnE(_Ff!2T zX!(-1FJwV=Z}-mq{~4LG4xiP2y9fjQOfJ;^+CXnyx{0`-cQzxt&TnmzCLd=_9^$5B=$?h4c=;3S>Q_cPG>>;_k}C=r&l;V81gSBblb!gp^dAG!ztRB0c1CiNjo=%! z-dzKpz44RRh*Xr|ThRfcf|g{GL|Y5aP*6_MIS(APAK~7Ye7i&nUhKp5i?yH=Yd&IN zeFi%8C^>AUvk>AkNSp;V0v6u0^#gW}b#-2-!t)nGUj-XkuswJ1B;j3Yi7Kb(2;5>r zWCi~RRq~ocBmXFU`mc##SCqbIk99G7kocxeN)++-K54Vt?NmR2Kz=lyS7o`M(~$7L zd8Ktq;~atV?wpq6!APq;!aOVWN9K7}kw!&Vj;vdY1y6z83V>dVhmAFNuW~T}d9m&r zB3-*EAN^64FQ)|tP^ZtZC4{|a+?^GJ6Kj2@A#fyiDOfBbvnGEa7z{IidT77S#_XKnh1@6UW0P_2cPUk(ndOK_pPD+95Z7R( z;MgUGQMmqK=6dn@sLAd%5C9bLZyWQaqy$^TZ@-|}^51^_E9zw=u2vu+mZ%`0u)cc4 zpC2FT9A{SnUS#NpU6-&jKgjw3jE;LbFkcFmiFzE0CwL3XDDM}D(I<1qu=+PHJ4Sf! zYrhd`zM4Ho^_q=b@p|@HR>q6qJc^!Ps~Iq|8-lZa$hP6@Vw$y$c`v z&!YQ0>dj^i$>Gul|0BjQGcjcgm*)(0QG=)ih%#fXCH}dpuvou$T0cY%tdw z%)nTXMqbS(HmS9j%pB+@CcFNKZEMtB%#X6|j=%W^xNS=w%o0)$Gh~m<$uwE1Kl)@~ z*cA9YJi`2SZaUVYa{4!N$0@!V6Op70Il3|Y@kzGxr}q{3;IQ5g>x=a-WNe!z;vdo& z#Wd-dk;!4wvZ*{xoH?JBl~>U_Bg}7dDTx>z=_O<7ZZLMPi4v*DWQex>ES7z_dt3uWn})sbE&_CYX^aHIJl4GKi&yJMyxPZ~Csn>19LzKQv~S{EdtG;zm#NID zIrC+5MjVnFc1E1=Lfzer07NJ2mC`7G8s9G;v#HY8U*~|k4erbv zjQ2s3?pwCrHTMYj9u$2jArawXet(2RXsbt`SWA!p^s>6$zJ)IO9M{p}Jv_;N3D zlO|4O1*;BzYRJwh{q}OSCJ=h@XvB^nMGe{60_cgHCg(oh@zmxYEX;%A_KtMlpghIcuFabniwy|M` z-Q)MyS#MN!--&8XSnZ-TyiXc#$T#(Hm?^3{oIQOjIhR}|?y$ChpUl4+;BMN6qQ#!m ze!=mRC082pTVYJ!9zcC_5aftbizw0EHw4NcKiPCv~rhGkVa&xYQGUepz} zEvJ;rF|IkOw^MmaSqBj2Kj!}J01Y&XL>L!`I)y9%kqsWWUw_%@hvY}sCcaeF&uOiq z4_!Pt_k6SHj(9?S_54~}#Nmqu;F7*58t9rSH-mK5$Pd4oe1~ch1spuN9V3vJ=lMGq z=Yp=kqL{Si%yR~EhW6ZF?MArUSlFI=6}7Vmg8J(fbvu=tNVf{qg!r}AE0)7n=R)UfBO)!(NJmwvkfqB?AGNZjv}Qw{D7IG{Ti`- zSqoA?E#TFh?8pXH@A1a9RrW#NFGC(M(^ONm4;>;VeQ3HXRn-ePgsHtW+%wXP^UtL! zJs!gl(Bl!$74@mNP@uJn~;V*Sz_9ORIq|Z_vYo?G4!5pdO2vPER z1@+npKfZg_xYCPXx#o}0Yuwu|j+vNrejglG?*=3aZmo9^)MREAGXckV6&oNTA#xm? zz81`QNZFHBV(%2vfGrz7w1aFjb+{}jY=9>#ON-V?Ji`>{V~9PJt<)6_V&!vivtzsA zcB6#E9(?8&Xd&~!5Vlaf2dpx9S?9x~k?@9A;X6>_8l)RVCVYFr1Ma)Y`^HTrNbTu$ z0{osUt`(Z-Y)2SIAJ8h%**w$~Un+k>s9xOAKE*gcGCXt?cJav3DPG(-i;x`R#y3gE zfoh|tCl}rI83}GPR)7}gtVctAWd1EcnaOtIv(*kFaX+)$c}B3`+YD^2Xr&MQX#ybv zEn)@-+}p%&&SWi^!1_^tmOff@+|b~yG95C0L&*+lS(2X|vPo&C#EeylgM)$JU%6um z6iInx-S@=Orn{zG>n={+b=Hqo@T(6tw46Y6E?EF3@siegA?av93l2UibtUgyVtehfv_&%!dLfUIil|U@&`A;UU+U4|@40rCAJ^P%OT+Ey%%O|xO zIq;01t>>%4MQ*&xbc-tWF9Iv@uP$n;9$`eMJ>0p_s5)4yq;%})s(y?`hEPvHmaB)s zi^L1VxjBY;Q(hR~{<+=6UE@~?)~Drysl}-wOG_y;0TirXe8`?kh=>eVI-Z@EeS4{r z_`1hMX3yrO+evd^dl;nx5dt{AfU-$86YPPKM>&c8%(C9^V?12Y9=p1 ztP~A#GR38Dd@;y37CVSW?66y;x zP3a*fNp-E9oE)Fj8i)VMP*7ID7d`cDTq)p=?6*ZxOqXs{!4Hy*<@Mv9cz(N6>Y7I- zddse!*)B!5s~*F`wKfv_oF*R}W$?Xt`^#>bIH~QkAk!PQ(`dVQ{#d5!*7@0@kY%*n zklKN%=!@Y+PCu0L!=O&|z2=qhO$yFgD+(RLY_tNl_c78PN^!8wEOk>z{s$?0wMVx1 zvVQK@J$PrpFLkyxz|COYJd_tI@IAtR6gxvnkuU!f0v?LypprtXm_9jG)Iu1E{AZ?m zeeyfA_yM|uk>~C9?xszExZ zv92s-Q_g;G*}ZN$X{^mffU~NcA)h;a7@z1BY1xaNm3Tj7q-I;A^NbsUQE_!&EooCZ zhK=pZ1br!*>&S!cH#Hn>EW4>3jqZ`Y$NOfi;!wSQzFPVC;$*U}uCD~m@JO1P>8q{8 zmQ+mXN03ZkHvw|3Gao&7t)7c@C}(Rq*w=r6L^`@M`Q4{^7YkzEqT4)&xgMv(;Ja;BoNz zJK9*A?#gc77G;<2LsZm!)6#hMO&iFy7ohxibbKwR_lKRJ05Qzwh#Z4&HWJwqZ5}HM z52Z({IVH|*o-Hk-%wt|IV>Y9bUtxoCQ^`{5+ZPFnWa*otR^j8zJk z=Ag&O!}Ea@uUJNXH3bHgN3{C5+~2otMQveyb1`IisTmKsj2+Hu=f;M(^QTa(AZ= zL3B6it^Li4wjbn{0n^_k?IpSeMNB-F81UXKJv1}@R){J@d;e9Ei+}3-X|Ahz(1%~SoGb^C2 z#C$80wfpXYQFLDJySki(r`;MqlLb~IZ|-GqF;5@W=dt}(FLlhQ!g-QkkoC(mm34cKU4m!LdI`RZriMa z-lKk9ryI^E7M*JbDdt^T!ORV1tkb24gfX*|q&cJW$Ex?8VG!WnhNNuoMpUAZ#x4Yf z-u;^M?hBAVr??%s;EvqwB=PpFP{8f($GHiEh~ci19Vej8|!gjuN*;QTJOhZ zt7Ba0n$OixV6r-QYPRl6bGoHx8#{Ed3xzy3){fvawUmd3*;P1CKIiwFTaPg`@dzjW zGQISDvoF#R+cq$R;Em1)*18Fse9`a_{;7I&h`nUjqE8Eif)W(4m)}e;Y=sg*W zIVtk!-^yATD6pM-rwp{D!cIG{BV@U1EPwX}Gw~L5hK2 zZV;@Obg`$a$w}ez>~glaA}AGYpYB>FTWA;NW=5xQ&q}w=G7$|xZ2Y_Z>Vo3>H40Uk zRih9-BQ(^NOos&+g{>$~eA$^0I{(66OHQL`?&pIES-y80GCk`fxVE;@bus{6?^9UE zj%>UqU1NkCH(E-zf9Ws%>Y{2q9RU>Mm%Fwc<71wpGOp(S)k8 zo01>tPiQRIk$;->BrN*ih)3&ClO- zkyoSywV(gI^AOjNmw0Kgqd!YK>7Kd9V=nZ(a{iKw?V<+`Gm|-C!bf<|px*Z2dO{zK zT&F^9DBer03WH3BJ4J(#W?SPOeN~C@_k7Rt?|0XBxC_k=6tdi;4PLw`8~N;Wf8Xup zQ2o2$1QzY2&EG=gB8<}a4Tx<_mu==AbV>eybiH+0RZ-VHx&Z+t4xn@&q@`0qLP-hf z?vf5^qz)2?lmh`EB_k%d}$U#xmH$LH&DB6jJ1oRHDj zi|m|!5=$Cc7uEsvy8)ACcfS!}?p*YBmISp_H}bYY9D)oqpq|Y=bQ1MyKsqPrTidQT zHUcF83y*&okyhmFT!<3!*G}=q7b{JE7dr|h2Aj2b=0=2Mi3i&DedpYg>#yo!Z7S+h z{_@wMBVXI#v#R%DeujYQzsMB{hWH};qVqete_{>R^Kuz=Ek4#_7|}5Oi}cdcFU$aK zxb9WFno?rBhIoxB($N z27=ET+}f#6VjsC2vA*-I(KPzAo1(mXKsBD&b-TX3d$$gLXN`XIeDoYp>)XWW2WOIW7>|D=>e36BX<8WaWqV!GE3 zwo69eBSNNbnpApd#C5t{-`Jz09C%Bff9LeIT;rjYRx=jRf^q{VC+Z-lLdNlY)F*T} znf2U}w0?CUr+VPp#w>?5Q0I%N*RF``}c9MhRbYuj%@UmjVynr8N zN0y;w$M2=ar|?lozZYDO?>fna@-> zP>Ta8+-))(;j2wU*cXa>vZ&9Gkw**36nk0Mj%=pgY$#g<<+SL?oD;R50mP_}yL1vD zNF|6kyl|bc&ZLYk0p<|fHh0>=x|~dR@s}Nnaep*oephu)y`cFLtAM1=pC#y9Da&21 zUwUMD{hH+L;^CEfu5?@N|31(J;UzlYV z@|-b=7QG1zc#(;WnjJJ1F?#~{d3Fh|@=ip^EDxzR(x^``Bo8dQS*%F$?9mRzUrd#B z8jlyEj1xaL@QG#J9a)o=AJ%+<^!{b<8vF8 z-vPwW{w>F=yUwt3ZkW(`zW)o7H%dJw7JS<-ZTi8(DhHe2=%_eveztziPMPeI3lx!x z^z%odpN4jSK#KSfGednOrekNMbQ2?PsRt1hoM#`08j9tSW}FzXW#*iMD^XfZnyHMr z|BUbUL%d?)kzNTP94X%L&Npi7pueTedMJLCk=H{_R>^_0I{>Zj@l5FR`!fDS;KCdB za$hIe34yMS1&bAG|E*q3>q?L^82n{&-+jadjh}}S9iZdc4?p>cisl!yLMO!>93i*= zuA4giHFq|-(!*;AnODB4yl;cOSjlk`{rv`eR};9=dEpWIY)ej;<|pR8!vRtVL;Au1 zOu^ZTY?Xp{g&4OWA|&|Y^Nv;v-Rd$Y2mkj7X$zkntG307-lGuPT`nRknNKV-`JVPN zJ$qN}t?)dV{Q0lB2;V)m$7m>+)LI5y50D zdT7ydIgzve0h2^VHkrV3?~u;CBWeltf?_8hVqW_%%~mfh*1L(AwPK{|-SSk#4}BHE za6(8s0@Fn1>Dk(cP3O-EB+d5|fBt_`>~|&p-^**$nR4O3Lmo78`lXM*bp_E3|7Twk zGFZf0^N6IJdxRzP@`}%YGY>JP9X%y49lV4GHGQsEKW#tdqaypfX*H=+aZZBAkJS88mvoEVX_+X#(C0^uBZ-l5SIoJsZ|xWlgKS?9JK!?7>njri5!btla^QKAWe@MFcnmZpjj)H3ckqA0%}s1GuQM0sR=qIJXgHux#-%uNLX;-3${f4K7L%B}7PDKLsQC`>~@r@+c3p z8oNm7L{kM_g2nBC@=NAuj|+P1!dCye47FZj*sJ${Ly&z#-0h17u6TLwS5t9F;h$K^ zo%#|#OJ)QmLslP*W6+5k#NkY0NQYNCQaPKe(wNx@L#>bJfQl5i;h(0A@G^M);-&;U7trCVwd= zLMAr{%)k+SuR6ibqB1}2S7a_eb=Uwg;ZuN3s+b#E!=l)O7btuhfT*Ln?K#JTKOEoY zr)?nuqTJS)pVF_s<7=|eiBC3GNbnLPHJTYA%l4S;MC4;S^oVhn>6el^nUDN-6Umhp zjTzvxw6WwNn>G8RPKYk0#1E&A7P)3V5)b=qn}Ci&0$y0i>zCrnke$f^Dw@bUi1cGV zB=Yy;jOzUmsreDV&s8|cA1;2dCVoel^OuHJj&C8CCl3HDT2YX5Ej5n_M>>xs> zhnaZ93?^ScM#@c89fnF$pqC-?MKtfKUMU!C&vVWhA}u92WItN+bH2utLbAK6MUol; z49KEhrr}9wR0j{@^4WE(7zKIBMa<MA$KMQ>7+J^5M%qE04+Ax0ffp;J70DnLs;2%9+qjUT3?H^*`hEM zpxx}N9S!|2EDi1dT>AB;mdQs{MmeNf(X#LE?Y^V#pCooZNRgcH^}f)!jAY@*&XS-ajn$B~`m<^wKQ#AH0nix~Q0isdc8H`F|dsr@F-z%d< zX_$^cnlU;_^asEO>zQ!mn~=o!Vp7F+eavrGss5*rC`2&hI>$i+JuLHI+X((nHJzHt z+?h^QHPs5Ab-bpgz|n$hgV4pg1zKrjaa-B<@-Am4XQHf)Z*OpSo5RfOwv;#x_y3hC zDYsX$UzuMc`eFi9h))3e^2JutBhPmOPTPkv8y-!Q%!FylW# zW4i9Ea9T;Db&U7^#3J%~aDC;KIMF6^%quBN*S5tFo8L5}ktxM-mQGVzNnZ>2qqh%m zx}a{EJP-{nFc5xV9+XuR3-{|$M`kD$vr%9Qi#}p(zMQO?2BnS~BpQG1RoRoj*W91J zuZP-RoZ@%<=77e}FsXe+Vp~HVS*M6-T2EG@`7!)yHV3-=l7?HO_m}D7dz;|`V@%5N z^wJsx0t~^`%TojiG0%EUCF!MT5Ooc>eD|~9r&eHQCbWuC?`qZ+AQmRt~ok$e3br;==1VTQzC7eLJ@wLXFo|wDpbs|jFeL90QTyUZS233gE(L{8q0q84m>^FDnhHfX$Zi((E^ z|Myg{$%VnK-4AFgsG^litVk)$Vf0RYc9OKK_?Vv&gi0_@@&7198_e3-O$(IFNd5^L zPMp=Gx>@%2UrJv#4J*zMMTx*563ufpgNo2Ua>Ymb8Zl9w3@attMTgJqu7A*f8^8x{ z7j+@Z5CI#-rA%;q=-iHHbN7EFMCQjj)kI6cJoxwGFzMVz^x;#=gGj2-yBB7W)8Q5a zP?|G~bOaE>flxHVNX^vrAe_x2CjFgS@U6~SG~)lH!)Lxo8OrCqGX&X*#tSq>%!c!d zn)a1ocR>Zh6?qM>Tk63$!PShifDaJkT8t!>rgUMXya4-kkcJt|cv`Jd?1%FDy0W@K zki8%e`Q*O8*6quAP17#<`ePc}jrd<#5s^-6Ew_)5-To*)u|(2RVt3c_XxBEfZFw_h zH^PD=-oK@jfheqoa7|r9ogU7+{k5&^b+Tu&JS&ia4!+Kt#nOplb93)RLDi?H3-pqI zIg?cWfqu`W35rU}0H;LN$p)1!M@snSZUNu?b?d3<^Ml(m!*+)|cjt9(er|~Q>B}}~ z`DssRk-Y7JP9pPinL610rE+)qBB!3s4MbChD2}|z(q}hIXM#J1 zLGo$_bn42rpU#-HoJN`|UNEIAg4dUUq~3DJX4VNGQt$OXMM#9S`wr{>%9VxB(H5P5 za+LiLWOL}B&3RqBSa>xWZFTWL$h{un0T`0m$L>a~aNHOUACDbJkK7LAmfP0YS9`D* zipN;okY8f_31@|Gt}sj0?H$=kPOw}xbKxiyhkdM9&V>MxC`V8AUpvXV@DL>BWE`+^ z@yOWs;Facw2QsE1^vG+-7VOAA%|w`Kh(5UfHPxjGdAS#ULHx0DH^K*_&G!mp z>Ftm35?7P7GWlZqqT{b^L0Jw*Mg321cV|0ORHmWh#-U14F)_?gYLzczY2 zchg1cM!!Z3&JL1twGFAq)x&i!ZV-0l1T@Sm3%&09lW0>CE|@a>*ZhpmW3`g~%Xv5! z{F_`CkOzULMGrLSaT1buGPhRX#|U6MQ3AlA+oVkLT(-MXSo2Lc)#}LnwFuD&m#Iv{ z&l-IaitS)7f#-I=+@kmIc1as|Tb_8`Wi}ZdiO_>r3DYxx4B#V9BiHPqv;aQo>4Opb ztb&!+rCY8EV~38tu}tLxhQ_e1^kN9yg_y!bzX(WgjAlEeH}3+QxRu6!v#{p1s|hoJ zSStv~@es5eLzb2p*jLLfc;A}=1zO&DpoyU{&wrH5;KL$N3X5HaV;g^q zC7D>0_bgQ!=e9L2bO$%Lb4n~01bxVwx*j0o++Q*%OctwEwMTJReb|a?JONm=(!h${di_xP(g= zyAn$bzc$Y1h8h32@V$RSO{WO5CO!vref~-;E+FZS2m@IX%1HCzg`0%CnV(r5+8HRN z+kXm!Q7Egyx>}lBSVnn?0J2%wMq6wptrpgSub+?%v;jZ&eoA69RtM&-~(8OWcALaH z-NS3|vmf=Op>N~62k)(2ylv-)rq$OS(}sm;-elQ|obL*IWY3hMkrv@{X|+ZAMd4%Y zC*tTiJrgghEcE^~)nJYhCSlK>dfKavKMn28Lb#1{%?z+rSV2eOha@}rzVNj{Zbby& z=+pJw?#=z@&l}2aq_E3o=7p3)=A^ECl^&Iz4cpy95f;JB`j_|)1NHekZLF-{8`M)? zA^%y0|E2I+3HuetfS-`d>lbir<%@!i#ca&x@07&yjBtqJ4{6zYoa);amcxpg$=X@=T&fEd6^#-`fE5~MH;kExlmgI=pu>h zq2?gu(iSnD+5BeiFmiAYcZ;8!i@_9lKB8kV&#iRe550psaVti#U46KQ*fSMGTvT zEJYB{=A-niZk{^TVj<~+b*41CiT`Xd9-rd#F}2cIcD1FCz#|S-Jqxo&<^qCZtu4Ok zc+HMsc{wfp@?}%XFa&SZSOMh=dR&Hbi}jhL zFyD^$B~M`rLvgTWIeIJ$|&7_gX0%BeGcf9aVfSfsQsB0A#J3xAj;OC&{D>U`mOCZA3NVt<2k&! z>47NTrR&9}T|S}Q-9hu&;CZ+QBpcx=I*L1oXq^d$rocx;w=oIl??iGiYPe z9-l+{K@{=>8Ur-}Cuh<5LX{D1H1I&u_?~p6aI5CkoID47gL-fO&pKaD(Pv{R0;VYB zEZdwgepNETmC4{GCZICF^^L!yB%Ph}CDo;MJQtnCc2Y+W!CJ>i=74bO%`64xbDWiQ zU2XebCsI$87UWds$(x`W$~V*(E32yPu)NrtLb>Z=a zJmbOQ@JIIr$a9Ay3jFk8uG^>z9`%{a5cw@oB0g9Q6G{BNSpO9d$;XHcJ~2N-Eb%j< z$CiR(cXmg>B_JK>n?*nQu=3M~2?4%ZKi&DImX?)E+l6a*)JL_@Ij5iC)l=Xp?1un- zWZZ|KS;5N1DNg6xyBj?(quDgU%$XW73wT}`r&6@f4&CUf2y%U;B#~akHk<8-Q8C*+ zjcUXYDwUAi(wj{oD^{zzRX)-8SXomvZ)Q#`_;D8*=Os-rke_^cbEeyQQKreas*X~& z5nDHQi?3tfJA3lwl405(b!A%b*<^OAr!8fSNIgqr=2q)EQ!Y4faFB@Gitv)|AjT`< zry?fu8zo4)vp1SGCPdTk_M>weWr)vw^#V2ok~QV8cl~;$A(nU7rVrOslQp$&(9HXZ zgO96@l!Dl=N*BuKwhJ1t-W226UX(Bj-47JY?x4PXW}!v#5lOywOE`%>HaOyav3x)+ zOwYL|&0ng&xrgh`h7MMc<)T~*;pB@z-2c%6c%1nglwa<}CKs;|R9VqZKwlLhP>amb zsHwn>2NKQsA+z4u+1GLDmH&`zbX~Y%lZp++8reFCzDRjN=0vmAn%mRy6P4%#VShlFFW42^G9- zTsz4-wY121DWAqu(izf1jGL z>(X>-F)ut&SXlk$T2IIMt&21f{)E)Au;NnEaS)J4!v@kgNbOvAL>BlKyBGeR_Yd06 zHJb=WOHOn4y68<5|@_uQRP;#7#U z3%7({a+XS`LZ}0@srji1?9DNbJPXs`vrsXb@(qO|2gPo@+aY$n-taBCH3{rd*r!+` zEsx7V+S+M5eK=86ac?}rG4u4s9-jI^@Z<)fK)aFTNv8Y?paJLljN(k|NseBV?52*l z-DW|#`nf#p0 z-jPo}529g?DGVhW(S00pe#{~cK>X+)JvzdoiU6d60~qD+jm9|U`Fx+6HGV!hm7|!6q8Nyt8!KqP zUk`$8AE|hW(5v)7r$2gsxOu`9a~Lp$XUf$t01Vy}U$&`T>n-eh_VhCUg^DyoV_e^* zF@JsJYc8oF$ZWc=1wk=oEBPDuAcu~vT5}|MzHgm~d_YXkVd+f)#h&Z#j#kRu-xQCO zGcNd*@?4xI0i0j5bQ{|w(U5c9lz?=I>!Ee-SGMfmQT9Pos;dLrrlZ(!>oaP?)GAf) z6Jf7mm`F~F+6Z6Pyc`mLXskU(K&|cOCWjeQpqkugiDGW&hxmrXS!~Gyz5Ttgm(W{=XoFOmrcJrcBC{irHzIr1g~i>_eZbn{mnZsHtvp5 zmXNdf{pYlDdJCir(Y*NOA^g=Co;N2YH={C~-hD|B+47YRHUR&nZy2!9e$zej@h`f7wQ!yEVw(r=-t{nkO5 z9}_&r7@{S3+%F(4&2IYL)oo1;@Edpd&G{Pm?_!UfxbEO$pJ;#pWfqQdA{ryv6-b{P zM5CBV5yb!xEU$A_l72h+e6WgIT&S`un&AnTQ>&1-tMo$iVu+C}r}rC7VFN=PCc?LI z1_R4u?50c8dPomfgH5bL{7ijP2lB9^8Z1%)L3EU8*Vq_(sk|rf%29Bry%}aO<6*jn zT-%Ez&jRx1h&}7e;EtO9>5i($S^N1=y_5Iwm%qNPlR2II8D)-8RE<{Yej*6e5t)z0 zr4sYQ_ZKI?sN*zrf`gm7#VO;KGV0J2FRx( z-#u109}I2YhF&OS{%W9j!-ixgE4hiUro#e&x+FQ{I zp1oC)EH0Skbp9?RL0Pe7cCsSJa$;`W&W*biMeCcmc)iPBmteaIl5kA5+G46HKYmmY z4&F=c&aT{_pkCmMzYN=7W~6%qRtTF#*XpMeakT$8%A!mu%N%V~ zP|{Z4^1N93;kd?qVJh+BXD|86wvkS|J6ejVOP<^O1zT#H<$%X^f0gZObL@ zG?vsu{q)MJhx=S-u%)>w51H3HsJnUY<_{}soxoB6;_D4;ZkWP3TBP?=cudLjN$QrX zd++v(b1k+ow_JQMS9wUh^-*{QFg}?9fY#SX%bcDI*K^g8gEOT*Z7i#dPV}wJ4tx=L zPX$*EG3pXMnlXNtrN1wZwcl5i&s{PRLlZ_ZbR9`l9lJ6onJoih1;em!WN7}R4khaLY! z>NVrj3L#PK$+n0Y!AKZPIA#8*G0Wj8sM!_xk(;R zEu^og)BNawWiSIHwha+crj0NB!BLaZD6Zy^d`NFL+)@;v7+1IfX zMkP>PGdXsW|Ew;UL~$r*Pem287i7#Fx=iVZ1Zlk1Rb0>2mUk!?E`Fc1ciC8DsiZ=T zUkIO6%ZMOo_V?F@my!R8wRDs`So$T}7|j|sIECe_3Mhj!6+qa@?I@wy*4sreY@#0|d?_A6A_ZzKc&$OU;KEbsm1u=%`)dit*itYa5C z0pmcrVWfo|>N00;6-FMy_ap!1ZlrI2m;`7ut8m?PuSbVk?%M@zrTodt|A4(y0wgO~ zQ3*#gNC~Q7F?OnpK3^6f8ay!<8EDj|2;>7#03c1XmgVlwo!54jEBA*owhY?r2_vq} zwi~yovPv?f1HRJ;Rg)c^^g&nBp^`F5l0NvJRfXf`Y||%n+`7L6|HJAMCZ>xUElEl6@Bir7Y4O!<9BN|1)zcN<3yeHUiPkyYYbzxLX(cQ5++2T{ehLJt?s zsSoaYlo}Jjs)Hms6XC9qaKYA9=3oPqJrDgOx-cFnV$H*}IOR4WM5`^RHBcaV=EPTyEqAKW&7q|Qhf;~Wx1UD{Za%b=pEFNfvB zSp-;|dpDhtfz6``BG=4f#sXlDCF6*wi_UCuBQ|Kphb>uTc>nkPmr0BvH3JLN+&R zST5$yC>i?wSzi|oWKfE93E{}TYq`>+(^Bh_y)P6rHDX1IPfBbp6GDHyZ9l}mTk&lG zbFdnJX%QUpdl4EYvY$gw04OI7>)a}Lf3ESi3{m*(-7I+4K_kZ2{=sd#s#39Vp$R)K zb|Bt&Wn1Thh=Q>cwUB87kZo#Q(A{@i4YlfBFM-w_+k ziyJb$<|6BC=euz^j&=R8pBqCUYS|y-HeS4d9U(o&4vXI1s->L3(t!#y$~Y+Pa`5|aRBjWP z1Rz>%vsG8*;A7}I^krE=E}R1nJiHDH2C+0xBX1r+QXOvuNTTsY$t~Fuh8G1>cp%Q- zJZr7}Dvk(%S3upapLaK3^HsE;-J6a}6k`Rrp{IlQmCL2oCsJYJsDv>QHm)B2F`(m4 zuy`(Zf7#J0*D_lm5G{yEHkJK4YxibckL~rJoqVpQhuC2t3Tn^)lYiqc?7|C?ZC)6R zAgK)y6P#(;P8{x!7%=?xm!>zO#RO(|7ob0yXv0<-uLZGh-}2TpKt0SRPiEyDf)` zmWROQi+CKVO5FOR@wE1G*mS8W6_CMreD|T{ox1a;jcYb#QW&=|K{<_e z9Pd|D83rvT8w4GtPoU(n7~^J0+t|wG9FpV3-m-b_88swO%(|cyEgLB$6Gbz=#ci#N z&qc^@)MsP;%w^b1v;BQt+mU2m8NJmH0W4QfyM^`cjcgZ6%{?E~gNslovo};|u`M+) zkS~$2<7Ll2)u7c$k)h>w?4mQ6odjsZlgAueafp;O6jliy#v88I(#ypd@*w6#2(Iuk zTpVzyAD6!up@?%kX{0?ROT88Bu0-^<5YpQ5@5kB>BW`-#b=~$aZYr02lEgb}tE7lw zQxVdslVhy;6I`GqcZuTJ&}pNT#HYlh5Zgp6Mw(y@NbSSfnx{W=znl)(hp<#~Gawl$ zjO(y{w^)gUIk25kz=BZJK$R6fuN;=p;WmZ1?$tqlS#WiTN;E0Y>d2G!321yyu+c(? zJnDRgaZPQ*Z*-KB=4xw20^Q7kL!t2V{V9%JxlxO8c@cyRK2F-$y@(gJ`lY&em#;FL zq~eM?#=HSqau7bim>&*Ses~|_pKBC65^maIYP6h~;v zoe3eaqxWgQXElTf{21sxVek-NR?gE`@YOI98G=GJOM2mgI-Tv!&n)%GN)HI%T`9r9 zQ1!T3Vh;C|tyzq3M$(deX)900v1<4&;^t))Mbp2uCkyIgmHoGbKZX`J7Q76Y_x`5m z)ndCko=OZ~z3CT#nA1$X4^lMz`%^}N5zl-GSb0lEADQ*)K-$#aRhbMH1UAfo{`!rc z_ua5WwV6wU#i11m$SZ84;n|yi(fv=DhhMPeZH!|psDSf^&!)VC{A|`7gksw9|1EX-r_!fp@-0+pBOC=MKSjVK`Hf(HH(xj1$VhycdzsyqMvn$ZvXsfFA{tdz4O zL2oM%UR_3rTDDP`f;g@id^~1KP;1=W2{F~GVwiigv!(m(?at9I>tKjC=~Savt|%O% z1Od?-L+o%r6Dvyu6WMNIj15=+fqPu;dS|GmU8kC(dD%+l4mC{`1}>cB+pC3!=1ZS2 z8+OrW=?G((&?0@!J^e`n5f4c!JRA%9R>oD1)5dMA;*LkrZbFh}r$Ew1(8K0SMgC2O z7?jqpZC}t6L$oc^JuP-KwAjG|MT{7R3JCYvdX?`~9DJckRZDa~a+?^2I2t_`h8Zx8 zo~TG55w3j%=TEtX5{M8!M)%9!H=|29CTRh~`%{Zv*0}dh*sX$%yfTBN{qNJ{~wqfV=hiGn~)%6kO%DXLXc~UfAW#7+x z*eUvab>ovKB5u7%6=nEe;cP(u?0W^h=ZKi=QA@|e-6z<>(m@&>bbcdB;9o)cTMx&pcNr&OC39Q@D)uTEKZy_!GOb zz<&p5L{w)2O0X4dFAE+mds;*>;%5Ial)%p9R|ornO9MrwljpbR%px;?#jEJ4Z3xr1^!S87T>k+>#(w#OamOCv(~r|5kU;ccljon(9QDy9%2cPMr&6iOJthyEhlYLl1TV}(_T@M2U}?rgS4NT_eLTBn z8#^KtwM(5vBoZ0kMyYNzKasXpJf|Id(t+lU*f(AE8{^z(jT~*J`XJ^$GkJY|x=sT( z%|=AP!Lj!E;0dIGEc$y3SSy7HPUDSPNPZBq?n;!#Q*Q50zqzg9ZK`$i zz330>5JupraU21FC*o7x{wv0BOj`Yw9_oe3yS zQUKw{{t>xqJ_;q(q|@a5$ow3Bd<6p4OtrasO$bq>g0uU5#JSoM*$C!RgbxlXi*K{C zk-|!aK3nS2CAt!7Ne3DpFopouanbnu`CIUF<`2I(Z~P!)?apFw#4J~#@AwJGzF4oR z+{euOquk^d&tyfwVTBtpOhz|jbs|LZM;00uvY9yVDcru?W!~qgqPPtVg>57-6sUd= zrQHO%6h8DRx%UhOS?^|8O>GI^+isWV&F`v0)q2ulj$L)j#UFhQBK6{EBMj)<00qjn_N9;B${ z@m7ENPc*A-_fqgPg`(76u74{wcTLV<=C8)h0TQ@{uOPj2?xK0S(Oy+;ddB4(Z1^Ue zWb~EvXB2zS72dv6Yk4(0zi*%bmfN2b6}kdlY`e6ix=Ajj*4c&h<~MhqEJz8gz}^>rtN$#{6@T z>r(5%F2$BY!X~lbI!Ww$IhM$nz}lRbX~{?X;FsCLI~b(sM&CNrUUb>m1+9bCo#SIb zvIDHRgh6v@;w)#B3Lq>C^6$cam)Ade?-zf}S}hxSno_s;2H~x0AO)mMM!he7Zw~^f z6BFzAEOi^)X`j@Mk~+oUpTdQ(qP^d9Dyu(XIyCxC+{%T!$eV|kmRJe?-Csbn-}Gx^ zng#5{0Y0^s28QHLdOY9^6eJ*gPCd=+Q8WcXS1gSE!{xj4|Z4P1^Lm?M!2MELbW~hW3xtTpmQq>5>*`C-BXB zjmaDu-+l&%dWVp_GSig^dj%6_!bZ^F7g|uZP5?MzfD3~;AopN>v+&R#V5l_c2uW<9 zMmh>iY%A3nP6rs~v2w!n246iW^uF>QbezzD_X?Rr^oDU+{ByxE$su9;J4pVwYPU+7 z&^mP$*OxE^OnM>84}!ULsOR?F+u3W$lVr|=lJO9k%9tVqG)?)W19dQLVqqm5Yw9i! zBTx2b4}4;nqp9NTN&Z>}N!hNNhH+w7NAxxiSxn~HMi5|%X`+!aM*D{NSPuD<;jO*v}md zZ!;O5x05$Rd#sStn@KSaIc&D>Zl8HMZeN3j3=ssHv&Mj@bzBPZPCeOq(h9P8W)?wB zR{~0;b5VWmoQQ1Vb;Q7^PZtNNv;Fb+@yCNB4isSmm_2c7H$twX=pd`+z>lQhGc#yT z^1l}cpKS)A<|SaP%?OK4GlW`J>jO;gN$fQ2KdxB1V#bDKX4N zQWobuP0nDro`=?iyaXVe41gymv&^!42yWxG6lU#9eI~(?>{~3>v0e43dnZG{gdzKy zuX2$)sP5qW-q= z08kJR0a!pr3+qF=R?5Pqed?HkKCC-s_&udY~SmD(y=r&DHS4>W;#ZWQy~dl0Z$U475Ya{LD}uk~>ErqpG>+|TzH0>!J8Xvt*45TOYPzrW zu$+1xMiY8h7Ayvesvqrheq#VAT90_OD-Vl->c87{Gx^4T-q^J~ptXPS>}@@oR8}UP zZZUo5*EWALBliJMOXqvVKXOQE>B0p_!Yha4a!uC--3ye+$J9=kGS)-#zF)CBevsaJ zOuIQ89r8QL^USzR6gzQ0;iczzIg0){y9mN^+juGj_ZO7=+zOlqSWIMGCe9u$vI3Y- z!B7or9hKYnTJ$*8iblu@D6S&8v`D7uV=0rv;nUi?^*8Q@pWmuz7<^(O)%wtn z`BVfMv}KfwsEQ+PhM#Oa7Gdh<+==rk?t@+$B|8Ox7#obA?UW)CRKd@KA?+e=JQwFR-bBq3??`C~3`Gv+)l-)`JILMv}pJ?=g_s zj>3ghw<CD7P^{xD_UY8pR-uL|T_`(n-XeLnX}cXNHx&AYYJ7vYfn@@co-CiI~I^ z{^RRx1^Ay-x5A{rpCuesi1CNKTpcT1%y3)#h=2ytmQ!h>e#?EmiIyQopS2I zb=L+@c|#V&iUo1l68J>l8f`Hy!E)Zn!H-CsmX43nyc$ z8E`Wo_?Ex?w;E_>z@ShF)q4xz$KT%s5Pn=rlc(zXa%Ldf=e{u9esC>aOmk#nF=fz- z;aLVH^b1k#zAb?0BT(B8A8c{CD_}hfvr%{YM2v({mdR%3L!?)_{gU8S-&w+|XP^dJ z#;v~ct9eo4G86qVW2B>v2e#5fcaY=6^ueO?Q}~!^M0FmxE`s}we}+vB?TG;egd!Zk zsmn-j;W&h1caciNh|(z3RW_hD9R}lw2zs=*+H5CQynacJ%-9?GGwf}b#YTiZq3slC zStLM585E-be6RIG^d*?yKtdn(#qh4;runvP7|*+I;DZsR5+3HFToqcKj6liq|6pI$ z5y!MOI<8dvBV#2m`413{1X)uFT@>)Sin!MQl*zx%fVVyDM zP_GAU2P{D^DlS^MUdwwN#-q!G`h+?w`jn^h;tm1%)usQRV`d6^z`qaV^*UXgy>25I z^|go^;DeVC?~GJ$zvO*G{j;{|*;$>w9p6slRojF%2`Mhj>@hk0JxrG1X}XQ~6s7J& z%OUq*P$vkUa!j72F*?#dymwUKk6aSODr6{+mKZR zGRqmG#TJ^*Nw>e10b!#@B;cIV$&stGX;O%7#=#^-wRnn$^3?aEZ) z3IFwf{PO3s=+MbB)2d`wg!yZs$U@Coj)yc{kk%96$sSCzDtI*g!6;cCeJhs^to$ra z*RdK(Aj$pjcgRYY>OAG%R_jzKQM^*eKvsW3Slo;KBH~7=-A~&?{F8(*QVcIy?psW3 zWu=OXK5ZwL@M=*umX2k|zwZ1qe9KuR#Usyc_^?40_#5{Rb95%vs-yw zzY2nW7QOTuh4XRBvMYKESw80XkGCZ6uQ0%I^Q6F(qO0_CfHD|_U-CPKA(-(f30FtY)a;c zujsUh5VI86i<$2+6DCRVPqwsFHKAhNBVwCBW?Cn&tA*Jc_Y+a$bcEBguPUZF?5*^l zhAaWvW_!=Fz$>l5Sd-?2{I{U7?jLn^J!e@TS8Ns+46F>?!?SGNaygTqfsJxpa5waf zD9bt@Y|nJ4>`CjllbI{dyZIjw0{OshbBwRW>o%-xkQVgBfGI1nT#p#7lvg<@9SHdy zEcS7TK4bBh-_)!38np@!{+XT&W(&3$!-T-*V z6)?tg=XoV`YxtA5(43GjG4hz;P-=zi;Om*8>f zsLxokA@AE!k^CCQk9zvwKw`-4UIVvnusH(#X*xn9C}5oSIGW9o?EX6Qa+0`VEi+jn zX+$(@6mT|D5=Y*fR^|K-&g`pc6r@C>8;p1bxXj11W#2OZFte{NJ_zy+R?|~L$B6P& z@sitZ4my+Ujz@URp-<4h)k|oJ8lcAeROGW~;qylgMick6YS{IDt^{Flv0|3A=(l3* z6&ri)wLfQnB$wJ2_-G;>Cz=|XEX9|F?#<)--0PO=U)oEMDgKalB2*+Bqq&RSiQ0Mc z?Qr%yf%Mg%&t_|G)8msoKYt9JU;+9ta7yXyw&w4d!VG4?a2;R#TFehfLuR!sW{-m3 z%iqx!AR|T`yc43&RKF@kCp9CG|IVz#n3kkrt)0_>+4ZnS_cN)cr|ixzlP%U}+nZvt zcF|z^Y;0nfl#`?{3S|`8h<96^=wHs|`1#@TL;c467eE^FUsYg?rKEYRTP}HcQBxLm z#5V8+G1^Tn2I!wtR4M>(=a8j$=ZjbwI>VAlWfx*zdveyAv9{&jJS6bYimMPb>La5T z-V1$Isc*-zm*nQO-^^P5@){NB(SHKKW=vMDbq)DT`w}5p14NWns0&jGdA;)B>|*c| z5fA>P(ut6tcFd9T6H1i97k`8Ai*uIz@u@=@CD2^DnV%g8w~iNB&Gk2WEwz?rLzqHj zJu4B%^ocg^)IF?Dd=Vm!9eG<7CFUt}VMp`;j0jAP7V+Q>6%vAnqZ?;_U#NvE6nIB{ z3RCok@EPPvh6JD6Z5}aiwA5)8lH_t4vh(d7$N8!nH{Yg{u?lbsD}%#MrP{tt#{hS1;pL?@#MZpOMf&y?s%UQA}|-A>LQylu3cJYUjumh4V9 z>CTaB;NW#I7*VDHfWya{yLUV>>UDFy{*LO+HCyN9Ist6Q1ZG74HRziZXGbrL&7%8- zjrmW>@4=w#swnyemWR3ShO(bF=EsHkTdbC{4T*)B3fsX=#kusg8fg3nl+BVk$B8$~ zF}u$Qc+UQamk0rAxR0qohlT&Px6P+fti-k50(jocW!hFKHRK&OC5zh;?c0 zfqZT|HY&TCtMnq&kZaf1>xHVdH~Omy>$ap65)|p_9j&0P9PshgXN_p(C3c7c9YV8{ zL7!r0=-WgYpQH)9Y~PQ84tDGmA5Q{(i&MW1cEE#Pd&^i&+^{DNQGc7vS zP)6|k0w-j{69Y_6rsoiyP(NA2E&1Ak)g2;9{5FRwviuAZ!)XJzS~*=c@UtQU`@7#h z#u7I57~-Vwp|dMn*a&?G<7Ha7lX5auiMq3})N^2xYGUQ90b$f(jn@BM@qVQ*fb?}a zZp)D9o)fBpBo`G*s}Uzidi!tjD!HCtcHr+cbKM_}h~GZDlC90FwG+8Lk&eWVQ|m_1 zA-AvQ%eFI}R7Ip+StR?4lVy%}$i)o=)XQN_ged3NH*n8%skCrV0j`CLBE7II@(Syv z-iRkURe!Jm^rGjPa$3nKQ9HiPeq`~t>n!nK>N)J%y|DChJd<0;hv0=DGl@t!#lymT ztv>du_whuHl*?$V8M0NcWgKl`IyJ|==+Mk(&3C+-UrxtQ&_6Eiec17eYw^<&PyoUQ zD_mS1;$n<%uaYP?$MkzW4Q`Iyo(`~*_gv$PgxW{TC+5K)TWs?8TOfV0Z@m=Ye8l43 z>pIj{r-FB79nXh(=3!!iJJzF4p|7(g@4eh+uz%p;)7%H+UEZQ1M>c0C14LZSKyd;H zs^Q4`a-#xA;W|$y0d15u^qbfm(w0a;*kkuU=kVR49zxWMTzSJGAm8dn4aE9)5D?@{g9hPIM$sk@ zTGp*%wX#{m9+i3#5J)39z+t3=Mc#RK#=%n{m25M)-cx_}G97Lm;ng`UJUc5+j8cBo|+< zp2S2aOpM?_GUhbWJ*5APs@O<8p4NAEUU!5 z-;%!$%qQ_lUc5kugw%m6a(Uw6Z8E%8tYM}wKdK1hdKAVr2;jOp8t9b6rapSNt~oAX zg3+F&qMHBu0K6c6cfpt#$W6Ug*NjueGLsg5w_f%M*5^)t+tz=Wr8fW+Q6E&;5(lMH zMAH&pJ{~aXgCrA`J&27UxplXE=}0K^&kSh*hQY#;!0H)ETkyUNG|Y?^-f~-#d~Xsn z)zJf(@ghb?OK^0p#~?o$w|ymN-!(7?au*rZa zZCwHE02C$bwg*9RkPem#yzZ4~um==`DPyfoHfJq5K#_dZ0hmR|U#{_kumt!mEbUv1 z$m=q?TLB6fjMd_KosbTEbGP*3vnwp8z)W6 z6S3^{A2^AojPk0`zHc6dWq-|?7M`_-2T4w*g)3IwnQAE+ z8gBu3Lg%4f`aZ)A4qEH9wPnNSDwpk*R#yWw74asqm*fGAgd7I!o7Q%9Kv(i-O2Df! zWz?tIRnT5nRkb!4+@aRX34o$NqX0x#E750%=4md%a%hwx`VVlE1-MJZwXJh_tvlqQ zRSn7U1F3 zeKgajfR2mzP=~~iT>SbB63u-7Wo-LF7ybj4RUhCwM~XMu3uYXHPvy!w^0;%A&KETt zM|`6}7eM`_k7gHM@z9ZIjZe!11X+BK{`j*A$iV|+h@R&_1TbSYN zRsl11TS+PW1%#DNYIF+sl~fMO5>5gueC;QY{No6)KvPEGTw>YOOGT}dfT+-2(V#>(7W#zt)PL9sYh6yQ8)xqG6Q$qi8y z);AVb?3d}Soh;rNzc4}=Y1NC6SDzziRK-;-m3e;0yD9a4RMbIKPuPTkNU6E`Cn~%X zG6e8*c;k*`oKf7^0u2P=XqTH1%Rs8oj>GVVsq%a0Pre_h4nB7#X8vq>$_r*d=Oln- zxR|~%HoH-H`(0u1>7q#PZPeKa>9Qbo%t@xbKW!-`7 z&cd&P>O@xq1#`}uH=i&T>eeEU3`vW_8-WloXv)soN_ob4VRf6|B z3_#)Hen@SPg*J7uK6o=JR60>^(-0z6gB14j6BA~^?N*}{^}*twJ#LvU(=Jk8NiNj zm(e;ZZ(Kh1F!(m`n@~J#qxHv<7&Dt~q3J`V8L>llsPT`{~O9gfZ(k!$J<;9v(#Q`As5eE2^(aou*p`CG|>1!c(t~pxdn{p#fQlp4k!Qih!ok0#wAAa{k#n&ThUE;>*v7x-IS=79ENG zYN0hbNsB4V+7&K?Gp4pRFf$-eB6uWDemSFc`so~ac9F!tcdoEdVq!VZ&aoXc&Q7z@ zJiDMl6ZS}p^YpN|kuoN9@s~dYhHZC9=!|BE{(9Bj2MD%1j=>r0-LW4KWp@zw0a@A| zn0v%uFW~xj%s+1eMbi=oY6YqK7-}`>+`}bSxx6WDOS%+tyz4u+$b=~(KKb5-*l;kYr5Cl;wDPC8F zAY$-WVu*|g{P^uv-UWV;K2lb=4jmEx!B-@|g&-uPbp5Kj^T&l@rw?qsF4KEz(Wa!# z*GZKw(zjgpS4v2|&ZDepaJlK~{QH7J$BMGe%*Be}t);x+gKeE&%dcT3%9{g8PqjrE zQ*Vo$zlbA7W67?!UVi+@C#}@$d4B}-tY>)!s|f{DFK zupTIc+B<=o@E7c5al#LDP&?rVbXguw_%TUFN%#?R_W%CR|9c4kfAoYzf_Gy(-ML~6 zMo|(|_fk>6KJhZuqw6m)-7x$*D;$tyh&uR{5X=av29QFK5s9I1R)wn9L%Xw+n;ty( z`L3w(ebTdUzVTz7B3YfmgGVHB{N%g(>WHIQRa&@-8D4%3LN2q7ZoQQyh0r<@dKilq zxyy6GUHd;GQzWhrZz#-v>iKP%c{;;Sr`E<<-nWzb&cY1YeJir9^=qEB^pu)xoGbU_ zlJ4#q1)(~rZ!gg#ZAo?R)xUIRhn} z0>O-bKOGQ%?XZ%5XoOMic4r9 zxH-t#6tB_`b=}G(oysh?N*_DictM9yy`s^kNQunEP$oKmfVZyr`r zW!6=!+b&f(9B7-2-`*Yl@Vb--0>1oAj5a!|<%jzenEa;+S$Wlo~X6oscPmGUKPBR63K21tP0i37LGJi^VbWC=uuxwr{ zA1jAJ>U+PV;#M=iRbR66e0-6KNjUl*Cxm1HOUe!suA737P(-otJC;gFP9f`X_Kd1B zA;uYNZ5M{@v{woPZXKAE;GjuT@X1=n2~$;-!ycwD%BHs?g7<$3QLix~R6S@?Kaii+ z6!M>M@PX9uO}D*(xY&OHzV_ z-h#r3y+83J_I$5o&w^v)s8_uH?|x=PZXROhAiG0nj#TwNi%E-3>Cp|p@rU%rN zNN@0p>JSV4^3sfzy4{9DdYDal!vHjnE+xOfu2`El-7@2}RG#2`#8|Jb)elskp;%{50eC`%E0G5-h+Wz^VK42=8g6Nq)I4p5jx2mnK-G;Pb z>!vN8ar7;I1=nN$n=LDt?eO;A($g=s5V3+nq=3KT4Qd&`6_o{NSuO-^Q&vu#(Oc{9 zhv!q?VN*0kvSaXmhS8FPF2B<{te6-n>YihV@GuB^|0?aUlXb}Kk~nE&!ic^f{6fa( zd1MC%BJ!eF>DrRzEhjy7ft*{Xn_$2SDibpyN86$*DaEXbQ+_6 zEDyQ4tl;uoOcC^3-AY>H5aFs<{Zow7ZT4lZ6yLTfiP&CR!#-yU{a564F9!zgyy_5cf zD?ZC~q@q6HfX=LWJ6i_t)k?DJQl}rbRJ~Z(fLtxTg^kUqX&woPT~2y*b;t#?5;t!S z$KEDnCo*>~jpb)vzfB+Sz3B`;3`(8@;T5gk&aor-peDki~GSrNejJ^al z5rM6f)o<0$4KHnJNH}s>9dwOqQ6qCvjA+8qK9<3vI!WDs?sg14$%mm!e*iQ_9dq7b zWbV-1;oxU#=IBIJN$Rn0q;h=gvkGM6x^)Ayl~})J@Qv;vvL0+2e|D6~X2G_qEZ6g= zaY|7$*hPn%!s%Xr4*XW_Ez4BaCS%~|yo`11;S6%NGQi-sfWc|1nZ^Sb!J4|aqJ1~d zJ$b^`bR10MXi<2?Yj~iXijcosc-V-Y1IASjJaY~_({6DfyBw9(JD7JkW@5vKMTKO_ zry)e88Idn%`2F@Wc`LReJJ8w~D<~4gMYT5TrrW4%g)GL0lNkhD!dB2l>rGea{e<=@ z1dF6cwad&v$8b2KGD-a}7_R~_x;$CV^2?)&2OGUbW#oDvw?PIqQAl>^Qh?n9qG!JrVQMumOO&lcs`sbf!^rSkI;zU2jJ`tg+CL!k&< zi|~mL+uz5vTlcaAhwZQ4Kj4B?Kqf(Z2KLkkYxc(K^`@lm56Pi7$I${O+8-!xX!+IQ z8occ4Kh-E$3kY8JQKT_q2PSk8_k0`crEef7`*cR;qMHnMeT255;L~4Gev6Na`=u=# zX7EOR@UVJhgVl2=3LHUGw0>i`mrG*h?9bWvl~Y1OesHCegpk4;kE6{zB@)NWk4ihF z!cS4y!?5hYmXl42-@WWjCT1x!FkbOY;So{fNUDVNkhvVX*YFcut#Zk~S9F!bTh7uF za|eTM7?hXSAN2m(_>vQ3}^j+h`hBhcL(pTxcNpc+%$aYm)Dl%RcIQ6Zrv$4>75vX{#FEZkXTr;o7I~ z3s3xnXxHU;kF(!|_$)VAgl+lIWSEumnyevhgNrZDL5dJ1gl)Ov-0ZboKQM@Bb9s&l#H*c*9QIPj`;oRx9*M;&x(FziF%Sh=%_f=il)nSllFzhjp<(ARh4 zKvWI9ItN@pWgLU#s;gfkk1bD^c4D~$8Bu|kd}d}_<22APEXlhz9?OBA=t*-7yd*Az zZ9#ushu(ULshpDY(%mTx*q$<~rUCYg$aO8+qZaPD7r5zaM&Z$28;Vt5|5vnsuA4y1 z`$`F4@Z9ftW~j6h2blwhM4p|?be=5O6=P+2ING%;!x-Yt1GBoO`{S=l&KyNP(*XyF z$$fW(oc{%Em~g~jzNj4<%Bc-Hbj?>h$#dU=&IG8z>Tt%Sxet}lUfTQ3(Z5*3X%Ix> z1m|@Hb@J%9yhp;R`Qesk4zw}m&#-E|B~3L+wvw5y430|O=p5Ak0@&MMLT{yMe&c+v zgmD)A=cn-8Ubx~i`$N6IOCY|^4dSZ%ML~YF6tikyDR#OYOTeh??5kD3?X4Q$&MMb`4aSUc>Ytfz%a-MZs@cY`#3O z)EU@%54j;ob;vp`zeZfr#^s~K=wHfHIRd9c0Q*If81zjZc77{(#cF04My$vPUNTT| z_N3K~zfh9#Q$D;JwaNuQLpQ`I11SNu8cFoh8+v6NcI}SD9WD97mHu8DA=2x@iBUn@ zCA!U9!ifmz5iq(@R!-@5l$m9^Q{(Pr6(t6lD$i1+QD8Y4m+^r-I}br7qA`xa?`82G z4hfo^B8EgjCduvs9ANF9`c{m}4#y2P#k7!yjP9lXusn89Sm|;d+QRATa0wPqhIG0!fBY3!c>Yt2vK}^xv4by&?h{_pn)`jJR=9^Hra?Im zl#xFimjK{Ya@Al=try&%8r zF5>*$5+hFyy#&WweX9G__NrY0qtp=At~lBXUFinV&iVkfWX>9i3IV~}fSbB)ci7pc zK6NJ<0lCJ5)AF7;dpI4NvWR-Nsq3kGZ(<#~Z~S-hxn;{|G=*J@<@hWRsYDH}1Ga*6 zaSL+uI&`is+E0o{_!fHs@A4%;lNF?@6eCR>;)4!Gb`8DrsbuIh*~}~8i#0uq8;5hV z1B!y%6-~fTIM9C`??15RV+?+i!w4_nIe*xkxHb#J27wdip7p-7RQt^$v)76>jJPS= zUI{&!@nDg~${hUH$#*|PvA2{!ZfJfG03I*`hZZX0SsSYmWwYJ+;JD-{-k`=8X>7|o zZbDoQpo*yT`Q97RuY96(ALD_fC%gea=3nKNv$=Xovcmvm5#uaG$cR);vBo-7!KtC6 zJd9SV$B_qZ*RlVS5{^SaI>7NSS|v<*9r_J4!1e9q*l7@;4y4rTelCI?1j7&I_*O;R#G|6HZia^Idu01s4?DX6a zeo4lQPfilPZ2K-c&_nw1!VA24Tg;GM#){c$+5X*ljy9i;36)D)SvYZNw=dT&$+-zz zKdU;lh;Ea6V+LMq59*cN-iNRuFFf|!S*!OZZrJv{s0G;VLfAkdi2eLHlfjzp_qB3K z`6^ww(xAk#{(00$9sJv+GDELG4ru2F96|@Yi9G6WwAj15eWo9LNP&q$U5Fp1W{FT^%L(UXkim7h?>EpI|8{9BcJdc32LIdJ z7k+~!92HWEYiYz849HVsKN5aZtCl|2?NjM2y1K}N$Hm3iimmB(9Ir3@>oIacu3?bi zbm#$nd+*M5xKck*TESj5AC6L~ZUtn|YPggi;0bvPQVNlf9gd%4CJwg_QB>=58ek;^u~Om^$M3_<5}ky5L-axLhzkr3W`Io< zS`SM&0ixk8%8YO|N#Fb4I}Z?fF5#nzeD3ae?HFf_IXd^XqC%pTiOwwDqc+OaNWi=e zfIGMe6sW;&IRwCEihdmQuu|5O!1jP=hDaQG8@_nFa&JQ51X-Wqw?fqgeY%iPR0y-c ziI5Ak6f5hm8iF-C;m8RPxQb7l$0_c0Q$>?IJui$$G#Rrsom37dzCLoCflZar!NANW z&y=czd&E6U!#Iw44^r09x5_JSbMr7r^5Eo1^4eLj@8z~oD2(_zZ^Al4ncM4ChdZ-)n@zD7cpIqm7v6#J`pNNgKC8!Y^eQ#>Gbn{L{UABm1^1@iTFt4$ zeOk&g(Y>(QfSa^<0#eW{nly~qxpKQ%d826aruHnc76HPF7rffur!R=FeU-@soNVFa z*0DYQNDd)5RLHzvm0M-u4pIinB|sqJN*Y7Y%)*WA^Y3EqGXnolYj?e7dW0O7K@T$; zx@J~R7w1&*poIZUq1`un@OWo452IBduHe{!vllwiG^vh_ldr?6Yf$ZIwdk0VcCaH@ zQh{fvFB&y1OT!ib6-9n7+H}Sh9f(|JG$IALs{z2FAE*!!K$21~J!$$PawHs+#;(bV zB@QJV?mPJTgDTh3DF@VwNfv@E#l(u5hKI`dRU&XPn+%Iy^J{tB_(rsTV@Cz|&+Y6Z z&9<&r>Fbc9Bv@fZV0Zm=OiEDKYkfhja&ELPTT{!Sz=>=pCG;~4-a0r{it7Cf9u~xa zQ_WG-^ctxIXsT+t%SRd_}}f0FIIXo(5<7 zyN8~oO2}2|iuQxg4wO1*27=%A!c$=IiW`+xX)C0Lbg%6}>S+HFclF37HodXT{wx)@ zHt$&y|0hs^?{HXI)!@jVrzMWePwHk`i z=w@UXF%6=l;19&imePn$8b>$2I%pgj4?-&vY!9?&!}!OH*)+C@bim3fUgZZ3{8%shdI9Wt5=WE zkF2qRH?e{TFF$aq2k86J$3FQDh_AI}PB>U_0rrn*_JiNqK8)IU8)v2sdu$G2Kkj}> z+Z~}7xu+Z_&bjip-}@Y*Zl5SoLXW8JG* z$MMmXw=xh0-?zu=v6dyIreZZ8WxYUie#3n1 z$mpCK^j=fYQRqawMieSUJ?>;3jt;nNb1KyB&7o~z_XK7{&jKNxdKT>rmt7~06kQE} zr<6IL2f5%|3qS`?|Hgb>W;RDkN0-5(fUbnD1Vte{e4aEbvDPLoCFIkXFPvoIAB85t zG@Ax~gKvF8A}}s3XPT9u1viB!_eeUjv&Q;saHhW*Ir9|cJ5g^@knd7dx=T%^@}q@{ z;*y*#lrqeS1nftla>Lcb_Va=I4G=iCCmIO_vs4Vo;JQUik!2u*$ZOQ&qz&EID6LPB z^Ak=iXS#T6eK7hQUX2Z*`t^SS3>howpA{v#{W7PqePD6r+jIO5z4M;_q9gDW-ju`D zz8Vg+t`&iWHk-3Gy&e)evAxN+(>z@ zM7iwdFOx|`o~s%MlPNjpPm_VFW;_bCGHU8e`g;MJhJ|OTjORlks{$ZqF*c*wcz*z1 z{gK>vZ(B=$|K}YI_*o@j(}rl4HLqgVReQV6uVDaP*n%+sx71!L?IvvF;KVr*hF3fZ z0unQ-d!I9dB>F{715Ul!m>SKN@)wGGj|#cXCGo=hH1BWBC{u9eBOZwE8dz07Nc6Lm z(QP7e3wBn_55t1e4T zpyz}}z!rK$^Xx_@(7>zzV#xH@W=Z9YJ9-I9NAWS zepeVN5)mFa+SQS%@xI$Lf#xH00)Si99n8X7y8XqikE?j1BOkthYxL<@uKxodS^qH$ z&_Yy=0=9`78Ck9<;U7mR$GKt+sBbc)Ayn?G9Q?}hmiaT`zXRBVIl_=Gd->(ouH!kF z5es+~HzX*gSm_R@$b?L7}Gv1O@ zAZJB%+*dQDxZ%w}sldi-^e2k{+x{~v4uNB7Imt9GK%Fygp0J!(@KKMIm#@dEmU9C+ z`gYvyyCkRi7$m5;HI*sG(Q~9M8GH9Wo6$k9Kv?#R=&X5V-)h^b1rb>q;53d7Eok{C z-ehpYPhS!}0bK*TU2uMC^;mKK>kCwf{}|nWArdolIz}F@yc7M$I0vePO#;xCP1LS;bk=Q{7iP$GbVl*o)-MRwZ813jVYij3U?v| z85mW?*)VwQUkHptHh;X zkR>`g0J6xo2gQ%{T#HHL!kq$#?Eipu{5%(UNm%25w|AEv{=DSH6OcZzH-p=LMmFb> zBzQp)s6hX2@jo4K=h24H)^B`{8QBiNi3tx1xID{~XqVN2NRg|uIe{K9I0HmBa$1C{ zcF7{>sZP37IzOa;3$;Hdg%r~pN7G_iHoSek(sJ+!6?X;@;S2y2KYXf$U8vq!f?dBk zfo@1hM>szT(4DqM6NFpE#7?kjU$@A%iBQOaM64f9sP6y9C6Mj5-G(_YsGvE3AX3+d z1_Qw(l6lz(nzydl+aQ(ZIs&YS3W+$-rZZX&#U3M%yeCT4YYA99446SSMwmlajoMS7 z{=Zt=7u3>z_1h#*r4|;Yn-9=&rwPvp+dN^DPCPvG1{Go~_?xRpTWGX1hj@ zuvi&Cr;1hq;9D`4GiyoW?9=H`NF)`3q+)PGjiX^w^Z<%F%xALv0WbF^(BFzA8?M~m zC0YStdSPu!$f@y-ZReLdTupFdCU}fn?Vnr%ib^I*qs)19{w<{92i_6ES;=g2^)&FF z$ee%FbO7v_BT?~-Kk;>*#xpb$@(gkhgR~N+9Y?ErfBKW!rhMnu{XmQr1x{!2k!8bh z*makfW_q=00Hu-w9M$zCfTX6=1Vt6DOf>N7(hjWHTc(;{S3i6ZCr=B~hXjY=p!v?- zZIlew8-wQ#BYw#e#DUzuuQdq2?gx+f0$N#dYvtTOyFOGo3M=E{2&Ih5K~uUw*66U; zm`yP&TbZbX1)-X{)*12}1S$q8wwK3UJ;DR~T8}H0Nm@e5Q*F*&Vu4bw0>`cWrpt0( z;l(|gb8Z9|akYv2gx@u5d!CBL$^&J?uxWq?b|HfNVr?1^%w`d@BM^-?=u_iRDo??oy=r~Pma^pkz6_iL5jY3(1=%Sp**>=w&6{DwzDmbn zvlf`7KNb~IaZDcrcrf_hB0FIuFF+Ux)ncqQl@s&`RdqERiH(W?b~Tu(1wH5{yaO*7 zy#I@jYJJmlS=|T4OT$448I_gthgk((oK|L2y#KvV_j*cVC=mZF#?hwaHwbFy%zhn! zWaT+87-$HsfI{8M3=ve)QK;+&2FWgAQmCR1YS_O+xmyJIuxFIj386tvH7aAeRPUbE zrp>14@%g4}K(==-aGFgzu79zLlGku6xdnD+ZW7`P;t;l*^jGB$j~WKA0)(}V)5kRr zv^}qPaUgjx$j5ZEFn6vnu@exec-ZbR8@1oV%glao33kv6k2prWPYv04)0K%@>Tsd% zZ+tui?#B78gxO3Opb4D`tvlUYvXQUhv+p-hA-8q{;LL+^ZfY=4F=i{uf})OTBTEv| zX6*VFTlk?Z*=2wc`z&o+46E+-OJ{xMK)%J`M|26evL7S|0N{CGs$iX8-%GF|bSk46 zXvTq(u&~iUMY0oX8lgki&VUfLV~C59u_TboIae~EcSOADMuiCN_`)kHhb1=cxZD62 zbjuh@{RNMCEpP%P90HW~QFc~BH&38%##RVyLZ)HF#0NN`hHs2GEqr;G6$G0Y@m3QJ!3YS`#e>`C({MN zGhM1*L2O6>7x(@|s4e#3!_yt#5Xs*L2b-v|C@NAERfP%(iWJr5CM6ahL_yz#f+5r`D%Uwy6k=?A~Dn)IgRkuf#!p!f~9ij}q9S+#d>IvFrX z#{ceifI+C}x!rR{++*J33SkF(2-K-OnzyG8$v}k|Xa~bnjm_8;2Pq63)pZHj_P8VU zm&Gx`k0mZ3uLCsHXFgonqpklIgFL&ZmCKFqeX(NcI3j=J>PB14F?h^vGgvwf_m-Iz z_qr=oVK5{q*wmGH39qdjtX48`k+T?lQvCns6Vb*~f(wto6mo3d4Gblc7Y9pqC}dl` z5?${xj;13yCKJr=&(E;m*!|1=e`a`Z404poa`WN|tT}L*`KPjd6PZ+XxN!!uZIj+b z^`3=tZ;7yPI!V8RYH7H%PD_y0to$wM2&Z()cicCKu$&r z{J`rWz4lGI~fe2RB<;DQW?te1M4mQNNDc%IA5<_0DX@LB0{26M*UN`>K&@bU13e# z8KeSL>LKuV#}g$GQsvwP+SNpcDj_tQP;8w0?^^+SudX=JW3jb#_5N>7}fCsVwBzuV38>&D=V6-@p+sO)ubzy(q5_>cy&)2ae+lI^`3 zd=y2C@K;0r40jE#A#hGm0{+1uy@xSKYf}G=YzX8rERtnttZx!qEGC-e9 z?o<=#`K_FjPz9mbXyqyFHC{5CK<@%5_!pn~51dir53i^gb$9}62Qzj?-eYN157tgr zzw9IwN<|VP&(tl@a#7;AF~95w_ZGg+#@kUGRu33Qt|70VG?2SKXS)_n?=%Ppi*~^k z7noe^kMlbLbHB#Q_YlNZ25&GAXbgHLw~YjsNM6Q(P4JiJ7Kj`M+}?zaE6@AmvVaup z$mc%REC1D=xa+7?eY27hssPdPm6dHxa{;x73!MEH-%^iTx0oX+ma1R-=ow~t?Hh0{ z3pU5*2DDQp547A#?VuSjfTlQ-XCuk-RxV5F=PIu**cWIEH6&fkWkQ5{Y_75+{5Kb9 z5f=PX_tVGx*%cy)(UJf*)JPiwsgZVD1wa!Nuw{Yx8LHzPE(!qJEoEw>N-)_~FWLkF z(Dabssnq9+7wjSi-zoJ!B0aqUr4lUHZc-kyBph~i09faQn}=I)$~g?u&qKfuo*LY( zXhT~Fcv^&f#77Wfe=3GnaxcCz?Mz*Z`Ko)F%K`9%mlXw4T{;=y=s>%m>vhGAmpXj? zKL|qU_YPFZ5QVH82LC3qYMJ{jG~)|4-|_Q=z1u>2u^HF|C9Rfo1 zA8?a701muH9~`sNfSo{>$pmp{xWdgj;5X`Go1h?>$WbLM^2mdp4h;scrA3Mr0&C%3 zlVORGde>RUhPkuJ$J;+b%*dSc2=t0mkpK2xuZhfq)e3{Y5gCj$D!Ia z&cWIVgd*YW3-*zn+GuyWV(DfLf*7H)3<#w9XqnbUMj91GT@Yrxo|L;|I_FB%4>9}`Luw-% zHr}QCK-t&*LzMs`Q-bAukG=!|w)?nu`iBs({Mu#j5j zthvA7z|lGxpqJm(p`mMGqzguZS_)vabBfX=EU_|G1qd2{i^VH*-=j6uMd(C9PF8G5 z{oyOs*9+z(+0Jg9Ve)f3RNv^lor=;F5)#nYDt5oR^2x=tCZ`!MMXWq_QPqiGQbe~K_ zhaL=Fr#rdXEI9^K#~Ks>h(E=v;|5)2-&TUdD?2Ucmx|9UbVHbBBTI%NiHL}NHWiFerpn8c zjicqsxTq*yDa?{4^TAlpygK@_D068hQaWRAYXhKo12w1Au*%cbA=vS zvGI=IzZ_J2@M*lNB;(OnBEy1A*&^GG5)aEQ*HJx#QAbn?rQ$^fMsPs~QzQS`be*!;S?l`h;1mlaS4 z?(UuCoe9t+c^~+wunhQSp_LLOc0BtBc6e#_4gi_PTyf%5;$WURz)9V@3Z?L+KT3$# zz|ML_A$?v>XE;UToLthz4@AIjTyrWs^C41b00Ft_d7OWKsKA*eSh3cnvT=eo{Ur1`=f zWXK;Y6D5!qI`CD45>*>@Tec88w(P}GRk0rNdTZyr} zRb_9M_-;PnD9^g8*cnX09=TK=0zIY!UWY5?c&_+O9s6Den|<-mNsKbiDL-1TtLK)C zNFShnz`mLrz=r&uYY*C9I~~QbN!b&iZoDKu?d1;o+{@#0UZD~2*5%5gRh#7tngenI zs_Wq?+Xd||sL}oT@`>dFM*mW~nS(%Cw1Q$OEU^`Z7%9dpcge}#ihvV{HzBb- zuWdkweCh`ue)e!hk~8IY>hBdTNWmA8DWHF)>Ua3P9;%oQaECye8u=Me%2S9?mhPTZ zXnqO?y6=w5nG^ma82npt=)-I3GOY3IS07-flTE=eAnytH>uFU6TjV4P?)!zS%jHGE z=|@W-=kEVd*t6E^aJ<`GbJ2wqG^DfjaQ{wQpRfS6ceLVPN zKo1lfw1)eXlAkbu@du+^1et(nLEFxO&#vcYl4$SYLy%aj_F|e9Y=lUp^)CQkP5XP` z+jY<|9&IyaQw%TU!0%8K*Om@Ceq#!qO9ArjLfN$x zmDN0Ne(pM+HHxD}4-YZK_KaN6ppZ~&%)r)8qh)gOHS>4z=7Pu7D}0X!c4pBDF_+Da zWrp&Nz}{}YX2xPb3ztH_Dq&krwyUkX>NQB&hrh3ROy9P0qw4l31Ra9hun3^#Ch|~; zUx#DAf=p*z7X|we@|B~y*Kir!WB~^tTS=oZ0!Fis*2XG-N(Cfm1EsI)ug03iIq)Pg z*;|0vzNTCnlo8 zqYkM_g~Wg;k}Xcfz4g489q|cwDLtZ^U&dg$j|*R z*F4seoD59U~4JT7QdqEx5gE6l1B`p2iOsiRed*um8#0{ z$>5uINtdgRRHn=Lvn^q-V__wIj!AV%Q7{Ta|9SnB{rIm?vS|Y~@ zOI6z|o=85y^qqg-2f^$h-t9;NN|O0oqZLSI4iuJ4&KhJLnFeI1pDW zL$_b}>`vXCm>d1b3Z}}(HlGypK-0i z82+j$DX)*%ten5)q(2~mF);&|4R~M!f9QLysCF>J6=~~89D3hMyn@NHIH(nr?Kr|O zH}rb&O!O{i=(Xo<+!|BIGC%Pr4E&H_@Inm7G{sLCv3<*kPRcK+Q_d~j$+j+#X_68!SD#Pg9huwH@JaEBDh#Bp#o z#lO620M3zhuqoM^zpDSAypSjUlyk>b1579}-A{`9miEq)=cyR(CZpQ3K+k`0RyozE z-HM56x$<-0UO_TJ4mgg6H4elrYbD5#`1o*%Se9nR)Eez@$0qRZz z<=VGP0O$>st*?K)ox*|i22OEWmkudqyW|D|a3x_1wG)yW+6|5@Esx6MM!sYf<{j}o zF~-U20pQNUg=E$|l`I)oY%5{ZPPDdz2ll2YsLJxa1Y}My5WaKxv=$xBJwv7aGPjcD zzIPp^mZ4OjRZv5WC2elp$?l-nh9A1+Qo4$Q^|8Z8C5WhjC zZO3eb78~!shMc~Qqur^YNCK}*+1%xBm<+|{-y>;>m~#K^+Hbkc?U7#zLeTtQ=N?`W@kc zio)1yjxOXFy|UXf`Ql_TcQBx~_59EI2$x{^6D_*xGe(4&E;K%eKv|BAw1yQNKym!n#L6Q$GpzUJ9llGHMp z&WVd)jBfIN{vnvV+?K!W3-4Bfjh)QRLlaqGszU0)waiebeHnw<0$W=ynfSVN39nan zy60pMx7;zt?W0~DgHAPaBICh52NwEWEQjcNaD(Yrm36IBaq$aJm-^Sda}*!G+GOCv{wd>*j zl!%KMcqLgxnTWl#Rf?=qT1j(94_w+_za(UezJIfJWT0XR;OPeVl2*kmu2rsUA+@zT zAV5=CNYBkcq+-;~_VBW|WgCBHe`oxOrGrqF_2+GW&S0Tm4v!M9KE+)^4p8%~Fx+0a zMN9@}sc!7#xDW!&U56l?vbviB|0p`Y;VGV{if4HnB@k4*k0_?Aapr#jHLIx2!_wgd$|);3f;Y*Ts(G#mu48|cO7#1aKFLm$8BW3j|_WfUs- zAjx;_zm+b1DVDJ$k2C7jCk;gjSXE85y0)GfPv=zmgN=&kWsGY*b=s z`k*Iv@PGgUP;i6M5nz?&5P7F!fQb~yt#-@xXnJty7A55Pz z8}N{FA7!EzPM68a1TDGMJSG#`2B0Ved!jZY5WChzdv0{rmD%7bo~zHs#m?Q25o*^B zzecE@aL(TndpK>GFn^y}%DBM$l_j`ve($w!SnBzH9@z6TcXK8h{nul%jP(YuscKF{YiI+?8g zkq}iwF1sasO!dDgC^Q9y405t6GS@z3@j}=V``za5wAj{KO}@dQ&Qt7~*64 zDJI!7bzo4l+iGdeMiR&hCdow4hTy)6a_YX43@p&??5#S;xUWtUCRtIE9t@8txt0hrmC&V=8fsW!7i>CKv>)| zg(~*aI-2#Dwlu*7&W-JwpW6jp^|)e6-gDVxr-AQW;9QbCQk{()4tk;O=7cw}&fdPY zVrZCAb$Uzagm$ zvvBi;WK#%8T3ii4+-bRbVjO(Cxvu#yN0D z2299+%WID~Lh`f9)!iI)ZIkn7Axd=RP*MK+k0>#K=cO6ZApiD6tKeE#DV*y2Ky&_l zA_gBYrL*O^G0`1Jf0m^!1++=O6@f1ZIA>dM0PfJK&9i>0g)K?nf`>?Pmd1%6CVJ)) z`T@MUcC0G5vzK0y8yjTMZt5l2C}Dw&da+= zP6I!!+?o2lzTj1mkVtxwE$JY?}wOSCaF z-HWU4ttD&igL<#CxUL2qP_dkt_v?n&>PKZX(}T^tuB=04!8s>U!K8ex=arFej5RG;=8%EA&y9esx6of>CZqTbWp zM!~%{-cdD}%zQg!^jv>h9ZB6fMAAfEOf_e*>XV%03V{*X(^Gef=Ib_Ul9V_74>EUuJB1q~!3E7-AYQd@azm;qz?4mGed@XsvXn zT@4QstXim955-zj*9kK?G`WZgTmi!qpZk8e!mM%;Az*0lQSv4!xOkmxq5a%9AR?vQXTFCteO}!W%DiOZ@jsIL0X0rw-h}gHCh%~o;0r>iv?51W9rqD z@j6ACZt!eOl@qGPq(b;5&1YCs9>p|6Yc#v9#3KPJV=z*rc!jF&PXm*9&}w}|wI>*w zx+jEU|cxM-kIz;dC&X=>^X30jIV3+8siLP z3uaA>bP6UM-n4{b1xC5-U90rZXT*#qzV}D9PrYzG59?X-yLgBM-mhXft}o<6C4N26-@EkK2rHcecH6cA8WwgYl-U z9;)b~%Is#_5Ww+Zl<#%?pXZ@dv3I%Q6A!E=h{MRWYWG>r&KL{UFHlgQ4q12DE_dUk zRPc3H_Y@E~mLv)Smy;XoFA%O(%5Tdv6wlN$m!E<<&sCCrwggOW=5uB44Nl!?ww1=x zk-;SI0u27ec?k}+ZH^~;84uSj)gH!$jBdOELPcM|=qD#tZZMC2F=Iyvtv|Zy2ands zoei`oJ|&EKtj}hyHSMz`0=~}RXL|Lj;C^%^5G8K{i23wN7SYZ{jaY^*zjpYf1eH2f zVq%RBKJhk-emRw3os>GWxDC;#p~tw|^FZ)Du-q(VozC z_xw&E{0=Xp;BKEqKF5)Q1T$jpiTJLBtMeOG)rq+lXj0d!E`d{-=+RFh{2w{XirBC{ znU{)T1>|pm{o7ir#1`Q0P)0bBo@w<-LhmFbBcMFYSIsChIrj z2RAVS@7(f<7)V}Vh0s z92jc_SE@-N!d=REcSG@3Qk~~_&w%Nz;a9UcFXlSKdpL}2Z+}On2}CAt3q&@BVsGS$ zR~{LnifCq&BSzZ1l`>0W`aEubzs(6m2lL}%d@*gn-0OU>3?q%15*my+BsT~|!f50_ zrjK>>yR(_+w2~Tas++BRCX=ZaXTiD;I`#!%=KF-eWJOaEj3=s6jpQrPs$ZvFB0Bnj zQjxs&*tC*I?DID!3zuneAQi8x-3FrnbFzcZi|`OT%dIrSSujx${lGBImoUq8^MO=D zzZBs{+)G;-5#vtoYKdtJ>3~Q-xZsDemguCd$i1a+wjFPnjZ)5@d|mw)QH#rTo_97#+JOAWm%M=l1H z6l}e$dZeIssGxiJU5kw{yXL+hY+B9+#kY=hR5ogVQeaEz1p{>8x`{H;yP>msamv$7 z_CpjY(co?dP!&!EmT!dz0B&C3IcED)+3Cy4yEiXy1pac!Iv@f4CHJ`Y_ss$jntsF% z7JpOM+QIBGTT=rMtc+n8e3X-Zya%1p#{mxfPhUiqsQsk8FVOS#b9jGV@7yPJ(5B4| z-kg-m7@Dmt`NoK|A?*Q58-xEDV>Vwm>?bf_Zq#M|-A1OnzYk8xkS*%ft?`b-C)CMS z#5BW9Ka@Is2i~0z?sZ!nkYY~-(Eu@R6n*)pU*z_auokX}j|$+MndB;@XX)QyIjU<= zDs?=t&D$il)GFd)TbF5|9}KWnZuvtNC|1_vRvtRP#h`LQlvttH-<9EQQsO?EH}7qw_&DoFyd^Z{r6)MYIBm>u|iY zVAH3;D{`%EEIx%J=;RLTrpsWEGvco6d4+nd?@aRRnje`D9{p&k0bknycyo(HWzeS{ z8qgw)qH^84>+FL#7)x7r`J_dow)YWyPnDe+6X=}jiDuDkf5}@8sPf44bVvP+tlDTK zTREh8F;tacu8=r=o{VqONq0(_nCv^jVHzScpX;=d%e2y6rM_2Lz6`Mc#X*wAi9ZfA|WZEbV`aK(%o^_px-&)ckVg&{(k-`Ywxx88_)B+?|P@= z@bmh9ocO1mW6~$QUE8FJziY5Q-!Kw|yIp=UaO1wYAKW;+{qP$@Tu-neEl`5Gcn#y$ za><1s^0>5S{aEvOF-y~A9hcjWHkl|;$IdEwL*a4k@vc{Wiya4OBTn`Zo%C5-$K!(v zzy-O6{w=nF4=m;5kHGz2g6*$3iE3Z=6mo9xpx-!@(gPy4z?Gzu0E?TY^5s* zq7kU%ws9;n&;Wx8+NW*nR0jCIj_<$q4O|IXYXuZ>@wn89L=9INuM0|{!PgA9LO)wCD3FaSGq9_;wZ23`}js0z0^)!7zr&*OKcC9-FhQ0IYgno-NCS9qAY zB^vag7gEGd-w11;mw$HQO$*!cSnxn2;8KR8KRnl$*O3Yu0v!$5+gN=@i@d3Hto>(Zk(@q_t5ut*||Cdajt;U3XAF&e~OB)XW}8b}oFN&>3tq zmi*x&CAur3g?P+8Z1c^eXkc2V!Tj*xfa7gunTtJQH{!Ur;R_8F(?^jh6?c^UVVfv> z^82?v?vy?4G*s`|Ur+9Re>t;ta@4E$Chx@I%gMt~ z&)Gpqb&h~LQU+p0_6xF28`2f-F#ocvXQJ=Y<%4?9)pnnQz0WkDcLGY6V^f-A>-qha z;o|5nI8wz%gce1wJkSBd4o2WJY91MYcO=dp&cgS7r>3yeU`}?8PZD@{jy_tI+$|}@ z?Z3O$lw}PHjp0v^_`K>?hx9|hNQ*p$lk}%xb`hIo;W@g+eb4^(`qy7i4pk>S6oP{8 zi+JsRYU}YQcg6hqo~b*6!*Inin8ziBaX9u)Q(gAJ3M|&W$3on7%q z1fDJG`v{f4q@ul4b`9^M-}~9o@7$vjSDXNTU6rk^Efd}1{USO_T@=^RT z71OsTOtPoe{OhmU1t7oGR#&W)UbdFR0o5CzK5q|hQ{wio`;(=;8X{sI3Vdu>mt8V> z$s>{bz%AjDh}hy|FQK_lpB`=}?2UQE zbW|)Cc&03ZvV5DDm)ArnOH#*-<^98r_AP2r15NI@S(EY68HtaaljrhHK+Ez`Q$)X9 zddtC0whSL2J7h>6>70N%oi^)F74}POqAP`*fsSTIpfS)rS+*{=@xxo3aNG29W%4*6 z2)J)Jq<~Hg+F=Vb&n?C%H!f@O7Si2wbzI%fJVzd2i_IjO59aliKPk zv7!bVD#axyFvQ|g&tPxn6>L8tl?PS_?;^#KOALkfs#h?)o?7-kOp7IU2g#4dL>FB? ziN;pT%&jji9~72vhO|30KJd)!F(OwfjHikv*m=WlzYo+|vNQvV+AZ`%V)U3$dDDWZ z!TCmTrT3d%U+spRM{7cTKWNg>(Gh$6?ya$0YHXNFwsPpMk?w$tHcFN|?zzeF-pmO9 zPtb8nVbWz+tv2E1xZZF1GaZ2te<=N0C;&$p>r9uTtrV!`oajZTvH&KF^T5qY++xDHQDbCPe`_kz8+Ig)l2-0mVl ztdQ-mM+cN6F=T0zfhuGAulXn4t=B_Hz9dYtStn+pELmYB+7yUvo89W|vp;towgr*m(+?cq)7IB_fVS3ub`Se>^)p zVqeZbd~fo7XZ&5@X@vmx1|3i}gVSi$Q@wngYo3$N{j=o6Q*n^-Y8o|`kS zl5*bPsp;{!#iH;ztJbDx5AzLpS{fCyHK>4)aJa*L!!U!)-m-9sDRxl<1FlF@QQETJ zY1rn)4Zmc@FFE;wjYR>+FZ2>@X?jok-Z^Pp*!BTf( z?)r=lW3E=<1W@0xe+6AP%O05u5#PP))sy`0VGs7Z7Bd~~vyOsu!B-tals$3rjhet8 zxc`axr;5bL3B!(NriPwmW}kzP5nu0Ktz_3u&H;NAU&rD(#`VFKklxR~s>DR}K@X>1 zXxbf$$FX+{8{;kfX6#T9YikFN+ZC^z^eR4eL5wcbQOwSTO zwt|{<`KTwKjdEGw8?|Vl-vU`)3BT9j=gk&!tUh$ey*c3}n{)40Ui||L*IJN=BTsfZ zNe#Se`cqHtgE4_mCad^Q`|C1pTSASmk^_;^1ACUJ{1Fu2K;N!|64n^t&7o6-;jG3rTEFb2pFoW zL_@W5SMP(vt!p7WZ3gAN;sRoOBdi0ITPsg^bu??yKc zHiHHqb5D~m4~J*O3nVoTYZC(f%a)4f+3Zm!Pd*SvgZfto9sPz(_1a_i_8n2fZEzLW z`H4{TmQq>6j=z1MVP*)aRzVP$f(05XHpd2C^^!M0>&i-Hrt_07qpHEXRfGDqo#G-t zcH9GqLWMx3u0QC^V1oX~`BCZI&KXHVO6Q54_n;|l1hISG=-_48IJxOm2QF7RO~+*_ z)YP7|^ONnf{#E|9jzJGZtX(*0*(U9zRkAK0j08DqtV>FH4PUD)gm0g|X5l`0VW24J zx2=`2@LL*jp{?yw^?KF?1`YrqE!;C0sCRGN8I+f9?--r;BV*zBIwAC$AY9okmeit2 ze^fg_rp~VHCA%4e#lcN)6a9J5Tp`+XF4+pNFfn?TQ~W+g*cEU##Tb8{f{9-$Y2H_9 zngDj#{X9$;8$F%Gd-e6;FzV6HHu62+;5Nle`XOUd^fCTb06v_WX<*5f7tX$1=Sw`+ z03FeGq|87b4JA3?wzV9s;5%bys?PrP(njL8TfqIEFs;W&BZKdm^7JV2iQK538ytl( zWCu>OL>#}_dyh{c!ZRb7qX@2KwyCuq;^rjr`jJ!$O+eAK=d5*gX-%JNuf8Z5khDEg zs_^zbm-O*R$;P-z#Tk*}Vo8T5;KKE4U*Rj?`vCd3SNMN^+Oltr+<6t*wQm(I@&b2d zK<^5kSK(x`XW!~n!a!E)!&SE|L}^j=WziN6z7RKz>2-it5_%`#WaapC?=^pdv4(Y0 zq|ZLoJTD$=WQ~YJJYt&!oscww8NXc6y8{bb#p3#-gU`XRrk>h~UHR1O)HG@_^~2WL zaIP=qN8tiHQ18KOSlvY+f(Wz8f4z}xWa(w=o(`8lELYM$TLYItn|4B~Z@g9^17!@kFaK4{6GL zONF{viu&cP4DJs<95Z~}M55hTB~~gxLX}I#t*rk;7%S=N0nIjG*HazWq6hxqL;-a< zAR_&^V#1iB&MJxf_*cd)!IS5`a2;CwxY2IbM^u3q`Nh$ZLaHjg_&~fM1cdYRWbBH| z{)I`}sn}3)^q`PxAGw*q#6@!a{nohsV@dD!?izEc&=(r#FM~*mC1yXKK^h>BVA@>CEwm#Cf>CtIEGkWwGUp12kHlcIQeZ}i^?tsS4-?g}MpjPo!8_hC@OM%aE9(D#AnGqzRnqng*!$X{r=^aUgP@B$5d9qk!98I&ciQW{H@5!kcgXgQYgJGr*?8DZM!`@GD^6PxWFL zIsSUO(Zr31Cf{4U<*bfD+uPX(Ds$I_W>0MAYJps20i*!q6P<6$IYJp}iYDfuY+`&f zj2~Ju#r@HX!nd-eH%(+Snpl>o3}p_#3WgDlJP)Tdk`5GW_U?j%-sMELcZuRrVJF?7 zGoI(iZZXpy5nD60L!29CfD!y;n56ArrEKeJ1T-jIZ8GkoVx%I}Xw-0TCh znrlCzBtxUXZ7AKdjBVzr4&sG{iaHFxE6OH3;bhyP^4rl8(xOVR%36;x-j`t}tbZ3sgB9@xxBP4zj zn6Kw3D1mvaC$HU+JlK_3-zNzPi|l!fByT3Gn);cEY(v+<@SADbIT&Ay_c=o?{o1f5 zDAQvhWWOzuT~bDX4!@@zOd@IxC1%|ynw?Y4-ocMx)IgJhOU`c|d4b*)aD$p>>PO(u z3a=MS@?r3D!#}1vB`A(q<}uCg6uLBxCQm8|;5e>r$vsf+g2!$gHaDkp^m$=yO2MAW zjJg+knNz~wokJU;0zq>NxOWw64{8zZT_nGH0#^=!U@N=(+@B2eg)^NL zkBIE}oFrEZhDH3yUT#beT~3mit>ON1ll~S*B&!b-k4x6IDm7f0z; zdF;GKH$LuMzaWw~?;*()BEs^$DJVF&W7pG2?w;Q#vnqP8gN9bL$Cp(3(aanABpRF@ z@8(;pol;(xYjrOA9-ftfJMTL(KZP@Ymd3@P$>2&b9;7cKHg5*~9G=ID?)%&eeTs%x zJKs3z{z7R6Dv$ zt8a{Bl!L!P+OzIR8l5=r^0QY5j6K`RdPpnDQ`LeuEq<0^4E#Npv1HEOXPX}RcN3nz zHSod(D!h)y@MWDk#WiL$DbH*IQTe(xY z&os_=O*>h5MdG7<%G$*7BX^_w)j~22>s{jQ-m^H{ydREh^jO-22kGY*cGQ<|>5|+i zGrAZL5ikcrWCT8DrI|);hX`goU6ROUU!HdFbDkx~ISkEVD$Y!aK_QIX{DlYmjj3u2 z5IBXVfQ+3akIJ>N7BicS9Xrx~p>NI9z$_XanRRh4Lg&FL5QHY;Gtdv=ftgA(Xk1DK zE5WPyuBIx}lty$DiAQtqp%8qdY_ItM!s!S4k#|Z_*-pkHIouP%)cXQYr_jDix1y|!>xSeJ$vHWUZfzCYqHz*dErxaGJxb+-QJ}GhiQ5u{A9sUNRt4Eh zs0LS!fzvTGdxEhrYTw?bc0ldkBq8$?2ZL+hl3p?4QL0*J*VaB-KU1OfO6ysR0SQ$T zs})9=zK+**d!{+r>p|%`^c9u<{s*<2i5SM2iQ<@rpEkl1TFVKNHV1vEo+Tg^$jnd(VrO72B z2#Qg2qG1Ea-v;%WN~thUY_yLAYebHJoRcu?&0lNZmK6w}LBIW+)OXZ5c(R)L{HWzH zVfobXmBltq$DCG7CfEXGyQASz=7iyO5IKY+rGpY1AMzr8qr_N&`h5F<@h8r`EbXO9 zn;d$`WPP`=*=uio_wIJ}O*iVTfc^DE_2dtqc0VyumW5-^8*Y=DrWkZc=Q!@4$ZjZ z7N1}8?pLBT9A8Tn7IQ_gM)x_B%QvaI=*RWb>lf?r(e>ry({D}Dy{rr zE0fXrC|$9yPrP%Wwzhiv{i7x?MP$l_Hq54sXt15a%2jF@GY=XYoX@JjC85vKW(1Wc zBB_drt#v~4nvj^Ku4S!Rl5c4*x!Vl_Qo;C4eoVXh3i(kE11s0C!BLm>zTE8A-00q7 zd&tSwQET$znS<%M*dm!8xjC!v<+b{D{9-l`F4k6qBH)^j zM&{dQHD~yP6gbXCA`WiL99cQv2~nPp37t#C-`W=60%sX6$-J!?R?_*h7()54*@}$a z+i=@F&uV|_=4VxwawyaKOHrR0zpHFZf$*W1vtnB`wqbMRQM6d)u24RzKsnd#aMbx& zWo%uifPAL?rg#m?;^%pf`L>}3>%sFq@&-}YcgAlhiEm0DrPACtLrEyhM#E<|qjGhk zAwtDd&u?s(XLu&Z@S@oCUSTx+Q8a=%(ichgz}?xv>li;`!u8GeTbeD~yMsp@KJ&<} zC3));_np4A=X9R5QRie+&1CgSHh(1T=Wv*Lz3@G{!nSkk>4(7TQq<)a2(Ubl6!!4R zR2B;_H7wDl%;lJfoae&7a5Ypddj?%M!mQuqZ%Fbc()FR&^oJsq1Sjd4YMM>k7hn6o zr=<4Vs`S^mc4}B@XAW&<$4U|!kE8^jds?zK`?i(;XJdn?lSg~GIzNWJF%PmpKG2ab z#l30lLjZX*GbascYUggH$V;xCIgEhnu7!{Aw|i%<)opCyClw{vmaH$I*;m#Z7rzdk z!YDd#RPAQ<^E}IyV5P7$PW%^{u*m-9?z>wu>8@&xA$nn<3(t%?4z8$FAXLwBrFpp; z1ka$+hZlnps<)#e^+bBaJ2||Ku*n0*^q??j0JfwFxs-oc-;aPmZ2JUkbLHtf&)JbS z{mz1~?55YrVA0=&)TjaH)PE8>aUTEF?sa zei;`gK5Z#h`u6zq*ZmKdX7aw~*KGIQJ}i3wqj9-mzbdT%_^4#0WVrm;y@>`Y*wM+& zGsKGxu358R$}RBt8fNMGN#pL=VR%K+2bWbx=Dvf*;=s44o1PGJ@u@!%iEa;M-Zy*) z-z;N@hSPO^I%KbN7*Ta`?N?5jK@Z`n_C8rdPuqD3?U7dGolz+?bdlTI?4P+)k~Q)? z?!&_Ck;hC$lJDLoh3=(aqD+fq+WhL6B9p$aAEsEnw@B+SqJJ)rQD}U1C@b*;>hl)r zSKJI51o*SRw|-E#ZOovk67&(34fmozUyixP%!#owEzR^?y|IL^(ia^X$t6~~KChme zmCre#Et54rfBbX4Lh^1EfCr6{H9{@iPtN9 z2tn?DOUVAikr@QDGuWh(y4qN{_{{~Abk;?4_gTIQWIxYva=`OVS9ytjdEQ{u5dA&be4cc-LU9pp2%8MGMW}j4!$VYZ zIz8;x1t-MFltrxyBzCG>OIl~Ek}V8I-pT-6;^U5U)7Tqp(Nyo?IOwNcP_Az@T-VZT zXa@a@U)$vkKU9y~cx<*G-C|WCrj-}je}$txURzsPUvvE92QkUR%z6;%*|z(VhsK6# zs!BGPcVd#7unBtt#f9$*GGOv@oa+RK%vId|HgWZ^Wkrv}mXp#r3wm^`BQr|6R*S-! z54(M-;+30bC)X$m;TD{(S~BF0z_9h4?(Mu8DI!|XL{n}BjDY1mb~>e>TW9d;JD2RlYK*~e%PsCr5#fs@`>jILRVDui`ReRpeF-`#t`mqBoJbk$szX zkHS>u0<>D1w_ZAUI2(XOcMu8!GaqaIbUWPoN)keKtOhlAGA<#k+iNE}_|pv&e=csn zCoIb}pz@w@c&FA6(OUrb|8@o3uqr&vdNC?69d=o{$2Jn_yOE~5*Nt6x#p3>MQjVDw zbgA7tR)CPDpigp)zx<%MW;^ZJdo*IhT&3Z5elBCNjf=(RT9rqy(q#*`r*U?@~d@N9u9 zCJD7~Yj>u98h)#N%I`%wh|Sr{&my={7H{Pt(7WAuPS>Y~>Rt8>1OWRpiBVD<%^l0I{g}NDZH=?qw0(BD?^XC~h6S~%XSw@TM zRcJ3lc!FKQ9W4LN?Y`b?_PkNytiSt44mZ(c#r=fP`T;KdV@2fqXQnk!B zx{@Ks)<1Qvdck;lIEF!Vn}0r5rPgK3k@;k#$>8o<47>n5sTiB4Cz-;Q62426{{$4| z-n%Ymxl!<~RTC9`IPu$j(?Do0XSde2%g)*sNE&G$8$YjE1$e``T#jAGlqhUyv6(z+ zzR666$>#5)tg^l>wKAntG<$W%S)lb^(B(L;w5$^uQMKm*mT#^a+{hy7Y3ZZ){^~UR z{i~=6`WL(6m%y5}Sq^1t*vLcYM%r2%BXhV`o=@`a@5dp1M2{aOq2#O|M@l`g@NeJA z^_O1775KbMMj&R^GCtDS@wkG2HE`Av8awv#yPB2_GP4!NPvVm$q(l4KvUb`aWzoDr z^)P_bq&OEpjHDM@%wt4|tktfD`h4?S5s$>(9(@r@U*}Yn{_CPOM|cg#y*={cIvCcL z9*Aw59%RPTKcU7X#_I@}dm9Y<|OJClR-A$|`qGJY&zwv_QQ>DPDb~K?+ zR0=9cddx06<_EKpA2Hn!H};hnk+>_NQ=AKX1VPGLGweHf@`- z{NmMjH@U;TS6XWu_@D!;XD~o08Jmg;y#y{3E>==s<42ci(>mqAMqX*Pa%1qgu!+ju zbkKW$O073Wgm0A{66J?&QixNwq^R6=kjp=&a`f`La|c7sb$oL`Zx zkuLUA;F&itf*E690{XgW^17H6_75s>`vm)^w)RMjYw?Zgo+L(>WCY}HnGva1sS2F( z^kb=r?T$v?dFX2OeDr zS2Gm1=Yw`M(K;8GCvFzZ-A0l*CA9A|!4TJPgGFNZVCb{pnx3ldzcxOD)*4w6qPZ+O z%+t526wNcl+noN}prksqVQMz@JLhiWxIL6rM3OC0%auuSB|{X(STSYQSMRQPO)oP< z^&-j92+zX)1*@MCIepzKI|e5g!4q8?aC~Nio%t&LcMAwaYn7-bH)^sLrmUU!1w@4~ zRnlk+1A1Mb$#%9`JvgwkWj1FS@KSu>~FrE=hp@ zrKcDhR+jTH#pr58P;-X3XmAcp+UUw2A{&BZ-JPkd;G{JV78rb%UA`A$e}+gAQp-`j z@Q~e4;nJO6S+W)Vd}PBKgY$;I*M8EWs3N?$$c2`Nk8?teIDYkk@2k2beR6g94L5)C z&FclPCLjW`aJ37dtqJ~|33{J&#N|q)YTFkK>r&s$4>-C7-6((Ft1F%CJemiEaN^U= zkrm-Aa*2z~w`s#14jo*>zUAUrK%E8lJ4v-LrVULrfkvBE4iHZ;7?4cs;LWUC>Qz}O z*`;)W+OQiD_s^eV3IA*X2gH4z?>Lr_wD1tK52E-U?&?g{i{)Mj#b#j0U;s}!$Jzb9 z?7=g}0+`lS&UQgG9_?oyassi*DVj{HM;Vwsks<{1b-ip9NWv(3%=a4^BY|cjf%X+s zjCdJxh}!dlMm1#gP2?nT879LuX+wUJQGNjxRO^TA%#R6GLbLnntTrX)2Chc0)uyGs5dQHWDEIN;oP?VOR5A1AJ5U1-`ore5TdUWq})`$_kYI;xoGCy_2&@1@zw=^nV>UtS8$}bf>nOW8)XmQ zuAABx+eo*eUECykb5;w8obJ{b608IM`P!cmquMZ&m+!q;$}i03LCMA~KEyg4z_(Md z65(fm{G`N${PU*M&rf9J5tQoh&X0z|^x>Cl5Gg&`H!hP4QrBlt3&OZ-C*2Al)<`rtEHN#>$79HH^~}%-EvPli{$k>{VSwhCI9J7 zE0sH9mY7EhKad#c3@}kCuF->z$U84?=m2+WXnci~uU1nssU@oGQTIipbXX9}8Gsh| z%lHht0}$gj$ZaerK81!-kC({zz+^8JSo}GT)wRMK;}F4Wc*zB_)wRW%5e4};CMd%a z(~$=~dw%H~*+cmX);%^?tq7#=%f#qKbG;2WPhnM*yv1KpB+Caxk%KZ{el$s9pY}@? zh>!pa1y>z9LvM-Z`CZdzPlpX{q$3RRjI%T_NofzJLl2}aMxrky5c8XwC6sByeob1U zkdyJm``ysfLKldK&xT9>lP!qHE{pPmkOXRfMZ6H9Q=M&a_Lv(j1At#$i@4~0NyzG6 z6pt}$JOCrzZ*&8X*ddUOgkR;tULhl9(84D9oEXGhjW+BWKQv5Oq4|j)n*a1&7w){8 z2YpC?v`)wj!^0*s*3htpmdDEFp-vWmHXWY_do=^#_W57Go#>*$^^Z6?zh0zSLV6qb z8YVA?+IRhyEhlS;2!gPo0zB4em{+6lYgLIaj&2zd{S|Bjt5y+Gh#vpA}7f4)h*#|5*~31yY_QN zRjRYzlreW- zlg6c)D{l8^@gc5UC6-Q+cq{%Ab0>auDM}aK(N7~yXXvew<`b0lzcSX%(b|`K-BI+$ zB2IfgJ)c1afWffmO|tB9-_bpHOGC~A!V%vB$xF=^8@VrEJdaN=m!bAc^}^NYhqp2e z#4n37mQ)N~s751dd^Z_0VME%Y#I*F$E0+V5cHc_fGVUx0%z=qjd>fH3J(frN2h)0E zd_|KI6f;zHF9qvHoW^dwFW^<9sIh<6XJ0b~9r&xJ2*_QI`U?cYC+Jfe z0N8Qx^Sy706&zpyrl8+Y+x`K;0rScRT+Yg9U+fD9KTA#)yRIcTv)>QB)`pQ#Dqthf zL#1_<&3sk{1dHg^;nuR*okK@vgPiOloV4BPCzn$Ys=ub}xPI(uqL(R>P-=M!13GuC zrtj>*Fc<`g4KTV2$!n>RrDFXiNaOoa&3YJZSRw6e-=wyHPjZwBPM7OjRapd@Z@E#2 zlO#2FLp7}_Oh~lqz_q%r;z|@=1!0jzv6}0sh1;~9B1F7 zzUyFjFRJ+kChh5L3lIx%L_+@ZJgM&YPZ{@K!p3hu{cUJVYkws#)$4T;+{v*8%NMaP zOG3wo14F7TCb;g+Eh3D0HzTDZ94(+krAtD}zfBEm7UKLC(DyuypF+Uk{oDZ3VAcVf z)08ei9Qqw#Bzi!-0D4QlE#06`BFSTggkK7|$nKHRW9n9})7Obe z>RJ0WEDdSHEGai_M(?(JqkfuVQ*JJz?)7B1$!RKw24(6i(kBI`+(<>Y)2$&Ul4@%j z0J{FHS&@Z{aqLyc?Pn#6R#bBdR^%Aw8L~0GqEdF2Q!Jb6IU- z+ML@d~6|67wzsTwJm0tal5TsRKza*ZH&&zV-aKIk+v) zZ3+=Lqgkw>VIEpbrC!MLZ>GheIywAze&rmE(0QB9SJWvl>vIm9dNG(4Ns))`F5T*W z2<9&JT6zp^e?VI|A4ZzOF~vlKK8oa0kLj;yjkf+%5kBZDsNzkE-kdO@$9R-CU{X!? zLQMfuV)(;0nI9-+73A((zDQ}oMu$3S>qiUITCkMS&B!+P0>e^zjJTv#)B=D<^g&7e zs(zy>DuElUr=+teTETj_w|Fp2&T1=|copBQW%s_1XKsg6IUL=x{ud?CaR2lcVL$QZ z*c@WWDxkQ)Hi>QyL1UU&xXV0FT1a9Ln0uJ1y7^1``bm2wgVAtuMb4a(QweHPAMQK^ zh(xHpJsy$R!373;BRoG@k*{Rw)YpvgR>AAm`35t1LqSYpvqUEq4YDfYRv5)!AL%il zEm9r%kSW0CUQk9^v4sAjrH&Uk>!4wSymx;s?~zZ59%jeXQs0MPba@ z1j>3snuyxeTeKm4dQWIYn+54M(_!ldOOUa}kO7w5Yx512Fx`dk+TR3&bF54pa9GXf z2yict?sPQ1>_+(We67!b8PgdH6}z^c{lY8x+o9QI;k2c6hXJGRzznsokb?udyS%*VXB%iy;2}WaMdn6w&oiq(V0xZDHUL;py zAc*<&Hv(3xf)T}E!wTtPxgj3Z;Q~^f&3J|+e(4_~^B(;F{VlPd6UAPjcUcl5voCS`d6=*sJsavebF zprAGoLxu~i7Qo0nroU(>umj(22B&m^p;(XlP~%kW=%_@5Z%O)yg?F=%3E9s*nNG@L zBYy3)74h$kH)?a$f~(u1-8;W*!huWGMa0yuMAp@t*q!(5{}Ozd^&@~P9gct96&DA; z=HT;_FYk`DK`{qGsZSnpOAcQAQ@-G%4ejUvtMo2)Dva4fI>9@KzD}pjwcJ3jJt?5t zlkbkGXz0dsleH6Wi-8P&UK_C8pzk+ahG8K4PfoQ@~l&$$kG925|6PxON5)Vo{= zXlxv#)j#d(>~uNwF z_OE((H^zOL6#OyU9`H#GHI=ZWKJ&Bt z8xA*krayxZ`HY5NyGT|yCfBwd^}xociY!2WPnfMzOrq`e>(xJ zrY)AODTLJTp{G#wF45WzZDB!*JPRVQ4H6<^oO1@DCy!7iuX;m9i+ z{H_frOCM3K9MF@`+q?L zkO76jNyAfcn6Q2Vyyngvg?(WrlwRRyUpfg?D|@QS`>l*FGpTTl1efl502+&XkeGPc<`Y z=L@d+7-AC*a|pxoIeu~Ph_c)FP8y}6;paP1DOq#MfJy59FSj!l+a9XH!4cgSw}y7c zY%Z6`NnYqtfuxU z@4-TRk!-(3hXZbgnxK$83$LciU*}H29|sZB zW7_OKe~IDtllueli!KXHso+s}tc_-AAd4-s%RFIz1i|y&Ge8C4dVO3W}GLhkNR9ioshig`>;Y^MURC7dHIX z|EsQ1^vcN@cWf-`sy~u^{p6w1)t5iwoQ#p^cxJuL$V^!NCY1%=wb)TZc6Gka!=dNx z-j{Ex+Dfy1Q%?;bw>HPhzJw2gab>4xAN`O=JmHx6PmY{oj=A%oD25b6^zrv&LUw{h zUPlN=pv091&NCp=M_$SNLFR2yx5~&FQ7#$3-P2#v>|$^f9++GJj1UAUtPyIis{b~^ z{K7l0jG!~_(ufE&kFWT%`NtB%g^}MaAxzNqSs5U?_}wnZ?=e(3O=S%#II3*Pi{?7X zv9{#0NF<9Dxa&P7LRX`A|1TYPVM`{;m%f>{Ww$Lc2L^}`R+H*_e&YX_YLwM|CH35o zBz=>jg~+IxR8L*l#Z@dh`b1n?J)t_#EW6`OI@^9^mFa0b)rO};NN>X#Sas)=E4i8VX*Qfr2voJX#x+g5FSartx z&TU2pJNKWT`j%@0d;UqLF*u@h9`vWmuh=U}p!`BpQiD%2@=%fV8&UCxw<=`ytgD1{D%kbMo^;N23`+J+f&o##Pw&6wPDdd6F8C=;QeIEO#o^Z+6dZR- zI6LMbK5qin+lW(6HrVWIVz+(w7s;kRIFlMKasKNLc$94-Z`9=klrNpzk;NMq7u11o ziP^u7EX8bZdh=X`CSDhmq~AU6_A^2C<;)3AEjH9;uSoE@OjPcgM%@nby4Z<8yZB!jl(?KirsI2M3>fRhSRcmI}(p8o41tnZ5*% zvp9f3YGhp!eLZlPWJE&MLQYSNY=2DEzh-BZ$V`lE|MHvv;^uDF zO*!i@RVfHii%pT^nUSV%CZnpZz8*@c2ck zP+dnAB7r<5i)YJ%dF6c495zJOS>PX|)-kd4m_@&Dxxa~cFVP;9&5R3bpsw^POG&P) z!?%RE`y81aH~DnsJFntjh(rHN08=egObeNtEar&Ho;>`&6fom*_T8Mi&P|)tkI0wc zSftT%Q7Kt#{i4^WPQdJ20nl=65P6O1`tNfgIOIx)SyT@tA)kFyVNehPFNj-@JJ2ED8r8)@hJ2{#vF^|)ScO@-yeZaL> zYj*!U2+3i2)hT`dLsoYI%t-Gs^Es;^n2{MIRCq2j(S~-Oi&-Hy=|uYSKkB2|_`(I$ z8~c5~@@8o2e3*fg&8Z!3goXCc5961_C%N`P>{59nudp0|Rm?S~gHJcJ4~-EwiG+RwPLQD06&gdX=aIuNyN1Bo=mXYPwj=+|@eQ8xAz)fgdju zc>Y1I0364XEBdnraBynM9Pez&_PGf_oyNzov3Atcru6;;FA`T7&r;qwir4KsAeuk_ zM>$O3do0O?(Zs^fY9%HY(?$Lg)KY{(yZ+MeRq(c$A8%S#zWJ)leNl;w==wWc@n`8~ zA@M8XNjFU`A!|a=ly5=J!irG0efDyUE+5vB-=lM;%aZHTOiC8$qr>G7&W2H#`E<2z z&#L-Gq{`U@M+n3|%KeV5YWye3UlCs%61<=E?t|EMl^W>c=~j{&wjQ5jiti9}ni{TC zva?w1=l@wpj}cHRpSz+2*ZGxG;zo=wAa7qQq{um37@#|kmuazSd0HDSO;Mt~(FMID zuK80v_y?yGa{5LHZtwBn%Oij)5%UYEdLVtfxyW$fVp#EHgW_=^yFhYBzmD~+oq0IfdCZKW+}$X zQJ=BY_aA=$10>c-*PFX8rzyNUV|}D4L-WkNqcCP{2eT`|wEF(OXxO_fGJcHjcsM^~ z#m+3RLiuQJRuY#*R9sfo7RSV!_R_e6$hQm?d(DRZM$@1ktSGLF|2OXsKMc5e>ixl0 z2}_^Dh*!1R;D49s?y;)ZpZIiPH+6p0ey~KQg&{kIjmtIe%f+|a`G}NjS!G6Y1fWQL zS0i37>5)Lxq|nPR{SeWj`JZpHep8tiR9>*lgBFp0GU8QU#>>u01dx<5Hog;qqNw(Y zkbs*XizA?mwNeA4XC@H80*y3reET7RcmBH`pfzT=;?TkTz%3KpJ!2tJdQxxTn%!C`q4_ z$k=jR_r85N^zbuemm&6-kT_G@)Nds8S;X_FmS_+D zKs?jRp=VwrZT*@Fn^finf&%8~J1AsS(cjOZnfs4J?5h$B!xsJj7C+x3Fv-7ex8kGm z)PTCzMY%h|*b>KdqV_`e3GE_UfcV{PyjAP_|En;LjRexOIM;`@{uK$77xre*@9$i@ zhemm!Nbx{K6hF+mR#a5>C=q-id+>#vB}0Sx0cO+@>A!6+24dttVuAQydY|!^5&ceus=Ywo;7& za1QI7n4-hEp*cU6o5F53LOgLpcfRpj{woZW2oP&ML_&|lfCP%SNP{iS_46oH1934q zz=k%sjP=ZjpA;_AxP4Oe9|?c@6F>Uf(Lq}TxEc}&nofMUTBf?2Lmoe z-w5#DW%t%I+Bh+v?bFhWhNu1m-1D0LTXqjw?JdD_|qM2{_#N^=fyqOsFBQ-%8iq&TskhnEDEH{rhGprOX`DRmVR ztj1c{f8eBm1??`}d78Yaa=91@hjLa+pMEGX%^n*Se91u{bCv0{lqN$-y$viPpbCi} ze91&NSrCa2Y|fkw^hJrsYybOsHO>p&&#!DwkK9&qaJVCH#T(IKSVE7%hEbr*WWW2- z3o+K3@XXAAd5%y$4}REz>%qZ4Aw5pwmyb0)x(lGO@&e#jDb^(i#!Itqp%Il3%Uo+e zG)sX(LVxmqr0NxAeQ#Dze&|{*GAi8?^1t{4-H^@4F3xI?6~+Rt3p`+B2>5X3Gezdl zTCz4!KLpglUH+PlPr{d{3>+TyCwL`TCn?|kwt_0`FYmd{p%eiNPRYWC005twp0svc zQNurPk+3e^2Spk?Rsksyd>C*^M1x^0&b)zOO4|>C*(6l&qKITP4W#sskm)kTmd~(C zhVkf(jl8EhrsB@Izp%Y$ryMW>Qy{*ih_3AhMO27O2srUH;Kp|M4x2e;Ugfuq=Rf}^ zj5;&o)(<@dBUkLR=t%MnOUqM5nj)91XbY{-p2J9>1HIvYdXbIT-5Q#|o7D1KZ2b!l z%r4lNpKwc=V~v1K;$%t2Qcu1g`gaJ>w_5wp&X|7(fNPNjl!@Tfy1v*Qwa&N%o)SpB z?E1%~!tPB#>aXjE_Bv-jhO8ekNoio8aNcuuZYaG|1&1!~D_%ZsOFE)B`ZxD7HGPX@ ziT#k~uAybQ^{O8lFm&!#$5^F*Jykn6R#k9ZNdAmL16LiOCU$@V)K~*$Y*PJ@vTCo#Vb&OSU#_|IfJGX ze98~K?3Vr)>LGd$IE9S?0>hT{Z)rc7?1et{n_1r4W!G+vfK!1Zvr=`2r9q~-ZDuu8=37s@KzvPXeLMM2lZ|VD~qjw}%oe6_mb7BO3(jb&d2{Ukhg}Fq1XAV6{xqFZRY; zuL_X;S26*!x7Uo|$DC%f-@G1O-M+E=&(NtTS2V0~HAaY<2v_(#h-%39Ys+S$+lrTZ z-+Nal9Y|-dso?4*cJx33bjC{cO7%SbU;iTrJof}ydgD*)-=AJm@6(Vqi*2W33}Z&TX<>E+zH zRqYyKbHWkJiw%uD;BwMWLBg_YB1K1qa#V)4^Iu^0`A2a=S}#GD3MeDZI#P|w+3M=+id?8Yq3)mq z6hMUb#QodL2R*e%wy^t1P>Qn_CgYkT?96!RDg#(19m4>mAyO27LKeKZNtKNpEJDkk zvZf`+UzG8-1GpaaPov>#b;gYv$jQQ?^A;t8VFT_OY!T@H=vt7r>%WwB3mjhz_V_dw z#WfDTlZFY=T@P=LXQ<}FuW!1=m}84%+3_x~nLTOk$SEiTrfa1@f=mhD>`Q?K`nYVL zfGVmo*~-z!zc=4Ow!D2tB6jJA5c<#B?%NCa&1_@At#$O~!r|!>DG%s2&Ktp1r89(V z@#1#-p~h?h4dseL)@axNL)lwLMcuYt!viCoO1FrFba$6X3n)k<-6ewLAOcc?(hVZr zDV@?F-6%?ThxB_6>U~|$``*vD*0+}YC&SEd&hvluOO~|YKUgTHsm)hq zUQ8FF2-;9j>QT)HV!!dv=H;nCXI|%;*Lyh3c|HF=l z;?m1l%6}6*Ub9zgfz-cD3hq&I;N`=O)boy(DH>)G9xKTDh}TWvoT~aU|Ea{5H!|I? z;7FoM11sWJpviwL0Lqa64F?dr3e5aF{m0pkGc($2Q*SD;cScY%tO{RrH&OEQj$0@I z1{~BH{OZ<`z?F2`ST{44VI;!%eez2G|97}}m~TUzMD;IfQCDRD24C;GPs^+nh|g`i zVdw9@9gPx4`Hex?k(R9?%7^$iArG+(Sz3S@50UAYFBSJ($ zSVG=KF6y~_@96nReR|PkxD5}L9D~}IdlSc0WcHH^KcO%OQe$+W?Y>n}eNO?ZYM}RyR$z)D86Iyy}w+5{|giqpDFP}0;3?uaYqAGr5UxN6lxuyc2?9rAX%8ytNCWC z^>CE&KeULqyi7z~iE3_)$RvKYOGQ;=VsP zl8HnjF41jn7>nP|KOKHvWUq2@jLB(u8ZrD@KorLhuYbi}R=Qia`gds%YPnmDLWD`0 zO_hHjyrvOQn<16mhFW{|Uq{BP>sbD4Bm~H{NL!oN9xvJqeLh9H?W`OTWCKYCjKh1X zF(M~s>EQY`BD}JP?$-DS{fcZ*&y@}{<;i*U?avK|g?#WRz-z*l{|$%;X+b1HD9!)T zGSicpHF)PZskZ&MNu;jZQui2=-<@kBd^m$qrZb$9s$nf9gU-F@?Z`9qrM>q&AF%v} zRsq*v+xGV1=)ZuF2ng+W=)YQdDJ-kcvGMoNhHxK3e=S%3Cgp(C?jTsRiHUZNfqn}l zc1dGU`RJr_`A0rpnKhBwa2YOl`{s-Swe%K;ysNS(*) zTkJ6fg@V@!-aTcY6O`sEt?+M!&XU4rQL4`FvdV9lD(hk|@^Y9$-}zZX+9x1@$wZ8| zs{FEBSDbE!F`z8ckHCd!u;XoODhK7iZZ>M`lDUTIQFxjv_~IE@ug=I&QfGP4mYNTv zZfBR{_bkvDe+f-_ZMz4DuDVFmSO6||$Hf99dm<5+QhAU$?q~dsjU0stqyY^WxH4F0 zZys2n^IYh~V9D)#ToE*R5fvvZ_5W~~xk?1LT*$1PvapW{$9?GW|Q2s3-!P&Nk&8FH^v zT2h+DZ2jLtHUYWXGvv)Rk_=G%NtEt}d4zi%G#ZN|-*Zn;3fYT0Rgz+c4SCvC9BX1Nt;(1T#7OWQk$Amt4lVr~CjZccI_gM_ln5)< zhV@Cz|L|WXr0x;vdsw-Y78xRI zs^J6B1MNQpP-Unt^@h(a8NfG@piZki+cB8>?0l`kCE+VE_%y4KGa>+93Np45NseTa z=k_addbC?XNS)=+P2Z^M9gfOWS}r~_S4Vq?H>Hb})~qa>+M(-39rcqyj->!u4o+SF zH-I`_YHGXmhPJir7Lq@vk_7ecsXaygL3gQQ*^#ee>Zoa(p(ns%ZpsSXs9EDOqmnx6 zH8{O5;tjb4akOk^=dDvpBz1)NOrD zaqic7eLCJ26V#i5ERJVefZX-}H$uo(W8iAy4by+|fc>7KgdBYl*RnPd=ra5>6+N){ z{T$OoPIW|^UThFH5%S~L8!DUb_4z23S$AO3FauX7b&s292*Nt*>#Y32vZq1#8iA~@PCaHxUUH#y5G?U6pFT{Ww~e%0Q4?8_yKhC z3Ro`0+5j<48XQ57;~HH*Q#bhjdB*{(i%Ufp)MH&SAU)-7#RE`FJiki>&U5}v4fze0 z6uA24N3tB^^1^(7H1+9OcG~34t6#J$p-^6QS98=s!H^Nf1^qpi zpC$Y?7kD7>D}PfTS%OjAO?~w1&#i~0Bgo4)NHktOU=Rj(W*M1p_SIrm-8(____bWe zEJsBNAn?1s4htHjcvSr4|dyI1nlVu*=3#93MM{gCcr6;8X8k;xcO?lE!-rvQtW)Ott@a_UXGu z%^|E>yQ%?TsZ~t)BWg34t-fxgFy4CLKL)Y*7&ZoZR;(5{1wXv?I zyc<1MB=vAf76q87NXIn0Rt*aNtjPbx6*v8j880!ecUPu#kP1t~$FToJ1%q_a-FEX%``25!K@37vH zU1bg=Adtd)Ct4G>#GAMkgGe3d(?GMEMBoOO(!-l1vhjkaI{#ObWUB&v9}a`@Ob^&3 z0zu1h7nRQX%=LQUJH4xNANdH>g9IyrzGOumnszGiQg&d0#yZMaK(#jVfiM^|6AN}B zYC%=k>g}$i*qyK$h=8Jj@P9z2XRL94OX6?FYt(M2W5Q^E;L<%3&)={=dHvCBW7)-- z=Ab1A$GhRV360xSCt0GW1E`y8cAz-he?AHseP9im|9HTd{2>P>&!A(V#x!&fmI@Jg zzoqNfksy7SIbH!Mpn-r@)sQ|zNC{4!E$4xurz89cACeX@f4|HSl}J!S3&Ly96pkk& zO()?VJCma5=HO#eG?JqNMS6imBd%33&iuzkO29MxhV>|R!4jfrnPxOfFYB%tvGZ7+ zacNy*syiTk8?o%o+9}(cvf`K6^?RFEsm#~6U%+R~p3k4lUoVSo))=4XgpdqTw*EVC zfSyL&k8a=F zEQEdOjt{w@TIv5XN?4875g@RN1v;vTUO z6NmsM%0avO!* zK!XmyLwgq&%IbsK5p>`cWc*-5ui5?i-2$F6AVZB;!L$bWk`cw@d@wh??e;BqERzOs zn_n=3uV{b1I~kAad?Q)tMCBPB*-VC(&^`jj6nmVYDbgf6vC(q{+ksDD47k=t3S;2H z(wMO|r&4t|AqFF+W^czhtuj5V@=**xKl;s}NC3s=zZev`w?JudN)FmzL1OTg)i_+g zD}_Hqsm}FsG+y`h=d#@H)29Zg?Z}Xy+#OL*Hrd{Ao;91owoIfbK+78C7zto|5|)41 z6psh#fP|1&{dbvQZIb;SiDe=5{oU{9uRt&SdVTc*k7zZ1XXlCBh@VdD_g;uKEvZR?p>?##}PeAJQML zeiG%J<`4GYtjID!A#Jr%-kIxU{Ox`xpd@R+*ph+h}hg;R*EN8%e2U*PA*kl33qwy{u>u2H0;gUs!IQt0*ABOWCLg5!{6DK^RhFM`L)0Aag zm|x;>wBT+#yurwEneUVh*DNWio@K1{mb+EgZ!Nw`oH(}EpKu+{Ys?G(#(Y9mC|2sc z9+VszUeA6ChL2cx#jP^i>7RwxDa zq7L#w7Qz{|tKRt4e-bvn-jDJw@jLsAhBpxH|8#64D+P1n zCV{O#m&(3fZ1K_PRVs1Ey>t_Z#CA)iyBG3{56I#Zh%wrqKV8J1^o5*32v8A0Wdwt{ zOZlATpEthRdF(zpDXXH__x4A@LvBc0=f|J>C$YvtH6D%)E2@_}b^=Zt3&ysV z?~GaoB?#FOmmDY*NyN!jXIm5p!5D94@d>2LhdS?@Iw?#QoA+%Rf(;3R=V{SBops~b zV*}3w=2XHL@G8F~hwsqEVD`K%FkFQSh{a7V@a2&E#+=eZ2FTBr_*h6un7@1++YuE$ zj-BJ@7WDNZXdaZ{hXRUB)Nvugk9zXgF%Crdk8haIs}BcQ{)AByXYL`N-cE6Y zwFP^9&-N)PhSbvC*~V#mFURZNhY1GZE+b?C*pkF0JTR-5oD0}#SPb{x+px_)hVGJn zVAa&ZN>j7l;3pBscK>93BtIzh2%2Uc&wBJeaqh}oYfgjUP#<+#4!7r&N@HVs(xq6tFlO!^XuDHZ$AbE zfK%{Xanb8JaY#uDF{(A(Ct{MaR~SHIuJuDI(X06lHlJQ}n;BidW`+A}k@3yBELw{R zCMjBraIWn{kn1!haJ|*-T?uGeE(d4|t8?qQ*5`^dBC)3Vipoo3mAz6=iKUadrWZd| z4{k4JWpf{|*ZwF$zKfxK2jt3fECwy){}SgHL2__1B@V3?|DR`Merx6|Pcq@wH$;+N zM3fXa$N7QAMx35URuD!gXJR!xW1dtPm6RoCE*xTovVuOmx6*haxbgbc);`!WdYN8Z zsgT!nzD?UbRsl$e6B5T~3D@*o*;T`!p}Qd$OAf0`wegc0j+++cAb9?;dk-fehA~@^ zn1{aE@?9gl&E2d9QcW!eNHx5nD!fJKog$HI4c>`^Th0Y@OX9pjj1%*m^a=K7pNiU; zQKn1kcSa_Pw1m|xj>W=QHqOP|x?h_lBz^~DB2Yb+qg5f7kO0<+#30y-{``LY43esC zK^oE@yb{i${}OliMOlLYT!t6GlRp+S?%BcrQq#_rWc+p|x-*Nt*yrM6Xi6?30^9(X z_u|`UoK4`T&NxQ63f@ME8WYz?rMvpE(9==KGB*2+HdYKY|4^2K07hT6KFM4l_eyW z2RL|qA#q)Ix+_+OnD=~5Zf$r3-t97W!3c>CY<@J7FO(W;ZGm> z2dtUD=#IlVf8yHheli=P!~snOOZyWEGq3c8=fSC~bXbtm&NfLV~Y zT{h!4Z0Ki{9xg}6V8#X_17FF^bl>6K`e9WcOSd%cNdve~pax#nQ2=G4MrDAgXgW^# zI<%8C@ZH9@z>PpzOO?1!IM1_>PyJOFoZr99H}qO6 zL3;#c;B!V~=6(z}b@CdacPZqU z6RsE<0dI%O)A01nG}%?XSWm5Cx$>ZCw}B>}oUef+7SL6qmp+>CraJ=}6*0U)JN{7E zZxu2Ku{74z5tqo1Cf#I9E~zMUd}Z4IasR{n{j$@g_O>O{1BJ^c)25KpYm0@o*!3CL zF?)~aR&zrEGj~PkoMf;+dFr`9d5U@i4JKUUvr3p3)i zu~x*xu*z5kL+WWmR@&dVmn*w@@JGNB!dk>jC62XQ^Ep2~03qv_nIEL$Y4~=5*FsD8 zOlp#(l5Y^l`;#CecozRrP`YFYEslM2-h8+oqSjtXbY#%*1k7h<201YNk>2n5G;}Bv%ljW%^0*9q_>H`#rA2YIjrG`NSUY1 zd4wfyDsF~H#P!kbIT-kA^|VjF<(Y599$n9JZE(LbZJ*f+30C+@F6?NUs>b{hcO4_W z1C;u3k!2cY&Y@ff7FAl+jr7@kce>w8?E0*Rq&e~FQ_)lPw(B)4KflYj?Hq;5y<|M> zghn!0mO*g*PqSFC$4T1XwrFS$@yc4jb`392=S!{t_>p}V27ANtz3f$p+NK9L>q zkosdDh-jbN_wEDH{Q#MqvUb@=xhMMEoX*X|zbpS)BwY!Ga%VJcqWp>}v%d@!bPI^J z4l94nj#!i9n?~J1de*t&>nUql-a={IhA(kl=U>44`HLEqh{U{tbErs~i=2j3*MeE# zM~oh1_+jGI;m;!Oy77{4mbp=FeOdRRT0kV{I$eeoJ$JE|xnx%pZs5`JV>+{Zgyo() zy4mn#{>JOJ^e1I$w6_5zWd`>8e@tGqFp9xjnwaBMd(aHgugtkX*`f3v%Mx{cj7-BC zapTu|&jO+&3o$UO-fUag_lboDh8LvSVXS~*H#JzJkANlubI+vbLp?R@bEe)XsO7yOzmybX?Djm7Tg zInlY^6|Z{3H0`r5iGkl=C^ywvB-~yJCT)g~Z8~`I03OTJq{)k^&#zId3yOhlnG;nM zU$)uHMGHoxEEL;UtQ9tu(LQ7e;TDeY zWG&@}Ro((Rj*4JKRd?GqLpK4{%!2Rrw%_M2E?Q6OtsLw$uS^~@=7!gM@7k_lRflAC zN*Cq$?lnIyT_{zo_O4jw>L{(d!FcZ0A0^$OV<(aTsR%nyQO1Cn;xHOf<$$k>)fthg zl9Oio*02N_a%y$iPFt*N+we91@=(sHVegr?M>&cjpc?V-8aap%(9!$fK0x0Hig*&Z~h+KWonl~AYYeX z%e{^8i#xk-n(^b=hb4x}B`2!36TRw&F+Nz$&z0Y~my3@;q+fWto zq-TBNduc^6yEXmAywVC54gUwg1Y1)>8~vO70mVdM#!MbK#{8Cs@E!267GDgVFWYNR zAsCI}7;x^)k2qei^}ltN!J5Wl(6m>E+mk+X?T1f1dS8FG-yp*UO}rJz0QcGPuK0ul zDuXEFltNv&(Kr!!hZmpAN~`EDOCKnuj%Lq2u3u)_~}8!Ou8NVDErfB=Nvm2IwU=P6dt7e*l)D7_e(;uP2AC0 zdXEH_aj3E9jn)&`?5er9#Z%l>M|v&18fsp8mz46=%X*g|Y11alw_@P-C$Gy}tzb_= z^29vWBsDw)31jUj)VL{sWbv6`!GaU$f^e@M{Stvde)|Hru>ZkX%gW^Qi6R~gDvATC z|K+yON_gMKC)cOZWoMOqIJQ~=SE$0jIcS8JLB51}+!Bd|SdPH6kOcDdwCG@I1{`ZR zZ30ncD@l%=2vp^QiPi&YmawX-bQ)u1bh8S2l(>~+Y2egMWf&YRsf|L zoble60E{*~yUpN58&&d&G~pXS*Yqhfzn5&4CCe_to+$3pchw%2BDn5PCVTkBRN0V` z)(OMJ!13$uX=3eGuh=tYN>=-4bJ`2iKN0M(#PPy`+UKoNQJSeGqz5J+ZUTC%3{FO*k_KpM(S%;D;^(Qp)~uUM)8FwCCGLV7Q?Yx|_{h@`N4{#QCSnv)u$r5pUu{%D;)TtJ zDe5!wW69hV8Y}tyvTS|5{R#6=qY@}E&PcG&qh08*m@#1SnlC&+3o#E4JJI9cd6P*1 zJZZoFM2^1?@EZs@jg<`-HKvvJfsnnx;97p~O3EP9Z9?Ch51d>`0V#=;q?=;CJ!>&# z1(e_P?~u>roA$?P84UE^6q056AVY8|Az%b{Lm(==hlg+55Zl*S_|@=_Q;)Krt(eo= zV8_xosq8$0qn{xtB#-?T(X!qtFM5+XqIA*>!r!S4eptT)N#s1+*%>G-29alNC= zT%CS7Y_Q6%C7%9J2Nj1^d0o!d>_eyEQ)mRB*sLk*VFT0P!_=b9uS-^%DPzU>^i~l@ zUur%gZ=poxufTOk28URF3baR!nmrv&ufGG+@fOT?q2mub$yBjfgs07nyE?2O;MjkK zb^Vju43%>~d7rW<2_tpLv2f zqOF8$OuU!{3I~ojIezlDY9nVZe}lvz>r~)*UpA>@I>UG@2~> z?4u)mz#Fu?i`;M5fd`@_T)TSbZa~fJm`nuS*AsElY1y=y-fMa9PY-H6Yo=pdo)|7Z zP9r#y#rJ~F8)7C!M`-Okl`=KUU(2`p$j=F-Y4-H84IZS^s<3|M?VXE3(rmCvyPv-{>_tz}@K@{~=}c1uTa zc%dE7y{%D6G$_jr{B5cu`@N?5H>jgS8!km7<-gs5f-K z>RZJe-OF2I`W(W;TOmFPll56g&wJSSfqT6`VB*k??(d&U(uXw`+Hg>BHBqdEhYH_Z z5Z7YZ^+zdV1o~>PA`BbIc?Xy zRXq^X`wShL2~i(_MNv^3WmtZ_rbpI0n?pF)=0EjsnPy%RQyKz}_J6`z`kYY8ddn3+hRZ~)#c7QJrQ`Df>xP3(72-BE6?QOXK2 z=CVUwh`^K$W+avs9jM(|oy*`!lyEYm5#4P-217+>Myf?aNFJU3O#vzFo*uTSI3m1q@Ydlb@>zXH#Q<1ZHHc%ZBLWc91r;(0%Cf7U)&05Q>0=9DKJ z^^)k2+kGRKrNr>c(9~p5sj5#Sk&x21oWsPJ2WEFEV=it2Hgfoi)Fbn|*j^9lSH0DE zzxl%FU$U(r zNobUYreX){-mf|tR~_MbvjhOr%R|;FUrT(c8R82O?kowOVWdO7GOy1@dQJCKgr+}d zJG99=;KFrJ9&}1Owa6F%;qiO+S977Gqh^qK7@~Q+%7;I}!qE}_Q#qqd8_pfRxkBKn z^N368z8QrOC20s4hec3H6iaO*NMO(x#9ak^PLzym+oAy5N+SD7xZXzAI=B5WaPJ+) zi|Gv?zC%ng!Od!zd07qDd&EDY+yJr*W94qak9NctS0Mr8Y+FMv_|~!26N+ZQ)NHD;C)7TijmF%q;*NnDQi!fg;;eR zj$Sn!Dad2KQ{j%vaz;S@`O^xL;PzsORvgnAiA4cAdCMN1EcVIVs}|f++i!lAAxvLc zk!2~5g5Fc&RcO^dAOMV*&ZMo<&;}sLqU$1(ES^-;E0>E47rzTqbU@@&oXL1~!sWG4 za>^&U@$b|Obkg5orlk?U?=Cke(oA`pt0Gq}TgiwPtXC<>DQy{eyi8Y%5$~Y9eQ&Gi zGDud2EdGrgkOp%6;F@g-OPJcD%lRiX(Ud9Tgjms0PA6;*|2=A%8J091juc1iHi@T2 zfx20!$|f!wKlnkWe3#Q*4^hLRW%IH59{VQOSDCYk3b_=M&LzAD6E{px^YH%os?iES zp6~U>J<9!}2Oj*IAnKdaZ(n(bvk!`dJ-)A5dnCy9r)sv20RPb+sM?5s+hgEb>YAdd z2m3YdFFuF?f`^6C+Uxaw|0kKMiVytnv7yY%s8GNwg#K0S{B+;_o$J0He<_jLi0L3; z5E8VYuZ?YTjps|KX<`Y<80s=S^&`TG%-a;LuzlP)4iV=pLvK|<)D1U*<D~)T}60 zqa+)PpF_N4Z6~O!4od&kg}7;9YZT@f)H^j6 zq^HPYO=jFXRzZw%t|n>tJIAD_IcCstIuPjf6?HJ8GSZarO+x{PJfgWH`Z7P(fsA&_-hUqN& zEi~|nKRNy+2`C-{dd^aOUiMqG@TWXW?oIvT%J39G=C!{Rjjrv48=Z&scuvl*4%tYTAX`8s0nk9w%+r4=f7TJM{9vmt1{I1B_gZ5zQ5D zlw;whn|P^Uoatpyn1jupYp`w5f*5gqvh?9S@)7IIUq|A}PNbVt!4c|KP|Jm$#^ z3OSFfuj3sxYDnmsLsPy|CUX5$gHs}26&UkC;sq+Bm+R4gO_D92XqpnVW71iF{hF8$ z=F7l<7KHA8>>>pGtjM_+Pv;V3IPH*L!&ih1PgYr4?0Fu9XTBYIt>%9vX#sjH6$7!| z*rpP*HuO_$3Uoa6v!RvS_K*Yyuux_~D|;Lz2=tpW(|Q%Je$+?a51no;MN=Gb0VV(` z|Hw%@#=AoTii9WoSQW*dxGT_(}N&MD&D6O@yGedtiG^?*QfL=EJ9K z$!r^{rEZI-XwnfpK^DEfKQvGpsDasgzl51`kf4La0)VF<7RRaN)owVNV4g0D-*)NI93D@F?CA|Byjb5Q&EfN)BBqGToyynwtBUP$xC1DQZ*>_wK!hqt;;dX~L zE2%!`y+|3vhn>5a6if~D5c6CrTTZ`u{Df=~pb9&vBwr=S z#=bi09JkY=Zc1GIagGyChsUpT%>ZU?j21J*TimQd*56}X_RC!$9@zcp8Z%G^jLu4X zrl?2r=-N@epMq$4;5)B+V@NkOL2_PrdbdOZ>_&L&s#ozVDH5;v>H%r92+NC&fEMs& z|HurvUX{vU!RfhdZ~67<>N`zFyon?Om+6Et;GGSPm{1gIBG4SjPc(inVUim#-uYRj z2Z6gdqtzHK0^G<6n=j+WKz5eJNjL_L6wupF>dWkhA_}f?G@8=Qn_|$E&OU z?(2cUG^v>&NL1m>q#qnH!8;L@QyE$)XzulO2sf4Q>)g|N`M_w}P!!-L>xBTiMkS93 ztZNQ1#mMtHbhbu8xwG))GKY_xIK&xvZrzk&Ii!Xg9lqB)3)TZ-rV%D|azBAwoaZ`# z?Y=q`mxV+^<=1zI|HLs|l!FKPd77zAG$aAz{d^F15x~0s5^)QjDn5|~*d;R&WQP_` zY1xIdXsWH##2#wCnMS?E+NF2T;YTPQvd^`Y^%>`!T80JWq+)Q*0JRWgDgDyL@@u31 zGNk>n8eKQ5lf?x#y5nn3D@Y#ZU_UVPXFBlh)I?y77B4J5_4%3)suHiwo>S9jQbx@O z=Nk*^TMgY{!Nmjkonx>tzn*I+LpG?daX+(~vXi;%AO*hlFbrj~tGblW)i=Ew><$){*iOUUO6RMo1Db$4;o zK-Pk_Rw+sO><=Ab1dc#Eem{Mp)hFWfi{eo%zY6^ZWMbHV~-as1!8TB8lM zusr+by3uOfvx20u59M`@@`e$m&1y{Kl3Tu zTHQAEy13ySJK|Hpf!LMCN=gbjZ~1$@NhCroLaq5$M6xc4539bY0Y{2%cimxV{CgL# zQaaa?KfX3!-o z#bd2E#R;6}Jv3fY8B=Rw0cSg$dz}m^Haes}J@c|vw0fzvkFMS`&xYF+KP+3=Y2K9k zI`vrRNC7+WiwBF2t~}>`ObnYk_W3{ZX&ZXB#TVbu0q*0Z1!hj2Nf* zJ*#9N&+{egnHja&=N~WJ&pGF=`hTtYJQ{c;e-+5R*{8yNM^cM!t)+B^ZO%F}w}xx1 zj@Ei(ytD5W(c5{d0nt3mJ;g@+q|Nn#tL3T38Rf_$#Zgq^!BuvFpB9Tn9Opkdl=W(7 z;oNF)^$q8Q;kIro`WNx1%u_XErXKCCro9Rp(EGfLAvSr`PPIkayV&n^Rx-Z&fS^j% z4C12Wf{dGor?-A2Lo+VWS*7o2^-vRICszJ+U*#n1ol@9ZuZMDbR0DF`NZP`dO$MfF z^{WQ#@a%L*r|Nlik$;zkgL6u`%)AECf*th;)MYE3PBd5))4wQ4#h}=g;#;!*&Lm=o`V_D=m zY9wjsOX9@_6CW6B$Jm^(-c93N*}0unZs#7mGoUaE({WETUQ>V z7{N*I&UUv+rgKFIL<20S;)F3|lFPAuJ1ZjZ(yqDtvL_vF=_9Tr>fVo?fb#&`?z#Sn zC2@V^O3aQSD{btkx7rg$ON4zeqFazC35NTuVwujDdE*0KJ}Z7r+r1{)Rsua>WgG@I zqtBz}kIzb-Cn{_OYk6)j*;w#g?B}L;rjR@xh)oi4(P1@D2vk6nj_>t7{2E!S*gwV5 zKCNNP^PT*CM8hj%jKI3`^vCB{bJ%E>N*{}ReJfDNf?p~|X=8u#CW?9h)or93?|+Zs zCJ7OrdQmA`)Z{i!OYeHs>FR!c5qI9_`X>wHdEuJ&3Wb714xb|__e!P+OO zL`{k~OlQ2cyz`z6FSZ?WyewBr$Ia_tPOyrhm084%f6Y?uew5HiDY+cU%aEgr05-Ar z);@Z|pN%(XYR0bf_PVU1pf#>teyZ5`*g<^73cK-GZn6+@8S*NISDhOxjrB~IVq!A~ zKZ;L|a7_KhdVD3RIecVoP7#6#4)7kb*e1-hqkT#^)v-Tg3O_`ne#2rzeQ8aY<1R2L ztyxFsippZ6BRsPC0n&c<-9c92gHR5CneAS8Ac{$B?&v3SIak$u#3gaj9>@wMiOy_W z*fqiq^Gua>1CE6QI9;8o6lYr_&?YbU-5OgO);a2!eSas%Q?;kuHZGO2?$h_UrfI;& zFqFeD*sDQbcY-qSc-jJmLx}5a<)w;GZ{4e?@2aq_^T>QMhUGXMBt>pcn`O8AOz)og zff&;7jUq7ePMf^xXdgTKNa`&LCCJs`$fF0~JE=tpS(F7Zk;dMAqt8ueLVnb2|8=dDNfb6i#V_BIap zg}6UZkar*BmfPN3*rOfEnVEXZ<`L%oe16hzhfR9$#93AOURRDZXF zGw0|BOg@!s^7X~4OVQ)4xlOjsON>jk-M*&Qav0anPQOfu$t$3nAvt3svGXRpy&qI9pF*)_(z` zGQyAVDTzJz_Ht{IUt}bG;yS)QKfVfjI9lVPV*TQg_>Ugnuev09qJay936{(eUbi3_%H=anavu}X+7CAoI%y)csWSSe=l~AB_Tf|e;rC0nF zE(D^{y_<8HUVN0*sUd=VYU#e^bT@mfE2j6G?AAZAY3a^hj@yWP(WMI~x7D1t&)kok z#g5bw#%KiTpDi|{8aojhCNS-2=u(4fgN&g1_!Ii~+P-Df5J=7k6;JCE458s75&G`? z-}J-m1S0Bf*?WBTz0?l+UKXWml==Vq;(Bf{8y)Xy`tttL*O*khZ1OH!D@VGLH08_cn0CinQhSa8#9VK_CR5O&^}k zH8u#@e~=x>bxTm`^|b*f^&~Q-NNLeF#9X<*>W$k*yzLNs_T%UjH|k4+a7@&0wi&Pb zcB85Z%)96~W`0?ThZL-M9IGU{)1ywJja&1LLFtPoUuE<8wcQEs94>^7ctx zg38`(D4f>wC2Rpw2+BGn*DeJ*@xq?epH(&1w|c!7eJ`h4dtx4Bnn4=GN*40^Ibpjg z2=a_5!@W499210+TJwb)c-_Rg1Pol@ne?1Z*l)QT1y-E1y|Jiwy1R8At8A;^z-9{e zfwvdt1IJV)sPA7cz*h0_YMj^krPnz!J=;a{g$to61paucKIU)9bLM1v#ps`aaHt9k zs`pe&Nrz#)3oeZ7rDYO7y$z1ZpsRP?b$aLdQbX#4@0_rSl*n#g(roh8b(g~Ae$O9F zUh6-7o9rlD`y}=*lnAKI{mKUhGE7 z3|x@Xi+$6#LLNMa3KdN=L*L%vgmueGw}@KBLJ5Kbwm#d-Nei6gXdr{DBZG-)K6f2G zKkU=liYNva_$Ku*PjiHut7M+f3qap6?^SWlFbC%lTwOSwjfic!YTt8P5-+1T;LpiS zOb>avS;n*H|Lw)KuRJ)*3}Q2cpS>DRGQU^6$l7&8c9;%$C7^1FgULEe6xkBFmb*fp zEa>oZ)R5}6iL1|X>h=yfm7}sDFibma(!Yfq!-+Q`gK)PHXFViF$Auj<@T;V<7o%X zr+B0Hp^I$hF$izW0(IXb2xD8V#X@=73tjEx0*K2I8G|5@v_8tykslM3-(vY5eY0d) zP=y^|x>f`rE()~ObBTQ~dWwVTJ#sd_d@J?9Db>6BT7^vsOKt6?%E5L1&{GU8K}-63B#%NQQh5V5(&Vt;l#O(oAyj<}yxkhR{n4eNNSv(aSULRQ zD3`5~^)>D&2rUj=+{v?xU=c%GAN}I^vUvwruttO1#`gQ9S#^lS-t2LyP*be)&tgkt z+!2wfkLi$DI_&ZWGst5%b~5gUiGnN zYgIeN+-#GVy(|aelig_-m*J0upKsHCmSB<5)bX(2ckQ#0mU4yGYzZSzzgE((qVpcK z**-_pKm*5?#B-gv;=$)1jU2Pg=KvQ^x+-Z(6}Fuga}Vl&sJDei_l}yxMs0`#NZ;A0i`#z^X)U#-#k& zdPnIhe7f@)7A0{~aEPnjVq>cK6Zkn^Y)@WGY^wHRHV$b$i0MG$M^YCtg|u(VHcDB1 z-!PeVUkG@5aB1P`M{`Uuw@KD3Z`REPWARCkQg_MFe!uW^v7A=Vw%AV_ymloU+N7s@ zS)KoPRMjW-OAxoupT!PR>OP-fDWBx+t9-UTNy1n6N<>JiG9rccoHhSw63VR1_m{K^ z8n`I&nbBV&SP#>}zE3qed^MLd4E5uqBoy*~aB_JoFAM@{&UjzpHA3{csZ2>IwI8;rU@E;-QF1ax z138|dIWwrurkLcek3c!*U3mtS;rVT>gfCR`OMok#G2y6 zr+G=*9qBMxFT)bkDSNRxmcBsu_b)mc59-e8y`P+3KgfEIfGOAx64*QIs8qY+6{j@m z(b}s5x}v8jRLu6{f>fu|HK3m?E=>AsmmY!XzTcrC0M@bkCVe>%`8(46x+Qz$-sM>9N;Zc1jCl zGNz}HUK3O00_oF?^6cAH9p$v`=%=r}zKiB6M^y&T1tN<0Y%Hv;=MQw}6~eJYKM^!N zayfb7-Z=8{hFax4YiW9XQX=%rZk)$43@fX;XWke)Mmo=2;S_R}~+5T@pb_ zroyfL8OH6&hLQTVQ>li zLA+b)Zo`ICuKHYDZNJqDB?}E@3fWFi>`yy$&QzVM!Um{7sQEwbeP>&fOVsWYqM&r8 zC{k5Wkh(#dp+qczROua+UIIu}YS@BE7o>v_M7n^K0MbRNfq+!$HS{8&6ba!>_Icm` za6X)T$;FjnvS!ViRqlIb9#bH6sx*B>SQbYn@rMVco8w1tUMR`3@}_RTa>vjKTICBZ z3wWVpfw-NZ8k{*fLW%C@McRFN&nYQFHDKmLGDw(^a+sHy=m0_Qgbigb{B$oVSWN8M zX&}xXAF@g%l+Ka7L_mhVBTsBdZ=Ed0%)>n-&i;eH0)z1H6;Lhlf(lrHHHCkT+xU|e zKqwPsU^l2%$BS`L&jx>e_|KE0hM%d$I+S!2OVWK&Q7jha;_}na6qskFRtUERvI8T;5+QfTl>d&G)|BtV*8#$1@;56{=?rHN*XXlm+vBf{aXc1vn{kq`r<6|+A?fuYu;*pK&)cD6oiEx25 z8Q~9(*Vw|`o#w?%<6)ju*hg&CjIFHf2JM;(DZsK~fo0{{RL+l-Z5|NWNe^S2Sb+I%88cHiWoEIF}-!Q+SyIX@f6qk!0uqFl1H3P{H0g|wo4@sj#Bgn5oMS2Ri5jItXE$XJXhOZn+*fiEe%2==@x!kEfz6?xXR; zLBqmSYtH8_Jt)Dd4|=3dAfB~cj?SkK9!jY3Q^hkls-|c9>CJzC1w5WVh(}2OzxDN` z<>lijKV*|ImClxt@iyZcv%nBaL!xC-(RFNSI%Ae;VroR5ZLi6Hmg}+*8+HFoI+2?u zjZ5yP`*2qR2_D!ndY;kqfcWGfIjF+0HiI`gZA(^lSr#j?>GX0_HWfAD&yTsoK&!CS z@?BMMMbBZZN&0%o>JHzdPDnHQwmAAOMz|ZYRo3~csRFY4yCsEroQ3`MeX60W#!yqo z1oOG7v+)G7;?*^lz{<6sg7NS7X-7DGB)Fp-wgqS0PblQo!)4Q-h~ zyyd*oD=WF_ppE-g>#2yDtQ)tz2a7L^(csv}31`V*gU7s%~IxEev1Scw}+WZIf+N9%ZIgXfrV#RpeJ> z=c|qL=Wp3es0U7#R{J*dld8FfF$)PaKZ(z>vD%@n;f<{ogna-(6l>Wto2M%Qh={*~ z?pe`V8DH_b_-Kc?;vh2%&j=f5*aoChM;kZ?S3-7yoP&KFh3o4@!f(ekF~% zt`i@D@E|<3S&bm@o1bfw>#F5XjIVC;rgK_;aR1*BrqZ50nI_YdWDQN-751~RJKP?X zUKOHixp@vk&JveazcK{cm_Gb*a!D!SEiA<2($5k*>m*pzvCnRdxp8FpB`dDEWq1hNDX_?~aIc?-u zZ)~+rL$!_s+L|BPdvTsKU5Co^c{H`Q9gr7IoSAujy0cYs-J$nR;wm2f={_>2QQ^y&JBoAy z!OOdQ%0WjMi15jrYr^vkc|X{}ghsEfkF6^nch7i7EooNiPr=)6r>+vfmV{+P`sB@> za(jBIO)=L#t&Y9X)W;Ac)p=aoj72WRaWLnrti9(>PhKY+(?h?cDWLw80jqtD1LuIX zwx_F;vk7pPs?l-8pK6UX8GOy4QqN5GQl%A5T9GT(H@wVAj(lnESrQ@rJ`NE?CnjbJCO9 zha{k{T(qKAqJRrd=)lZ}E*Ny{(pwwPo7+kE0rU>KO)=etgxBo_Pjf5&P&_gDA&&)% z^kN#jb0cWicYnyHahFqKGTt(bq8%4pXVN6#Xv6MV+GHw^n6w6eFoZ z`8lMP)LLq=RbWIfG2WVF=zI4ea8b^tIQe(%4d8AQJh-w#S>|^(9=v9wNDRb*}584lZ}2cRJS^?WfW4XFWbA- z*+|=>M){WZ%}a13#R9cWD8LhJ+DkLv2UFgMpfw9AK^Ic1U>}MzuGe!Tu+|eRK&69vAg=!f7s1vcqL$flFGz5< z#dv_e7}RUTX3MwT!I~Ca&9j$9h-a@b!Jq=a)1CLzGYazjW_PlwMa)q^C|WV|!4~){ z4{UzxsT+*pq~r}>rCwj*Zai!exHO_UJ+E=Pa7d&5!$lOkE=M*p2e_?2n^dver)ygK z>Wz9nN?5%2ts8!L=p}BaW%hXzpNqe~p|XV6Fv}0-8e2i`f(;vOwM>{Cog*7p^22PM zIxA2Meu=E+{dABy)58=LO!*M3?dPCq?BvcL)LEwC{E2lD__;9J*{Xn=%KUg%ePB2? zH?buOZ@VL?P3|BZ<(3Esz*G_+rFUA&xY#^H0Vw3)Wl817SUq?@Kk)XAYwSIf{(XYi z9Ud6!PRZ++KAw`)0o7RMKu;f(iO;P2WY}c&+O&wM-LeaxH!NLf%1ZQFA*M{MdW*6l z$)158VEw}ah2Ji@&58EUf+@|rtvA!R$LhR%#-FlzQPtkK2ODf;{Bt?f%Jff+EQlgF zkz1)SllW|KT~|y<;irO6i8RlYRBoB>%7&WPNP$?Ui;k3CThDBa2*T(AONV1Oh!Vtvyw$%oi#S+eC|(;eJ1 zlH8oJ7b9C;qA9C=7A&wUyre^9VbW33NC`k?S%K!k?mWxflIqIuijv`7*nA~v%b(p= zt7tS21bHpxzR%&VcdcKY4LsW&+>jyPYh0j1Kx%HPADa*asZ10^N#GntSI6jlLflq# zdMQYygOmLW3_mU%*3%u#A-%-9&u1DL@;!PP>sd3v2X4XiL1=3|9N5?JX%-jDU}>#w zsi9y){!^bvK)To|(}8LEsT@(%joa>zPK(z%+jdHmh{r!)@Kd=Tzrsm?#84O{SR?Kj z=`vS^-w-fyiVz4IdRN~xk$&)frq$ImStR7IXZ`c=`V^f4aSBwZ}ngoM21XgT}=@w{!fc^I5&2+pj4AFjYC2 zBa3o0`meau#|4WhWTSfTYCN1!GB$H_zQy(~y6&)vqq87xuiZ?drTZw(NpqpqlE)2L zPe7;jCSq$Uv-g%QRSidH;pdzUaF^Ald5PKET3h+tQ|ymSYV+srk^Y%}fQ$%#1sF+i zSU~aj?wxIQsH?gpj%)e=8E}X%JIA$aM-`cFZw5}&zPM>Pwl$=bn25J;DWFd%B*4v-M;(3yW9- z>RXCL*j5#uQr`t265n?(JFL*g+yFBV2CSF!@asYV;P>z@>fy z(n1H_j%=Fww!z0XoW0o$79(Sezw+Hy9i>fV`)due67GL>)#E13`yV*Mok7+oJrgymjaK@7xaprAr`5F#zH=71>r* z*;WWoyW{Y3H%aZz2mB?Q7lMGq&T8Q3R3GEIEZ2-6PScEEGfSm7!v%wW)hl%8y{U^%(ud5;&m3j(eGX87`JUR`;R?ReXf4PKt-KZ9qk zECz*XCM?95(x~zFQQaUS%9}>A9H=U*Y-3F&)bsBK$bkk%_Sy$k26Ei zM$y)F*610UnMRIIhwAC752?faRCipAiI$}s8KVyf3}`ujD;O!UYuvOtwpK_F2dK2l z{IxC%eCCpnbfXoPjDm(Am`zqbq0Bd2*YddLxn7Kr=J+R2+5do6+me7pX=JbWp&C~| zROiVUo>1`Lf50k8*oy4?5g&yLD-bB_R%VKZyT2%~_!7ue(UzYR6+j99QpU;+S$M#1{xK` zYP7|DvdDVdL^GMDGV;_^De6>WYU}%}g>>nvD#_yq_eIxQ#np9c3Ab$9y zkECC3F4@}2s{FmEI3=77ko*y$==B;ZMq~yTL|hNu%(120XgFooG>|MOh$aa818mJq zWiy+@zGBI7uIFd;rGv|P^oK*il{u$@;1=KOPP+7q#2iY~a|kvp$py70;kxnRfT4xu zI)o0|{E4uAA>y{C{tSr=9V>K4i9S% zHpjD`%g0CKf;mQSek34$mEC!Hr8wdm=07g&GgI+G!obd&_kZW3o|>+u!E=ie;70=R zMPb8scb-*IVU7xyCjX^L5fLH^smz^H>*PUHi`{5G9fez^g6da)HXd#C-6ZJ6uD(;+ zi`Glf_5Wtu=zFt+FYKV@iKy?>oLX(A$As8@|5jVU`? znYT|dudp(;k_1Ho7j-(`801V03#a9|Wl7J=CgsLXt?i99$7dGRk6Vc330YwC-Fa-D zb_^{_7}jJ2nH7^BJ@g zr0qwE(Ie|_#QZe*$A@JI-~soa$KFH0YlRTy_pjTaKhX7Vhm($%zR2xJe-=01Nw<3) zMC_Qel#?_gwlgdF4l(zRq$D9K1zbujE-DhAQi*n_kD|Qt-s$Hy5Cm{D%YhxykC#Uz?mt!#-5*&9I1P?!w!Qe`EIQR!?$_WOxCA zfN;L6{${EC(~GCdzPOlj;YKUb-+4Y8xQ+3wUUu%3Cp1@uaBD8-`< zc{+xUk69sYC)_5>o%Ik|LRpL;qYr{; zbX4!()!V96rF0uBD%o&ERO{P8rjio@mehu*;`FNA)*4G$!8TM71kr_UOSN=(`c>&h zPSDGn)RbJdg$A=BLX4~}1lkDyt7kX^BKA~yodaegQa$Qk%wL2s;JIf|7PSVr_0(%c zO^E7N25Sqs??<|!XxYu>7!KKM1MAIy_9G(_qDB0+Y2a z8wlMFUZ0hDG6g)vq_fpnfqhJc!SV=^*E^9(I{%6zY!A5k2olZm}iAfOa2kTJfXCqG(kO<_A!!{an<~vBw&x(z8(ah(a|AwrO031n$ycKy_YPo** za3< zx&LHvU^=4N6jm93tu()cvlD*Lo-9HQAtA5=OH(P))V=QidaKbo-NBA8!urG6M$N?* zLhk9{*I^KkHhIPpleCBsk1|uqT6X)FxZt%-`fR0h$CCM8nU#6HUUWb(YLKa5^{#pT z{E$a-hlqf3q{mNy^hos$l|o;1L9JlxjglkxLO zrx3&pdX@rx#*0SwZC5#+Y5yLJ5Rf;Bmh@G=#)Dy{8BBD8C%~Tn1v$F8+!f!GJgzqN zsokK*`-qV4k%GYiAviPMduH)zWPFXe?9Fy~GX!aqE81D&0>VJpt2R*sK*f4&MX|EYQ~9640Q(U~ATV-%izhNFUKC(dc*1P;yq z>-q?z;8}HF;l*%+eP?z>E}(=IYI&cu5x*;!y&q*>mH=wFhLWADF47VX@awp*Y&^w7(RP)LrKEeiitV9XAfeSt&LNdM7 z*Og0CabJKNylq^)Imgb9?6t1gH$6q+nEL3claD9)ZE-g)X;fy|AzX&VQSp_Dt+x2$ za;5pS=&3UK===Z}^aKq`#kP$KaCbG?c5zV`wN%e@|nFT%9z50yC+ zLE`{u3iTnK5RKo{#{#Ge(yXO~3S6`NpB!Hb`mC$Y(k(sC2OB&+ANqZL#K-cbZ!+Pj zJ_J3vNXE1*iC^(r3ZDarc5OCQUp$YGE4n0c5a_WMHtbPY!XzBs0=yRJGwDA^I*yy- zZgzDVVf`?F4>rBccq=3~PK(z-NF{Kk`wQrgZ5IYC=mjv8C-BcQiNSv^w-L_v14-ui zMC8gBgqQJeUKbw2K(`I3PZxfcO9$YlMCFerXAg0}F%9#=dp0Ac-MTjP)zW)L=pl&X z0)&1oo>ad5(ti08*P4c%bG9V&YzGyka_dO3)@FlyfR9jXIM85s6xdXZodsLdiCP(v z(027P_&H<;SH`$MKB}#9!FHVjf_%YuS^*f!Rjv*c?CVp~Ui#>5iI%y@^iuiC<6mt< zL}0Ubfs*3@-@HX4;r+PB61ggieZsU4=vaiDO747K9nSaS^b_+r0;A^1OPFZ78L+M8 zqxdUQ9)6GQs{d_f#%QB;L#%m2^+Jp6`GZ`b4_QDcVJ@rMaldpw{T93Fx3L;L=Q;9x zEW!?bR^pi*=S0(LN_C9w))oV3Km`kKVFP1N*Jt1LF_7ktzUEtA+2ZDA<=2a&qV%bG zpeggwW?`-R8!O)*pUa?A=K|nT>H^@Y$-c^#!V_B=*-3f%? zua$_L$ffUwJz88f4KK$ULfWF`!OjZ+rF`*hpB~K`Lx@$^C=N=$9aBtT!~MK>eJrj@ z{*EmJtzAV0Lu_@Po2f^wJd1-yD8lp_Xn@uU*B(CL1nqOc(LoRzn(N^J?~FWW$ba%6 z0i_Bo1`R?F`9lz@zzYUE6nK##Ch$6Q4n}@E#|R|&|Nr>^mGl3YIVf#&^Er6>RXAE8T(NB{r; diff --git a/apps/ios/README.md b/apps/ios/README.md index 1ac08a78..5633523c 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -126,24 +126,17 @@ aussi côté remplissage. Sans lui, l'extension demandera le mot de passe maîtr ## Points de vigilance -- **L'icône suit la charte ghost-suite**, comme ghostmail, ghostcal ou ghostlink : une - silhouette de fantôme commune à toute la suite, que distinguent seulement trois teintes - et un glyphe intérieur — ici une clé. C'est la variante **remplie** - (`assets/logo/ghostpass-icon.svg`) et non le logo au trait : ce dernier fait 4 % de la - hauteur en épaisseur et disparaît à 16 px, comme l'explique `ghostlink.svg`. - Les teintes sont celles du logo d'origine (`#6EA7F7` → `#5394F5` → `#2E5CC5`), et le - cadrage est recentré sur le tracé : aligné en haut comme chez ghostlink, le fantôme - frôlerait le bord supérieur — 9 px au-dessus contre 52 en dessous. Les logos de la suite - sont **générés** par `tools/brand/ghost_suite.py` (« ne pas éditer à la main ») ; ce - fichier en reprend la sortie, faute d'accès au générateur. -- `assets/logo/ghostpass.svg` porte un nom trompeur : c'est un fantôme au trait ajouté par - le commit « feat(brand): add ghost-suite logo », hors charte actuelle. Ne pas s'en servir - comme marque du produit. - -- L'item nommé `"\0gp:folders"` est un **registre interne** partagé avec la web app. - Il est filtré de la liste ; l'afficher serait une régression visible. -- Passer l'app en arrière-plan **relâche les clés** (`VaultStore.lock()`). C'est - volontaire : un coffre ouvert dans un téléphone qui circule n'est plus un coffre. +- **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 diff --git a/assets/logo/ghostpass-icon.svg b/assets/logo/favicon.svg similarity index 69% rename from assets/logo/ghostpass-icon.svg rename to assets/logo/favicon.svg index 9b87868e..7859c964 100644 --- a/assets/logo/ghostpass-icon.svg +++ b/assets/logo/favicon.svg @@ -1,18 +1,15 @@ - - + + - - - + + + + d="M 240.0 9.0 C 245.3 9.3 262.5 9.7 272.0 11.0 C 281.5 12.3 288.0 13.8 297.0 17.0 C 306.0 20.2 317.8 25.7 326.0 30.0 C 334.2 34.3 338.5 36.8 346.0 43.0 C 353.5 49.2 365.2 60.8 371.0 67.0 C 376.8 73.2 377.0 73.5 381.0 80.0 C 385.0 86.5 390.8 96.2 395.0 106.0 C 399.2 115.8 403.5 129.0 406.0 139.0 C 408.5 149.0 409.3 154.0 410.0 166.0 C 410.7 178.0 410.8 195.8 410.0 211.0 C 409.2 226.2 407.0 244.0 405.0 257.0 C 403.0 270.0 401.2 277.5 398.0 289.0 C 394.8 300.5 391.0 313.2 386.0 326.0 C 381.0 338.8 374.7 353.0 368.0 366.0 C 361.3 379.0 354.8 390.8 346.0 404.0 C 337.2 417.2 322.2 436.2 315.0 445.0 C 307.8 453.8 308.0 454.0 303.0 457.0 C 298.0 460.0 290.7 462.5 285.0 463.0 C 279.3 463.5 273.7 462.0 269.0 460.0 C 264.3 458.0 259.7 453.8 257.0 451.0 C 254.3 448.2 253.8 446.5 253.0 443.0 C 252.2 439.5 251.5 434.3 252.0 430.0 C 252.5 425.7 253.0 422.3 256.0 417.0 C 259.0 411.7 267.2 403.2 270.0 398.0 C 272.8 392.8 272.7 389.7 273.0 386.0 C 273.3 382.3 272.7 378.8 272.0 376.0 C 271.3 373.2 271.2 371.5 269.0 369.0 C 266.8 366.5 263.7 362.3 259.0 361.0 C 254.3 359.7 244.8 360.5 241.0 361.0 C 237.2 361.5 238.5 361.7 236.0 364.0 C 233.5 366.3 234.0 363.0 226.0 375.0 C 218.0 387.0 199.5 419.3 188.0 436.0 C 176.5 452.7 164.3 466.3 157.0 475.0 C 149.7 483.7 147.8 484.8 144.0 488.0 C 140.2 491.2 137.2 492.7 134.0 494.0 C 130.8 495.3 129.2 495.8 125.0 496.0 C 120.8 496.2 113.2 495.8 109.0 495.0 C 104.8 494.2 102.5 492.7 100.0 491.0 C 97.5 489.3 95.7 487.3 94.0 485.0 C 92.3 482.7 90.7 482.0 90.0 477.0 C 89.3 472.0 88.7 461.0 90.0 455.0 C 91.3 449.0 92.5 447.8 98.0 441.0 C 103.5 434.2 116.5 421.8 123.0 414.0 C 129.5 406.2 134.0 400.2 137.0 394.0 C 140.0 387.8 141.2 382.5 141.0 377.0 C 140.8 371.5 138.3 364.8 136.0 361.0 C 133.7 357.2 131.5 355.2 127.0 354.0 C 122.5 352.8 113.8 352.7 109.0 354.0 C 104.2 355.3 101.8 357.7 98.0 362.0 C 94.2 366.3 89.3 375.7 86.0 380.0 C 82.7 384.3 81.0 385.5 78.0 388.0 C 75.0 390.5 71.8 393.0 68.0 395.0 C 64.2 397.0 60.5 399.0 55.0 400.0 C 49.5 401.0 40.0 401.2 35.0 401.0 C 30.0 400.8 28.5 400.7 25.0 399.0 C 21.5 397.3 16.5 394.0 14.0 391.0 C 11.5 388.0 10.7 384.5 10.0 381.0 C 9.3 377.5 9.3 373.7 10.0 370.0 C 10.7 366.3 11.2 363.0 14.0 359.0 C 16.8 355.0 22.0 351.8 27.0 346.0 C 32.0 340.2 38.8 332.0 44.0 324.0 C 49.2 316.0 54.0 306.5 58.0 298.0 C 62.0 289.5 65.0 282.0 68.0 273.0 C 71.0 264.0 74.0 253.2 76.0 244.0 C 78.0 234.8 78.8 233.3 80.0 218.0 C 81.2 202.7 82.0 165.5 83.0 152.0 C 84.0 138.5 84.5 143.2 86.0 137.0 C 87.5 130.8 88.3 123.8 92.0 115.0 C 95.7 106.2 102.8 92.5 108.0 84.0 C 113.2 75.5 118.2 69.7 123.0 64.0 C 127.8 58.3 130.8 55.2 137.0 50.0 C 143.2 44.8 150.3 38.5 160.0 33.0 C 169.7 27.5 185.0 20.7 195.0 17.0 C 205.0 13.3 212.7 12.2 220.0 11.0 C 227.3 9.8 235.8 10.2 239.0 10.0 Z M 308 180 m -52 0 a 52 52 0 1 0 104 0 a 52 52 0 1 0 -104 0 Z M 308 180 m -22 0 a 22 22 0 1 0 44 0 a 22 22 0 1 0 -44 0 Z M 256 160 L 160 160 L 160 200 L 256 200 Z M 176 200 L 206 200 L 206 234 L 176 234 Z M 218 200 L 244 200 L 244 226 L 218 226 Z"/> diff --git a/assets/logo/ghostpass.png b/assets/logo/ghostpass.png new file mode 100644 index 0000000000000000000000000000000000000000..0bd44863ebd8e6148332353c43e9deeffd19b60b GIT binary patch literal 47426 zcmXtfWmHscxHjELcSv^(9a19Q-Qj?AgTzpRbl1=&-3u+yIKi}&4E>~5b zR%cu62T8qES`@1|?AA5+?!D=?V;=y-s~M*+)A(a|$501w9wQ$!p8J0!qchYtaz8WJ zTV#57sS28L#{3=MARy6zAQ5+7*#`L#Rs0~PUtVe=!3w>u=-XUu=`$R zEc}1l0V2g&R3cN7O>O@kK-m5%isJ7fgtjB@e6?XZjiHplW9jWhJDloQ{Z)EyiWf`| z3G1P~r4~^wNaCL*H_2k7hzx7SU`IzfBl8mh)S7)um&g((`GENLj@vyG&(~#R2$>aT zC6Yp;W`u<5NRlH)MxxJ$kiR+q7WcJ3iKLg~yiM6_ zcQx81{>^~gIC<)NV6W?D?oL`UHF9@mDt=odx^Bxa1}o8TkcGm7k|(*3>V~1F(k~Ps z`eNad1U_y4NSxS-1hPv+ZucC&Kx%SY6M#yVEWZg~o=87!{9|$!X<4Ydg%N}l{7%dD zZpZd?0ydmfzi7{@298gJjjaAi+Z(he$^6_^aX7wq7g zE`MWSkmgBLOzJE_ZB|LZ4xjoKO8D$Rw_13WpYyu_fbtQwGv8EYA$x7FIud7DfBNWD zS`?!rp9uyLbBW71Y%$=5^bOIT*%toSTQ9w^y)j-^JRi{p)M57!iL8-BXT88j5#kO- zFz4HiF<+ODa!zp3w$d2FymL?011Yo6x`Tg46wf{|96HCMr=^g8e=o{ARv46XF=3k4XvOCg{q!mCZ*J|5e*Sh$aC!fO=iR%y8wn5{`BinWONMB+k_1D?> zt#%3D7+ll1p}1GqZ9P&gp=omD+gwJ9CFUu_i!vj-cHEIJumM53NN)TY>}G?lLt0=A zH_k80|JF?2v|La3j!JA1$r!TtY>WI&9@4n|=;-rK3rD~obCu1#2j;ap>$O*fQ4^0i zA~$X?W-v8-Tj?nU*_LY)3p*H);pcOPxm(OjWMvuHCqqEY-?y5OfG?%lm!Dg0Mr1z1 zNyk~9p8X&v_V%tn1bY}E^2$UZtC~OO|0_UNrPK-+?WAPVy&^Q(jzN_&+p74JV7`;x z5n_QSALOz6iV#!2!VmDl2nSs+OJ{){!m{*n1*un~OZbNbzI$@rVkQ0ELfKA5j3Lz;FlB5 z7MBEy5AX7pZ$+@ELd(o)ucU=PEXkYNm;PrdpEIVHBuQSV_Kf;#YA)P&Gh5Jpg(=fT z(#sd1(xMmWBvnZ4VQv|Nz1eavR+1+#bnxyX>YT~%kTwi<}?BJ&aZ>7+xCXxI52Qi&OA1rLYdB)Yq1mz9XH z6%yEpLn?ES60qc*BCZNZ@1(;w?DvF3=8UylaGXkEWn@$h3%1zcs5r?;aG7$|r zTSpa^jsnlDj2GI1CIDK#jXxkJ=-7I1|wopQz#78 zmwo*pxc#Orgt2N=Nm)-a`AN}@#fBY06T`wDo`6p8(9=J+{Mrvv=C2uz{7rp~+ypba z1ogBpSYCM0$&qNr1P-|CTBt3k?1cI5A#JswELYa7R+dFTKvbv(kBwHBNFV@&!#cU+ zgfPf(07guZSmvmIH}D3=T%GhY;7I#M)MHNV9(0|efJwy6NTOPPSZ^+7fOPgYxi_T0 z37OdQUdX)%ucn+UIkR{QkTaMMqPe*QBob^BZw2YSQ>}YDb0pvS>BOiPU=w@YktEnJ zH?B0GFL14-c~96FB*u66-seBH=gn(Z-s1E#^L+@s*O#kPh--4}?N!E7)7?h-$keJX zinsOJjWoK#u{#S_S#Pf$WpS$=QBpd!;hS!}(~xjExT~+ln`Coe1alt$5<8hXyce&p z=vT%NU>5BTY&@%b7(NGSiY=kN?|{qb@XwE`ADRO@23wn<1A&vZV#+knYOk#BWv_&m z`OWJP*8HJn8*%4j6RG-%&N-3Rz@HD<9H~dRuz_yt8SA3xX_p$fvr?Lx^SZIeb1imD zfd-?UakuO<5kMBg&V}&mC7yNejX$d^ur?lA{ni9`VkpuP_!>eo3#!!>Wvemz9cjj^ z{)zolfs4lI5_H2-yZ1%!F8&Hc+pV$FA#RCSe}!!Z+GluF`la=0Z}>+N>lDeJQEA7B z6$bYl^tv%~C%VLL%#tyYS_jUJlt72X#MSN?Q$5jYu<*eoDs#;lD73~4H6!o4(vku- z5Qo43y8Ihk3N|YObNFmuw1ffc*}A6&T~mYlgBE_m9g%zUHFZ#lUtn6Vej%rQiiULv zYbd7z=5nE5r*pJa7$325P?H8*zhJn-b&x=HD$XN-Gxd;w1y}-UN}EY@lh5WSh6fed zPRE>J6dMv9F^8tF3Y+{iG2T*BN!6Zz|5+Q0vGbb#RwP$=TSIo}6EU4v0nrQPTfB$I z3{Hf3mYGy1hQvcZI6ElobE@Y_c+fqO!i06|8ODR=q@&L_p^4i^_uY5e`cw+!H9X5S zrBTNHVtP?GXbgFVUr@H@;8NMpqRF-^{scA_HjcW%v8#fBKRzJfD!CWTYj0YTNWDUv zYWey2n?K$Rix+M2q3=1z5_yE$Dk7+?3;)PXi~Qrb!=i$Qu$))5gDqx&jBY3KqjJAP z{9Qt!{&q3VNHYZTc|@l1fgz}^kg>s?aG;GUmu5ycxYb5fky={aB2lz@2hIT_e*N3) zz}^x<@}B^F2(hT}fa$(?xN7@_krgs|{J_?C)F15+fdqF(>~(*U{lS*|{-Vb_g(+mk zNr%*G;7C!!{JO_`2u*)DVq#iUfk^2LH-lN#?GkrS&Y zPOwGar=#|3C^I7H=O%_nK4iaG4#1K%XHrH>rVdcxcVJVUSz7H6q%JI?{RH$^iSU{+ zX7ZKG8wa4}FJ5_@y}y?TV!VkIYAICSQts=gPurg;Nl|DQV^R4KA865Y0d3I(x5#4S zY&04opk|N-?yH%Kw~Biu{QJXcxzG-xwM5&HkU{oO^;dt@E5uXJVeCt2ZqMh_rj-59 zMcMgmDTEWHN*U328AmV{T!w2Fb{f4b2ZaM$-)x!}4=%fGVI#rl{U~q`s?=2eJ#`;o zz7>1uo-wmuk#mSnF0A~$MoDj z?!KW7ygo4Y{1V^k5(Ven$8kNHdQ?~RL>z7a{&w_vH#e3S)EGhtw4X5$5f6ihyj-do z?*0^YY#1ihBJ5nbc(G78jjz2 z$Cx>M{nYwy6t=gg*naK-j?-%0hOzF-)U9H$&-)&;5%k`)ab6>j*Sg|O=^lQ(1f8t? z6LUqEdV`6^gkvUp#yKb~0GjYzC3k58w!UqjUS`zqq#$1gyB9PPh+6#td1`jI)g{H~ zM5RJ&+E2XJG4jaOH2HOS2HoWb!aD7E(>bji1>?W(e~-Jl;aJD)mYQ_8U;rY?t?aTZ zQcIeiYJ7B6wchS}WAh}y^_~+IGq0#EN0uV|YAF^PrJfvL)Xyr-_+_K|7*8VW@LpR{ zXG)-ELdo)ODM$6@r~k35Jp9r!q=i*p+WVPAl=+qmca0aDut<*uQr4j)c|;w?x;7obG zKSg({!LU>7QE^qXtpnoVy7m|)y*I+d(WIQ2bSH_<^nwg1LQ^f7n%eXM+J^`kYKu(~ zbrKleRSeed;SO~iqj>=d>$zh_gXC80FY?ba13Pa*`LtTr4VYJN1?oPC`pt3O>l2!q zA|B~Fdcmc8c(U!JhfTOJRC{!27ki*O_cAO0WrqDRhmImLSJRB{b{~v~%=>=$N>>&6 zS&a{R{GDynG^q1bH)(o$NUmy{;!#lbLTm-3`2(FvZiY=0fbr*L!}Z_0M@mqwMjEd5 z*hLI<4Ec{2{y8t*@7A~n^!I8qosZvDy`iQ5go&^>+xW0Gm%Ru`4Vm6R#C;fQDOy5_ zG<%hWS%sGXI0ojmZp7O>AD4C3=pwHQxcsQyk3pB8kaTk+V{~$x?>;{#^9G`iEq)Rf z?_(QKdaKVPerqCSceG5QU|sw2^~JO(t}Apog9zP;1Y_@35_RLX3Z65D@4tbUxGrO5 zY2HwtJs8F>G3-UYRMzc;dX7AiXG&wesH#X{xcd>(Z~358!h^FT2fZxn7=l07TVuG? zo_@VT%f+oyWx_j$EuV9#19gF29Wf0v9o~A4Dbkg;xu-DVWV7*1=eL^7m`HyoaZ&e*~LW~Yo za5_gG>$;RRMPbdd!61OrZcfd(3ncJX?jGd5 zfe>|Np&oj4B$fnQgQThc*Xb)TS@X_QTdDLnf8gzYqq5$uE+);lH-4a zcR-O_sf!RY@r-HakyRl)l@S={o~FG5n}Ahk7B24ARg{zITuaSzypOf~-g`Y|Vx|HG z3wLbCuh!bj_ey~G#!*+XRKG~E1cP{rV|3!?+9HOfjxEv%_%DRQ+%1wgi0sz>R7cM8 zQ~QQWEbDKD$=n&{3E;re(*mWM_H~{c9_pswz>R^)=HndjlYjb& zHh7Mws;VdBlz3+gY8IRah!O7@8)nBFhOStPnrUUGP*@c&SnX~ReoTs$tGkQJ=|&p_ z6$c)rn~V0nhwA`2lhlpzeViXP%Y?XLHrHde*39GK-ouFa^b`tF8R?&9-Otkckj!Vc zYW1*5)J0M<+TRR$Sm~ll1*B7&zj9N|0LX&bSB_6XEJLcB)bZ( z0tCW!u$KM}{DWH$Z9CBh4k%fn=Clb*QYl~=5YOVVj_OHP7Of)pE=68c6Sz%-{aFp! zRyaOJ2itv|{lO&ks(PMLT2)(7l-G|^`z2sMb9W{(K?tYhFLglZ`ZtJ;vZ1TTUWD^M z)@$a|X3Wb3<5hAN6>gas7#5fS2vTg!!jX{knD1IR%mA5=9?Nxy542ajDFC^Q9dPtmGbtaH!xonqp*0}{5aIj?V-AeLX4yING4pa?1aIFI|LA7LDY4ZgwJVqzfCGg!m1?WO{Myasb2S>Kii%THYuRmvGJ zyCv7y%aIFS4}+ZymO;5H<-5U7403qngWGN1R{7f6k%x*DZx3#c1Gxf~`;)|d4;yxv zgE9nk^G~4a@?Jml=nZa8HU|8_#x0cJrs?w>gjDz}(QjZeP%>Cuauik5EFk`0aYZgK zn*@QHD)mgW%8%;(0j{nF{Bn6P`UoPe;WW*{S6BTXd= zy+WsXfD;UPtP>tJup`qsW6DJMA)XhQ4+NpNhD=NBYM2m^rW^V2tDtKcko;tdRhl!2 zlM6v7VJiU>2I?IpqY~sJBr1E)P-qAx8YPqtjcK4yTRObV(aXy1Q&_5KT7+}L)(P(_ zv=4EHY^8K?B!5*ZYE#_uRugE;#-b876y4{U$Klps9jt9{ap3woqX}E@uO8tN#3|7g z730kf?Iel8{U;zFaV6LCR?VhZ5M!6sQDluwM^&`1GR3{10V4Rmc*d1{1b=%4+K6`N zA#arvT;3SC-BZIeJ4h+a4>#-gABQkB4DZ@Mniy=VVu8EH(L~52wPod65#~vrN*VpA zj8SF7vH$m7MBD#t7{JTL`}a1L&`-VL`q~qSC4f74AfwSC6P)157#{v4Kq<^~Uf6C~ zi(cB&`j)5+(RR>8Lw;3g`>FUGX$WhcjK|1_Il>H76oXsKlbSx0_|J@$L6RgZ|Zx zI{Ortb+KRwaZl-Q$9*y}rH7tUtYzanvlo`tyg9fV70=pmC9S!1cIZ=Znv1#`3KRRw zEu>8exFl*bExS}y0bI15p=%rnVLmzRY#L?%&yqYMyGb<7&W;}^|K zRfCIte0{ITKHhp-jiqNqcY9{MI~xGCE7>wy;0zuF_Fl?M6721J%IZJr%&#wOy1Iv;xLAZkqfU$v5D--GR%&V;fHOGUxO_qK?Ul+2H}c91)PJxs zoww2v!J?Ag;$OT^Y#X+N?n;+O#_#;}&dXpTX;764p5F+-J7RJl_62QMp-g!jjiFg= zHZz4?qF9BJx?E(trEF?BVbIe5{9MsQ6PYLad$%gNbb!zgtnhxRn*F0M!j6S8IwR5U z7NCkZz-pz~L$&MswPVv=-vnbj@)0dXxrNLZkSd8>#ML2%aD766DvJt*=S9Ue!pFYY zp%B0#$qaTMA~AJgAN=}CVk zrEm{}Q!+Gy#7X=@!l_nouNf$M7O!y34}bHkCv9@rE+_wjz>`j3FVG?tNN&u>%u6NN zL3v=BLjR#b8Ik0lg?H1bVJ;Os?LpgRJ@mm~MV(KrwMI{T+p**{_Z6fZ3H%gK3}0aR zPGHBqz}}`S3vNd^V|#ak6P1V8{!2|>dn6)#paG717Y?{D0aT0f@IsL-8))=KmIS zS1R|uu>3`=K1OEBzB?QnIuT;OHEYxf&WFei0&rMIgEWFsp7;a>!r%pY)6I%?F_;j# zL#!lx(N^SM&aG6aN_JlnUC1D^(K~B=@!@|%R7NcE${$s{FlC}~DAXx1`D(_Z zq^CRxh+m-2%!KLR+3sR{{{Zg9eDH&Be-3287YUtG)`K6q%I9>i!hsCmn()tkZDsVo z4tu6NR_f*ltNi4#M5c&%6L=F!OUgteny6g7hvlNvaqV{KL<6q4pT?{wdiv*+LjjBa z>Vy8ze^di;SX6NTNKXAC@u^h8DZE=#5_MC><5G1h0g@}|T!Jq2JL_5tm9h@uo|rm+ z2!87kF%&C!=E1^@&qB~!(5GLZ=}-uN*L*kJH0%*;roj>&o0*Vh@44pkM_7$LCQ+FJ z!J8`I)dZ$(34OLZv%)ngIEmu-3$R_oe#XVW6gL8UpUL0Gv<%2qA(Cn=r1ZbYJfSp; z4}y?1&HXrUZGjc=-G8&y45r19v1E$ghy*H*=(`m!AT(f{AuE81kW_Y_-kT}66}PQL zhEhe&a$(VOYA>w1mSMwazH>DQ9fhOuX*(i>hRF&bK^E+#(_s4CiT31?e9?K=#vtlD z<}K%To$xD#gb5FKy=OQ#zD)$G%Vq3!NF z;Er1tb3+5xB)@ib*pv2;I>=Gz7z3dgH)FdALnJL(TO9J z87>L54pz~EbBn4XX9>)NRTYyiRz3DYpFeKrY0D6={zRG1X%6isQ&(b6Xc5ZBE@zUYp1ypk zCn}YpmqU26v`L?)5_*hr>SAkMbuaz6_Ds!n{AjvAMuFdCHOK#9)SzbQUsobk+VYd%T8(apNXIMA`}A`jUmMHpm{M_U zmNcWMe9i+>6cVGu=)5x21ZJ5Q567g$fmOk$y4OZh=9>oi8c&a#qO!m|eA<2XGzn^p z0KYqdU%1reQc*pvDc+p>2Q<~Sd4btRY?3m!q^GBU6+UcY!kwu$1mzAg&|mzqH{y)& z^1kH85b3OP9HaB=P2`o_%27-ETkDCkIHGKS}HBGzL~*d0 zg}My?e7_x4=0pbijhu_A2unB$3Kv10R|6B$iM^@w%?nONuen2ho$4+8^qJqVu?!h6Q}KwA$(40⋙DAvpFy^{%6kb`TNtXej0F%Tr*g@3aJy-pWQ!YV- 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 literal 0 HcmV?d00001 diff --git a/assets/logo/ghostpass.svg b/assets/logo/ghostpass.svg index 252f1757..f784987b 100644 --- a/assets/logo/ghostpass.svg +++ b/assets/logo/ghostpass.svg @@ -1,12 +1,23 @@ - - - - + + + + + + + + + - - + + - - + + + + + - \ No newline at end of file + diff --git a/assets/logo/icon-512.png b/assets/logo/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..923b1f0f30b7c69d6cc8c9bc887e1b65ad3e8116 GIT binary patch literal 41044 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⁡@<5khLU+zk|H{% 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 QAmFE`Wu#fA?hx_+05ID04FCWD literal 0 HcmV?d00001 diff --git a/tools/ios/make-app-icon.sh b/tools/ios/make-app-icon.sh new file mode 100755 index 00000000..7cb78906 --- /dev/null +++ b/tools/ios/make-app-icon.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Produit l'icône de l'application iOS à partir du logo de la suite. +# +# La source est `assets/logo/favicon.svg` : la variante **remplie** du logo, celle que +# ghostboard emploie comme icône. Le logo au trait ne convient pas — son trait fait 4 % +# de la hauteur et disparaît aux petites tailles. +# +# Deux écarts avec la source, et deux seulement : +# — le cadrage est recentré sur le tracé. Le viewBox d'origine est aligné en haut : le +# fantôme occupe y 9..496 dans un carré de 548, soit 9 px de marge au-dessus contre +# 52 en dessous. Sous le masque arrondi d'iOS, il frôlerait le bord. +# — le fond est aplati en blanc : une icône d'application ne peut pas être transparente, +# là où le favicon de la suite l'est. +# +# Le fichier source n'est pas modifié : il est généré par tools/brand/ghost_suite.py et +# porte la mention « ne pas éditer à la main ». +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SOURCE="$ROOT/assets/logo/favicon.svg" +CIBLE="$ROOT/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png" +MARGE=0.10 + +command -v rsvg-convert >/dev/null || { echo "rsvg-convert manquant : brew install librsvg" >&2; exit 1; } +[[ -f "$SOURCE" ]] || { echo "Logo introuvable : $SOURCE" >&2; exit 1; } + +TEMPO="$(mktemp -d)" +trap 'rm -rf "$TEMPO"' EXIT + +SOURCE="$SOURCE" MARGE="$MARGE" python3 - "$TEMPO/icone.svg" <<'PY' +import os, re, sys + +svg = open(os.environ["SOURCE"]).read() +marge = float(os.environ["MARGE"]) + +# Boîte du tracé : la silhouette n'emploie que M et C, donc les coordonnées alternent en +# x et y, et les courbes restent dans l'enveloppe de leurs points de contrôle. +trace = re.search(r']*\sd="([^"]*)"', svg, re.S).group(1) +silhouette = trace[: trace.index("Z") + 1] +valeurs = [float(n) for n in re.findall(r'-?\d+\.?\d*', silhouette)] +xs, ys = valeurs[0::2], valeurs[1::2] +cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2 +cote = max(max(xs) - min(xs), max(ys) - min(ys)) / (1 - 2 * marge) + +recadre = f'viewBox="{cx - cote / 2:.1f} {cy - cote / 2:.1f} {cote:.1f} {cote:.1f}"' +open(sys.argv[1], "w").write(re.sub(r'viewBox="[^"]*"', recadre, svg, count=1)) +print(f" tracé {min(xs):.0f}..{max(xs):.0f} × {min(ys):.0f}..{max(ys):.0f} → {recadre}") +PY + +rsvg-convert -w 1024 -h 1024 -b white "$TEMPO/icone.svg" -o "$CIBLE" +echo " icône écrite : ${CIBLE#$ROOT/}" From 3b66d5cbb89fbc6a932c5a4baa9c0ed4b8e6a8ab Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 11:18:42 +0200 Subject: [PATCH 11/50] ci(ios): mettre le job en sommeil, faute de runner macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La fusion de main amène la migration des pipelines vers Gitea Actions (#35). Le job iOS a suivi le renommage `.github/workflows/ci.yml` → `.gitea/workflows/ci.yml`, mais il visait `macos-latest` — une étiquette qu'aucun runner de la forge ne porte. Sur Gitea, un job dont aucun runner ne correspond ne échoue pas : il **attend**, et la pull request reste sans verdict. Le job est donc conditionné à une variable de dépôt, `MACOS_RUNNER`, et cible `[self-hosted, macos]`. Tant que la variable n'existe pas, il est ignoré ; le jour où un Mac est branché, il suffit de la définir. Les tests iOS deviennent donc une étape manuelle avant toute pull request touchant à l'application : `./tools/ios/run-ios-tests.sh`. C'est écrit dans le README, à l'endroit où l'on cherche comment les lancer. Vérifié après fusion : les 32 tests de contrat passent, ainsi que fmt, clippy et `cargo test --all`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitea/workflows/ci.yml | 15 ++++++++++++--- apps/ios/README.md | 8 ++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e264590d..64753740 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -83,9 +83,18 @@ jobs: ios: name: App iOS (SwiftUI + UniFFI) - # Xcode et le simulateur n'existent que sur macOS. Attention : sur un dépôt privé, - # les minutes macOS comptent dix fois — d'où un job qui ne tourne pas pour rien. - runs-on: macos-latest + # Xcode et le simulateur n'existent que sur macOS, et la forge n'a pas de runner de + # cette famille. Le job attend le sien : sans étiquette correspondante, Gitea le + # laisserait en file d'attente et la pull request resterait sans verdict — d'où la + # condition ci-dessous, qui le met en sommeil plutôt qu'en attente. + # + # Pour l'activer : brancher un runner étiqueté `macos` (Xcode, xcodegen, rustup, + # Node), puis définir la variable de dépôt `MACOS_RUNNER` à `true` dans + # Settings > Actions > Variables. Rien d'autre à changer ici. + # + # D'ici là, les tests iOS se lancent à la main : ./tools/ios/run-ios-tests.sh + if: vars.MACOS_RUNNER == 'true' + runs-on: [self-hosted, 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 diff --git a/apps/ios/README.md b/apps/ios/README.md index 5633523c..f5e80a3e 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -37,6 +37,14 @@ du projet vit dans `project.yml`, qui se relit et se fusionne — contrairement ./tools/ios/run-ios-tests.sh --unit-only # contrat seulement, quelques secondes ``` +**Ces tests ne tournent pas encore en intégration continue** : la forge n'a pas de runner +macOS, et Xcode n'existe nulle part ailleurs. Le job existe dans `.gitea/workflows/ci.yml`, +mis en sommeil par `if: vars.MACOS_RUNNER == 'true'` — un job sans runner correspondant +resterait en file d'attente et laisserait la pull request sans verdict. Pour le réveiller : +brancher un runner étiqueté `macos` (Xcode, xcodegen, rustup, Node) et définir la variable +de dépôt `MACOS_RUNNER`. **D'ici là, lancer le script à 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, From b9cef94c2da43584b4a4110aefc4f43aaa169d5d Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 11:29:09 +0200 Subject: [PATCH 12/50] =?UTF-8?q?test(server):=20verrouiller=20la=20forme?= =?UTF-8?q?=20des=20r=C3=A9ponses=20de=20l'API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans runner macOS, les tests de contrat iOS ne tournent pas en intégration continue : un changement de serveur qui casserait le contrat passerait sans que rien ne le signale. Or c'est exactement ce qui s'est produit — deux fois, et de façon invisible côté serveur. Ces tests vérifient des **types**, pas des valeurs : - `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, la ré-encodait, et obtenait une chaîne doublement échappée que serde refuse : toute connexion échouait. - `createdAt` / `updatedAt` / `deletedAt` sont des nombres, 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. C'est l'autre bout de la couture que tient `apps/ios/Tests/`, et le seul qui tourne partout : ni Xcode ni simulateur, juste `ubuntu-latest`. Vérifié en réintroduisant les deux régressions dans les routes : chacune fait tomber le test correspondant, et tout repasse une fois restauré. Un test qui ne sait pas échouer ne protège de rien. Le cas du second facteur n'est pas repris ici : `mfa.test.ts` vérifie déjà que `mfaRequired` arrive en 401, ce dont dépend le client iOS. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/README.md | 6 ++ apps/server/test/api-shape.test.ts | 128 +++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 apps/server/test/api-shape.test.ts diff --git a/apps/ios/README.md b/apps/ios/README.md index f5e80a3e..1505a962 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -60,6 +60,12 @@ Les deux régressions qui rendaient l'application inutilisable (paramètres KDF 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 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(); +}); From b47f6e0913fd4c58f429007c8a3a650434ba8176 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 11:33:45 +0200 Subject: [PATCH 13/50] =?UTF-8?q?ci(ios):=20pr=C3=A9parer=20le=20brancheme?= =?UTF-8?q?nt=20d'un=20runner=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le constat « pas de runner macOS » méritait d'être retourné : la machine de développement en est un. Il ne manque qu'un runner enregistré sur la forge, et le job existant se réveille. Le job cible désormais `runs-on: macos` — l'étiquette que porte un runner self-hosted — au lieu de `macos-latest`, qui désigne une image hébergée que Gitea ne fournit pas. Ses étapes changent en conséquence : sur une machine réelle, Xcode, rustup, xcodegen et Node sont ceux du poste. Plutôt que de les réinstaller à chaque exécution, le job vérifie qu'ils répondent et s'arrête net, avec un message lisible, si l'un manque. `tools/ci/README.md` donne la marche à suivre — et ce qu'elle engage. Deux points y sont dits franchement plutôt qu'enfouis : un runner self-hosted exécute le code des pull requests sur la machine qui l'héberge, ce qui suppose un dépôt fermé ; et cette machine doit être allumée quand une pull request arrive, faute de quoi le job attend sans fin. La variable `MACOS_RUNNER` reste l'interrupteur, et prend ici tout son sens : c'est elle qu'on bascule quand la machine s'absente, plutôt que de laisser les pull requests sans verdict. Il manque le jeton d'enregistrement, qui ne s'obtient que depuis l'interface de la forge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitea/workflows/ci.yml | 42 ++++++++++++------------- apps/ios/README.md | 13 ++++---- tools/ci/README.md | 68 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 28 deletions(-) create mode 100644 tools/ci/README.md diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 64753740..41e5b2e5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -83,34 +83,34 @@ jobs: ios: name: App iOS (SwiftUI + UniFFI) - # Xcode et le simulateur n'existent que sur macOS, et la forge n'a pas de runner de - # cette famille. Le job attend le sien : sans étiquette correspondante, Gitea le - # laisserait en file d'attente et la pull request resterait sans verdict — d'où la - # condition ci-dessous, qui le met en sommeil plutôt qu'en attente. + # 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). # - # Pour l'activer : brancher un runner étiqueté `macos` (Xcode, xcodegen, rustup, - # Node), puis définir la variable de dépôt `MACOS_RUNNER` à `true` dans - # Settings > Actions > Variables. Rien d'autre à changer ici. - # - # D'ici là, les tests iOS se lancent à la main : ./tools/ios/run-ios-tests.sh + # La condition n'est pas un ornement. Un runner self-hosted est une machine réelle : + # éteinte, en déplacement, ou simplement occupée, elle laisserait le job en file + # d'attente et la pull request sans verdict — Gitea n'échoue pas, il attend. La + # variable de dépôt `MACOS_RUNNER` (Settings > Actions > Variables) sert d'interrupteur : + # à `true` le job tourne, sinon il est ignoré et les tests iOS se lancent à la main. if: vars.MACOS_RUNNER == 'true' - runs-on: [self-hosted, macos] + 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 - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios - - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-node@v4 - with: - node-version: "24" - cache: npm - cache-dependency-path: apps/server/package-lock.json - - name: XcodeGen - run: brew install xcodegen + # 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 + xcodebuild -version | head -1 + - 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 diff --git a/apps/ios/README.md b/apps/ios/README.md index 1505a962..a199e9be 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -37,13 +37,12 @@ du projet vit dans `project.yml`, qui se relit et se fusionne — contrairement ./tools/ios/run-ios-tests.sh --unit-only # contrat seulement, quelques secondes ``` -**Ces tests ne tournent pas encore en intégration continue** : la forge n'a pas de runner -macOS, et Xcode n'existe nulle part ailleurs. Le job existe dans `.gitea/workflows/ci.yml`, -mis en sommeil par `if: vars.MACOS_RUNNER == 'true'` — un job sans runner correspondant -resterait en file d'attente et laisserait la pull request sans verdict. Pour le réveiller : -brancher un runner étiqueté `macos` (Xcode, xcodegen, rustup, Node) et définir la variable -de dépôt `MACOS_RUNNER`. **D'ici là, lancer le script à la main avant toute pull request -touchant à l'application.** +**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, et ce que +cela engage. 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 diff --git a/tools/ci/README.md b/tools/ci/README.md new file mode 100644 index 00000000..64d7abcb --- /dev/null +++ b/tools/ci/README.md @@ -0,0 +1,68 @@ +# Brancher un runner macOS sur la forge + +Les tests iOS réclament Xcode et un simulateur : ils ne tournent que sur macOS, et la +forge n'héberge que des runners Linux. Le job `ios` de `.gitea/workflows/ci.yml` attend +donc un runner étiqueté `macos`, à brancher une fois. + +## Ce que cela engage + +Un runner self-hosted **exécute le code des pull requests sur la machine qui l'héberge**. +Sur un dépôt privé entre gens de confiance, c'est le fonctionnement normal ; sur un dépôt +ouvert aux contributions extérieures, ce serait une porte grande ouverte. À garder en tête +si le dépôt change de statut. + +La machine doit aussi être allumée et connectée quand une pull request arrive. Sinon le +job reste en file d'attente : Gitea n'échoue pas, il attend, et la pull request n'a pas de +verdict. D'où l'interrupteur décrit plus bas. + +## Prérequis de la machine + +Xcode (avec un runtime de simulateur iOS), `xcodegen`, `rustup`, Node et `npm`. Le job les +vérifie au démarrage et s'arrête net avec un message clair s'il en manque un. + +```sh +brew install gitea-runner xcodegen +xcodebuild -downloadPlatform iOS # si aucun runtime de simulateur n'est installé +``` + +## Enregistrement + +Le jeton s'obtient dans la forge : **Settings > Actions > Runners > Create new runner** +(au niveau du dépôt, ou de l'organisation pour le partager entre projets). + +```sh +gitea-runner register --no-interactive \ + --instance https://git.stackops.ch \ + --token \ + --name "$(scutil --get ComputerName)" \ + --labels 'macos:host' +``` + +`macos:host` — le suffixe compte. Il demande d'exécuter les tâches **directement sur la +machine**, et non dans un conteneur : macOS n'en a pas, et le job a besoin de l'Xcode du +poste, pas d'une image. + +## Exécution + +```sh +brew services start gitea-runner # au démarrage de session, en arrière-plan +# ou, ponctuellement : +gitea-runner daemon +``` + +## L'interrupteur + +Le job est conditionné à la variable de dépôt `MACOS_RUNNER` +(**Settings > Actions > Variables**) : + +- `true` — le job tourne à chaque pull request ; +- absente ou toute autre valeur — le job est ignoré, et les tests iOS se lancent à la main + avec `./tools/ios/run-ios-tests.sh`. + +C'est ce qu'il faut basculer quand la machine part en vacances, plutôt que de laisser les +pull requests attendre un runner qui ne répondra pas. + +## Vérifier que le runner répond + +Il apparaît dans **Settings > Actions > Runners**, en ligne, avec son étiquette. En cas de +doute, `gitea-runner daemon` au premier plan montre les tâches qu'il reçoit. From ae6dd35bce0c84b38a654dc7d7fbf0b509d3ad82 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 11:35:56 +0200 Subject: [PATCH 14/50] =?UTF-8?q?docs(ci):=20distinguer=20le=20poste=20pr?= =?UTF-8?q?=C3=AAt=C3=A9=20de=20la=20machine=20d=C3=A9di=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le runner macOS est pour l'instant un portable personnel, en attendant un Mac mini. Les deux ne se configurent pas de la même façon, et la marche à suivre le dit maintenant. Sur un poste personnel : pas de service au démarrage. Un portable se ferme, voyage, change de réseau ; un runner lancé à la session travaillerait sans qu'on le sache et resterait injoignable la moitié du temps. On le lance au premier plan le temps d'une vérification, et `MACOS_RUNNER` suit le même rythme — à `false` par défaut, à `true` le temps utile. Sinon chaque pull request ouverte pendant que le portable dort attend un runner qui ne viendra pas. Sur la machine dédiée, c'est l'inverse : service permanent, variable à `true`. Le workflow ne change pas au passage de l'un à l'autre — même étiquette `macos`, seul le nom diffère. La procédure de retrait est documentée aussi : un runner qu'on débranche sans le supprimer côté forge reste un runner à qui Gitea confiera des tâches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/README.md | 4 +-- tools/ci/README.md | 87 ++++++++++++++++++++++++++++------------------ 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/apps/ios/README.md b/apps/ios/README.md index a199e9be..c1a3fae0 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -39,8 +39,8 @@ du projet vit dans `project.yml`, qui se relit et se fusionne — contrairement **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, et ce que -cela engage. Il est piloté par la variable de dépôt `MACOS_RUNNER` : tant qu'elle n'est pas +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**. diff --git a/tools/ci/README.md b/tools/ci/README.md index 64d7abcb..e8725917 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -2,33 +2,24 @@ Les tests iOS réclament Xcode et un simulateur : ils ne tournent que sur macOS, et la forge n'héberge que des runners Linux. Le job `ios` de `.gitea/workflows/ci.yml` attend -donc un runner étiqueté `macos`, à brancher une fois. +donc un runner étiqueté `macos`. -## Ce que cela engage +Deux situations, et elles ne se configurent pas pareil : un **poste personnel qu'on prête +en dépannage**, et une **machine dédiée** qui prendra le relais. -Un runner self-hosted **exécute le code des pull requests sur la machine qui l'héberge**. -Sur un dépôt privé entre gens de confiance, c'est le fonctionnement normal ; sur un dépôt -ouvert aux contributions extérieures, ce serait une porte grande ouverte. À garder en tête -si le dépôt change de statut. - -La machine doit aussi être allumée et connectée quand une pull request arrive. Sinon le -job reste en file d'attente : Gitea n'échoue pas, il attend, et la pull request n'a pas de -verdict. D'où l'interrupteur décrit plus bas. - -## Prérequis de la machine +## Prérequis, dans les deux cas Xcode (avec un runtime de simulateur iOS), `xcodegen`, `rustup`, Node et `npm`. Le job les -vérifie au démarrage et s'arrête net avec un message clair s'il en manque un. +vérifie au démarrage et s'arrête net, avec un message clair, s'il en manque un. ```sh brew install gitea-runner xcodegen xcodebuild -downloadPlatform iOS # si aucun runtime de simulateur n'est installé ``` -## Enregistrement - -Le jeton s'obtient dans la forge : **Settings > Actions > Runners > Create new runner** -(au niveau du dépôt, ou de l'organisation pour le partager entre projets). +Le jeton d'enregistrement s'obtient dans la forge : **Settings > Actions > Runners > +Create new runner**, au niveau du dépôt ou de l'organisation pour le partager entre les +projets de la suite. ```sh gitea-runner register --no-interactive \ @@ -38,31 +29,61 @@ gitea-runner register --no-interactive \ --labels 'macos:host' ``` -`macos:host` — le suffixe compte. Il demande d'exécuter les tâches **directement sur la -machine**, et non dans un conteneur : macOS n'en a pas, et le job a besoin de l'Xcode du -poste, pas d'une image. +`macos:host` — le suffixe compte. Il fait exécuter les tâches **directement sur la +machine** plutôt que dans un conteneur : macOS n'en a pas, et le job a besoin de l'Xcode +du poste, pas d'une image. + +## En dépannage, sur un poste personnel -## Exécution +**Pas de service qui démarre tout seul.** Un portable se ferme, voyage, change de réseau ; +un runner lancé à la session travaillerait sans qu'on le sache, et resterait injoignable la +moitié du temps. On le lance quand on veut qu'une pull request soit vérifiée, on l'arrête +ensuite : ```sh -brew services start gitea-runner # au démarrage de session, en arrière-plan -# ou, ponctuellement : -gitea-runner daemon +gitea-runner daemon # au premier plan : on voit les tâches arriver, Ctrl-C pour rendre la machine ``` -## L'interrupteur +Et l'interrupteur suit le même rythme. `MACOS_RUNNER` (**Settings > Actions > Variables**) +reste à `false` par défaut ; on le passe à `true` le temps de faire tourner la vérification. +Sinon, chaque pull request ouverte pendant que le portable est fermé attend un runner qui +ne viendra pas : Gitea n'échoue pas, il attend, et la pull request reste sans verdict. -Le job est conditionné à la variable de dépôt `MACOS_RUNNER` -(**Settings > Actions > Variables**) : +Un point à ne pas perdre de vue : un runner self-hosted **exécute le code des pull requests +sur la machine qui l'héberge**. Sur un dépôt privé entre gens de confiance, c'est le +fonctionnement normal. Sur un poste personnel, cela reste une raison de plus de ne +l'allumer que le temps utile — et de ne pas ouvrir le dépôt aux contributions extérieures +tant qu'il est branché. -- `true` — le job tourne à chaque pull request ; -- absente ou toute autre valeur — le job est ignoré, et les tests iOS se lancent à la main - avec `./tools/ios/run-ios-tests.sh`. +## Sur la machine dédiée + +Une fois le Mac mini en place, c'est l'inverse : il est fait pour attendre du travail. + +```sh +brew services start gitea-runner # démarre à la session et se relance tout seul +``` + +`MACOS_RUNNER` peut alors rester à `true` en permanence. + +Le workflow n'a **rien à changer** au passage de l'un à l'autre : les deux machines +portent la même étiquette `macos`, seul le nom diffère. Il suffit d'enregistrer le mini, +de vérifier qu'il apparaît en ligne, puis de retirer le portable. + +## Retirer un runner + +À faire quand le portable rend son tablier, pour ne pas laisser un runner fantôme que la +forge croira joignable : + +```sh +brew services stop gitea-runner 2>/dev/null || true +gitea-runner --config /opt/homebrew/etc/gitea-runner/config.yaml daemon --once 2>/dev/null || true +rm -f .runner # le fichier d'enregistrement, dans le dossier d'où il a été lancé +``` -C'est ce qu'il faut basculer quand la machine part en vacances, plutôt que de laisser les -pull requests attendre un runner qui ne répondra pas. +Puis le supprimer côté forge : **Settings > Actions > Runners**, bouton de suppression en +face de son nom. Tant qu'il y figure, Gitea peut lui confier des tâches. -## Vérifier que le runner répond +## Vérifier qu'il répond Il apparaît dans **Settings > Actions > Runners**, en ligne, avec son étiquette. En cas de doute, `gitea-runner daemon` au premier plan montre les tâches qu'il reçoit. From 8d9018ff50d5929ca9574b25693d9abe88237ca5 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 11:54:05 +0200 Subject: [PATCH 15/50] =?UTF-8?q?feat(ios):=20corbeille=20=E2=80=94=20rest?= =?UTF-8?q?aurer=20ou=20supprimer=20pour=20de=20bon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur pratique la suppression douce depuis toujours : `DELETE /api/vault/items/:id` déplace l'item vers la corbeille, et trois routes l'y attendaient sans que rien ne les appelle. Supprimer depuis l'application était donc réversible, mais personne ne pouvait s'en servir — ni voir ce qui traînait là. L'écran se consulte depuis le menu du coffre. Il ne garde rien en mémoire : un item supprimé n'a pas sa place dans `entries`, et le relire à l'ouverture coûte moins cher que de le tenir à jour pour un écran qu'on visite rarement. Deux gestes, et une asymétrie voulue. Balayer vers la droite restaure — rien de grave si la main glisse. Balayer vers la gauche supprime définitivement, et **demande confirmation** en nommant l'item : celui-là ne revient pas, et le geste ne doit pas ressembler à l'autre. Le parcours de test couvre le cycle entier : suppression, présence en corbeille, restauration, retour dans le coffre, nouvelle suppression, purge confirmée. La vérification ne porte pas sur la disparition du nom mais sur le vidage de la corbeille — l'item restauré réapparaît dans le coffre sous la feuille, où il reste visible de l'arbre d'accessibilité. C'est ce qui avait fait échouer la première version du test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/Ghostpass/Services/APIClient.swift | 16 ++++ apps/ios/Ghostpass/Services/VaultStore.swift | 41 ++++++++++ apps/ios/Ghostpass/Views/TrashView.swift | 86 ++++++++++++++++++++ apps/ios/Ghostpass/Views/VaultListView.swift | 6 ++ apps/ios/UITests/VaultFlowTests.swift | 49 +++++++++++ tools/ci/README.md | 10 ++- 6 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 apps/ios/Ghostpass/Views/TrashView.swift diff --git a/apps/ios/Ghostpass/Services/APIClient.swift b/apps/ios/Ghostpass/Services/APIClient.swift index fd2e28b6..b471eb89 100644 --- a/apps/ios/Ghostpass/Services/APIClient.swift +++ b/apps/ios/Ghostpass/Services/APIClient.swift @@ -152,4 +152,20 @@ struct APIClient { 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) + } } diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 301a6c37..71176f51 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -311,6 +311,47 @@ final class VaultStore: ObservableObject { } } + // ─── 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 = "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 = "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 diff --git a/apps/ios/Ghostpass/Views/TrashView.swift b/apps/ios/Ghostpass/Views/TrashView.swift new file mode 100644 index 00000000..f1e03458 --- /dev/null +++ b/apps/ios/Ghostpass/Views/TrashView.swift @@ -0,0 +1,86 @@ +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 { + List { + ForEach(entries) { entry in + VStack(alignment: .leading, spacing: 2) { + Text(entry.item.name) + if let login = entry.login, !login.username.isEmpty { + Text(login.username).font(.caption).foregroundStyle(.secondary) + } + } + .swipeActions(edge: .leading) { + Button("Restaurer") { + Task { + await store.restore(entry) + await recharger() + } + } + .tint(.blue) + } + .swipeActions { + Button("Supprimer", role: .destructive) { aPurger = entry } + } + } + } + .overlay { + if chargement { + ProgressView() + } else if entries.isEmpty { + ContentUnavailableView( + "Corbeille vide", systemImage: "trash", + description: Text("Les éléments supprimés atterrissent ici.")) + } + } + .navigationTitle("Corbeille") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fermer") { dismiss() } + .accessibilityIdentifier("button.closeTrash") + } + } + // Une suppression définitive ne se rattrape pas : elle se confirme. + .alert( + "Supprimer définitivement ?", + isPresented: .constant(aPurger != nil), + 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() } + } + } + + private func recharger() async { + chargement = true + entries = await store.loadTrash() + chargement = false + } +} diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 3a56f584..62da25bf 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -77,6 +77,8 @@ struct VaultListView: View { .accessibilityIdentifier("button.biometricOn") } } + Button("Corbeille", systemImage: "trash") { sheet = .trash } + .accessibilityIdentifier("button.trash") Button("Se déconnecter", role: .destructive) { Task { await store.signOut() } } @@ -103,6 +105,8 @@ struct VaultListView: View { ItemEditView(target: .existing(entry)).environmentObject(store) case .biometrics: BiometricSetupView().environmentObject(store) + case .trash: + TrashView().environmentObject(store) } } .alert( @@ -132,12 +136,14 @@ enum VaultSheet: Identifiable { case newItem case editItem(VaultEntry) case biometrics + case trash var id: String { switch self { case .newItem: return "new" case .editItem(let entry): return entry.id case .biometrics: return "biometrics" + case .trash: return "trash" } } } diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index 9abeaebd..07c47fd5 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -156,6 +156,55 @@ final class VaultFlowTests: XCTestCase { 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"].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)) diff --git a/tools/ci/README.md b/tools/ci/README.md index e8725917..6b0cfe78 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -21,7 +21,12 @@ Le jeton d'enregistrement s'obtient dans la forge : **Settings > Actions > Runne Create new runner**, au niveau du dépôt ou de l'organisation pour le partager entre les projets de la suite. +`register` écrit un fichier `.runner` — son identité auprès de la forge — **dans le +répertoire courant**, et `daemon` le cherche au même endroit. D'où un dossier à soi, +plutôt qu'un fichier égaré dans le dépôt ou au fond du dossier personnel : + ```sh +mkdir -p ~/.gitea-runner && cd ~/.gitea-runner gitea-runner register --no-interactive \ --instance https://git.stackops.ch \ --token \ @@ -41,7 +46,8 @@ moitié du temps. On le lance quand on veut qu'une pull request soit vérifiée, ensuite : ```sh -gitea-runner daemon # au premier plan : on voit les tâches arriver, Ctrl-C pour rendre la machine +cd ~/.gitea-runner && gitea-runner daemon # au premier plan : on voit les tâches + # arriver, Ctrl-C pour rendre la machine ``` Et l'interrupteur suit le même rythme. `MACOS_RUNNER` (**Settings > Actions > Variables**) @@ -77,7 +83,7 @@ forge croira joignable : ```sh brew services stop gitea-runner 2>/dev/null || true gitea-runner --config /opt/homebrew/etc/gitea-runner/config.yaml daemon --once 2>/dev/null || true -rm -f .runner # le fichier d'enregistrement, dans le dossier d'où il a été lancé +rm -rf ~/.gitea-runner # son identité auprès de la forge ``` Puis le supprimer côté forge : **Settings > Actions > Runners**, bouton de suppression en From e654112e9211b9e59f95f0b10bba94c4f3585374 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 12:21:09 +0200 Subject: [PATCH 16/50] =?UTF-8?q?feat(ios):=20l'interface=20adopte=20le=20?= =?UTF-8?q?syst=C3=A8me=20visuel=20de=20la=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'application portait les styles par défaut de SwiftUI : listes grises, formulaires système, aucun rapport avec les autres produits. Elle reprend maintenant le système visuel de ghostcal, transposé depuis `globals.css` — nuit profonde, surfaces de verre fumé, halo diffusé depuis le haut, intitulés en petites capitales espacées. Les produits de la suite partagent cette structure et ne se distinguent que par leur accent, qui est la teinte de leur logo. Pour GhostPass, le violet `#7B4DFF` — et sa variante claire `#B79CFF` pour ce qui doit rester lisible sur fond sombre : le violet plein manque de clarté sur la nuit, ce sont les deux teintes du logo qui se partagent le travail. `Theme.swift` réunit couleurs, mesures et vocabulaire commun : `GhostScreen`, `GhostSection`, `GhostRow`, `GlassCard`, les styles de boutons et de champs. Les sept écrans s'y conforment, extension de remplissage comprise — elle partage désormais le thème comme elle partageait déjà le modèle. Deux ajouts qui ne sont pas que décoratifs : - Le générateur affiche l'entropie du mot de passe proposé. Elle ne dit rien d'une fuite, seulement de ce qu'il en coûterait de le deviner — mais c'est la seule chose mesurable ici, et elle rend le curseur de longueur intelligible. - Une copie ne dit rien d'elle-même : un bandeau confirme laquelle, et rappelle que le presse-papiers s'efface dans trente secondes. Deux corrections trouvées en chemin : - Ma première version masquait le `NavigationLink` sous une opacité nulle pour éviter le chevron du système. Un lien invisible est aussi un lien **inatteignable**, au doigt comme au clavier. Le chevron du système fait donc l'affaire. - `textContentType(.password)` faisait proposer par iOS d'enregistrer le mot de passe maître dans son propre trousseau. Pour un gestionnaire de mots de passe, la proposition n'a pas de sens : l'attribut est retiré. Vérifié écran par écran dans le simulateur, en clair comme en sombre, puis par la suite complète : 32 tests de contrat, parcours, hors-ligne. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../LogoMark.imageset/Contents.json | 8 + .../LogoMark.imageset/LogoMark@1x.png | Bin 0 -> 2300 bytes .../LogoMark.imageset/LogoMark@2x.png | Bin 0 -> 5135 bytes .../LogoMark.imageset/LogoMark@3x.png | Bin 0 -> 8998 bytes apps/ios/Ghostpass/GhostpassApp.swift | 12 + apps/ios/Ghostpass/Theme/Theme.swift | 289 ++++++++++++++++++ .../Ghostpass/Views/BiometricSetupView.swift | 68 +++-- apps/ios/Ghostpass/Views/ItemDetailView.swift | 263 +++++++++------- apps/ios/Ghostpass/Views/ItemEditView.swift | 205 +++++++++---- .../Views/PasswordGeneratorView.swift | 101 +++++- apps/ios/Ghostpass/Views/TrashView.swift | 86 ++++-- apps/ios/Ghostpass/Views/UnlockView.swift | 203 +++++++----- apps/ios/Ghostpass/Views/VaultListView.swift | 126 ++++++-- apps/ios/GhostpassAutoFill/AutoFillView.swift | 160 +++++++--- apps/ios/README.md | 18 +- apps/ios/project.yml | 1 + ...{make-app-icon.sh => make-brand-assets.sh} | 26 +- 17 files changed, 1186 insertions(+), 380 deletions(-) create mode 100644 apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/Contents.json create mode 100644 apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@1x.png create mode 100644 apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@2x.png create mode 100644 apps/ios/Ghostpass/Assets.xcassets/LogoMark.imageset/LogoMark@3x.png create mode 100644 apps/ios/Ghostpass/Theme/Theme.swift rename tools/ios/{make-app-icon.sh => make-brand-assets.sh} (68%) 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 0000000000000000000000000000000000000000..f8ed542ca97d5b728298a1c763a39b02dd8675d2 GIT binary patch literal 2300 zcmV%b5Gyy0SZ!(f)u16|Gy$vv-1r$`>5gz z00vkVdJ54G7S4;H5rU5q!I6H=I55tZSXW`V;smUCkVQSZqskC7#o&u#U_<~BK#W@^ z>t|tMvjwc2yg=Rw8_yLZV8c9?G!b)*!8{T4&R}plUB$v16fBuCU-pKD>xvNYi~BiF z4AzN3c>sgc=@h|p62$|PACl&Lv2{5Cb#wWE2%Z!JD+J@)&W&Irw)0 zXLBBa<+&P}g?>OSOqe=PKF$?mml3dH7H0_XTnI*XrXMoEExCGv3EOZ0SU!`Vr-M2w zkoI6``Va-y<%%_Q57<&k>sTq?7Qv8^MmQ@S24A1LP+kj)H*^p9R}rRAF(kwh?p#*@ zie+!wj9gU$>oQUz22rCVmJXF5{qq#N7$n)@S; z!CgUc2M!u(*jK>2-d)0pfT8dx(o|#TeSKKd2j6x`2rNx zDgE^ef;%(Hu{6vwbOJSeQh_R69}nz-O~;o;`PM6LJaWjD}aPZ085n$QbE!$xD9&bZZ3Va zlA{xK9s`#2neCm51khs_e?JQx3<^f8lj7c8g>M1Vjct%Lw>+KiXMsZjXv31on_Vnwk zb`Hk_*py~T{YW5LPPkB40lgs}ge5-TC1owt_)v+Lat9zV@UbfHGXO~R@kI06nB3en4du^`w(A;2Lg7#E4sK*U`MfKE!u>Osk*8tJFIWGL{rAlUOJ zV0Ojn>p>mOHZ+McF+3(7q9w%>=$*WtpL}$c&Pj50r&|R3g5b}a05W!;LZXRLHC`iq z3F*=)ad^&Zxex?@UIbKD(!!||)oe>A#Kn@y?F|GSr-(@mc^*WF;@3fK$cunpC*Pm0 zVr~>Lj)bl+W*Z1S9-;o(V<4S^twC+bIS;tE;=S&XR=gu31KQSm#NTzdum72Hf(*HW zm|R|?9b2#g1A}CNKpq|-EzS>UWI*cZY8LM9SDFmjV>Zzr2HS!f59CO|eWU*|2Gqr5 zDx!2{rT`c(JH`$JA+ntkY6+gs#g@PXe0#)^zK+AKA{tE^WeSX>BOpXH`pk^Q|*Ef8+ZR691=q&tuJ*gQ95a4}S*7y}W zdY*AFzBGSAQyE1jCEe?nJzB5>TkVmoH$;y1I8Rbz)RYYmD&p64?*%Ddlv+<2`;Stg zB;sYq!y#*)+EI2UxgOu@&`;74-xYpzo9wtE1k~4$i&1q|G6*IJ*wXA=obd9V{vAGA zQe}m29rplrQ{5l0`CV!~hZ=1s$%i~>D`WV6KE?w%Btnyi?VSWzUxC3z5p&)se!uDM z;d}hoit>0^djxMo+f@c_`RsoP43GdTW?;hq0* z&&`3NixE30c0cKoZ-i{nG@EYg0gg#OhjsV$Z++_7)Vg!VeK?+Us?)^_rJf@&*{+wC zFtCQVE$)${!~b4uXr}ou+yEf0M)^G(9baD&xIMnP@0%+3lHl;8B2P$uAoQ9rA6!aV{}w^Q4^!5!9{NQMN5iyrE8k%He;VNa{~AxIIYS zS>7<{v2<-Xs=JiRB)3T$Qu1;SZ#$;h4d%W^qPyg(lN0e1~Xhi>kHZrNcKL54)d&6r>;pDM&%`m;V94 WnI>^TiFt_t000077i-U6g@lkl3-_%{qzh%_= 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 IDs2+@KP;`;kN^Mx literal 0 HcmV?d00001 diff --git a/apps/ios/Ghostpass/GhostpassApp.swift b/apps/ios/Ghostpass/GhostpassApp.swift index 1c7bac2a..a896207d 100644 --- a/apps/ios/Ghostpass/GhostpassApp.swift +++ b/apps/ios/Ghostpass/GhostpassApp.swift @@ -2,6 +2,18 @@ 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() @Environment(\.scenePhase) private var scenePhase diff --git a/apps/ios/Ghostpass/Theme/Theme.swift b/apps/ios/Ghostpass/Theme/Theme.swift new file mode 100644 index 00000000..5ef43445 --- /dev/null +++ b/apps/ios/Ghostpass/Theme/Theme.swift @@ -0,0 +1,289 @@ +import SwiftUI + +/// Le système visuel de la suite, transposé de `ghostcal/frontend/src/app/globals.css`. +/// +/// Les produits partagent la même structure — nuit profonde, surfaces de verre fumé, +/// halo coloré diffusé depuis le haut — et ne se distinguent que par leur accent, qui est +/// la teinte de leur logo. Pour GhostPass, c'est le violet : `#7B4DFF`, et sa variante +/// claire `#B79CFF` pour ce qui doit rester lisible sur fond sombre. +/// +/// 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 { + /// Fond de page. + static let gpBase = adaptative(sombre: 0x0B0F19, clair: 0xF5F8FC) + /// Cartes et lignes. + static let gpSurface = adaptative(sombre: 0x1E293B, clair: 0xFFFFFF) + /// Champs et surfaces imbriquées. + static let gpSurface2 = adaptative(sombre: 0x161E2E, clair: 0xEEF3F9) + /// Texte principal. + static let gpInk = adaptative(sombre: 0xE2E8F0, clair: 0x0F1B2D) + /// Texte secondaire. + static let gpMuted = adaptative(sombre: 0x94A3B8, clair: 0x5B6B82) + + /// Accent plein : fonds de boutons, pastilles. Assez sombre pour porter du blanc. + static let gpAccent = adaptative(sombre: 0x7B4DFF, clair: 0x5B2FD0) + /// Accent en texte ou en icône. Sur fond de nuit, le violet plein manque de clarté : + /// c'est la teinte haute du logo qui prend le relais. + static let gpAccentText = adaptative(sombre: 0xB79CFF, clair: 0x5B2FD0) + /// Ce qui se pose sur `gpAccent`. + static let gpOnAccent = Color.white + + static let gpBorder = adaptativeAlpha(sombre: (0xFFFFFF, 0.08), clair: (0x0F172A, 0.10)) + static let gpBorderStrong = adaptativeAlpha(sombre: (0xFFFFFF, 0.16), clair: (0x0F172A, 0.16)) + + static let gpDanger = adaptative(sombre: 0xFB7185, clair: 0xD11F45) + static let gpSuccess = adaptative(sombre: 0x4ADE80, clair: 0x15803D) + + 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 fond de l'application : nuit profonde et halo diffusé depuis le haut — la lueur du +/// fantôme, qui donne sa profondeur à l'ensemble sans rien coûter en lisibilité. +struct GhostBackground: View { + var body: some View { + Color.gpBase + .overlay(alignment: .top) { + RadialGradient( + colors: [Color.gpAccent.opacity(0.18), .clear], + center: .top, startRadius: 0, endRadius: 520 + ) + .frame(height: 620) + .offset(y: -140) + .blur(radius: 40) + } + .ignoresSafeArea() + } +} + +/// 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)) + .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 { + var titre: String? + var note: String? + @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: String + 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(valeur) + } else { + Text(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) + } +} diff --git a/apps/ios/Ghostpass/Views/BiometricSetupView.swift b/apps/ios/Ghostpass/Views/BiometricSetupView.swift index 069b643e..a7811bab 100644 --- a/apps/ios/Ghostpass/Views/BiometricSetupView.swift +++ b/apps/ios/Ghostpass/Views/BiometricSetupView.swift @@ -11,25 +11,53 @@ struct BiometricSetupView: View { var body: some View { NavigationStack { - Form { - Section { - SecureField("Mot de passe maître", text: $password) - .textContentType(.password) - .accessibilityIdentifier("field.masterConfirm") - } footer: { + 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.") + + "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 { - Section { - Text(message).foregroundStyle(.red) - } + Label(message, systemImage: "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("Activer \(store.biometryLabel)") + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -37,18 +65,14 @@ struct BiometricSetupView: View { store.errorMessage = nil dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - 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 = "" - } - .disabled(password.isEmpty) - .accessibilityIdentifier("button.confirmBiometric") + .foregroundStyle(Color.gpMuted) } } } + .tint(Color.gpAccentText) + } + + private func invite(_ texte: String) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) } } diff --git a/apps/ios/Ghostpass/Views/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift index d92d5047..4bb699a5 100644 --- a/apps/ios/Ghostpass/Views/ItemDetailView.swift +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -1,11 +1,13 @@ import SwiftUI +/// Le détail d'un élément du coffre. struct ItemDetailView: View { let entry: VaultEntry var onEdit: () -> Void @EnvironmentObject private var store: VaultStore @State private var revealed = false + @State private var copie: String? /// 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 @@ -13,133 +15,190 @@ struct ItemDetailView: View { private var live: VaultEntry { store.entries.first { $0.id == entry.id } ?? entry } var body: some View { - Form { + GhostScreen { switch live.item.data { - case .login(let login): - Section("Identifiants") { - CopyRow(label: "Nom d'utilisateur", value: login.username) - SecretRow(label: "Mot de passe", value: login.password, revealed: $revealed) - } - if let config = login.totp.flatMap(Totp.parse) { - Section("Code à usage unique") { - // `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 { - Text(otp.code) - .font(.system(.title2, design: .monospaced)) - .accessibilityIdentifier("text.totp") - Spacer() - Text("\(otp.remaining) s") - .font(.footnote) - .foregroundStyle(otp.remaining <= 5 ? .red : .secondary) - .monospacedDigit() - Button { - Clipboard.copy(otp.code) - } label: { - Image(systemName: "doc.on.doc") - } - .buttonStyle(.borderless) - } - } else { - Text("Clé de vérification illisible.") - .foregroundStyle(.secondary) - } - } - } - } - if !login.uris.isEmpty { - Section("Adresses") { - ForEach(login.uris, id: \.self) { uri in - Text(uri).font(.callout).textSelection(.enabled) - } - } - } - case .secureNote(let note): - Section("Note") { - Text(note.content).textSelection(.enabled) - } - case .card(let card): - Section("Carte") { - CopyRow(label: "Titulaire", value: card.cardholder) - SecretRow(label: "Numéro", value: card.number, revealed: $revealed) - LabeledContent("Expiration", value: "\(card.expMonth)/\(card.expYear)") - } + 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 { - Section("Notes") { Text(notes).textSelection(.enabled) } + GhostSection(titre: "Notes") { + Text(notes) + .foregroundStyle(Color.gpInk) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } } if let folder = live.item.folder, !folder.isEmpty { - Section("Dossier") { Text(folder).foregroundStyle(.secondary) } + GhostSection(titre: "Dossier") { + Label(folder, systemImage: "folder") + .foregroundStyle(Color.gpMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } } } .navigationTitle(live.item.name) .navigationBarTitleDisplayMode(.inline) .toolbar { Button("Modifier", action: onEdit) + .foregroundStyle(Color.gpAccentText) .accessibilityIdentifier("button.edit") } + .overlay(alignment: .bottom) { confirmationDeCopie } + .animation(.snappy, value: copie) } -} - -private struct CopyRow: View { - let label: String - let value: String - var body: some View { - HStack { - LabeledContent(label, value: value) - Spacer() - Button { - Clipboard.copy(value) - } label: { - Image(systemName: "doc.on.doc") + @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é") + } } - .buttonStyle(.borderless) } - } -} -/// Un secret ne s'affiche que sur demande explicite, et la copie ne l'affiche pas : -/// l'épaule du voisin est un modèle de menace plus courant que l'attaquant distant. -private struct SecretRow: View { - let label: String - let value: String - @Binding var revealed: Bool - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(label).font(.caption).foregroundStyle(.secondary) - // `.enabled` et `.disabled` sont deux types distincts : un ternaire ne les - // unifie pas. Le modificateur ne porte donc que sur le cas révélé — masqué, - // il n'y aurait de toute façon que des puces à sélectionner. - Group { - if revealed { - Text(value).textSelection(.enabled) + 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(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(String(repeating: "•", count: max(value.count, 8))) + Text("Clé de vérification illisible.") + .foregroundStyle(Color.gpMuted) + .padding(14) } } - .font(.system(.body, design: .monospaced)) } - Spacer() - Button { - revealed.toggle() - } label: { - Image(systemName: revealed ? "eye.slash" : "eye") + } + + if !login.uris.isEmpty { + GhostSection(titre: "Adresses") { + ForEach(Array(login.uris.enumerated()), id: \.offset) { index, uri in + if index > 0 { GhostDivider() } + HStack { + Text(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) + } } - .buttonStyle(.borderless) - .accessibilityIdentifier("button.reveal") - Button { - Clipboard.copy(value) - } label: { - Image(systemName: "doc.on.doc") + } + } + + @ViewBuilder + private func contenuNote(_ note: SecureNote) -> some View { + GhostSection(titre: "Note") { + Text(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é") } } - .buttonStyle(.borderless) + 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)") {} + } + } + + /// 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(copie, systemImage: "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: String) { + Clipboard.copy(valeur) + copie = "\(message) — effacé dans \(Int(Clipboard.lifetime)) s" + 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 index 0f488231..13e23d61 100644 --- a/apps/ios/Ghostpass/Views/ItemEditView.swift +++ b/apps/ios/Ghostpass/Views/ItemEditView.swift @@ -17,89 +17,60 @@ struct ItemEditView: View { @State private var password = "" @State private var uri = "" @State private var totp = "" - @State private var generating = false @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 = "Identifiant" case note = "Note" case card = "Carte" var id: String { rawValue } + + 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 { - Form { - Section { - TextField("Nom", text: $name) + GhostScreen { + GhostSection(titre: "Nom") { + TextField("", text: $name, prompt: invite("GitHub, Amazon…")) + .foregroundStyle(Color.gpInk) + .padding(14) .accessibilityIdentifier("field.name") - if isNew { - Picker("Type", selection: $kind) { - ForEach(Kind.allCases) { Text($0.rawValue).tag($0) } - } - } + } + + if isNew { + selecteurDeType } switch kind { - case .login: - Section("Identifiants") { - TextField("Nom d'utilisateur", text: $username) - .accessibilityIdentifier("field.username") - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - HStack { - SecureField("Mot de passe", text: $password) - .accessibilityIdentifier("field.password") - Button { - generating = true - } label: { - Image(systemName: "dice") - } - .buttonStyle(.borderless) - .accessibilityIdentifier("button.generate") - } - TextField("Adresse du site", text: $uri) - .accessibilityIdentifier("field.uri") - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - } - Section { - TextField("Clé ou URI otpauth://", text: $totp) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .accessibilityIdentifier("field.totp") - } header: { - Text("Code à usage unique") - } footer: { - Text( - "La clé reste chiffrée dans l'item ; les codes sont calculés " - + "sur l'appareil.") - } - case .note: - Section("Contenu") { - TextEditor(text: $noteContent).frame(minHeight: 140) - } - case .card: - Section("Carte") { - TextField("Titulaire", text: $cardholder) - TextField("Numéro", text: $cardNumber).keyboardType(.numberPad) - HStack { - TextField("MM", text: $expMonth).keyboardType(.numberPad) - TextField("AAAA", text: $expYear).keyboardType(.numberPad) - } - SecureField("Cryptogramme", text: $cardCode) - } + case .login: champsIdentifiant + case .note: champsNote + case .card: champsCarte } - Section("Classement") { - TextField("Dossier (ex. Travail/Serveurs)", text: $folder) - TextField("Notes", text: $notes, axis: .vertical).lineLimit(2...6) + 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 ? "Nouvel élément" : "Modifier") @@ -107,6 +78,7 @@ struct ItemEditView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Annuler") { dismiss() } + .foregroundStyle(Color.gpMuted) .accessibilityIdentifier("button.cancel") } ToolbarItem(placement: .confirmationAction) { @@ -116,6 +88,8 @@ struct ItemEditView: View { dismiss() } } + .fontWeight(.semibold) + .foregroundStyle(Color.gpAccentText) .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("button.save") } @@ -125,6 +99,115 @@ struct ItemEditView: View { 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.rawValue).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: String) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) } private var isNew: Bool { diff --git a/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift b/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift index 7a45f3cc..3119deab 100644 --- a/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift +++ b/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift @@ -2,7 +2,7 @@ import SwiftUI /// Génération d'un mot de passe, présentée depuis le formulaire d'édition. /// -/// Le mot de passe n'est proposé que ; il n'est retenu que si l'utilisateur l'accepte. +/// 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. @@ -14,28 +14,51 @@ struct PasswordGeneratorView: View { var body: some View { NavigationStack { - Form { - Section { - Text(password.isEmpty ? " " : password) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier("text.generated") - Button("Régénérer", systemImage: "arrow.clockwise") { regenerate() } - .accessibilityIdentifier("button.regenerate") + GhostScreen { + GhostSection { + VStack(spacing: 14) { + Text(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) } - Section("Longueur : \(options.length)") { + 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") } - Section("Caractères") { - Toggle("Minuscules", isOn: binding(\.lowercase)) - Toggle("Majuscules", isOn: binding(\.uppercase)) - Toggle("Chiffres", isOn: binding(\.digits)) - Toggle("Symboles", isOn: binding(\.symbols)) + 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") @@ -43,18 +66,64 @@ struct PasswordGeneratorView: View { .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: String, _ exemple: String, _ path: WritableKeyPath + ) -> some View { + Toggle(isOn: binding(path)) { + HStack(spacing: 8) { + Text(titre).foregroundStyle(Color.gpInk) + Text(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): (String, 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() { diff --git a/apps/ios/Ghostpass/Views/TrashView.swift b/apps/ios/Ghostpass/Views/TrashView.swift index f1e03458..28d3bac8 100644 --- a/apps/ios/Ghostpass/Views/TrashView.swift +++ b/apps/ios/Ghostpass/Views/TrashView.swift @@ -16,35 +16,42 @@ struct TrashView: View { var body: some View { NavigationStack { - List { - ForEach(entries) { entry in - VStack(alignment: .leading, spacing: 2) { - Text(entry.item.name) - if let login = entry.login, !login.username.isEmpty { - Text(login.username).font(.caption).foregroundStyle(.secondary) - } - } - .swipeActions(edge: .leading) { - Button("Restaurer") { - Task { - await store.restore(entry) - await recharger() + 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 } } - } - .tint(.blue) - } - .swipeActions { - Button("Supprimer", role: .destructive) { aPurger = entry } } } - } - .overlay { - if chargement { - ProgressView() - } else if entries.isEmpty { - ContentUnavailableView( - "Corbeille vide", systemImage: "trash", - description: Text("Les éléments supprimés atterrissent ici.")) + .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") @@ -52,6 +59,7 @@ struct TrashView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Fermer") { dismiss() } + .foregroundStyle(Color.gpAccentText) .accessibilityIdentifier("button.closeTrash") } } @@ -76,6 +84,32 @@ struct TrashView: View { }) .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(entry.item.name) + .font(.system(.body, weight: .medium)) + .foregroundStyle(Color.gpInk) + if let login = entry.login, !login.username.isEmpty { + Text(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 { diff --git a/apps/ios/Ghostpass/Views/UnlockView.swift b/apps/ios/Ghostpass/Views/UnlockView.swift index 6beeff9b..3135798a 100644 --- a/apps/ios/Ghostpass/Views/UnlockView.swift +++ b/apps/ios/Ghostpass/Views/UnlockView.swift @@ -1,5 +1,8 @@ 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 @@ -12,89 +15,153 @@ struct UnlockView: View { @State private var useSavedSession = false var body: some View { - NavigationStack { - Form { - if useSavedSession { - Section("Coffre") { - LabeledContent("Compte", value: store.savedEmail) - SecureField("Mot de passe maître", text: $password) - .textContentType(.password) - .accessibilityIdentifier("field.master") - } - } else { - Section("Serveur") { - TextField("https://ghostpass.stackops.ch", text: $server) - .accessibilityIdentifier("field.server") - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - } - Section("Compte") { - TextField("Adresse e-mail", text: $email) - .accessibilityIdentifier("field.email") - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.emailAddress) - SecureField("Mot de passe maître", text: $password) - .textContentType(.password) - .accessibilityIdentifier("field.master") - if needsTotp { - TextField("Code à 6 chiffres", text: $totpCode) - .keyboardType(.numberPad) - } - } + ZStack { + GhostBackground() + + ScrollView { + VStack(spacing: 24) { + enseigne + carte } + .frame(maxWidth: 420) + .padding(.horizontal, 20) + .padding(.vertical, 40) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + } + .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) + Text("GhostPass") + .font(.system(.title, design: .rounded, weight: .bold)) + .foregroundStyle(Color.gpInk) + Text(useSavedSession ? "Coffre enregistré sur cet appareil" : "Coffre chiffré de bout en bout") + .font(.footnote) + .foregroundStyle(Color.gpMuted) + } + } - if let message = store.errorMessage { - Section { - Text(message).foregroundStyle(.red) + private var carte: some View { + VStack(alignment: .leading, spacing: 18) { + if useSavedSession { + champ("Compte") { + Text(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") } } + } - Section { - Button(action: submit) { - if store.isBusy { - ProgressView() - } else { - Text(useSavedSession ? "Déverrouiller" : "Se connecter") - } - } - .disabled(store.isBusy || password.isEmpty) - .accessibilityIdentifier("button.submit") + if let message = store.errorMessage { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(Color.gpDanger) + .fixedSize(horizontal: false, vertical: true) + } - if useSavedSession && store.canUnlockWithBiometrics { - Button("Déverrouiller avec \(store.biometryLabel)") { - Task { await store.unlockWithBiometrics() } - } - .disabled(store.isBusy) - .accessibilityIdentifier("button.biometric") + VStack(spacing: 10) { + Button(action: submit) { + if store.isBusy { + ProgressView().tint(Color.gpOnAccent) + } else { + Text(useSavedSession ? "Déverrouiller" : "Se connecter") } + } + .buttonStyle(PrimaryButtonStyle(enabled: !store.isBusy && !password.isEmpty)) + .disabled(store.isBusy || password.isEmpty) + .accessibilityIdentifier("button.submit") - if store.hasSavedSession { - Button(useSavedSession ? "Utiliser un autre compte" : "Coffre enregistré") { - useSavedSession.toggle() - password = "" - } - .font(.footnote) - .accessibilityIdentifier("button.switchAccount") + if useSavedSession && store.canUnlockWithBiometrics { + Button("Déverrouiller avec \(store.biometryLabel)") { + Task { await store.unlockWithBiometrics() } } + .buttonStyle(SecondaryButtonStyle()) + .disabled(store.isBusy) + .accessibilityIdentifier("button.biometric") } - } - .navigationTitle("GhostPass") - .onAppear { + if store.hasSavedSession { - useSavedSession = true - // Une session enregistrée + 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() } + Button(useSavedSession ? "Utiliser un autre compte" : "Revenir au coffre enregistré") { + useSavedSession.toggle() + password = "" + store.errorMessage = nil } - } else { - server = store.savedServer - email = store.savedEmail + .font(.footnote) + .foregroundStyle(Color.gpAccentText) + .padding(.top, 2) + .accessibilityIdentifier("button.switchAccount") } } } + .glassCard() + } + + private func champ( + _ intitule: String, @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: String) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) } private func submit() { diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 62da25bf..1a713617 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -1,5 +1,6 @@ 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 = "" @@ -18,26 +19,43 @@ struct VaultListView: View { var body: some View { NavigationStack { - List { - if store.isOffline { - Label( - "Hors ligne — coffre affiché depuis cet appareil", - systemImage: "wifi.slash" - ) - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityIdentifier("banner.offline") - } - ForEach(visible) { entry in - NavigationLink(value: entry) { - VaultRow(entry: entry) + ZStack { + GhostBackground() + + List { + if store.isOffline { + bandeauHorsLigne + .listRowInsets(.init(top: 0, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } - .swipeActions { - Button("Supprimer", role: .destructive) { - Task { await store.delete(entry) } + + ForEach(visible) { entry in + // 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. + NavigationLink(value: entry) { + VaultRow(entry: entry) + } + .listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .swipeActions { + Button("Supprimer", role: .destructive) { + Task { await store.delete(entry) } + } } } } + .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 @@ -48,18 +66,13 @@ struct VaultListView: View { } } .searchable(text: $search, prompt: "Rechercher") - .overlay { - if store.entries.isEmpty { - ContentUnavailableView( - "Coffre vide", systemImage: "lock", - description: Text("Ajoutez un identifiant avec le bouton +.")) - } - } .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) { @@ -71,10 +84,8 @@ struct VaultListView: View { } .accessibilityIdentifier("button.biometricOff") } else { - Button("Activer \(store.biometryLabel)") { - sheet = .biometrics - } - .accessibilityIdentifier("button.biometricOn") + Button("Activer \(store.biometryLabel)") { sheet = .biometrics } + .accessibilityIdentifier("button.biometricOn") } } Button("Corbeille", systemImage: "trash") { sheet = .trash } @@ -85,6 +96,7 @@ struct VaultListView: View { .accessibilityIdentifier("button.signOut") } label: { Image(systemName: "ellipsis.circle") + .foregroundStyle(Color.gpAccentText) } .accessibilityIdentifier("button.settings") } @@ -93,6 +105,7 @@ struct VaultListView: View { sheet = .newItem } label: { Image(systemName: "plus") + .foregroundStyle(Color.gpAccentText) } .accessibilityIdentifier("button.add") } @@ -128,6 +141,32 @@ struct VaultListView: View { + "appareil, relisible par \(store.biometryLabel) seul.") }) } + .tint(Color.gpAccentText) + } + + 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) } } @@ -161,21 +200,42 @@ enum EditTarget: Identifiable { } } +/// Une ligne du coffre : une pastille de type, le nom, ce qui aide à le reconnaître. private struct VaultRow: View { let entry: VaultEntry var body: some View { - HStack(spacing: 12) { - Image(systemName: icon) - .frame(width: 28) - .foregroundStyle(.secondary) + HStack(spacing: 14) { + 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(entry.item.name).font(.body) + Text(entry.item.name) + .font(.system(.body, weight: .medium)) + .foregroundStyle(Color.gpInk) + .lineLimit(1) if let subtitle, !subtitle.isEmpty { - Text(subtitle).font(.caption).foregroundStyle(.secondary) + Text(subtitle) + .font(.caption) + .foregroundStyle(Color.gpMuted) + .lineLimit(1) } } + + 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 var icon: String { diff --git a/apps/ios/GhostpassAutoFill/AutoFillView.swift b/apps/ios/GhostpassAutoFill/AutoFillView.swift index 64d848e5..0ced3d48 100644 --- a/apps/ios/GhostpassAutoFill/AutoFillView.swift +++ b/apps/ios/GhostpassAutoFill/AutoFillView.swift @@ -8,11 +8,14 @@ struct AutoFillView: View { var body: some View { NavigationStack { - Group { - if store.isUnlocked { - liste - } else { - deverrouillage + ZStack { + GhostBackground() + Group { + if store.isUnlocked { + liste + } else { + deverrouillage + } } } .navigationTitle("GhostPass") @@ -20,50 +23,74 @@ struct AutoFillView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Annuler") { store.cancel() } + .foregroundStyle(Color.gpMuted) .accessibilityIdentifier("button.cancel") } } } + .tint(Color.gpAccentText) } private var deverrouillage: some View { - Form { - if store.hasSession { - Section("Coffre") { - LabeledContent("Compte", value: store.account) - SecureField("Mot de passe maître", text: $password) - .textContentType(.password) - .accessibilityIdentifier("field.master") - } - Section { - Button("Déverrouiller") { - Task { - await store.unlock(password: password) - password = "" + ScrollView { + VStack(spacing: 18) { + if store.hasSession { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 7) { + Text("Compte").sectionLabel() + Text(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") } - } - .disabled(store.isBusy || password.isEmpty) - .accessibilityIdentifier("button.submit") - if store.canUseBiometrics { - Button("Déverrouiller avec \(store.biometryLabel)") { - Task { await store.unlockWithBiometrics() } + if let message = store.errorMessage { + Label(message, systemImage: "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") + } } - .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) } - } 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( - "Coffre indisponible", systemImage: "lock", - description: Text("Ouvrez GhostPass et connectez-vous une fois.")) - } - - if let message = store.errorMessage { - Section { Text(message).foregroundStyle(.red) } } + .padding(20) } .onAppear { // La biométrie d'emblée : c'est le geste attendu, et le remplissage doit @@ -77,35 +104,70 @@ struct AutoFillView: View { private var liste: some View { List { if !store.suggested.isEmpty { - Section("Pour ce site") { - ForEach(store.suggested) { ligne($0) } - } + section("Pour ce site", store.suggested) } if !store.others.isEmpty { - Section(store.suggested.isEmpty ? "Coffre" : "Autres identifiants") { - ForEach(store.others) { ligne($0) } - } + section(store.suggested.isEmpty ? "Coffre" : "Autres identifiants", store.others) } } + .listStyle(.plain) + .scrollContentBackground(.hidden) .overlay { if store.entries.isEmpty { - ContentUnavailableView( - "Aucun identifiant", systemImage: "key", - description: Text(store.errorMessage ?? "Le coffre local ne contient rien.")) + ContentUnavailableView { + Label("Aucun identifiant", systemImage: "key") + } description: { + Text(store.errorMessage ?? "Le coffre local ne contient rien.") + } + .foregroundStyle(Color.gpMuted) } } } + private func section(_ titre: String, _ 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: { + Text(titre).sectionLabel().padding(.leading, 2) + } + } + private func ligne(_ entry: VaultEntry) -> some View { Button { store.pick(entry) } label: { - VStack(alignment: .leading, spacing: 2) { - Text(entry.item.name).foregroundStyle(.primary) - if let login = entry.login, !login.username.isEmpty { - Text(login.username).font(.caption).foregroundStyle(.secondary) + 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(entry.item.name) + .font(.system(.body, weight: .medium)) + .foregroundStyle(Color.gpInk) + if let login = entry.login, !login.username.isEmpty { + Text(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: String) -> Text { + Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) } } diff --git a/apps/ios/README.md b/apps/ios/README.md index c1a3fae0..68796077 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -90,7 +90,8 @@ passe faux, et le fait qu'aucune session n'autorise l'activation. | `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/Views/` | SwiftUI : déverrouillage, liste, détail, édition | +| `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 @@ -137,6 +138,21 @@ aussi côté remplissage. Sans lui, l'extension demandera le mot de passe maîtr - **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* diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 8a53ac57..85cf47fa 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -58,6 +58,7 @@ targets: - path: GhostpassAutoFill - path: Ghostpass/Model - path: Ghostpass/Services + - path: Ghostpass/Theme - path: Generated dependencies: - framework: GhostpassCrypto.xcframework diff --git a/tools/ios/make-app-icon.sh b/tools/ios/make-brand-assets.sh similarity index 68% rename from tools/ios/make-app-icon.sh rename to tools/ios/make-brand-assets.sh index 7cb78906..68a387b9 100755 --- a/tools/ios/make-app-icon.sh +++ b/tools/ios/make-brand-assets.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Produit l'icône de l'application iOS à partir du logo de la suite. +# Produit les images de marque de l'application iOS à partir du logo de la suite : +# l'icône de l'application, et la marque affichée dans l'interface. # # La source est `assets/logo/favicon.svg` : la variante **remplie** du logo, celle que # ghostboard emploie comme icône. Le logo au trait ne convient pas — son trait fait 4 % @@ -18,7 +19,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SOURCE="$ROOT/assets/logo/favicon.svg" -CIBLE="$ROOT/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png" +ASSETS="$ROOT/apps/ios/Ghostpass/Assets.xcassets" +CIBLE="$ASSETS/AppIcon.appiconset/AppIcon-1024.png" +MARQUE="$ASSETS/LogoMark.imageset" MARGE=0.10 command -v rsvg-convert >/dev/null || { echo "rsvg-convert manquant : brew install librsvg" >&2; exit 1; } @@ -49,3 +52,22 @@ PY rsvg-convert -w 1024 -h 1024 -b white "$TEMPO/icone.svg" -o "$CIBLE" echo " icône écrite : ${CIBLE#$ROOT/}" + +# La marque affichée dans l'interface : même silhouette, mais sur fond transparent — elle +# se pose sur la nuit de l'application, pas sur un carré blanc. +mkdir -p "$MARQUE" +for facteur in 1 2 3; do + rsvg-convert -w $((64 * facteur)) -h $((64 * facteur)) "$TEMPO/icone.svg" \ + -o "$MARQUE/LogoMark@${facteur}x.png" +done +cat > "$MARQUE/Contents.json" <<'JSON' +{ + "images" : [ + { "filename" : "LogoMark@1x.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "LogoMark@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "LogoMark@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} +JSON +echo " marque écrite : ${MARQUE#$ROOT/}" From a708e1752232404ad18269826fe68bfeb7037baa Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 12:39:44 +0200 Subject: [PATCH 17/50] feat(ios): organiser le coffre en dossiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les dossiers existaient déjà comme champ libre sur chaque élément : on pouvait taper « Travail/Serveurs » dans le formulaire, mais rien ne permettait de voir la liste des dossiers, d'en créer un vide, ni d'en renommer la logique. Un dossier n'existait que tant qu'un élément le mentionnait. L'écran Dossiers rend la chose visible et gère le cas manquant : un dossier vide. Comme le serveur ne connaît que des éléments chiffrés, la liste des dossiers vides vit dans un élément de registre nommé `gp:folders`, chiffré comme les autres. Le coffre le filtre à la lecture — d'où le test qui vérifie qu'aucune ligne fantôme `gp:folders` n'apparaît dans la liste. La création se fait par un champ en ligne plutôt que par une alerte : une alerte SwiftUI n'expose pas l'identifiant de son champ texte, ce qui la rend intestable, et la même correction avait déjà dû être faite deux fois ailleurs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/Ghostpass/Services/VaultStore.swift | 125 +++++++++++- apps/ios/Ghostpass/Views/FoldersView.swift | 192 +++++++++++++++++++ apps/ios/Ghostpass/Views/VaultListView.swift | 56 +++++- apps/ios/Tests/ContractTests.swift | 45 +++++ apps/ios/UITests/VaultFlowTests.swift | 40 ++++ 5 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 apps/ios/Ghostpass/Views/FoldersView.swift diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 71176f51..d4a68f78 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -13,6 +13,13 @@ final class VaultStore: ObservableObject { @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] = [] + /// Identité de cet item de registre, pour le mettre à jour plutôt que le multiplier. + private var folderRegistryID: 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 @@ -113,6 +120,8 @@ final class VaultStore: ObservableObject { isOffline = false pendingPassword = nil offersBiometricEnrollment = false + emptyFolders = [] + folderRegistryID = nil } /// Déconnexion : révoque la session côté serveur et efface tout localement. @@ -243,14 +252,14 @@ final class VaultStore: ObservableObject { func refresh() async { guard let account else { return } if entries.isEmpty, let caches = VaultCache.load() { - entries = Self.entries(from: caches, with: account) + 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) - entries = Self.entries(from: dtos, with: account) + appliquer(Self.lecture(dtos, account)) isOffline = false errorMessage = nil await CredentialIdentities.sync(entries) @@ -267,11 +276,38 @@ final class VaultStore: ObservableObject { private static func entries(from dtos: [EncryptedItemDTO], with account: Account) -> [VaultEntry] { - dtos.compactMap { dto in - guard let item = try? decrypt(dto, with: account), !isRegistry(item) else { return nil } - return VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt) + lecture(dtos, account).entries + } + + /// Le même travail, mais en retenant au passage le registre des dossiers : il traverse + /// la liste comme les autres éléments, autant le cueillir là plutôt que de refaire un + /// tour de déchiffrement pour lui seul. + private static func lecture(_ dtos: [EncryptedItemDTO], _ account: Account) + -> (entries: [VaultEntry], registryID: String?, folders: [String]) + { + var entries: [VaultEntry] = [] + var registryID: String? + var folders: [String] = [] + + for dto in dtos { + guard let item = try? decrypt(dto, with: account) else { continue } + if isRegistry(item) { + registryID = dto.id + if case .secureNote(let note) = item.data, + let data = note.content.data(using: .utf8), + let chemins = try? JSONDecoder().decode([String].self, from: data) + { + folders = chemins + } + continue + } + entries.append(VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt)) } - .sorted { $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending } + + entries.sort { + $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending + } + return (entries, registryID, folders.sorted { $0.localizedCompare($1) == .orderedAscending }) } func save(_ item: VaultItem, id: String?) async { @@ -311,6 +347,83 @@ final class VaultStore: ObservableObject { } } + private func appliquer( + _ lecture: (entries: [VaultEntry], registryID: String?, folders: [String]) + ) { + entries = lecture.entries + folderRegistryID = lecture.registryID + emptyFolders = lecture.folders + } + + // ─── 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: "/")) + } + + /// Écrit le registre : un `SecureNote` dont le contenu est la liste des chemins, sous + /// un nom que l'interface masque. Même format que la web app, au caractère près. + private func saveFolders() async { + guard let api, let token, let account else { return } + let contenu = String( + decoding: (try? JSONEncoder().encode(emptyFolders)) ?? Data("[]".utf8), as: UTF8.self) + let item = VaultItem( + name: VaultConstants.foldersItemName, notes: nil, folder: nil, + data: .secureNote(SecureNote(content: contenu))) + do { + let (key, data) = try Self.encrypt(item, with: account) + if let folderRegistryID { + _ = try await api.updateItem( + token: token, id: folderRegistryID, encryptedKey: key, encryptedData: data) + } else { + let cree = try await api.createItem( + token: token, encryptedKey: key, encryptedData: data) + folderRegistryID = cree.id + } + await refresh() + } catch is URLError { + errorMessage = "Serveur injoignable : les dossiers n'ont pas été enregistrés." + } catch { + errorMessage = error.localizedDescription + } + } + // ─── Corbeille ─── /// Les items supprimés, déchiffrés à la demande. Ils ne sont pas conservés dans diff --git a/apps/ios/Ghostpass/Views/FoldersView.swift b/apps/ios/Ghostpass/Views/FoldersView.swift new file mode 100644 index 00000000..8d79e4f8 --- /dev/null +++ b/apps/ios/Ghostpass/Views/FoldersView.swift @@ -0,0 +1,192 @@ +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: "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( + titre: 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: .constant(aSupprimer != nil), + 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: String, 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)) + + Text(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) + } +} diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 1a713617..f22cc41d 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -7,11 +7,18 @@ struct VaultListView: View { /// 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? private var visible: [VaultEntry] { - guard !search.isEmpty else { return store.entries } - return store.entries.filter { entry in - entry.item.name.localizedCaseInsensitiveContains(search) + 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) } @@ -30,6 +37,11 @@ struct VaultListView: View { .listRowSeparator(.hidden) } + filtreDeDossier + .listRowInsets(.init(top: 0, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + ForEach(visible) { entry in // Un lien en bonne et due forme : masquer le NavigationLink sous // une opacité nulle le rendrait inatteignable, au clavier comme @@ -120,6 +132,8 @@ struct VaultListView: View { BiometricSetupView().environmentObject(store) case .trash: TrashView().environmentObject(store) + case .folders: + FoldersView(selection: $folder).environmentObject(store) } } .alert( @@ -144,6 +158,40 @@ struct VaultListView: View { .tint(Color.gpAccentText) } + /// 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)) + Text(folder ?? "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) @@ -176,6 +224,7 @@ enum VaultSheet: Identifiable { case editItem(VaultEntry) case biometrics case trash + case folders var id: String { switch self { @@ -183,6 +232,7 @@ enum VaultSheet: Identifiable { case .editItem(let entry): return entry.id case .biometrics: return "biometrics" case .trash: return "trash" + case .folders: return "folders" } } } diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 6e326b46..7d41510c 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -493,3 +493,48 @@ final class AutoFillLogicTests: XCTestCase { 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("//"), "") + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index 07c47fd5..4007fbf2 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -144,6 +144,46 @@ final class VaultFlowTests: XCTestCase { XCTAssertTrue(code.label.allSatisfy(\.isNumber), "code TOTP non numérique : « \(code.label) »") shot(app, "2-detail") + // 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"].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"] From 74f49ce853cad73b600ce742547ca1749106f2ba Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 13:33:31 +0200 Subject: [PATCH 18/50] =?UTF-8?q?feat(ios):=20th=C3=A8me=20et=20langue=20a?= =?UTF-8?q?u=20choix=20de=20l'utilisateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'application suivait le système sans discuter : sa palette venait du réglage clair/sombre de l'appareil, et ses textes étaient écrits en français dans le code. Deux réglages manquaient donc — celui qu'on veut quand on lit ses mots de passe dans un train de nuit, et celui qu'on veut quand la langue du téléphone n'est pas celle dans laquelle on travaille. Le catalogue de chaînes prend le français pour source : les clefs sont les textes eux-mêmes, et l'anglais en est la traduction. Cela demandait de distinguer partout ce qui se traduit de ce qui ne se traduit pas — le nom d'un dossier, un identifiant, un mot de passe appartiennent à l'utilisateur et s'affichent tels quels, d'où les `Text(verbatim:)`. Les messages fabriqués dans les services passent par `tr(…)`, qui lit la traduction dans le paquet de la langue choisie plutôt que dans celle du système. Les deux choix vivent dans les préférences du groupe d'applications : le remplissage automatique est un autre processus, et une fenêtre surgie en plein Safari qui reviendrait au thème clair trahirait le morceau rapporté. Deux défauts d'affichage sont sortis du bois en chemin : L'écran du coffre portait deux modificateurs `.alert`, dont l'un avec une liaison `.constant` incapable d'enregistrer la fermeture que le système lui demandait. Résultat : la proposition d'activer Face ID s'affichait, se dupliquait dans la hiérarchie d'accessibilité, et ses boutons n'exécutaient plus rien — l'alerte restait à l'écran et bloquait le coffre entier. Le journal de l'application le montre sans ambiguïté : « proposition » y figure, « refus » jamais, après cinq frappes. La proposition devient une feuille, l'erreur garde une alerte avec une liaison qui écrit en retour. Les tests, eux, fixent désormais la langue du simulateur : sans cela la même suite passait ou échouait selon les réglages du poste qui l'exécute. Le test des réglages ne se contente pas de cliquer : il vérifie que le titre du coffre passe à « Vault », que le choix survit à une relance, et mesure la luminance de l'écran pour établir que le thème clair est bien plus clair que le sombre. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/Ghostpass/GhostpassApp.swift | 7 + apps/ios/Ghostpass/InfoPlist.xcstrings | 52 + .../Ghostpass/Resources/Localizable.xcstrings | 1286 +++++++++++++++++ apps/ios/Ghostpass/Services/Biometrics.swift | 12 + apps/ios/Ghostpass/Services/Preferences.swift | 133 ++ apps/ios/Ghostpass/Services/VaultStore.swift | 25 +- apps/ios/Ghostpass/Theme/Theme.swift | 13 +- .../Ghostpass/Views/BiometricOfferView.swift | 72 + .../Ghostpass/Views/BiometricSetupView.swift | 8 +- apps/ios/Ghostpass/Views/FoldersView.swift | 10 +- apps/ios/Ghostpass/Views/ItemDetailView.swift | 36 +- apps/ios/Ghostpass/Views/ItemEditView.swift | 22 +- .../Views/PasswordGeneratorView.swift | 10 +- apps/ios/Ghostpass/Views/SettingsView.swift | 103 ++ apps/ios/Ghostpass/Views/TrashView.swift | 5 +- apps/ios/Ghostpass/Views/UnlockView.swift | 40 +- apps/ios/Ghostpass/Views/VaultListView.swift | 71 +- .../ios/GhostpassAutoFill/AutoFillStore.swift | 6 +- apps/ios/GhostpassAutoFill/AutoFillView.swift | 31 +- .../CredentialProviderViewController.swift | 11 +- .../ios/GhostpassAutoFill/InfoPlist.xcstrings | 52 + apps/ios/Tests/ContractTests.swift | 64 + apps/ios/UITests/VaultFlowTests.swift | 156 +- apps/ios/project.yml | 11 + tools/ios/run-ios-tests.sh | 11 +- 25 files changed, 2152 insertions(+), 95 deletions(-) create mode 100644 apps/ios/Ghostpass/InfoPlist.xcstrings create mode 100644 apps/ios/Ghostpass/Resources/Localizable.xcstrings create mode 100644 apps/ios/Ghostpass/Services/Preferences.swift create mode 100644 apps/ios/Ghostpass/Views/BiometricOfferView.swift create mode 100644 apps/ios/Ghostpass/Views/SettingsView.swift create mode 100644 apps/ios/GhostpassAutoFill/InfoPlist.xcstrings diff --git a/apps/ios/Ghostpass/GhostpassApp.swift b/apps/ios/Ghostpass/GhostpassApp.swift index a896207d..023b83fe 100644 --- a/apps/ios/Ghostpass/GhostpassApp.swift +++ b/apps/ios/Ghostpass/GhostpassApp.swift @@ -15,6 +15,7 @@ struct GhostpassApp: App { } @StateObject private var store = VaultStore() + @StateObject private var prefs = Preferences.shared @Environment(\.scenePhase) private var scenePhase var body: some Scene { @@ -27,6 +28,12 @@ struct GhostpassApp: App { } } .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 // Passer en arrière-plan relâche les clés. Un coffre qui reste ouvert 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/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings new file mode 100644 index 00000000..27c56a99 --- /dev/null +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -0,0 +1,1286 @@ +{ + "sourceLanguage": "fr", + "strings": { + " — effacé dans %lld s": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " — cleared in %lld s" + } + } + } + }, + "%1$@ n'a pas pu être activé (code %2$d).": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ could not be enabled (code %2$d)." + } + } + } + }, + "%@ n'a pas permis d'ouvrir le coffre.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ could not open the vault." + } + } + } + }, + "%lld": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld" + } + } + } + }, + "%lld s": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld s" + } + } + } + }, + "123456": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "123456" + } + } + } + }, + "AAAA": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "YYYY" + } + } + } + }, + "Activer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn on" + } + } + } + }, + "Activer %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn on %@" + } + } + } + }, + "Adresse copiée": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Address copied" + } + } + } + }, + "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" + } + } + } + }, + "Ajoutez un identifiant avec le bouton +.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add a login with the + button." + } + } + } + }, + "Annuler": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } + }, + "Apparence": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + } + } + }, + "Aucun dossier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No folders" + } + } + } + }, + "Aucun identifiant": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No logins" + } + } + } + }, + "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" + } + } + } + }, + "Caractères": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Characters" + } + } + } + }, + "Carte": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Card" + } + } + } + }, + "Chiffres": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Digits" + } + } + } + }, + "Clair": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + } + } + }, + "Classement": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Organisation" + } + } + } + }, + "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" + } + } + } + }, + "Compte": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + } + } + }, + "Contenu": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Content" + } + } + } + }, + "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é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." + } + } + } + }, + "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" + } + } + } + }, + "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 %@" + } + } + } + }, + "Enregistrer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + } + } + }, + "Erreur": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + } + } + }, + "Excellent": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Excellent" + } + } + } + }, + "Expiration": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expiry" + } + } + } + }, + "Faible": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weak" + } + } + } + }, + "Fermer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + } + } + }, + "GhostPass": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GhostPass" + } + } + } + }, + "GitHub, Amazon…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub, Amazon…" + } + } + } + }, + "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" + } + } + } + }, + "Identifiant": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Login" + } + } + } + }, + "Identifiants": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Credentials" + } + } + } + }, + "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." + } + } + } + }, + "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 local ne contient rien.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The local vault is empty." + } + } + } + }, + "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" + } + } + } + }, + "Minuscules": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lowercase" + } + } + } + }, + "Modifier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + } + } + }, + "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." + } + } + } + }, + "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" + } + } + } + }, + "Note": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Note" + } + } + } + }, + "Notes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notes" + } + } + } + }, + "Nouvel élément": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New item" + } + } + } + }, + "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." + } + } + } + }, + "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" + } + } + } + }, + "Rechercher": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + } + } + }, + "Renouvelé toutes les %lld secondes.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renewed every %lld seconds." + } + } + } + }, + "Restaurer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore" + } + } + } + }, + "Revenir au coffre enregistré": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to saved vault" + } + } + } + }, + "Réglages": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + } + } + }, + "Régénérer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Regenerate" + } + } + } + }, + "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'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 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." + } + } + } + }, + "Solide": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Strong" + } + } + } + }, + "Sombre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + } + } + }, + "Supprimer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + } + } + }, + "Supprimer ce dossier ?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this folder?" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "Verrouiller": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock" + } + } + } + }, + "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" + } + } + } + }, + "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." + } + } + } + }, + "≈ %lld bits": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "≈ %lld bits" + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/ios/Ghostpass/Services/Biometrics.swift b/apps/ios/Ghostpass/Services/Biometrics.swift index 3a3d8e09..24b5e29a 100644 --- a/apps/ios/Ghostpass/Services/Biometrics.swift +++ b/apps/ios/Ghostpass/Services/Biometrics.swift @@ -25,4 +25,16 @@ enum Biometrics { 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/Preferences.swift b/apps/ios/Ghostpass/Services/Preferences.swift new file mode 100644 index 00000000..6ad61076 --- /dev/null +++ b/apps/ios/Ghostpass/Services/Preferences.swift @@ -0,0 +1,133 @@ +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) } + } + + private let defaults = UserDefaults(suiteName: SharedStore.appGroup) + + private enum Clefs { + static let apparence = "gp.apparence" + static let langue = "gp.langue" + } + + 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 + } + + /// `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) +} + +/// 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/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index d4a68f78..fd050aa2 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -91,7 +91,7 @@ final class VaultStore: ObservableObject { /// 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 = "Aucune session enregistrée sur cet appareil." + errorMessage = tr("Aucune session enregistrée sur cet appareil.") return } isBusy = true @@ -108,7 +108,7 @@ final class VaultStore: ObservableObject { proposeBiometricsIfPossible(password) await refresh() } catch { - errorMessage = "Mot de passe maître incorrect." + errorMessage = tr("Mot de passe maître incorrect.") } } @@ -152,6 +152,7 @@ final class VaultStore: ObservableObject { } 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 } @@ -174,7 +175,7 @@ final class VaultStore: ObservableObject { 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 = "\(Biometrics.label) n'a pas permis d'ouvrir le coffre." + errorMessage = tr("\(Biometrics.label) n'a pas permis d'ouvrir le coffre.") return } await unlockOffline(password: password) @@ -187,6 +188,7 @@ final class VaultStore: ObservableObject { else { return } pendingPassword = password offersBiometricEnrollment = true + NSLog("GP-BIO proposition") } /// L'utilisateur accepte la proposition faite juste après un déverrouillage : le mot @@ -203,7 +205,7 @@ final class VaultStore: ObservableObject { @discardableResult func enableBiometrics(password: String) -> Bool { guard let session = SharedStore.load() else { - errorMessage = "Aucune session enregistrée sur cet appareil." + errorMessage = tr("Aucune session enregistrée sur cet appareil.") return false } do { @@ -212,7 +214,7 @@ final class VaultStore: ObservableObject { encryptedUserKey: session.encryptedUserKey, encryptedPrivateKey: session.encryptedPrivateKey) } catch { - errorMessage = "Mot de passe maître incorrect." + errorMessage = tr("Mot de passe maître incorrect.") return false } return store(password) @@ -222,7 +224,7 @@ final class VaultStore: ObservableObject { private func store(_ password: String) -> Bool { let status = Keychain.setBiometric(password, for: Keychain.Key.masterPassword) guard status == errSecSuccess else { - errorMessage = "\(Biometrics.label) n'a pas pu être activé (code \(status))." + errorMessage = tr("\(Biometrics.label) n'a pas pu être activé (code \(status)).") return false } Keychain.set("1", for: Keychain.Key.biometricsEnabled) @@ -233,6 +235,7 @@ final class VaultStore: ObservableObject { /// 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) @@ -326,7 +329,7 @@ final class VaultStore: ObservableObject { } 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 = "Serveur injoignable : la modification n'a pas été enregistrée." + errorMessage = tr("Serveur injoignable : la modification n'a pas été enregistrée.") } catch { errorMessage = error.localizedDescription } @@ -341,7 +344,7 @@ final class VaultStore: ObservableObject { VaultCache.save(dtos.filter { $0.id != entry.id }) } } catch is URLError { - errorMessage = "Serveur injoignable : la suppression n'a pas été enregistrée." + errorMessage = tr("Serveur injoignable : la suppression n'a pas été enregistrée.") } catch { errorMessage = error.localizedDescription } @@ -418,7 +421,7 @@ final class VaultStore: ObservableObject { } await refresh() } catch is URLError { - errorMessage = "Serveur injoignable : les dossiers n'ont pas été enregistrés." + errorMessage = tr("Serveur injoignable : les dossiers n'ont pas été enregistrés.") } catch { errorMessage = error.localizedDescription } @@ -446,7 +449,7 @@ final class VaultStore: ObservableObject { try await api.restoreItem(token: token, id: entry.id) await refresh() } catch is URLError { - errorMessage = "Serveur injoignable : l'item n'a pas été restauré." + errorMessage = tr("Serveur injoignable : l'item n'a pas été restauré.") } catch { errorMessage = error.localizedDescription } @@ -459,7 +462,7 @@ final class VaultStore: ObservableObject { do { try await api.purgeItem(token: token, id: entry.id) } catch is URLError { - errorMessage = "Serveur injoignable : l'item n'a pas été supprimé." + errorMessage = tr("Serveur injoignable : l'item n'a pas été supprimé.") } catch { errorMessage = error.localizedDescription } diff --git a/apps/ios/Ghostpass/Theme/Theme.swift b/apps/ios/Ghostpass/Theme/Theme.swift index 5ef43445..db617367 100644 --- a/apps/ios/Ghostpass/Theme/Theme.swift +++ b/apps/ios/Ghostpass/Theme/Theme.swift @@ -200,8 +200,10 @@ struct GhostScreen: View { /// Une section : un intitulé en petites capitales, puis une carte qui réunit ses lignes. struct GhostSection: View { - var titre: String? - var note: String? + // 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 { @@ -237,7 +239,8 @@ struct GhostDivider: View { /// Une ligne de consultation : un intitulé, une valeur, et ce qu'on peut en faire. struct GhostRow: View { - let intitule: String + 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 @@ -253,9 +256,9 @@ struct GhostRow: View { // les unifie pas. Le modificateur ne porte donc que sur ce qui se copie. Group { if estSecret { - Text(valeur) + Text(verbatim: valeur) } else { - Text(valeur).textSelection(.enabled) + Text(verbatim: valeur).textSelection(.enabled) } } .font(monospace ? .system(.body, design: .monospaced) : .body) 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 index a7811bab..f46d8245 100644 --- a/apps/ios/Ghostpass/Views/BiometricSetupView.swift +++ b/apps/ios/Ghostpass/Views/BiometricSetupView.swift @@ -41,7 +41,11 @@ struct BiometricSetupView: View { } if let message = store.errorMessage { - Label(message, systemImage: "exclamationmark.triangle.fill") + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } .font(.footnote) .foregroundStyle(Color.gpDanger) .fixedSize(horizontal: false, vertical: true) @@ -72,7 +76,7 @@ struct BiometricSetupView: View { .tint(Color.gpAccentText) } - private func invite(_ texte: String) -> Text { + private func invite(_ texte: LocalizedStringKey) -> Text { Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) } } diff --git a/apps/ios/Ghostpass/Views/FoldersView.swift b/apps/ios/Ghostpass/Views/FoldersView.swift index 8d79e4f8..d108fa3e 100644 --- a/apps/ios/Ghostpass/Views/FoldersView.swift +++ b/apps/ios/Ghostpass/Views/FoldersView.swift @@ -29,14 +29,16 @@ struct FoldersView: View { } ligne( - titre: "Tous les éléments", icone: "tray.full", + 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( - titre: chemin, icone: "folder", + // 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 { @@ -146,7 +148,7 @@ struct FoldersView: View { Task { await store.createFolder(chemin) } } - private func ligne(titre: String, icone: String, compte: Int, chemin: String?) -> some View { + private func ligne(titre: Text, icone: String, compte: Int, chemin: String?) -> some View { Button { selection = chemin dismiss() @@ -158,7 +160,7 @@ struct FoldersView: View { .frame(width: 34, height: 34) .background(Color.gpAccent.opacity(0.16), in: RoundedRectangle(cornerRadius: 9)) - Text(titre) + titre .font(.system(.body, weight: .medium)) .foregroundStyle(Color.gpInk) .lineLimit(1) diff --git a/apps/ios/Ghostpass/Views/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift index 4bb699a5..31c5eda5 100644 --- a/apps/ios/Ghostpass/Views/ItemDetailView.swift +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -7,7 +7,9 @@ struct ItemDetailView: View { @EnvironmentObject private var store: VaultStore @State private var revealed = false - @State private var copie: String? + /// 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 @@ -24,7 +26,7 @@ struct ItemDetailView: View { if let notes = live.item.notes, !notes.isEmpty { GhostSection(titre: "Notes") { - Text(notes) + Text(verbatim: notes) .foregroundStyle(Color.gpInk) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -33,14 +35,20 @@ struct ItemDetailView: View { } if let folder = live.item.folder, !folder.isEmpty { GhostSection(titre: "Dossier") { - Label(folder, systemImage: "folder") - .foregroundStyle(Color.gpMuted) + // 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(live.item.name) + .navigationTitle(Text(verbatim: live.item.name)) .navigationBarTitleDisplayMode(.inline) .toolbar { Button("Modifier", action: onEdit) @@ -82,7 +90,7 @@ struct ItemDetailView: View { TimelineView(.periodic(from: .now, by: 1)) { context in if let otp = Totp.code(for: config, at: context.date) { HStack(spacing: 14) { - Text(otp.code) + Text(verbatim: otp.code) .font(.system(.title2, design: .monospaced, weight: .semibold)) .foregroundStyle(Color.gpInk) .accessibilityIdentifier("text.totp") @@ -108,7 +116,7 @@ struct ItemDetailView: View { ForEach(Array(login.uris.enumerated()), id: \.offset) { index, uri in if index > 0 { GhostDivider() } HStack { - Text(uri) + Text(verbatim: uri) .font(.callout) .foregroundStyle(Color.gpInk) .textSelection(.enabled) @@ -125,7 +133,7 @@ struct ItemDetailView: View { @ViewBuilder private func contenuNote(_ note: SecureNote) -> some View { GhostSection(titre: "Note") { - Text(note.content) + Text(verbatim: note.content) .foregroundStyle(Color.gpInk) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -181,7 +189,13 @@ struct ItemDetailView: View { @ViewBuilder private var confirmationDeCopie: some View { if let copie { - Label(copie, systemImage: "checkmark.circle.fill") + 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) @@ -193,9 +207,9 @@ struct ItemDetailView: View { } } - private func copier(_ valeur: String, _ message: String) { + private func copier(_ valeur: String, _ message: LocalizedStringKey) { Clipboard.copy(valeur) - copie = "\(message) — effacé dans \(Int(Clipboard.lifetime)) s" + 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 index 13e23d61..e3d732d0 100644 --- a/apps/ios/Ghostpass/Views/ItemEditView.swift +++ b/apps/ios/Ghostpass/Views/ItemEditView.swift @@ -26,11 +26,21 @@ struct ItemEditView: View { @State private var generating = false enum Kind: String, CaseIterable, Identifiable { - case login = "Identifiant" - case note = "Note" - case card = "Carte" + 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" @@ -73,7 +83,7 @@ struct ItemEditView: View { .accessibilityIdentifier("field.notes") } } - .navigationTitle(isNew ? "Nouvel élément" : "Modifier") + .navigationTitle(isNew ? Text("Nouvel élément") : Text("Modifier")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -112,7 +122,7 @@ struct ItemEditView: View { } label: { VStack(spacing: 6) { Image(systemName: cas.icone).font(.system(size: 17, weight: .medium)) - Text(cas.rawValue).font(.caption.weight(.medium)) + Text(cas.libelle).font(.caption.weight(.medium)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) @@ -206,7 +216,7 @@ struct ItemEditView: View { } } - private func invite(_ texte: String) -> Text { + private func invite(_ texte: LocalizedStringKey) -> Text { Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) } diff --git a/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift b/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift index 3119deab..1deb8354 100644 --- a/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift +++ b/apps/ios/Ghostpass/Views/PasswordGeneratorView.swift @@ -17,7 +17,7 @@ struct PasswordGeneratorView: View { GhostScreen { GhostSection { VStack(spacing: 14) { - Text(password.isEmpty ? " " : password) + Text(verbatim: password.isEmpty ? " " : password) .font(.system(.title3, design: .monospaced, weight: .medium)) .foregroundStyle(Color.gpInk) .textSelection(.enabled) @@ -85,12 +85,14 @@ struct PasswordGeneratorView: View { } private func bascule( - _ titre: String, _ exemple: String, _ path: WritableKeyPath + _ titre: LocalizedStringKey, _ exemple: String, + _ path: WritableKeyPath ) -> some View { Toggle(isOn: binding(path)) { HStack(spacing: 8) { Text(titre).foregroundStyle(Color.gpInk) - Text(exemple) + // « a-z », « 0-9 » : des exemples de caractères, pas de la prose. + Text(verbatim: exemple) .font(.caption.monospaced()) .foregroundStyle(Color.gpMuted) } @@ -105,7 +107,7 @@ struct PasswordGeneratorView: View { /// seulement de ce qu'il en coûterait de le deviner. private var force: some View { let bits = entropie - let (libelle, couleur): (String, Color) = + let (libelle, couleur): (LocalizedStringKey, Color) = bits >= 100 ? ("Excellent", .gpSuccess) : bits >= 72 ? ("Solide", .gpSuccess) : bits >= 50 ? ("Correct", .gpAccentText) diff --git a/apps/ios/Ghostpass/Views/SettingsView.swift b/apps/ios/Ghostpass/Views/SettingsView.swift new file mode 100644 index 00000000..1025c6f8 --- /dev/null +++ b/apps/ios/Ghostpass/Views/SettingsView.swift @@ -0,0 +1,103 @@ +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: "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 index 28d3bac8..f203adeb 100644 --- a/apps/ios/Ghostpass/Views/TrashView.swift +++ b/apps/ios/Ghostpass/Views/TrashView.swift @@ -95,11 +95,12 @@ struct TrashView: View { .frame(width: 34, height: 34) .background(Color.gpSurface2, in: RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 2) { - Text(entry.item.name) + Text(verbatim: entry.item.name) .font(.system(.body, weight: .medium)) .foregroundStyle(Color.gpInk) if let login = entry.login, !login.username.isEmpty { - Text(login.username).font(.caption).foregroundStyle(Color.gpMuted) + Text(verbatim: login.username).font(.caption) + .foregroundStyle(Color.gpMuted) } } Spacer(minLength: 8) diff --git a/apps/ios/Ghostpass/Views/UnlockView.swift b/apps/ios/Ghostpass/Views/UnlockView.swift index 3135798a..e193ff9c 100644 --- a/apps/ios/Ghostpass/Views/UnlockView.swift +++ b/apps/ios/Ghostpass/Views/UnlockView.swift @@ -54,9 +54,17 @@ struct UnlockView: View { Text("GhostPass") .font(.system(.title, design: .rounded, weight: .bold)) .foregroundStyle(Color.gpInk) - Text(useSavedSession ? "Coffre enregistré sur cet appareil" : "Coffre chiffré de bout en bout") - .font(.footnote) - .foregroundStyle(Color.gpMuted) + 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) } } @@ -64,7 +72,7 @@ struct UnlockView: View { VStack(alignment: .leading, spacing: 18) { if useSavedSession { champ("Compte") { - Text(store.savedEmail) + Text(verbatim: store.savedEmail) .foregroundStyle(Color.gpMuted) .frame(maxWidth: .infinity, alignment: .leading) .ghostField() @@ -107,7 +115,13 @@ struct UnlockView: View { } if let message = store.errorMessage { - Label(message, systemImage: "exclamationmark.triangle.fill") + // 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) @@ -117,8 +131,10 @@ struct UnlockView: View { Button(action: submit) { if store.isBusy { ProgressView().tint(Color.gpOnAccent) + } else if useSavedSession { + Text("Déverrouiller") } else { - Text(useSavedSession ? "Déverrouiller" : "Se connecter") + Text("Se connecter") } } .buttonStyle(PrimaryButtonStyle(enabled: !store.isBusy && !password.isEmpty)) @@ -135,10 +151,16 @@ struct UnlockView: View { } if store.hasSavedSession { - Button(useSavedSession ? "Utiliser un autre compte" : "Revenir au coffre enregistré") { + 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) @@ -151,7 +173,7 @@ struct UnlockView: View { } private func champ( - _ intitule: String, @ViewBuilder _ contenu: () -> Contenu + _ intitule: LocalizedStringKey, @ViewBuilder _ contenu: () -> Contenu ) -> some View { VStack(alignment: .leading, spacing: 7) { Text(intitule).sectionLabel() @@ -160,7 +182,7 @@ struct UnlockView: View { } /// Un texte d'invite lisible : le gris par défaut de SwiftUI disparaît sur nos surfaces. - private func invite(_ texte: String) -> Text { + private func invite(_ texte: LocalizedStringKey) -> Text { Text(texte).foregroundColor(Color.gpMuted.opacity(0.7)) } diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index f22cc41d..88d07023 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -102,6 +102,8 @@ struct VaultListView: View { } 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() } } @@ -134,30 +136,47 @@ struct VaultListView: View { TrashView().environmentObject(store) case .folders: FoldersView(selection: $folder).environmentObject(store) + case .settings: + SettingsView().environmentObject(Preferences.shared) + case .biometricOffer: + BiometricOfferView().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: .constant(store.errorMessage != nil), + "Erreur", isPresented: erreurAffichee, actions: { Button("OK") { store.errorMessage = nil } }, - message: { Text(store.errorMessage ?? "") }) - .alert( - "Utiliser \(store.biometryLabel) ?", - isPresented: $store.offersBiometricEnrollment, - actions: { - Button("Activer") { store.acceptOfferedBiometrics() } - .accessibilityIdentifier("button.acceptBiometric") - Button("Plus tard", role: .cancel) { store.declineBiometrics() } - .accessibilityIdentifier("button.laterBiometric") - }, - message: { - Text( - "Votre mot de passe maître sera conservé dans le trousseau de cet " - + "appareil, relisible par \(store.biometryLabel) seul.") - }) + 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 } + }) + } + + /// 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 { @@ -167,9 +186,15 @@ struct VaultListView: View { HStack(spacing: 8) { Image(systemName: folder == nil ? "tray.full" : "folder.fill") .font(.system(size: 13, weight: .medium)) - Text(folder ?? "Tous les éléments") - .font(.subheadline.weight(.medium)) - .lineLimit(1) + 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) @@ -225,6 +250,8 @@ enum VaultSheet: Identifiable { case biometrics case trash case folders + case settings + case biometricOffer var id: String { switch self { @@ -233,6 +260,8 @@ enum VaultSheet: Identifiable { case .biometrics: return "biometrics" case .trash: return "trash" case .folders: return "folders" + case .settings: return "settings" + case .biometricOffer: return "biometricOffer" } } } @@ -266,12 +295,12 @@ private struct VaultRow: View { .frame(width: 38, height: 38) VStack(alignment: .leading, spacing: 2) { - Text(entry.item.name) + Text(verbatim: entry.item.name) .font(.system(.body, weight: .medium)) .foregroundStyle(Color.gpInk) .lineLimit(1) if let subtitle, !subtitle.isEmpty { - Text(subtitle) + Text(verbatim: subtitle) .font(.caption) .foregroundStyle(Color.gpMuted) .lineLimit(1) diff --git a/apps/ios/GhostpassAutoFill/AutoFillStore.swift b/apps/ios/GhostpassAutoFill/AutoFillStore.swift index d3257393..0596194e 100644 --- a/apps/ios/GhostpassAutoFill/AutoFillStore.swift +++ b/apps/ios/GhostpassAutoFill/AutoFillStore.swift @@ -41,7 +41,7 @@ final class AutoFillStore: ObservableObject { func unlock(password: String) async { guard let session = SharedStore.load() else { - errorMessage = "Ouvrez GhostPass une fois pour activer le remplissage." + errorMessage = tr("Ouvrez GhostPass une fois pour activer le remplissage.") return } isBusy = true @@ -53,7 +53,7 @@ final class AutoFillStore: ObservableObject { encryptedPrivateKey: session.encryptedPrivateKey) load(with: account) } catch { - errorMessage = "Mot de passe maître incorrect." + errorMessage = tr("Mot de passe maître incorrect.") } } @@ -66,7 +66,7 @@ final class AutoFillStore: ObservableObject { }.value isBusy = false guard let password else { - errorMessage = "\(Biometrics.label) n'a pas permis d'ouvrir le coffre." + errorMessage = tr("\(Biometrics.label) n'a pas permis d'ouvrir le coffre.") return } await unlock(password: password) diff --git a/apps/ios/GhostpassAutoFill/AutoFillView.swift b/apps/ios/GhostpassAutoFill/AutoFillView.swift index 0ced3d48..14a90418 100644 --- a/apps/ios/GhostpassAutoFill/AutoFillView.swift +++ b/apps/ios/GhostpassAutoFill/AutoFillView.swift @@ -38,7 +38,7 @@ struct AutoFillView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 7) { Text("Compte").sectionLabel() - Text(store.account) + Text(verbatim: store.account) .foregroundStyle(Color.gpMuted) .frame(maxWidth: .infinity, alignment: .leading) .ghostField() @@ -51,7 +51,11 @@ struct AutoFillView: View { } if let message = store.errorMessage { - Label(message, systemImage: "exclamationmark.triangle.fill") + Label { + Text(verbatim: message) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } .font(.footnote) .foregroundStyle(Color.gpDanger) .fixedSize(horizontal: false, vertical: true) @@ -104,10 +108,12 @@ struct AutoFillView: View { private var liste: some View { List { if !store.suggested.isEmpty { - section("Pour ce site", store.suggested) + section(Text("Pour ce site"), store.suggested) } if !store.others.isEmpty { - section(store.suggested.isEmpty ? "Coffre" : "Autres identifiants", store.others) + section( + store.suggested.isEmpty ? Text("Coffre") : Text("Autres identifiants"), + store.others) } } .listStyle(.plain) @@ -117,14 +123,18 @@ struct AutoFillView: View { ContentUnavailableView { Label("Aucun identifiant", systemImage: "key") } description: { - Text(store.errorMessage ?? "Le coffre local ne contient rien.") + if let message = store.errorMessage { + Text(verbatim: message) + } else { + Text("Le coffre local ne contient rien.") + } } .foregroundStyle(Color.gpMuted) } } } - private func section(_ titre: String, _ entries: [VaultEntry]) -> some View { + private func section(_ titre: Text, _ entries: [VaultEntry]) -> some View { Section { ForEach(entries) { entry in ligne(entry) @@ -133,7 +143,7 @@ struct AutoFillView: View { .listRowSeparator(.hidden) } } header: { - Text(titre).sectionLabel().padding(.leading, 2) + titre.sectionLabel().padding(.leading, 2) } } @@ -148,11 +158,12 @@ struct AutoFillView: View { .frame(width: 34, height: 34) .background(Color.gpAccent.opacity(0.16), in: RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 2) { - Text(entry.item.name) + Text(verbatim: entry.item.name) .font(.system(.body, weight: .medium)) .foregroundStyle(Color.gpInk) if let login = entry.login, !login.username.isEmpty { - Text(login.username).font(.caption).foregroundStyle(Color.gpMuted) + Text(verbatim: login.username).font(.caption) + .foregroundStyle(Color.gpMuted) } } Spacer(minLength: 8) @@ -167,7 +178,7 @@ struct AutoFillView: View { .buttonStyle(.plain) } - private func invite(_ texte: String) -> Text { + 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 index 6df6a173..21ec9dca 100644 --- a/apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift +++ b/apps/ios/GhostpassAutoFill/CredentialProviderViewController.swift @@ -52,7 +52,16 @@ final class CredentialProviderViewController: ASCredentialProviderViewController user: identifiant, password: motDePasse)) } - let root = UIHostingController(rootView: AutoFillView().environmentObject(store)) + // 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] 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/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 7d41510c..9bcc0cca 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -1,3 +1,4 @@ +import SwiftUI import XCTest @testable import Ghostpass @@ -538,3 +539,66 @@ final class FolderRegistryTests: XCTestCase { 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") + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index 4007fbf2..e70a47fb 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -1,4 +1,5 @@ import LocalAuthentication +import UIKit import XCTest /// Parcours de bout en bout contre un serveur GhostPass local, amorcé par @@ -58,6 +59,50 @@ final class VaultFlowTests: XCTestCase { 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() + } + } + + 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") } private func aucuneLigneFantome(_ app: XCUIApplication, _ contexte: String) { @@ -66,17 +111,59 @@ final class VaultFlowTests: XCTestCase { XCTAssertEqual(fantome.count, 0, "ligne fantôme gp:folders visible (\(contexte))") } + private func ouvrirReglages(_ app: XCUIApplication) { + let menu = app.buttons["button.settings"] + XCTAssertTrue(menu.waitForExistence(timeout: 30), "le menu du coffre est absent") + let entree = app.buttons["button.preferences"] + // 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. + var ouvert = false + for _ in 0..<4 { + taper(menu) + if entree.waitForExistence(timeout: 5) { + ouvert = true + break + } + } + XCTAssertTrue(ouvert, "« Réglages » absent du menu") + entree.tap() + XCTAssertTrue( + app.buttons["button.doneSettings"].waitForExistence(timeout: 20), + "l'écran des réglages ne s'est pas ouvert") + } + + /// 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) - // Sur un simulateur où une biométrie est inscrite, l'app propose de l'activer. - // On écarte la proposition explicitement : la subir ferait échouer les taps - // suivants pour une raison sans rapport avec ce que ce test vérifie. - let plusTard = app.buttons["button.laterBiometric"].firstMatch - if plusTard.waitForExistence(timeout: 5) { plusTard.tap() } shot(app, "1-coffre") // 2. La liste montre le coffre, et rien de ce qui doit rester caché @@ -358,4 +445,63 @@ final class VaultFlowTests: XCTestCase { 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") + } } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 85cf47fa..59732e02 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -7,6 +7,11 @@ options: 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: @@ -15,6 +20,9 @@ settings: # 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: @@ -59,6 +67,9 @@ targets: - 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 diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh index 90ba7fa3..ac6d9bb0 100755 --- a/tools/ios/run-ios-tests.sh +++ b/tools/ios/run-ios-tests.sh @@ -113,6 +113,14 @@ $booted || { echo "Le simulateur n'a pas démarré en 90 s." >&2; exit 1; } # presse-papiers avec l'hôte : le clavier interroge le pasteboard à chaque prise de focus. defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false +# L'application existe en français et en anglais, et suit la langue de l'appareil tant +# que l'utilisateur n'en choisit pas une. Les tests, eux, attendent des libellés précis : +# on fixe donc la langue du simulateur plutôt que d'hériter de celle du poste, sur lequel +# la même suite passerait ou échouerait selon les réglages de son propriétaire. Le +# réglage vaut aussi pour Safari, qui héberge l'extension de remplissage. +xcrun simctl spawn "$DEVICE" defaults write .GlobalPreferences AppleLanguages -array fr +xcrun simctl spawn "$DEVICE" defaults write .GlobalPreferences AppleLocale -string fr_CH + # ── Biométrie simulée ───────────────────────────────────────────────────────── # Sans inscription, `test02Biometrie` se met en skip plutôt que de passer par hasard. BIOMETRICS=0 @@ -210,7 +218,8 @@ say "Parcours de bout en bout" # test. Les tests connaissent ces valeurs par défaut ; c'est le contrat entre eux et ce # script — d'où le port et le compte figés plus haut. if ! run_tests GhostpassUITests/VaultFlowTests/test01ParcoursComplet \ - -only-testing:GhostpassUITests/VaultFlowTests/test02Biometrie; then + -only-testing:GhostpassUITests/VaultFlowTests/test02Biometrie \ + -only-testing:GhostpassUITests/VaultFlowTests/test05Preferences; then echo "--- journal de l'application ---" >&2 xcrun simctl spawn "$DEVICE" log show --last 15m --style compact \ --predicate 'process == "Ghostpass"' 2>/dev/null | grep -a "GP-" | tail -25 >&2 || From 5dff75185e78ae5f7f259bc909aa54946fcefe3d Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 15:16:51 +0200 Subject: [PATCH 19/50] =?UTF-8?q?feat:=20favoris=20et=20sant=C3=A9=20du=20?= =?UTF-8?q?coffre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux manques que la web app ne comblait pas non plus. Les favoris d'abord : un coffre de cinquante entrées se parcourt mal quand on n'en ouvre que trois au quotidien. La santé ensuite, qui répond à la seule question qu'un gestionnaire de mots de passe doit savoir poser à sa place — lesquels sont à refaire. Un favori n'est pas une propriété de l'élément : le modèle du cœur Rust n'en a pas, et l'inventer ici reviendrait à écrire des items que la web app ne saurait plus relire. La liste vit donc dans son propre item de registre, chiffré comme les autres, exactement comme celle des dossiers vides. D'où le vrai changement de cette série : les registres se reconnaissent désormais à leur préfixe, `\0gp:`, et non plus à leur nom exact. Les deux applications filtraient `\0gp:folders` nommément ; le registre des favoris serait donc apparu comme une ligne fantôme dans le navigateur — précisément ce qu'on s'était attaché à éviter pour les dossiers. Le préfixe permet à un client d'en ajouter un sans que les autres aient à le connaître : la web app ignore celui des favoris, mais elle sait ne pas l'afficher. Le test qui traquait la ligne fantôme en éprouve maintenant plusieurs, dont un nom qu'aucune version n'utilise. Le barème de force est celui de la web app au point près, et les tests portent ses vecteurs : un mot de passe jugé faible dans le navigateur et bon sur le téléphone ferait douter des deux écrans. La vérification des fuites reprend le k-anonymat de `breach.ts` — cinq caractères d'empreinte partent, le mot de passe et son empreinte complète restent sur l'appareil. C'est le seul endroit de l'application qui parle à un tiers : l'appel ne se déclenche donc que sur un bouton, jamais à l'ouverture de l'écran. Une fois un élément mis en favori, il figure deux fois dans la liste — dans sa section et à sa place habituelle. Les requêtes du parcours qui le frappaient passent par `firstMatch` : une requête qui rend deux éléments ne se frappe pas telle quelle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/Ghostpass/Model/VaultItem.swift | 18 +- .../Ghostpass/Resources/Localizable.xcstrings | 251 ++++++++++++++++++ apps/ios/Ghostpass/Services/Breach.swift | 56 ++++ .../Ghostpass/Services/PasswordHealth.swift | 80 ++++++ apps/ios/Ghostpass/Services/VaultStore.swift | 122 ++++++--- apps/ios/Ghostpass/Views/HealthView.swift | 222 ++++++++++++++++ apps/ios/Ghostpass/Views/ItemDetailView.swift | 36 +++ apps/ios/Ghostpass/Views/VaultListView.swift | 89 ++++++- apps/ios/Tests/ContractTests.swift | 88 ++++++ apps/ios/UITests/VaultFlowTests.swift | 62 ++++- apps/web/src/App.svelte | 5 +- apps/web/src/lib/crypto.ts | Bin 14929 -> 15714 bytes 12 files changed, 965 insertions(+), 64 deletions(-) create mode 100644 apps/ios/Ghostpass/Services/Breach.swift create mode 100644 apps/ios/Ghostpass/Services/PasswordHealth.swift create mode 100644 apps/ios/Ghostpass/Views/HealthView.swift diff --git a/apps/ios/Ghostpass/Model/VaultItem.swift b/apps/ios/Ghostpass/Model/VaultItem.swift index a851f101..83b8b289 100644 --- a/apps/ios/Ghostpass/Model/VaultItem.swift +++ b/apps/ios/Ghostpass/Model/VaultItem.swift @@ -98,8 +98,18 @@ struct VaultEntry: Identifiable, Hashable { } enum VaultConstants { - /// L'item qui stocke l'arborescence des dossiers porte un nom commençant par un - /// octet NUL : il est invisible dans l'UI web, il doit l'être ici aussi. Un nom - /// choisi par l'utilisateur ne peut pas entrer en collision avec celui-là. - static let foldersItemName = "\u{0}gp:folders" + /// 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" } diff --git a/apps/ios/Ghostpass/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings index 27c56a99..e0ca362b 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -12,6 +12,7 @@ } }, "%1$@ n'a pas pu être activé (code %2$d).": { + "extractionState": "stale", "localizations": { "en": { "stringUnit": { @@ -41,6 +42,16 @@ } } }, + "%lld mots de passe à revoir": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld passwords to review" + } + } + } + }, "%lld s": { "localizations": { "en": { @@ -51,6 +62,16 @@ } } }, + "%lld éléments examinés": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld items examined" + } + } + } + }, "123456": { "localizations": { "en": { @@ -181,6 +202,16 @@ } } }, + "Aucun mot de passe connu des fuites publiques.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No password known to public breaches." + } + } + } + }, "Aucune session enregistrée sur cet appareil.": { "localizations": { "en": { @@ -201,6 +232,16 @@ } } }, + "Bon": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Good" + } + } + } + }, "Caractères": { "localizations": { "en": { @@ -541,6 +582,16 @@ } } }, + "Favoris": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favourites" + } + } + } + }, "Fermer": { "localizations": { "en": { @@ -551,6 +602,26 @@ } } }, + "Force": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Strength" + } + } + } + }, + "Fuites connues": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Known breaches" + } + } + } + }, "GhostPass": { "localizations": { "en": { @@ -651,6 +722,26 @@ } } }, + "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." + } + } + } + }, "Les éléments supprimés atterrissent ici.": { "localizations": { "en": { @@ -691,6 +782,16 @@ } } }, + "Mettre en favori": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add to favourites" + } + } + } + }, "Minuscules": { "localizations": { "en": { @@ -751,6 +852,36 @@ } } }, + "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" + } + } + } + }, "Nom": { "localizations": { "en": { @@ -921,6 +1052,16 @@ } } }, + "Retirer des favoris": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from favourites" + } + } + } + }, "Revenir au coffre enregistré": { "localizations": { "en": { @@ -931,6 +1072,16 @@ } } }, + "Rien à signaler": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing to report" + } + } + } + }, "Réglages": { "localizations": { "en": { @@ -951,6 +1102,26 @@ } } }, + "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": { @@ -1031,6 +1202,26 @@ } } }, + "Serveur injoignable : les favoris n'ont pas été enregistrés.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable: favourites were not saved." + } + } + } + }, + "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": { @@ -1151,6 +1342,46 @@ } } }, + "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" + } + } + } + }, + "Une seule fuite suffit alors à ouvrir plusieurs comptes.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A single breach then opens several accounts." + } + } + } + }, "Utiliser": { "localizations": { "en": { @@ -1221,6 +1452,26 @@ } } }, + "Vérification…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking…" + } + } + } + }, + "Vérifier les fuites": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for breaches" + } + } + } + }, "https://ghostpass.stackops.ch": { "localizations": { "en": { 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/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/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index fd050aa2..60d03b4f 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -18,8 +18,13 @@ final class VaultStore: ObservableObject { /// 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] = [] - /// Identité de cet item de registre, pour le mettre à jour plutôt que le multiplier. - private var folderRegistryID: 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 @@ -121,7 +126,8 @@ final class VaultStore: ObservableObject { pendingPassword = nil offersBiometricEnrollment = false emptyFolders = [] - folderRegistryID = nil + favorites = [] + registryIDs = [:] } /// Déconnexion : révoque la session côté serveur et efface tout localement. @@ -282,35 +288,52 @@ final class VaultStore: ObservableObject { lecture(dtos, account).entries } - /// Le même travail, mais en retenant au passage le registre des dossiers : il traverse - /// la liste comme les autres éléments, autant le cueillir là plutôt que de refaire un - /// tour de déchiffrement pour lui seul. - private static func lecture(_ dtos: [EncryptedItemDTO], _ account: Account) - -> (entries: [VaultEntry], registryID: String?, folders: [String]) - { + /// 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 registryID: String? 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) { - registryID = dto.id - if case .secureNote(let note) = item.data, - let data = note.content.data(using: .utf8), - let chemins = try? JSONDecoder().decode([String].self, from: data) - { - folders = chemins + 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 } - entries.append(VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt)) + resultat.entries.append(VaultEntry(id: dto.id, item: item, updatedAt: dto.updatedAt)) } - entries.sort { + resultat.entries.sort { $0.item.name.localizedCaseInsensitiveCompare($1.item.name) == .orderedAscending } - return (entries, registryID, folders.sorted { $0.localizedCompare($1) == .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 { @@ -350,12 +373,11 @@ final class VaultStore: ObservableObject { } } - private func appliquer( - _ lecture: (entries: [VaultEntry], registryID: String?, folders: [String]) - ) { + private func appliquer(_ lecture: Lecture) { entries = lecture.entries - folderRegistryID = lecture.registryID + registryIDs = lecture.registryIDs emptyFolders = lecture.folders + favorites = lecture.favorites } // ─── Dossiers ─── @@ -400,28 +422,64 @@ final class VaultStore: ObservableObject { .trimmingCharacters(in: CharacterSet(charactersIn: "/")) } - /// Écrit le registre : un `SecureNote` dont le contenu est la liste des chemins, sous - /// un nom que l'interface masque. Même format que la web app, au caractère près. 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.")) + } + + // ─── 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(emptyFolders)) ?? Data("[]".utf8), as: UTF8.self) + decoding: (try? JSONEncoder().encode(valeurs)) ?? Data("[]".utf8), as: UTF8.self) let item = VaultItem( - name: VaultConstants.foldersItemName, notes: nil, folder: nil, + name: nom, notes: nil, folder: nil, data: .secureNote(SecureNote(content: contenu))) do { let (key, data) = try Self.encrypt(item, with: account) - if let folderRegistryID { + if let identifiant = registryIDs[nom] { _ = try await api.updateItem( - token: token, id: folderRegistryID, encryptedKey: key, encryptedData: data) + token: token, id: identifiant, encryptedKey: key, encryptedData: data) } else { let cree = try await api.createItem( token: token, encryptedKey: key, encryptedData: data) - folderRegistryID = cree.id + registryIDs[nom] = cree.id } await refresh() } catch is URLError { - errorMessage = tr("Serveur injoignable : les dossiers n'ont pas été enregistrés.") + errorMessage = echec } catch { errorMessage = error.localizedDescription } @@ -477,7 +535,7 @@ final class VaultStore: ObservableObject { /// 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 == VaultConstants.foldersItemName + item.name.hasPrefix(VaultConstants.registryPrefix) } /// Le cœur échange des `EncryptedItem` en JSON (`encrypted_key` / `encrypted_data`), 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/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift index 31c5eda5..0b53860e 100644 --- a/apps/ios/Ghostpass/Views/ItemDetailView.swift +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -51,6 +51,15 @@ struct ItemDetailView: View { .navigationTitle(Text(verbatim: live.item.name)) .navigationBarTitleDisplayMode(.inline) .toolbar { + 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") @@ -81,6 +90,10 @@ struct ItemDetailView: View { copier(login.password, "Mot de passe copié") } } + if !login.password.isEmpty { + GhostDivider() + force(login.password) + } } if let config = login.totp.flatMap(Totp.parse) { @@ -162,6 +175,29 @@ struct ItemDetailView: View { } } + /// 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 { diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 88d07023..765bb387 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -9,6 +9,12 @@ struct VaultListView: View { @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 @@ -25,7 +31,7 @@ struct VaultListView: View { } var body: some View { - NavigationStack { + NavigationStack(path: $chemin) { ZStack { GhostBackground() @@ -42,21 +48,30 @@ struct VaultListView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) - ForEach(visible) { entry in - // 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. - NavigationLink(value: entry) { - VaultRow(entry: entry) + // 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) - .swipeActions { - Button("Supprimer", role: .destructive) { - Task { await store.delete(entry) } - } - } + } + + ForEach(visible) { entry in + ligne(entry) + .listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } .listStyle(.plain) @@ -100,6 +115,10 @@ struct VaultListView: View { .accessibilityIdentifier("button.biometricOn") } } + Button("Santé du coffre", systemImage: "checkmark.shield") { + sheet = .health + } + .accessibilityIdentifier("button.health") Button("Corbeille", systemImage: "trash") { sheet = .trash } .accessibilityIdentifier("button.trash") Button("Réglages", systemImage: "gearshape") { sheet = .settings } @@ -124,7 +143,7 @@ struct VaultListView: View { .accessibilityIdentifier("button.add") } } - .sheet(item: $sheet) { destination in + .sheet(item: $sheet, onDismiss: ouvrirLElementDemande) { destination in switch destination { case .newItem: ItemEditView(target: .new).environmentObject(store) @@ -140,6 +159,8 @@ struct VaultListView: View { SettingsView().environmentObject(Preferences.shared) case .biometricOffer: BiometricOfferView().environmentObject(store) + case .health: + HealthView { aOuvrir = $0 }.environmentObject(store) } } // Une seule alerte, et rien d'autre par-dessus : deux modificateurs `.alert` @@ -170,6 +191,38 @@ struct VaultListView: View { }) } + /// 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) } + } + } + } + + 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() { @@ -252,6 +305,7 @@ enum VaultSheet: Identifiable { case folders case settings case biometricOffer + case health var id: String { switch self { @@ -262,6 +316,7 @@ enum VaultSheet: Identifiable { case .folders: return "folders" case .settings: return "settings" case .biometricOffer: return "biometricOffer" + case .health: return "health" } } } @@ -282,6 +337,7 @@ enum EditTarget: Identifiable { /// 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) { @@ -308,6 +364,13 @@ private struct VaultRow: View { } Spacer(minLength: 8) + + if favori { + Image(systemName: "star.fill") + .font(.system(size: 12)) + .foregroundStyle(Color.gpAccentText) + .accessibilityHidden(true) + } } .padding(.horizontal, 14) .padding(.vertical, 11) diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 9bcc0cca..509aec7f 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -602,3 +602,91 @@ final class TraductionTests: XCTestCase { 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(""))) + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index e70a47fb..d50288fe 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -105,16 +105,29 @@ final class VaultFlowTests: XCTestCase { 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) { - let fantome = app.staticTexts.containing( - NSPredicate(format: "label CONTAINS[c] %@", "gp:folders")) - XCTAssertEqual(fantome.count, 0, "ligne fantôme gp:folders visible (\(contexte))") + 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) { let menu = app.buttons["button.settings"] XCTAssertTrue(menu.waitForExistence(timeout: 30), "le menu du coffre est absent") - let entree = app.buttons["button.preferences"] + let entree = app.buttons[identifiant] // 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. @@ -126,11 +139,8 @@ final class VaultFlowTests: XCTestCase { break } } - XCTAssertTrue(ouvert, "« Réglages » absent du menu") + XCTAssertTrue(ouvert, "« \(identifiant) » absent du menu") entree.tap() - XCTAssertTrue( - app.buttons["button.doneSettings"].waitForExistence(timeout: 20), - "l'écran des réglages ne s'est pas ouvert") } /// La luminance moyenne d'une capture, entre 0 et 1. @@ -205,8 +215,34 @@ final class VaultFlowTests: XCTestCase { app.staticTexts["Forgejo"].waitForExistence(timeout: 60), "l'item créé n'apparaît pas dans la liste") - // 4. Modification - app.buttons["Forgejo, clara"].tap() + // 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. 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)) @@ -245,7 +281,7 @@ final class VaultFlowTests: XCTestCase { 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"].tap() + 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)) @@ -273,7 +309,7 @@ final class VaultFlowTests: XCTestCase { // 5. Suppression if app.navigationBars.buttons["Coffre"].exists { app.navigationBars.buttons["Coffre"].tap() } - let ligne = app.buttons["Forgejo prod, clara"] + let ligne = app.buttons["Forgejo prod, clara"].firstMatch XCTAssertTrue(ligne.waitForExistence(timeout: 30)) ligne.swipeLeft() XCTAssertTrue(app.buttons["Supprimer"].waitForExistence(timeout: 30)) @@ -307,7 +343,7 @@ final class VaultFlowTests: XCTestCase { "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"].swipeLeft() + app.buttons["Forgejo prod, clara"].firstMatch.swipeLeft() XCTAssertTrue(app.buttons["Supprimer"].waitForExistence(timeout: 20)) app.buttons["Supprimer"].tap() expectation( diff --git a/apps/web/src/App.svelte b/apps/web/src/App.svelte index 83ac82cb..5d6a90d4 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, @@ -396,7 +397,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 +898,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 }); } } diff --git a/apps/web/src/lib/crypto.ts b/apps/web/src/lib/crypto.ts index 4ad2e39f37b8d43dda6de9de5d1aff6675766950..a6232317d309c862f42f226978d9f877e8c30485 100644 GIT binary patch delta 905 zcmZuvy>1gh5QZ+eqap$VgF(4Ovh3bbB#`1DE6$IcL_p=N)^qW|-QKY~8v-FRH5HP1 z06K7kkeq^w=AM8@AP<0e1ZK}h{D|(f_suuo&wSrYj_DYoj}C}2b@Byi zF=o}zj48(Q+=K){XK`#1O!zWJ6(1R%`aj3?fCN#DT@7SXR~n55aON2_mdGShd7h9~ zOqme~F<2Zj8nS=}^29qhlE=O>4!kMF$gVU+S?@r3wkX^#^RDC;h{@6^!W8e7VaSyY zlbL2@*xK*F`K!0GJ5pZ%LJu->R05O8GYu1RiqYF(EJx2Jb)H#9TZc}x--cAVVDz^Y zn(Y){icDZjJQruPJfdQ3yyR!mmbxt-k_*yB*_3=LvI#muH=M;IBxqAnUAzkpO0%F7 zLUAR1Bnq6DcAy_gRO+$BD)a($A*@)D#gbbRRyK4aO@%Q!6kD-GQ=CbRq^VxPOT}t1F||bIsBBx!czc?woZ02A#%P(nsqKCO0m9 zzC5e|gsS(A)H$_U)~%66?~&j_-6F#&DK}S6REfIKjK;)Md3dop$}CB{at?mhj(C|- z)Anu+G9UcLp1l;aT{0#S8Qwsf+v0=l67HI`MGmG5<>2C&^q~ABw=%u4x{$tv6L>r`wJe#4VDtZdll=O(g`as?K!N}O delta 114 zcmV-&0FD3RdeL;SkO-5i3N$JpC~0(MZ6IW2AZ^2`bYWy~Zf?V=Wpf~HVRLbH!>J%- zVQzCEY$sJoDU;p`BMl+|XK*@ZZ){{`a&scH91FM+vz#4f0Tv)VJv|^sPfSEbQd3_^ UR7Fi+PC-pYlO7) Date: Wed, 26 Aug 2026 15:46:01 +0200 Subject: [PATCH 20/50] =?UTF-8?q?feat(ios):=20historique=20des=20mots=20de?= =?UTF-8?q?=20passe,=20actions=20rapides,=20alertes=20r=C3=A9par=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois choses qui manquaient à l'usage plutôt qu'au catalogue. L'application archivait déjà les mots de passe remplacés — le champ existe dans le modèle, l'écran d'édition le remplit — mais rien ne les montrait jamais. Une donnée écrite et jamais lue n'est pas une fonctionnalité : c'est du poids mort dans un item chiffré. Le détail les déplie maintenant, un par un, masqués par défaut. C'est ce qui sauve un compte dont le changement de mot de passe a échoué à mi-chemin : le service a gardé l'ancien, l'application le nouveau. L'historique est aussi plafonné à vingt, comme sur le web. Sans plafond il grossissait sans fin, et un item qui enfle à chaque modification finit par coûter cher à transporter — pour des versions que personne ne remontera jamais si loin. 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 : mot de passe, nom d'utilisateur, code à usage unique, sans traverser deux écrans. Restaient enfin deux alertes présentées avec une liaison `.constant` — la corbeille et les dossiers. C'est le motif exact qui a rendu la proposition biométrique intapable : la liaison refuse d'enregistrer la fermeture que le système lui demande, et SwiftUI croit ensuite l'alerte encore là. Seules sur leur écran, elles fonctionnaient ; elles n'attendaient qu'une seconde alerte pour cesser de le faire. Une liaison qui écrit en retour les remplace. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/Ghostpass/Model/VaultItem.swift | 4 ++ .../Ghostpass/Resources/Localizable.xcstrings | 60 +++++++++++++++++ apps/ios/Ghostpass/Views/FoldersView.swift | 14 +++- apps/ios/Ghostpass/Views/ItemDetailView.swift | 65 +++++++++++++++++++ apps/ios/Ghostpass/Views/ItemEditView.swift | 10 ++- apps/ios/Ghostpass/Views/TrashView.swift | 14 +++- apps/ios/Ghostpass/Views/VaultListView.swift | 32 +++++++++ apps/ios/Tests/ContractTests.swift | 40 ++++++++++++ apps/ios/UITests/VaultFlowTests.swift | 16 +++++ 9 files changed, 251 insertions(+), 4 deletions(-) diff --git a/apps/ios/Ghostpass/Model/VaultItem.swift b/apps/ios/Ghostpass/Model/VaultItem.swift index 83b8b289..a2042c4d 100644 --- a/apps/ios/Ghostpass/Model/VaultItem.swift +++ b/apps/ios/Ghostpass/Model/VaultItem.swift @@ -112,4 +112,8 @@ enum VaultConstants { /// 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 index e0ca362b..c2bc053b 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -162,6 +162,26 @@ } } }, + "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": { @@ -412,6 +432,36 @@ } } }, + "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" + } + } + } + }, "Corbeille": { "localizations": { "en": { @@ -1032,6 +1082,16 @@ } } }, + "Remplacé": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replaced" + } + } + } + }, "Renouvelé toutes les %lld secondes.": { "localizations": { "en": { diff --git a/apps/ios/Ghostpass/Views/FoldersView.swift b/apps/ios/Ghostpass/Views/FoldersView.swift index d108fa3e..2ee6501c 100644 --- a/apps/ios/Ghostpass/Views/FoldersView.swift +++ b/apps/ios/Ghostpass/Views/FoldersView.swift @@ -89,7 +89,7 @@ struct FoldersView: View { } } .alert( - "Supprimer ce dossier ?", isPresented: .constant(aSupprimer != nil), + "Supprimer ce dossier ?", isPresented: presentation($aSupprimer), presenting: aSupprimer, actions: { chemin in Button("Supprimer", role: .destructive) { @@ -191,4 +191,16 @@ struct FoldersView: View { .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/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift index 0b53860e..95ea6b3e 100644 --- a/apps/ios/Ghostpass/Views/ItemDetailView.swift +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -7,6 +7,12 @@ struct ItemDetailView: View { @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? @@ -96,6 +102,10 @@ struct ItemDetailView: View { } } + 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 @@ -175,6 +185,61 @@ struct ItemDetailView: View { } } + /// 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 { diff --git a/apps/ios/Ghostpass/Views/ItemEditView.swift b/apps/ios/Ghostpass/Views/ItemEditView.swift index e3d732d0..9f84b06c 100644 --- a/apps/ios/Ghostpass/Views/ItemEditView.swift +++ b/apps/ios/Ghostpass/Views/ItemEditView.swift @@ -284,11 +284,17 @@ struct ItemEditView: View { /// 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 login.passwordHistory + return Array(login.passwordHistory.prefix(VaultConstants.passwordHistoryLimit)) } - return [login.password] + login.passwordHistory + return Array( + ([login.password] + login.passwordHistory) + .prefix(VaultConstants.passwordHistoryLimit)) } } diff --git a/apps/ios/Ghostpass/Views/TrashView.swift b/apps/ios/Ghostpass/Views/TrashView.swift index f203adeb..ac162a75 100644 --- a/apps/ios/Ghostpass/Views/TrashView.swift +++ b/apps/ios/Ghostpass/Views/TrashView.swift @@ -66,7 +66,7 @@ struct TrashView: View { // Une suppression définitive ne se rattrape pas : elle se confirme. .alert( "Supprimer définitivement ?", - isPresented: .constant(aPurger != nil), + isPresented: presentation($aPurger), presenting: aPurger, actions: { entry in Button("Supprimer", role: .destructive) { @@ -118,4 +118,16 @@ struct TrashView: View { 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/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 765bb387..6ee29b77 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -215,6 +215,38 @@ struct VaultListView: View { 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() { diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 509aec7f..4f04b39c 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -690,3 +690,43 @@ final class RegistryTests: XCTestCase { 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) + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index d50288fe..f313a210 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -267,6 +267,22 @@ final class VaultFlowTests: XCTestCase { 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() From f622f4c961e0c0832c348f0e39938948050ec3a4 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 17:23:40 +0200 Subject: [PATCH 21/50] =?UTF-8?q?feat(ios):=20r=C3=A9cup=C3=A9ration=20de?= =?UTF-8?q?=20compte=20par=20cl=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il manquait 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 — et la web app, elle, l'offrait déjà : l'asymétrie aurait fini par coûter un coffre à quelqu'un. Le binding décide de ce qui est faisable sans toucher au cœur Rust. Il expose `create_recovery` et `recover` ; il n'expose ni `seal_user_key_for` ni `open_emergency`. L'accès d'urgence et les coffres partagés restent donc hors d'atteinte, et c'est très bien ainsi : les ajouter demanderait de rouvrir le cœur, ce qui n'est pas au programme. 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é. Sans elle, les deux ne valent rien. C'est aussi pourquoi l'écran qui l'affiche est irremplaçable : personne ne la conserve, et elle ne s'affiche qu'une fois. Trois tests de contrat éprouvent le parcours contre le vrai binding : les clefs du JSON, un coffre qui se relit après réinitialisation — un coffre qu'on rouvre mais dont les items ne se déchiffrent plus n'est pas un coffre récupéré — et une clé fausse qui doit échouer dans le cœur, pas seulement au refus du serveur. Le parcours d'interface fait le tour complet contre le serveur : créer la clé, oublier le mot de passe, réinitialiser, rouvrir. Il repose ensuite le compte comme il l'a trouvé, en réinitialisant une seconde fois vers le mot de passe d'origine, pour que la suite hors ligne retrouve la session qu'elle attend. Deux défauts d'outillage sont sortis en chemin. Le lien « mot de passe oublié » se trouve sous le clavier, et un tap dans le vide ne referme pas un clavier en SwiftUI : l'écran le déclare maintenant avec `.scrollDismissesKeyboard`. Et la conservation des rapports faisait un `cp -R` sur une destination existante, ce qui copie *dedans* : le rapport du run précédent avalait le nouveau, qu'on cherchait ensuite en vain. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../Ghostpass/Resources/Localizable.xcstrings | 200 ++++++++++++++++++ apps/ios/Ghostpass/Services/APIClient.swift | 46 ++++ apps/ios/Ghostpass/Services/VaultStore.swift | 95 +++++++++ .../Ghostpass/Views/RecoverAccountView.swift | 102 +++++++++ .../ios/Ghostpass/Views/RecoveryKeyView.swift | 111 ++++++++++ apps/ios/Ghostpass/Views/UnlockView.swift | 50 +++++ apps/ios/Ghostpass/Views/VaultListView.swift | 8 + apps/ios/Tests/ContractTests.swift | 99 +++++++++ apps/ios/UITests/VaultFlowTests.swift | 140 ++++++++++++ tools/ios/run-ios-tests.sh | 9 +- 10 files changed, 858 insertions(+), 2 deletions(-) create mode 100644 apps/ios/Ghostpass/Views/RecoverAccountView.swift create mode 100644 apps/ios/Ghostpass/Views/RecoveryKeyView.swift diff --git a/apps/ios/Ghostpass/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings index c2bc053b..e1e42bf8 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -122,6 +122,16 @@ } } }, + "Adresse de serveur invalide.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid server address." + } + } + } + }, "Adresse du site": { "localizations": { "en": { @@ -312,6 +322,26 @@ } } }, + "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": { @@ -432,6 +462,16 @@ } } }, + "Copier la clé": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy key" + } + } + } + }, "Copier le code": { "localizations": { "en": { @@ -462,6 +502,16 @@ } } }, + "Copiée": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied" + } + } + } + }, "Corbeille": { "localizations": { "en": { @@ -512,6 +562,16 @@ } } }, + "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": { @@ -582,6 +642,16 @@ } } }, + "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": { @@ -712,6 +782,16 @@ } } }, + "Huit caractères au minimum.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Eight characters minimum." + } + } + } + }, "Identifiant": { "localizations": { "en": { @@ -732,6 +812,16 @@ } } }, + "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é reste chiffrée dans l'élément ; les codes sont calculés sur l'appareil.": { "localizations": { "en": { @@ -772,6 +862,16 @@ } } }, + "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 second facteur protège même un mot de passe connu.": { "localizations": { "en": { @@ -902,6 +1002,36 @@ } } }, + "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": { @@ -982,6 +1112,16 @@ } } }, + "Nouveau mot de passe maître": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New master password" + } + } + } + }, "Nouvel élément": { "localizations": { "en": { @@ -1162,6 +1302,26 @@ } } }, + "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." + } + } + } + }, "Sans code à usage unique": { "localizations": { "en": { @@ -1232,6 +1392,16 @@ } } }, + "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": { @@ -1272,6 +1442,16 @@ } } }, + "Serveur injoignable.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server unreachable." + } + } + } + }, "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": { @@ -1432,6 +1612,16 @@ } } }, + "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." + } + } + } + }, "Une seule fuite suffit alors à ouvrir plusieurs comptes.": { "localizations": { "en": { @@ -1482,6 +1672,16 @@ } } }, + "Votre clé de récupération": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your recovery key" + } + } + } + }, "Votre mot de passe": { "localizations": { "en": { diff --git a/apps/ios/Ghostpass/Services/APIClient.swift b/apps/ios/Ghostpass/Services/APIClient.swift index b471eb89..db29bbd8 100644 --- a/apps/ios/Ghostpass/Services/APIClient.swift +++ b/apps/ios/Ghostpass/Services/APIClient.swift @@ -28,6 +28,14 @@ struct PreloginResponse: Decodable { 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 @@ -122,6 +130,44 @@ struct APIClient { _ = 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] { diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 60d03b4f..9f9372c8 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -454,6 +454,101 @@ final class VaultStore: ObservableObject { echec: tr("Serveur injoignable : les favoris n'ont pas été enregistrés.")) } + // ─── 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 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/UnlockView.swift b/apps/ios/Ghostpass/Views/UnlockView.swift index e193ff9c..93bb31f4 100644 --- a/apps/ios/Ghostpass/Views/UnlockView.swift +++ b/apps/ios/Ghostpass/Views/UnlockView.swift @@ -13,6 +13,11 @@ struct UnlockView: View { @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 { @@ -29,6 +34,17 @@ struct UnlockView: View { .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 { @@ -114,6 +130,18 @@ struct UnlockView: View { } } + 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. @@ -150,6 +178,21 @@ struct UnlockView: View { .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() @@ -172,6 +215,13 @@ struct UnlockView: View { .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 { diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 6ee29b77..aaa355ae 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -119,6 +119,10 @@ struct VaultListView: View { sheet = .health } .accessibilityIdentifier("button.health") + Button("Clé de récupération", systemImage: "key.horizontal") { + sheet = .recoveryKey + } + .accessibilityIdentifier("button.recoveryKey") Button("Corbeille", systemImage: "trash") { sheet = .trash } .accessibilityIdentifier("button.trash") Button("Réglages", systemImage: "gearshape") { sheet = .settings } @@ -161,6 +165,8 @@ struct VaultListView: View { BiometricOfferView().environmentObject(store) case .health: HealthView { aOuvrir = $0 }.environmentObject(store) + case .recoveryKey: + RecoveryKeyView().environmentObject(store) } } // Une seule alerte, et rien d'autre par-dessus : deux modificateurs `.alert` @@ -338,6 +344,7 @@ enum VaultSheet: Identifiable { case settings case biometricOffer case health + case recoveryKey var id: String { switch self { @@ -349,6 +356,7 @@ enum VaultSheet: Identifiable { case .settings: return "settings" case .biometricOffer: return "biometricOffer" case .health: return "health" + case .recoveryKey: return "recoveryKey" } } } diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 4f04b39c..fae95292 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -730,3 +730,102 @@ final class PasswordHistoryTests: XCTestCase { 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") + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index f313a210..7048c7f9 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -37,6 +37,15 @@ final class VaultFlowTests: XCTestCase { 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) } @@ -79,6 +88,41 @@ final class VaultFlowTests: XCTestCase { } } + /// 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) @@ -556,4 +600,100 @@ final class VaultFlowTests: XCTestCase { 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) + } + + /// 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/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh index ac6d9bb0..a8817dcf 100755 --- a/tools/ios/run-ios-tests.sh +++ b/tools/ios/run-ios-tests.sh @@ -52,7 +52,11 @@ cleanup() { if [[ $code -ne 0 || "${GHOSTPASS_KEEP_RESULTS:-}" == "1" ]]; then mkdir -p "$RESULTS" for bundle in "$WORK"/*.xcresult; do - [[ -e "$bundle" ]] && cp -R "$bundle" "$RESULTS/" 2>/dev/null || true + # `cp -R` sur une destination qui existe déjà copie *dedans* : le rapport du run + # précédent avalerait le nouveau, qu'on chercherait ensuite en vain. + [[ -e "$bundle" ]] || continue + rm -rf "$RESULTS/$(basename "$bundle")" + cp -R "$bundle" "$RESULTS/" 2>/dev/null || true done [[ -e "$WORK/server.log" ]] && cp "$WORK/server.log" "$RESULTS/" 2>/dev/null || true echo "Rapports conservés dans apps/ios/TestResults/" >&2 @@ -219,7 +223,8 @@ say "Parcours de bout en bout" # script — d'où le port et le compte figés plus haut. if ! run_tests GhostpassUITests/VaultFlowTests/test01ParcoursComplet \ -only-testing:GhostpassUITests/VaultFlowTests/test02Biometrie \ - -only-testing:GhostpassUITests/VaultFlowTests/test05Preferences; then + -only-testing:GhostpassUITests/VaultFlowTests/test05Preferences \ + -only-testing:GhostpassUITests/VaultFlowTests/test06Recuperation; then echo "--- journal de l'application ---" >&2 xcrun simctl spawn "$DEVICE" log show --last 15m --style compact \ --predicate 'process == "Ghostpass"' 2>/dev/null | grep -a "GP-" | tail -25 >&2 || From fa42f61a72ee9fa658f1859e57673969d9050015 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 17:52:32 +0200 Subject: [PATCH 22/50] =?UTF-8?q?feat(ios):=20import=20d'un=20CSV=20export?= =?UTF-8?q?=C3=A9=20ailleurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le premier obstacle à l'adoption d'un gestionnaire de mots de passe n'est pas son interface, c'est ce qu'il faut abandonner pour y venir. La web app importait déjà ; le téléphone, non. L'analyseur est transposé de `apps/web/src/lib/csv.ts`, table d'équivalences comprise : Bitwarden, 1Password, LastPass et Chrome ne nomment pas leurs colonnes de la même façon, et c'est précisément ce qu'elle absorbe. Le même fichier doit donner le même coffre des deux côtés — sinon migrer depuis le téléphone et migrer depuis le navigateur produiraient deux résultats différents. L'écran montre avant de déposer. Un import écrit dans le coffre et ne se défait qu'entrée par entrée : on lit le fichier, on annonce ce qu'on y a trouvé, on attend un second geste. Le dépôt se fait en une fournée avec un seul rechargement — rafraîchir après chaque ligne d'un fichier de deux cents entrées ferait deux cents allers-retours et un écran qui clignote. Les tests portent les cas de bord du format, là où un découpage naïf casse en silence : une virgule dans un mot de passe, un guillemet doublé, un saut de ligne échappé, une ligne trop courte, les lignes vides que sèment les exports. Plus les colonnes des quatre gestionnaires. Le choix du fichier appartient au sélecteur du système ; le parcours vérifie seulement que l'écran s'ouvre. L'écran rappelle d'effacer le fichier ensuite : un export CSV contient les mots de passe en clair, et il traîne dans les Fichiers une fois l'import fini. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../Ghostpass/Resources/Localizable.xcstrings | 216 ++++++++++++++++++ apps/ios/Ghostpass/Services/CsvImport.swift | 112 +++++++++ apps/ios/Ghostpass/Services/VaultStore.swift | 29 +++ apps/ios/Ghostpass/Views/ImportView.swift | 203 ++++++++++++++++ apps/ios/Ghostpass/Views/VaultListView.swift | 8 + apps/ios/Tests/ContractTests.swift | 124 ++++++++++ apps/ios/UITests/VaultFlowTests.swift | 11 +- 7 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 apps/ios/Ghostpass/Services/CsvImport.swift create mode 100644 apps/ios/Ghostpass/Views/ImportView.swift diff --git a/apps/ios/Ghostpass/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings index e1e42bf8..dfeacbb3 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -1,6 +1,16 @@ { "sourceLanguage": "fr", "strings": { + "": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "" + } + } + } + }, " — effacé dans %lld s": { "localizations": { "en": { @@ -32,6 +42,22 @@ } } }, + "%@ n'a pas pu être activé (code %d).": { + "localizations": { + "fr": { + "stringUnit": { + "state": "new", + "value": "%1$@ n'a pas pu être activé (code %2$d)." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ could not be enabled (code %d)." + } + } + } + }, "%lld": { "localizations": { "en": { @@ -42,6 +68,16 @@ } } }, + "%lld entrées importées.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld entries imported." + } + } + } + }, "%lld mots de passe à revoir": { "localizations": { "en": { @@ -72,6 +108,16 @@ } } }, + "(sans nom)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(no name)" + } + } + } + }, "123456": { "localizations": { "en": { @@ -202,6 +248,16 @@ } } }, + "Aperçu": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" + } + } + } + }, "Apparence": { "localizations": { "en": { @@ -242,6 +298,16 @@ } } }, + "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": { @@ -302,6 +368,26 @@ } } }, + "Choisir un fichier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a file" + } + } + } + }, + "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": { @@ -662,6 +748,16 @@ } } }, + "Entrées trouvées": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Entries found" + } + } + } + }, "Erreur": { "localizations": { "en": { @@ -722,6 +818,16 @@ } } }, + "Fichier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File" + } + } + } + }, "Force": { "localizations": { "en": { @@ -812,6 +918,36 @@ } } }, + "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" + } + } + } + }, "La clé de récupération est la seule issue. Notez-la et rangez-la ailleurs que dans ce coffre.": { "localizations": { "en": { @@ -862,6 +998,36 @@ } } }, + "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": { @@ -1062,6 +1228,16 @@ } } }, + "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": { @@ -1272,6 +1448,16 @@ } } }, + "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": { @@ -1372,6 +1558,16 @@ } } }, + "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": { @@ -1732,6 +1928,16 @@ } } }, + "et %lld autres": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "and %lld more" + } + } + } + }, "https://ghostpass.stackops.ch": { "localizations": { "en": { @@ -1782,6 +1988,16 @@ } } }, + "À importer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To import" + } + } + } + }, "≈ %lld bits": { "localizations": { "en": { diff --git a/apps/ios/Ghostpass/Services/CsvImport.swift b/apps/ios/Ghostpass/Services/CsvImport.swift new file mode 100644 index 00000000..14527a9d --- /dev/null +++ b/apps/ios/Ghostpass/Services/CsvImport.swift @@ -0,0 +1,112 @@ +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 + let dossier = premier(ligne, "folder", "vault", "group") + let totp = premier(ligne, "totp", "login_totp", "otpauth") + let adresse = premier(ligne, "url", "login_uri", "website", "uri") + return VaultItem( + name: premier(ligne, "name", "title").isEmpty + ? NSLocalizedString("(sans nom)", comment: "") + : premier(ligne, "name", "title"), + notes: nil, + folder: dossier.isEmpty ? nil : dossier, + data: .login( + Login( + username: premier(ligne, "username", "login_username", "login"), + 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/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 9f9372c8..8fbff254 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -454,6 +454,35 @@ final class VaultStore: ObservableObject { echec: tr("Serveur injoignable : les favoris n'ont pas été enregistrés.")) } + // ─── 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 — 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/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index aaa355ae..f28696aa 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -123,6 +123,10 @@ struct VaultListView: View { sheet = .recoveryKey } .accessibilityIdentifier("button.recoveryKey") + Button("Importer un CSV", systemImage: "square.and.arrow.down") { + sheet = .importCSV + } + .accessibilityIdentifier("button.import") Button("Corbeille", systemImage: "trash") { sheet = .trash } .accessibilityIdentifier("button.trash") Button("Réglages", systemImage: "gearshape") { sheet = .settings } @@ -167,6 +171,8 @@ struct VaultListView: View { HealthView { aOuvrir = $0 }.environmentObject(store) case .recoveryKey: RecoveryKeyView().environmentObject(store) + case .importCSV: + ImportView().environmentObject(store) } } // Une seule alerte, et rien d'autre par-dessus : deux modificateurs `.alert` @@ -345,6 +351,7 @@ enum VaultSheet: Identifiable { case biometricOffer case health case recoveryKey + case importCSV var id: String { switch self { @@ -357,6 +364,7 @@ enum VaultSheet: Identifiable { case .biometricOffer: return "biometricOffer" case .health: return "health" case .recoveryKey: return "recoveryKey" + case .importCSV: return "import" } } } diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index fae95292..66bdb36a 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -829,3 +829,127 @@ final class RecoveryTests: XCTestCase { "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) + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index 7048c7f9..de7d7f88 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -275,7 +275,16 @@ final class VaultFlowTests: XCTestCase { aucuneLigneFantome(app, "après mise en favori") shot(app, "4-favoris") - // 5. Santé du coffre + // 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. Santé du coffre ouvrirLeMenu(app, "button.health") let resume = app.descendants(matching: .any) .matching(identifier: "card.healthSummary").firstMatch From c555ba7821815cbc5ace547cc19f6050c9ca5cfe Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 18:15:42 +0200 Subject: [PATCH 23/50] =?UTF-8?q?feat(ios):=20ic=C3=B4nes=20des=20sites=20?= =?UTF-8?q?et=20export=20du=20coffre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une liste de pastilles grises se parcourt à la lecture ; une liste de logos se reconnaît d'un coup d'œil. Les icônes viennent du proxy du serveur de l'utilisateur — `GET /api/icons?domain=…`, la même route que la web app —, jamais d'un tiers : demander l'icône de Décathlon à Google reviendrait à lui annoncer qu'on a un compte chez Décathlon. Le serveur, lui, apprend les domaines. C'est le prix de l'icône, et un coffre chiffré de bout en bout ne dit rien d'autre de son contenu : d'où l'interrupteur dans les réglages, avec la raison écrite à côté. Allumé par défaut, comme sur le web. À défaut d'icône — coupée, absente, ou serveur qui refuse — la pastille retombe sur une initiale colorée, dont la palette est celle de la web app pour que le même élément garde la même couleur d'un écran à l'autre. Deux défauts que les tests ont sortis, et qu'aucun essai à la main n'aurait montrés : une adresse IP passait le filtre « contient un point » et faisait demander au serveur d'aller chercher une icône sur le réseau local ; et une adresse de serveur vide produisait une URL relative, requête vers nulle part. Le filtre applique maintenant la règle du serveur — extension alphabétique — et exige un hôte. L'export est le pendant de l'import : un gestionnaire dont on ne peut pas partir n'est pas un coffre, c'est une nasse. Format identique à celui de la web app, donc un fichier sorti d'ici se relit là-bas et réciproquement — le test qui compte est l'aller-retour, avec les caractères qui cassent un échappement approximatif. Le mot de passe maître est redemandé avant de fabriquer le fichier. Un téléphone déverrouillé posé sur une table ne doit pas suffire à vider le coffre en clair, et la vérification passe par le cœur : une dérivation Argon2id, pas une comparaison de chaînes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../Ghostpass/Resources/Localizable.xcstrings | 102 +++++++++++- apps/ios/Ghostpass/Services/CsvExport.swift | 46 ++++++ apps/ios/Ghostpass/Services/Favicon.swift | 121 ++++++++++++++ apps/ios/Ghostpass/Services/Preferences.swift | 11 ++ apps/ios/Ghostpass/Services/VaultStore.swift | 26 +++ apps/ios/Ghostpass/Theme/Theme.swift | 6 + apps/ios/Ghostpass/Views/ExportView.swift | 149 ++++++++++++++++++ apps/ios/Ghostpass/Views/SettingsView.swift | 13 ++ apps/ios/Ghostpass/Views/VaultListView.swift | 28 +++- apps/ios/Tests/ContractTests.swift | 121 ++++++++++++++ apps/ios/UITests/VaultFlowTests.swift | 12 +- 11 files changed, 621 insertions(+), 14 deletions(-) create mode 100644 apps/ios/Ghostpass/Services/CsvExport.swift create mode 100644 apps/ios/Ghostpass/Services/Favicon.swift create mode 100644 apps/ios/Ghostpass/Views/ExportView.swift diff --git a/apps/ios/Ghostpass/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings index dfeacbb3..3a4fedd0 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -44,17 +44,17 @@ }, "%@ n'a pas pu être activé (code %d).": { "localizations": { - "fr": { - "stringUnit": { - "state": "new", - "value": "%1$@ n'a pas pu être activé (code %2$d)." - } - }, "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)." + } } } }, @@ -208,6 +208,16 @@ } } }, + "Afficher les logos": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show logos" + } + } + } + }, "Ajoutez un identifiant avec le bouton +.": { "localizations": { "en": { @@ -788,6 +798,26 @@ } } }, + "Exporter": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export" + } + } + } + }, + "Exporter le coffre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export the vault" + } + } + } + }, "Faible": { "localizations": { "en": { @@ -828,6 +858,16 @@ } } }, + "Fichier enregistré.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File saved." + } + } + } + }, "Force": { "localizations": { "en": { @@ -898,6 +938,16 @@ } } }, + "Icônes des sites": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Site icons" + } + } + } + }, "Identifiant": { "localizations": { "en": { @@ -988,6 +1038,16 @@ } } }, + "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": { @@ -998,6 +1058,16 @@ } } }, + "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": { @@ -1098,6 +1168,16 @@ } } }, + "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": { @@ -1388,6 +1468,16 @@ } } }, + "Préparer le fichier": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prepare the file" + } + } + } + }, "Rechercher": { "localizations": { "en": { 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/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/Preferences.swift b/apps/ios/Ghostpass/Services/Preferences.swift index 6ad61076..f4ef40a9 100644 --- a/apps/ios/Ghostpass/Services/Preferences.swift +++ b/apps/ios/Ghostpass/Services/Preferences.swift @@ -23,17 +23,28 @@ final class Preferences: ObservableObject { 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) } + } + 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" } 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 } /// `nil` laisse SwiftUI suivre le réglage du système. diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 8fbff254..e4d44786 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -454,6 +454,32 @@ final class VaultStore: ObservableObject { 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 diff --git a/apps/ios/Ghostpass/Theme/Theme.swift b/apps/ios/Ghostpass/Theme/Theme.swift index db617367..00784e2f 100644 --- a/apps/ios/Ghostpass/Theme/Theme.swift +++ b/apps/ios/Ghostpass/Theme/Theme.swift @@ -36,6 +36,12 @@ extension Color { static let gpDanger = adaptative(sombre: 0xFB7185, clair: 0xD11F45) static let gpSuccess = adaptative(sombre: 0x4ADE80, 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 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/SettingsView.swift b/apps/ios/Ghostpass/Views/SettingsView.swift index 1025c6f8..67002ed3 100644 --- a/apps/ios/Ghostpass/Views/SettingsView.swift +++ b/apps/ios/Ghostpass/Views/SettingsView.swift @@ -24,6 +24,19 @@ struct SettingsView: View { .padding(12) } + 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." diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index f28696aa..5e8533f8 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -127,6 +127,10 @@ struct VaultListView: View { 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 } @@ -173,6 +177,8 @@ struct VaultListView: View { RecoveryKeyView().environmentObject(store) case .importCSV: ImportView().environmentObject(store) + case .exportCSV: + ExportView().environmentObject(store) } } // Une seule alerte, et rien d'autre par-dessus : deux modificateurs `.alert` @@ -352,6 +358,7 @@ enum VaultSheet: Identifiable { case health case recoveryKey case importCSV + case exportCSV var id: String { switch self { @@ -365,6 +372,7 @@ enum VaultSheet: Identifiable { case .health: return "health" case .recoveryKey: return "recoveryKey" case .importCSV: return "import" + case .exportCSV: return "export" } } } @@ -389,14 +397,20 @@ private struct VaultRow: View { var body: some View { HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gpAccent.opacity(0.16)) - Image(systemName: icon) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(Color.gpAccentText) + // 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) } - .frame(width: 38, height: 38) VStack(alignment: .leading, spacing: 2) { Text(verbatim: entry.item.name) diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 66bdb36a..eb5efe7c 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -953,3 +953,124 @@ final class CsvImportTests: XCTestCase { 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(""), "?") + } +} diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index de7d7f88..191e8102 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -284,7 +284,17 @@ final class VaultFlowTests: XCTestCase { "l'écran d'import ne s'ouvre pas") app.buttons["button.closeImport"].tap() - // 6. Santé du coffre + // 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 From 6a4835b2bfc8a8e063c45ab6a64f604a45c36f1d Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 19:29:36 +0200 Subject: [PATCH 24/50] =?UTF-8?q?ci:=20dire=20=C3=A0=20voix=20haute=20si?= =?UTF-8?q?=20les=20tests=20iOS=20sont=20activ=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le job iOS est filtré par `if: vars.MACOS_RUNNER == 'true'`. Un job écarté par un `if:` ne laisse aucune trace dans le rapport : il n'échoue pas, il n'attend pas, il n'existe pas. Personne ne remarque que les tests n'ont pas tourné — c'est exactement la situation qu'on vient de passer un moment à diagnostiquer à l'aveugle. Ce job-ci tourne toujours, sur Linux, et annonce ce que le garde a décidé. Il distingue les trois cas qui se ressemblent de loin : variable absente, variable présente avec une autre valeur, ou contexte `vars` que la forge ne sait pas résoudre — celui-ci demande Gitea 1.20, et une version antérieure rendrait une chaîne vide sans rien dire. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitea/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 41e5b2e5..7a852cbb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -81,6 +81,26 @@ jobs: 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" = "true" ]; then + echo "Le job iOS est activé et sera dispatché vers un runner étiqueté « macos »." + elif [ -z "$valeur" ]; then + echo "::warning::MACOS_RUNNER est vide : variable absente, hors de portée, ou contexte « vars » non géré par cette version de Gitea. Les tests iOS ne tourneront pas." + else + echo "::warning::MACOS_RUNNER vaut « $valeur » et non « true ». Les tests iOS ne tourneront pas." + fi + ios: name: App iOS (SwiftUI + UniFFI) # Xcode et le simulateur n'existent que sur macOS : ce job réclame un runner de cette From ff3396e60744c050938ade874dd3559d08fe15a6 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 19:35:38 +0200 Subject: [PATCH 25/50] ci: exercer le workflow sur les branches de travail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le job iOS n'existe que sur cette branche. Selon la version, une forge résout le workflow d'une pull request depuis la branche source ou depuis la cible — dans le second cas, un job ajouté sur une branche ne s'exécute jamais avant d'être fusionné, ce qui est exactement l'ordre inverse de ce qu'on attend d'une CI. Pour un événement `push`, il n'y a pas d'ambiguïté : c'est le workflow du commit poussé qui s'exécute. Déclencher aussi sur `feat/**` et `fix/**` donne donc un verdict sur le travail en cours, là où il se fait. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitea/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7a852cbb..e9ff3b93 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 From 29df36cd793edb4eb3953f61f24167971d88b925 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 19:50:34 +0200 Subject: [PATCH 26/50] =?UTF-8?q?feat(ios):=20verrouillage=20diff=C3=A9r?= =?UTF-8?q?=C3=A9=20et=20=C3=A9cran=20masqu=C3=A9=20en=20arri=C3=A8re-plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'application se refermait dès qu'on la quittait. C'est le réglage le plus sûr, et le plus pénible : coller un mot de passe demande un aller-retour vers Safari, et chaque retour redemandait le mot de passe maître. Un coffre qu'on renonce à ouvrir ne protège plus rien. Le délai devient réglable — immédiat, 1, 5 ou 15 minutes — et vaut immédiat par défaut : le comportement d'avant reste celui qu'on obtient sans rien décider. Relâcher est un choix, pas un réglage qu'on subit. Ce choix ouvre une brèche que le verrouillage immédiat masquait : iOS photographie l'écran quand l'application le quitte, et garde cette vignette sur disque. Un coffre resté ouvert s'y retrouverait en clair — noms de sites, identifiants — visible d'un glissement dans le sélecteur d'applications. Un voile prend donc sa place dès que l'application n'est plus au premier plan. La décision de refermer est isolée dans une fonction pure et testée sur ses cas limites : au délai pile, et une horloge qui recule — fuseau, correction NTP. Sans ce dernier garde-fou, un écart négatif laisserait le coffre ouvert indéfiniment. Entre se tromper vers l'ouvert et se tromper vers le fermé, le choix est fait d'avance. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/Ghostpass/GhostpassApp.swift | 19 +++++- .../Ghostpass/Resources/Localizable.xcstrings | 60 +++++++++++++++++++ apps/ios/Ghostpass/Services/Preferences.swift | 47 +++++++++++++++ apps/ios/Ghostpass/Services/VaultStore.swift | 36 +++++++++++ apps/ios/Ghostpass/Theme/Theme.swift | 25 ++++++++ apps/ios/Ghostpass/Views/SettingsView.swift | 28 +++++++++ apps/ios/Tests/ContractTests.swift | 52 ++++++++++++++++ 7 files changed, 264 insertions(+), 3 deletions(-) diff --git a/apps/ios/Ghostpass/GhostpassApp.swift b/apps/ios/Ghostpass/GhostpassApp.swift index 023b83fe..c6357aab 100644 --- a/apps/ios/Ghostpass/GhostpassApp.swift +++ b/apps/ios/Ghostpass/GhostpassApp.swift @@ -27,6 +27,14 @@ struct GhostpassApp: App { 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é @@ -36,9 +44,14 @@ struct GhostpassApp: App { .environment(\.locale, prefs.locale ?? Locale.autoupdatingCurrent) } .onChange(of: scenePhase) { _, phase in - // Passer en arrière-plan relâche les clés. Un coffre qui reste ouvert - // pendant que le téléphone circule n'est plus un coffre. - if phase == .background { store.lock() } + // 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/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings index 3a4fedd0..7101e440 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -278,6 +278,36 @@ } } }, + "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 dossier": { "localizations": { "en": { @@ -968,6 +998,16 @@ } } }, + "Immédiatement": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Immediately" + } + } + } + }, "Importer": { "localizations": { "en": { @@ -1438,6 +1478,16 @@ } } }, + "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." + } + } + } + }, "Plus tard": { "localizations": { "en": { @@ -1948,6 +1998,16 @@ } } }, + "Verrouillage": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Locking" + } + } + } + }, "Verrouiller": { "localizations": { "en": { diff --git a/apps/ios/Ghostpass/Services/Preferences.swift b/apps/ios/Ghostpass/Services/Preferences.swift index f4ef40a9..045519f2 100644 --- a/apps/ios/Ghostpass/Services/Preferences.swift +++ b/apps/ios/Ghostpass/Services/Preferences.swift @@ -30,12 +30,18 @@ final class Preferences: ObservableObject { 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() { @@ -45,6 +51,10 @@ final class Preferences: ObservableObject { // 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. @@ -78,6 +88,43 @@ func tr(_ valeur: String.LocalizationValue) -> String { 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 diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index e4d44786..9babd686 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -117,6 +117,42 @@ final class VaultStore: ObservableObject { } } + // ─── 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 diff --git a/apps/ios/Ghostpass/Theme/Theme.swift b/apps/ios/Ghostpass/Theme/Theme.swift index 00784e2f..1c9f2315 100644 --- a/apps/ios/Ghostpass/Theme/Theme.swift +++ b/apps/ios/Ghostpass/Theme/Theme.swift @@ -296,3 +296,28 @@ struct GhostIconButton: View { .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/SettingsView.swift b/apps/ios/Ghostpass/Views/SettingsView.swift index 67002ed3..ecd75c36 100644 --- a/apps/ios/Ghostpass/Views/SettingsView.swift +++ b/apps/ios/Ghostpass/Views/SettingsView.swift @@ -24,6 +24,34 @@ struct SettingsView: View { .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é." diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index eb5efe7c..ece4643d 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -1074,3 +1074,55 @@ final class FaviconTests: XCTestCase { 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) + } +} From 0cc962ca2d9c91d128271668f779f5ceb560741d Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 22:00:57 +0200 Subject: [PATCH 27/50] =?UTF-8?q?ci:=20l'interrupteur=20des=20tests=20iOS?= =?UTF-8?q?=20ne=20sert=20plus=20qu'=C3=A0=20couper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La variable `MACOS_RUNNER` était posée au niveau *utilisateur*. `ghostpass` appartient à l'organisation `stackops` : la portée ne correspond pas, `vars` rendait une chaîne vide, et le job iOS était écarté sans laisser de trace. On a cru pendant des heures avoir une CI iOS qu'on n'avait pas. Le garde exigeait `== 'true'`. Il exige maintenant `!= 'false'` : à `false` le job est écarté, dans tous les autres cas il tourne. Le défaut n'est plus le silence mais l'exécution — et si aucun runner macOS n'est allumé, le job attend au lieu de disparaître. Une file d'attente se remarque ; une absence, non. Le README dit désormais où poser la variable — sur le dépôt ou l'organisation, pas sur l'utilisateur — et le job `Tests iOS — activés ?` affiche à chaque exécution la valeur qu'il voit, pour qu'on n'ait plus à la deviner. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitea/workflows/ci.yml | 31 +++++++++++++++++++++---------- tools/ci/README.md | 23 ++++++++++++++++++----- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e9ff3b93..329911b9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -97,12 +97,15 @@ jobs: run: | valeur='${{ vars.MACOS_RUNNER }}' echo "MACOS_RUNNER = « $valeur »" - if [ "$valeur" = "true" ]; then - echo "Le job iOS est activé et sera dispatché vers un runner étiqueté « macos »." + if [ "$valeur" = "false" ]; then + echo "::warning::MACOS_RUNNER vaut « false » : les tests iOS sont volontairement écartés." elif [ -z "$valeur" ]; then - echo "::warning::MACOS_RUNNER est vide : variable absente, hors de portée, ou contexte « vars » non géré par cette version de Gitea. Les tests iOS ne tourneront pas." + 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 "::warning::MACOS_RUNNER vaut « $valeur » et non « true ». Les tests iOS ne tourneront pas." + echo "Le job iOS est activé et sera dispatché vers un runner étiqueté « macos »." fi ios: @@ -110,12 +113,20 @@ jobs: # 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). # - # La condition n'est pas un ornement. Un runner self-hosted est une machine réelle : - # éteinte, en déplacement, ou simplement occupée, elle laisserait le job en file - # d'attente et la pull request sans verdict — Gitea n'échoue pas, il attend. La - # variable de dépôt `MACOS_RUNNER` (Settings > Actions > Variables) sert d'interrupteur : - # à `true` le job tourne, sinon il est ignoré et les tests iOS se lancent à la main. - if: vars.MACOS_RUNNER == 'true' + # 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. diff --git a/tools/ci/README.md b/tools/ci/README.md index 6b0cfe78..c975abf0 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -50,10 +50,23 @@ cd ~/.gitea-runner && gitea-runner daemon # au premier plan : on voit les tâc # arriver, Ctrl-C pour rendre la machine ``` -Et l'interrupteur suit le même rythme. `MACOS_RUNNER` (**Settings > Actions > Variables**) -reste à `false` par défaut ; on le passe à `true` le temps de faire tourner la vérification. -Sinon, chaque pull request ouverte pendant que le portable est fermé attend un runner qui -ne viendra pas : Gitea n'échoue pas, il attend, et la pull request reste sans verdict. +Et l'interrupteur suit le même rythme — mais il ne sert qu'à *couper*. `MACOS_RUNNER` à +`false` écarte le job iOS ; dans tous les autres cas, y compris si la variable n'existe +pas, il tourne. On le passe donc à `false` quand le portable reste fermé un moment, pour +qu'une pull request n'attende pas un runner qui ne viendra pas : Gitea n'échoue pas, il +attend, et la pull request resterait sans verdict. + +Le sens a été inversé après coup, et la raison mérite d'être dite. L'interrupteur exigeait +d'abord `MACOS_RUNNER == 'true'`. Une variable qui ne se résout pas rend alors une chaîne +vide, le job disparaît du rapport sans un mot, et on croit avoir une CI iOS pendant des +heures sans en avoir. Une file d'attente se remarque ; une absence, non. + +**La portée compte, et c'est exactement ce qui nous est arrivé.** Une variable posée dans +*Paramètres utilisateur > Actions > Variables* ne s'applique qu'aux dépôts de cet +utilisateur. `ghostpass` appartient à l'organisation `stackops` : il faut la poser sur le +dépôt (**Paramètres du dépôt > Actions > Variables**) ou sur l'organisation. Le job +`Tests iOS — activés ?` affiche à chaque exécution la valeur qu'il voit, précisément pour +qu'on n'ait plus à le deviner. Un point à ne pas perdre de vue : un runner self-hosted **exécute le code des pull requests sur la machine qui l'héberge**. Sur un dépôt privé entre gens de confiance, c'est le @@ -69,7 +82,7 @@ Une fois le Mac mini en place, c'est l'inverse : il est fait pour attendre du tr brew services start gitea-runner # démarre à la session et se relance tout seul ``` -`MACOS_RUNNER` peut alors rester à `true` en permanence. +`MACOS_RUNNER` n'a alors plus lieu d'exister : sans elle, le job tourne. Le workflow n'a **rien à changer** au passage de l'un à l'autre : les deux machines portent la même étiquette `macos`, seul le nom diffère. Il suffit d'enregistrer le mini, From 53d68c6ce7a0ed761842890e541bfe55646bd1a9 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 22:13:34 +0200 Subject: [PATCH 28/50] =?UTF-8?q?ci:=20d=C3=A9clencher=20un=20passage=20ap?= =?UTF-8?q?r=C3=A8s=20rattachement=20du=20runner=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le runner était enregistré au niveau utilisateur ; un dépôt d'organisation ne lui envoie jamais de tâche. Réenregistré sur stackops/ghostpass. Commit vide pour vérifier la prise en charge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h From c58c852bbdf10761b1806ac64f8329985fba7605 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 22:34:32 +0200 Subject: [PATCH 29/50] =?UTF-8?q?s=C3=A9cu:=20gitleaks=20ne=20doit=20plus?= =?UTF-8?q?=20buter=20sur=20le=20vecteur=20TOTP=20de=20la=20RFC=206238?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le scan échouait sur `GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ` dans les tests iOS — base32 de « 12345678901234567890 », le vecteur publié par la norme, que la règle generic-api-key prend pour une clé à cause de son entropie. On autorise la valeur, pas le fichier : contrôle négatif fait, un faux secret planté dans apps/ios/Tests/ est toujours détecté (6 signalements sans, 7 avec). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitleaks.toml | 9 +++++++++ 1 file changed, 9 insertions(+) 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''', +] From 30b31dc7af5007977d1aa07bcf3bc01cfd913e89 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 23:39:19 +0200 Subject: [PATCH 30/50] =?UTF-8?q?ci:=20consigner=20la=20port=C3=A9e=20du?= =?UTF-8?q?=20jeton=20d'enregistrement=20et=20la=20capacit=C3=A9=20du=20ru?= =?UTF-8?q?nner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux pièges payés ce soir, tous deux silencieux. La portée : un runner enregistré depuis *Paramètres utilisateur* ne reçoit jamais de tâche d'un dépôt d'organisation. `register` répond « registered successfully », le daemon annonce son étiquette, et le job attend un exécuteur invisible. Le README donne maintenant l'URL exacte et la requête d'API qui vérifie le rattachement. La capacité : run-ios-tests.sh prend le port 3111 en dur et un simulateur nommé. Un commit déclenche deux runs — push et pull request — dont les jobs iOS se marchaient dessus. capacity: 1 les serialise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- tools/ci/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/ci/README.md b/tools/ci/README.md index c975abf0..a81f6804 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -21,6 +21,26 @@ Le jeton d'enregistrement s'obtient dans la forge : **Settings > Actions > Runne Create new runner**, au niveau du dépôt ou de l'organisation pour le partager entre les projets de la suite. +> **La portée du jeton décide de tout, et l'erreur est silencieuse.** Les pages +> *Paramètres **utilisateur** > Actions > Exécuteurs* et *Paramètres du **dépôt** > +> Actions > Exécuteurs* se ressemblent et délivrent chacune un jeton. Un runner +> enregistré avec le premier ne recevra jamais de tâche d'un dépôt appartenant à une +> organisation : `register` répond « registered successfully », le daemon annonce son +> étiquette, et le job attend indéfiniment un exécuteur que le dépôt ne voit pas. On l'a +> payé deux fois. L'URL sans ambiguïté : +> `https://git.stackops.ch/stackops/ghostpass/settings/actions/runners`, ou +> `.../org/stackops/settings/actions/runners` pour toute l'organisation. +> +> Pour vérifier plutôt que d'espérer, une fois enregistré : +> +> ```sh +> curl -sS -H "Authorization: token " \ +> https://git.stackops.ch/api/v1/repos/stackops/ghostpass/actions/runners | jq . +> ``` +> +> Une liste vide veut dire que le runner est ailleurs. (L'exécuteur Linux partagé n'y +> figure pas : il est de type *Global*, la page web agrège les portées, l'API non.) + `register` écrit un fichier `.runner` — son identité auprès de la forge — **dans le répertoire courant**, et `daemon` le cherche au même endroit. D'où un dossier à soi, plutôt qu'un fichier égaré dans le dépôt ou au fond du dossier personnel : @@ -38,6 +58,17 @@ gitea-runner register --no-interactive \ machine** plutôt que dans un conteneur : macOS n'en a pas, et le job a besoin de l'Xcode du poste, pas d'une image. +**Une tâche à la fois.** `run-ios-tests.sh` prend le port 3111 en dur, pilote un +simulateur nommé et écrit dans `apps/ios/TestResults/` : deux jobs iOS concurrents sur la +même machine se marchent dessus, et le second meurt sur « Le port 3111 est déjà occupé ». +Or un même commit en déclenche deux — celui du `push` et celui de la pull request. Il faut +donc borner le runner, dans `~/.gitea-runner/config.yaml` : + +```yaml +runner: + capacity: 1 +``` + ## En dépannage, sur un poste personnel **Pas de service qui démarre tout seul.** Un portable se ferme, voyage, change de réseau ; From c75c3143b389d1f5c4bb520419bf6a3f3fcdb116 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 23:44:15 +0200 Subject: [PATCH 31/50] =?UTF-8?q?ci:=20relancer=20avec=20les=20journaux=20?= =?UTF-8?q?de=20job=20conserv=C3=A9s=20c=C3=B4t=C3=A9=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les deux tâches précédentes se terminaient en deux minutes sans qu'aucun xcodebuild ne démarre : l'échec est en amont du script de test, et la sortie du job ne figure pas dans le journal du daemon. Le runner écrit désormais une copie de chaque log dans ~/.gitea-runner/logs/jobs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h From b5ff0aec3758d299ec9096e12c0fce71ad54ce30 Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 23:49:31 +0200 Subject: [PATCH 32/50] ci: le daemon doit avoir cargo dans son PATH, et garder les logs de job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le job s'arrêtait sur « cargo introuvable sur ce runner », étape Prérequis du poste, en deux minutes et sans qu'aucun xcodebuild ne démarre. rustup vit dans ~/.cargo/bin, ajouté au PATH par ~/.zprofile — que nohup ne charge pas. Xcode et xcodegen passaient, eux, étant dans /opt/homebrew/bin. Le diagnostic a pris trois tentatives parce que le journal du daemon ne contient que « tâche reçue » : la sortie du job n'y figure pas. Le README explique maintenant comment la conserver. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- tools/ci/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tools/ci/README.md b/tools/ci/README.md index a81f6804..b46a276b 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -81,6 +81,28 @@ cd ~/.gitea-runner && gitea-runner daemon # au premier plan : on voit les tâc # arriver, Ctrl-C pour rendre la machine ``` +**Le job hérite du `PATH` du daemon, pas de celui du shell interactif.** `rustup` +s'installe dans `~/.cargo/bin` et s'ajoute au `PATH` depuis `~/.zprofile` : un daemon +lancé par `nohup`, `launchd` ou tout ce qui ne charge pas le profil ne verra donc pas +`cargo`, et le job s'arrêtera net sur « cargo introuvable sur ce runner » — après avoir +trouvé `xcodebuild` et `xcodegen`, qui vivent dans `/opt/homebrew/bin`. En arrière-plan, +on le lui donne explicitement : + +```sh +cd ~/.gitea-runner && PATH="$HOME/.cargo/bin:$PATH" nohup gitea-runner daemon \ + >> ~/.gitea-runner/logs/daemon.log 2>&1 & +``` + +**Pour voir ce qu'un job a fait**, activer la conservation des logs dans +`~/.gitea-runner/config.yaml` — le journal du daemon ne contient que « tâche reçue », et +sans ça un échec ne laisse qu'un silence de deux minutes : + +```yaml +log: + job: + dir: "/Users//.gitea-runner/logs/jobs" +``` + Et l'interrupteur suit le même rythme — mais il ne sert qu'à *couper*. `MACOS_RUNNER` à `false` écarte le job iOS ; dans tous les autres cas, y compris si la variable n'existe pas, il tourne. On le passe donc à `false` quand le portable reste fermé un moment, pour From 00fce96b31109a6889e8534df2991155c7d9926d Mon Sep 17 00:00:00 2001 From: claravnk Date: Wed, 26 Aug 2026 23:56:40 +0200 Subject: [PATCH 33/50] ci: xcodebuild ne doit pas mourir de SIGPIPE en affichant sa version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'étape Prérequis trouvait bien les six outils, imprimait « Xcode 26.6 », puis sortait en 141. `xcodebuild -version | head -1` : head ferme le tuyau après la première ligne, xcodebuild prend un SIGPIPE, et le shell des steps tourne en `-e -o pipefail` — l'étape échoue après avoir réussi. Rien de propre au poste : GitHub Actions emploie le même shell par défaut. `sed -n 1p` lit tout le flux et ne ferme rien. Seule occurrence du motif dans .gitea/workflows et tools/. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitea/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 329911b9..3b4e3de8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -143,7 +143,10 @@ jobs: 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 - xcodebuild -version | head -1 + # `| 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 From b7a5070e993d291fd489decd9279e9139dee1bb1 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 00:21:12 +0200 Subject: [PATCH 34/50] =?UTF-8?q?feat(ffi):=20exposer=20les=20coffres=20pa?= =?UTF-8?q?rtag=C3=A9s=20et=20l'acc=C3=A8s=20d'urgence=20au=20binding=20mo?= =?UTF-8?q?bile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le cœur les implémente depuis longtemps (org.rs, sharing.rs) et le binding WASM les expose déjà ; seule la façade UniFFI s'arrêtait au périmètre v1, comme son en-tête l'annonçait. Rien de nouveau n'est écrit côté crypto : ce commit transpose l'API du WASM sans changer les noms ni les échanges, pour qu'un coffre partagé créé depuis le web s'ouvre depuis l'iPhone. Org, OrgCreation et EmergencyVault gardent leurs clés en mémoire Rust ; Swift ne manipule que des objets opaques et des blobs déjà chiffrés. Un écart assumé sur OrgCreation.org() : le WASM le protège par un « une seule fois », faute de pouvoir cloner son Org. Derrière un Arc, ce verrou ne protège rien — la clé ne traverse la frontière dans aucun des deux cas. 8 tests ajoutés aux 6 existants, dont trois négatifs : un sceau forgé par un tiers est refusé, un accès d'urgence adressé à quelqu'un d'autre ne s'ouvre pas, et l'ancien mot de passe ne vaut plus après une reprise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- crates/ghostpass-crypto-ffi/Cargo.toml | 4 + crates/ghostpass-crypto-ffi/src/lib.rs | 191 +++++++++++++++++++- crates/ghostpass-crypto-ffi/tests/facade.rs | 176 ++++++++++++++++++ 3 files changed, 367 insertions(+), 4 deletions(-) diff --git a/crates/ghostpass-crypto-ffi/Cargo.toml b/crates/ghostpass-crypto-ffi/Cargo.toml index e3337d21..6695c575 100644 --- a/crates/ghostpass-crypto-ffi/Cargo.toml +++ b/crates/ghostpass-crypto-ffi/Cargo.toml @@ -18,6 +18,10 @@ uniffi = { version = "0.29", features = ["cli"] } serde_json = "1.0.150" base64 = "0.22.1" thiserror = "2.0.18" +# Mêmes versions que le cœur et que le binding WASM : `PublicKey` traverse la frontière +# entre les crates, deux versions de crypto_box donneraient deux types incompatibles. +crypto_box = "0.9.1" +zeroize = "1.9" [[bin]] name = "uniffi-bindgen" diff --git a/crates/ghostpass-crypto-ffi/src/lib.rs b/crates/ghostpass-crypto-ffi/src/lib.rs index ee8fb87a..3dc7efa6 100644 --- a/crates/ghostpass-crypto-ffi/src/lib.rs +++ b/crates/ghostpass-crypto-ffi/src/lib.rs @@ -6,14 +6,16 @@ //! traverse la frontière FFI, donc rien de sensible ne peut atterrir dans un log Swift, //! un crash report ou une capture d'écran de débogueur. //! -//! Périmètre : cycle de vie du compte, coffre personnel et passkey — ce dont l'application -//! v1 a besoin. Le partage d'organisation et l'accès d'urgence existent dans le cœur et -//! dans le binding WASM ; ils seront ajoutés ici quand l'application les exposera, pas avant. +//! Périmètre : cycle de vie du compte, coffre personnel, passkey, coffres partagés et accès +//! d'urgence. Les deux derniers sont transposés du binding WASM sans en changer les noms ni +//! les échanges, pour qu'un coffre partagé écrit depuis le web s'ouvre depuis l'iPhone. use base64::engine::general_purpose::STANDARD; use base64::Engine; -use ghostpass_crypto::{keys, vault, EncString, EncryptedItem, KdfParams, VaultItem}; +use crypto_box::PublicKey; +use ghostpass_crypto::{keys, org, sharing, vault, EncString, EncryptedItem, KdfParams, VaultItem}; use std::sync::Arc; +use zeroize::Zeroizing; uniffi::setup_scaffolding!(); @@ -39,6 +41,11 @@ fn decode_key_32(b64: &str) -> Result<[u8; 32], GhostpassError> { }) } +/// Décode une clé publique de partage (X25519, 32 octets) depuis sa représentation base64. +fn decode_public_key(b64: &str) -> Result { + Ok(PublicKey::from(decode_key_32(b64)?)) +} + /// Paramètres KDF par défaut (JSON), à stocker avec le compte côté serveur. #[uniffi::export] pub fn default_kdf_params() -> Result { @@ -217,3 +224,179 @@ impl Account { Ok(wrapped.to_string()) } } + +// ─── Partage / organisations ─────────────────────────────────────────────── + +/// Contexte d'une organisation : détient l'Org Key en mémoire Rust, jamais exposée à Swift. +#[derive(uniffi::Object)] +pub struct Org { + org_key: Zeroizing<[u8; 32]>, +} + +#[uniffi::export] +impl Org { + /// Chiffre un item (JSON `VaultItem`) sous l'Org Key. Renvoie un JSON `EncryptedItem`. + pub fn encrypt_item(&self, item_json: String) -> Result { + let item: VaultItem = serde_json::from_str(&item_json).map_err(err)?; + let enc = vault::encrypt_item(&self.org_key, &item).map_err(err)?; + serde_json::to_string(&enc).map_err(err) + } + + /// Déchiffre un JSON `EncryptedItem` sous l'Org Key. Renvoie le JSON `VaultItem`. + pub fn decrypt_item(&self, encrypted_item_json: String) -> Result { + let enc: EncryptedItem = serde_json::from_str(&encrypted_item_json).map_err(err)?; + let item = vault::decrypt_item(&self.org_key, &enc).map_err(err)?; + serde_json::to_string(&item).map_err(err) + } + + /// Ré-enveloppe un item d'une ancienne Org Key vers celle-ci (rotation / révocation), + /// **sans** déchiffrer le contenu. + pub fn rewrap_item( + &self, + old_org: Arc, + encrypted_item_json: String, + ) -> Result { + let enc: EncryptedItem = serde_json::from_str(&encrypted_item_json).map_err(err)?; + let rewrapped = + vault::rewrap_item_key(&old_org.org_key, &self.org_key, &enc).map_err(err)?; + serde_json::to_string(&rewrapped).map_err(err) + } +} + +/// Résultat de la création d'une org : le contexte `Org` et l'Org Key scellée pour le créateur, +/// à stocker côté serveur comme entrée du membre-admin. +/// +/// Le binding WASM protège son accesseur par un « ne peut être appelé qu'une fois », parce que +/// son `Org` n'est pas clonable. Ici l'objet est derrière un `Arc` : le partager ne duplique pas +/// la clé, qui ne quitte de toute façon jamais le Rust. Le verrou n'aurait rien protégé. +#[derive(uniffi::Object)] +pub struct OrgCreation { + org: Arc, + sealed_for_self: String, +} + +#[uniffi::export] +impl OrgCreation { + /// Org Key scellée pour le créateur (base64), à transmettre au serveur. + pub fn sealed_for_self(&self) -> String { + self.sealed_for_self.clone() + } + + /// Contexte `Org` correspondant. + pub fn org(&self) -> Arc { + Arc::clone(&self.org) + } +} + +/// Coffre d'un donneur ouvert par son contact de confiance, le temps d'un accès d'urgence. +#[derive(uniffi::Object)] +pub struct EmergencyVault { + user_key: Zeroizing<[u8; 32]>, +} + +#[uniffi::export] +impl EmergencyVault { + /// Lecture : déchiffre un item du coffre du donneur avec son USK récupéré. + pub fn decrypt_item(&self, encrypted_item_json: String) -> Result { + let enc: EncryptedItem = serde_json::from_str(&encrypted_item_json).map_err(err)?; + let item = vault::decrypt_item(&self.user_key, &enc).map_err(err)?; + serde_json::to_string(&item).map_err(err) + } + + /// Reprise : prépare la réinitialisation du mot de passe maître du donneur à partir de son + /// USK récupéré. Renvoie un JSON `{ master_password_hash, encrypted_user_key }`. + pub fn takeover( + &self, + grantor_email: String, + kdf_params_json: String, + new_password: String, + ) -> Result { + let params: KdfParams = serde_json::from_str(&kdf_params_json).map_err(err)?; + params.ensure_strong().map_err(err)?; + let reset = keys::takeover_reset( + &self.user_key, + &grantor_email, + new_password.as_bytes(), + params, + ) + .map_err(err)?; + serde_json::to_string(&reset).map_err(err) + } +} + +#[uniffi::export] +impl Account { + /// Crée une organisation : génère une Org Key et la scelle, de façon authentifiée, pour soi. + pub fn create_org(&self) -> Result, GhostpassError> { + let org_key = org::generate_org_key(); + let sealed = sharing::box_seal(&self.keys.secret_key, &self.keys.public_key, &org_key) + .map_err(err)?; + Ok(Arc::new(OrgCreation { + org: Arc::new(Org { + org_key: Zeroizing::new(org_key), + }), + sealed_for_self: STANDARD.encode(sealed), + })) + } + + /// Ouvre une Org Key reçue d'un admin, **en vérifiant qu'elle provient de sa clé publique**. + /// Sans cette vérification, un serveur actif pourrait substituer une Org Key de son choix. + pub fn open_org( + &self, + admin_public_key: String, + sealed: String, + ) -> Result, GhostpassError> { + let admin_public = decode_public_key(&admin_public_key)?; + let sealed_bytes = STANDARD.decode(sealed).map_err(err)?; + let org_key = + org::open_org_key(&self.keys.secret_key, &admin_public, &sealed_bytes).map_err(err)?; + Ok(Arc::new(Org { + org_key: Zeroizing::new(org_key), + })) + } + + /// Scelle l'Org Key pour un membre, en tant qu'admin émetteur. Renvoie le blob base64. + pub fn seal_org_key_for_member( + &self, + org: Arc, + member_public_key: String, + ) -> Result { + let member_public = decode_public_key(&member_public_key)?; + let sealed = + org::seal_org_key_for_member(&self.keys.secret_key, &member_public, &org.org_key) + .map_err(err)?; + Ok(STANDARD.encode(sealed)) + } + + /// Scelle l'USK du compte pour un contact de confiance (accès d'urgence), de façon + /// authentifiée. Le blob part au serveur et n'est ouvrable qu'avec la clé privée du contact. + pub fn seal_user_key_for(&self, contact_public_key: String) -> Result { + let contact_public = decode_public_key(&contact_public_key)?; + let sealed = sharing::box_seal( + &self.keys.secret_key, + &contact_public, + self.keys.user_key.as_slice(), + ) + .map_err(err)?; + Ok(STANDARD.encode(sealed)) + } + + /// (Contact) Ouvre un accès d'urgence reçu d'un donneur : récupère son USK en mémoire Rust, + /// en vérifiant que le blob provient bien de la clé publique du donneur. + pub fn open_emergency( + &self, + grantor_public_key: String, + sealed: String, + ) -> Result, GhostpassError> { + let grantor_public = decode_public_key(&grantor_public_key)?; + let sealed_bytes = STANDARD.decode(sealed).map_err(err)?; + let opened = sharing::box_open(&self.keys.secret_key, &grantor_public, &sealed_bytes) + .map_err(err)?; + let arr: [u8; 32] = opened.try_into().map_err(|_| GhostpassError::Crypto { + message: "USK d'urgence invalide".to_string(), + })?; + Ok(Arc::new(EmergencyVault { + user_key: Zeroizing::new(arr), + })) + } +} diff --git a/crates/ghostpass-crypto-ffi/tests/facade.rs b/crates/ghostpass-crypto-ffi/tests/facade.rs index 64eb0d4b..61c50802 100644 --- a/crates/ghostpass-crypto-ffi/tests/facade.rs +++ b/crates/ghostpass-crypto-ffi/tests/facade.rs @@ -7,6 +7,7 @@ use serde_json::Value; const EMAIL: &str = "clara@stackops.ch"; const PASSWORD: &str = "correct horse battery staple"; +const NOUVEAU: &str = "un tout autre mot de passe, bien assez long"; fn item_json() -> String { serde_json::json!({ @@ -125,3 +126,178 @@ fn base64_secret() -> String { use base64::{engine::general_purpose::STANDARD, Engine}; STANDARD.encode([7u8; 32]) } + +// ─── Coffres partagés ─── + +/// Deux comptes distincts, comme deux membres d'une équipe. +fn deux_comptes() -> (std::sync::Arc, std::sync::Arc) { + let admin = register(PASSWORD.into(), EMAIL.into()).unwrap().account(); + let membre = register( + "un autre mot de passe long".into(), + "kevin@stackops.ch".into(), + ) + .unwrap() + .account(); + (admin, membre) +} + +#[test] +fn un_membre_ouvre_le_coffre_partage_que_l_admin_lui_a_scelle() { + let (admin, membre) = deux_comptes(); + + let creation = admin.create_org().unwrap(); + let chiffre = creation.org().encrypt_item(item_json()).unwrap(); + + let scelle = admin + .seal_org_key_for_member(creation.org(), membre.public_key()) + .unwrap(); + let org_du_membre = membre.open_org(admin.public_key(), scelle).unwrap(); + + let clair: Value = serde_json::from_str(&org_du_membre.decrypt_item(chiffre).unwrap()).unwrap(); + assert_eq!(clair["data"]["data"]["password"], "s3cr3t"); +} + +#[test] +fn l_admin_retrouve_son_propre_coffre_par_le_sceau_qu_il_s_est_adresse() { + let admin = register(PASSWORD.into(), EMAIL.into()).unwrap().account(); + let creation = admin.create_org().unwrap(); + let chiffre = creation.org().encrypt_item(item_json()).unwrap(); + + // Le serveur ne stocke que `sealed_for_self` : au prochain déverrouillage, c'est par lui + // que l'admin retrouve l'Org Key, exactement comme un membre ordinaire. + let org = admin + .open_org(admin.public_key(), creation.sealed_for_self()) + .unwrap(); + assert!(org.decrypt_item(chiffre).is_ok()); +} + +#[test] +fn un_sceau_forge_par_un_tiers_est_refuse() { + let (admin, membre) = deux_comptes(); + let intrus = register( + "encore un mot de passe long".into(), + "intrus@ailleurs.test".into(), + ) + .unwrap() + .account(); + + // L'intrus scelle SA propre Org Key vers le membre : techniquement valide, mais elle ne + // vient pas de l'admin. C'est ce qu'un serveur actif tenterait pour lire le coffre. + let creation = intrus.create_org().unwrap(); + let contrefacon = intrus + .seal_org_key_for_member(creation.org(), membre.public_key()) + .unwrap(); + + assert!(membre.open_org(admin.public_key(), contrefacon).is_err()); +} + +#[test] +fn apres_rotation_l_ancienne_org_key_ne_lit_plus_les_items_reenveloppes() { + let admin = register(PASSWORD.into(), EMAIL.into()).unwrap().account(); + let ancienne = admin.create_org().unwrap().org(); + let chiffre = ancienne.encrypt_item(item_json()).unwrap(); + + let nouvelle = admin.create_org().unwrap().org(); + let reenveloppe = nouvelle + .rewrap_item(ancienne.clone(), chiffre.clone()) + .unwrap(); + + // La nouvelle clé lit l'item ré-enveloppé, l'ancienne non : c'est la révocation. + assert!(nouvelle.decrypt_item(reenveloppe.clone()).is_ok()); + assert!(ancienne.decrypt_item(reenveloppe).is_err()); + // Le contenu n'a pas été re-chiffré, seule l'enveloppe de la clé a changé. + let a: Value = serde_json::from_str(&chiffre).unwrap(); + let b: Value = serde_json::from_str(&nouvelle.rewrap_item(ancienne, chiffre).unwrap()).unwrap(); + assert_eq!(a["encrypted_data"], b["encrypted_data"]); +} + +// ─── Accès d'urgence ─── + +#[test] +fn le_contact_de_confiance_lit_le_coffre_du_donneur() { + let (donneur, contact) = deux_comptes(); + let chiffre = donneur.encrypt_item(item_json()).unwrap(); + + let scelle = donneur.seal_user_key_for(contact.public_key()).unwrap(); + let coffre = contact + .open_emergency(donneur.public_key(), scelle) + .unwrap(); + + let clair: Value = serde_json::from_str(&coffre.decrypt_item(chiffre).unwrap()).unwrap(); + assert_eq!(clair["name"], "Serveur de test"); +} + +#[test] +fn un_acces_d_urgence_adresse_a_quelqu_un_d_autre_ne_s_ouvre_pas() { + let (donneur, contact) = deux_comptes(); + let tiers = register( + "un mot de passe bien assez long".into(), + "tiers@ailleurs.test".into(), + ) + .unwrap() + .account(); + + let scelle = donneur.seal_user_key_for(contact.public_key()).unwrap(); + assert!(tiers.open_emergency(donneur.public_key(), scelle).is_err()); +} + +#[test] +fn la_reprise_ouvre_le_coffre_du_donneur_avec_un_nouveau_mot_de_passe() { + // On garde le blob d'inscription du donneur : la reprise ne ré-enveloppe que l'USK, la + // clé privée de partage reste celle d'origine et doit venir du même compte. + let inscription = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let donneur = inscription.account(); + let (kdf, _, epk) = blob_fields(&inscription.blob()); + let chiffre = donneur.encrypt_item(item_json()).unwrap(); + + let contact = register( + "un autre mot de passe long".into(), + "kevin@stackops.ch".into(), + ) + .unwrap() + .account(); + let scelle = donneur.seal_user_key_for(contact.public_key()).unwrap(); + let coffre = contact + .open_emergency(donneur.public_key(), scelle) + .unwrap(); + + let reset: Value = serde_json::from_str( + &coffre + .takeover(EMAIL.into(), kdf.clone(), NOUVEAU.into()) + .unwrap(), + ) + .unwrap(); + let euk = reset["encrypted_user_key"].as_str().unwrap().to_string(); + + let repris = Account::unlock(NOUVEAU.into(), EMAIL.into(), kdf, euk, epk).unwrap(); + let clair: Value = serde_json::from_str(&repris.decrypt_item(chiffre).unwrap()).unwrap(); + assert_eq!(clair["data"]["data"]["password"], "s3cr3t"); +} + +#[test] +fn l_ancien_mot_de_passe_du_donneur_ne_vaut_plus_apres_la_reprise() { + let inscription = register(PASSWORD.into(), EMAIL.into()).unwrap(); + let donneur = inscription.account(); + let (kdf, _, epk) = blob_fields(&inscription.blob()); + + let contact = register( + "un autre mot de passe long".into(), + "kevin@stackops.ch".into(), + ) + .unwrap() + .account(); + let scelle = donneur.seal_user_key_for(contact.public_key()).unwrap(); + let coffre = contact + .open_emergency(donneur.public_key(), scelle) + .unwrap(); + + let reset: Value = serde_json::from_str( + &coffre + .takeover(EMAIL.into(), kdf.clone(), NOUVEAU.into()) + .unwrap(), + ) + .unwrap(); + let euk = reset["encrypted_user_key"].as_str().unwrap().to_string(); + + assert!(Account::unlock(PASSWORD.into(), EMAIL.into(), kdf, euk, epk).is_err()); +} From 800e4fd51acce9cf4f6c23f60c180fb672478e67 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 01:11:33 +0200 Subject: [PATCH 35/50] =?UTF-8?q?feat(ios):=20acc=C3=A8s=20d'urgence=20?= =?UTF-8?q?=E2=80=94=20confier=20son=20coffre=20=C3=A0=20un=20contact=20de?= =?UTF-8?q?=20confiance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le flux complet, dans les deux sens : désigner un contact, accepter, demander l'accès, refuser pendant le délai, consulter, reprendre le compte. Le délai est le cœur du dispositif. Le contact demande, 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. C'est le serveur qui compte les jours — l'application ne fait que les afficher, et ne décide jamais de la disponibilité elle-même. Le scellement se fait en local, vers la clé publique du contact récupérée par /api/users/lookup : le serveur ne transporte qu'un blob qu'il ne peut pas ouvrir. ItemDetailView gagne un vrai mode lecture seule, et pas seulement un bouton caché : il cherchait l'entrée dans *notre* coffre par identifiant, si bien que sur un coffre confié le bouton favori aurait écrit chez nous pour l'identifiant d'un autre. DechiffreurDItems factorise l'ouverture d'un item entre Account et EmergencyVault plutôt que de réécrire la construction de l'enveloppe. 8 tests de contrat, dont ceux qui écartent un rôle ou un état inconnus du serveur — mieux vaut ne rien afficher qu'afficher n'importe quoi — et celui qui vérifie que les horodatages sont lus en millisecondes. 46 clés traduites, 261 au total, aucune sans anglais. Les organisations attendront : leur surface serveur est un système de permissions complet (collections, groupes, droits), et la livrer à moitié donnerait une fonctionnalité qui a l'air de marcher. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- Cargo.lock | 2 + .../Ghostpass/Resources/Localizable.xcstrings | 460 ++++++++++++++++++ apps/ios/Ghostpass/Services/APIClient.swift | 100 ++++ .../Ghostpass/Services/EmergencyAccess.swift | 100 ++++ apps/ios/Ghostpass/Services/VaultStore.swift | 132 +++++ .../Ghostpass/Views/EmergencyInviteView.swift | 126 +++++ .../Views/EmergencyTakeoverView.swift | 111 +++++ .../Ghostpass/Views/EmergencyVaultView.swift | 109 +++++ apps/ios/Ghostpass/Views/EmergencyView.swift | 253 ++++++++++ apps/ios/Ghostpass/Views/ItemDetailView.swift | 36 +- apps/ios/Ghostpass/Views/VaultListView.swift | 8 + apps/ios/Tests/ContractTests.swift | 77 +++ 12 files changed, 1501 insertions(+), 13 deletions(-) create mode 100644 apps/ios/Ghostpass/Services/EmergencyAccess.swift create mode 100644 apps/ios/Ghostpass/Views/EmergencyInviteView.swift create mode 100644 apps/ios/Ghostpass/Views/EmergencyTakeoverView.swift create mode 100644 apps/ios/Ghostpass/Views/EmergencyVaultView.swift create mode 100644 apps/ios/Ghostpass/Views/EmergencyView.swift diff --git a/Cargo.lock b/Cargo.lock index b6c45626..451731f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,10 +518,12 @@ name = "ghostpass-crypto-ffi" version = "0.0.0" dependencies = [ "base64", + "crypto_box", "ghostpass-crypto", "serde_json", "thiserror", "uniffi", + "zeroize", ] [[package]] diff --git a/apps/ios/Ghostpass/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings index 7101e440..cbfbdf69 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -58,6 +58,26 @@ } } }, + "%@ 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": { @@ -138,6 +158,66 @@ } } }, + "Accepter": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept" + } + } + } + }, + "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 %@." + } + } + } + }, "Activer": { "localizations": { "en": { @@ -398,6 +478,36 @@ } } }, + "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." + } + } + } + }, "Chiffres": { "localizations": { "en": { @@ -418,6 +528,16 @@ } } }, + "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": { @@ -568,6 +688,16 @@ } } }, + "Coffres qui me sont confiés": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vaults entrusted to me" + } + } + } + }, "Compte": { "localizations": { "en": { @@ -578,6 +708,36 @@ } } }, + "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": { @@ -708,6 +868,36 @@ } } }, + "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": { @@ -738,6 +928,16 @@ } } }, + "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": { @@ -998,6 +1198,26 @@ } } }, + "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": { @@ -1038,6 +1258,16 @@ } } }, + "Invitation envoyée": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invitation sent" + } + } + } + }, "La clé de récupération est la seule issue. Notez-la et rangez-la ailleurs que dans ce coffre.": { "localizations": { "en": { @@ -1098,6 +1328,56 @@ } } }, + "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": { @@ -1148,6 +1428,16 @@ } } }, + "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": { @@ -1168,6 +1458,36 @@ } } }, + "Lecture seule": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View only" + } + } + } + }, + "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": { @@ -1208,6 +1528,16 @@ } } }, + "Mes contacts de confiance": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "My trusted contacts" + } + } + } + }, "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": { @@ -1408,6 +1738,16 @@ } } }, + "Nouveau mot de passe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New password" + } + } + } + }, "Nouveau mot de passe maître": { "localizations": { "en": { @@ -1478,6 +1818,26 @@ } } }, + "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": { @@ -1488,6 +1848,26 @@ } } }, + "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." + } + } + } + }, "Plus tard": { "localizations": { "en": { @@ -1528,6 +1908,16 @@ } } }, + "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": { @@ -1538,6 +1928,16 @@ } } }, + "Refuser": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deny" + } + } + } + }, "Remplacé": { "localizations": { "en": { @@ -1558,6 +1958,26 @@ } } }, + "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": { @@ -1568,6 +1988,36 @@ } } }, + "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": { @@ -2058,6 +2508,16 @@ } } }, + "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." + } + } + } + }, "Vérification…": { "localizations": { "en": { diff --git a/apps/ios/Ghostpass/Services/APIClient.swift b/apps/ios/Ghostpass/Services/APIClient.swift index db29bbd8..28ab42e8 100644 --- a/apps/ios/Ghostpass/Services/APIClient.swift +++ b/apps/ios/Ghostpass/Services/APIClient.swift @@ -69,6 +69,53 @@ private struct ServerError: Decodable { 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 +} + /// 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 { @@ -214,4 +261,57 @@ struct APIClient { 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) + } } 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/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 9babd686..42c8f029 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -747,4 +747,136 @@ final class VaultStore: ObservableObject { } 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 + } + } +} + +/// 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" + } } 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/ItemDetailView.swift b/apps/ios/Ghostpass/Views/ItemDetailView.swift index 95ea6b3e..2ca525fc 100644 --- a/apps/ios/Ghostpass/Views/ItemDetailView.swift +++ b/apps/ios/Ghostpass/Views/ItemDetailView.swift @@ -3,7 +3,10 @@ import SwiftUI /// Le détail d'un élément du coffre. struct ItemDetailView: View { let entry: VaultEntry - var onEdit: () -> Void + 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 @@ -20,7 +23,10 @@ struct ItemDetailView: View { /// 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 { store.entries.first { $0.id == entry.id } ?? entry } + private var live: VaultEntry { + guard !lectureSeule else { return entry } + return store.entries.first { $0.id == entry.id } ?? entry + } var body: some View { GhostScreen { @@ -57,18 +63,22 @@ struct ItemDetailView: View { .navigationTitle(Text(verbatim: live.item.name)) .navigationBarTitleDisplayMode(.inline) .toolbar { - 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) + if !lectureSeule { + Button { + Task { await store.toggleFavorite(live) } + } label: { + Image(systemName: store.isFavorite(live) ? "star.fill" : "star") + } .foregroundStyle(Color.gpAccentText) - .accessibilityIdentifier("button.edit") + .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) diff --git a/apps/ios/Ghostpass/Views/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 5e8533f8..6136685e 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -123,6 +123,10 @@ struct VaultListView: View { sheet = .recoveryKey } .accessibilityIdentifier("button.recoveryKey") + Button("Accès d'urgence", systemImage: "person.2.badge.key") { + sheet = .emergency + } + .accessibilityIdentifier("button.emergency") Button("Importer un CSV", systemImage: "square.and.arrow.down") { sheet = .importCSV } @@ -179,6 +183,8 @@ struct VaultListView: View { ImportView().environmentObject(store) case .exportCSV: ExportView().environmentObject(store) + case .emergency: + EmergencyView().environmentObject(store) } } // Une seule alerte, et rien d'autre par-dessus : deux modificateurs `.alert` @@ -359,6 +365,7 @@ enum VaultSheet: Identifiable { case recoveryKey case importCSV case exportCSV + case emergency var id: String { switch self { @@ -371,6 +378,7 @@ enum VaultSheet: Identifiable { case .biometricOffer: return "biometricOffer" case .health: return "health" case .recoveryKey: return "recoveryKey" + case .emergency: return "emergency" case .importCSV: return "import" case .exportCSV: return "export" } diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index ece4643d..2b8f5fe4 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -1126,3 +1126,80 @@ final class VerrouillageTests: XCTestCase { 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)") + } + } +} From df2fedf1eb4fd8f169b21dbae71981347e55e091 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 09:39:34 +0200 Subject: [PATCH 36/50] =?UTF-8?q?test(ios):=20=C3=A9prouver=20l'acc=C3=A8s?= =?UTF-8?q?=20d'urgence,=20et=20rendre=20test02=20capable=20d'=C3=A9chouer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test07Urgence confie l'accès à un contact puis le retire. Ce qu'il prouve n'est pas visible à l'écran : confier l'accès enchaîne trois opérations invisibles — recherche de la clé publique du contact, scellement de l'USK vers elle par le cœur Rust, dépôt d'un blob que le serveur ne peut pas ouvrir. Le seul témoin de leur bon enchaînement est le contact qui apparaît dans la liste. Le harnais amorce un second compte, sans coffre : sceller vers quelqu'un suppose que ce quelqu'un existe déjà, et c'est SA clé publique qui protège notre secret. test02Biometrie ouvrait le menu par un tap direct, alors que le projet a un helper écrit pour ce cas — un tap sur une barre encore en cours de mise en page rend un point {-1, -1} et se perd. Le menu ne s'ouvrant pas, aucun bouton n'existait, et le test en concluait « l'application ne voit pas de biométrie ». Le journal dit le contraire : l'app propose l'inscription et affiche l'entrée d'activation. Le test ne testait rien et l'expliquait avec assurance. L'ouverture du menu est désormais extraite dans deplierLeMenu, qui atteste son succès sur « Santé du coffre » — toujours présente, quelle que soit la biométrie. L'absence d'entrée biométrique ne devient une conclusion qu'ensuite. test02 pourra donc échouer, ce qu'il n'a jamais pu faire. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/UITests/VaultFlowTests.swift | 97 ++++++++++++++++++++++----- tools/ios/run-ios-tests.sh | 24 ++++++- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index 191e8102..9530dec6 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -20,6 +20,9 @@ final class VaultFlowTests: XCTestCase { 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 @@ -169,22 +172,29 @@ final class VaultFlowTests: XCTestCase { /// 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] - // 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. - var ouvert = false for _ in 0..<4 { taper(menu) - if entree.waitForExistence(timeout: 5) { - ouvert = true - break - } + if entree.waitForExistence(timeout: 5) { return true } } - XCTAssertTrue(ouvert, "« \(identifiant) » absent du menu") - entree.tap() + return false } /// La luminance moyenne d'une capture, entre 0 et 1. @@ -487,9 +497,11 @@ final class VaultFlowTests: XCTestCase { // 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 ». - let reglages = app.buttons["button.settings"] - XCTAssertTrue(reglages.waitForExistence(timeout: 30), "menu des réglages absent") - reglages.tap() + // « 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 @@ -511,10 +523,10 @@ final class VaultFlowTests: XCTestCase { // Déjà active : refermer le menu sans y toucher. app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap() } else { - // Le menu ne propose rien : l'application ne voit aucune biométrie, alors même - // que le runner en voyait une. L'inscription simulée du simulateur ne survit - // pas toujours au recyclage que fait xcodebuild entre deux invocations. On - // s'arrête là plutôt que de rendre vert un chemin qu'on n'a pas exercé. + // 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 — " @@ -682,6 +694,57 @@ final class VaultFlowTests: XCTestCase { 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( diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh index a8817dcf..791144dc 100755 --- a/tools/ios/run-ios-tests.sh +++ b/tools/ios/run-ios-tests.sh @@ -22,6 +22,11 @@ IOS="$ROOT/apps/ios" PORT=3111 EMAIL="clara@ghostpass.test" PASSWORD="correct horse battery staple" +# Un second compte, sans coffre : c'est le contact de confiance de l'accès d'urgence. +# Sceller une clé vers quelqu'un suppose que ce quelqu'un existe déjà côté serveur — sa +# clé publique est la seule chose qui protège la nôtre. +CONTACT_EMAIL="kevin@ghostpass.test" +CONTACT_PASSWORD="un tout autre mot de passe de test" SERVER_URL="http://127.0.0.1:$PORT" UNIT_ONLY=false [[ "${1:-}" == "--unit-only" ]] && UNIT_ONLY=true @@ -216,6 +221,22 @@ for item in seed["items"]: print(f" compte créé, {len(seed['items'])} items déposés") PY +say "Amorçage du contact de confiance (compte seul, sans coffre)" +cargo run -q -p ghostpass-crypto-ffi --example seed-vault -- \ + "$CONTACT_EMAIL" "$CONTACT_PASSWORD" >"$WORK/contact.json" +SERVER_URL="$SERVER_URL" python3 - "$WORK/contact.json" <<'CONTACT' +import json, os, sys, urllib.request +base = os.environ["SERVER_URL"] +seed = json.load(open(sys.argv[1])) +req = urllib.request.Request( + base + "/api/auth/register", + data=json.dumps(seed["registration"]).encode(), + headers={"Content-Type": "application/json"}) +with urllib.request.urlopen(req) as r: + r.read() +print(" contact créé") +CONTACT + # ── Parcours de bout en bout ────────────────────────────────────────────────── say "Parcours de bout en bout" # Pas d'environnement à passer : `xcodebuild` n'en propage aucun jusqu'au processus de @@ -224,7 +245,8 @@ say "Parcours de bout en bout" if ! run_tests GhostpassUITests/VaultFlowTests/test01ParcoursComplet \ -only-testing:GhostpassUITests/VaultFlowTests/test02Biometrie \ -only-testing:GhostpassUITests/VaultFlowTests/test05Preferences \ - -only-testing:GhostpassUITests/VaultFlowTests/test06Recuperation; then + -only-testing:GhostpassUITests/VaultFlowTests/test06Recuperation \ + -only-testing:GhostpassUITests/VaultFlowTests/test07Urgence; then echo "--- journal de l'application ---" >&2 xcrun simctl spawn "$DEVICE" log show --last 15m --style compact \ --predicate 'process == "Ghostpass"' 2>/dev/null | grep -a "GP-" | tail -25 >&2 || From e3e97214414cfea650d6f807ce92aa9a4cf94cb0 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 10:21:04 +0200 Subject: [PATCH 37/50] =?UTF-8?q?fix(ios):=20test02=20repose=20la=20biom?= =?UTF-8?q?=C3=A9trie=20qu'il=20vient=20d'activer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réparé hier, il s'exécutait enfin — et emportait test05, test06 et test07, tous en échec sur « ni formulaire de connexion, ni bascule vers un autre compte ». La cause n'était pas dans les tests tombés : test02 active Face ID et le laissait actif, si bien que l'application se rouvrait seule et qu'il n'y avait plus de formulaire à remplir. Une dépendance d'ordre entre tests, masquée des mois durant par un test qui ne faisait rien. Il repose maintenant la machine comme test06 repose le mot de passe. Suite complète verte : cinq parcours sur cinq, un seul saut restant (test04Remplissage, extension Safari). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/UITests/VaultFlowTests.swift | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index 9530dec6..1ed602e9 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -541,6 +541,33 @@ final class VaultFlowTests: XCTestCase { 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 From c607d3babbb4a2cffae5b16126d0d44d62d53ab6 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 10:21:21 +0200 Subject: [PATCH 38/50] =?UTF-8?q?feat(da):=20GhostPass=20passe=20au=20bleu?= =?UTF-8?q?=20n=C3=A9on=20de=20la=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'application iOS était violette alors que sa marque est bleue — et le violet est celui de ghostbit. L'origine se lit dans l'en-tête du thème : « transposé de ghostcal/frontend/src/app/globals.css », dont il a repris la structure sans reprendre la teinte. Relevé des dépôts avant de trancher : violet ghostbit, lime ghostmon, orange ghostboard, menthe ghostauth, cyan ghostcal, jaune ghostmail. Le bleu #2E7DFF est le seul créneau froid libre, et c'est déjà celui du logo. Base Dracula commune (#21222C, #282A36, #F8F8F2, #8B9CC8), sémantiques Dracula (#FF5555, #50FA7B), fond en dégradé à quatre arrêts comme le reste de la suite. Le néon n'est pas la couleur mais son rayonnement : deux halos superposés — un noyau serré et vif, une aura large et discrète. Un seul ferait une tache molle. Le modificateur .neon() transpose le --brand-glow partagé et réduit son rayon de moitié en thème clair, où un néon sur fond blanc devient une bavure. Appliqué au logo et à l'action principale, jamais à un bouton désactivé : faire luire ce sur quoi on ne peut pas appuyer appellerait l'œil au mauvais endroit. Le web reçoit les mêmes jetons sous les mêmes noms que ghostbit et ghostmon, de sorte qu'un futur portage soit un copier-coller et non une traduction. Vérifié : iOS compile, suite complète verte — dont test05Preferences, qui juge les thèmes à la luminance des captures et non à l'état d'une case. Le du web n'a pas pu être exercé ici (wasm-pack absent du poste, le paquet WASM que la web app importe n'existe donc pas) ; le CSS est analysé valide, 235 règles, et la CI construira le reste. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/Ghostpass/Theme/Theme.swift | 106 +++++++++++++++++----- apps/ios/Ghostpass/Views/UnlockView.swift | 3 + apps/web/src/app.css | 69 +++++++++----- 3 files changed, 136 insertions(+), 42 deletions(-) diff --git a/apps/ios/Ghostpass/Theme/Theme.swift b/apps/ios/Ghostpass/Theme/Theme.swift index 1c9f2315..62ed1533 100644 --- a/apps/ios/Ghostpass/Theme/Theme.swift +++ b/apps/ios/Ghostpass/Theme/Theme.swift @@ -1,40 +1,54 @@ import SwiftUI -/// Le système visuel de la suite, transposé de `ghostcal/frontend/src/app/globals.css`. +/// Le système visuel néon de la suite, partagé avec `ghostbit`, `ghostmon`, `ghostboard`, +/// `ghostauth` et `ghostcal`. /// -/// Les produits partagent la même structure — nuit profonde, surfaces de verre fumé, -/// halo coloré diffusé depuis le haut — et ne se distinguent que par leur accent, qui est -/// la teinte de leur logo. Pour GhostPass, c'est le violet : `#7B4DFF`, et sa variante -/// claire `#B79CFF` pour ce qui doit rester lisible sur fond sombre. +/// 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: 0x0B0F19, clair: 0xF5F8FC) + static let gpBase = adaptative(sombre: 0x21222C, clair: 0xF5F8FC) /// Cartes et lignes. - static let gpSurface = adaptative(sombre: 0x1E293B, clair: 0xFFFFFF) + static let gpSurface = adaptative(sombre: 0x282A36, clair: 0xFFFFFF) /// Champs et surfaces imbriquées. - static let gpSurface2 = adaptative(sombre: 0x161E2E, clair: 0xEEF3F9) + static let gpSurface2 = adaptative(sombre: 0x323445, clair: 0xEEF3F9) /// Texte principal. - static let gpInk = adaptative(sombre: 0xE2E8F0, clair: 0x0F1B2D) + static let gpInk = adaptative(sombre: 0xF8F8F2, clair: 0x0F1B2D) /// Texte secondaire. - static let gpMuted = adaptative(sombre: 0x94A3B8, clair: 0x5B6B82) + 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: 0x7B4DFF, clair: 0x5B2FD0) - /// Accent en texte ou en icône. Sur fond de nuit, le violet plein manque de clarté : - /// c'est la teinte haute du logo qui prend le relais. - static let gpAccentText = adaptative(sombre: 0xB79CFF, clair: 0x5B2FD0) + 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)) - static let gpDanger = adaptative(sombre: 0xFB7185, clair: 0xD11F45) - static let gpSuccess = adaptative(sombre: 0x4ADE80, clair: 0x15803D) + /// 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. @@ -79,14 +93,47 @@ enum GP { static let paddingCard: CGFloat = 24 } -/// Le fond de l'application : nuit profonde et halo diffusé depuis le haut — la lueur du -/// fantôme, qui donne sa profondeur à l'ensemble sans rien coûter en lisibilité. +/// 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 { - Color.gpBase + fond .overlay(alignment: .top) { RadialGradient( - colors: [Color.gpAccent.opacity(0.18), .clear], + colors: [Color.gpNeon.opacity(schema == .dark ? 0.26 : 0.12), .clear], center: .top, startRadius: 0, endRadius: 520 ) .frame(height: 620) @@ -95,6 +142,19 @@ struct GhostBackground: View { } .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. @@ -130,7 +190,11 @@ struct PrimaryButtonStyle: ButtonStyle { .padding(.vertical, 14) .background( Color.gpAccent.opacity(enabled ? (configuration.isPressed ? 0.8 : 1) : 0.35), - in: RoundedRectangle(cornerRadius: GP.radius)) + 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)) } } diff --git a/apps/ios/Ghostpass/Views/UnlockView.swift b/apps/ios/Ghostpass/Views/UnlockView.swift index 93bb31f4..54e3d790 100644 --- a/apps/ios/Ghostpass/Views/UnlockView.swift +++ b/apps/ios/Ghostpass/Views/UnlockView.swift @@ -67,6 +67,9 @@ struct UnlockView: View { .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) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 77f2d5a6..28d8c994 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -12,26 +12,44 @@ --radius-sm: 8px; --radius-pill: 999px; - /* ── Thème SOMBRE (défaut) ── */ - --canvas: #15171c; - --surface: #1f2229; - --surface-2: #292d35; - --ink: #e7e9ed; - --ink-2: #a9afba; - --ink-3: #757c88; - --line: #2f333b; - --line-2: #3a3f49; - - --accent: #5394f5; - --accent-2: #6ea7f7; - --accent-soft: #243349; - --ring: rgba(83, 148, 245, 0.35); - - --danger: #ef6b72; + /* ── Thème SOMBRE (défaut) ── + Base Dracula, commune à toute la suite ; seule la teinte de marque distingue les + produits. Pour GhostPass c'est le bleu #2E7DFF, celui du logo — le seul créneau + froid encore libre : ghostbit est violet, ghostmon lime, ghostboard orange, + ghostauth menthe, ghostcal cyan, ghostmail jaune. */ + --canvas: #21222c; + --surface: #282a36; + --surface-2: #323445; + --ink: #f8f8f2; + --ink-2: #8b9cc8; + --ink-3: #6b7a9e; + --line: #44475a; + --line-2: #565a70; + + /* Marque : bleu néon */ + --brand-primary: #2e7dff; + --brand-light: #7fb2ff; + --brand-deep: #1a4fcc; + --brand-ghost: #d6e6ff; + --brand-bg: #080d18; + --brand-gradient: linear-gradient(135deg, #2e7dff 0%, #1a4fcc 100%); + /* Le néon de la suite : un noyau serré et vif, une aura large et discrète. Les deux + comptent — un seul halo fait une tache molle, la superposition fait une source. */ + --brand-glow: drop-shadow(0 0 8px rgba(46, 125, 255, 0.6)) + drop-shadow(0 0 20px rgba(46, 125, 255, 0.35)); + --bg-gradient: linear-gradient(150deg, #080d18 0%, #0a1220 45%, #080f1a 75%, #090a10 100%); + + --accent: var(--brand-primary); + --accent-2: var(--brand-light); + --accent-soft: rgba(46, 125, 255, 0.15); + --ring: rgba(46, 125, 255, 0.35); + + /* Sémantiques : celles de la base Dracula partagée. */ + --danger: #ff5555; --danger-soft: #3a2123; - --success: #4fc28a; + --success: #50fa7b; --success-soft: #1d3a2c; - --warn: #e0a23c; + --warn: #ffb86c; --warn-soft: #3a2f1a; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); @@ -54,10 +72,13 @@ --line: #e4e7ec; --line-2: #d3d8e0; - --accent: #2e5cc5; + --accent: var(--brand-deep); --accent-2: #244a9e; --accent-soft: #eaf0fb; - --ring: rgba(46, 92, 197, 0.26); + --ring: rgba(26, 79, 204, 0.26); + /* En plein jour, le néon devient une bavure : on le réduit sans le supprimer. */ + --brand-glow: drop-shadow(0 0 6px rgba(46, 125, 255, 0.3)); + --bg-gradient: none; --danger: #b3261e; --danger-soft: #fbece9; @@ -86,7 +107,11 @@ body { color: var(--ink); font-size: 15px; line-height: 1.5; + /* Le dégradé de la suite empêche le noir d'être plat ; en thème clair il n'a pas lieu + d'être, et `--canvas` reprend la main. */ background: var(--canvas); + background-image: var(--bg-gradient, none); + background-attachment: fixed; } /* ─── Typographie ─── */ @@ -146,9 +171,11 @@ code, width: 1.9em; height: 1.9em; color: #fff; - background: linear-gradient(160deg, var(--accent), var(--accent-2)); + background: var(--brand-gradient); border-radius: 9px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2); + /* La marque rayonne : c'est elle qui rattache GhostPass au reste de la suite. */ + filter: var(--brand-glow); } .brand .mark svg { From 3cbd3251e69ac729e2772384643aeb2ec184e7b8 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 10:31:01 +0200 Subject: [PATCH 39/50] =?UTF-8?q?feat(da):=20le=20fant=C3=B4me=20iOS=20pas?= =?UTF-8?q?se=20au=20bleu,=20et=20redevient=20reproductible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La palette était bleue mais le logo restait violet : LogoMark et AppIcon sont des PNG figés dans le catalogue Xcode, que le changement de jetons ne touchait pas. Le web ne l'a pas eu, son logo étant un SVG inline qui suit currentColor. Les sources vectorielles de la charte entrent dans le dépôt (tools/brand/), avec la commande qui en produit les PNG. Les images du catalogue sont des *sorties* : sans le vecteur, le prochain changement de teinte serait de la retouche pixel. Le fantôme et son épaisseur de trait viennent de la charte ghost-suite, identiques à ghostmail et ghostcal ; seuls le glyphe — une clé — et les trois teintes distinguent GhostPass. AppIcon est aplati sur blanc : l'App Store refuse une icône à canal alpha. LogoMark le garde, puisqu'il se pose sur le fond dégradé de l'écran. Vérifié : hasAlpha no / yes respectivement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../AppIcon.appiconset/AppIcon-1024.png | Bin 80736 -> 86514 bytes .../LogoMark.imageset/LogoMark@1x.png | Bin 2300 -> 2784 bytes .../LogoMark.imageset/LogoMark@2x.png | Bin 5135 -> 6388 bytes .../LogoMark.imageset/LogoMark@3x.png | Bin 8998 -> 11410 bytes tools/brand/ghostpass-brand.svg | 24 +++++++++++++ tools/brand/ghostpass-icon.svg | 16 +++++++++ tools/brand/render-ios-assets.sh | 34 ++++++++++++++++++ 7 files changed, 74 insertions(+) create mode 100644 tools/brand/ghostpass-brand.svg create mode 100644 tools/brand/ghostpass-icon.svg create mode 100755 tools/brand/render-ios-assets.sh diff --git a/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/ios/Ghostpass/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index 6265f1d23d9c04f45abd77ceca96ce64d96769e9..16abcc074a69d24ac7144b22e73441a13c6795af 100644 GIT binary patch literal 86514 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 literal 80736 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 N002ovPDHLkV1nZoV0r)m delta 2271 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 literal 8998 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 IDs2+@KP;`;kN^Mx diff --git a/tools/brand/ghostpass-brand.svg b/tools/brand/ghostpass-brand.svg new file mode 100644 index 00000000..876440f2 --- /dev/null +++ b/tools/brand/ghostpass-brand.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tools/brand/ghostpass-icon.svg b/tools/brand/ghostpass-icon.svg new file mode 100644 index 00000000..0f018e3f --- /dev/null +++ b/tools/brand/ghostpass-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/tools/brand/render-ios-assets.sh b/tools/brand/render-ios-assets.sh new file mode 100755 index 00000000..aebc0f18 --- /dev/null +++ b/tools/brand/render-ios-assets.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Rend les assets iOS depuis les sources vectorielles de la charte. +# +# Les PNG du catalogue Xcode sont des *sorties* : les retoucher à la main condamne le +# prochain changement de teinte à de la peinture pixel. La source est le SVG, et cette +# commande est la seule façon de produire les PNG. +# +# ./tools/brand/render-ios-assets.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BRAND="$ROOT/tools/brand" +ASSETS="$ROOT/apps/ios/Ghostpass/Assets.xcassets" + +command -v rsvg-convert >/dev/null || { + echo "rsvg-convert introuvable — brew install librsvg" >&2 + exit 1 +} + +# LogoMark : la silhouette pleine, sur fond transparent. 64 pt, donc 64/128/192 px. +for facteur in 1 2 3; do + taille=$((64 * facteur)) + rsvg-convert -w "$taille" -h "$taille" \ + "$BRAND/ghostpass-icon.svg" \ + -o "$ASSETS/LogoMark.imageset/LogoMark@${facteur}x.png" +done + +# AppIcon : 1024 px, et **sans canal alpha** — l'App Store refuse une icône transparente. +# On aplatit donc sur du blanc, comme le faisait l'icône précédente. +rsvg-convert -w 1024 -h 1024 -b white \ + "$BRAND/ghostpass-icon.svg" \ + -o "$ASSETS/AppIcon.appiconset/AppIcon-1024.png" + +echo "Assets rendus depuis tools/brand/ghostpass-icon.svg" From 4026128a3d101076e69a5c773db5de3c648e7e98 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 11:12:29 +0200 Subject: [PATCH 40/50] =?UTF-8?q?feat(ios):=20coffres=20partag=C3=A9s=20?= =?UTF-8?q?=E2=80=94=20lire=20et=20=C3=A9crire=20dans=20le=20coffre=20d'un?= =?UTF-8?q?e=20=C3=A9quipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lister ses équipes, accepter une invitation, ouvrir un coffre partagé, parcourir ses collections, lire et — selon le rôle — écrire. L'ouverture passe par openOrg, qui vérifie que l'Org Key provient bien de l'administrateur. Sans cette vérification, un serveur actif pourrait en substituer une et lire tout ce qu'on y écrirait ensuite : c'est la raison d'être de la box authentifiée du cœur, et la perdre ici l'annulerait. Deux décisions à consigner. Le serveur filtre déjà les collections selon les droits du membre, donc l'application affiche ce qu'on lui rend au lieu de réimplémenter la permission — deux implémentations d'une règle de sécurité finissent par diverger. Et rien n'est mis en cache hors ligne, contrairement au coffre personnel : un accès partagé se révoque par rotation d'Org Key, garder une copie locale déchiffrable après un départ viderait la rotation de son sens. L'administration reste hors périmètre — créer une équipe, inviter, gérer groupes et droits, faire tourner la clé. C'est la moitié de la surface serveur, et l'écran l'annonce plutôt que d'offrir un bouton qui échouerait. ItemEditView prend maintenant sa destination en paramètre, le coffre personnel restant le défaut : un second éditeur aurait divergé du premier. 6 tests de contrat, dont celui qui vérifie la séparation dans les deux sens — un item chiffré sous l'Org Key s'ouvre par le coffre d'équipe et échoue pour le compte personnel. 85 tests de contrat au total, suite complète verte. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../Ghostpass/Resources/Localizable.xcstrings | 180 ++++++++++++++++++ apps/ios/Ghostpass/Services/APIClient.swift | 99 ++++++++++ .../Ghostpass/Services/Organizations.swift | 69 +++++++ apps/ios/Ghostpass/Services/VaultStore.swift | 125 ++++++++++++ apps/ios/Ghostpass/Views/ItemEditView.swift | 10 +- apps/ios/Ghostpass/Views/OrgVaultView.swift | 156 +++++++++++++++ .../Ghostpass/Views/OrganizationsView.swift | 140 ++++++++++++++ apps/ios/Ghostpass/Views/VaultListView.swift | 8 + apps/ios/Tests/ContractTests.swift | 78 ++++++++ 9 files changed, 864 insertions(+), 1 deletion(-) create mode 100644 apps/ios/Ghostpass/Services/Organizations.swift create mode 100644 apps/ios/Ghostpass/Views/OrgVaultView.swift create mode 100644 apps/ios/Ghostpass/Views/OrganizationsView.swift diff --git a/apps/ios/Ghostpass/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings index cbfbdf69..707d7c57 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -168,6 +168,16 @@ } } }, + "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": { @@ -218,6 +228,26 @@ } } }, + "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": { @@ -238,6 +268,16 @@ } } }, + "Administrateur": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Administrator" + } + } + } + }, "Adresse copiée": { "localizations": { "en": { @@ -418,6 +458,26 @@ } } }, + "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 entrée trouvée : le fichier est vide, ou ses colonnes ne sont pas reconnues.": { "localizations": { "en": { @@ -508,6 +568,26 @@ } } }, + "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": { @@ -688,6 +768,16 @@ } } }, + "Coffres partagés": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shared vaults" + } + } + } + }, "Coffres qui me sont confiés": { "localizations": { "en": { @@ -698,6 +788,16 @@ } } }, + "Collection": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collection" + } + } + } + }, "Compte": { "localizations": { "en": { @@ -1268,6 +1368,26 @@ } } }, + "Invitation reçue": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invitation received" + } + } + } + }, + "Invitations": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invitations" + } + } + } + }, "La clé de récupération est la seule issue. Notez-la et rangez-la ailleurs que dans ce coffre.": { "localizations": { "en": { @@ -1468,6 +1588,16 @@ } } }, + "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": { @@ -1528,6 +1658,26 @@ } } }, + "Membre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Member" + } + } + } + }, + "Membre actif": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active member" + } + } + } + }, "Mes contacts de confiance": { "localizations": { "en": { @@ -1538,6 +1688,16 @@ } } }, + "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": { @@ -1938,6 +2098,16 @@ } } }, + "Rejoindre l'équipe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Join the team" + } + } + } + }, "Remplacé": { "localizations": { "en": { @@ -2518,6 +2688,16 @@ } } }, + "Vous n'appartenez à aucune équipe.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You do not belong to any team." + } + } + } + }, "Vérification…": { "localizations": { "en": { diff --git a/apps/ios/Ghostpass/Services/APIClient.swift b/apps/ios/Ghostpass/Services/APIClient.swift index 28ab42e8..d43bf76f 100644 --- a/apps/ios/Ghostpass/Services/APIClient.swift +++ b/apps/ios/Ghostpass/Services/APIClient.swift @@ -116,6 +116,41 @@ private struct TakeoverBody: Encodable { 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] +} + /// 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 { @@ -314,4 +349,68 @@ struct APIClient { 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) + } } diff --git a/apps/ios/Ghostpass/Services/Organizations.swift b/apps/ios/Ghostpass/Services/Organizations.swift new file mode 100644 index 00000000..6cb3a4e4 --- /dev/null +++ b/apps/ios/Ghostpass/Services/Organizations.swift @@ -0,0 +1,69 @@ +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 +} diff --git a/apps/ios/Ghostpass/Services/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 42c8f029..615296ee 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -857,6 +857,119 @@ final class VaultStore: ObservableObject { 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 + } + } } /// Coffre d'un donneur, ouvert le temps d'une consultation. `coffre` reste un objet opaque du @@ -880,3 +993,15 @@ private struct RepriseDUrgence: Decodable { 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/Views/ItemEditView.swift b/apps/ios/Ghostpass/Views/ItemEditView.swift index 9f84b06c..78aff4e1 100644 --- a/apps/ios/Ghostpass/Views/ItemEditView.swift +++ b/apps/ios/Ghostpass/Views/ItemEditView.swift @@ -5,6 +5,10 @@ import SwiftUI /// 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 @@ -94,7 +98,11 @@ struct ItemEditView: View { ToolbarItem(placement: .confirmationAction) { Button("Enregistrer") { Task { - await store.save(build(), id: existingID) + if let enregistrer { + await enregistrer(build(), existingID) + } else { + await store.save(build(), id: existingID) + } dismiss() } } diff --git a/apps/ios/Ghostpass/Views/OrgVaultView.swift b/apps/ios/Ghostpass/Views/OrgVaultView.swift new file mode 100644 index 00000000..7e1b56d7 --- /dev/null +++ b/apps/ios/Ghostpass/Views/OrgVaultView.swift @@ -0,0 +1,156 @@ +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 + + 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.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: $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..53299b2d --- /dev/null +++ b/apps/ios/Ghostpass/Views/OrganizationsView.swift @@ -0,0 +1,140 @@ +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? + + 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") + } + } + } + .tint(Color.gpAccentText) + .task { await recharger() } + .sheet(item: $ouvert) { coffre in + OrgVaultView(ouvert: coffre).environmentObject(store) + } + } + + 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( + "Les coffres partagés se créent depuis l'application web ; ils apparaîtront ici dès qu'on vous y aura invité." + ) + .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/VaultListView.swift b/apps/ios/Ghostpass/Views/VaultListView.swift index 6136685e..f8b9dc56 100644 --- a/apps/ios/Ghostpass/Views/VaultListView.swift +++ b/apps/ios/Ghostpass/Views/VaultListView.swift @@ -127,6 +127,10 @@ struct VaultListView: View { 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 } @@ -185,6 +189,8 @@ struct VaultListView: View { 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` @@ -366,6 +372,7 @@ enum VaultSheet: Identifiable { case importCSV case exportCSV case emergency + case organizations var id: String { switch self { @@ -379,6 +386,7 @@ enum VaultSheet: Identifiable { case .health: return "health" case .recoveryKey: return "recoveryKey" case .emergency: return "emergency" + case .organizations: return "organizations" case .importCSV: return "import" case .exportCSV: return "export" } diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 2b8f5fe4..e9e51d43 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -1203,3 +1203,81 @@ final class AccesDUrgenceTests: XCTestCase { } } } + +// ─── 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)) + } +} From c0661aaa1f153990d6660bc5a1bc53fe1c3befb4 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 12:29:12 +0200 Subject: [PATCH 41/50] =?UTF-8?q?feat(ios):=20administration=20des=20?= =?UTF-8?q?=C3=A9quipes=20=E2=80=94=20membres,=20collections,=20groupes,?= =?UTF-8?q?=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Créer une équipe, inviter, changer un rôle, révoquer, gérer collections et groupes avec leurs droits. L'écran de la liste ne dit plus que les coffres partagés se créent depuis le web : c'est devenu faux. La révocation fait tourner l'Org Key dans le même geste. Les deux sont indissociables : retirer quelqu'un sans changer la clé le laisserait lire tout ce qui s'écrira ensuite, puisqu'il en garde une copie. La rotation ne re-protège pas ce qu'il a déjà vu, et l'interface le dit plutôt que de le taire. Le serveur ne rend pas la clé publique des membres, seulement leur adresse : la rotation doit les résoudre une à une. Si l'une échoue, tout est annulé — sauter la personne 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é. 5 tests, dont celui qui vérifie la propriété centrale de la rotation : le contenu chiffré est identique avant et après, seule l'enveloppe de la clé change, et l'ancienne clé ne lit plus l'item. 90 tests de contrat au total. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .../Ghostpass/Resources/Localizable.xcstrings | 390 ++++++++++++++++++ apps/ios/Ghostpass/Services/APIClient.swift | 185 +++++++++ .../Ghostpass/Services/Organizations.swift | 58 +++ apps/ios/Ghostpass/Services/VaultStore.swift | 227 ++++++++++ apps/ios/Ghostpass/Views/OrgAdminView.swift | 239 +++++++++++ apps/ios/Ghostpass/Views/OrgGroupView.swift | 145 +++++++ apps/ios/Ghostpass/Views/OrgInviteView.swift | 117 ++++++ apps/ios/Ghostpass/Views/OrgVaultView.swift | 18 + .../Ghostpass/Views/OrganizationsView.swift | 27 +- apps/ios/Tests/ContractTests.swift | 79 ++++ 10 files changed, 1484 insertions(+), 1 deletion(-) create mode 100644 apps/ios/Ghostpass/Views/OrgAdminView.swift create mode 100644 apps/ios/Ghostpass/Views/OrgGroupView.swift create mode 100644 apps/ios/Ghostpass/Views/OrgInviteView.swift diff --git a/apps/ios/Ghostpass/Resources/Localizable.xcstrings b/apps/ios/Ghostpass/Resources/Localizable.xcstrings index 707d7c57..b29ee05a 100644 --- a/apps/ios/Ghostpass/Resources/Localizable.xcstrings +++ b/apps/ios/Ghostpass/Resources/Localizable.xcstrings @@ -32,6 +32,16 @@ } } }, + "%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": { @@ -278,6 +288,16 @@ } } }, + "Administration": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Administration" + } + } + } + }, "Adresse copiée": { "localizations": { "en": { @@ -428,6 +448,16 @@ } } }, + "Aucun accès": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No access" + } + } + } + }, "Aucun dossier": { "localizations": { "en": { @@ -478,6 +508,16 @@ } } }, + "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": { @@ -568,6 +608,16 @@ } } }, + "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": { @@ -798,6 +848,16 @@ } } }, + "Collections": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collections" + } + } + } + }, "Compte": { "localizations": { "en": { @@ -968,6 +1028,16 @@ } } }, + "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": { @@ -1068,6 +1138,16 @@ } } }, + "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": { @@ -1218,6 +1298,16 @@ } } }, + "Gestion": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage" + } + } + } + }, "GhostPass": { "localizations": { "en": { @@ -1238,6 +1328,16 @@ } } }, + "Groupes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Groups" + } + } + } + }, "Générer": { "localizations": { "en": { @@ -1358,6 +1458,16 @@ } } }, + "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": { @@ -1388,6 +1498,46 @@ } } }, + "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": { @@ -1398,6 +1548,16 @@ } } }, + "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": { @@ -1408,6 +1568,16 @@ } } }, + "La personne": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The person" + } + } + } + }, "Langue": { "localizations": { "en": { @@ -1578,6 +1748,26 @@ } } }, + "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": { @@ -1678,6 +1868,26 @@ } } }, + "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": { @@ -1738,6 +1948,16 @@ } } }, + "Modifier ce membre": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit this member" + } + } + } + }, "Mot de passe": { "localizations": { "en": { @@ -1878,6 +2098,16 @@ } } }, + "Nom de l'équipe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Team name" + } + } + } + }, "Note": { "localizations": { "en": { @@ -1898,6 +2128,16 @@ } } }, + "Nouveau groupe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New group" + } + } + } + }, "Nouveau mot de passe": { "localizations": { "en": { @@ -1928,6 +2168,26 @@ } } }, + "Nouvelle collection": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New collection" + } + } + } + }, + "Nouvelle équipe": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New team" + } + } + } + }, "Numéro": { "localizations": { "en": { @@ -2028,6 +2288,36 @@ } } }, + "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": { @@ -2198,6 +2488,16 @@ } } }, + "Retirer l'accès": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove access" + } + } + } + }, "Revenir au coffre enregistré": { "localizations": { "en": { @@ -2268,6 +2568,26 @@ } } }, + "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": { @@ -2408,6 +2728,16 @@ } } }, + "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": { @@ -2438,6 +2768,16 @@ } } }, + "Son rôle": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Their role" + } + } + } + }, "Supprimer": { "localizations": { "en": { @@ -2458,6 +2798,26 @@ } } }, + "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": { @@ -2578,6 +2938,16 @@ } } }, + "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": { @@ -2718,6 +3088,16 @@ } } }, + "adresse@exemple.com": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "address@example.com" + } + } + } + }, "et %lld autres": { "localizations": { "en": { @@ -2788,6 +3168,16 @@ } } }, + "Écriture": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Write" + } + } + } + }, "≈ %lld bits": { "localizations": { "en": { diff --git a/apps/ios/Ghostpass/Services/APIClient.swift b/apps/ios/Ghostpass/Services/APIClient.swift index d43bf76f..42284ee0 100644 --- a/apps/ios/Ghostpass/Services/APIClient.swift +++ b/apps/ios/Ghostpass/Services/APIClient.swift @@ -151,6 +151,92 @@ 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 { @@ -413,4 +499,103 @@ struct APIClient { _ = 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/Organizations.swift b/apps/ios/Ghostpass/Services/Organizations.swift index 6cb3a4e4..94ec5b04 100644 --- a/apps/ios/Ghostpass/Services/Organizations.swift +++ b/apps/ios/Ghostpass/Services/Organizations.swift @@ -67,3 +67,61 @@ struct CoffrePartageOuvert { 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/VaultStore.swift b/apps/ios/Ghostpass/Services/VaultStore.swift index 615296ee..fdf3552b 100644 --- a/apps/ios/Ghostpass/Services/VaultStore.swift +++ b/apps/ios/Ghostpass/Services/VaultStore.swift @@ -970,6 +970,233 @@ final class VaultStore: ObservableObject { 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 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 index 7e1b56d7..9384efd4 100644 --- a/apps/ios/Ghostpass/Views/OrgVaultView.swift +++ b/apps/ios/Ghostpass/Views/OrgVaultView.swift @@ -17,6 +17,7 @@ struct OrgVaultView: View { @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) @@ -48,6 +49,16 @@ struct OrgVaultView: View { .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 { @@ -66,6 +77,13 @@ struct OrgVaultView: View { 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) } } diff --git a/apps/ios/Ghostpass/Views/OrganizationsView.swift b/apps/ios/Ghostpass/Views/OrganizationsView.swift index 53299b2d..0fbb9475 100644 --- a/apps/ios/Ghostpass/Views/OrganizationsView.swift +++ b/apps/ios/Ghostpass/Views/OrganizationsView.swift @@ -13,6 +13,8 @@ struct OrganizationsView: View { @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 { @@ -54,6 +56,14 @@ struct OrganizationsView: View { .foregroundStyle(Color.gpAccentText) .accessibilityIdentifier("button.closeOrgs") } + ToolbarItem(placement: .primaryAction) { + Button { + creation = true + } label: { + Image(systemName: "plus") + } + .accessibilityIdentifier("button.newOrg") + } } } .tint(Color.gpAccentText) @@ -61,6 +71,21 @@ struct OrganizationsView: View { .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 } } @@ -79,7 +104,7 @@ struct OrganizationsView: View { .foregroundStyle(Color.gpInk) .frame(maxWidth: .infinity, alignment: .center) Text( - "Les coffres partagés se créent depuis l'application web ; ils apparaîtront ici dès qu'on vous y aura invité." + "Créez-en une, ou attendez qu'on vous invite : les équipes dont vous ferez partie apparaîtront ici." ) .font(.footnote) .foregroundStyle(Color.gpMuted) diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index e9e51d43..6923a705 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -1281,3 +1281,82 @@ final class OrganisationsTests: XCTestCase { 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)) + } +} From eef3622805056e8c98ec3ea94a71808f36d6542b Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 12:29:32 +0200 Subject: [PATCH 42/50] =?UTF-8?q?test(ios):=20test01=20n'ouvrait=20plus=20?= =?UTF-8?q?la=20feuille=20de=20cr=C3=A9ation=20une=20fois=20sur=20deux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il tapait « Ajouter », attendait, puis retapait de la même façon. Or c'est le geste qui échoue : sur une barre de navigation encore en cours de mise en page, XCUITest calcule un point de frappe {-1, -1} et le tap se perd. Réessayer un geste raté à l'identique ne le rend pas moins raté. Le coût dépassait le temps perdu : l'échec disait « champ field.name hors d'atteinte », ce qui envoie chercher du côté du formulaire — alors que le formulaire ne s'était jamais ouvert. Même mensonge que test02 ce matin, qui affirmait « pas de biométrie » quand le menu ne s'ouvrait pas. Emploie désormais taper(), qui vise le centre du cadre en coordonnées, avec quatre tentatives — la même parade que ouvrirLeMenu. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/UITests/VaultFlowTests.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/ios/UITests/VaultFlowTests.swift b/apps/ios/UITests/VaultFlowTests.swift index 1ed602e9..f329f962 100644 --- a/apps/ios/UITests/VaultFlowTests.swift +++ b/apps/ios/UITests/VaultFlowTests.swift @@ -239,13 +239,20 @@ final class VaultFlowTests: XCTestCase { // 3. Création let plus = app.buttons["button.add"] XCTAssertTrue(plus.waitForExistence(timeout: 30)) - plus.tap() - if !app.buttons["button.cancel"].waitForExistence(timeout: 10) { - plus.tap() // la feuille rate parfois le premier tap juste après le chargement - XCTAssertTrue( - app.buttons["button.cancel"].waitForExistence(timeout: 30), - "la feuille de création ne s'ouvre pas") + // `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") From 15f87c905c6b91f3cebc3356541966cd63522e80 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 12:29:32 +0200 Subject: [PATCH 43/50] feat(da): la charte devient reproductible, et le web rejoint la suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/brand/ghost_suite.py tient la silhouette du fantôme et en tire les deux variantes de la charte : le logo au trait avec ses tirets, l'icône pleine au glyphe découpé. Le script que les SVG de la suite référençaient depuis toujours n'existait dans aucun dépôt ; il existe désormais, et il est fidèle — il reproduit les fichiers existants à l'identique, et les PNG régénérés sont inchangés au bit près. Le web rejoint la suite, sur quatre points que le seul changement de jetons n'avait pas touchés : - .auth-brand inondait la moitié de l'écran de la couleur d'accent. La suite pose de la nuit et laisse le néon faire l'accent ; sans nuit pour le porter, le halo était invisible. - La marque était un cadenas générique dans un badge translucide, qui ne rattachait GhostPass à rien. C'est le fantôme de la charte — même tracé que le favicon et que l'icône iOS. - Le favicon n'existait pas : aucune balise icon, pas même de dossier public/. Le theme-color était resté sur un turquoise d'une palette antérieure. - Les polices : Spectral en serif donnait des titres de magazine là où la suite emploie system-ui. Elles venaient de Google Fonts — pour un coffre zero-knowledge, livrer l'adresse IP de l'utilisateur à un tiers à chaque déverrouillage. Plus aucune police distante. L'état désactivé du bouton de soumission n'était pas couvert : un bouton inerte rayonnait. Vérifié en rendant la page construite dans un Chrome sans interface, plutôt qu'en supposant. npm run check : 128 fichiers, zéro erreur. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/web/index.html | 12 +- apps/web/public/favicon.svg | 14 ++ apps/web/src/App.svelte | 15 +- apps/web/src/app.css | 67 +++++-- tools/brand/ghost_suite.py | 166 ++++++++++++++++++ tools/brand/ghostpass-brand.svg | 14 +- tools/brand/ghostpass-icon.svg | 8 +- ...{render-ios-assets.sh => render-assets.sh} | 7 +- 8 files changed, 264 insertions(+), 39 deletions(-) create mode 100644 apps/web/public/favicon.svg create mode 100755 tools/brand/ghost_suite.py rename tools/brand/{render-ios-assets.sh => render-assets.sh} (77%) 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 5d6a90d4..73b90797 100644 --- a/apps/web/src/App.svelte +++ b/apps/web/src/App.svelte @@ -1107,9 +1107,18 @@ {#snippet logoMark()} - - - + + {/snippet} {#snippet eyeIcon()} diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 28d8c994..6b806b18 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3,9 +3,14 @@ :root { /* Typographies */ - --font-sans: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; - --font-serif: "Spectral", Georgia, "Times New Roman", serif; - --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; + /* Les polices de la suite, à l'identique de ghostbit et ghostmon. Aucune n'est + téléchargée : un coffre zero-knowledge qui appelle Google Fonts à chaque ouverture + livrerait l'adresse IP de ses utilisateurs à un tiers, à chaque déverrouillage. */ + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + /* Le serif de l'ancienne charte n'existe plus dans la suite ; les titres prennent la + police d'interface. Le jeton reste, pour ne pas casser ce qui le référence. */ + --font-serif: var(--font-sans); + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, Consolas, monospace; /* Rayons (partagés) */ --radius: 12px; @@ -37,6 +42,10 @@ comptent — un seul halo fait une tache molle, la superposition fait une source. */ --brand-glow: drop-shadow(0 0 8px rgba(46, 125, 255, 0.6)) drop-shadow(0 0 20px rgba(46, 125, 255, 0.35)); + /* Le même halo en `box-shadow`, pour ce qui a un fond : `filter` s'applique au rendu + entier de l'élément et mangerait son propre remplissage. */ + --glow-shadow: 0 0 10px rgba(46, 125, 255, 0.55), 0 0 28px rgba(46, 125, 255, 0.32); + --glow-shadow-faible: 0 0 12px rgba(46, 125, 255, 0.25); --bg-gradient: linear-gradient(150deg, #080d18 0%, #0a1220 45%, #080f1a 75%, #090a10 100%); --accent: var(--brand-primary); @@ -78,6 +87,8 @@ --ring: rgba(26, 79, 204, 0.26); /* En plein jour, le néon devient une bavure : on le réduit sans le supprimer. */ --brand-glow: drop-shadow(0 0 6px rgba(46, 125, 255, 0.3)); + --glow-shadow: none; + --glow-shadow-faible: none; --bg-gradient: none; --danger: #b3261e; @@ -170,17 +181,16 @@ code, place-items: center; width: 1.9em; height: 1.9em; - color: #fff; - background: var(--brand-gradient); - border-radius: 9px; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2); + /* Pas de badge : la silhouette porte elle-même le dégradé de la charte. Le poser aussi en + fond aurait rendu le fantôme invisible sur son propre dégradé. */ + background: none; /* La marque rayonne : c'est elle qui rattache GhostPass au reste de la suite. */ filter: var(--brand-glow); } .brand .mark svg { - width: 1em; - height: 1em; + width: 100%; + height: 100%; } /* ─── App shell : topbar + (sidebar · liste · détail) ─── */ @@ -966,17 +976,31 @@ a.kv-value:hover { justify-content: space-between; gap: 2rem; padding: clamp(2rem, 5vw, 3.75rem); - color: #fff; - background: linear-gradient(150deg, var(--accent) 0%, var(--accent-2) 68%, #16306b 100%); + color: var(--ink); + /* La suite pose du sombre et laisse le néon faire l'accent. Inonder la moitié de l'écran + de la couleur de marque écrasait tout le reste — et rendait le halo invisible, faute + de nuit pour le porter. */ + background: var(--bg-gradient, var(--canvas)); overflow: hidden; } +/* Le halo de marque, diffusé depuis le haut : la lueur du fantôme. */ +.auth-brand::before { + content: ""; + position: absolute; + inset: -20% -20% auto -20%; + height: 70%; + background: radial-gradient(60% 60% at 30% 0%, var(--accent-soft), transparent 70%); + filter: blur(40px); + pointer-events: none; +} + /* Motif de points discret, estompé vers un coin */ .auth-brand::after { content: ""; position: absolute; inset: 0; - background-image: radial-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px); + background-image: radial-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px); background-size: 22px 22px; -webkit-mask-image: radial-gradient(120% 75% at 85% 15%, #000, transparent 70%); mask-image: radial-gradient(120% 75% at 85% 15%, #000, transparent 70%); @@ -994,8 +1018,11 @@ a.kv-value:hover { } .auth-brand .brand .mark { - background: rgba(255, 255, 255, 0.16); + /* Pas de fond : c'est la silhouette elle-même qui porte le dégradé et le halo. Le badge + translucide d'avant aplatissait les deux. */ + background: none; box-shadow: none; + filter: var(--brand-glow); } .auth-hero { @@ -1247,7 +1274,8 @@ button { button:focus-visible { outline: none; - box-shadow: 0 0 0 3px var(--ring); + box-shadow: 0 0 0 3px var(--ring), var(--glow-shadow-faible, none); + border-color: var(--brand-primary, var(--accent)); } form > button[type="submit"], @@ -1256,9 +1284,18 @@ form > button[type="submit"], font-weight: 600; font-size: 0.92rem; color: #fff; - background: var(--accent); + background: var(--brand-gradient, var(--accent)); border-color: var(--accent); + /* Le néon de la suite, en ombre portée plutôt qu'en filtre : un `filter` sur un bouton + rognerait son propre fond. */ + box-shadow: var(--shadow-sm), var(--glow-shadow, none); +} + +/* Faire luire ce sur quoi on ne peut pas appuyer appellerait l'œil au mauvais endroit. */ +form > button[type="submit"]:disabled, +.btn-primary:disabled { box-shadow: var(--shadow-sm); + filter: saturate(0.5); } .btn-primary { diff --git a/tools/brand/ghost_suite.py b/tools/brand/ghost_suite.py new file mode 100755 index 00000000..3dd821ed --- /dev/null +++ b/tools/brand/ghost_suite.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Générateur de la charte ghost-suite. + +Les produits de la suite partagent une même silhouette de fantôme ; seuls le glyphe +qu'elle porte et ses trois teintes les distinguent. Ce script tient cette silhouette une +fois pour toutes et en tire, pour chaque produit, les deux variantes de la charte : + + logo — la silhouette au trait, avec ses trois tirets détachés. Le trait fait 4 % de la + hauteur et disparaît en dessous de 16 px ; c'est la variante des en-têtes. + icone — la même courbe refermée et remplie, le glyphe découpé en creux, cadrée au carré. + C'est la variante des favicons et des icônes d'application, qui tient petit. + +Usage : + python3 tools/brand/ghost_suite.py ghostpass # écrit les deux SVG + python3 tools/brand/ghost_suite.py --liste # produits déclarés + +Les SVG produits sont des *sorties* : on ne les retouche pas à la main, on change la +déclaration du produit ici et on régénère. C'est ce qui permet à un changement de teinte +de rester une opération d'une ligne. +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from pathlib import Path + +# ─── Ce qui est commun à toute la suite ─────────────────────────────────────── + +# La silhouette, en coordonnées d'un cadre 421 × 548. Tracé ouvert : la variante logo le +# caresse au trait, la variante icône le referme et le remplit. +SILHOUETTE = 'M 240.0 9.0 C 245.3 9.3 262.5 9.7 272.0 11.0 C 281.5 12.3 288.0 13.8 297.0 17.0 C 306.0 20.2 317.8 25.7 326.0 30.0 C 334.2 34.3 338.5 36.8 346.0 43.0 C 353.5 49.2 365.2 60.8 371.0 67.0 C 376.8 73.2 377.0 73.5 381.0 80.0 C 385.0 86.5 390.8 96.2 395.0 106.0 C 399.2 115.8 403.5 129.0 406.0 139.0 C 408.5 149.0 409.3 154.0 410.0 166.0 C 410.7 178.0 410.8 195.8 410.0 211.0 C 409.2 226.2 407.0 244.0 405.0 257.0 C 403.0 270.0 401.2 277.5 398.0 289.0 C 394.8 300.5 391.0 313.2 386.0 326.0 C 381.0 338.8 374.7 353.0 368.0 366.0 C 361.3 379.0 354.8 390.8 346.0 404.0 C 337.2 417.2 322.2 436.2 315.0 445.0 C 307.8 453.8 308.0 454.0 303.0 457.0 C 298.0 460.0 290.7 462.5 285.0 463.0 C 279.3 463.5 273.7 462.0 269.0 460.0 C 264.3 458.0 259.7 453.8 257.0 451.0 C 254.3 448.2 253.8 446.5 253.0 443.0 C 252.2 439.5 251.5 434.3 252.0 430.0 C 252.5 425.7 253.0 422.3 256.0 417.0 C 259.0 411.7 267.2 403.2 270.0 398.0 C 272.8 392.8 272.7 389.7 273.0 386.0 C 273.3 382.3 272.7 378.8 272.0 376.0 C 271.3 373.2 271.2 371.5 269.0 369.0 C 266.8 366.5 263.7 362.3 259.0 361.0 C 254.3 359.7 244.8 360.5 241.0 361.0 C 237.2 361.5 238.5 361.7 236.0 364.0 C 233.5 366.3 234.0 363.0 226.0 375.0 C 218.0 387.0 199.5 419.3 188.0 436.0 C 176.5 452.7 164.3 466.3 157.0 475.0 C 149.7 483.7 147.8 484.8 144.0 488.0 C 140.2 491.2 137.2 492.7 134.0 494.0 C 130.8 495.3 129.2 495.8 125.0 496.0 C 120.8 496.2 113.2 495.8 109.0 495.0 C 104.8 494.2 102.5 492.7 100.0 491.0 C 97.5 489.3 95.7 487.3 94.0 485.0 C 92.3 482.7 90.7 482.0 90.0 477.0 C 89.3 472.0 88.7 461.0 90.0 455.0 C 91.3 449.0 92.5 447.8 98.0 441.0 C 103.5 434.2 116.5 421.8 123.0 414.0 C 129.5 406.2 134.0 400.2 137.0 394.0 C 140.0 387.8 141.2 382.5 141.0 377.0 C 140.8 371.5 138.3 364.8 136.0 361.0 C 133.7 357.2 131.5 355.2 127.0 354.0 C 122.5 352.8 113.8 352.7 109.0 354.0 C 104.2 355.3 101.8 357.7 98.0 362.0 C 94.2 366.3 89.3 375.7 86.0 380.0 C 82.7 384.3 81.0 385.5 78.0 388.0 C 75.0 390.5 71.8 393.0 68.0 395.0 C 64.2 397.0 60.5 399.0 55.0 400.0 C 49.5 401.0 40.0 401.2 35.0 401.0 C 30.0 400.8 28.5 400.7 25.0 399.0 C 21.5 397.3 16.5 394.0 14.0 391.0 C 11.5 388.0 10.7 384.5 10.0 381.0 C 9.3 377.5 9.3 373.7 10.0 370.0 C 10.7 366.3 11.2 363.0 14.0 359.0 C 16.8 355.0 22.0 351.8 27.0 346.0 C 32.0 340.2 38.8 332.0 44.0 324.0 C 49.2 316.0 54.0 306.5 58.0 298.0 C 62.0 289.5 65.0 282.0 68.0 273.0 C 71.0 264.0 74.0 253.2 76.0 244.0 C 78.0 234.8 78.8 233.3 80.0 218.0 C 81.2 202.7 82.0 165.5 83.0 152.0 C 84.0 138.5 84.5 143.2 86.0 137.0 C 87.5 130.8 88.3 123.8 92.0 115.0 C 95.7 106.2 102.8 92.5 108.0 84.0 C 113.2 75.5 118.2 69.7 123.0 64.0 C 127.8 58.3 130.8 55.2 137.0 50.0 C 143.2 44.8 150.3 38.5 160.0 33.0 C 169.7 27.5 185.0 20.7 195.0 17.0 C 205.0 13.3 212.7 12.2 220.0 11.0 C 227.3 9.8 235.8 10.2 239.0 10.0' + +# Les trois tirets détachés qui accompagnent la silhouette dans la variante logo. Ils +# disparaissent de la variante icône, où ils deviendraient des salissures à 16 px. +TIRETS = 'M 201.0 509.0 L 227.0 478.0 M 38.0 537.0 L 63.0 508.0 M 50.0 442.0 L 26.0 470.0' + +LARGEUR, HAUTEUR = 421, 548 +EPAISSEUR = 22 # 4 % de la hauteur + + +@dataclass(frozen=True) +class Produit: + """Un produit de la suite : son nom, ses trois teintes, son glyphe. + + `glyphe_trait` est dessiné au trait par-dessus la silhouette (variante logo) ; + `glyphe_plein` est découpé en creux dans la silhouette remplie (variante icône). + Les deux décrivent la même figure, dans les deux techniques que réclament les deux + variantes — un tracé au trait ne se remplit pas, et l'inverse non plus. + """ + + nom: str + claire: str + primaire: str + profonde: str + glyphe_trait: str + glyphe_plein: str + + +PRODUITS = { + "ghostpass": Produit( + nom="GhostPass", + claire="#9CC3FF", + primaire="#2E7DFF", + profonde="#143F8F", + # Une clé : l'anneau, la tige, et deux dents. + glyphe_trait='\n \n \n ', + glyphe_plein='M 142 196 A 48 48 0 1 0 238 196 A 48 48 0 1 0 142 196 Z M 170 196 A 20 20 0 1 0 210 196 A 20 20 0 1 0 170 196 Z M 243 181 H 325 A 15 15 0 0 1 340 196 V 196 A 15 15 0 0 1 325 211 H 243 A 15 15 0 0 1 228 196 V 196 A 15 15 0 0 1 243 181 Z M 298 205 H 298 A 12 12 0 0 1 310 217 V 235 A 12 12 0 0 1 298 247 H 298 A 12 12 0 0 1 286 235 V 217 A 12 12 0 0 1 298 205 Z M 328 205 H 328 A 12 12 0 0 1 340 217 V 225 A 12 12 0 0 1 328 237 H 328 A 12 12 0 0 1 316 225 V 217 A 12 12 0 0 1 328 205 Z', + ), +} + +# Les autres produits de la suite — ghostbit, ghostmon, ghostboard, ghostauth, ghostcal, +# ghostmail, ghostlink — partagent cette silhouette et n'attendent que leur glyphe et +# leurs teintes. On les déclare ici au fur et à mesure qu'on récupère leurs tracés, plutôt +# que d'en inventer d'approchants : un logo presque juste est pire qu'un logo absent. + + +# ─── Rendu ──────────────────────────────────────────────────────────────────── + + +def indente(tracé: str, marge: str = " ") -> str: + """Aligne un bloc de tracés sur l'indentation du SVG produit.""" + return "\n".join(marge + l.strip() for l in tracé.strip().split("\n")) + + +def degrade(produit: Produit) -> str: + """Le dégradé diagonal de la charte : claire en haut à droite, profonde en bas à gauche.""" + return ( + f' \n' + f' \n' + f' \n' + f' \n' + f' \n' + f' \n' + f' ' + ) + + +def logo(produit: Produit) -> str: + """La silhouette au trait, ses tirets, et le glyphe par-dessus.""" + return f""" + +{degrade(produit)} + + + + + +{indente(produit.glyphe_trait)} + + +""" + + +def icone(produit: Produit) -> str: + """La même courbe, refermée et remplie, le glyphe en creux, cadrée au carré. + + Le cadre passe de 421 × 548 à 548 × 548 : on écarte le viewBox de part et d'autre + plutôt que de déformer la silhouette. + """ + marge = (HAUTEUR - LARGEUR) / 2 + return f""" + +{degrade(produit)} + + +""" + + +def main() -> int: + analyseur = argparse.ArgumentParser(description=__doc__) + analyseur.add_argument("produit", nargs="?", help="nom du produit à générer") + analyseur.add_argument("--liste", action="store_true", help="produits déclarés") + analyseur.add_argument( + "--sortie", type=Path, default=Path(__file__).parent, help="répertoire de sortie" + ) + args = analyseur.parse_args() + + if args.liste or not args.produit: + for cle, p in sorted(PRODUITS.items()): + print(f"{cle:12} {p.nom:12} {p.primaire}") + return 0 + + produit = PRODUITS.get(args.produit) + if produit is None: + connus = ", ".join(sorted(PRODUITS)) + print(f"produit inconnu : {args.produit} (connus : {connus})", file=sys.stderr) + return 1 + + args.sortie.mkdir(parents=True, exist_ok=True) + for suffixe, contenu in (("brand", logo(produit)), ("icon", icone(produit))): + chemin = args.sortie / f"{args.produit}-{suffixe}.svg" + chemin.write_text(contenu, encoding="utf-8") + print(f"écrit {chemin}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/brand/ghostpass-brand.svg b/tools/brand/ghostpass-brand.svg index 876440f2..59424034 100644 --- a/tools/brand/ghostpass-brand.svg +++ b/tools/brand/ghostpass-brand.svg @@ -1,8 +1,6 @@ - + @@ -16,9 +14,9 @@
    - - - - + + + + diff --git a/tools/brand/ghostpass-icon.svg b/tools/brand/ghostpass-icon.svg index 0f018e3f..04643520 100644 --- a/tools/brand/ghostpass-icon.svg +++ b/tools/brand/ghostpass-icon.svg @@ -1,9 +1,7 @@ - + diff --git a/tools/brand/render-ios-assets.sh b/tools/brand/render-assets.sh similarity index 77% rename from tools/brand/render-ios-assets.sh rename to tools/brand/render-assets.sh index aebc0f18..807545c0 100755 --- a/tools/brand/render-ios-assets.sh +++ b/tools/brand/render-assets.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Rend les assets iOS depuis les sources vectorielles de la charte. +# Rend les assets applicatifs depuis les sources vectorielles de la charte. # # Les PNG du catalogue Xcode sont des *sorties* : les retoucher à la main condamne le # prochain changement de teinte à de la peinture pixel. La source est le SVG, et cette @@ -31,4 +31,9 @@ rsvg-convert -w 1024 -h 1024 -b white \ "$BRAND/ghostpass-icon.svg" \ -o "$ASSETS/AppIcon.appiconset/AppIcon-1024.png" +# Web : le favicon et la marque de l'en-tête sont le même fichier, servi tel quel. Un SVG +# tient à 16 px comme à 512, et évite d'entretenir six tailles de PNG. +mkdir -p "$ROOT/apps/web/public" +cp "$BRAND/ghostpass-icon.svg" "$ROOT/apps/web/public/favicon.svg" + echo "Assets rendus depuis tools/brand/ghostpass-icon.svg" From 4277805713e978d37855023dd983adc42a8446e0 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 13:18:01 +0200 Subject: [PATCH 44/50] feat(da): la charte couvre les huit produits de la suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le générateur ne connaissait que ghostpass. Les glyphes et les teintes des six autres ont été relevés sur leurs logos publiés — pas reconstitués : le script reproduit ces six logos à l'identique, ce qui prouve qu'il est bien celui que leurs en-têtes désignent, et non une imitation. Trois variantes icônes manquaient (ghostmon, ghostboard, ghostlink), leurs dépôts ne publiant que le logo. Elles sont composées ici, d'après la règle que révèlent les quatre paires existantes : le motif est repris et agrandi d'un quart, les traits ouverts deviennent pleins, les formes fermées aussi — sauf quand le creux porte le sens. Les maillons de ghostlink restent donc évidés, pour la même raison que l'anneau de la clé de ghostpass : pleins, ils deviendraient deux pastilles. ghostboard passe au vert 133°. Son logo publié (#3D7BFF) et son style.css (#FF6B1A) se contredisaient, et aucune des deux teintes n'allait : le bleu se confondait avec ghostpass à quatre points près, l'orange revient à ghostauth. 133° tombe dans le plus large intervalle libre — 51° de ghostmon, 31° de ghostauth. C'est donc le logo de ghostboard qui doit suivre, pas l'inverse, et le commentaire le dit. ghostauth reçoit un bouclier portant une coche, faute de tracé publié. La clé de ghostpass dit « un secret que tu détiens » ; l'authentification dit « on a vérifié qui tu es » — un verdict, pas un objet. rejoue la comparaison hors ligne : un générateur qui a dérivé de ses propres sorties ne sert plus à rien. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- .gitignore | 1 + tools/brand/ghost_suite.py | 138 +++++++++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 8b6b1df3..9be84594 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ 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/tools/brand/ghost_suite.py b/tools/brand/ghost_suite.py index 3dd821ed..95e429db 100755 --- a/tools/brand/ghost_suite.py +++ b/tools/brand/ghost_suite.py @@ -55,7 +55,9 @@ class Produit: primaire: str profonde: str glyphe_trait: str - glyphe_plein: str + # `None` quand aucune variante icône n'a été publiée pour ce produit : on ne devine + # pas un glyphe plein à partir d'un glyphe au trait. + glyphe_plein: str | None PRODUITS = { @@ -68,12 +70,84 @@ class Produit: glyphe_trait='\n \n \n ', glyphe_plein='M 142 196 A 48 48 0 1 0 238 196 A 48 48 0 1 0 142 196 Z M 170 196 A 20 20 0 1 0 210 196 A 20 20 0 1 0 170 196 Z M 243 181 H 325 A 15 15 0 0 1 340 196 V 196 A 15 15 0 0 1 325 211 H 243 A 15 15 0 0 1 228 196 V 196 A 15 15 0 0 1 243 181 Z M 298 205 H 298 A 12 12 0 0 1 310 217 V 235 A 12 12 0 0 1 298 247 H 298 A 12 12 0 0 1 286 235 V 217 A 12 12 0 0 1 298 205 Z M 328 205 H 328 A 12 12 0 0 1 340 217 V 225 A 12 12 0 0 1 328 237 H 328 A 12 12 0 0 1 316 225 V 217 A 12 12 0 0 1 328 205 Z', ), + "ghostauth": Produit( + nom="GhostAuth", + claire="#FFA05C", + primaire="#FF6A00", + profonde="#A34400", + # Orange à 25°, dans le plus large intervalle libre restant — 55° de ghostlink + # (330°) et 29° de ghostmail (54°). Son `brand.css` déclare #00E6A8, un vert d'eau : + # comme le logo de ghostboard, ce fichier a dérivé et devra suivre. + # + # Un bouclier portant une coche. La clé de ghostpass dit « un secret que tu + # détiens » ; l'authentification dit « on a vérifié qui tu es » — c'est un verdict, + # pas un objet. Aucun autre glyphe de la suite ne l'occupe, et il tient à 16 px là + # où un trousseau ou une empreinte se brouillent. + glyphe_trait='\n ', + # Bouclier plein, coche découpée en creux : au contour, il se refermerait en tache. + glyphe_plein='M 175.0 140.0 L 260.0 117.5 L 345.0 140.0 L 345.0 192.5 A 110.0 110.0 0 0 1 260.0 267.5 A 110.0 110.0 0 0 1 175.0 192.5 Z M 207.4 199.4 L 247.5 242.8 L 317.6 166.9 L 297.4 148.1 L 247.5 202.2 L 227.6 180.6 Z', + ), + "ghostbit": Produit( + nom="GhostBit", + claire="#F050FF", + primaire="#A93BFF", + profonde="#6C1BD8", + glyphe_trait='\n ', + glyphe_plein='M 250 122 L 190 180 L 250 238 L 222 238 L 162 180 L 222 122 Z M 268 122 L 328 180 L 268 238 L 296 238 L 356 180 L 296 122 Z', + ), + "ghostmon": Produit( + nom="GhostMon", + claire="#CFFF5E", + primaire="#A3FF00", + profonde="#5BC800", + glyphe_trait='\n \n ', + glyphe_plein='M 168.7 198.8 A 13.8 13.8 0 0 1 196.3 198.8 L 196.3 218.7 A 13.8 13.8 0 0 1 168.7 218.7 Z M 246.2 163.8 A 13.8 13.8 0 0 1 273.8 163.8 L 273.8 218.7 A 13.8 13.8 0 0 1 246.2 218.7 Z M 323.7 133.8 A 13.8 13.8 0 0 1 351.3 133.8 L 351.3 218.7 A 13.8 13.8 0 0 1 323.7 218.7 Z', + ), + "ghostboard": Produit( + nom="GhostBoard", + claire="#5CFF7F", + primaire="#00FF37", + profonde="#00A323", + # Vert à 133°, décidé après avoir constaté que le logo publié (#3D7BFF) et le + # `style.css` (#FF6B1A) se contredisaient — et qu'aucune des deux teintes n'allait : + # le bleu se confondait avec ghostpass à quatre points près, l'orange est pris. + # 133° tombe dans le plus large intervalle libre de la suite : 51° de ghostmon (82°) + # et 31° de ghostauth (164°). + # + # Ce script ne reproduit donc plus le logo publié de ghostboard : c'est le logo qui + # doit être régénéré, pas cette déclaration qui doit revenir en arrière. + glyphe_trait='\n \n \n ', + glyphe_plein='M 185.0 122.5 L 230.0 122.5 A 17.5 17.5 0 0 1 247.5 140.0 L 247.5 167.5 A 17.5 17.5 0 0 1 230.0 185.0 L 185.0 185.0 A 17.5 17.5 0 0 1 167.5 167.5 L 167.5 140.0 A 17.5 17.5 0 0 1 185.0 122.5 Z M 287.5 122.5 L 332.5 122.5 A 17.5 17.5 0 0 1 350.0 140.0 L 350.0 167.5 A 17.5 17.5 0 0 1 332.5 185.0 L 287.5 185.0 A 17.5 17.5 0 0 1 270.0 167.5 L 270.0 140.0 A 17.5 17.5 0 0 1 287.5 122.5 Z M 185.0 197.5 L 230.0 197.5 A 17.5 17.5 0 0 1 247.5 215.0 L 247.5 242.5 A 17.5 17.5 0 0 1 230.0 260.0 L 185.0 260.0 A 17.5 17.5 0 0 1 167.5 242.5 L 167.5 215.0 A 17.5 17.5 0 0 1 185.0 197.5 Z M 287.5 197.5 L 332.5 197.5 A 17.5 17.5 0 0 1 350.0 215.0 L 350.0 242.5 A 17.5 17.5 0 0 1 332.5 260.0 L 287.5 260.0 A 17.5 17.5 0 0 1 270.0 242.5 L 270.0 215.0 A 17.5 17.5 0 0 1 287.5 197.5 Z', + ), + "ghostlink": Produit( + nom="GhostLink", + claire="#FF8FC6", + primaire="#FF2D95", + profonde="#B00A5E", + glyphe_trait='\n ', + glyphe_plein='M 200.0 210.0 L 240.0 170.0 A 37.5 37.5 0 0 1 292.5 222.5 L 252.5 262.5 A 37.5 37.5 0 0 1 200.0 210.0 Z M 213.8 223.8 L 253.8 183.8 A 9.9 9.9 0 0 1 278.7 208.7 L 238.7 248.7 A 9.9 9.9 0 0 1 213.8 223.8 Z M 230.0 132.5 L 270.0 92.5 A 37.5 37.5 0 0 1 322.5 145.0 L 282.5 185.0 A 37.5 37.5 0 0 1 230.0 132.5 Z M 243.8 146.3 L 283.8 106.3 A 9.9 9.9 0 0 1 308.7 131.2 L 268.7 171.2 A 9.9 9.9 0 0 1 243.8 146.3 Z', + ), + "ghostcal": Produit( + nom="GhostCal", + claire="#9CFBFF", + primaire="#00F0FF", + profonde="#0E8FA8", + glyphe_trait='\n \n \n ', + glyphe_plein='M 164 140 L 356 140 L 356 174 L 164 174 Z M 164 192 L 356 192 L 356 268 L 164 268 Z M 190 112 L 216 112 L 216 156 L 190 156 Z M 304 112 L 330 112 L 330 156 L 304 156 Z', + ), + "ghostmail": Produit( + nom="GhostMail", + claire="#FFF48A", + primaire="#FFE500", + profonde="#D9A800", + glyphe_trait='\n ', + glyphe_plein='M 160 138 L 360 138 L 260 226 Z M 160 170 L 160 268 L 360 268 L 360 170 L 260 254 Z', + ), } -# Les autres produits de la suite — ghostbit, ghostmon, ghostboard, ghostauth, ghostcal, -# ghostmail, ghostlink — partagent cette silhouette et n'attendent que leur glyphe et -# leurs teintes. On les déclare ici au fur et à mesure qu'on récupère leurs tracés, plutôt -# que d'en inventer d'approchants : un logo presque juste est pire qu'un logo absent. +# La suite est complète : sept produits, sept teintes, sept glyphes. Ceux de ghostbit, +# ghostcal, ghostmail, ghostmon, ghostboard et ghostlink ont été relevés sur leurs logos +# publiés ; celui de ghostauth a été composé ici, faute de tracé publié. # ─── Rendu ──────────────────────────────────────────────────────────────────── @@ -122,6 +196,11 @@ def icone(produit: Produit) -> str: Le cadre passe de 421 × 548 à 548 × 548 : on écarte le viewBox de part et d'autre plutôt que de déformer la silhouette. """ + if produit.glyphe_plein is None: + raise ValueError( + f"{produit.nom} n'a pas de glyphe plein : la variante icône ne peut pas être" + " produite sans lui, et l'approcher donnerait un logo faux." + ) marge = (HAUTEUR - LARGEUR) / 2 return f""" ", "", svg, flags=re.S)).strip() + + ecarts = [] + for cle, produit in sorted(PRODUITS.items()): + variantes = [("brand", logo(produit))] + if produit.glyphe_plein is not None: + variantes.append(("icon", icone(produit))) + for suffixe, attendu in variantes: + chemin = repertoire / f"{cle}-{suffixe}.svg" + if not chemin.exists(): + continue # seuls les produits dont le dépôt porte les SVG sont comparés + if normalise(chemin.read_text(encoding="utf-8")) != normalise(attendu): + ecarts.append(chemin.name) + else: + print(f"{chemin.name:28} conforme") + + if ecarts: + print(f"\nont dérivé : {', '.join(ecarts)}", file=sys.stderr) + return 1 + print("\nLes SVG du dépôt sont bien ceux que ce script produit.") + return 0 + + def main() -> int: analyseur = argparse.ArgumentParser(description=__doc__) analyseur.add_argument("produit", nargs="?", help="nom du produit à générer") analyseur.add_argument("--liste", action="store_true", help="produits déclarés") + analyseur.add_argument( + "--verifier", + action="store_true", + help="régénère et compare aux SVG du dépôt, sans rien écrire", + ) analyseur.add_argument( "--sortie", type=Path, default=Path(__file__).parent, help="répertoire de sortie" ) args = analyseur.parse_args() + if args.verifier: + return verifier(args.sortie) + if args.liste or not args.produit: for cle, p in sorted(PRODUITS.items()): print(f"{cle:12} {p.nom:12} {p.primaire}") @@ -155,7 +275,13 @@ def main() -> int: return 1 args.sortie.mkdir(parents=True, exist_ok=True) - for suffixe, contenu in (("brand", logo(produit)), ("icon", icone(produit))): + variantes = [("brand", logo(produit))] + try: + variantes.append(("icon", icone(produit))) + except ValueError as refus: + print(f"variante icône non produite — {refus}", file=sys.stderr) + + for suffixe, contenu in variantes: chemin = args.sortie / f"{args.produit}-{suffixe}.svg" chemin.write_text(contenu, encoding="utf-8") print(f"écrit {chemin}") From ce6a406a5438b5f9f6bd41d9998e406332440c57 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 13:25:16 +0200 Subject: [PATCH 45/50] =?UTF-8?q?ci:=20consigner=20que=20la=20machine=20ne?= =?UTF-8?q?=20porte=20qu'une=20suite=20=C3=A0=20la=20fois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payé à l'instant : une suite locale et un job CI ont démarré à trente secondes d'intervalle sur le même poste. Vérifier que la machine est libre avant de lancer ne suffit pas — le runner ne prévient pas, rien ne verrouille entre les deux, et c'est une course. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- tools/ci/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/ci/README.md b/tools/ci/README.md index b46a276b..199311c4 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -93,6 +93,17 @@ cd ~/.gitea-runner && PATH="$HOME/.cargo/bin:$PATH" nohup gitea-runner daemon \ >> ~/.gitea-runner/logs/daemon.log 2>&1 & ``` +**La machine ne porte qu'une suite à la fois.** `run-ios-tests.sh` prend le port 3111 en +dur, crée un simulateur et écrit dans `apps/ios/TestResults/` : deux exécutions +concurrentes se marchent dessus, et celle qui perd la course meurt sur « Le port 3111 est +déjà occupé » — un échec qui ne dit rien du code. + +Le runner ne prévient pas avant de démarrer un job, et rien ne verrouille la machine entre +lui et un lancement à la main. Vérifier que le poste est libre avant de lancer ne suffit +donc pas : c'est une course, et un job CI peut arriver dans l'intervalle. En pratique, on +attend le verdict d'un push avant de relancer une suite locale, plutôt que de courir à +côté du runner. + **Pour voir ce qu'un job a fait**, activer la conservation des logs dans `~/.gitea-runner/config.yaml` — le journal du daemon ne contient que « tâche reçue », et sans ça un échec ne laisse qu'un silence de deux minutes : From 40d9e14052795f988e76d938f71bf86f7b1d193d Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 17:02:02 +0200 Subject: [PATCH 46/50] test(ios): test04 saute enfin pour sa vraie raison, et la dit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois défauts empilés, dénoués un par un en faisant parler l'écran plutôt qu'en raisonnant sur ce qu'il devrait montrer. Il visait le champ à 23 % de la hauteur d'é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'ouvre pas », accusant le système d'une erreur de visée. Il trouve maintenant le champ dans l'arbre d'accessibilité et vise le centre de son cadre réel. Il exigeait ensuite un clavier avant de continuer, alors qu'il ne cherche pas un clavier mais l'entrée « Mots de passe ». J'ai d'abord cru qu'un simulateur sans clavier logiciel la proposerait quand même : en joignant l'écran au saut, j'ai constaté le contraire. Cette entrée vit dans la barre d'accessoires du clavier — sans clavier, pas de barre. C'est donc une limite de l'environnement, pas de GhostPass : un simulateur sans interface n'affiche aucun clavier, et ConnectHardwareKeyboard n'y peut rien puisqu'il est lu par l'application Simulator, absente de ce mode. Le test distingue désormais les deux cas et nomme le coupable dans chacun, en joignant la capture et l'arbre — que le harnais remonte dans sa sortie. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/UITests/AutoFillSafariTests.swift | 49 ++++++++++++++++++---- tools/ios/run-ios-tests.sh | 15 ++++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/apps/ios/UITests/AutoFillSafariTests.swift b/apps/ios/UITests/AutoFillSafariTests.swift index 2c3d15cc..4bc4f2a6 100644 --- a/apps/ios/UITests/AutoFillSafariTests.swift +++ b/apps/ios/UITests/AutoFillSafariTests.swift @@ -35,16 +35,51 @@ final class AutoFillSafariTests: XCTestCase { safari.typeText(page + "\n") sleep(5) - // Un tap par coordonnée : dans une vue web, le champ existe dans l'arbre - // d'accessibilité sans toujours être atteignable par un tap ordinaire. - safari.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.23)).tap() + // 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) - try XCTSkipUnless(safari.keyboards.count > 0, "le clavier ne s'est pas ouvert sur la page") + // 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 - try XCTSkipUnless( - motsDePasse.waitForExistence(timeout: 20), - "le clavier ne propose pas « Mots de passe » sur cette version d'iOS") + 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) diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh index 791144dc..2856bcb3 100755 --- a/tools/ios/run-ios-tests.sh +++ b/tools/ios/run-ios-tests.sh @@ -47,7 +47,13 @@ DEVICE="" cleanup() { local code=$? [[ -n "$BIO_PID" ]] && kill "$BIO_PID" 2>/dev/null || true - [[ -n "$PAGE_PID" ]] && kill "$PAGE_PID" 2>/dev/null || true + [[ -n "$PAGE_PID" ]] && # Le remplissage se saute encore sur certaines versions d'iOS ; le test joint alors l'écran +# qu'il a vu. On le remonte ici, sans quoi il resterait dans un journal que personne n'ouvre. +xcrun simctl spawn "$DEVICE" log show --last 5m --style compact \ + --predicate 'process == "GhostpassUITests-Runner"' 2>/dev/null | + grep -a "GP-AUTOFILL" | head -40 >&2 || true + +kill "$PAGE_PID" 2>/dev/null || true [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true # `npm start` lance tsx dans un processus fils : tuer le sous-shell le laisserait # orphelin, à écouter le port, et le run suivant se heurterait à son compte déjà créé. @@ -130,6 +136,13 @@ defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false xcrun simctl spawn "$DEVICE" defaults write .GlobalPreferences AppleLanguages -array fr xcrun simctl spawn "$DEVICE" defaults write .GlobalPreferences AppleLocale -string fr_CH +# Le remplissage automatique vit dans la barre d'accessoires du clavier : sans clavier +# logiciel, il n'y a pas de barre. Ce réglage demande au simulateur de ne pas se croire +# relié à un clavier matériel — mais il est lu par l'application Simulator, qui ne tourne +# pas dans une exécution sans interface. On le pose quand même, pour les lancements depuis +# Xcode ; `test04Remplissage` se saute en le disant quand aucun clavier ne paraît. +defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false 2>/dev/null || true + # ── Biométrie simulée ───────────────────────────────────────────────────────── # Sans inscription, `test02Biometrie` se met en skip plutôt que de passer par hasard. BIOMETRICS=0 From b3a524cd24abf3ec566088528ccc8e42e3699bad Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 17:02:14 +0200 Subject: [PATCH 47/50] =?UTF-8?q?fix(web):=20le=20th=C3=A8me=20clair=20?= =?UTF-8?q?=C3=A9tait=20blanc=20sur=20blanc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En faisant suivre le panneau d'accueil au thème plutôt qu'à un bleu figé, j'avais corrigé son titre — mais ses enfants gardaient leurs propres règles, écrites pour un panneau bleu foncé : #fff pour la marque, rgba(255,255,255,.88) pour les puces, .72 pour la mention du bas. En plein jour, le nom du produit, les trois arguments et la mention légale devenaient invisibles. Ni le type-check ni la construction ne pouvaient le voir : du CSS valide qui produit du texte illisible reste du CSS valide. Il a fallu ouvrir la page dans les deux thèmes. Les voiles de survol de la barre latérale souffraient du même mal à l'envers : rgba(20,23,28,.05), un voile sombre, invisible sur la base Dracula. Le survol avait cessé de répondre sans que rien ne le signale. Ils passent à des jetons qui éclaircissent la nuit et assombrissent le jour. Le bandeau d'alerte figeait un rouge de l'ancienne palette ; il dérive maintenant de --danger. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/web/src/app.css | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 6b806b18..e1c1a2ad 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -46,6 +46,10 @@ entier de l'élément et mangerait son propre remplissage. */ --glow-shadow: 0 0 10px rgba(46, 125, 255, 0.55), 0 0 28px rgba(46, 125, 255, 0.32); --glow-shadow-faible: 0 0 12px rgba(46, 125, 255, 0.25); + /* Voiles de survol. Sur la nuit ils éclaircissent, en plein jour ils assombrissent : un + voile sombre posé sur du sombre ne se voit pas, et le survol cesse de répondre. */ + --voile: rgba(255, 255, 255, 0.06); + --voile-fort: rgba(255, 255, 255, 0.10); --bg-gradient: linear-gradient(150deg, #080d18 0%, #0a1220 45%, #080f1a 75%, #090a10 100%); --accent: var(--brand-primary); @@ -89,6 +93,8 @@ --brand-glow: drop-shadow(0 0 6px rgba(46, 125, 255, 0.3)); --glow-shadow: none; --glow-shadow-faible: none; + --voile: rgba(15, 23, 42, 0.05); + --voile-fort: rgba(15, 23, 42, 0.09); --bg-gradient: none; --danger: #b3261e; @@ -276,7 +282,7 @@ code, } .nav-item:hover { - background: rgba(20, 23, 28, 0.05); + background: var(--voile); color: var(--ink); } @@ -474,7 +480,7 @@ code, } .tree-all:hover { - background: rgba(128, 135, 145, 0.12); + background: var(--voile-fort); color: var(--ink); } @@ -506,7 +512,7 @@ code, } .tree-folder:hover { - background: rgba(128, 135, 145, 0.12); + background: var(--voile-fort); } .tree-folder.active { @@ -1000,7 +1006,7 @@ a.kv-value:hover { content: ""; position: absolute; inset: 0; - background-image: radial-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px); + background-image: radial-gradient(var(--voile-fort) 1px, transparent 1px); background-size: 22px 22px; -webkit-mask-image: radial-gradient(120% 75% at 85% 15%, #000, transparent 70%); mask-image: radial-gradient(120% 75% at 85% 15%, #000, transparent 70%); @@ -1013,7 +1019,9 @@ a.kv-value:hover { } .auth-brand .brand { - color: #fff; + /* Ces règles supposaient un panneau bleu foncé, donc du blanc partout. Le panneau suit + maintenant le thème : en plein jour, du blanc y devient invisible. */ + color: var(--ink); font-size: 1.3rem; } @@ -1049,11 +1057,11 @@ a.kv-value:hover { align-items: flex-start; gap: 0.75rem; font-size: 0.98rem; - color: rgba(255, 255, 255, 0.88); + color: var(--ink-2); } .auth-points li strong { - color: #fff; + color: var(--ink); font-weight: 600; } @@ -1063,13 +1071,14 @@ a.kv-value:hover { flex: none; margin-top: 0.05rem; padding: 3px; - background: rgba(255, 255, 255, 0.18); + color: var(--accent-2); + background: var(--accent-soft); border-radius: 50%; } .auth-brand-foot { font-size: 0.8rem; - color: rgba(255, 255, 255, 0.72); + color: var(--ink-3); } /* Formulaire (droite) */ @@ -1674,8 +1683,10 @@ button:disabled { max-width: min(92vw, 460px); padding: 0.75rem 1rem; font-size: 0.88rem; - color: #fff; - background: #2a1614; + color: var(--ink); + /* Dérivé de --danger : figer un rouge le laissait à l'ancienne palette, et il jurait + avec les sémantiques Dracula de la suite. */ + background: color-mix(in srgb, var(--danger) 22%, var(--surface)); border: 1px solid color-mix(in srgb, var(--danger) 50%, transparent); border-radius: 10px; box-shadow: var(--shadow-md); @@ -1686,7 +1697,7 @@ button:disabled { width: 16px; height: 16px; flex: none; - color: #ff8b80; + color: var(--danger); } .toast button { From bbe1e433df57c9470067bdd2bde753fcedb74e46 Mon Sep 17 00:00:00 2001 From: claravnk Date: Thu, 27 Aug 2026 17:27:36 +0200 Subject: [PATCH 48/50] ci: ne pas tuer par motif ce que le runner partage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payé à l'instant : un pkill -f "tsx src/index.ts" destiné à mon serveur de test a emporté celui du job CI en cours. Les trois tests suivants ont échoué sur « le coffre ne s'est pas ouvert — serveur injoignable », accusant le code d'un dégât causé par le ménage. Même erreur que le pgrep qui s'attendait lui-même : un motif plus large que sa cible. Viser le PID retenu au démarrage, ou le port. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- tools/ci/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/ci/README.md b/tools/ci/README.md index 199311c4..a9e1d228 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -98,6 +98,14 @@ dur, crée un simulateur et écrit dans `apps/ios/TestResults/` : deux exécutio concurrentes se marchent dessus, et celle qui perd la course meurt sur « Le port 3111 est déjà occupé » — un échec qui ne dit rien du code. +**Et ne jamais tuer par motif ce qui décrit aussi les processus du runner.** Le harnais +lance son backend par `npm start`, c'est-à-dire `tsx src/index.ts`. Un `pkill -f "tsx +src/index.ts"` destiné à son propre serveur de test emporte donc aussi celui du job en +cours — les tests suivants échouent alors sur « le coffre ne s'est pas ouvert — serveur +injoignable », un message qui accuse le code d'un dégât causé par le ménage. Payé une +fois : viser le PID retenu au démarrage, ou le port, jamais une chaîne que le runner +partage. + Le runner ne prévient pas avant de démarrer un job, et rien ne verrouille la machine entre lui et un lancement à la main. Vérifier que le poste est libre avant de lancer ne suffit donc pas : c'est une course, et un job CI peut arriver dans l'intervalle. En pratique, on From 8fffa9bd7a88fb11179d8a553f3e6764eccaad1b Mon Sep 17 00:00:00 2001 From: claravnk Date: Fri, 28 Aug 2026 06:00:15 +0200 Subject: [PATCH 49/50] =?UTF-8?q?ci:=20option=20pour=20afficher=20le=20sim?= =?UTF-8?q?ulateur,=20et=20ce=20qu'elle=20n'a=20pas=20r=C3=A9solu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHOSTPASS_SIMULATOR_VISIBLE=1 demande à Simulator.app d'afficher l'appareil éphémère du test — ouvrir l'application sans préciser l'identifiant montre le dernier utilisé, pas celui qu'on vient de créer. Éteint par défaut : la CI n'a aucune raison d'ouvrir une fenêtre. Posé pour éprouver test04Remplissage, qui se saute faute de clavier logiciel. Cela n'a pas suffi : même affiché sur le bon appareil, clavier matériel débranché avant l'ouverture, aucun clavier n'apparaît. Trois hypothèses sont donc éliminées, et deux restent — le réglage de clavier propre à l'appareil plutôt qu'à l'application, et la possibilité qu'un tap sur un champ de vue web ne lui donne pas le focus sous automatisation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- tools/ios/run-ios-tests.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh index 2856bcb3..d470589a 100755 --- a/tools/ios/run-ios-tests.sh +++ b/tools/ios/run-ios-tests.sh @@ -111,6 +111,17 @@ print(phones[-1]["identifier"], runtime["identifier"]) DEVICE="$(xcrun simctl create "ghostpass-tests-$$" "$DEVTYPE" "$RUNTIME")" say "Simulateur éphémère $DEVICE ($DEVTYPE)" xcrun simctl boot "$DEVICE" >/dev/null 2>&1 || true +# Par défaut le simulateur reste invisible : `xcodebuild` le pilote sans interface, ce qui +# est plus rapide et convient à la CI. Mais aucun clavier logiciel n'y apparaît, et la +# barre de remplissage automatique vit dans ce clavier — `test04Remplissage` s'y saute +# donc toujours. GHOSTPASS_SIMULATOR_VISIBLE=1 demande à Simulator.app d'afficher **cet** +# appareil : ouvrir l'application sans préciser l'identifiant montre le dernier utilisé, +# pas l'éphémère qu'on vient de créer. +if [[ "${GHOSTPASS_SIMULATOR_VISIBLE:-}" == "1" ]]; then + say "Simulateur visible : $DEVICE" + open -a Simulator --args -CurrentDeviceUDID "$DEVICE" + sleep 8 +fi # `simctl bootstatus -b` peut ne jamais rendre la main sur un simulateur qu'on vient de # créer — en intégration continue, le job tournerait alors jusqu'à son propre délai. # On interroge nous-mêmes, avec une borne. From 4f3aec9e00da63ec24f700400d1b3b4ad34dda9c Mon Sep 17 00:00:00 2001 From: claravnk Date: Fri, 28 Aug 2026 10:47:40 +0200 Subject: [PATCH 50/50] =?UTF-8?q?fix(import):=20les=20notes=20=C3=A9taient?= =?UTF-8?q?=20perdues=20=C3=A0=20chaque=20migration,=20des=20deux=20c?= =?UTF-8?q?=C3=B4t=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tout coffre importé depuis un autre gestionnaire arrivait sans ses notes. iOS posait `notes: nil` en dur ; le web ne passait simplement pas le champ, que son encryptItem sait pourtant porter. Silencieux dans les deux cas — l'import annonçait le bon nombre d'entrées. Deux dossiers se perdaient aussi : LastPass nomme le sien `grouping`, Dashlane `category`. Aucun des deux n'était reconnu, et des noms devinés ne les auraient pas trouvés — c'est en relevant les en-têtes réels des exports que ça se voit. La table couvre désormais Bitwarden, 1Password, LastPass, Dashlane, Chrome et KeePass, avec ses sources en commentaire. Elle est identique des deux côtés : migrer depuis le téléphone et migrer depuis le navigateur doivent donner le même coffre. 7 tests, un par gestionnaire, écrits avec leurs en-têtes réels. Le dernier vérifie qu'une note de plusieurs lignes contenant virgules et guillemets survit entière. 97 tests de contrat au total. Reste ignoré : les colonnes favorite et fav. Les favoris vivent dans un registre indexé par identifiant d'item, et ces identifiants n'existent qu'après dépôt — les importer demande une seconde passe. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h --- apps/ios/Ghostpass/Services/CsvImport.swift | 21 +++- apps/ios/Tests/ContractTests.swift | 108 ++++++++++++++++++++ apps/web/src/App.svelte | 15 ++- tools/ios/run-ios-tests.sh | 30 ++++++ 4 files changed, 165 insertions(+), 9 deletions(-) diff --git a/apps/ios/Ghostpass/Services/CsvImport.swift b/apps/ios/Ghostpass/Services/CsvImport.swift index 14527a9d..c8ee8861 100644 --- a/apps/ios/Ghostpass/Services/CsvImport.swift +++ b/apps/ios/Ghostpass/Services/CsvImport.swift @@ -84,18 +84,29 @@ enum CsvImport { /// c'est ce que contiennent les exports des gestionnaires concurrents. static func items(_ texte: String) -> [VaultItem] { enregistrements(texte).map { ligne in - let dossier = premier(ligne, "folder", "vault", "group") - let totp = premier(ligne, "totp", "login_totp", "otpauth") - let adresse = premier(ligne, "url", "login_uri", "website", "uri") + // 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: nil, + notes: note.isEmpty ? nil : note, folder: dossier.isEmpty ? nil : dossier, data: .login( Login( - username: premier(ligne, "username", "login_username", "login"), + username: premier(ligne, "username", "login_username", "login", "user"), password: premier(ligne, "password", "login_password"), uris: adresse.isEmpty ? [] : [adresse], totp: totp.isEmpty ? nil : totp))) diff --git a/apps/ios/Tests/ContractTests.swift b/apps/ios/Tests/ContractTests.swift index 6923a705..29b3ba5d 100644 --- a/apps/ios/Tests/ContractTests.swift +++ b/apps/ios/Tests/ContractTests.swift @@ -1360,3 +1360,111 @@ final class AdministrationDEquipeTests: XCTestCase { 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/web/src/App.svelte b/apps/web/src/App.svelte index 73b90797..69368fc6 100644 --- a/apps/web/src/App.svelte +++ b/apps/web/src/App.svelte @@ -136,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++; diff --git a/tools/ios/run-ios-tests.sh b/tools/ios/run-ios-tests.sh index d470589a..85a37d2d 100755 --- a/tools/ios/run-ios-tests.sh +++ b/tools/ios/run-ios-tests.sh @@ -16,6 +16,14 @@ # d'écran comprises), pour inspecter ce qu'a vu le test. set -euo pipefail +# `rustup` et `cargo` vivent dans ~/.cargo/bin, ajouté au PATH par le profil du shell — que +# ni un daemon lancé par nohup ni certains shells non interactifs ne chargent. Plutôt que de +# demander à chacun d'exporter le PATH avant d'appeler ce script, on le complète ici. +if ! command -v rustup >/dev/null && [[ -x "$HOME/.cargo/bin/rustup" ]]; then + PATH="$HOME/.cargo/bin:$PATH" + export PATH +fi + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" IOS="$ROOT/apps/ios" # Figé : les tests portent la même valeur par défaut (voir VaultFlowTests). @@ -302,6 +310,28 @@ say "Remplissage automatique dans Safari" (cd "$ROOT/tools/ios/testpage" && python3 -m http.server 8099 --bind 127.0.0.1 >/dev/null 2>&1) & PAGE_PID=$! +# `pluginkit -e use` enregistre l'extension, mais iOS exige en plus qu'elle soit cochée +# dans Réglages > Général > Saisie automatique — un geste qui ne se scripte pas. Cette +# pause laisse le faire à la main, simulateur visible, avant que le test ne s'exécute. +# Une pause *minutée*, et non une attente de touche : cette commande finit souvent en +# arrière-plan, où `read &2 + echo "" >&2 + echo " 1. Cliquer dans la fenêtre du simulateur, puis Cmd-K — « Toggle Software" >&2 + echo " Keyboard ». C'est le blocage principal : sous automatisation, iOS croit" >&2 + echo " qu'un clavier matériel est branché et n'affiche jamais le clavier logiciel." >&2 + echo " Or la barre de remplissage vit dans ce clavier." >&2 + echo "" >&2 + echo " 2. Réglages > Général > Saisie automatique : activer, et cocher GhostPass." >&2 + echo " Ce réglage ne s'écrit pas par script — il vit dans un magasin système que" >&2 + echo " \`defaults\` n'atteint pas. Il n'est utile qu'une fois le clavier obtenu :" >&2 + echo " le test contrôle le clavier avant le fournisseur." >&2 + sleep "$GHOSTPASS_PAUSE_REMPLISSAGE" +fi + if xcrun simctl spawn "$DEVICE" pluginkit -e use -i ch.stackops.ghostpass.autofill 2>/dev/null; then if ! run_tests GhostpassUITests/AutoFillSafariTests/test04Remplissage; then kill "$PAGE_PID" 2>/dev/null || true