feat(android): HTTP client for the sync endpoint, with ETag reuse
Fetches the opaque container and the ETag; a 304 skips both the download and the 4-second derivation, which is the whole point of keeping the tag between calls. Redirects are not followed - one to another host would send the bearer token there. Local testing goes through adb reverse tcp:8080 tcp:80, which makes the phone reach the PC as 127.0.0.1 and satisfies the endpoint's loopback exemption. That needs usesCleartextTraffic in the manifest, marked for removal before shipping.
This commit is contained in:
@@ -12,7 +12,14 @@
|
||||
<queries>
|
||||
<%queries-child-elements%>
|
||||
</queries>
|
||||
<!-- usesCleartextTraffic : TEST UNIQUEMENT. Android bloque le trafic en
|
||||
clair depuis la version 9, y compris vers 127.0.0.1 ; sans cet
|
||||
attribut l'essai local via "adb reverse" echoue avec une erreur
|
||||
reseau opaque. A RETIRER avant diffusion : l'endpoint de production
|
||||
est en HTTPS, et le jeton Bearer circule dans un en-tete a chaque
|
||||
appel. -->
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:persistent="%persistent%"
|
||||
android:restoreAnyVersion="%restoreAnyVersion%"
|
||||
android:label="%label%"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
unit PMA.SyncClient;
|
||||
{
|
||||
Lecture de l'instantane chiffre depuis l'endpoint PHP (server-php/sync.php).
|
||||
|
||||
Ne dechiffre rien : il ramene des octets opaques et l'ETag qui les identifie.
|
||||
Le dechiffrement est le travail de PMA.Crypto, et il coute 4 secondes — d'ou
|
||||
l'importance de l'ETag, qui evite de le refaire quand rien n'a change.
|
||||
|
||||
Le serveur repond 404 aussi bien pour un jeton invalide que pour un
|
||||
instantane absent : c'est voulu (un appelant non authentifie n'apprend pas
|
||||
ce qui existe), et le client ne peut donc pas les distinguer. Le message
|
||||
utilisateur doit couvrir les deux cas.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils, System.Classes, System.Net.HttpClient,
|
||||
System.Net.URLClient, System.Net.HttpClientComponent;
|
||||
|
||||
type
|
||||
TSyncStatus = (
|
||||
ssOK, // 200 : nouveau contenu dans Container
|
||||
ssNotModified, // 304 : l'ETag detenu est toujours le bon
|
||||
ssNoSnapshot, // 404 : pas d'instantane, OU jeton invalide
|
||||
ssRefused, // 403 : HTTPS exige par le serveur
|
||||
ssHttpError, // autre code
|
||||
ssNetworkError // rien n'a repondu
|
||||
);
|
||||
|
||||
TSyncResult = record
|
||||
Status: TSyncStatus;
|
||||
Container: string; // le JSON chiffre, vide sauf sur ssOK
|
||||
ETag: string; // a repasser au prochain appel
|
||||
HttpCode: Integer;
|
||||
Error: string; // message brut, pour le journal
|
||||
end;
|
||||
|
||||
TSyncClient = class
|
||||
private
|
||||
FBaseUrl: string;
|
||||
FToken: string;
|
||||
FTimeoutMs: Integer;
|
||||
public
|
||||
constructor Create(const ABaseUrl, AToken: string);
|
||||
|
||||
/// GET de l'instantane. AKnownETag non vide -> If-None-Match, et un 304
|
||||
/// evite de retelecharger ET de re-deriver la cle.
|
||||
function Fetch(const AKnownETag: string = ''): TSyncResult;
|
||||
|
||||
property BaseUrl: string read FBaseUrl write FBaseUrl;
|
||||
property Token: string read FToken write FToken;
|
||||
/// Volontairement court : sur mobile, un reseau qui ne repond pas doit
|
||||
/// rendre la main vite plutot que de figer l'ecran.
|
||||
property TimeoutMs: Integer read FTimeoutMs write FTimeoutMs;
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
||||
constructor TSyncClient.Create(const ABaseUrl, AToken: string);
|
||||
begin
|
||||
inherited Create;
|
||||
FBaseUrl := ABaseUrl.Trim;
|
||||
FToken := AToken.Trim;
|
||||
FTimeoutMs := 15000;
|
||||
end;
|
||||
|
||||
function TSyncClient.Fetch(const AKnownETag: string): TSyncResult;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LResp: IHTTPResponse;
|
||||
LStream: TStringStream;
|
||||
begin
|
||||
Result := Default(TSyncResult);
|
||||
Result.Status := ssNetworkError;
|
||||
|
||||
if (FBaseUrl = '') or (FToken = '') then
|
||||
begin
|
||||
Result.Error := 'URL ou jeton non configure';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := FTimeoutMs;
|
||||
LHttp.ResponseTimeout := FTimeoutMs;
|
||||
// Pas de redirection suivie : une redirection vers un autre hote nous
|
||||
// ferait envoyer le jeton ailleurs.
|
||||
LHttp.HandleRedirects := False;
|
||||
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken;
|
||||
if AKnownETag <> '' then
|
||||
LHttp.CustomHeaders['If-None-Match'] := AKnownETag;
|
||||
|
||||
LStream := TStringStream.Create('', TEncoding.UTF8);
|
||||
try
|
||||
try
|
||||
LResp := LHttp.Get(FBaseUrl, LStream);
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Result.Status := ssNetworkError;
|
||||
Result.Error := E.ClassName + ': ' + E.Message;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
Result.HttpCode := LResp.StatusCode;
|
||||
Result.ETag := LResp.HeaderValue['ETag'];
|
||||
|
||||
case LResp.StatusCode of
|
||||
200:
|
||||
begin
|
||||
Result.Status := ssOK;
|
||||
Result.Container := LStream.DataString;
|
||||
// Un corps vide avec un 200 signale un proxy ou une erreur de
|
||||
// configuration, pas un instantane : le signaler ici evite un
|
||||
// "mot de passe incorrect" trompeur plus loin.
|
||||
if Result.Container.Trim = '' then
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := '200 avec un corps vide';
|
||||
end;
|
||||
end;
|
||||
304: Result.Status := ssNotModified;
|
||||
403:
|
||||
begin
|
||||
Result.Status := ssRefused;
|
||||
Result.Error := 'Le serveur exige HTTPS';
|
||||
end;
|
||||
404: Result.Status := ssNoSnapshot;
|
||||
else
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := 'HTTP ' + LResp.StatusCode.ToString + ' ' + LResp.StatusText;
|
||||
end;
|
||||
finally
|
||||
LStream.Free;
|
||||
end;
|
||||
finally
|
||||
LHttp.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
+127
-4
@@ -21,14 +21,18 @@ uses
|
||||
System.Hash, System.Diagnostics, System.NetEncoding,
|
||||
FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs,
|
||||
FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types,
|
||||
FMX.ScrollBox, FMX.Layouts;
|
||||
FMX.ScrollBox, FMX.Layouts, FMX.Edit;
|
||||
|
||||
type
|
||||
TForm8 = class(TForm)
|
||||
private
|
||||
FLog: TMemo;
|
||||
FBtn: TButton;
|
||||
FBtnFetch: TButton;
|
||||
FUrl, FToken, FPwd: TEdit;
|
||||
FETag: string; // conserve entre deux appuis : c'est ce qui permet le 304
|
||||
procedure RunSelfTest(Sender: TObject);
|
||||
procedure RunFetch(Sender: TObject);
|
||||
procedure Log(const AMsg: string);
|
||||
public
|
||||
constructor Create(AOwner: TComponent); override;
|
||||
@@ -40,7 +44,7 @@ var
|
||||
implementation
|
||||
|
||||
uses
|
||||
PMA.Crypto, PMA.TestVector;
|
||||
System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient;
|
||||
|
||||
{$R *.fmx}
|
||||
|
||||
@@ -49,12 +53,38 @@ begin
|
||||
inherited;
|
||||
Caption := 'PM — crypto self-test';
|
||||
|
||||
// Champs de configuration. Rien n'est persiste : c'est un banc d'essai, et
|
||||
// le mot de passe de sync ouvre le coffre entier — il n'a pas a trainer sur
|
||||
// le disque tant que le stockage Keystore n'est pas ecrit.
|
||||
FUrl := TEdit.Create(Self);
|
||||
FUrl.Parent := Self; FUrl.Align := TAlignLayout.Top;
|
||||
FUrl.Margins.Rect := RectF(8, 8, 8, 0);
|
||||
FUrl.TextPrompt := 'http://127.0.0.1:8080/server-php/sync.php';
|
||||
|
||||
FToken := TEdit.Create(Self);
|
||||
FToken.Parent := Self; FToken.Align := TAlignLayout.Top;
|
||||
FToken.Margins.Rect := RectF(8, 4, 8, 0);
|
||||
FToken.TextPrompt := 'jeton Bearer';
|
||||
|
||||
FPwd := TEdit.Create(Self);
|
||||
FPwd.Parent := Self; FPwd.Align := TAlignLayout.Top;
|
||||
FPwd.Margins.Rect := RectF(8, 4, 8, 0);
|
||||
FPwd.TextPrompt := 'mot de passe de sync';
|
||||
FPwd.Password := True;
|
||||
|
||||
FBtnFetch := TButton.Create(Self);
|
||||
FBtnFetch.Parent := Self; FBtnFetch.Align := TAlignLayout.Top;
|
||||
FBtnFetch.Margins.Rect := RectF(8, 4, 8, 0);
|
||||
FBtnFetch.Height := 48;
|
||||
FBtnFetch.Text := 'Telecharger et dechiffrer';
|
||||
FBtnFetch.OnClick := RunFetch;
|
||||
|
||||
FBtn := TButton.Create(Self);
|
||||
FBtn.Parent := Self;
|
||||
FBtn.Align := TAlignLayout.Top;
|
||||
FBtn.Margins.Rect := RectF(8, 8, 8, 8);
|
||||
FBtn.Margins.Rect := RectF(8, 4, 8, 8);
|
||||
FBtn.Height := 48;
|
||||
FBtn.Text := 'Lancer le test';
|
||||
FBtn.Text := 'Test crypto (vecteur)';
|
||||
FBtn.OnClick := RunSelfTest;
|
||||
|
||||
FLog := TMemo.Create(Self);
|
||||
@@ -77,6 +107,99 @@ begin
|
||||
end);
|
||||
end;
|
||||
|
||||
procedure TForm8.RunFetch(Sender: TObject);
|
||||
var
|
||||
LUrl, LTok, LPwd: string;
|
||||
begin
|
||||
FLog.Lines.Clear;
|
||||
FBtnFetch.Enabled := False;
|
||||
// Lus ICI, sur le thread principal : toucher un controle FMX depuis le
|
||||
// thread de travail est un plantage differe, pas une erreur de compilation.
|
||||
LUrl := FUrl.Text.Trim;
|
||||
LTok := FToken.Text.Trim;
|
||||
LPwd := FPwd.Text;
|
||||
|
||||
TThread.CreateAnonymousThread(
|
||||
procedure
|
||||
var
|
||||
LClient: TSyncClient;
|
||||
LRes: TSyncResult;
|
||||
LPlain: string;
|
||||
LSw: TStopwatch;
|
||||
LJson: TJSONObject;
|
||||
LArr: TJSONArray;
|
||||
LLive, LTrash, I: Integer;
|
||||
begin
|
||||
LClient := TSyncClient.Create(LUrl, LTok);
|
||||
try
|
||||
Log('GET ' + LUrl);
|
||||
if FETag <> '' then Log(' If-None-Match: ' + FETag);
|
||||
LSw := TStopwatch.StartNew;
|
||||
LRes := LClient.Fetch(FETag);
|
||||
LSw.Stop;
|
||||
Log(' HTTP ' + LRes.HttpCode.ToString + ' en ' +
|
||||
LSw.ElapsedMilliseconds.ToString + ' ms');
|
||||
|
||||
case LRes.Status of
|
||||
ssNotModified:
|
||||
begin
|
||||
Log(' ==> 304 : inchange, rien a re-dechiffrer');
|
||||
Exit;
|
||||
end;
|
||||
ssNoSnapshot:
|
||||
begin
|
||||
// Le serveur repond pareil dans les deux cas, par conception.
|
||||
Log(' ==> 404 : pas d''instantane OU jeton invalide');
|
||||
Exit;
|
||||
end;
|
||||
ssRefused, ssHttpError, ssNetworkError:
|
||||
begin
|
||||
Log(' ==> echec : ' + LRes.Error);
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
Log(' recu ' + Length(LRes.Container).ToString + ' octets');
|
||||
if LRes.ETag <> '' then
|
||||
begin
|
||||
FETag := LRes.ETag;
|
||||
Log(' ETag conserve : ' + FETag);
|
||||
end;
|
||||
|
||||
Log('');
|
||||
Log('Dechiffrement...');
|
||||
LSw := TStopwatch.StartNew;
|
||||
LPlain := DecryptContainer(LRes.Container, LPwd);
|
||||
LSw.Stop;
|
||||
Log(' ' + LSw.ElapsedMilliseconds.ToString + ' ms pour ' +
|
||||
Length(LPlain).ToString + ' caracteres');
|
||||
|
||||
// Compte vivant / corbeille : c'est ce que le vrai client devra
|
||||
// afficher separement.
|
||||
LJson := TJSONObject.ParseJSONValue(LPlain) as TJSONObject;
|
||||
if LJson <> nil then
|
||||
try
|
||||
if LJson.TryGetValue<TJSONArray>('entries', LArr) then
|
||||
begin
|
||||
LLive := 0; LTrash := 0;
|
||||
for I := 0 to LArr.Count - 1 do
|
||||
if (LArr.Items[I] as TJSONObject).GetValue<Integer>('deleted', 0) = 1
|
||||
then Inc(LTrash) else Inc(LLive);
|
||||
Log(' ' + LLive.ToString + ' entrees, ' +
|
||||
LTrash.ToString + ' en corbeille');
|
||||
end;
|
||||
finally
|
||||
LJson.Free;
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
Log('ERREUR ' + E.ClassName + ' : ' + E.Message);
|
||||
end;
|
||||
LClient.Free;
|
||||
TThread.Queue(nil, procedure begin FBtnFetch.Enabled := True; end);
|
||||
end).Start;
|
||||
end;
|
||||
|
||||
procedure TForm8.RunSelfTest(Sender: TObject);
|
||||
begin
|
||||
FLog.Lines.Clear;
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ uses
|
||||
FMX.Forms,
|
||||
mainApk in 'mainApk.pas' {Form8},
|
||||
PMA.Crypto in 'android-client\PMA.Crypto.pas',
|
||||
PMA.TestVector in 'android-client\PMA.TestVector.pas';
|
||||
PMA.TestVector in 'android-client\PMA.TestVector.pas',
|
||||
PMA.SyncClient in 'android-client\PMA.SyncClient.pas';
|
||||
|
||||
{$R *.res}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user