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.
This commit is contained in:
r-zakarya
2026-08-21 14:07:39 +01:00
parent b1be802e22
commit f32cde81fc
4 changed files with 288 additions and 1 deletions
+176 -1
View File
@@ -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<string>('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;