Conversation
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.
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.
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…qui expire 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…suite `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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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 <produit>. » 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…arées 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…unner 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…ng mobile 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…ance 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…ouer
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…, rotation 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…deux
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
…ôtés 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AbzwMAWY2PEnXK4WvZq3h
Contributor
Author
|
Closing: this PR only existed to run CI back when the forge had no runner for the Do not merge anything here. GitHub is a force-pushed mirror of the forge -- a merge made on this side is erased at the next sync, as happened to ghostcal PR #103. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mirror of forge PR #34 (source of truth: https://git.stackops.ch/stackops/ghostpass/pulls/34).
Opened here only to run CI: the forge has no registered runner, so PRs there carry no checks.
Locally, the three commands of the
Cœur crypto (Rust)job all exit 0:cargo fmt --all --check,cargo clippy --all-targets -- -D warnings,cargo test --all(31 tests, 6 new).