feat(android): folder filter, icons, sort, template, updated_at, FLAG_SECURE

Works through the plan's §2 and §3 items that need no hardware and no
platform decision.

- allowBackup="false". The private directory holds vault.bin; a cloud
  backup has no business in a password manager's path, even though what
  would leave is encrypted by a key that stays in the chip.
- FLAG_SECURE, Release builds only - in debug it also blocks development
  screenshots. Complementary to hiding on WillBecomeInactive rather than
  redundant: that one covers the recents preview, the flag also covers
  manual captures, screen recording and casting to an external display.
- List sorted by name (snapshot order is DB order, arbitrary to the eye),
  entry icons decoded once per vault rather than on every keystroke, and a
  folder dropdown whose selection survives a background refresh. No tag
  filter: text search already matches tags.
- template and updated_at read and shown in the detail. updated_at is also
  step 1 of the write path - nothing is arbitrable without it.

Android code is now type-checked without deploying: dccaarm64 reaches the
link step and fails only on the NDK's ldandroid.exe. A JNI declaration that
does not compile no longer waits for an APK to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 11:23:29 +01:00
parent 3bb6f6f903
commit 9a4170c44f
7 changed files with 288 additions and 42 deletions
+54 -29
View File
@@ -61,17 +61,19 @@ retéléchargé, pas rester illisible. C'est l'autre branche de la même garde.
Par ordre de gravité, pas de taille.
1. **`android:allowBackup="false"`** dans
[AndroidManifest.template.xml](../AndroidManifest.template.xml). Une ligne.
Aujourd'hui absente : la sauvegarde Google peut donc emporter `vault.bin`.
Le fichier est chiffré par une clé qui ne quitte pas la puce, donc ce qui
sort est inexploitable — mais il sort, et le README prescrit déjà l'inverse.
2. **Retirer `usesCleartextTraffic="true"`** du même fichier, et servir
1. ~~**`android:allowBackup="false"`**~~ — **fait**. Le répertoire privé
contient `vault.bin` ; la sauvegarde Google n'a rien à y faire, même si ce
qui en sortirait serait chiffré par une clé restée dans la puce.
2. **Retirer `usesCleartextTraffic="true"`** du manifeste, et servir
l'endpoint en HTTPS avec un vrai certificat. L'attribut est marqué
TEST-ONLY dans le manifeste ; le jeton Bearer circule à chaque appel.
3. **`FLAG_SECURE`** sur la fenêtre. On masque déjà l'écran avant la capture
des applications récentes, mais rien n'empêche une capture d'écran
manuelle. `getWindow().setFlags` par JNI, quelques lignes.
TEST-ONLY ; le jeton Bearer circule à chaque appel. **Gardé pour l'instant** :
le retirer casserait l'essai local par `adb reverse`, qui passe en clair.
3. ~~**`FLAG_SECURE`**~~ — **fait**, [PMA.ScreenGuard.pas](PMA.ScreenGuard.pas),
**en Release seulement** : en debug le drapeau interdit aussi les captures
d'écran de développement. Le vérifier demande donc une compilation Release.
Complémentaire du masquage sur `WillBecomeInactive`, pas redondant : celui-ci
traite l'aperçu des récentes, le drapeau traite en plus les captures
manuelles, l'enregistrement d'écran et la duplication vers un écran externe.
4. **Marquer le presse-papiers sensible** — depuis Android 13 le système
affiche un aperçu de ce qui vient d'être copié, mot de passe compris.
`ClipDescription` + extra `android.content.extra.IS_SENSITIVE`.
@@ -89,17 +91,23 @@ Par ordre de gravité, pas de taille.
Rangé par ce que ça apporte réellement sur un téléphone.
1. **Pièces jointes** — elles sont dans l'instantané, personne ne les lit.
Lister nom/taille par entrée, et enregistrer via le sélecteur système.
Attention à la mémoire : `content_b64` est déjà décodé en RAM avec tout le
reste de l'instantané, un coffre avec des PDF pèse lourd.
2. **Filtres dossier et étiquette.** `TVault.Search` ne prend qu'une requête
texte ; ajouter deux paramètres facultatifs et une rangée de puces
au-dessus de la liste. Les dossiers viennent du tableau `folders`.
3. **Icônes** (`icon_b64`) en tête de ligne, à la place des initiales.
Purement cosmétique, mais c'est ce qui rend une liste balayable.
4. **`template`** — afficher le sous-type (carte, clé SSH…) comme le fait le
poste Windows, et adapter les intitulés d'une carte bancaire.
5. **Tri.** L'ordre actuel est celui de l'instantané. Par nom, au minimum.
Lister nom/taille par entrée, et enregistrer. **Décision à prendre avant
d'écrire quoi que ce soit** : où enregistrer sur Android 10+. Le répertoire
privé de l'application ne sert à rien à l'utilisateur, et *Téléchargements*
passe par `MediaStore` ou un `Intent` de partage. C'est cette question, pas
le code, qui bloque. Attention aussi à la mémoire : `content_b64` est déjà
décodé en RAM avec le reste de l'instantané, un coffre avec des PDF pèse.
2. ~~**Filtre dossier**~~ — **fait**, liste déroulante à côté de la recherche,
alimentée par le tableau `folders`, et le choix survit à un rafraîchissement
de fond. **Pas de filtre d'étiquette** : la recherche texte couvre déjà les
étiquettes, en taper le nom suffit.
3. ~~**Icônes**~~ (`icon_b64`) — **fait**, décodées une fois par coffre et non à
chaque frappe (la liste est reconstruite à chaque caractère). Une icône SVG
n'est pas chargeable par FMX : l'entrée retombe sur ses initiales.
4. ~~**`template`**~~ — **fait**, affiché comme *Type* dans le détail. Adapter
en plus les intitulés d'une carte bancaire reste à voir.
5. ~~**Tri par nom**~~ — **fait** dans `TVault.Search`. L'ordre de l'instantané
est celui de la base, donc arbitraire à l'œil.
6. **`TListView` à la place de `TListBox`** le jour où la reconstruction à
chaque frappe se voit. Marqué `ponytail:` dans le code, pas avant.
@@ -128,9 +136,8 @@ d'interface d'édition.
### Dans l'ordre
1. **`updated_at` dans `TVaultEntry`.** Rien n'est arbitrable sans lui. À faire
même si l'écriture est repoussée : la lecture peut déjà afficher « modifié
le… ».
1. ~~**`updated_at` dans `TVaultEntry`**~~ — **fait**, et déjà affiché comme
*Modifié le* dans le détail. Rien n'était arbitrable sans lui.
2. **AES-GCM en chiffrement.** On n'a que le déchiffrement, des deux côtés
(JNI et CNG). Il faut aussi un sel de 32 octets et un IV de 12 octets tirés
d'un générateur cryptographique — `SecureRandom` côté Android,
@@ -171,8 +178,26 @@ tombstones ni génération d'uuid.
## Ordre proposé
1. Vérifier §1 sur téléphone — c'est ce qui valide le travail déjà fait.
2. §2 items 1 et 2 : deux lignes de manifeste, et le risque le plus concret.
3. §3 items 1 à 3 : pièces jointes, filtres, icônes. Le client devient
réellement utilisable au quotidien.
4. §4 étape 1 seule (`updated_at`), puis décider si l'écriture vaut son coût.
1. **Vérifier §1 sur téléphone** — c'est ce qui valide le travail déjà fait, et
rien d'autre ne peut le remplacer.
2. §2 item 4 (presse-papiers sensible) puis 6 (verrouillage sur inactivité).
3. §3 item 1, une fois tranché où atterrissent les pièces jointes.
4. §4 à partir de l'étape 2, si l'écriture vaut son coût.
---
## Vérifier le code Android sans le déployer
Le compilateur croisé va jusqu'à l'édition de liens, qui échoue seulement
parce que `ldandroid.exe` appartient au NDK et n'est pas dans le `PATH` :
```bash
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dccaarm64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\android64\release" pmserverApk.dpr
```
`F1026 File not found: 'ldandroid.exe'` **sans erreur au-dessus** signifie que
tout a compilé. C'est ce qui permet de valider des déclarations JNI sans
attendre un APK — le pont construit ses signatures à partir des types déclarés,
et une signature fausse ne se voyait jusqu'ici qu'à l'exécution. Cela ne
prouve pas qu'une signature *correspond* à une méthode Java réelle, seulement
qu'elle est cohérente : le déploiement reste le juge final.
+48
View File
@@ -0,0 +1,48 @@
unit PMA.ScreenGuard;
{
FLAG_SECURE : interdit la capture d'ecran et l'apparition du contenu dans
l'apercu des applications recentes.
COMPLEMENTAIRE, pas redondant, avec le masquage sur WillBecomeInactive :
celui-ci traite l'apercu des recentes, celui-la traite en plus les captures
manuelles, l'enregistrement d'ecran et la duplication vers un ecran externe -
qu'aucun code applicatif ne peut intercepter.
ACTIF EN RELEASE SEULEMENT. En debug le drapeau empeche aussi les captures
d'ecran de developpement, ce qui rend le diagnostic penible pour un gain nul
sur un appareil de test. Le verifier demande donc une compilation Release.
}
interface
/// A appeler une fois, au demarrage, depuis le thread principal.
procedure SecureScreen;
implementation
{$IF DEFINED(ANDROID) AND NOT DEFINED(DEBUG)}
uses
// System.SysUtils : sans elle, TAndroidHelper.GetJActivity reste un appel
// non expanse et le compilateur le signale a chaque usage.
System.SysUtils, Androidapi.Helpers, Androidapi.JNI.App,
Androidapi.JNI.GraphicsContentViewText, FMX.Helpers.Android;
procedure SecureScreen;
begin
// setFlags touche la fenetre : thread d'interface obligatoire.
CallInUIThread(
procedure
begin
TAndroidHelper.Activity.getWindow.setFlags(
TJWindowManager_LayoutParams.JavaClass.FLAG_SECURE,
TJWindowManager_LayoutParams.JavaClass.FLAG_SECURE);
end);
end;
{$ELSE}
procedure SecureScreen;
begin
// Rien a faire : ni Windows ni une compilation de debogage.
end;
{$ENDIF}
end.
+52 -4
View File
@@ -25,6 +25,14 @@ type
TVaultEntry = record
Uuid, Title, Site, Username, Password, Folder, Tags, Kind, Totp: string;
/// Sous-type (carte, cle SSH, serveur...). Vide pour un identifiant ou
/// une note ordinaire.
Template: string;
/// Icone en data URI. Non chiffree, purement cosmetique.
IconB64: string;
/// ISO 8601 UTC. Sert d'abord a l'affichage ; ce sera l'arbitre de la
/// fusion le jour ou le client ecrira.
UpdatedAt: string;
Fields: TArray<TVaultField>;
Deleted: Boolean;
/// Le corps d'une note EST son mot de passe cote stockage : il n'y a pas
@@ -39,6 +47,7 @@ type
TVault = class
private
FEntries: TArray<TVaultEntry>;
FFolders: TArray<string>;
FUsername: string;
FExportedAt: string;
public
@@ -47,12 +56,22 @@ type
/// Efface le contenu. Appele au verrouillage.
procedure Clear;
/// Indices des entrees correspondant a AQuery, dans l'ordre d'affichage.
/// Indices des entrees correspondant a AQuery, TRIES par nom affiche -
/// l'ordre de l'instantane est celui de la base, donc arbitraire a l'oeil.
/// Requete vide = tout ce qui n'est pas en corbeille.
///
/// La recherche porte sur le nom, le site, l'identifiant et les tags -
/// PAS sur le mot de passe : personne ne cherche par mot de passe, et le
/// comparer allongerait inutilement son sejour en memoire de travail.
function Search(const AQuery: string; AIncludeTrash: Boolean = False): TArray<Integer>;
/// Comme les etiquettes sont couvertes par la recherche texte, il n'y a
/// pas de filtre d'etiquette separe : taper son nom suffit.
///
/// AFolder vide = tous les dossiers.
function Search(const AQuery: string; const AFolder: string = '';
AIncludeTrash: Boolean = False): TArray<Integer>;
/// Dossiers declares par l'instantane, dans son ordre.
property Folders: TArray<string> read FFolders;
function Count: Integer;
function LiveCount: Integer;
@@ -66,7 +85,7 @@ type
implementation
uses
System.Character;
System.Character, System.Generics.Defaults;
{ TVaultEntry }
@@ -88,6 +107,7 @@ end;
procedure TVault.Clear;
begin
SetLength(FEntries, 0);
SetLength(FFolders, 0);
FUsername := '';
FExportedAt := '';
end;
@@ -155,6 +175,21 @@ begin
FExportedAt := LRoot.GetValue<string>('exported_at',
LRoot.GetValue<string>('snapshot_at', ''));
// Dossiers : l'instantane porte {name, color, icon}, seul le nom sert ici.
// Un instantane ancien peut ne pas avoir le tableau du tout.
if LRoot.TryGetValue<TJSONArray>('folders', LArr) then
begin
SetLength(FFolders, LArr.Count);
N := 0;
for I := 0 to LArr.Count - 1 do
begin
if not (LArr.Items[I] is TJSONObject) then Continue;
FFolders[N] := (LArr.Items[I] as TJSONObject).GetValue<string>('name', '');
if FFolders[N] <> '' then Inc(N);
end;
SetLength(FFolders, N);
end;
if not LRoot.TryGetValue<TJSONArray>('entries', LArr) then
raise Exception.Create('Instantane sans tableau "entries"');
@@ -174,6 +209,9 @@ begin
FEntries[N].Totp := LObj.GetValue<string>('totp_secret', '');
FEntries[N].Tags := TagsToString(LObj.GetValue('tags'));
FEntries[N].Fields := ParseFields(LObj.GetValue('custom_fields'));
FEntries[N].Template := LObj.GetValue<string>('template', '');
FEntries[N].IconB64 := LObj.GetValue<string>('icon_b64', '');
FEntries[N].UpdatedAt := LObj.GetValue<string>('updated_at', '');
// deleted arrive en 0/1 depuis nos conteneurs ; absent = vivant.
FEntries[N].Deleted := LObj.GetValue<Integer>('deleted', 0) = 1;
Inc(N);
@@ -206,7 +244,8 @@ begin
Result := FEntries[AIndex];
end;
function TVault.Search(const AQuery: string; AIncludeTrash: Boolean): TArray<Integer>;
function TVault.Search(const AQuery: string; const AFolder: string;
AIncludeTrash: Boolean): TArray<Integer>;
var
LQ: string;
LRes: TList<Integer>;
@@ -230,12 +269,21 @@ begin
begin
E := FEntries[I];
if E.Deleted and not AIncludeTrash then Continue;
if (AFolder <> '') and not SameText(E.Folder, AFolder) then Continue;
if Matches(E) then LRes.Add(I);
end;
Result := LRes.ToArray;
finally
LRes.Free;
end;
// Tri sur le nom affiche, insensible a la casse et a l'accentuation faible
// de CompareText - suffisant pour une liste qu'on parcourt a l'oeil.
TArray.Sort<Integer>(Result, TComparer<Integer>.Construct(
function(const A, B: Integer): Integer
begin
Result := CompareText(FEntries[A].Display, FEntries[B].Display);
end));
end;
end.
+7
View File
@@ -91,6 +91,12 @@ le champ mot de passe et s'affiche donc sous un autre intitulé, sans masque ;
et le **code 2FA**, seule ligne vivante, rafraîchie chaque seconde et placée en
tête — c'est ce qu'on vient chercher sur un téléphone.
La liste est triée par nom — l'ordre de l'instantané est celui de la base,
arbitraire à l'œil — et porte l'icône de l'entrée quand elle en a une, décodée
une fois par coffre et non à chaque frappe. Une liste déroulante filtre par
dossier ; il n'y a **pas** de filtre d'étiquette, la recherche texte les
couvrant déjà.
Le TOTP ([PMA.Totp.pas](PMA.Totp.pas)) accepte le base32 nu comme l'URI
`otpauth://`, les deux circulant dans les instantanés selon la façon dont
l'entrée a été créée. Vérifié contre les six vecteurs de la RFC 6238.
@@ -158,6 +164,7 @@ Node ouvre le fichier et pas le client, le défaut est dans le client.
| `PMA.Biometric.pas` | authentification via `FMX.BiometricAuth` |
| `PMA.Vault.pas` | l'instantané en mémoire : lecture et recherche |
| `PMA.Totp.pas` | codes TOTP (RFC 6238), base32 ou `otpauth://` |
| `PMA.ScreenGuard.pas` | `FLAG_SECURE` (Release seulement) |
| `selftest.dpr` | vérification console : conteneur + TOTP |
| `PMA.TestVector.pas` | le vecteur embarqué, plus référencé par l'interface |
| `make-testvector.js` | régénère le vecteur avec WebCrypto |