Skip to content

feat(ffi): expose the crypto core to mobile through UniFFI - #34

Closed
ClaraVnk wants to merge 51 commits into
mainfrom
feat/ios-app
Closed

ClaraVnk wants to merge 51 commits into
mainfrom
feat/ios-app

Conversation

@ClaraVnk

Copy link
Copy Markdown
Contributor

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).

ClaraVnk and others added 30 commits August 25, 2026 14:56
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
ClaraVnk and others added 21 commits August 26, 2026 23:39
…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
@ClaraVnk

Copy link
Copy Markdown
Contributor Author

Closing: this PR only existed to run CI back when the forge had no runner for the stackops org. A runner has served the forge since 2026-08-26, and the review of record lives there: https://git.stackops.ch/stackops/ghostpass/pulls/34

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.

@ClaraVnk ClaraVnk closed this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant