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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user