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
+40
View File
@@ -117,6 +117,23 @@ function LastStoreError: string;
/// certitude, devrait l'appeler : il se retelecharge, mais pas gratuitement. /// certitude, devrait l'appeler : il se retelecharge, mais pas gratuitement.
procedure ClearVault; 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. /// Chemin du fichier, pour le journal et les tests.
function VaultPath: string; function VaultPath: string;
@@ -147,6 +164,7 @@ uses
const const
VAULT_FILE = 'vault.bin'; VAULT_FILE = 'vault.bin';
LOCAL_FILE = 'vault-local.json';
SETTINGS_FILE = 'settings.bin'; SETTINGS_FILE = 'settings.bin';
// Un alias par schema : la contrainte d'authentification est figee a la // Un alias par schema : la contrainte d'authentification est figee a la
// creation de la cle et ne peut pas etre ajoutee apres coup. // creation de la cle et ne peut pas etre ajoutee apres coup.
@@ -194,6 +212,28 @@ begin
Result := FLastStoreError; Result := FLastStoreError;
end; 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; function VaultPath: string;
begin begin
// GetHomePath sur Android = /data/data/<paquet>/files, prive a l'app. // GetHomePath sur Android = /data/data/<paquet>/files, prive a l'app.
+27
View File
@@ -35,6 +35,11 @@ type
/// ecrire quand meme la ferait reapparaitre. /// ecrire quand meme la ferait reapparaitre.
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string; 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). /// 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 /// 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. /// 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)); TTimeZone.Local.ToUniversalTime(Now));
end; 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; function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
var var
LRoot, LEntry: TJSONObject; LRoot, LEntry: TJSONObject;
+45
View File
@@ -227,6 +227,27 @@
<div class="screen" id="unlock"> <div class="screen" id="unlock">
<div class="scroll"> <div class="scroll">
<div class="status" id="status"></div> <div class="status" id="status"></div>
<!-- SE CONNECTER, comme sur le poste. Le mot de passe maitre ouvre le
conteneur local - c'est le dechiffrement lui-meme qui verifie, aucun
secret n'est compare - et le nom dit a qui appartient ce coffre.
Cache des qu'un cache existe : redemander six secondes de derivation
a chaque lancement alors que le cache est la n'apporterait rien, et
c'est le code de deverrouillage qui garde cette porte-la. -->
<div id="authbox" class="stack" hidden>
<div class="in-wrap">
<input class="field-in" id="user" placeholder="username"
autocomplete="off" autocorrect="off" autocapitalize="none"
spellcheck="false">
<div class="in-x" data-clear="user">&#215;</div>
</div>
<div class="in-wrap">
<input class="field-in" id="pass" placeholder="master password"
type="password" autocomplete="off">
<div class="in-x" data-clear="pass">&#215;</div>
</div>
<button class="btn wide main" id="btnAuth">Sign in</button>
</div>
<div class="stack"> <div class="stack">
<button class="btn wide main" id="btnOpen">&#10140;&nbsp; Open vault</button> <button class="btn wide main" id="btnOpen">&#10140;&nbsp; Open vault</button>
<button class="btn wide" id="btnSync">&#8635;&nbsp; Sync</button> <button class="btn wide" id="btnSync">&#8635;&nbsp; Sync</button>
@@ -385,6 +406,19 @@ function pushConfig() {
el.addEventListener('blur', 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 () { document.getElementById('btnOpen').addEventListener('click', function () {
pushConfig(); // le code vient peut-etre d'etre tape pushConfig(); // le code vient peut-etre d'etre tape
send('openvault'); send('openvault');
@@ -720,6 +754,17 @@ var PM = {
/// n'est pose supprimerait le seul endroit ou en poser un ; le montrer sans /// 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 /// rien dire laisserait croire qu'il en faut un pour ouvrir. C'est donc le
/// texte d'aide qui porte la difference. /// 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) { setPinNeeded: function (on) {
document.getElementById('pin').placeholder = document.getElementById('pin').placeholder =
on ? 'unlock code' : 'set an unlock code (optional)'; on ? 'unlock code' : 'set an unlock code (optional)';
+176 -1
View File
@@ -114,6 +114,8 @@ type
/// Coordonnees du serveur. Delphi en est le detenteur ; la page ne fait /// Coordonnees du serveur. Delphi en est le detenteur ; la page ne fait
/// que les editer et les lui renvoyer quand un champ est quitte. /// que les editer et les lui renvoyer quand un champ est quitte.
FUrl, FToken, FPwd: string; 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 /// Code d'ouverture du cache local, choisi dans l'application. JAMAIS
/// ENREGISTRE : il est vide a chaque lancement et doit etre retape. C'est /// 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 /// ce qui le distingue du jeton, dont la persistance est un confort sans
@@ -162,6 +164,10 @@ type
procedure OnIdleTimer(Sender: TObject); procedure OnIdleTimer(Sender: TObject);
procedure ShowUnlock; 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 ShowList;
procedure OnTotpTick(Sender: TObject); procedure OnTotpTick(Sender: TObject);
@@ -341,6 +347,20 @@ begin
// coffre qu'il avait parfaitement en main. // coffre qu'il avait parfaitement en main.
WebCall(Format('PM.setPinNeeded(%s)', [LowerCase(BoolToStr(VaultNeedsPin, True))])); 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; LCanOpen := HasVault and not FVaultUnreadable;
if LCanOpen then if LCanOpen then
begin begin
@@ -354,7 +374,10 @@ begin
end end
else else
begin 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)'); WebCall('PM.setActions(false, "Download vault", false)');
end; end;
end; end;
@@ -849,6 +872,17 @@ begin
else if LAction = 'sync' then else if LAction = 'sync' then
DoSync(nil) 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 // 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 // transporte un secret. Elle ne quitte pas le processus, l'iframe qui la
// porte est detruite, et HandleWebCommand ne journalise que l'action. // porte est detruite, et HandleWebCommand ne journalise que l'action.
@@ -933,6 +967,147 @@ begin
ShowList; ShowList;
end; 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; procedure TForm8.OpenLocalNow;
var var
LPlain, LETag, LPwd: string; LPlain, LETag, LPwd: string;