Files
Password-Manager/android-client/PMA.Totp.pas
T
r-zakarya 20ba2438df 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>
2026-08-15 10:35:09 +01:00

153 lines
4.5 KiB
ObjectPascal

unit PMA.Totp;
{
Codes TOTP (RFC 6238) - la raison principale de sortir son telephone.
Rien de specifique a Android : base32, HMAC-SHA1, troncature dynamique. Le
poste Windows a deja son implementation en JavaScript ; celle-ci est la
meme mecanique, verifiee contre les memes vecteurs de la RFC.
Le secret arrive soit en base32 nu, soit en URI otpauth:// - les deux
circulent dans les instantanes selon la facon dont l'entree a ete creee.
On accepte les deux, et l'URI fournit en prime sa periode et son nombre de
chiffres quand ils different des valeurs par defaut.
}
interface
uses
System.SysUtils;
/// Code courant pour ASecret, et le temps restant avant renouvellement.
/// Retourne '' si le secret est vide ou illisible - jamais d'exception : un
/// mauvais secret ne doit pas empecher l'affichage du reste de l'entree.
function TotpNow(const ASecret: string; out ASecondsLeft, APeriod: Integer): string;
/// Meme chose a un instant donne. Existe pour que les vecteurs de la RFC
/// soient verifiables : ils sont tous exprimes a une date fixe.
function TotpAt(const ASecret: string; AUnix: Int64;
out ASecondsLeft, APeriod: Integer): string;
implementation
uses
System.Classes, System.Hash, System.DateUtils, System.NetEncoding;
const
B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function Base32Decode(const S: string): TBytes;
var
I, LBits, LValue, LIdx, LPos: Integer;
LCh: Char;
begin
SetLength(Result, Length(S)); // majorant : 5 bits par caractere
LBits := 0; LValue := 0; LIdx := 0;
for I := 1 to Length(S) do
begin
LCh := UpCase(S[I]);
// Espaces, tirets et remplissage sont ignores : les secrets se
// recopient souvent par groupes de quatre.
LPos := Pos(LCh, B32);
if LPos = 0 then Continue;
LValue := (LValue shl 5) or (LPos - 1);
Inc(LBits, 5);
if LBits >= 8 then
begin
Result[LIdx] := (LValue shr (LBits - 8)) and $FF;
Inc(LIdx);
Dec(LBits, 8);
end;
end;
SetLength(Result, LIdx);
end;
/// Valeur d'un parametre de requete dans une URI otpauth://, ou '' s'il
/// manque. Assez pour secret/period/digits, sans tirer un analyseur d'URI.
function UriParam(const AUri, AName: string): string;
var
LStart, LEnd: Integer;
LKey: string;
begin
Result := '';
LKey := '?' + AName + '=';
LStart := Pos(LKey, AUri);
if LStart = 0 then
begin
LKey := '&' + AName + '=';
LStart := Pos(LKey, AUri);
if LStart = 0 then Exit;
end;
Inc(LStart, Length(LKey));
LEnd := LStart;
while (LEnd <= Length(AUri)) and (AUri[LEnd] <> '&') do Inc(LEnd);
Result := Copy(AUri, LStart, LEnd - LStart);
end;
function TotpNow(const ASecret: string; out ASecondsLeft, APeriod: Integer): string;
begin
// Toujours en UTC : un decalage horaire ferait un code faux tout en
// paraissant parfaitement valide.
Result := TotpAt(ASecret,
DateTimeToUnix(TTimeZone.Local.ToUniversalTime(Now), True),
ASecondsLeft, APeriod);
end;
function TotpAt(const ASecret: string; AUnix: Int64;
out ASecondsLeft, APeriod: Integer): string;
var
LSecret: string;
LDigits, LOffset, LBinary, LMod, I: Integer;
LKey, LMsg, LHash: TBytes;
LCounter: Int64;
begin
Result := '';
ASecondsLeft := 0;
APeriod := 30;
LDigits := 6;
LSecret := ASecret.Trim;
if LSecret = '' then Exit;
if LSecret.ToLower.StartsWith('otpauth://') then
begin
if UriParam(LSecret, 'period') <> '' then
APeriod := StrToIntDef(UriParam(LSecret, 'period'), 30);
if UriParam(LSecret, 'digits') <> '' then
LDigits := StrToIntDef(UriParam(LSecret, 'digits'), 6);
LSecret := UriParam(LSecret, 'secret');
if LSecret = '' then Exit;
end;
if (APeriod <= 0) or (LDigits < 6) or (LDigits > 8) then Exit;
LKey := Base32Decode(LSecret);
if Length(LKey) = 0 then Exit;
LCounter := AUnix div APeriod;
ASecondsLeft := APeriod - (AUnix mod APeriod);
// Compteur sur 8 octets, gros-boutiste.
SetLength(LMsg, 8);
for I := 7 downto 0 do
begin
LMsg[I] := Byte(LCounter and $FF);
LCounter := LCounter shr 8;
end;
LHash := THashSHA1.GetHMACAsBytes(LMsg, LKey);
if Length(LHash) < 20 then Exit;
// Troncature dynamique : les 4 derniers bits designent l'offset.
LOffset := LHash[19] and $0F;
LBinary := ((LHash[LOffset] and $7F) shl 24) or
((LHash[LOffset + 1] and $FF) shl 16) or
((LHash[LOffset + 2] and $FF) shl 8) or
(LHash[LOffset + 3] and $FF);
LMod := 1;
for I := 1 to LDigits do LMod := LMod * 10;
Result := Format('%.*d', [LDigits, LBinary mod LMod]);
end;
end.