From f32cde81fcaa8f81dc2d7ea55a69c375675755b1 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:07:39 +0100 Subject: [PATCH] feat(android): a local account, so the app serves without a repository Until now the phone could only read a vault that came from elsewhere: without a URL, a token and a sync password it showed nothing. It now signs in like the desktop does - username and master password - and can create its own vault, with no network at all. The local container carries exactly what the repository would carry: same format, same encryption under the password. Two reasons, not one. It is the desktop's model, where the master password protects the vault at rest rather than the hardware alone; and the format staying the repository's means sync keeps working with nothing to convert. No Keystore on that file - it is already encrypted under the password, and binding it to the device would make it unrecoverable after a reinstall. vault.bin keeps that role: it is the fast-open cache, and only it needs sealing. The password opens the container - the decryption itself is the check, no secret is ever compared - and the username, read from inside once open, says whose vault it is. The home screen now knows which of three situations it is in: nothing here yet, a container without a cache, or a cache ready to open. Verified on the device: with the cache set aside, the screen offers username, master password and Create vault, alongside the download path. Creating an account could not be exercised to the end - the device locked its screen mid-run - and the vault that was set aside is back in place, same size, still sealed under its code. --- android-client/PMA.Store.pas | 40 +++++++ android-client/PMA.VaultPatch.pas | 27 +++++ android-client/web/app.html | 45 ++++++++ mainApk.pas | 177 +++++++++++++++++++++++++++++- 4 files changed, 288 insertions(+), 1 deletion(-) diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index 4233aab..174b2b4 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -117,6 +117,23 @@ function LastStoreError: string; /// certitude, devrait l'appeler : il se retelecharge, mais pas gratuitement. procedure ClearVault; +/// LE CONTENEUR LOCAL - l'autorite quand l'appareil se sert seul. +/// +/// Il porte EXACTEMENT ce que porterait le depot : meme format, meme +/// chiffrement sous mot de passe (`pm-encrypted-export-v1`, PBKDF2 600k). Deux +/// raisons, pas une. C'est le modele du poste - le mot de passe maitre protege +/// le coffre au repos, et pas seulement le materiel. Et le format restant celui +/// du depot, la synchronisation continue de marcher sans rien convertir. +/// +/// PAS DE KEYSTORE ICI, volontairement : ce fichier est deja chiffre sous le +/// mot de passe, comme sa copie distante. Le lier en plus a l'appareil le +/// rendrait irrecuperable apres une reinstallation, alors que le mot de passe, +/// lui, se retient. `vault.bin` garde ce role - c'est le cache d'ouverture +/// rapide, et lui seul a besoin d'etre scelle. +function HasLocalContainer: Boolean; +function LoadLocalContainer: string; +procedure SaveLocalContainer(const AContainer: string); + /// Chemin du fichier, pour le journal et les tests. function VaultPath: string; @@ -147,6 +164,7 @@ uses const VAULT_FILE = 'vault.bin'; + LOCAL_FILE = 'vault-local.json'; 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. @@ -194,6 +212,28 @@ begin Result := FLastStoreError; end; +function LocalContainerPath: string; +begin + Result := TPath.Combine(TPath.GetHomePath, LOCAL_FILE); +end; + +function HasLocalContainer: Boolean; +begin + Result := TFile.Exists(LocalContainerPath); +end; + +function LoadLocalContainer: string; +begin + Result := ''; + if TFile.Exists(LocalContainerPath) then + Result := TFile.ReadAllText(LocalContainerPath, TEncoding.UTF8); +end; + +procedure SaveLocalContainer(const AContainer: string); +begin + TFile.WriteAllText(LocalContainerPath, AContainer, TEncoding.UTF8); +end; + function VaultPath: string; begin // GetHomePath sur Android = /data/data//files, prive a l'app. diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index f0224af..11cf28c 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -35,6 +35,11 @@ type /// ecrire quand meme la ferait reapparaitre. function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string; +/// Un instantane vide, au nom de ce compte. Sert a l'inscription depuis le +/// telephone : meme forme que ce qu'ecrit le poste, pour qu'un coffre ne +/// naisse pas dans un dialecte a part. +function NewEmptySnapshot(const AUsername: string): string; + /// Horodatage UTC au format que tout le reste emploie (toISOString cote JS). /// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une /// fois : il n'y a qu'une source d'heure ici, et elle est en UTC. @@ -48,6 +53,28 @@ begin TTimeZone.Local.ToUniversalTime(Now)); end; +function NewEmptySnapshot(const AUsername: string): string; +var + LRoot: TJSONObject; +begin + LRoot := TJSONObject.Create; + try + // Les memes cles que buildSyncSnapshot, tableaux vides compris : un lecteur + // qui trouve la cle absente et un lecteur qui la trouve vide ne se + // comportent pas forcement pareil, et ce coffre sera lu par le poste. + LRoot.AddPair('version', TJSONNumber.Create(1)); + LRoot.AddPair('snapshot_at', NowUtcIso); + LRoot.AddPair('username', AUsername); + LRoot.AddPair('folders', TJSONArray.Create); + LRoot.AddPair('avatar_b64', ''); + LRoot.AddPair('entries', TJSONArray.Create); + LRoot.AddPair('tombstones', TJSONArray.Create); + Result := LRoot.ToJSON; + finally + LRoot.Free; + end; +end; + function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string; var LRoot, LEntry: TJSONObject; diff --git a/android-client/web/app.html b/android-client/web/app.html index bf0af06..6c69892 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -227,6 +227,27 @@
+ + +
@@ -385,6 +406,19 @@ function pushConfig() { el.addEventListener('blur', pushConfig); }); +// Le meme bouton dans les deux sens : creer un coffre et en ouvrir un ne +// different que par ce qui existe deja sur l'appareil, et Delphi le sait. +var authMode = 'open'; +document.getElementById('btnAuth').addEventListener('click', function () { + var u = document.getElementById('user').value.trim(); + var p = document.getElementById('pass').value; + // Le mot de passe part, puis le champ se vide : il n'a plus rien a faire + // dans le document une fois la commande envoyee. + send((authMode === 'register' ? 'register' : 'login') + + '?u=' + encodeURIComponent(u) + '&p=' + encodeURIComponent(p)); + document.getElementById('pass').value = ''; +}); + document.getElementById('btnOpen').addEventListener('click', function () { pushConfig(); // le code vient peut-etre d'etre tape send('openvault'); @@ -720,6 +754,17 @@ var PM = { /// n'est pose supprimerait le seul endroit ou en poser un ; le montrer sans /// rien dire laisserait croire qu'il en faut un pour ouvrir. C'est donc le /// texte d'aide qui porte la difference. + /// Dans lequel des trois cas se trouve cet appareil : rien du tout, + /// un conteneur sans cache, ou un cache pret a s'ouvrir. + setAuthMode: function (mode) { + authMode = mode; + var box = document.getElementById('authbox'); + box.hidden = (mode === 'open'); + document.getElementById('pinbox').hidden = (mode !== 'open'); + document.getElementById('btnAuth').textContent = + (mode === 'register') ? 'Create vault' : 'Sign in'; + }, + setPinNeeded: function (on) { document.getElementById('pin').placeholder = on ? 'unlock code' : 'set an unlock code (optional)'; diff --git a/mainApk.pas b/mainApk.pas index 8a2c569..8d803b4 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -114,6 +114,8 @@ type /// Coordonnees du serveur. Delphi en est le detenteur ; la page ne fait /// que les editer et les lui renvoyer quand un champ est quitte. FUrl, FToken, FPwd: string; + /// Le compte de cet appareil. Vide tant qu'aucun coffre local n'existe. + FUser: string; /// Code d'ouverture du cache local, choisi dans l'application. JAMAIS /// ENREGISTRE : il est vide a chaque lancement et doit etre retape. C'est /// ce qui le distingue du jeton, dont la persistance est un confort sans @@ -162,6 +164,10 @@ type procedure OnIdleTimer(Sender: TObject); procedure ShowUnlock; + /// Cree un coffre local vide au nom donne, chiffre sous ce mot de passe. + procedure RegisterLocal(const AUser, APwd: string); + /// Ouvre le conteneur local avec ce mot de passe, et verifie le nom. + procedure LoginLocal(const AUser, APwd: string); procedure ShowList; procedure OnTotpTick(Sender: TObject); @@ -341,6 +347,20 @@ begin // coffre qu'il avait parfaitement en main. WebCall(Format('PM.setPinNeeded(%s)', [LowerCase(BoolToStr(VaultNeedsPin, True))])); + // TROIS SITUATIONS, ET ELLES NE SE DEVINENT PAS DE L'EXTERIEUR : + // 'register' - rien sur cet appareil : il faut creer un coffre (ou en + // telecharger un, ce que les reglages du serveur permettent) ; + // 'login' - un conteneur local existe mais aucun cache utilisable : le + // mot de passe maitre est necessaire, et il coute six secondes ; + // 'open' - un cache est la : l'ouverture est immediate, eventuellement + // derriere le code de deverrouillage. + if HasVault and not FVaultUnreadable then + WebCall('PM.setAuthMode("open")') + else if HasLocalContainer then + WebCall('PM.setAuthMode("login")') + else + WebCall('PM.setAuthMode("register")'); + LCanOpen := HasVault and not FVaultUnreadable; if LCanOpen then begin @@ -354,7 +374,10 @@ begin end else begin - Status('No local vault - first download'); + if HasLocalContainer then + Status('Sign in') + else + Status('No vault on this device yet'); WebCall('PM.setActions(false, "Download vault", false)'); end; end; @@ -849,6 +872,17 @@ begin else if LAction = 'sync' then DoSync(nil) + // AVEC 'setpwd', LES SEULES COMMANDES QUI TRANSPORTENT UN SECRET. Meme + // regle qu'elle : la valeur ne quitte pas le processus, l'iframe qui la + // porte est detruite, et seule l'action est journalisee - jamais le mot de + // passe, ni sa longueur. + else if LAction = 'register' then + RegisterLocal(TNetEncoding.URL.Decode(Param('u')), + TNetEncoding.URL.Decode(Param('p'))) + else if LAction = 'login' then + LoginLocal(TNetEncoding.URL.Decode(Param('u')), + TNetEncoding.URL.Decode(Param('p'))) + // La NOUVELLE valeur remonte par le pont - c'est la seule commande qui // transporte un secret. Elle ne quitte pas le processus, l'iframe qui la // porte est detruite, et HandleWebCommand ne journalise que l'action. @@ -933,6 +967,147 @@ begin ShowList; end; +/// INSCRIPTION - un coffre qui nait ici, sans reseau. +/// +/// Ce que le poste fait dans SQLite, l'appareil le fait dans un fichier : le +/// conteneur local porte exactement ce que porterait le depot, chiffre sous ce +/// mot de passe. Le poste ne connaitra pas ce compte pour autant - il cree les +/// siens dans sa base, et rien ne les rapproche aujourd'hui. +/// +/// LA DERIVATION COUTE QUATRE A SIX SECONDES sur ce telephone : elle ne peut +/// pas rester sur le thread d'interface, sinon l'ecran gele sans rien dire. +procedure TForm8.RegisterLocal(const AUser, APwd: string); +begin + if FBusy then Exit; + if AUser = '' then + begin + Status('Pick a username'); + Exit; + end; + // Huit caracteres, comme le poste. Ce mot de passe est la SEULE chose qui + // protege le conteneur - il n'y a pas de serveur derriere pour limiter les + // tentatives, et le fichier est un fichier. + if Length(APwd) < 8 then + begin + Status('Master password: 8 characters at least'); + Exit; + end; + // Un coffre existant ne se remplace pas par accident : ce serait perdre tout + // ce qu'il contient, sans retour possible. + if HasLocalContainer or HasVault then + begin + Status('This device already holds a vault'); + Exit; + end; + + SetBusy(True); + Status('Creating the vault...'); + TThread.CreateAnonymousThread( + procedure + var + LPlain, LContainer: string; + begin + try + LPlain := NewEmptySnapshot(AUser); + LContainer := EncryptContainer(LPlain, APwd); + SaveLocalContainer(LContainer); + // Pas d'ETag : rien n'a ete publie. Le cache s'ecrit quand meme, sinon + // la prochaine ouverture redemanderait six secondes de derivation pour + // un coffre vide. + SaveVault(LPlain, '', APwd, BiometricAvailable, False, FPin); + TThread.Queue(nil, + procedure + begin + SetBusy(False); + FUser := AUser; + FSyncPwd := APwd; + Log('Vault created locally for ' + AUser); + AdoptVault(LPlain); + end); + except + on E: Exception do + TThread.Queue(nil, + procedure + begin + SetBusy(False); + Log('Register failed: ' + E.ClassName + ' - ' + E.Message); + Status('Could not create the vault - see log'); + end); + end; + end).Start; +end; + +/// CONNEXION - le mot de passe ouvre le conteneur, le nom identifie. +/// +/// C'est le dechiffrement qui fait office de verification : un mot de passe +/// faux echoue sur la marque d'authenticite de GCM, sans qu'aucun secret n'ait +/// eu besoin d'etre compare. Le nom, lui, se lit DANS le conteneur une fois +/// ouvert - il ne protege rien, il dit a qui appartient ce coffre. +procedure TForm8.LoginLocal(const AUser, APwd: string); +begin + if FBusy then Exit; + if not HasLocalContainer then + begin + Status('No vault on this device'); + Exit; + end; + if APwd = '' then + begin + Status('Master password required'); + Exit; + end; + + SetBusy(True); + Status('Opening...'); + TThread.CreateAnonymousThread( + procedure + var + LPlain, LOwner: string; + LKey, LSalt: TBytes; + LIters: Integer; + LDoc: TJSONObject; + begin + try + LPlain := DecryptContainerEx(LoadLocalContainer, APwd, LKey, LSalt, LIters); + LOwner := ''; + LDoc := TJSONObject.ParseJSONValue(LPlain) as TJSONObject; + if LDoc <> nil then + try + LOwner := LDoc.GetValue('username', ''); + finally + LDoc.Free; + end; + // Un nom qui ne correspond pas n'est PAS un refus d'acces - le mot de + // passe a deja ouvert le coffre, le mal serait fait. C'est une faute de + // frappe qu'on signale, et le coffre s'ouvre au nom qu'il porte. + if (AUser <> '') and (LOwner <> '') and not SameText(AUser, LOwner) then + TThread.Queue(nil, procedure + begin Log('Vault belongs to ' + LOwner + ', not ' + AUser); end); + + SaveVault(LPlain, FETag, APwd, BiometricAvailable, False, FPin); + TThread.Queue(nil, + procedure + begin + SetBusy(False); + if LOwner <> '' then FUser := LOwner else FUser := AUser; + FSyncPwd := APwd; + AdoptVault(LPlain); + end); + except + on E: Exception do + TThread.Queue(nil, + procedure + begin + SetBusy(False); + Log('Login failed: ' + E.ClassName + ' - ' + E.Message); + // GCM echoue a sa verification quand la cle est fausse, et son + // message ne le dit pas. Le cas de loin le plus frequent. + Status('Wrong master password'); + end); + end; + end).Start; +end; + procedure TForm8.OpenLocalNow; var LPlain, LETag, LPwd: string;