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
+9 -2
View File
@@ -1,7 +1,14 @@
// 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
// telecharge : il n'a rien a faire dans un depot, meme sans remote.
// VALEURS DE PREMIER LANCEMENT SEULEMENT. Une fois que l'appareil a enregistre
// 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
FIRSTRUN_TOKEN = '';
TEST_PWD = '';
+110 -1
View File
@@ -107,6 +107,20 @@ procedure ClearVault;
/// Chemin du fichier, pour le journal et les tests.
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
{$IF DEFINED(ANDROID)}
@@ -115,7 +129,8 @@ uses
{$ENDIF}
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
// creation de la cle et ne peut pas etre ajoutee apres coup.
KEY_ALIAS = 'pm_vault_key'; // AES, sans authentification
@@ -596,6 +611,100 @@ begin
end;
{$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;
var
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` |
| `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
/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`.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
PMDARKSTYLE RCDATA "pm-dark.style"