feat(android): TOTP codes and custom fields in the detail screen
The detail screen showed site, username and password and silently dropped everything else - custom fields, notes, 2FA. It is now built per entry rather than from a fixed template: every row is a caption, a value, and whether it is secret, so reveal and copy exist once instead of once per kind of field. Two rows stay special on purpose. A note's body is stored in the password column, so it gets its own caption and no mask. The 2FA code is the only live row - refreshed every second, placed first, since it is what you pull a phone out for. PMA.Totp takes bare base32 or an otpauth:// URI; both travel in snapshots depending on how the entry was created. Checked against all six RFC 6238 vectors, including T=20000000000, which overflows 32 bits. The rows hold plaintext, so clearing them is part of locking, not of navigating away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+184
-61
@@ -56,12 +56,20 @@ const
|
||||
{$I android-client\PMA.LocalConfig.inc}
|
||||
|
||||
type
|
||||
/// Une ligne du detail, avant tout controle. Site, identifiant, mot de
|
||||
/// passe et champs personnalises se ramenent tous a la meme chose : un
|
||||
/// intitule, une valeur, et le fait qu'elle soit secrete ou non. Les traiter
|
||||
/// separement dupliquerait revelation et copie quatre fois.
|
||||
TDetRow = record
|
||||
Caption, Value: string;
|
||||
Secret: Boolean;
|
||||
end;
|
||||
|
||||
TForm8 = class(TForm)
|
||||
private
|
||||
FVault: TVault;
|
||||
FFiltered: TArray<Integer>; // indices dans FVault, resultat de la recherche
|
||||
FCurrent: TVaultEntry;
|
||||
FPwdShown: Boolean;
|
||||
// L'ETag n'est conserve QUE si le dechiffrement qui a suivi a reussi :
|
||||
// envoyer If-None-Match sans detenir le clair correspondant fait
|
||||
// economiser un telechargement pour se retrouver sans donnees.
|
||||
@@ -83,10 +91,21 @@ type
|
||||
FCount: TLabel;
|
||||
|
||||
// --- ecran 3 : detail
|
||||
// Les lignes sont construites PAR ENTREE : leur nombre depend des champs
|
||||
// personnalises. On garde de quoi les detruire et de quoi retrouver
|
||||
// l'etiquette de valeur d'une ligne pour la reveler.
|
||||
FLayDetail: TLayout;
|
||||
FDetTitle: TLabel;
|
||||
FDetSite, FDetUser, FDetPwd, FDetMeta, FDetToast: TLabel;
|
||||
FBtnReveal: TButton;
|
||||
FDetBody: TVertScrollBox;
|
||||
FDetTitle, FDetToast: TLabel;
|
||||
FRows: TArray<TDetRow>;
|
||||
FRowCtls: TArray<TLayout>;
|
||||
FRowValues: TArray<TLabel>;
|
||||
FRowShown: TArray<Boolean>;
|
||||
|
||||
// --- TOTP : une ligne vivante, rafraichie chaque seconde
|
||||
FTotpTimer: TTimer;
|
||||
FTotpValue: TLabel;
|
||||
FTotpCode: string;
|
||||
|
||||
// --- presse-papiers
|
||||
FClipTimer: TTimer;
|
||||
@@ -99,6 +118,8 @@ type
|
||||
procedure ShowUnlock;
|
||||
procedure ShowList;
|
||||
procedure ShowDetail(AVaultIndex: Integer);
|
||||
procedure ClearDetailRows;
|
||||
procedure OnTotpTick(Sender: TObject);
|
||||
|
||||
procedure DoOpenLocal(Sender: TObject);
|
||||
// Separee de DoOpenLocal : une methode anonyme ne peut pas appeler une
|
||||
@@ -135,7 +156,7 @@ var
|
||||
implementation
|
||||
|
||||
uses
|
||||
PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric;
|
||||
PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp;
|
||||
|
||||
{$R *.fmx}
|
||||
|
||||
@@ -167,6 +188,11 @@ begin
|
||||
FClipTimer.Interval := CLIPBOARD_CLEAR_SECONDS * 1000;
|
||||
FClipTimer.OnTimer := OnClipTimer;
|
||||
|
||||
FTotpTimer := TTimer.Create(Self);
|
||||
FTotpTimer.Enabled := False;
|
||||
FTotpTimer.Interval := 1000;
|
||||
FTotpTimer.OnTimer := OnTotpTick;
|
||||
|
||||
// Le bouton retour materiel arrive comme une touche : sans ce gestionnaire
|
||||
// il ferme l'application depuis le detail, au lieu de revenir a la liste.
|
||||
OnKeyUp := OnHardwareBack;
|
||||
@@ -303,33 +329,19 @@ begin
|
||||
FSearch.OnChangeTracking := OnSearchChanged;
|
||||
end;
|
||||
|
||||
/// Une ligne du detail : intitule discret, valeur, bouton Copier.
|
||||
/// ATag porte ce qu'il faut copier (1 site, 2 identifiant, 3 mot de passe).
|
||||
function DetailRow(AParent: TFmxObject; const ACaption: string; ATag: Integer;
|
||||
AOnCopy: TNotifyEvent; out AValue: TLabel): TLayout;
|
||||
/// Intitule discret au-dessus d'une valeur. Meme forme pour toutes les
|
||||
/// lignes, y compris celle du TOTP.
|
||||
function MakeRow(AParent: TFmxObject; const ACaption: string;
|
||||
out AValue: TLabel): TLayout;
|
||||
var
|
||||
LCap: TLabel;
|
||||
LBtn: TButton;
|
||||
begin
|
||||
Result := TLayout.Create(AParent);
|
||||
Result := TLayout.Create(nil); // detruite a la main, voir ClearDetailRows
|
||||
Result.Parent := AParent;
|
||||
Result.Align := TAlignLayout.Top;
|
||||
Result.Height := 62;
|
||||
Result.Margins.Rect := RectF(12, 10, 12, 0);
|
||||
|
||||
// Pas de bouton sur une ligne purement informative (dossier, etiquettes) :
|
||||
// un "Copier" inerte se clique quand meme.
|
||||
if Assigned(AOnCopy) then
|
||||
begin
|
||||
LBtn := TButton.Create(Result);
|
||||
LBtn.Parent := Result;
|
||||
LBtn.Align := TAlignLayout.Right;
|
||||
LBtn.Width := 90;
|
||||
LBtn.Text := 'Copier';
|
||||
LBtn.Tag := ATag;
|
||||
LBtn.OnClick := AOnCopy;
|
||||
end;
|
||||
|
||||
LCap := TLabel.Create(Result);
|
||||
LCap.Parent := Result;
|
||||
LCap.Align := TAlignLayout.Top;
|
||||
@@ -346,9 +358,21 @@ begin
|
||||
AValue.TextSettings.Font.Size := 16;
|
||||
end;
|
||||
|
||||
function AddRowButton(ARow: TLayout; const AText: string; ATag: Integer;
|
||||
AOnClick: TNotifyEvent): TButton;
|
||||
begin
|
||||
Result := TButton.Create(ARow);
|
||||
Result.Parent := ARow;
|
||||
Result.Align := TAlignLayout.Right;
|
||||
Result.Margins.Left := 6;
|
||||
Result.Width := 90;
|
||||
Result.Text := AText;
|
||||
Result.Tag := ATag;
|
||||
Result.OnClick := AOnClick;
|
||||
end;
|
||||
|
||||
procedure TForm8.BuildDetail;
|
||||
var
|
||||
LRowPwd: TLayout;
|
||||
LBtnBack: TButton;
|
||||
begin
|
||||
FLayDetail := TLayout.Create(Self);
|
||||
@@ -374,20 +398,11 @@ begin
|
||||
FDetToast.TextSettings.Font.Size := 12;
|
||||
FDetToast.Opacity := 0.7;
|
||||
|
||||
// Du bas vers le haut : dossier/tags, mot de passe, identifiant, site, titre.
|
||||
DetailRow(FLayDetail, 'Dossier / etiquettes', 0, nil, FDetMeta);
|
||||
|
||||
LRowPwd := DetailRow(FLayDetail, 'Mot de passe', 3, OnCopyTap, FDetPwd);
|
||||
FBtnReveal := TButton.Create(LRowPwd);
|
||||
FBtnReveal.Parent := LRowPwd;
|
||||
FBtnReveal.Align := TAlignLayout.Right;
|
||||
FBtnReveal.Margins.Right := 6;
|
||||
FBtnReveal.Width := 90;
|
||||
FBtnReveal.Text := 'Afficher';
|
||||
FBtnReveal.OnClick := OnRevealTap;
|
||||
|
||||
DetailRow(FLayDetail, 'Identifiant', 2, OnCopyTap, FDetUser);
|
||||
DetailRow(FLayDetail, 'Site', 1, OnCopyTap, FDetSite);
|
||||
// Defilant : une entree avec dix champs personnalises depasse l'ecran d'un
|
||||
// telephone, et les lignes ne sont connues qu'a l'ouverture.
|
||||
FDetBody := TVertScrollBox.Create(Self);
|
||||
FDetBody.Parent := FLayDetail;
|
||||
FDetBody.Align := TAlignLayout.Client;
|
||||
|
||||
FDetTitle := TLabel.Create(Self);
|
||||
FDetTitle.Parent := FLayDetail;
|
||||
@@ -428,6 +443,8 @@ end;
|
||||
|
||||
procedure TForm8.ShowList;
|
||||
begin
|
||||
// Quitter le detail rend ses valeurs en clair, et arrete le compte a rebours.
|
||||
ClearDetailRows;
|
||||
FLayUnlock.Visible := False;
|
||||
FLayDetail.Visible := False;
|
||||
FLayList.Visible := True;
|
||||
@@ -435,19 +452,55 @@ begin
|
||||
OnSearchChanged(nil);
|
||||
end;
|
||||
|
||||
procedure TForm8.ClearDetailRows;
|
||||
var
|
||||
LRow: TLayout;
|
||||
begin
|
||||
// Appelable avant la fin du constructeur (ShowList), d'ou la garde.
|
||||
if FTotpTimer <> nil then FTotpTimer.Enabled := False;
|
||||
FTotpValue := nil;
|
||||
FTotpCode := '';
|
||||
// Detruites explicitement plutot que par DeleteChildren : le conteneur est
|
||||
// un TVertScrollBox, dont les enfants comprennent ses propres barres.
|
||||
for LRow in FRowCtls do LRow.Free;
|
||||
SetLength(FRowCtls, 0);
|
||||
SetLength(FRowValues, 0);
|
||||
SetLength(FRowShown, 0);
|
||||
SetLength(FRows, 0);
|
||||
end;
|
||||
|
||||
procedure TForm8.ShowDetail(AVaultIndex: Integer);
|
||||
var
|
||||
LMeta: string;
|
||||
begin
|
||||
FCurrent := FVault.Entry(AVaultIndex);
|
||||
FPwdShown := False;
|
||||
LMeta, LPwdCaption: string;
|
||||
I: Integer;
|
||||
LRow: TLayout;
|
||||
LValue: TLabel;
|
||||
|
||||
procedure Add(const ACaption, AValue: string; ASecret: Boolean);
|
||||
begin
|
||||
if AValue = '' then Exit; // une ligne vide n'apprend rien
|
||||
SetLength(FRows, Length(FRows) + 1);
|
||||
FRows[High(FRows)].Caption := ACaption;
|
||||
FRows[High(FRows)].Value := AValue;
|
||||
FRows[High(FRows)].Secret := ASecret;
|
||||
end;
|
||||
|
||||
begin
|
||||
ClearDetailRows;
|
||||
FCurrent := FVault.Entry(AVaultIndex);
|
||||
FDetTitle.Text := FCurrent.Display;
|
||||
FDetSite.Text := FCurrent.Site;
|
||||
FDetUser.Text := FCurrent.Username;
|
||||
FDetPwd.Text := MASK;
|
||||
FDetToast.Text := '';
|
||||
FBtnReveal.Text := 'Afficher';
|
||||
|
||||
// Le corps d'une note est stocke dans le champ mot de passe : meme donnee,
|
||||
// autre intitule, et pas de raison de la masquer comme un secret.
|
||||
if FCurrent.IsNote then LPwdCaption := 'Contenu' else LPwdCaption := 'Mot de passe';
|
||||
|
||||
Add('Site', FCurrent.Site, False);
|
||||
Add('Identifiant', FCurrent.Username, False);
|
||||
Add(LPwdCaption, FCurrent.Password, not FCurrent.IsNote);
|
||||
for I := 0 to High(FCurrent.Fields) do
|
||||
Add(FCurrent.Fields[I].Caption, FCurrent.Fields[I].Value,
|
||||
FCurrent.Fields[I].Secret);
|
||||
|
||||
LMeta := FCurrent.Folder;
|
||||
if FCurrent.Tags <> '' then
|
||||
@@ -455,12 +508,71 @@ begin
|
||||
if LMeta <> '' then LMeta := LMeta + ' - ';
|
||||
LMeta := LMeta + FCurrent.Tags;
|
||||
end;
|
||||
FDetMeta.Text := LMeta;
|
||||
Add('Dossier / etiquettes', LMeta, False);
|
||||
|
||||
SetLength(FRowValues, Length(FRows));
|
||||
SetLength(FRowShown, Length(FRows));
|
||||
SetLength(FRowCtls, Length(FRows));
|
||||
|
||||
// A REBOURS : des controles alignes en haut s'empilent dans l'ordre inverse
|
||||
// de leur creation, donc la derniere ligne se cree en premier.
|
||||
for I := High(FRows) downto 0 do
|
||||
begin
|
||||
LRow := MakeRow(FDetBody, FRows[I].Caption, LValue);
|
||||
FRowCtls[I] := LRow;
|
||||
FRowValues[I] := LValue;
|
||||
FRowShown[I] := False;
|
||||
if FRows[I].Secret then
|
||||
begin
|
||||
LValue.Text := MASK;
|
||||
AddRowButton(LRow, 'Afficher', I, OnRevealTap);
|
||||
end
|
||||
else
|
||||
LValue.Text := FRows[I].Value;
|
||||
AddRowButton(LRow, 'Copier', I, OnCopyTap);
|
||||
end;
|
||||
|
||||
// Le TOTP est la seule ligne vivante : creee en dernier, donc affichee en
|
||||
// tete, parce que c'est ce qu'on vient chercher sur un telephone.
|
||||
if FCurrent.Totp <> '' then
|
||||
begin
|
||||
LRow := MakeRow(FDetBody, 'Code 2FA', FTotpValue);
|
||||
SetLength(FRowCtls, Length(FRowCtls) + 1);
|
||||
FRowCtls[High(FRowCtls)] := LRow;
|
||||
FTotpValue.TextSettings.Font.Size := 26;
|
||||
AddRowButton(LRow, 'Copier', -1, OnCopyTap);
|
||||
OnTotpTick(nil); // pas d'attente d'une seconde a l'ouverture
|
||||
FTotpTimer.Enabled := True;
|
||||
end;
|
||||
|
||||
FLayList.Visible := False;
|
||||
FLayDetail.Visible := True;
|
||||
end;
|
||||
|
||||
procedure TForm8.OnTotpTick(Sender: TObject);
|
||||
var
|
||||
LLeft, LPeriod: Integer;
|
||||
begin
|
||||
if FTotpValue = nil then
|
||||
begin
|
||||
FTotpTimer.Enabled := False;
|
||||
Exit;
|
||||
end;
|
||||
FTotpCode := TotpNow(FCurrent.Totp, LLeft, LPeriod);
|
||||
if FTotpCode = '' then
|
||||
begin
|
||||
// Un secret illisible ne doit pas passer pour un code : le dire.
|
||||
FTotpValue.Text := 'secret illisible';
|
||||
FTotpTimer.Enabled := False;
|
||||
Exit;
|
||||
end;
|
||||
// Coupe en deux, comme partout ailleurs : six chiffres d'affilee se
|
||||
// recopient mal.
|
||||
FTotpValue.Text := Copy(FTotpCode, 1, Length(FTotpCode) div 2) + ' ' +
|
||||
Copy(FTotpCode, Length(FTotpCode) div 2 + 1, MaxInt) +
|
||||
' ' + LLeft.ToString + ' s';
|
||||
end;
|
||||
|
||||
procedure TForm8.OnBackTap(Sender: TObject);
|
||||
begin
|
||||
// Tag 1 = le bouton "Verrouiller" de la liste, 0 = le "Retour" du detail.
|
||||
@@ -507,9 +619,10 @@ begin
|
||||
FETag := '';
|
||||
FVault.Clear;
|
||||
FList.Clear;
|
||||
// Les lignes du detail portent des valeurs EN CLAIR (FRows) : les detruire
|
||||
// fait partie du verrouillage, pas de l'affichage.
|
||||
ClearDetailRows;
|
||||
FCurrent := Default(TVaultEntry);
|
||||
FDetPwd.Text := MASK;
|
||||
FPwdShown := False;
|
||||
ShowUnlock;
|
||||
end;
|
||||
|
||||
@@ -787,28 +900,38 @@ end;
|
||||
---------------------------------------------------------------------------- }
|
||||
|
||||
procedure TForm8.OnRevealTap(Sender: TObject);
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
FPwdShown := not FPwdShown;
|
||||
if FPwdShown then
|
||||
if not (Sender is TButton) then Exit;
|
||||
I := TButton(Sender).Tag;
|
||||
if (I < 0) or (I > High(FRows)) then Exit;
|
||||
|
||||
FRowShown[I] := not FRowShown[I];
|
||||
if FRowShown[I] then
|
||||
begin
|
||||
FDetPwd.Text := FCurrent.Password;
|
||||
FBtnReveal.Text := 'Masquer';
|
||||
FRowValues[I].Text := FRows[I].Value;
|
||||
TButton(Sender).Text := 'Masquer';
|
||||
end
|
||||
else
|
||||
begin
|
||||
FDetPwd.Text := MASK;
|
||||
FBtnReveal.Text := 'Afficher';
|
||||
FRowValues[I].Text := MASK;
|
||||
TButton(Sender).Text := 'Afficher';
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TForm8.OnCopyTap(Sender: TObject);
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
if not (Sender is TButton) then Exit;
|
||||
case TButton(Sender).Tag of
|
||||
1: CopyToClipboard(FCurrent.Site, 'Site');
|
||||
2: CopyToClipboard(FCurrent.Username, 'Identifiant');
|
||||
3: CopyToClipboard(FCurrent.Password, 'Mot de passe');
|
||||
end;
|
||||
I := TButton(Sender).Tag;
|
||||
// -1 : la ligne du TOTP, qui n'est pas dans FRows - sa valeur change chaque
|
||||
// periode et ne vit que dans FTotpCode. Copie sans l'espace d'affichage.
|
||||
if I = -1 then
|
||||
CopyToClipboard(FTotpCode, 'Code 2FA')
|
||||
else if (I >= 0) and (I <= High(FRows)) then
|
||||
CopyToClipboard(FRows[I].Value, FRows[I].Caption);
|
||||
end;
|
||||
|
||||
procedure TForm8.CopyToClipboard(const AText, ALabel: string);
|
||||
|
||||
Reference in New Issue
Block a user