diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index 4d19337..de6eb84 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -98,7 +98,8 @@ function HasVault: Boolean; procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; ARequireAuth: Boolean = False; AFreshKey: Boolean = False; const APin: string = ''; const AKey: TBytes = nil; - const ASalt: TBytes = nil; AIterations: Integer = 0); + const ASalt: TBytes = nil; AIterations: Integer = 0; + AClearPin: Boolean = False); /// Le fichier local est-il scelle sous un code d'application ? Lu dans /// l'en-tete, sans rien dechiffrer. @@ -887,7 +888,7 @@ end; procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; ARequireAuth, AFreshKey: Boolean; const APin: string; - const AKey, ASalt: TBytes; AIterations: Integer); + const AKey, ASalt: TBytes; AIterations: Integer; AClearPin: Boolean); {$IF DEFINED(ANDROID)} var LWrap: TJSONObject; @@ -908,7 +909,12 @@ begin // // Retirer le code demanderait donc un geste explicite, qui n'existe pas // encore : aujourd'hui on le remplace en le retapant. - if (APin = '') and VaultNeedsPin then + // AClearPin EST LA SEULE SORTIE, et elle se demande. Le refus ci-dessous + // protege tous les autres chemins - c'est lui qui a empeche une + // synchronisation de defaire un code en silence. Retirer un code reste + // possible, mais seulement quand quelqu'un l'a decide et vient de prouver + // qu'il le connaissait en ouvrant le coffre. + if (APin = '') and VaultNeedsPin and not AClearPin then begin FLastStoreError := 'cache sealed under an unlock code; refusing to ' + 'rewrite it without one'; diff --git a/android-client/TESTS.md b/android-client/TESTS.md index 23b2505..9a54ff7 100644 --- a/android-client/TESTS.md +++ b/android-client/TESTS.md @@ -120,12 +120,32 @@ prouve rien. côte à côte**. Ils demandent deux gestes différents ; les confondre dirait « quelque chose ne va pas » sans dire quoi. -### F. Pièces jointes +### F. Réglages + +1. Roue dentée dans la liste, ou *Settings* sur l'accueil → l'écran s'ouvre. +2. Les trois champs du dépôt doivent porter **les valeurs en place**, pas du + vide : ils ne sont qu'une vue des champs que Delphi connaît déjà. +3. Modifier l'URL, revenir par la flèche, *Sync* → la nouvelle valeur sert. + C'est la contre-épreuve du report à la fermeture : un réglage modifié puis + refermé **sans quitter le champ** doit compter. +4. Touche **retour** dans les réglages → elle referme **sans** verrouiller ni + revenir à la liste. +5. **Poser un code** depuis les réglages : taper, *Set this code*, puis ouvrir + le coffre → *« Unlock code changed »*. Le sceau se pose toujours à + l'ouverture, et nulle part ailleurs. +6. **Retirer le code** : *Remove the unlock code*, deux appuis → *« Unlock code + removed »*. Fermer l'application, rouvrir → **le code n'est plus demandé**. +7. Contre-épreuve du retrait : verrouiller, puis essayer de retirer le code + sans avoir ouvert le coffre → refus *« Open the vault first »*. C'est ce qui + empêche quelqu'un tenant le téléphone déverrouillé de lever la protection + sans la connaître. + +### G. Pièces jointes 1. Ligne *Attachment* → flèche de téléchargement → le fichier apparaît dans *Téléchargements* sous son nom, accents et arabe compris. -### G. Code de déverrouillage +### H. Code de déverrouillage 1. Poser un code, ouvrir le coffre, fermer, rouvrir → il est demandé. 2. Code faux → *« Wrong unlock code »*, et **Open vault reste offert** (le @@ -135,7 +155,7 @@ prouve rien. 4. Contre-épreuve : taper n'importe quoi dans le champ du code puis *Sync* → le code **ne doit pas changer**. -### H. Empaquetage +### I. Empaquetage 1. `build-apk.ps1 -Release -Keystore ... -Alias ...` → la sortie doit afficher `Signer #1 certificate DN: CN=R Zakarya`. Si elle affiche `CN=Android diff --git a/android-client/web/app.html b/android-client/web/app.html index f9470ce..8033779 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -305,6 +305,20 @@ font: 11px/1.45 monospace; white-space: pre-wrap; word-break: break-all; -webkit-user-select: text; user-select: text; } + /* LES REGLAGES SONT UN VOILE, PAS UNE QUATRIEME VUE DU RAIL. Chaque vue du + rail coute ses regles de transformation et une place dans la navigation + par la touche retour ; les reglages ne sont pas un lieu ou l'on circule, + c'est une parenthese qu'on ouvre et qu'on referme. */ + .sheet { position: fixed; inset: 0; z-index: 80; display: none; + flex-direction: column; + background: linear-gradient(var(--bg-top), var(--bg-bot)); } + body.on-settings .sheet { display: flex; } + .sheet h2 { margin: 18px 4px 6px; font-size: 13px; font-weight: 600; + color: var(--muted); text-transform: uppercase; + letter-spacing: .06em; } + .sheet .note { color: var(--muted); font-size: 13px; margin: 6px 4px 0; + line-height: 1.45; } + [hidden] { display: none !important; } .toast { @@ -363,8 +377,9 @@
- + +
@@ -446,6 +461,13 @@ autocapitalize="none" spellcheck="false">
×
+ +

Settings

+ +
+

Repository

+
+ +
×
+
+
+ +
×
+
+
+ +
×
+
+
The master password never leaves this device. It is what + decrypts the vault; the token only opens the repository.
+ +

Unlock code

+
+ +
×
+
+ + +
The code seals the copy kept on this device. Removing it + leaves the vault readable by anyone holding the phone unlocked.
+ +

Diagnostics

+ +
+
+ +
@@ -570,6 +639,14 @@ document.getElementById('btnSync').addEventListener('click', function () { // cible. Pose sur l'ECRAN, pas sur le groupe des reglages - le code a son // propre bloc depuis qu'il n'appartient plus au serveur, et sa croix serait // restee sans effet. +document.getElementById('settings').addEventListener('click', function (ev) { + var id = ev.target.getAttribute('data-clear'); + if (!id) return; + var el = document.getElementById(id); + el.value = ''; + el.focus(); +}); + document.getElementById('unlock').addEventListener('click', function (ev) { var id = ev.target.getAttribute('data-clear'); if (!id) return; @@ -591,9 +668,7 @@ function togglePanel(name) { document.querySelector('[data-panel="' + n + '"]').classList.toggle('open', open); }); } -document.getElementById('btnCfg').addEventListener('click', function () { - togglePanel('cfg'); -}); +document.getElementById('btnCfg').addEventListener('click', openSettings); document.getElementById('btnLog').addEventListener('click', function () { togglePanel('log'); }); @@ -1162,6 +1237,7 @@ document.getElementById('fields').addEventListener('click', function (ev) { + '&k=' + newKind); }); document.getElementById('new').addEventListener('click', openNewEntry); +document.getElementById('gear').addEventListener('click', openSettings); // LE MENU DE L'ENTREE, et la suppression qui y vit. // @@ -1322,6 +1398,60 @@ document.addEventListener('click', function (ev) { closeMenu(); }); +// LES REGLAGES. Ils lisent et ecrivent les MEMES champs que l'accueil - la +// page n'en garde qu'une copie, Delphi reste la seule source. +function openSettings() { + document.getElementById('sUrl').value = elUrl.value; + document.getElementById('sTok').value = elTok.value; + document.getElementById('sPwd').value = elPwd.value; + document.getElementById('sPinNew').value = ''; + document.body.classList.add('on-settings'); + send('state?view=settings'); +} + +function closeSettings() { + // Ce qui a ete tape ici redescend vers Delphi avant de fermer, sinon un + // reglage modifie puis referme sans quitter le champ serait perdu. + elUrl.value = document.getElementById('sUrl').value.trim(); + elTok.value = document.getElementById('sTok').value.trim(); + elPwd.value = document.getElementById('sPwd').value; + pushConfig(); + document.body.classList.remove('on-settings'); + send('state?view=' + view); +} + +document.getElementById('setBack').addEventListener('click', closeSettings); +document.getElementById('sLog').addEventListener('click', function () { + closeSettings(); + if (document.getElementById('log').hidden) togglePanel('log'); +}); +document.getElementById('sPinSet').addEventListener('click', function () { + var v = document.getElementById('sPinNew').value; + if (!v) { toast('Type a code first'); return; } + // LE MEME CHEMIN QUE L'ACCUEIL : c'est l'ouverture du coffre qui pose ou + // change le sceau, et elle seule. Ici on ne fait que remplir le champ. + document.getElementById('pin2').value = v; + pushConfig(); + closeSettings(); + toast('Open the vault to apply the new code'); +}); +document.getElementById('sPinClear').addEventListener('click', function (ev) { + var b = ev.currentTarget; + if (b.getAttribute('data-armed') === null) { + b.setAttribute('data-armed', '1'); + b.textContent = 'Really remove - tap again'; + setTimeout(function () { + b.removeAttribute('data-armed'); + b.textContent = 'Remove the unlock code'; + }, 4000); + return; + } + b.removeAttribute('data-armed'); + b.textContent = 'Remove the unlock code'; + send('pinclear'); + closeSettings(); +}); + function goList() { closeMenu(); document.body.classList.remove('on-detail'); @@ -1391,11 +1521,17 @@ var PM = { o.style.opacity = busy ? .4 : 1; s.style.opacity = busy ? .4 : 1; }, + /// LES CHAMPS RESTENT LA SOURCE, l'ecran de reglages n'en est qu'une vue. + /// Ils ne s'affichent plus - le panneau repliable a cede la place au voile - + /// mais c'est toujours d'eux que part pushConfig, et Delphi ne connait qu'eux. + /// Les vider ferait perdre la configuration a la premiere synchronisation. setConfig: function (url, tok, pwd, show) { // elPin volontairement absent : rien ne le restaure, jamais. elUrl.value = url; elTok.value = tok; elPwd.value = pwd; - // Replier des champs vides revient a cacher la seule chose a faire. - if (show !== !document.getElementById('cfg').hidden) togglePanel('cfg'); + // Rien a deplier : c'est le voile des reglages qui les montre desormais. + // Un depot pas encore configure s'annonce donc autrement - le bouton + // d'accueil dit "Download vault" et l'ecran, "No vault on this device". + if (show) toast('Set the repository in Settings'); }, /// LE JOURNAL SORT DE LA VUE EN RELEASE, sans disparaitre. @@ -1492,6 +1628,9 @@ var PM = { document.getElementById('lock').classList.toggle('ahead', !!on); document.getElementById('btnSync').classList.toggle('ahead', !!on); }, + setVersion: function (v) { + document.getElementById('sVersion').textContent = v; + }, setBusy: function (on) { if (on) document.getElementById('busy').firstChild.innerHTML = ico('loader', 'Working') + 'Please wait...'; @@ -1502,6 +1641,8 @@ var PM = { /// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce /// qu'il veut dire. clearSelection: function () { clearSel(); }, + /// La touche retour referme les reglages avant de toucher au coffre. + closeSettings: function () { closeSettings(); }, // Efface tout ce qui est en clair. Appele au passage en arriere-plan et au // verrouillage : le tas JS n'est pas efface par le masquage de la vue. wipe: function () { diff --git a/mainApk.pas b/mainApk.pas index 2e5e9e9..76b947e 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -303,6 +303,9 @@ type procedure PublishPending; /// Demande au depot s'il a bouge, sans rien adopter. procedure ProbeRemote; + /// Retire le code de deverrouillage : le cache se rescelle sous la seule + /// cle de l'appareil. Exige un coffre ouvert. + procedure ClearUnlockCode; procedure ReopenEntryByUuid(const AUuid: string); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); @@ -406,6 +409,7 @@ begin // cache par la version du depot, et ce qui attend ici serait perdu. FPending := HasPendingFlag; PushConfig; + WebCall('PM.setVersion("Coffre 1.0.0")'); ShowUnlock; {$IFNDEF DEBUG} // Le journal quitte l'ecran d'accueil, sans quitter l'application : un appui @@ -530,6 +534,16 @@ begin end; // C'est la PAGE qui sait ce qu'elle affiche - Delphi ne le devine pas. Elle // le declare a chaque changement de vue (cmd://state). + // LES REGLAGES SE REFERMENT D'ABORD. Ils sont une parenthese posee par-dessus + // le reste : la touche retour la ferme, elle ne saute pas ce qu'il y a + // derriere. + if FWebView = 'settings' then + begin + WebCall('PM.closeSettings()'); + Key := 0; + Exit; + end; + // UNE SELECTION EN COURS S'ANNULE D'ABORD. Verrouiller le coffre parce qu'on // a voulu annuler un choix serait le contraire de ce que le geste veut dire. if FWebView = 'select' then @@ -1605,6 +1619,9 @@ begin TNetEncoding.URL.Decode(Param('k')), TNetEncoding.URL.Decode(Param('d'))) + else if LAction = 'pinclear' then + ClearUnlockCode + else if LAction = 'delentry' then DeleteEntriesAt(TNetEncoding.URL.Decode(Param('i'))) @@ -2518,6 +2535,54 @@ end; /// pas repondre sans lui - mais il est jete sans etre dechiffre : pas de /// derivation, pas d'ecriture, seulement une comparaison de chaines. Le prix /// est en reseau, pas en temps ni en batterie. +/// RETIRER LE CODE DE DEVERROUILLAGE. +/// +/// Il manquait : vider le champ voulait dire "rien tape", pas "enlever la +/// protection", et rien d'autre ne le proposait. On pouvait donc en poser un +/// et jamais en sortir. +/// +/// EXIGE UN COFFRE OUVERT, et c'est la preuve qu'on cherche : le cache ne +/// s'est laisse lire que sous le bon code. Sans cette condition, n'importe qui +/// tenant l'appareil deverrouille pourrait retirer la protection sans la +/// connaitre. +procedure TForm8.ClearUnlockCode; +begin + if FBusy then Exit; + if not VaultNeedsPin then + begin + WebCall('PM.toast("No unlock code to remove")'); + Exit; + end; + if (FPlain = '') or (FSyncPwd = '') then + begin + WebCall('PM.toast("Open the vault first")'); + Exit; + end; + try + SaveVault(FPlain, FETag, FSyncPwd, BiometricAvailable, False, '', + FKey, FKeySalt, FKeyIters, True); + if VaultNeedsPin then + begin + Log('Unlock code not removed: ' + LastStoreError); + WebCall('PM.toast("Could not remove the unlock code")'); + Exit; + end; + FPinSealed := ''; + FPin := ''; + FPinNew := ''; + Log('Unlock code removed'); + WebCall('PM.toast("Unlock code removed")'); + WebCall('PM.pinChanged()'); + ShowUnlock; + except + on E: Exception do + begin + Log('Unlock code not removed: ' + E.ClassName + ' - ' + E.Message); + WebCall('PM.toast("Could not remove the unlock code")'); + end; + end; +end; + procedure TForm8.ProbeRemote; begin if FBusy or (FUrl = '') or (FToken = '') then Exit;