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.
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/<paquet>/files, prive a l'app.
+27
View File
@@ -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;
+45
View File
@@ -227,6 +227,27 @@
<div class="screen" id="unlock">
<div class="scroll">
<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">
<button class="btn wide main" id="btnOpen">&#10140;&nbsp; Open vault</button>
<button class="btn wide" id="btnSync">&#8635;&nbsp; Sync</button>
@@ -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)';