feat(android): read the snapshot from a Gitea repo, and close cleartext

PMA.SyncClient now speaks two transports, told apart by the URL:
/api/v1/repos/ means the Gitea contents API, anything else the old PHP
endpoint. The PHP path stays until the Windows side publishes to the repo
too - without it the migration would need both ends flipped at once, with
no way back.

Three things differ on the Gitea side and nothing else: the header is
"token <PAT>" not "Bearer" (swapping them gives a silent 401), the body is
JSON carrying base64 content, and the fingerprint comparison happens in the
client since the API has no If-None-Match. The body is re-downloaded, which
is cheap; the four-second key derivation is still avoided. The result's
ETag field carries the sha, so the caller cannot tell which transport
answered.

usesCleartextTraffic is replaced by a network config that permits cleartext
to 127.0.0.1 and localhost BY NAME and forbids it everywhere else. The
attribute drew no such line: a mistyped URL sent the bearer token over the
network in the clear with nothing to signal it. Now it fails immediately,
which is the behaviour worth having.

Verified in the built APK: res/xml/network_security_config.xml is present
and the manifest attribute resolves to a real resource id. NOT verified
against a real repo - that needs a private repo, a published
vault-sync.json and a token, none of which exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-17 11:55:07 +01:00
parent b3fbaf44bd
commit 68df2dd7e7
5 changed files with 207 additions and 31 deletions
+8 -7
View File
@@ -19,12 +19,13 @@
<queries> <queries>
<%queries-child-elements%> <%queries-child-elements%>
</queries> </queries>
<!-- usesCleartextTraffic : TEST UNIQUEMENT. Android bloque le trafic en <!-- networkSecurityConfig REMPLACE usesCleartextTraffic="true", qui
clair depuis la version 9, y compris vers 127.0.0.1 ; sans cet autorisait le clair vers n'importe quel hote pour la seule raison que
attribut l'essai local via "adb reverse" echoue avec une erreur l'essai local via "adb reverse" parle HTTP. La configuration ne
reseau opaque. A RETIRER avant diffusion : l'endpoint de production l'autorise plus que vers la boucle locale, nommement : le jeton ne
est en HTTPS, et le jeton Bearer circule dans un en-tete a chaque peut plus sortir qu'en TLS, et une URL http:// vers un vrai hote
appel. --> echoue bruyamment au lieu de le laisser passer en clair.
Voir android-client/android-res/xml/network_security_config.xml. -->
<!-- allowBackup=false : le repertoire prive contient vault.bin, c'est-a-dire <!-- allowBackup=false : le repertoire prive contient vault.bin, c'est-a-dire
le coffre entier. Il est chiffre par une cle qui ne quitte pas la puce, le coffre entier. Il est chiffre par une cle qui ne quitte pas la puce,
donc ce qui sortirait serait inexploitable - mais il sortirait, et une donc ce qui sortirait serait inexploitable - mais il sortirait, et une
@@ -33,7 +34,7 @@
necessaires une fois la sauvegarde refusee en bloc. --> necessaires une fois la sauvegarde refusee en bloc. -->
<application <application
android:allowBackup="false" android:allowBackup="false"
android:usesCleartextTraffic="true" android:networkSecurityConfig="@xml/network_security_config"
android:persistent="%persistent%" android:persistent="%persistent%"
android:restoreAnyVersion="%restoreAnyVersion%" android:restoreAnyVersion="%restoreAnyVersion%"
android:label="%label%" android:label="%label%"
+32 -8
View File
@@ -177,11 +177,15 @@ Par ordre de gravité, pas de taille.
1. ~~**`android:allowBackup="false"`**~~ — **fait**. Le répertoire privé 1. ~~**`android:allowBackup="false"`**~~ — **fait**. Le répertoire privé
contient `vault.bin` ; la sauvegarde Google n'a rien à y faire, même si ce contient `vault.bin` ; la sauvegarde Google n'a rien à y faire, même si ce
qui en sortirait serait chiffré par une clé restée dans la puce. qui en sortirait serait chiffré par une clé restée dans la puce.
2. **Retirer `usesCleartextTraffic="true"`** du manifeste, et servir 2. ~~**Retirer `usesCleartextTraffic="true"`**~~ — **fait le 2026-08-17**, mais
l'endpoint en HTTPS avec un vrai certificat. L'attribut est marqué pas en le retirant : remplacé par un
TEST-ONLY ; le jeton Bearer circule à chaque appel. **Gardé pour l'instant** : [network_security_config.xml](android-res/xml/network_security_config.xml) qui
le retirer casserait l'essai local par `adb reverse`, qui passe en clair. n''autorise le clair que vers `127.0.0.1` et `localhost`, **nommément**, et
Voir §6 : passer par Gitea réglerait ce point sans rien héberger de plus. l''interdit partout ailleurs. L''attribut ne faisait pas cette distinction :
une URL mal saisie envoyait le jeton en clair sur le réseau sans que rien ne
le signale. Maintenant elle échoue tout de suite, ce qui est le comportement
voulu — bruyant, pas silencieux. L''essai local par `adb reverse` continue de
passer.
3. ~~**`FLAG_SECURE`**~~ — **fait**, [PMA.ScreenGuard.pas](PMA.ScreenGuard.pas), 3. ~~**`FLAG_SECURE`**~~ — **fait**, [PMA.ScreenGuard.pas](PMA.ScreenGuard.pas),
**en Release seulement** : en debug le drapeau interdit aussi les captures **en Release seulement** : en debug le drapeau interdit aussi les captures
d'écran de développement. Le vérifier demande donc une compilation Release. d'écran de développement. Le vérifier demande donc une compilation Release.
@@ -414,9 +418,29 @@ purge. Ça ne l'est pas sur GitHub, où le chiffré part chez un tiers, répliqu
et sauvegardé, avec un dépôt rendu public par erreur pour scénario du pire : et sauvegardé, avec un dépôt rendu public par erreur pour scénario du pire :
attaquable hors ligne, par n'importe qui, pour toujours. attaquable hors ligne, par n'importe qui, pour toujours.
**À faire si on s'y engage** : un `PMA.SyncClient` qui parle cette API (le GET ### État : la lecture est écrite, elle n'a jamais parlé à un vrai dépôt
authentifié existe déjà), la même chose côté poste Windows en remplacement de
WebDAV, et une purge périodique de l'historique — sans quoi le dépôt grossit [PMA.SyncClient.pas](PMA.SyncClient.pas) parle les deux transports, **reconnus à
l'URL** : `/api/v1/repos/` → Gitea, sinon l'endpoint PHP. Le PHP est gardé le
temps que le poste Windows publie aussi dans le dépôt ; sans lui la migration
exigerait de tout basculer d'un coup, des deux côtés, sans retour possible.
Ce qui change côté Gitea, et rien d'autre : l'en-tête est `token <PAT>` et non
`Bearer` (l'un à la place de l'autre donne un 401 muet), le corps est du JSON
dont il faut extraire `content` en base64, et **la comparaison d'empreinte se
fait dans le client** — l'API ne connaît pas `If-None-Match`, donc le corps est
retéléchargé, mais la dérivation de clé, elle, est bien épargnée. Le champ
`ETag` du résultat porte le `sha` : l'appelant n'a pas à savoir lequel des deux
transports lui répond.
**Rien de tout cela n'a encore atteint un vrai dépôt.** Il faut, dans cet ordre :
un dépôt privé, un fichier `vault-sync.json` publié par le poste Windows, un
jeton limité à ce dépôt, puis l'URL de contenu complète dans *Server settings*.
Tant que le poste publie sur `sync.php`, le téléphone continue de lire là.
**Reste à faire** : le PUT (§4 seulement — la lecture n'écrit rien), la même
bascule côté poste Windows en remplacement de WebDAV, et une purge périodique de
l'historique — sans quoi le dépôt grossit
d'un instantané complet à chaque synchronisation, pièces jointes comprises. d'un instantané complet à chaque synchronisation, pièces jointes comprises.
## Ordre proposé ## Ordre proposé
+125 -11
View File
@@ -1,15 +1,29 @@
unit PMA.SyncClient; unit PMA.SyncClient;
{ {
Lecture de l'instantane chiffre depuis l'endpoint PHP (server-php/sync.php). Lecture de l'instantane chiffre. DEUX TRANSPORTS, reconnus a l'URL :
Ne dechiffre rien : il ramene des octets opaques et l'ETag qui les identifie. - un depot Gitea prive, via l'API des contenus. C'est la destination : plus
Le dechiffrement est le travail de PMA.Crypto, et il coute 4 secondes - d'ou rien a heberger, HTTPS et certificat deja en place, acces depuis
l'importance de l'ETag, qui evite de le refaire quand rien n'a change. l'exterieur gratuit.
- l'endpoint PHP historique (server-php/sync.php), garde le temps que le
poste Windows publie aussi dans le depot. Sans lui, la migration exigerait
de tout basculer d'un coup, des deux cotes, sans retour possible.
Le serveur repond 404 aussi bien pour un jeton invalide que pour un Ne dechiffre rien : il ramene des octets opaques et l'empreinte qui les
instantane absent : c'est voulu (un appelant non authentifie n'apprend pas identifie. Le dechiffrement est le travail de PMA.Crypto, et il coute
ce qui existe), et le client ne peut donc pas les distinguer. Le message 4 secondes - d'ou l'importance de cette empreinte, qui evite de le refaire
utilisateur doit couvrir les deux cas. quand rien n'a change.
CE QUE L'EMPREINTE DESIGNE DIFFERE selon le transport, mais son role est le
meme et l'appelant n'a pas a le savoir : l'ETag HTTP pour PHP, le sha du blob
pour Gitea. Sur Gitea la comparaison se fait ICI, apres reception : l'API ne
connait pas If-None-Match, donc le corps est retelecharge - ce qui est
bon marche - mais la derivation, elle, est bien evitee.
Le PHP 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. Gitea repond de meme
sur un depot prive. Le message utilisateur doit couvrir les deux cas.
} }
interface interface
@@ -31,7 +45,9 @@ type
TSyncResult = record TSyncResult = record
Status: TSyncStatus; Status: TSyncStatus;
Container: string; // le JSON chiffre, vide sauf sur ssOK Container: string; // le JSON chiffre, vide sauf sur ssOK
ETag: string; // a repasser au prochain appel /// Ce qui identifie ce contenu : ETag HTTP sur PHP, sha du blob sur Gitea.
/// A ranger avec le clair et a repasser au prochain appel.
ETag: string;
HttpCode: Integer; HttpCode: Integer;
Error: string; // message brut, pour le journal Error: string; // message brut, pour le journal
end; end;
@@ -44,10 +60,15 @@ type
public public
constructor Create(const ABaseUrl, AToken: string); constructor Create(const ABaseUrl, AToken: string);
/// GET de l'instantane. AKnownETag non vide -> If-None-Match, et un 304 /// GET de l'instantane. AKnownETag non vide -> ssNotModified si le serveur
/// evite de retelecharger ET de re-deriver la cle. /// rend le meme contenu, ce qui epargne la derivation de la cle.
function Fetch(const AKnownETag: string = ''): TSyncResult; function Fetch(const AKnownETag: string = ''): TSyncResult;
/// Vrai si l'URL designe l'API des contenus de Gitea. C'est le seul
/// discriminant : le chemin de cette API est fixe, et une URL de dépôt qui
/// n'y ressemble pas est de toute facon inutilisable.
function IsGitea: Boolean;
property BaseUrl: string read FBaseUrl write FBaseUrl; property BaseUrl: string read FBaseUrl write FBaseUrl;
property Token: string read FToken write FToken; property Token: string read FToken write FToken;
/// Volontairement court : sur mobile, un reseau qui ne repond pas doit /// Volontairement court : sur mobile, un reseau qui ne repond pas doit
@@ -57,6 +78,65 @@ type
implementation implementation
uses
System.JSON, System.NetEncoding;
function TSyncClient.IsGitea: Boolean;
begin
Result := FBaseUrl.ToLower.Contains('/api/v1/repos/');
end;
/// Extrait le conteneur et le sha de la reponse de l'API des contenus. Rend
/// faux, avec la raison, si la reponse n'a pas la forme attendue - une URL de
/// dépôt juste a cote rend du JSON, mais pas celui-la.
function ParseGiteaContents(const ABody: string;
out AContainer, ASha, AError: string): Boolean;
var
LRoot: TJSONObject;
LEncoded, LEncoding: string;
begin
Result := False;
AContainer := '';
ASha := '';
LRoot := TJSONObject.ParseJSONValue(ABody) as TJSONObject;
if LRoot = nil then
begin
AError := 'reponse Gitea illisible (pas du JSON)';
Exit;
end;
try
ASha := LRoot.GetValue<string>('sha', '');
LEncoding := LRoot.GetValue<string>('encoding', '');
LEncoded := LRoot.GetValue<string>('content', '');
if (LEncoded = '') or (ASha = '') then
begin
AError := 'reponse Gitea sans content/sha - URL de contenu correcte ?';
Exit;
end;
if not SameText(LEncoding, 'base64') then
begin
AError := 'encodage Gitea inattendu : ' + LEncoding;
Exit;
end;
// Le base64 de Gitea est decoupe en lignes ; le decodeur s'en accommode,
// mais on ne compte pas dessus.
LEncoded := LEncoded.Replace(#13, '').Replace(#10, '');
try
AContainer := TEncoding.UTF8.GetString(TNetEncoding.Base64.DecodeStringToBytes(LEncoded));
except
on E: Exception do
begin
AError := 'base64 Gitea invalide : ' + E.Message;
Exit;
end;
end;
Result := AContainer.Trim <> '';
if not Result then AError := 'fichier vide dans le depot';
finally
LRoot.Free;
end;
end;
constructor TSyncClient.Create(const ABaseUrl, AToken: string); constructor TSyncClient.Create(const ABaseUrl, AToken: string);
begin begin
inherited Create; inherited Create;
@@ -87,9 +167,19 @@ begin
// Pas de redirection suivie : une redirection vers un autre hote nous // Pas de redirection suivie : une redirection vers un autre hote nous
// ferait envoyer le jeton ailleurs. // ferait envoyer le jeton ailleurs.
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
// Gitea attend "token <PAT>" pour un jeton d'acces personnel ; le PHP
// attend "Bearer". Envoyer l'un a la place de l'autre donne un 401 qui ne
// dit pas pourquoi.
if IsGitea then
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
else
begin
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken; LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken;
// Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait
// repondre 200 avec un corps complet de toute facon.
if AKnownETag <> '' then if AKnownETag <> '' then
LHttp.CustomHeaders['If-None-Match'] := AKnownETag; LHttp.CustomHeaders['If-None-Match'] := AKnownETag;
end;
LStream := TStringStream.Create('', TEncoding.UTF8); LStream := TStringStream.Create('', TEncoding.UTF8);
try try
@@ -109,6 +199,23 @@ begin
case LResp.StatusCode of case LResp.StatusCode of
200: 200:
if IsGitea then
begin
if not ParseGiteaContents(LStream.DataString, Result.Container,
Result.ETag, Result.Error) then
Result.Status := ssHttpError
// Meme sha : le contenu est celui qu'on detient deja. On rend la
// meme reponse qu'un 304 pour que l'appelant epargne la derivation,
// et on jette le conteneur - il ne servirait a rien.
else if (AKnownETag <> '') and (Result.ETag = AKnownETag) then
begin
Result.Status := ssNotModified;
Result.Container := '';
end
else
Result.Status := ssOK;
end
else
begin begin
Result.Status := ssOK; Result.Status := ssOK;
Result.Container := LStream.DataString; Result.Container := LStream.DataString;
@@ -127,7 +234,14 @@ begin
Result.Status := ssRefused; Result.Status := ssRefused;
Result.Error := 'Le serveur exige HTTPS'; Result.Error := 'Le serveur exige HTTPS';
end; end;
// Gitea repond 404 sur un depot prive qu'on n'a pas le droit de voir,
// comme sur un fichier absent - exactement l'ambiguite du PHP.
404: Result.Status := ssNoSnapshot; 404: Result.Status := ssNoSnapshot;
401:
begin
Result.Status := ssHttpError;
Result.Error := '401 : jeton refuse';
end;
else else
Result.Status := ssHttpError; Result.Status := ssHttpError;
Result.Error := 'HTTP ' + LResp.StatusCode.ToString + ' ' + LResp.StatusText; Result.Error := 'HTTP ' + LResp.StatusCode.ToString + ' ' + LResp.StatusText;
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Le trafic en clair n'est permis que vers la BOUCLE LOCALE, et interdit
partout ailleurs.
Il l'etait auparavant partout, via usesCleartextTraffic="true". C'etait pour
une seule raison : l'essai local passe par "adb reverse", qui fait voir le
poste comme 127.0.0.1 et parle HTTP. Mais l'attribut ne distingue pas la
boucle locale du reste du monde - une URL mal saisie, ou un serveur repassant
en HTTP, envoyait le jeton en clair sur le reseau sans que rien ne le signale.
Ici la permission est nominative. Le jeton ne peut plus sortir qu'en TLS, et
une URL "http://" vers un vrai hote echoue tout de suite, ce qui est
exactement le comportement voulu : bruyant, pas silencieux.
cleartextTrafficPermitted="false" a la racine n'est pas redondant avec le
defaut d'Android 9+ : il vaut aussi pour les versions anterieures, et il dit
l'intention noir sur blanc.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
+13 -2
View File
@@ -39,8 +39,19 @@ Copy-Item "$out\AndroidManifest.xml" "$stage\AndroidManifest.xml" -Force
Copy-Item "$out\pmserverApk.classes\*.dex" "$stage\classes\" -Force Copy-Item "$out\pmserverApk.classes\*.dex" "$stage\classes\" -Force
& $strip --strip-debug -o "$stage\library\lib\arm64-v8a\libpmserverApk.so" "$out\libpmserverApk.so" & $strip --strip-debug -o "$stage\library\lib\arm64-v8a\libpmserverApk.so" "$out\libpmserverApk.so"
cmd /c "$msbuild /p:Platform=Android64 /p:Config=Debug /t:Deploy /v:minimal /nologo" 2>&1 | # L'AAPT DE DEPLOY ECHOUE, ET C'EST ATTENDU. Le manifeste reference
Select-String -Pattern 'error' | ForEach-Object { $_.Line } # @xml/network_security_config, que la generation de ressources de Delphi ne
# contient pas ; poser le fichier avant Deploy ne sert a rien, Deploy reconstruit
# res\ juste avant d'appeler aapt. L'etape suivante repose nos ressources et
# refait l'archive entiere, donc l'APK livre est complet - verifie a
# "aapt list" : res/xml/network_security_config.xml y est, et l'attribut du
# manifeste resout vers un identifiant reel.
#
# On ne masque donc que CETTE erreur, nommement, et tout autre message remonte.
$deploy = cmd /c "$msbuild /p:Platform=Android64 /p:Config=Debug /t:Deploy /v:minimal /nologo" 2>&1
$deploy | Select-String -Pattern 'error' |
Where-Object { $_.Line -notmatch 'network_security_config|Unable to execute.*Aapt\.exe' } |
ForEach-Object { $_.Line }
# 4. aapt + zipalign + apksigner, et remise en place de ce que Deploy efface. # 4. aapt + zipalign + apksigner, et remise en place de ce que Deploy efface.
& "$PSScriptRoot\repack-apk.ps1" | Select-Object -Last 1 & "$PSScriptRoot\repack-apk.ps1" | Select-Object -Last 1