feat(android): settings live on the device, dark theme, and the app is called Coffre

URL and token are no longer constants. They sit in settings.bin, sealed with the no-auth Keystore AES key - deliberately weaker than the vault: they must be readable to rebuild a vault that no longer exists, and a bearer token only opens ENCRYPTED data, worthless without the sync password that stays in the guarded vault. Retyping 64 hex characters after every cache purge was the real risk: the risk of writing them down elsewhere. They are saved only after a fetch that worked - proven settings, not typed ones.

The token and sync password moved to the gitignored local include, as first-run defaults only. Tracked source now carries no secret, and leaving them empty is a viable configuration: the app asks.

Theme: Delphi's Dark.style, copied into the repo and linked as a resource rather than deployed as a file - this project's deployment puts nothing beside the app, which is the same hole that forces the manifest and dex staging. A failure to load is never fatal; the default look is ugly but works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 15:33:56 +01:00
parent 861d721513
commit 49efccdd59
10 changed files with 5623 additions and 18 deletions
+3
View File
@@ -81,3 +81,6 @@ pmserverApk.res
# Mot de passe de sync du banc d'essai - jamais versionne # Mot de passe de sync du banc d'essai - jamais versionne
android-client/PMA.LocalConfig.inc android-client/PMA.LocalConfig.inc
# Genere depuis theme.rc + pm-dark.style, tous deux suivis (voir README).
android-client/theme/theme.RES
android-client/theme/theme.res
+9 -2
View File
@@ -1,7 +1,14 @@
// Modele. Copier en PMA.LocalConfig.inc (ignore par git) et remplir. // Modele. Copier en PMA.LocalConfig.inc (ignore par git) et remplir.
// //
// Le mot de passe de sync ouvre TOUT le coffre une fois le conteneur // VALEURS DE PREMIER LANCEMENT SEULEMENT. Une fois que l'appareil a enregistre
// telecharge : il n'a rien a faire dans un depot, meme sans remote. // ses reglages, elles ne sont plus lues. Laisser vide est parfaitement viable :
// l'application demande alors de saisir jeton et mot de passe, ce qui est le
// comportement d'une vraie installation.
//
// Le jeton authentifie l'appareil aupres de l'endpoint ; le mot de passe de
// sync ouvre TOUT le coffre une fois le conteneur telecharge. Aucun des deux
// n'a sa place dans un depot.
const const
FIRSTRUN_TOKEN = '';
TEST_PWD = ''; TEST_PWD = '';
+110 -1
View File
@@ -107,6 +107,20 @@ procedure ClearVault;
/// Chemin du fichier, pour le journal et les tests. /// Chemin du fichier, pour le journal et les tests.
function VaultPath: string; function VaultPath: string;
/// Reglages du serveur : URL et jeton Bearer.
///
/// FICHIER SEPARE, ET PROTECTION PLUS FAIBLE, DELIBEREMENT. Ils sont chiffres
/// par la cle AES du Keystore - liee au materiel, mais lisible SANS
/// authentification. Deux raisons :
/// - il faut pouvoir les relire pour reconstruire un coffre efface, alors
/// que la cle qui protege le coffre ne sert a rien s'il n'existe plus ;
/// - le jeton ne donne acces qu'a du CHIFFRE. Sans le mot de passe de sync,
/// qui lui reste dans le coffre protege, il ne vaut rien.
/// Retaper un jeton de 64 caracteres apres chaque purge de cache serait le
/// vrai risque : celui de le noter ailleurs.
procedure SaveDeviceSettings(const AUrl, AToken: string);
function LoadDeviceSettings(out AUrl, AToken: string): Boolean;
implementation implementation
{$IF DEFINED(ANDROID)} {$IF DEFINED(ANDROID)}
@@ -115,7 +129,8 @@ uses
{$ENDIF} {$ENDIF}
const const
VAULT_FILE = 'vault.bin'; VAULT_FILE = 'vault.bin';
SETTINGS_FILE = 'settings.bin';
// Un alias par schema : la contrainte d'authentification est figee a la // Un alias par schema : la contrainte d'authentification est figee a la
// creation de la cle et ne peut pas etre ajoutee apres coup. // creation de la cle et ne peut pas etre ajoutee apres coup.
KEY_ALIAS = 'pm_vault_key'; // AES, sans authentification KEY_ALIAS = 'pm_vault_key'; // AES, sans authentification
@@ -596,6 +611,100 @@ begin
end; end;
{$ENDIF} {$ENDIF}
{$IF DEFINED(ANDROID)}
/// Chiffre sous la cle AES du Keystore, sans contrainte d'authentification.
/// Format : [IV][chiffre+tag]. Sert aux reglages, pas au coffre.
function AesSeal(const APlain: TBytes): TBytes;
var
LCipher: JCipherX;
LIv, LCt: TBytes;
begin
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureAesKey);
LIv := FromJava(LCipher.getIV); // genere par le Keystore, jamais choisi ici
LCt := FromJava(LCipher.doFinal(ToJava(APlain)));
SetLength(Result, Length(LIv) + Length(LCt));
Move(LIv[0], Result[0], Length(LIv));
Move(LCt[0], Result[Length(LIv)], Length(LCt));
end;
function AesOpen(const ARaw: TBytes): TBytes;
var
LCipher: JCipherX;
LIv, LCt: TBytes;
begin
if Length(ARaw) < IV_LEN + 16 then Exit(nil);
SetLength(LIv, IV_LEN);
Move(ARaw[0], LIv[0], IV_LEN);
SetLength(LCt, Length(ARaw) - IV_LEN);
Move(ARaw[IV_LEN], LCt[0], Length(LCt));
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureAesKey,
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv)));
Result := FromJava(LCipher.doFinal(ToJava(LCt)));
end;
{$ENDIF}
procedure SaveDeviceSettings(const AUrl, AToken: string);
{$IF DEFINED(ANDROID)}
var
LObj: TJSONObject;
begin
LObj := TJSONObject.Create;
try
LObj.AddPair('url', AUrl);
LObj.AddPair('token', AToken);
try
TFile.WriteAllBytes(TPath.Combine(TPath.GetHomePath, SETTINGS_FILE),
AesSeal(TEncoding.UTF8.GetBytes(LObj.ToJSON)));
except
// Des reglages non enregistres se retapent ; ce n'est pas une raison
// d'interrompre une synchronisation qui, elle, a reussi.
on E: Exception do FLastStoreError := E.ClassName + ': ' + E.Message;
end;
finally
LObj.Free;
end;
end;
{$ELSE}
begin
end;
{$ENDIF}
function LoadDeviceSettings(out AUrl, AToken: string): Boolean;
{$IF DEFINED(ANDROID)}
var
LPath: string;
LObj: TJSONObject;
begin
Result := False;
AUrl := '';
AToken := '';
LPath := TPath.Combine(TPath.GetHomePath, SETTINGS_FILE);
if not TFile.Exists(LPath) then Exit;
try
LObj := TJSONObject.ParseJSONValue(
TEncoding.UTF8.GetString(AesOpen(TFile.ReadAllBytes(LPath)))) as TJSONObject;
if LObj = nil then Exit;
try
AUrl := LObj.GetValue<string>('url', '');
AToken := LObj.GetValue<string>('token', '');
Result := AUrl <> '';
finally
LObj.Free;
end;
except
Result := False;
end;
end;
{$ELSE}
begin
Result := False;
AUrl := '';
AToken := '';
end;
{$ENDIF}
function VaultNeedsAuth: Boolean; function VaultNeedsAuth: Boolean;
var var
LFs: TFileStream; LFs: TFileStream;
+43
View File
@@ -0,0 +1,43 @@
unit PMA.Theme;
{
Thème sombre, chargé depuis une ressource embarquée.
POURQUOI UNE RESSOURCE ET NON UN FICHIER : le mecanisme de deploiement de ce
projet ne pose aucun fichier a cote de l'application - c'est le meme trou qui
oblige a mettre en place le manifeste et les .dex a la main. Une ressource
liee dans le .so ne depend de rien : elle est la ou n'est pas, et elle est la.
Le style vient de Dark.style, livre avec Delphi, recopie dans le depot pour
que la compilation ne dependre pas d'un chemin d'installation.
Un echec ici n'est jamais fatal : sans style, l'application garde l'apparence
par defaut. Elle est laide, elle fonctionne.
}
interface
/// A appeler une fois, avant la creation de la forme.
procedure ApplyDarkTheme;
implementation
uses
System.SysUtils, System.Types, System.Classes, FMX.Styles, FMX.Types;
{$R 'theme\theme.res' 'theme\theme.rc'}
procedure ApplyDarkTheme;
var
LStyle: TFmxObject;
begin
try
// La lecture appartient a TStyleStreaming, l'application a TStyleManager.
LStyle := TStyleStreaming.LoadFromResource(HInstance, 'PMDARKSTYLE', RT_RCDATA);
if LStyle <> nil then
TStyleManager.SetStyle(LStyle);
except
// Rien : l'apparence par defaut reste utilisable.
end;
end;
end.
+7
View File
@@ -196,6 +196,13 @@ De quoi il a besoin, mis en place à la main sous `Android64\Debug\pmserverApk\`
| `classes\*.dex` | `Android64\Debug\pmserverApk.classes\*.dex` | | `classes\*.dex` | `Android64\Debug\pmserverApk.classes\*.dex` |
| `library\lib\arm64-v8a\libpmserverApk.so` | `Android64\Debug\libpmserverApk.so` | | `library\lib\arm64-v8a\libpmserverApk.so` | `Android64\Debug\libpmserverApk.so` |
Le thème a besoin de sa ressource, **une fois** (MSBuild ne compile pas les
`.rc` référencés par `{$R}` — c'est une commodité de l'EDI, pas du compilateur) :
```bash
cd android-client\theme && brcc32 theme.rc -fotheme.res
```
Ensuite `msbuild pmserverApk.dproj /p:Platform=Android64 /p:Config=Debug Ensuite `msbuild pmserverApk.dproj /p:Platform=Android64 /p:Config=Debug
/t:Deploy` produit et signe `bin\pmserverApk.apk`, et `adb install -r` l'installe. /t:Deploy` produit et signe `bin\pmserverApk.apk`, et `adb install -r` l'installe.
Le correctif durable serait d'écrire ces entrées une fois dans le `.dproj`. Le correctif durable serait d'écrire ces entrées une fois dans le `.dproj`.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
PMDARKSTYLE RCDATA "pm-dark.style"
+23 -11
View File
@@ -32,20 +32,20 @@ uses
PMA.Vault; PMA.Vault;
const const
// Parametres du banc d'essai, pre-remplis pour ne pas les retaper. // VALEURS DE PREMIER LANCEMENT UNIQUEMENT. Des que l'appareil a enregistre
// ses reglages, elles ne servent plus a rien : LoadDeviceSettings les
// remplace. Rien de secret ne doit vivre ici - le jeton et le mot de passe
// viennent du fichier local hors depot, plus bas.
// //
// Le port differe selon la plateforme : sur Windows on attaque Apache // Le port differe selon la plateforme : sur Windows on attaque Apache
// directement (80), sur Android on passe par "adb reverse tcp:8080 tcp:80", // directement (80), sur Android on passe par "adb reverse tcp:8080 tcp:80",
// qui fait voir le PC comme 127.0.0.1 - ce qui satisfait au passage // qui fait voir le PC comme 127.0.0.1 - ce qui satisfait au passage
// l'exemption HTTPS de l'endpoint, reservee a la boucle locale. // l'exemption HTTPS de l'endpoint, reservee a la boucle locale.
{$IF DEFINED(ANDROID)} {$IF DEFINED(ANDROID)}
TEST_URL = 'http://127.0.0.1:8080/server-php/sync.php'; FIRSTRUN_URL = 'http://127.0.0.1:8080/server-php/sync.php';
{$ELSE} {$ELSE}
TEST_URL = 'http://127.0.0.1/server-php/sync.php'; FIRSTRUN_URL = 'http://127.0.0.1/server-php/sync.php';
{$ENDIF} {$ENDIF}
// Jeton d'un serveur de test local. Il n'authentifie qu'un appareil et ne
// donne acces qu'a du chiffre - mais a remplacer avant toute mise en ligne.
TEST_TOKEN = '8765e5bf426039e9888f44bfbab27d489d12b7dbaaa54c8c695ae6af8b2a820e';
// Assez pour aller coller dans une autre application, trop court pour qu'un // Assez pour aller coller dans une autre application, trop court pour qu'un
// mot de passe traine dans l'historique du presse-papiers. // mot de passe traine dans l'historique du presse-papiers.
@@ -56,9 +56,10 @@ const
// allume, application ouverte. // allume, application ouverte.
AUTOLOCK_MINUTES = 3; AUTOLOCK_MINUTES = 3;
// TEST_PWD vient d'un fichier LOCAL, hors depot : il ouvre le coffre entier // FIRSTRUN_TOKEN et TEST_PWD viennent d'un fichier LOCAL, hors depot : le
// une fois le conteneur telecharge. Absent apres un clone -> recopier // jeton authentifie l'appareil, le mot de passe ouvre le coffre entier. Ni
// PMA.LocalConfig.template.inc. // l'un ni l'autre n'a sa place dans un depot, meme sans remote. Absents apres
// un clone -> recopier PMA.LocalConfig.template.inc.
{$I android-client\PMA.LocalConfig.inc} {$I android-client\PMA.LocalConfig.inc}
type type
@@ -285,6 +286,8 @@ begin
end; end;
procedure TForm8.BuildUnlock; procedure TForm8.BuildUnlock;
var
LUrl, LToken: string;
begin begin
FLayUnlock := TLayout.Create(Self); FLayUnlock := TLayout.Create(Self);
FLayUnlock.Parent := Self; FLayUnlock.Parent := Self;
@@ -310,8 +313,14 @@ begin
FLaySettings.Align := TAlignLayout.Top; FLaySettings.Align := TAlignLayout.Top;
FLaySettings.Height := 116; FLaySettings.Height := 116;
FLaySettings.Visible := False; FLaySettings.Visible := False;
FToken := TopEdit(FLaySettings, 'jeton Bearer', TEST_TOKEN); FToken := TopEdit(FLaySettings, 'jeton Bearer', FIRSTRUN_TOKEN);
FUrl := TopEdit(FLaySettings, 'URL de l''endpoint', TEST_URL); FUrl := TopEdit(FLaySettings, 'URL de l''endpoint', FIRSTRUN_URL);
// Ce que l'appareil a retenu l'emporte sur les valeurs de premier lancement.
if LoadDeviceSettings(LUrl, LToken) then
begin
FUrl.Text := LUrl;
if LToken <> '' then FToken.Text := LToken;
end;
FBtnSettings := TopButton(FLayUnlock, 'Reglages du serveur', FBtnSettings := TopButton(FLayUnlock, 'Reglages du serveur',
OnToggleSettings, 36); OnToggleSettings, 36);
@@ -1017,6 +1026,9 @@ begin
// Le mot de passe part avec le coffre : c'est lui qui permettra au // Le mot de passe part avec le coffre : c'est lui qui permettra au
// prochain lancement de rafraichir sans rien demander. // prochain lancement de rafraichir sans rien demander.
SaveVault(LPlain, FETag, APwd, LCanAuth); SaveVault(LPlain, FETag, APwd, LCanAuth);
// Ce qui vient de fonctionner merite d'etre retenu : on n'enregistre
// pas des reglages saisis, on enregistre des reglages EPROUVES.
SaveDeviceSettings(AUrl, ATok);
if VaultNeedsAuth then if VaultNeedsAuth then
Log(' cache ecrit - protege par biometrie/code') Log(' cache ecrit - protege par biometrie/code')
else if LCanAuth then else if LCanAuth then
+5 -1
View File
@@ -12,12 +12,16 @@ uses
PMA.Vault in 'android-client\PMA.Vault.pas', PMA.Vault in 'android-client\PMA.Vault.pas',
PMA.Totp in 'android-client\PMA.Totp.pas', PMA.Totp in 'android-client\PMA.Totp.pas',
PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas', PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas',
PMA.Clipboard in 'android-client\PMA.Clipboard.pas'; PMA.Clipboard in 'android-client\PMA.Clipboard.pas',
PMA.Theme in 'android-client\PMA.Theme.pas';
{$R *.res} {$R *.res}
begin begin
Application.Initialize; Application.Initialize;
// Avant la creation de la forme : appliquer un style apres coup laisse les
// controles deja construits avec l'ancien.
ApplyDarkTheme;
Application.CreateForm(TForm8, Form8); Application.CreateForm(TForm8, Form8);
Application.Run; Application.Run;
end. end.
+3 -3
View File
@@ -1,4 +1,4 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup> <PropertyGroup>
<ProjectGuid>{1AB0ADDE-54BE-4156-B446-73E9D7B3BE4D}</ProjectGuid> <ProjectGuid>{1AB0ADDE-54BE-4156-B446-73E9D7B3BE4D}</ProjectGuid>
<ProjectVersion>20.1</ProjectVersion> <ProjectVersion>20.1</ProjectVersion>
@@ -110,7 +110,7 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Base_Android)'!=''"> <PropertyGroup Condition="'$(Base_Android)'!=''">
<DCC_UsePackage>FireDACIBDriver;fmx;DbxCommonDriver;bindengine;IndyIPCommon;IndyProtocols;dbxcds;FmxTeeUI;FireDACSqliteDriver;DbxClientDriver;soapmidas;dbexpress;inet;CustomIPTransport;IndySystem;ibxbindings;FireDACCommon;bindcompdbx;rtl;DBXSqliteDriver;FireDAC;xmlrtl;ibxpress;dsnap;FireDACCommonDriver;IndyIPClient;bindcompfmx;ibmonitor;fmxFireDAC;fmxase;dbrtl;DBXInterBaseDriver;bindcomp;IndyCore;RESTBackendComponents;RESTComponents;IndyIPServer;dsnapxml;tethering;CloudService;FMXTee;soaprtl;soapserver;$(DCC_UsePackage)</DCC_UsePackage> <DCC_UsePackage>FireDACIBDriver;fmx;DbxCommonDriver;bindengine;IndyIPCommon;IndyProtocols;dbxcds;FmxTeeUI;FireDACSqliteDriver;DbxClientDriver;soapmidas;dbexpress;inet;CustomIPTransport;IndySystem;ibxbindings;FireDACCommon;bindcompdbx;rtl;DBXSqliteDriver;FireDAC;xmlrtl;ibxpress;dsnap;FireDACCommonDriver;IndyIPClient;bindcompfmx;ibmonitor;fmxFireDAC;fmxase;dbrtl;DBXInterBaseDriver;bindcomp;IndyCore;RESTBackendComponents;RESTComponents;IndyIPServer;dsnapxml;tethering;CloudService;FMXTee;soaprtl;soapserver;$(DCC_UsePackage)</DCC_UsePackage>
<VerInfo_Keys>package=com.embarcadero.$(MSBuildProjectName);label=$(MSBuildProjectName);versionCode=1;versionName=1.0.0;persistent=False;restoreAnyVersion=False;installLocation=auto;largeHeap=False;theme=TitleBar;hardwareAccelerated=true;apiKey=</VerInfo_Keys> <VerInfo_Keys>package=com.embarcadero.$(MSBuildProjectName);label=Coffre;versionCode=1;versionName=1.0.0;persistent=False;restoreAnyVersion=False;installLocation=auto;largeHeap=False;theme=TitleBar;hardwareAccelerated=true;apiKey=</VerInfo_Keys>
<BT_BuildType>Debug</BT_BuildType> <BT_BuildType>Debug</BT_BuildType>
<VerInfo_IncludeVerInfo>true</VerInfo_IncludeVerInfo> <VerInfo_IncludeVerInfo>true</VerInfo_IncludeVerInfo>
<Android_LauncherIcon36>$(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png</Android_LauncherIcon36> <Android_LauncherIcon36>$(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png</Android_LauncherIcon36>
@@ -144,7 +144,7 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Base_Android64)'!=''"> <PropertyGroup Condition="'$(Base_Android64)'!=''">
<DCC_UsePackage>FireDACIBDriver;fmx;DbxCommonDriver;bindengine;IndyIPCommon;IndyProtocols;dbxcds;FmxTeeUI;FireDACSqliteDriver;DbxClientDriver;soapmidas;dbexpress;inet;CustomIPTransport;IndySystem;ibxbindings;FireDACCommon;bindcompdbx;rtl;DBXSqliteDriver;FireDAC;xmlrtl;ibxpress;dsnap;FireDACCommonDriver;IndyIPClient;bindcompfmx;ibmonitor;fmxFireDAC;dbrtl;DBXInterBaseDriver;bindcomp;IndyCore;RESTBackendComponents;RESTComponents;IndyIPServer;dsnapxml;tethering;CloudService;FMXTee;soaprtl;soapserver;$(DCC_UsePackage)</DCC_UsePackage> <DCC_UsePackage>FireDACIBDriver;fmx;DbxCommonDriver;bindengine;IndyIPCommon;IndyProtocols;dbxcds;FmxTeeUI;FireDACSqliteDriver;DbxClientDriver;soapmidas;dbexpress;inet;CustomIPTransport;IndySystem;ibxbindings;FireDACCommon;bindcompdbx;rtl;DBXSqliteDriver;FireDAC;xmlrtl;ibxpress;dsnap;FireDACCommonDriver;IndyIPClient;bindcompfmx;ibmonitor;fmxFireDAC;dbrtl;DBXInterBaseDriver;bindcomp;IndyCore;RESTBackendComponents;RESTComponents;IndyIPServer;dsnapxml;tethering;CloudService;FMXTee;soaprtl;soapserver;$(DCC_UsePackage)</DCC_UsePackage>
<VerInfo_Keys>package=com.embarcadero.$(MSBuildProjectName);label=$(MSBuildProjectName);versionCode=1;versionName=1.0.0;persistent=False;restoreAnyVersion=False;installLocation=auto;largeHeap=False;theme=TitleBar;hardwareAccelerated=true;apiKey=</VerInfo_Keys> <VerInfo_Keys>package=com.embarcadero.$(MSBuildProjectName);label=Coffre;versionCode=1;versionName=1.0.0;persistent=False;restoreAnyVersion=False;installLocation=auto;largeHeap=False;theme=TitleBar;hardwareAccelerated=true;apiKey=</VerInfo_Keys>
<BT_BuildType>Debug</BT_BuildType> <BT_BuildType>Debug</BT_BuildType>
<VerInfo_IncludeVerInfo>true</VerInfo_IncludeVerInfo> <VerInfo_IncludeVerInfo>true</VerInfo_IncludeVerInfo>
<Android_LauncherIcon36>$(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png</Android_LauncherIcon36> <Android_LauncherIcon36>$(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png</Android_LauncherIcon36>