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
+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;