Compare commits
428 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5722347e75 | |||
| 23568049da | |||
| 370d35f69a | |||
| 67c13332d0 | |||
| 253e81252c | |||
| fc945ac6ab | |||
| 427d294b22 | |||
| 8f0bbb580f | |||
| 61ec5ff7a1 | |||
| 988694b20d | |||
| 405ac7fac4 | |||
| 2cb7d29450 | |||
| d85f01ede2 | |||
| cd6e70449d | |||
| 960d2b388a | |||
| 2b9f3ca37d | |||
| fc03c90c95 | |||
| bf9b62a541 | |||
| 77688a5af2 | |||
| da74a675be | |||
| f8b7ffe379 | |||
| 5bcb969552 | |||
| fa2160104c | |||
| 719522df92 | |||
| 8d51aa54f7 | |||
| cbcdb3f593 | |||
| b2c677997e | |||
| d856e51230 | |||
| 23dc0a78ac | |||
| fba05f1ce5 | |||
| 8905599214 | |||
| 4fda63ec30 | |||
| 363b934e8a | |||
| 99af31a111 | |||
| 277e710754 | |||
| eb49a4ab50 | |||
| 85691bb2f1 | |||
| d43176576f | |||
| cc51540283 | |||
| 0ec32a7c19 | |||
| 0930a866c2 | |||
| b3f725137e | |||
| d428a6fc7f | |||
| 5cc80727d6 | |||
| 4e0223bee5 | |||
| a41c61405c | |||
| 203f45eb7c | |||
| 494efddb44 | |||
| 9b26a1dbd0 | |||
| af28788a0a | |||
| dd44a164f6 | |||
| ed7ccdd2d6 | |||
| 17cfc11084 | |||
| ea2450abfd | |||
| 8b4130f13e | |||
| a81fd88414 | |||
| 1a19c838cd | |||
| ab5eeb60f5 | |||
| 900f5ba740 | |||
| 45564e7266 | |||
| fe00fe8484 | |||
| 6c5fb2f4ea | |||
| 138abea88d | |||
| ac3e9db4d9 | |||
| 35d0d8ddf3 | |||
| 0604cca148 | |||
| 6b908e73a5 | |||
| 9ce07ae5d0 | |||
| 7fa26858e3 | |||
| f7665b4225 | |||
| 52f5024de0 | |||
| 09bbd3fe9e | |||
| ba4ab18d0f | |||
| 61c52e0edd | |||
| 2010dec22a | |||
| 73e3faa24c | |||
| 4ae845bc13 | |||
| 92ceb1e8c9 | |||
| af2a0dfed8 | |||
| a24b18e90f | |||
| 248cdafa49 | |||
| 4672ba8fd8 | |||
| 8315ce9230 | |||
| 0fa521b709 | |||
| 3e0bc7bd84 | |||
| 6d8f36b5c7 | |||
| b245f223f2 | |||
| 405d295523 | |||
| 40f7667243 | |||
| 6b3001666e | |||
| cb2e2c7a5f | |||
| 2e84c8a7f8 | |||
| 5df1c7e8e8 | |||
| ebd4752ec9 | |||
| 0619740da1 | |||
| 00d5b582cc | |||
| a3a6d8b0f8 | |||
| 1f9606fed4 | |||
| d06410dd7e | |||
| b1f84cb977 | |||
| 4ba4c20dc3 | |||
| 68722e301c | |||
| 1dba5d392f | |||
| 0badc5fcfc | |||
| c25d68b721 | |||
| 2fa093d49c | |||
| 0b0f1e3fcd | |||
| eb9999c4b2 | |||
| 30b2413ea3 | |||
| 31bf4f920c | |||
| 795f07fd8a | |||
| 5522089939 | |||
| 5107ebf7b2 | |||
| 7a0ae6398b | |||
| 732c52a64e | |||
| 4785dc116a | |||
| 131bf44b36 | |||
| ad8a037e29 | |||
| 7ee2feba1b | |||
| 79a6625613 | |||
| bde22d2288 | |||
| bb222e1903 | |||
| 527a953b2a | |||
| 9a32fe8881 | |||
| 642dfdb077 | |||
| 078d548110 | |||
| d65fe909b4 | |||
| f32cde81fc | |||
| b1be802e22 | |||
| afc6119cf8 | |||
| b7125a7373 | |||
| 8f8a8ec3f4 | |||
| 90ea09a718 | |||
| 7a9fe4ae77 | |||
| 75fde64c20 | |||
| 3392bf7c7a | |||
| a41eeb8bfa | |||
| 3e020acf24 | |||
| 7528146a4e | |||
| 587536bb68 | |||
| 4d9fd2c34a | |||
| b9a3b3e748 | |||
| f245cdcc38 | |||
| 440bfd5536 | |||
| 6bb056220e | |||
| 5324785ed9 | |||
| 060611f101 | |||
| 382e38913b | |||
| 683655415f | |||
| 90e8fabf58 | |||
| ae48fadb6b | |||
| c73ac66313 | |||
| 60e3cae3e4 | |||
| 65f5659d06 | |||
| f97e44768a | |||
| aa87580443 | |||
| 930f74147f | |||
| eac3cac9d6 | |||
| c1d0eb3891 | |||
| 6cab9b7c0a | |||
| c2f7f47eba | |||
| 661aac744c | |||
| 74b20266b3 | |||
| c215a5755d | |||
| 0bcf9b95a2 | |||
| 68df2dd7e7 | |||
| b3fbaf44bd | |||
| 77809b09c8 | |||
| d1d5d29879 | |||
| ec8b5b3410 | |||
| 8c1cabb5bd | |||
| 470598a723 | |||
| 7218df2f6f | |||
| e85f46f501 | |||
| 218a0dde03 | |||
| d22089900c | |||
| 4347dba8d9 | |||
| 9bc005696d | |||
| 2e2ded1146 | |||
| 27a7a322d6 | |||
| 1609b4d328 | |||
| 40475b0204 | |||
| 8f9ebb8d47 | |||
| 8dedcaecab | |||
| ceaba331d8 | |||
| 5ef4987272 | |||
| 49377b0990 | |||
| aba873b9cc | |||
| e128b85658 | |||
| 301a999596 | |||
| 773e47330f | |||
| d472dd4ef8 | |||
| e4a50df64d | |||
| fa424a3bc7 | |||
| bc89ec0010 | |||
| b900b5b609 | |||
| e8e305b6fd | |||
| 5a2774974a | |||
| 09f108aa8d | |||
| e5eb91cd3f | |||
| 25dc524585 | |||
| fdeb870541 | |||
| 202b93c246 | |||
| 65e6ba6a54 | |||
| 31ee1b45c6 | |||
| b5ccf63a85 | |||
| 49efccdd59 | |||
| 861d721513 | |||
| 3a9248c6c7 | |||
| bbb44a3787 | |||
| 4c6376edbf | |||
| 7229028060 | |||
| 7939d268f3 | |||
| 0e39cf7e56 | |||
| f3a8631440 | |||
| c4d2bdde73 | |||
| 8dc0840e03 | |||
| 1dc4f3bdb6 | |||
| 09048410e1 | |||
| 5187da3070 | |||
| 57a295ad36 | |||
| 4258e9b559 | |||
| 6d31bf83d2 | |||
| 4805c9ac31 | |||
| 9a4170c44f | |||
| 3bb6f6f903 | |||
| ba0d3c1d3c | |||
| acf4b466e0 | |||
| 20ba2438df | |||
| 3232f6f163 | |||
| 0ddfe5c5fd | |||
| bf7aac478b | |||
| 4effc3611d | |||
| 3cbfd54c26 | |||
| 60b806536a | |||
| 1e7477cdd6 | |||
| 9649dac7b2 | |||
| 5fdb7f914e | |||
| 3c1525e46f | |||
| a641669150 | |||
| ccab734d61 | |||
| d9cadf2cac | |||
| 8796bb341d | |||
| c684d9073f | |||
| 307bbeae83 | |||
| ffe203a1ef | |||
| 4cc79a5216 | |||
| c607e26c4a | |||
| 386bf74a2a | |||
| 02d89c58d2 | |||
| 90ac75525a | |||
| ec4fa433a9 | |||
| c74b708b39 | |||
| ebcd75fd2e | |||
| 125cb11756 | |||
| afe47259d7 | |||
| 476d6660ad | |||
| d242f09c53 | |||
| c3c6bce406 | |||
| b9b098fe12 | |||
| 485dd3631e | |||
| c1e5d14eac | |||
| af1157f3b0 | |||
| 5236aa8b98 | |||
| a36164174e | |||
| c8518c806d | |||
| f8e78f6940 | |||
| 5ce18e7ab5 | |||
| 3c14836b15 | |||
| 7074e7ff93 | |||
| ecaa226354 | |||
| 4c734de266 | |||
| 8c633b69de | |||
| a12f3a2c91 | |||
| 29ba069659 | |||
| cb0a366e52 | |||
| 69b0e84f63 | |||
| dc7b18736e | |||
| 2027d9b820 | |||
| 69a62994b4 | |||
| 9e5c5945f9 | |||
| fb65f4b7d6 | |||
| 961d77534f | |||
| fd03c1da25 | |||
| c9068a6732 | |||
| e80703d2c4 | |||
| 6bee69eb1a | |||
| 2436825ed6 | |||
| 254bbc3bca | |||
| eaadc06daa | |||
| dca98dde9b | |||
| e07488e8b4 | |||
| 4a78dc2e90 | |||
| d6701a8994 | |||
| c9c1e98470 | |||
| 1c0423680d | |||
| 42d2d6fda5 | |||
| 03cc7adc25 | |||
| be629b2bcf | |||
| 6bb64832ef | |||
| 5d10935ae2 | |||
| d8a1728371 | |||
| bd86899c2b | |||
| 5eb279ff31 | |||
| 7158664b0d | |||
| 2579264285 | |||
| 9b32ae407e | |||
| e919a79522 | |||
| 4936aa78ca | |||
| a8c1a88ec4 | |||
| e5758f984c | |||
| a2cd08c676 | |||
| c71ea643e7 | |||
| a0a069c7f6 | |||
| 6705a8b478 | |||
| 4b7f5ff599 | |||
| 9dc9f625d6 | |||
| 2cab20eb76 | |||
| d8643b96d1 | |||
| 415dc6c8ba | |||
| 67107fba37 | |||
| 52af21f917 | |||
| 83a0e08df8 | |||
| 4499c8c570 | |||
| 80711272c4 | |||
| 7c08a69bb2 | |||
| 16577a22be | |||
| 38ba208455 | |||
| 40e2054496 | |||
| 2a1da8913b | |||
| 33d8df3dee | |||
| a9e56c52b5 | |||
| 3c84bdca49 | |||
| e98acabbbd | |||
| 17e817f8ab | |||
| b52cdc2d0b | |||
| 99b68c3e67 | |||
| ca23b88582 | |||
| 8d03b74b6a | |||
| 04f52851fc | |||
| ab8186ef75 | |||
| 4f2aff9de4 | |||
| 17e48aa688 | |||
| 8fa956f07f | |||
| 283676224f | |||
| 0506d757dd | |||
| eec7bc4f80 | |||
| 169cdddf9d | |||
| e6a749465e | |||
| 087eb52d66 | |||
| 220710174f | |||
| af234ce049 | |||
| f198eaf6f3 | |||
| e68536420f | |||
| 59090dcf67 | |||
| e88dbf3ec8 | |||
| 610755e8e6 | |||
| 2773cc444b | |||
| 7ae20aac43 | |||
| 63dd7a296a | |||
| 72fe01f513 | |||
| 7a19bdc13b | |||
| ca54c39c75 | |||
| 03c638b2e1 | |||
| 28f0d1c5bc | |||
| ab12f6bf66 | |||
| 8204ccb316 | |||
| 03a8a6f8a6 | |||
| 6bed75f49e | |||
| f45a17d876 | |||
| 6518b59860 | |||
| 1bcb74b0bd | |||
| c818f12c24 | |||
| dd69747cb8 | |||
| 3513e7b6e5 | |||
| feb956001e | |||
| 4965ff2518 | |||
| 3d3ab3b02b | |||
| 4c745d2dd9 | |||
| 7c86f35fce | |||
| 2d6ba98073 | |||
| 47689f693c | |||
| 0184fc7cc2 | |||
| d40fd57133 | |||
| 8b02a5650a | |||
| 660f495649 | |||
| 1638246a53 | |||
| 30e37b3fbd | |||
| a6ba0100ee | |||
| 18fe13a861 | |||
| f45b96d147 | |||
| 0434eec85e | |||
| d101192e22 | |||
| e7dca84f40 | |||
| 665d8ea4e6 | |||
| 9b7d04ee46 | |||
| b542106c76 | |||
| de50a5f71f | |||
| 5f807bcf23 | |||
| 4ceb7ab872 | |||
| 04bc809c71 | |||
| 47c29bd04e | |||
| a95becd582 | |||
| 6f4363bde6 | |||
| 9ea26be422 | |||
| d146fbe796 | |||
| e1ddc65f85 | |||
| 3dbce780d2 | |||
| 4b9ffaab62 | |||
| 4c2287fd35 | |||
| 3f07c1217b | |||
| 72ef034459 | |||
| a0b6c5c67a | |||
| 506230f138 | |||
| bef739b7ca | |||
| 24d74df2ab | |||
| 60cf26b259 | |||
| a1e028bfc5 | |||
| 4dfd908a9a | |||
| 0d40328a24 | |||
| a8e0a3da00 | |||
| 5799407f55 | |||
| 370432f638 | |||
| 47aec30934 | |||
| 2e7aebf651 | |||
| 19b66b7bbd | |||
| c2cc9c72e6 | |||
| 71f45eb8e1 |
@@ -31,3 +31,10 @@
|
||||
*.ico binary
|
||||
*.png binary
|
||||
*.rar binary
|
||||
|
||||
# RAD Studio exige CRLF dans les sources Pascal — sans cette regle, git les
|
||||
# normalise en LF au commit et le compilateur le signale a chaque unite.
|
||||
*.pas text eol=crlf
|
||||
*.dpr text eol=crlf
|
||||
*.dproj text eol=crlf
|
||||
*.fmx text eol=crlf
|
||||
|
||||
+37
@@ -62,7 +62,44 @@ delphi-backend/vault - Copie*.db
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
# Les icones du lanceur sont du contenu, pas des captures d'ecran de passage.
|
||||
# Elles se regenerent par make-icons.ps1, mais les suivre evite d'avoir a le
|
||||
# savoir - et de dependre de System.Drawing pour fabriquer un APK.
|
||||
!android-client/android-res/**/*.png
|
||||
|
||||
# Delphi build artifacts
|
||||
delphi-backend/*.vrc
|
||||
delphi-backend/*.$manifest
|
||||
|
||||
# PHP sync endpoint — holds the bearer token / the snapshot itself
|
||||
server-php/config.php
|
||||
vault-data/
|
||||
|
||||
# Sorties de compilation Android (regenerees a chaque build)
|
||||
Android/
|
||||
Android64/
|
||||
*.o
|
||||
*.apk
|
||||
*.apk.idsig
|
||||
pmserverApk.res
|
||||
|
||||
# Mot de passe de sync du banc d'essai - jamais versionne
|
||||
android-client/PMA.LocalConfig.inc
|
||||
# Genere depuis theme.rc + pm-dark.style, tous deux suivis (voir README).
|
||||
android-client/theme/theme.RES
|
||||
android-client/theme/theme.res
|
||||
android-client/web/*.res
|
||||
|
||||
# Magasins de cles de signature. Le perdre interdit toute mise a jour de
|
||||
# l'application deja installee, Android refusant une signature differente :
|
||||
# il se sauvegarde ailleurs, il ne se versionne pas.
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Cle privee des licences. Qui la detient fabrique des licences valables :
|
||||
# elle vit dans un gestionnaire de mots de passe, pas dans un depot.
|
||||
tools/licence-private.key
|
||||
|
||||
# Blocs brouilles intermediaires (BuildAssets.ps1). Regeneres a chaque build,
|
||||
# comme assets.res : jetables, pas versionnes.
|
||||
delphi-backend/assets/obf/
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- BEGIN_INCLUDE(manifest) -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="%package%"
|
||||
android:versionCode="%versionCode%"
|
||||
android:versionName="%versionName%"
|
||||
android:installLocation="%installLocation%">
|
||||
<uses-sdk android:minSdkVersion="%minSdkVersion%" android:targetSdkVersion="%targetSdkVersion%" />
|
||||
<!-- LA LISTE EST ECRITE ICI, PAS HERITEE. Le marqueur de substitution qui
|
||||
se trouvait a cette place y deversait les defauts de Delphi :
|
||||
localisation fine et grossiere,
|
||||
camera, telephone, etat du telephone, stockage externe en lecture et
|
||||
en ecriture. Aucune n'est utilisee, et sur un coffre a mots de passe
|
||||
ce sont exactement les mauvais signaux - celui qui lit la fiche de
|
||||
l'application y voit une demande de position et d'appareil photo sans
|
||||
explication possible.
|
||||
|
||||
Il n'en reste qu'une : le reseau, pour parler au depot. Les deux de
|
||||
biometrie ci-dessous sont deja explicites depuis longtemps. Ajouter
|
||||
une permission demande donc de l'ecrire ici, ce qui est le but.
|
||||
|
||||
NE PAS ECRIRE LE NOM DU MARQUEUR DANS CE COMMENTAIRE : la substitution
|
||||
est textuelle et ne regarde pas si elle tombe dans un commentaire -
|
||||
le nommer pour l'expliquer suffit a le faire revenir. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Absente de la liste par defaut de Delphi, et sans elle BiometricPrompt
|
||||
leve java.lang.SecurityException a la premiere invite - pas au
|
||||
demarrage, donc le defaut ne se voit qu'en essayant d'ouvrir le coffre.
|
||||
USE_FINGERPRINT couvre les versions anterieures a Android 9, ou
|
||||
BiometricPrompt n'existe pas encore. -->
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||
<uses-feature android:glEsVersion="0x00020000" android:required="true"/>
|
||||
<queries>
|
||||
<%queries-child-elements%>
|
||||
</queries>
|
||||
<!-- networkSecurityConfig REMPLACE usesCleartextTraffic="true", qui
|
||||
autorisait le clair vers n'importe quel hote pour la seule raison que
|
||||
l'essai local via "adb reverse" parle HTTP. La configuration ne
|
||||
l'autorise plus que vers la boucle locale, nommement : le jeton ne
|
||||
peut plus sortir qu'en TLS, et une URL http:// vers un vrai hote
|
||||
echoue bruyamment au lieu de le laisser passer en clair.
|
||||
Voir android-client/android-res/xml/network_security_config.xml. -->
|
||||
<!-- allowBackup=false : le repertoire prive contient vault.bin, c'est-a-dire
|
||||
le coffre entier. Il est chiffre par une cle qui ne quitte pas la puce,
|
||||
donc ce qui sortirait serait inexploitable - mais il sortirait, et une
|
||||
sauvegarde cloud n'a rien a faire dans le chemin d'un gestionnaire de
|
||||
mots de passe. fullBackupContent et dataExtractionRules ne sont pas
|
||||
necessaires une fois la sauvegarde refusee en bloc. -->
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:persistent="%persistent%"
|
||||
android:restoreAnyVersion="%restoreAnyVersion%"
|
||||
android:label="%label%"
|
||||
android:debuggable="%debuggable%"
|
||||
android:largeHeap="%largeHeap%"
|
||||
android:icon="%icon%"
|
||||
android:theme="%theme%"
|
||||
android:hardwareAccelerated="%hardwareAccelerated%"
|
||||
android:resizeableActivity="true"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<%provider%>
|
||||
<%application-meta-data%>
|
||||
<%uses-libraries%>
|
||||
<!-- Trigger Google Play services to install the backported photo picker module. -->
|
||||
<service
|
||||
android:name="com.google.android.gms.metadata.ModuleDependencies"
|
||||
android:enabled="false"
|
||||
android:exported="false"
|
||||
tools:ignore="MissingClass">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="photopicker_activity:0:required" android:value="" />
|
||||
</service>
|
||||
<%services%>
|
||||
<!-- Our activity is a subclass of the built-in NativeActivity framework class.
|
||||
This will take care of integrating with our NDK code. -->
|
||||
<activity
|
||||
android:name="com.embarcadero.firemonkey.FMXNativeActivity"
|
||||
android:exported="true"
|
||||
android:label="%activityLabel%"
|
||||
android:configChanges="orientation|keyboard|keyboardHidden|screenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask">
|
||||
<!-- Tell NativeActivity the name of our .so -->
|
||||
<meta-data android:name="android.app.lib_name" android:value="%libNameValue%" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- FMX n'affiche pas BiometricPrompt depuis notre NativeActivity : il
|
||||
lance SA PROPRE activite, qui heberge le fragment androidx. Elle
|
||||
vit dans fmx.dex.jar, mais Android exige qu'elle soit declaree ici
|
||||
- sinon android.content.ActivityNotFoundException a la premiere
|
||||
invite, et nulle part avant.
|
||||
|
||||
Theme translucide du framework : cette activite ne dessine rien
|
||||
d'elle-meme, elle ne fait qu'heberger le dialogue systeme. Sans
|
||||
cela elle recouvrirait l'ecran d'une surface opaque le temps de
|
||||
l'authentification. On prend un theme @android: et non AppCompat,
|
||||
absent des ressources generees. -->
|
||||
<activity
|
||||
android:name="com.embarcadero.firemonkey.biometricauth.BiometricFragmentActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<%activity%>
|
||||
<%receivers%>
|
||||
</application>
|
||||
</manifest>
|
||||
<!-- END_INCLUDE(manifest) -->
|
||||
@@ -22,6 +22,44 @@ Toute modif `index.html` / `js/` / `css/` nécessite :
|
||||
Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le
|
||||
plus courant après modif frontend.
|
||||
|
||||
### Blocs embarqués brouillés
|
||||
|
||||
Depuis 2026-09-05, `BuildAssets.ps1` **brouille** chaque fichier avant de le
|
||||
donner à `brcc32` (dans `assets/obf/`, gitignoré ; les sources en clair ne sont
|
||||
jamais touchées) et `PM.EmbeddedAssets.DeobfuscateAsset` le débrouille au moment
|
||||
de servir.
|
||||
|
||||
```
|
||||
bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
|
||||
sortie = XOR( magie 'PMA1' + contenu du fichier )
|
||||
```
|
||||
|
||||
- **C'est de l'opacité, pas du secret.** La clé est dans l'exe : qui tient le
|
||||
binaire retrouve le texte. Ce que ça supprime, c'est l'onglet *Image* de
|
||||
Process Explorer et n'importe quel `strings` — le frontend n'est plus lisible
|
||||
en survolant le fichier. L'onglet *Memory*, un débogueur ou la réponse HTTP le
|
||||
rendent toujours : WebView2 doit recevoir du JavaScript en clair pour
|
||||
l'exécuter. D'où **XOR plutôt qu'AES** : à sécurité rigoureusement égale
|
||||
(nulle, contre qui a l'exe), dix lignes au lieu d'un appel CNG, et surtout
|
||||
aucune force affichée qu'on n'a pas.
|
||||
- Le flux dépend du **nom de la ressource** : tous les fichiers commencent par
|
||||
les mêmes lignes de commentaire, un flux commun rendrait ces préfixes
|
||||
identiques d'un bloc à l'autre.
|
||||
- **La magie `PMA1` préfixe le clair.** Sans elle, une clé qui a dérivé entre le
|
||||
script et l'exe servirait du binaire au navigateur : écran blanc, aucune
|
||||
trace. Avec elle, `TryServeEmbedded` refuse. **Symptôme à reconnaître :
|
||||
`/index.html` en 404 juste après avoir touché `AssetObfSecret` d'un seul
|
||||
côté** — les deux constantes (`$ObfSecret` / `AssetObfSecret`) doivent rester
|
||||
identiques.
|
||||
- **Coût mesuré : 2,45 ms** pour les 820 Ko embarqués (AES chronométré comme
|
||||
borne haute ; le XOR est plus rapide), une seule fois au chargement de la
|
||||
page. À comparer aux ~650 ms d'Argon2id au déverrouillage.
|
||||
- **Les tests frontend ne sont pas concernés** : ils lisent les `.js` sur le
|
||||
disque, jamais le `.res`.
|
||||
- Vérifié en comparant les flux des deux côtés bloc par bloc (ils doivent être
|
||||
identiques au bit près) puis en refaisant l'aller-retour sur `app.js`,
|
||||
`index.html` et `style.css`.
|
||||
|
||||
### Découpage frontend (§3.1, en cours)
|
||||
|
||||
`app.js` (~12k lignes) est **progressivement scindé** en fichiers classic-script
|
||||
@@ -148,17 +186,296 @@ Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`.
|
||||
|
||||
- `Ctrl+Shift+L` : autofill complet (user + Tab + password)
|
||||
- `Ctrl+Shift+P` : autofill password seul (forms step-2, unlock screens)
|
||||
- `Ctrl+Shift+T` : autofill du **code 2FA** (cliquer le champ 2FA d'abord).
|
||||
ID hotkey 47 / `akTotp`. Ne matche que les entrées avec `totp_secret` (filtre
|
||||
appliqué **avant** le scoring → le picker ne propose jamais un candidat sans
|
||||
TOTP). JS génère le code et l'envoie **dans le slot password avec un username
|
||||
vide** → réutilise le chemin password-only de `ExecuteAutofill`, aucun code
|
||||
natif en plus.
|
||||
- `Ctrl+Shift+A` : new entry pré-rempli avec le titre de la fenêtre foreground
|
||||
- `Ctrl+Shift+D` : toggle debug panel (uniquement si `config.txt` présent)
|
||||
|
||||
Combos autofill configurables depuis Settings (synced en DB
|
||||
via `settings_json`).
|
||||
|
||||
## Fill bar (remplir à la souris)
|
||||
|
||||
Menu du tray → **Fill bar** : une barrette de trois
|
||||
boutons *Full* / *Password* / *2FA* qui flotte au-dessus de tout et fait
|
||||
exactement ce que font `Ctrl+Shift+L` / `Ctrl+Shift+P` / `Ctrl+Shift+T`. Deux
|
||||
boutons de plus, *Open* et `X` : quand la barrette est allumée, elle est souvent
|
||||
la seule chose de l'application à l'écran — revenir au coffre ou s'en
|
||||
débarrasser ne doit pas obliger à retrouver l'icône du tray.
|
||||
|
||||
**`WS_EX_NOACTIVATE` est toute l'astuce.** Une fenêtre ordinaire prendrait le
|
||||
premier plan au clic — le champ que l'utilisateur vient de cliquer perdrait son
|
||||
curseur, et `GetForegroundWindow` nous désignerait nous-mêmes comme cible.
|
||||
NOACTIVATE veut dire que le clic ne déplace jamais le premier plan : le
|
||||
navigateur reste actif, son champ reste focalisé, et la capture voit exactement
|
||||
ce que voit le chemin hotkey.
|
||||
|
||||
Détails :
|
||||
- Fenêtre créée par un **second** `AllocateHWnd` (`FLazyWnd`), séparée de la
|
||||
fenêtre message-only : `WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE`,
|
||||
deux enfants `BUTTON` (IDs 101/102) → `WM_COMMAND` → `FireAutofillRequest`.
|
||||
- `FireAutofillRequest(AKind)` est le corps **extrait** du handler `WM_HOTKEY` :
|
||||
hotkey et barrette partagent capture de la cible, matching de titre et picker.
|
||||
Un seul chemin, deux gestes.
|
||||
- `WM_NCHITTEST` → `HTCAPTION` : toute la barrette est sa propre barre de titre
|
||||
(elle finit par se poser sur le champ qu'elle remplit, il faut pouvoir la
|
||||
pousser). `WM_ERASEBKGND` peint le fond — la classe `TPUtilWindow`
|
||||
d'`AllocateHWnd` n'a **pas** de brosse de fond.
|
||||
- **Le nom interne est resté `lazy`** — `FLazyWnd`, `LAZY_*`, les prefs
|
||||
`lazyOn`/`lazyPos`/`lazyAlphaPct`. Seul le libellé a changé : renommer les
|
||||
clés de prefs perdrait les réglages déjà posés sur la machine.
|
||||
- **L'état allumé/éteint est retenu** en pref DPAPI (`lazyOn`) et repris au
|
||||
démarrage par `RestoreLazyBar`, appelé depuis `FormCreate` **après**
|
||||
`ApplyTitleBarTheme` et après le câblage de `OnTrayRestore` : la barrette se
|
||||
peint elle-même (il lui faut le thème) et son bouton *Open* passe par cet
|
||||
événement.
|
||||
- Position : coin bas-droit de la *work area* au premier lancement, puis
|
||||
**retenue** en pref DPAPI (`lazyPos`, `'x,y'`), écrite sur `WM_EXITSIZEMOVE`
|
||||
(une écriture par déplacement, pas une par pixel parcouru). Elle n'est reprise
|
||||
que si `MonitorFromRect(…, MONITOR_DEFAULTTONULL)` la trouve encore sur un
|
||||
écran : la barrette n'a **pas d'entrée dans la barre des tâches**, donc une
|
||||
position héritée d'un écran débranché la rendrait définitivement
|
||||
irrécupérable.
|
||||
- Le `X` **poste** `WM_LAZY_CLOSE` à la fenêtre message-only au lieu de détruire
|
||||
sur place : libérer une fenêtre depuis son propre gestionnaire libérerait, en
|
||||
plein envoi, l'instance qui lui sert de window proc.
|
||||
- **Transparence au repos** (`WS_EX_LAYERED` + `SetLayeredWindowAttributes`) :
|
||||
pleine opacité quand la souris est dessus, `FLazyAlphaPct` sinon. Réglée par
|
||||
**clic droit sur la barrette** → 100/75/50/25 %, persistée en pref DPAPI
|
||||
(`lazyAlphaPct`, défaut 60) — **pas** dans `settings_json`, qui exige une
|
||||
session alors que la barrette peut être allumée coffre fermé. Le menu
|
||||
appartient à la fenêtre message-only : `TrackPopupMenu` réclame un
|
||||
`SetForegroundWindow` pour se refermer seul, et le faire sur la barrette
|
||||
détruirait le `NOACTIVATE` qui la fait fonctionner. Deux portes vers ce menu —
|
||||
`WM_NCRBUTTONUP` (le fond est non-client à cause de `HTCAPTION`) et
|
||||
`WM_CONTEXTMENU` (remonté par les boutons).
|
||||
- **`TOPMOST` n'est pas un rang, c'est une bande** : la barre des tâches y est
|
||||
aussi, et cliquer son fond vide la remonte en tête de cette bande — une
|
||||
barrette posée dessus passe **derrière** et semble avoir disparu. Le même
|
||||
battement de 200 ms la repose (`SetWindowPos … SWP_NOMOVE or SWP_NOSIZE`) ;
|
||||
sans mouvement ni taille à changer, l'appel ne repeint rien. **Ce battement
|
||||
s'arrête pendant le menu d'opacité** (`KillTimer` / `SetTimer` autour de
|
||||
`TrackPopupMenu`, qui est modal) : le menu déroulant est lui aussi dans la
|
||||
bande `TOPMOST`, et se faisait donc recouvrir par la barrette qui venait de
|
||||
l'ouvrir.
|
||||
- **Traîner depuis un bouton** : les boutons sont subclassés (`GWLP_WNDPROC`,
|
||||
une seule proc d'origine — même classe). Sur `WM_LBUTTONDOWN`, **`DragDetect`**
|
||||
tranche entre clic et glissement en respectant le seuil réglé par
|
||||
l'utilisateur. Glissement → `ReleaseCapture` + `WM_SYSCOMMAND, SC_MOVE or
|
||||
HTCAPTION`. Clic → on **envoie `BN_CLICKED` à la place du bouton** : DragDetect
|
||||
a avalé le `WM_LBUTTONUP`, lui rendre la main le laisserait enfoncé pour
|
||||
toujours. Le clic droit n'est pas intercepté, donc le menu d'opacité tient.
|
||||
- Le survol est **sondé** par un `SetTimer` de 200 ms, pas par `TrackMouseEvent` :
|
||||
avec quatre fenêtres enfants, passer de la barrette à un bouton envoie déjà un
|
||||
`WM_MOUSELEAVE` au parent. Comparer le curseur au rectangle ignore ce piège.
|
||||
- Transparente **n'est pas traversante** : la barrette avale toujours les clics
|
||||
sur toute sa surface (`WS_EX_TRANSPARENT` contredirait ses boutons).
|
||||
- **Thème** : boutons `BS_OWNERDRAW` + `WM_DRAWITEM`, palette recopiée des
|
||||
variables de `css/style.css` en COLORREF (`$00BBGGRR` — octets **inversés**
|
||||
par rapport à l'hexa CSS). `SetPreferredAppMode(ForceDark)` ne themise que ce
|
||||
que le système dessine lui-même (menus, ascenseurs, infobulles) : un bouton
|
||||
Win32 standard reste gris clair sur fond sombre. `ApplyTitleBarTheme` mémorise
|
||||
le mode dans `FDarkTheme` et `RedrawWindow(… RDW_ALLCHILDREN)` la barrette.
|
||||
Le cadre est peint dans `WM_ERASEBKGND` plutôt que par `WS_BORDER`, qui se
|
||||
sert dans les couleurs système.
|
||||
|
||||
## Licences (`PM.Licence`)
|
||||
|
||||
Verification **hors ligne** d'une licence signee ECDSA P-256. Une licence est
|
||||
une chaine `charge-utile.signature`, chacune en base64url, fabriquee par
|
||||
`tools/licence.ps1` avec la cle privee qui reste chez l'editeur. Seule la cle
|
||||
**publique** est dans l'exe : elle ne permet que de verifier.
|
||||
|
||||
```
|
||||
tools\licence.ps1 -Init # une seule fois : cree la paire,
|
||||
# imprime la constante Delphi
|
||||
tools\licence.ps1 -For "a@b.com" -Name "Ami" # une licence
|
||||
```
|
||||
|
||||
- **`LicencePublicKeyBlob`** (72 octets) est un `BCRYPT_ECCPUBLIC_BLOB` :
|
||||
magic `ECS1` + longueur(32) + X(32) + Y(32). **Livre a zero** — tant qu'on
|
||||
n'y a pas colle la sortie de `-Init`, aucune licence n'est valide. Ne jamais
|
||||
regenerer la paire ensuite : toutes les licences deja emises tomberaient.
|
||||
- **La cle privee s'ecrit dans `%USERPROFILE%\Documents\pmserver-licence-private.key`,
|
||||
hors du depot par construction.** Elle a d'abord vecu dans `tools\`, ou elle
|
||||
n'attendait qu'un menage pour disparaitre — ce qui est arrive le 2026-09-05,
|
||||
sans copie, obligeant a regenerer la paire et a reemettre. Le `.gitignore` la
|
||||
protegeait du depot ; rien ne la protegeait de son proprietaire. Sa vraie
|
||||
place est un coffre ; le fichier n'est qu'un intermediaire, et `-KeyFile`
|
||||
permet de la relire d'ou on l'a mise.
|
||||
- **Perdre la privee et perdre la publique n'ont pas le meme effet** : sans la
|
||||
privee on ne peut plus emettre AUCUNE licence nouvelle (les existantes
|
||||
continuent — l'exe n'a que la publique) ; la reparation est de regenerer,
|
||||
reconstruire et reemettre chacune. Indolore avec une licence en circulation,
|
||||
ingerable avec vingt.
|
||||
- **Aucun ASN.1 nulle part.** .NET sur Windows EST CNG : la signature produite
|
||||
par le script (`r||s` brut, 64 octets) est deja celle qu'attend
|
||||
`BCryptVerifySignature`, et les coordonnees exportees se rangent telles
|
||||
quelles dans le blob. Le decodage DER, moitie penible de l'exercice
|
||||
d'habitude, n'existe pas ici.
|
||||
- `BCryptVerifySignature` attend **l'empreinte**, pas le message : SHA-256 est
|
||||
calcule avant l'appel (`THashSHA2.Create` + `Update`, pas `GetHashBytes` qui
|
||||
n'a pas de surcharge `TBytes`).
|
||||
- **La signature d'abord, la lecture ensuite** : `SplitAndVerify` n'analyse
|
||||
jamais une charge utile dont il ne sait pas encore qu'elle vient de nous.
|
||||
- `IsLicensed` **REVERIFIE** la licence rangee au lieu de lire un drapeau : un
|
||||
drapeau se retourne, une signature se refabrique seulement avec la cle privee.
|
||||
Resultat mis en cache en memoire, invalide par `ActivateLicence`/`ClearLicence`.
|
||||
- Rangee en pref DPAPI (`licenceKey`) — liee au compte Windows, survit au
|
||||
changement de port comme le reste.
|
||||
- **Pas d'expiration dans la charge utile**, volontairement : une date qu'on
|
||||
fait respecter avec l'horloge de la machine du client ne prouve rien. Le
|
||||
champ `d` n'est qu'une date d'emission.
|
||||
|
||||
### Liage au poste (`PM.MachineId`)
|
||||
|
||||
Le champ `m` de la charge utile porte l'empreinte du poste ; `MachineMatches`
|
||||
la compare **apres** la verification de signature — une empreinte n'a de sens
|
||||
que dans une charge utile dont on sait deja qu'elle vient de nous.
|
||||
|
||||
- Empreinte = `SHA256('pmserver/machine/v1|' + UUID carte mere + '|' + serie du
|
||||
volume systeme)`, tronquee a 16 hexa, affichee en groupes de quatre
|
||||
(`CD6F-F1B3-25C4-0111`). **Hachee, pas concatenee** : elle se recopie dans un
|
||||
courriel, et l'UUID brut de la carte mere est un identifiant materiel unique
|
||||
qui n'a pas a circuler en clair ni a finir dans nos journaux.
|
||||
- UUID lu par WMI (`Win32_ComputerSystemProduct`, COM via `Winapi.ActiveX` pour
|
||||
`IEnumVariant`). **`BoardUUID` appelle `CoInitializeEx` lui-meme** : sans ca
|
||||
l'empreinte dependait de qui avait demarre avant — apres l'init de WebView2
|
||||
elle lisait le vrai UUID, avant elle echouait et retombait sur `MachineGuid`,
|
||||
soit DEUX empreintes pour une seule machine et une licence liee a l'une que
|
||||
l'autre refuse. Un identifiant qui depend de l'ordre de demarrage n'est pas un
|
||||
identifiant. **Et on ne fait PAS de `CoUninitialize` symetrique** : il parait
|
||||
propre et se paie d'une violation d'acces, les mandataires WMI et le cache de
|
||||
type-info de ComObj se liberant plus tard, dans un appartement ferme. **Un UUID nul ou tout a FF est rejete** : beaucoup de VM et
|
||||
d'images clonees l'annoncent, et s'en contenter ferait tomber des postes
|
||||
differents sur la MEME empreinte — une licence emise pour l'un ouvrirait chez
|
||||
l'autre. Repli sur `MachineGuid` (HKLM\...\Cryptography), distinct par
|
||||
installation. Les deux sources absentes → `'unavailable'`, qui ne correspond a
|
||||
aucune licence : mieux qu'une chaine vide, identique partout.
|
||||
- Calculee une fois puis retenue (l'appel WMI coute quelques centaines de ms).
|
||||
- **`m` absent = licence REFUSEE.** Les licences flottantes ont existe le temps
|
||||
d'une seance et l'une d'elles est sortie ; les refuser en bloc est la **seule
|
||||
facon de l'annuler**. Un schema hors ligne n'a pas de revocation — rien ne
|
||||
peut aller dire a un exe deja installe qu'une cle ne vaut plus — donc la seule
|
||||
prise qui reste est de cesser d'accepter toute une classe de cles. Le cout
|
||||
assume : plus de licence de site, une cle par poste. `-Floating` a ete retire
|
||||
du script, qui ne peut donc plus fabriquer une cle que l'exe rejetterait.
|
||||
- Un poste dont l'empreinte vaut `'unavailable'` (ni WMI ni MachineGuid) ne peut
|
||||
plus etre licencie : le script n'accepte que 16 hexa. C'est le bon sens de
|
||||
l'erreur — mieux vaut un poste qu'on ne peut pas licencier qu'une empreinte
|
||||
degeneree qui ouvrirait partout.
|
||||
- **Consequence de flux** : on ne peut plus emettre une licence avant que le
|
||||
client ait installe. L'ecran d'activation affiche l'Installation ID, il
|
||||
l'envoie, on emet. Changer la carte mere ou le disque systeme casse la
|
||||
licence — la reparation est une reemission.
|
||||
- Ce qui casse l'empreinte : carte mere remplacee, disque systeme remplace,
|
||||
Windows reinstalle sur un autre volume, machine clonee.
|
||||
- `SelfTestLicence` a l'`initialization` (comme `SelfTestCrypto`) rejoue un
|
||||
vecteur fige signe par une **paire de test** dont la privee a ete jetee :
|
||||
licence valable acceptee, un octet retourne dans la charge utile refuse.
|
||||
`VerifyAgainstKey` prend la cle publique en parametre exactement pour que
|
||||
l'auto-test n'ait pas besoin d'un second jeu de constantes de production.
|
||||
|
||||
### Ecran d'activation au demarrage
|
||||
|
||||
`ensureLicence()` (js/app.js) est appele **en tete d'`init()`**, avant tout le
|
||||
reste : un ecran d'authentification qui s'affiche puis se fait recouvrir
|
||||
donnerait l'impression que le coffre etait accessible. Il ne rend la main que
|
||||
sur une licence acceptee — pas de bouton « plus tard ».
|
||||
|
||||
- Balisage `#licenceScreen` dans index.html, **place apres `#authScreen`** et
|
||||
reprenant ses classes (`.auth-screen`, `.auth-card`, `.auth-hint`,
|
||||
`.auth-form`) : zero style nouveau, sauf `#licenceScreen { z-index: 300 }`
|
||||
pour passer au-dessus du bandeau (100) et du voile d'attente (200).
|
||||
- Pont : `cmd://licence/status` et `cmd://licence/activate?key=`, reponse par
|
||||
`Bridge.onLicenceResult(ok, porteur)`. La cle est du base64url plus un point,
|
||||
donc rien a encoder de special sur le trajet.
|
||||
- **Le statut est demande deux fois** avant de montrer l'ecran : un demarrage a
|
||||
froid lent peut manquer la fenetre de 3 s, et le silence se lirait comme un
|
||||
refus — presenter l'activation a quelqu'un qui a deja paye coute plus cher
|
||||
qu'un second appel.
|
||||
- Une cle refusee ne dit **pas pourquoi** : signature invalide, recopie fautive
|
||||
et charge utile trafiquee se ressemblent, et detailler n'aiderait que celui
|
||||
qui essaie d'en fabriquer une. Le journal note le verdict, **jamais la cle**.
|
||||
- Hors du navigateur hote (`Bridge.active` faux : dev en navigateur, harnais de
|
||||
test) il n'y a pas de licence a verifier — la porte est ouverte.
|
||||
- **Settings → Licence** affiche porteur, courriel, date d'emission et
|
||||
Installation ID. Rempli par `applyLicenceInfo(st)` **une fois au demarrage**
|
||||
(les deux chemins : deja licencie, et activation reussie) plutot que branche
|
||||
sur le rendu des reglages — rien n'y change pendant une session, et le panneau
|
||||
est deja dans le DOM, cache. Section masquee hors navigateur hote : une suite
|
||||
de tirets ferait croire a une activation perdue.
|
||||
- Les champs sont **echappes avant interpolation** dans `ExecuteJavaScript`,
|
||||
bien qu'ils viennent d'une charge utile signee : signee ne veut pas dire
|
||||
inoffensive, c'est l'editeur qui les a tapes au moment d'emettre.
|
||||
- Piege Delphi rencontre : **une routine imbriquee ne se declare pas au milieu
|
||||
d'un corps de procedure** (seulement dans la section de declaration). En
|
||||
glisser une casse l'inference des methodes anonymes de tout ce qui suit —
|
||||
cinq `TThread.Queue` sans rapport se sont mis a ne plus compiler.
|
||||
|
||||
**Scenario d'essai** : [LICENCE-TESTS.md](LICENCE-TESTS.md) — cinq series, dont
|
||||
une (D) dediee a la stabilite de l'empreinte, qui est le defaut deja rencontre.
|
||||
La remise a zero passe par le bouton **Deactivate (debug)** au bas de
|
||||
Settings -> Licence, revele seulement quand `config.txt` existe a cote de l'exe
|
||||
(`cmd://licence/clear`, inerte sans lui). Il donne aussi son unique appelant a
|
||||
`PM.Licence.ClearLicence`, et evite d'avoir a supprimer `prefs.bin` — qui
|
||||
desactive mais emporte le mode de deverrouillage, le nom retenu et la sync.
|
||||
|
||||
### Cote Android : differe, et le jour venu on reutilisera la licence desktop
|
||||
|
||||
**Rien n'est fait, et c'est deliberе** — l'APK ne part chez personne aujourd'hui,
|
||||
on l'installe soi-meme par adb. Un dispositif de licences sans destinataire est
|
||||
du code qui vieillit sans jamais s'executer chez quelqu'un.
|
||||
|
||||
Le jour ou l'APK partira chez des clients, **ne pas refaire un second dispositif**.
|
||||
Trois raisons, a garder pour ne pas les re-deriver :
|
||||
|
||||
- **L'elegance du cote Windows ne se transporte pas.** Elle venait de ce que
|
||||
.NET sur Windows EST CNG : la signature `r||s` brute est deja celle qu'attend
|
||||
`BCryptVerifySignature`. **Java attend du DER** — il faudrait emettre deux
|
||||
formats ou convertir, plus un pont JNI vers `java.security.Signature`. La
|
||||
moitie penible qu'on avait evitee reviendrait.
|
||||
- **Pas d'empreinte materielle solide sur Android.** Plus d'IMEI ni de numero de
|
||||
serie sans permission privilegiee ; il reste `ANDROID_ID`, **remis a zero par
|
||||
une reinitialisation d'usine** — donc des clients legitimes qui perdent leur
|
||||
licence sans rien avoir fait. C'est le defaut couteux, celui qui refuse
|
||||
quelqu'un qui a paye.
|
||||
- **Un APK se contourne bien plus facilement qu'un exe.** `repack-apk.ps1` en
|
||||
est la demonstration : decompiler, retirer un test, resigner est de la routine
|
||||
outillee.
|
||||
|
||||
**La forme retenue** : l'instantane publie par le poste porte la licence, et
|
||||
l'APK exige d'y en trouver une. Le telephone lit deja cet instantane — aucun
|
||||
nouveau format d'emission, aucune empreinte Android, aucune licence
|
||||
supplementaire a fabriquer.
|
||||
|
||||
**Et l'APK n'a PAS a verifier la signature** : il constate la presence, rien de
|
||||
plus. Payer le DER et le JNI pour durcir un controle que le repaquetage retire
|
||||
en dix minutes serait le mauvais echange. Le but est de tenir le mobile attache
|
||||
a un poste paye, pas de resister a une attaque — l'APK ne peut pas resister, et
|
||||
faire semblant couterait plus que de l'admettre.
|
||||
|
||||
**ORDRE OBLIGATOIRE.** Tant que `LicencePublicKeyBlob` est a zero, aucune
|
||||
licence n'est valide et l'ecran d'activation est **infranchissable** — l'exe
|
||||
construit ne s'ouvre plus. Poser la cle publique AVANT de deployer :
|
||||
`-Init` → coller les 72 octets → reconstruire → `-For` pour s'emettre une
|
||||
licence a soi.
|
||||
|
||||
**Ce que ca arrete** : sans la cle privee, personne ne fabrique de licence
|
||||
valable. **Ce que ca n'arrete pas** : rapiecer l'exe pour sauter l'appel — vrai
|
||||
de tout schema hors ligne. La seule parade reelle serait de deporter une
|
||||
fonction sur un serveur, ce qui contredit le « 100 % hors ligne ».
|
||||
|
||||
## Debug mode
|
||||
|
||||
Fichier `config.txt` à côté de l'exe (tous champs optionnels) :
|
||||
```
|
||||
port=8765 # 0 ou absent = port aléatoire ephemeral (49152-65535)
|
||||
# occupé = jusqu'à 3 essais, replis en éphémère
|
||||
debug=true # affiche PanelTop (Start/Stop, log memo)
|
||||
require_token=false # désactive le token validation (curl/Postman direct)
|
||||
require_process_check=false # désactive le PID-on-socket check
|
||||
@@ -173,7 +490,17 @@ Le serveur Indy n'accepte une requête que si elle a :
|
||||
- OU cookie `pm_token=<token>; HttpOnly; SameSite=Strict; Path=/`
|
||||
|
||||
Au start :
|
||||
- Port aléatoire dans `[49152, 65535]` si pas configuré
|
||||
- Port aléatoire dans `[49152, 65535]` si pas configuré. Tiré de
|
||||
`PM.Crypto.RandomBytes` (BCryptGenRandom), **pas** de `Random` : `Randomize`
|
||||
n'est appelé nulle part, donc `Random` rendait la même suite à chaque
|
||||
lancement — le port « aléatoire » était en réalité le même tous les jours, ce
|
||||
qui vidait de son sens l'aléatoire censé compliquer un balayage.
|
||||
- **Port occupé → jusqu'à 3 essais** (`BindAttempts`). Le premier honore le port
|
||||
demandé ; les suivants prennent un éphémère. Se lier EST le test : un sondage
|
||||
préalable serait plus faible (un service qui écoute sans répondre passerait
|
||||
inaperçu) et sujet à une course (le port peut être pris entre le sondage et le
|
||||
lien). Chaque déplacement est journalisé — un serveur qui change de port en
|
||||
silence est pire qu'un serveur qui refuse de démarrer.
|
||||
- Token random 32-chars hex généré
|
||||
- WebView2 navigate vers `http://127.0.0.1:{port}/index.html?pmt={token}`
|
||||
- JS strip le `?pmt=...` du URL bar via `history.replaceState`
|
||||
@@ -262,8 +589,22 @@ La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS).
|
||||
`Bridge.onFaviconResult(reqId, host, dataUri)`
|
||||
- Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint
|
||||
dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet
|
||||
(qui re-PUT le password chiffré)
|
||||
- Bulk : `DELETE /entries/icons/all` purge tout (bouton "Clear cache")
|
||||
(qui re-PUT le password chiffré). Body `{icon_b64, custom:bool}`.
|
||||
- **Provenance `icon_custom INTEGER`** (0 = favicon auto-fetchée = cache
|
||||
jetable, 1 = upload utilisateur). **Invariant : une icône custom n'est
|
||||
JAMAIS touchée par une action automatique.** `saveEntryIcon(id, uri,
|
||||
isCustom)` pose le flag (upload→1, fetch/remove→0). `backfillFavicons`
|
||||
skippe `icon_custom` (même sur "Re-fetch all"), `DELETE /entries/icons/all`
|
||||
filtre `COALESCE(icon_custom,0)=0`. Seul l'utilisateur change/retire une
|
||||
custom, par entrée (boutons Upload/Remove du slideover). `GET /entries`
|
||||
echoe `icon_custom`. **Propagé** de bout en bout : export manuel,
|
||||
auto-backup, snapshot de sync l'écrivent ; `encryptImportEntry` le porte
|
||||
dans le body ; les INSERT **create + bulk-import** le lisent
|
||||
(`GetValue<Boolean>('icon_custom', False)` → 0 par défaut si absent, ex.
|
||||
CSV étranger). Round-trip export→import et sync préservent donc la
|
||||
provenance. Une icône vide (`icon_b64=''`) force toujours `icon_custom=0`.
|
||||
- Bulk : `DELETE /entries/icons/all` purge le cache auto (bouton "Clear
|
||||
cache") — préserve les uploads custom
|
||||
- Render : `entry-avatar` contient `<img class="entry-avatar-img">` si
|
||||
`icon_b64`, sinon fallback aux initials. `onerror` repasse aux
|
||||
initials si la data URI est corrompue
|
||||
@@ -476,6 +817,17 @@ retirée** (site='' quand chiffré) — le client la fait. `folder` reste en cla
|
||||
(requête serveur de réassignation sur delete-folder). Reste en clair :
|
||||
`folder`, `kind`, métadonnées d'attachments, nombre de lignes, timestamps.
|
||||
|
||||
**La corbeille voyage dans les sauvegardes, pas dans la sync** (2026-08-03) :
|
||||
`buildBackupEntry` (js/app.import.js) est le point unique qui met en forme une
|
||||
entrée pour un conteneur — partagé par l'export manuel **et** l'auto-backup, qui
|
||||
en portaient deux copies. Les deux exportent `state.entries` **+**
|
||||
`state.trashed`, avec `deleted` / `deleted_at` ; `deleted_at` est préservé pour
|
||||
que l'auto-purge reprenne où elle en était au lieu de repartir à zéro (d'où son
|
||||
ajout à l'écho de `GET /entries`). `buildSyncSnapshot` **n'utilise pas** ce
|
||||
helper et n'envoie que le vivant : la corbeille est device-local, et republier
|
||||
des lignes supprimées les dupliquerait (cf. le bug F8 où 30 entrées mises à la
|
||||
corbeille revenaient vivantes, deux lignes pour un même uuid).
|
||||
|
||||
Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours**
|
||||
mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement
|
||||
sur certaines actions :
|
||||
@@ -567,6 +919,21 @@ Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT
|
||||
hotkeys autofill, etc. **Device-only** (localStorage seulement) :
|
||||
`quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled`
|
||||
(toggle hotkey Win32), `rememberedUsername` (auth screen autofill local).
|
||||
Ces trois-là sont **device-only mais pas localStorage-only** : chacun est
|
||||
miroité en pref DPAPI, sinon le changement de port les ramène à leur défaut
|
||||
à chaque lancement (`autofillEnabled` revenait ainsi sur ON une fois
|
||||
désactivé). Lu dans `enterApp` juste avant `autofillPushHotkeys`, la copie
|
||||
DPAPI faisant autorité.
|
||||
|
||||
**Piège : tout réglage lu AVANT le login doit être en pref DPAPI.**
|
||||
`settings_json` demande une session, et `localStorage` est vidé à chaque
|
||||
lancement (port éphémère = nouvelle origine). Un réglage qui ne vit que
|
||||
dans ces deux-là est systématiquement à sa valeur par défaut sur l'écran
|
||||
d'auth. C'est ce qui faisait retomber un coffre en mode PIN sur le mot de
|
||||
passe maître à chaque démarrage : `unlockMode` est donc **miroité en pref
|
||||
DPAPI** (écrit au changement + dans `loadServerSettings`, relu par
|
||||
`showAuth`), la copie DPAPI faisant autorité au boot. Même raison pour
|
||||
`rememberedUsername`.
|
||||
|
||||
## Sync (WebDAV, auto-merge)
|
||||
|
||||
@@ -697,7 +1064,8 @@ persisted to `argon2_m/t/p`). `/login/challenge` returns them via
|
||||
## PIN unlock
|
||||
|
||||
Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick
|
||||
Unlock. Three modes (`state.unlockMode`, synced via `settings_json`) :
|
||||
Unlock. Three modes (`state.unlockMode`, dans `settings_json` **et** en pref
|
||||
DPAPI) :
|
||||
|
||||
- `pw` — master password only (legacy, default)
|
||||
- `pin` — PIN unlocks the vault on this device
|
||||
@@ -904,6 +1272,27 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
|
||||
brand, expiry…) rendent un `<input>` + menu custom `.so-combo` (flèche =
|
||||
toutes les options, input = frappe libre). PAS un `<datalist>` natif
|
||||
(il filtre au texte tapé → n'affiche que ce qui matche, déroutant).
|
||||
- **Champ Notes multiligne** (`field.multiline`) : bouton *Add note* du
|
||||
panneau d'édition → champ `textarea` **secret par défaut**, masqué via
|
||||
`-webkit-text-security` (un `textarea` n'a pas de `type=password`).
|
||||
Hauteur dérivée du contenu (`autoSizeTextarea`, plafond 320 px) plutôt
|
||||
que mémorisée — la rangée est reconstruite à chaque ouverture et
|
||||
`localStorage` ne survit pas au changement de port.
|
||||
**Pas de colonne `notes_enc` dédiée** : le corps d'une entrée *note* EST
|
||||
`encrypted_password`, donc un vrai champ notes aurait exigé une nouvelle
|
||||
colonne chiffrée + les 7 points de passage ci-dessus ; `custom_fields`
|
||||
est déjà porté par rotation / duplicate / export-import / sync.
|
||||
**Piège** : les champs personnalisés sont reconstruits **clé par clé** à
|
||||
l'ouverture (normalisation) et au Save, et chacun jette ce qu'il ne
|
||||
nomme pas → toute nouvelle clé (`multiline`, comme `options` avant elle)
|
||||
doit être portée dans les **deux**, sinon elle disparaît silencieusement.
|
||||
- **Rangée de champ personnalisé** : `libellé | valeur (œil dedans) | ⋮`.
|
||||
L'œil n'existe que sur un champ secret (sa présence EST l'indicateur) ;
|
||||
*Copy value*, *Store as secret* / *Make public* et *Remove field* sont
|
||||
dans le menu `.entry-kebab-menu` réutilisé des cartes (la fermeture au
|
||||
clic extérieur est déjà câblée). Basculer le flag secret se fait **en
|
||||
place**, jamais via `rerender()` : reconstruire la liste fait flasher le
|
||||
panneau et perd le focus + la hauteur auto du multiligne.
|
||||
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
|
||||
texte ; match sur le label de section garde toute la section. Esc dans
|
||||
la search avec query = clear ; Esc vide = ferme Settings.
|
||||
@@ -951,10 +1340,17 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
|
||||
assumé device-local (purge de cache favicon). **`favorite` et `pinned`
|
||||
(toggles `POST /entries/{id}/favorite|pin`) sont aussi exemptés exprès** :
|
||||
absents de `buildSyncSnapshot`, donc **device-local par design** (décidé
|
||||
2026-07-11) — comme l'avatar. `favorite` est quand même dans l'auto-backup +
|
||||
export/import (round-trip local), juste pas propagé par le sync. Si un jour on
|
||||
veut les synchroniser : ajouter au snapshot + bumper `updated_at` sur le toggle
|
||||
+ appliquer au merge.
|
||||
2026-07-11) — comme l'avatar. En revanche ils **round-trippent** par
|
||||
export/import + auto-backup (restaurer une sauvegarde rend la liste telle
|
||||
quelle) : container → `parseEntriesFromJSON` → `encryptImportEntry` →
|
||||
INSERT create + bulk-import (`GetValue<Boolean>(…, False)` → 0 si absent,
|
||||
ex. CSV étranger). **C'est l'omission dans le snapshot de sync qui les garde
|
||||
device-local** — `encryptImportEntry` fait un passthrough générique, donc
|
||||
les remettre dans `buildSyncSnapshot` suffirait à les propager (+ bumper
|
||||
`updated_at` sur le toggle + appliquer au merge). Piège historique : le
|
||||
snapshot écrivait `favorite` mais **rien ne le relisait** (ni parser, ni
|
||||
body, ni INSERT) → l'export semblait le porter alors que l'import le
|
||||
perdait, et `pinned` n'était nulle part.
|
||||
- **Timestamps = UTC partout** : tout `created_at`/`updated_at`/`deleted_at`
|
||||
écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS
|
||||
`FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` /
|
||||
@@ -999,7 +1395,11 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
|
||||
|
||||
## Git
|
||||
|
||||
- Pas de remote configuré (dev local)
|
||||
- Remote : `origin` = `https://git.ai-agents4you.com/Zaki/Password-Manager.git`
|
||||
(Gitea auto-heberge, le meme serveur que celui qui sert de cible de sync).
|
||||
`main` suit `origin/main`, mais **rien n'est pousse automatiquement** — les
|
||||
commits s'accumulent en local et partent quand on le demande. Au 2026-09-06,
|
||||
427 commits d'avance.
|
||||
- `vault.db`, `*.db-shm`, `*.db-wal`, `delphi-backend/Win32/`, `*.dcu`,
|
||||
et les artefacts BuildAssets **générés** `assets.res`/`assets.rc`/`assets.inc`
|
||||
gitignored (régénérés avant chaque build Delphi, puis linkés dans l'exe —
|
||||
|
||||
+561
@@ -182,6 +182,13 @@ stricte ; incohérence de **format** pré-existante (serveur `'yyyy-mm-dd
|
||||
hh:nn:ss'` vs export JS `toISOString()` avec `T…Z`) sur les rares rows sans
|
||||
`created_at`.
|
||||
|
||||
> ⚠️ **MAJ 2026-07-16 — « Tout est désormais UTC » était inexact, puis corrigé.**
|
||||
> La passe de substitution avait raté **`PM.Session.CreateSession`**
|
||||
> (`FormatDateTime(..., IncHour(Now, 24))`, heure locale). ✅ **Corrigé en
|
||||
> §6c-2** (`IncHour(NowUTC, 24)` + `Authenticate` sur `NowUTC`). C'était le
|
||||
> dernier `FormatDateTime(..., Now)` du backend → §2.2 est maintenant réellement
|
||||
> clos.
|
||||
|
||||
### 2.3 🟡 `catch (_) {}` silencieux en cascade
|
||||
|
||||
Nombreux `catch (_) {}` dans `applyRemoteSnapshot`, `buildSyncSnapshot`,
|
||||
@@ -410,6 +417,551 @@ invisibles, perf et code mort. Rien de bloquant ; classé par type.
|
||||
|
||||
---
|
||||
|
||||
## 6c. Revue backend Delphi (2026-07-16) — correctifs proposés, NON appliqués
|
||||
|
||||
Passe manuelle sur les ~10 500 lignes Delphi (`Source/` + `Handlers/` + `.dpr`),
|
||||
axée crypto/auth/session/HTTP/SQL. Les correctifs ci-dessous sont **écrits mais
|
||||
pas appliqués** — chacun est autonome.
|
||||
|
||||
**Sain, vérifié (pour ne pas re-chasser)** : aucune injection SQL (les seuls SQL
|
||||
concaténés sont `PRAGMA table_info(...)` / `ALTER TABLE` dans `PM.Database`, sur
|
||||
constantes internes) ; autorisation scopée par `user_id` **partout** sans
|
||||
exception ; le `SET` dynamique de [Folders.pas:199](delphi-backend/Handlers/PM.Handler.Folders.pas:199)
|
||||
est assemblé de littéraux fixes, pas d'input ; les routes non authentifiées sont
|
||||
exactement les bonnes (register, login, challenge, ping, redeem, stub passkey) ;
|
||||
`ValidateConnectingProcess` échoue **fermé** si le lookup PID rate ; primitives
|
||||
crypto correctes + auto-testées à l'init. La fuite d'énumération de comptes de
|
||||
`/login/challenge` est **documentée et assumée** ([Auth.pas:1189](delphi-backend/Handlers/PM.Handler.Auth.pas:1189))
|
||||
→ pas comptée comme finding.
|
||||
|
||||
---
|
||||
|
||||
### 6c-1. 🔴 `/migrate-kdf` ne re-chiffre que 1 blob sur 8 — perte de données
|
||||
|
||||
> ✅ **RÉSOLU 2026-07-16 — chemin désactivé (option ultra).** Les 2 call-sites
|
||||
> qui déclenchaient l'auto-migration au login/reauth
|
||||
> ([app.js:1246, 1443](js/app.js)) sont retirés + la fonction morte
|
||||
> `runKdfMigration` supprimée. Sans le client qui orchestre le swap de clé,
|
||||
> l'endpoint serveur ne peut plus corrompre (il ne fait qu'un UPDATE de ce
|
||||
> qu'on lui envoie) → laissé inerte, non touché. **DB vérifiée avant fix : les 2
|
||||
> comptes sont déjà à 600k**, le chemin n'a donc jamais mordu. Un compte legacy
|
||||
> <600k s'upgrade désormais via **Change Master Password** (qui re-chiffre les
|
||||
> 8 blobs + attachments correctement et passe à Argon2id). §6c-4 (brick d'un
|
||||
> compte -v2 par ce même endpoint) devient également inatteignable.
|
||||
|
||||
**C'est le seul vrai bug de perte de données de cette passe.**
|
||||
|
||||
La migration KDF passe les itérations 100k → 600k, donc **change la clé AES
|
||||
dérivée** — puis [app.js:1035](js/app.js:1035) bascule `state.cryptoKey = newKey`.
|
||||
Or [`runKdfMigration`](js/app.js:961) ne re-chiffre que `encrypted_password`/`iv`,
|
||||
et [`HandleMigrateKdf`](delphi-backend/Handlers/PM.Handler.Auth.pas:795) n'UPDATE
|
||||
que ces deux colonnes.
|
||||
|
||||
Restent chiffrés sous l'**ancienne** clé, donc définitivement illisibles :
|
||||
`totp_secret`, `custom_fields`, `username_enc`, `site_enc`, `title_enc`,
|
||||
`tags_enc`, `template_enc` (+ leurs IV), et les attachments.
|
||||
|
||||
Le contraste est net avec [`doChangeMasterPassword`](js/app.js:6106) /
|
||||
[`HandleChangeMasterPassword`](delphi-backend/Handlers/PM.Handler.Auth.pas:1038),
|
||||
qui affrontent **exactement la même situation** (changement de clé) et traitent
|
||||
correctement les 8 colonnes. C'est une nouvelle instance de la classe de bug
|
||||
décrite en **§3.3** — et la checklist « Entry payload » du CLAUDE.md cite
|
||||
`doChangeMasterPassword` mais **pas** `runKdfMigration` : voilà par où c'est passé.
|
||||
|
||||
**Symptôme** : sournois, pas bruyant. [`decryptEntryMeta`](js/app.js:1502) ignore
|
||||
silencieusement les champs qui ne déchiffrent pas (`if (v !== '[ERROR]') e[f] = v`)
|
||||
→ titres/usernames deviennent `undefined` à l'écran, puis le premier save réécrit
|
||||
ce vide par-dessus l'ancien ciphertext. Là c'est irréversible.
|
||||
|
||||
**Portée** : uniquement les comptes à `kdf_iterations < 600000`. Les inscriptions
|
||||
partent directement à 600k ([app.js:1283](js/app.js:1283)) → legacy uniquement.
|
||||
**Non vérifié** s'il en reste : aucun `vault.db` sur la machine de revue, pas de
|
||||
CLI sqlite3. À trancher par `SELECT username, kdf_iterations, hash_algo FROM users;`
|
||||
— si tout est à 600k, le bug n'a jamais mordu, mais le chemin reste **armé** et
|
||||
auto-déclenché par signal serveur.
|
||||
|
||||
#### Correctif recommandé — extraire un helper partagé
|
||||
|
||||
Recopier la boucle de `doChangeMasterPassword` dans `runKdfMigration` serait
|
||||
reproduire la cause (duplication, §3.3). La boucle
|
||||
[app.js:6115-6179](js/app.js:6115) est **déjà exactement** la logique voulue :
|
||||
l'extraire et l'appeler des deux côtés.
|
||||
|
||||
```js
|
||||
// js/app.js — nouveau helper, unique source de vérité du re-chiffrement.
|
||||
// Prend la clé courante (oldKey) et la cible (newKey), retourne le payload
|
||||
// serveur. N'écrit RIEN dans state : l'appelant décide quand basculer.
|
||||
async function reEncryptEntriesForNewKey(oldKey, newKey, onProgress) {
|
||||
const out = [];
|
||||
let done = 0;
|
||||
for (const e of state.entries) {
|
||||
done++;
|
||||
if (onProgress) onProgress(done, state.entries.length);
|
||||
|
||||
state.cryptoKey = oldKey;
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
if (plain === '[ERROR]') throw new Error('Could not decrypt entry id=' + e.id);
|
||||
|
||||
try {
|
||||
state.cryptoKey = newKey;
|
||||
const re = await encryptPwd(plain);
|
||||
|
||||
let totpEnc = '', totpIv = '';
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
state.cryptoKey = oldKey;
|
||||
const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||
state.cryptoKey = newKey;
|
||||
if (plainTotp !== '[ERROR]') {
|
||||
const t = await encryptPwd(plainTotp);
|
||||
totpEnc = t.encrypted; totpIv = t.iv;
|
||||
}
|
||||
}
|
||||
|
||||
let cfEnc = '', cfIv = '';
|
||||
if (e.custom_fields && e.custom_fields_iv) {
|
||||
state.cryptoKey = oldKey;
|
||||
const plainCf = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
|
||||
state.cryptoKey = newKey;
|
||||
if (plainCf.length > 0) {
|
||||
const c = await encryptCustomFields(plainCf);
|
||||
cfEnc = c.encrypted; cfIv = c.iv;
|
||||
}
|
||||
}
|
||||
|
||||
state.cryptoKey = newKey;
|
||||
const meta = {};
|
||||
for (const f of ENCRYPTED_META_FIELDS) {
|
||||
if (e[f]) {
|
||||
const c = await encryptPwd(e[f]);
|
||||
meta[f + '_enc'] = c.encrypted;
|
||||
meta[f + '_iv'] = c.iv;
|
||||
} else {
|
||||
meta[f + '_enc'] = '';
|
||||
meta[f + '_iv'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
out.push({
|
||||
id: e.id,
|
||||
encrypted_password: re.encrypted, iv: re.iv,
|
||||
totp_secret: totpEnc, totp_iv: totpIv,
|
||||
custom_fields: cfEnc, custom_fields_iv: cfIv,
|
||||
...meta,
|
||||
});
|
||||
} finally {
|
||||
state.cryptoKey = oldKey; // restauré tant que le serveur n'a pas confirmé
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
`doChangeMasterPassword` remplace sa boucle par un appel ; `runKdfMigration`
|
||||
remplace la sienne :
|
||||
|
||||
```js
|
||||
// js/app.js — dans runKdfMigration, branche `if (kdfChange)`
|
||||
newKey = await deriveKey(masterPwd, state.salt, toIters);
|
||||
newCiphertexts = await reEncryptEntriesForNewKey(state.cryptoKey, newKey);
|
||||
```
|
||||
|
||||
⚠️ **Les attachments restent à traiter** : `doChangeMasterPassword` les gère dans
|
||||
une boucle séparée *après* le round-trip serveur (GET blob → décrypt oldKey →
|
||||
encrypt newKey → `PUT /attachments/:id`). `runKdfMigration` ne l'a pas du tout.
|
||||
Même extraction à faire (`reEncryptAttachmentsForNewKey(oldKey, newKey)`), appelée
|
||||
des deux côtés après le commit.
|
||||
|
||||
#### Côté Delphi — aligner l'UPDATE sur celui de la rotation
|
||||
|
||||
```pascal
|
||||
// PM.Handler.Auth.pas — HandleMigrateKdf, remplace l'UPDATE de la ligne ~795.
|
||||
// Colonnes identiques à HandleChangeMasterPassword (ligne ~1039) : c'est le
|
||||
// même événement (la clé change), donc le même jeu de blobs doit voyager.
|
||||
LQ.SQL.Text :=
|
||||
'UPDATE vault_entries SET ' +
|
||||
' encrypted_password = :ep, iv = :iv, ' +
|
||||
' totp_secret = :ts, totp_iv = :tiv, ' +
|
||||
' custom_fields = :cf, custom_fields_iv = :cfiv, ' +
|
||||
' username_enc = :uenc, username_iv = :uiv, ' +
|
||||
' site_enc = :senc, site_iv = :siv, ' +
|
||||
' title_enc = :tenc, title_iv = :tiv2, ' +
|
||||
' tags_enc = :genc, tags_iv = :giv, ' +
|
||||
' template_enc = :tplenc, template_iv = :tpliv, ' +
|
||||
' updated_at = CURRENT_TIMESTAMP ' +
|
||||
'WHERE id = :id AND user_id = :uid';
|
||||
```
|
||||
|
||||
…avec la même lecture de champs + binding que
|
||||
[Auth.pas:1051-1077](delphi-backend/Handlers/PM.Handler.Auth.pas:1051).
|
||||
|
||||
#### Alternative stratégique (plus propre, plus de risque)
|
||||
|
||||
Un « migrate-kdf » **est** une rotation de master pw vers le même mot de passe.
|
||||
Faire déléguer `runKdfMigration` à `/change-master-password` (nouveau salt +
|
||||
`argon2id-v2`) supprimerait ce chemin dupliqué *et* ferait basculer les comptes
|
||||
legacy sur Argon2id d'un coup — ce que §1.2 attend de toute façon. Contrepartie :
|
||||
`HandleChangeMasterPassword` fait `DeleteAllUserSessions` + `CreateSession`
|
||||
([Auth.pas:1161](delphi-backend/Handlers/PM.Handler.Auth.pas:1161)) et le CLAUDE.md
|
||||
impose le wipe du blob PIN → une migration silencieuse de fond deviendrait
|
||||
visible. À arbitrer ; le correctif chirurgical ci-dessus est le chemin bas risque.
|
||||
|
||||
#### Test de non-régression suggéré
|
||||
|
||||
La suite `js/tests/` couvre déjà le round-trip crypto. Ajouter : *entry avec TOTP
|
||||
+ custom_fields + méta chiffrée → `reEncryptEntriesForNewKey` → tout déchiffre
|
||||
sous newKey*. C'est le filet qui aurait attrapé ça.
|
||||
|
||||
---
|
||||
|
||||
### 6c-2. 🟠 Expiry de session écrite en local, lue en UTC (corrige §2.2)
|
||||
|
||||
> ✅ **RÉSOLU 2026-07-16.** `CreateSession` écrit `IncHour(NowUTC, 24)` et
|
||||
> `Authenticate` compare `LExpires < NowUTC` ([PM.Session.pas](delphi-backend/Source/PM.Session.pas)).
|
||||
> Les 3 lecteurs (Authenticate, RequireCSRF, CleanupExpired) sont désormais tous
|
||||
> sur UTC. `NowUTC` + `System.DateUtils` déjà dans les uses → aucun uses touché.
|
||||
> §2.2 est maintenant réellement clos (c'était le dernier `FormatDateTime(...,
|
||||
> Now)` du backend). Sessions existantes : expirent sous 24 h, purgées au boot.
|
||||
|
||||
[`CreateSession`](delphi-backend/Source/PM.Session.pas:155) écrit
|
||||
`FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(Now, 24))` — heure **locale**.
|
||||
C'est le dernier `FormatDateTime(..., Now)` du backend et il contredit la règle
|
||||
du CLAUDE.md (« Timestamps = UTC partout … JAMAIS `FormatDateTime(..., Now)` »).
|
||||
`NowUTCStr` existe et est utilisé partout ailleurs.
|
||||
|
||||
Trois lecteurs, deux bases de temps :
|
||||
|
||||
| Lecteur | Base | Cohérent ? |
|
||||
|---|---|---|
|
||||
| [`Authenticate`](delphi-backend/Source/PM.Session.pas:89) | `Now` (local) | oui |
|
||||
| [`RequireCSRF`](delphi-backend/Source/PM.Session.pas:122) | `datetime('now')` (UTC) | **non** |
|
||||
| [`CleanupExpired`](delphi-backend/Source/PM.Database.pas:469) | `datetime('now')` (UTC) | **non** |
|
||||
|
||||
**Pourquoi ça ne s'est jamais vu** : en UTC+1/+2 le décalage va dans le sens
|
||||
inoffensif — `Authenticate` coupe à 24 h avant que la fenêtre CSRF (26 h) ne
|
||||
serve. En **UTC-5** la fenêtre CSRF tombe à **19 h** : entre 19 h et 24 h la
|
||||
session s'authentifie normalement, les GET passent, et **toute écriture répond
|
||||
403 « Invalid CSRF token »**. Scénario plausible : « Start with Windows » + quick
|
||||
unlock, machine laissée allumée plus d'une journée.
|
||||
|
||||
```pascal
|
||||
// PM.Session.pas — CreateSession (~ligne 155)
|
||||
// UTC comme partout ailleurs : RequireCSRF et CleanupExpired comparent contre
|
||||
// SQLite datetime('now') qui EST UTC.
|
||||
LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(NowUTC, 24));
|
||||
```
|
||||
|
||||
```pascal
|
||||
// PM.Session.pas — Authenticate (~ligne 89)
|
||||
// Doit suivre : expires_at est désormais UTC.
|
||||
if (LExpires <> 0) and (LExpires < NowUTC) then
|
||||
```
|
||||
|
||||
`PM.Database` (qui expose `NowUTC`) est déjà dans le `uses` de l'interface,
|
||||
`System.DateUtils` (pour `IncHour`) déjà dans celui de l'implémentation → aucun
|
||||
`uses` à toucher. Les trois lecteurs s'alignent alors sur UTC.
|
||||
|
||||
**Note migration** : les sessions déjà en base gardent leur `expires_at` local.
|
||||
Sans importance — elles expirent sous 24 h, et `CleanupExpired` les purge au
|
||||
prochain démarrage.
|
||||
|
||||
---
|
||||
|
||||
### 6c-3. 🟡 Le CSRF est validé contre la mauvaise session
|
||||
|
||||
> ✅ **RÉSOLU 2026-07-16.** `RequireCSRF` lit désormais `csrf_token WHERE
|
||||
> token_hash = :th AND user_id = :uid` (la session présentée), plus de
|
||||
> `ORDER BY created_at DESC LIMIT 1`. `ExtractBearerToken`/`SHA256Hex` déjà
|
||||
> utilisés par `Authenticate` dans la même unité ; signature inchangée, aucun
|
||||
> call-site touché. Le re-test d'expiry est retiré (Authenticate tourne avant
|
||||
> et l'a déjà fait, désormais sur la même base UTC après §6c-2).
|
||||
|
||||
[`RequireCSRF`](delphi-backend/Source/PM.Session.pas:120) cherche le
|
||||
`csrf_token` par `user_id ORDER BY created_at DESC LIMIT 1` — la session la plus
|
||||
récente, **pas celle réellement présentée**. Deux conséquences :
|
||||
|
||||
- `created_at` a une résolution d'**une seconde** : deux sessions créées dans la
|
||||
même seconde donnent une égalité que SQLite tranche arbitrairement → il peut
|
||||
lire le token de l'ancienne → **403 fantôme**.
|
||||
- Conceptuellement, le contrôle devient « connais-tu *un* CSRF valide pour cet
|
||||
utilisateur » au lieu de « celui lié à ta session ».
|
||||
|
||||
`Authenticate` a **déjà** localisé la bonne ligne par `token_hash` : lire
|
||||
`csrf_token` depuis cette ligne-là est plus simple *et* correct. La signature ne
|
||||
change pas → aucun call-site à toucher.
|
||||
|
||||
```pascal
|
||||
// PM.Session.pas — RequireCSRF
|
||||
var
|
||||
LSubmitted, LStored, LTokenHash: string;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
if SameText(ARequest.Command, 'GET') then Exit;
|
||||
|
||||
LSubmitted := ARequest.RawHeaders.Values['X-CSRF-Token'];
|
||||
if LSubmitted = '' then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 403, 'Missing CSRF token');
|
||||
raise ESessionRejected.Create('missing csrf');
|
||||
end;
|
||||
|
||||
// Lier le CSRF à la session PRÉSENTÉE, pas à la plus récente du user.
|
||||
// AUserId reste en filtre (defense in depth).
|
||||
LTokenHash := SHA256Hex(ExtractBearerToken(ARequest));
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text :=
|
||||
'SELECT csrf_token FROM sessions ' +
|
||||
'WHERE token_hash = :th AND user_id = :uid';
|
||||
LQ.ParamByName('th').AsString := LTokenHash;
|
||||
LQ.ParamByName('uid').AsInteger := AUserId;
|
||||
LQ.Open;
|
||||
// ... suite inchangée
|
||||
```
|
||||
|
||||
L'expiry n'a plus à être re-testée ici : `Authenticate` tourne toujours avant et
|
||||
l'a déjà fait (et après **6c-2** les deux sont sur la même base de temps).
|
||||
|
||||
---
|
||||
|
||||
### 6c-4. 🟡 `/migrate-kdf` rétrograderait un compte `-v2` (verrouillage définitif)
|
||||
|
||||
> ✅ **RÉSOLU 2026-07-16.** Au-delà de 6c-1 (client), l'endpoint serveur
|
||||
> `HandleMigrateKdf` a été **vidé de ses ~165 lignes destructrices** et remplacé
|
||||
> par un reject `410` après auth+CSRF ([Auth.pas:656](delphi-backend/Handlers/PM.Handler.Auth.pas)).
|
||||
> Un appel isolé ne peut donc plus **ni corrompre** (6c-1) **ni brick** un compte
|
||||
> -v2 (6c-4) : les deux sont fermés côté serveur, pas seulement « inatteignables
|
||||
> par convention ». Le signal `kdfMigration` du login reste (vestigial, client
|
||||
> l'ignore, endpoint = 410) — non retiré car câblé via un paramètre sur le
|
||||
> hot-path login, churn sans bénéfice.
|
||||
|
||||
Le garde d'idempotence ([Auth.pas:722](delphi-backend/Handlers/PM.Handler.Auth.pas:722))
|
||||
ne court-circuite que si l'algo vaut `pbkdf2-sha256`. Un compte `argon2id-v2` ou
|
||||
`pbkdf2-sha256-v2` **passe au travers** et se fait réécrire en
|
||||
`HASH_ALGO_CURRENT` ([Auth.pas:763](delphi-backend/Handlers/PM.Handler.Auth.pas:763)
|
||||
et [785](delphi-backend/Handlers/PM.Handler.Auth.pas:785)) → la formule de
|
||||
verifier change → **lockout définitif**.
|
||||
|
||||
Non atteignable aujourd'hui : le register envoie `kdfIterations: 600000`
|
||||
([app.js:1296](js/app.js:1296)) donc le login ne signale jamais de migration pour
|
||||
un compte v2 — et le commentaire [app.js:1016](js/app.js:1016) montre que le cas a
|
||||
été raisonné. Mais l'endpoint est authentifié, CSRF'é et **armé** ; le garde coûte
|
||||
4 lignes et rend la protection locale au lieu de dépendre de l'appelant.
|
||||
|
||||
```pascal
|
||||
// PM.Handler.Auth.pas — HandleMigrateKdf, juste avant le garde d'idempotence
|
||||
// existant (~ligne 722).
|
||||
// Les schemes -v2 ont un verifier découplé : migrate-kdf ne sait pas les
|
||||
// réécrire sans changer la formule → refuser au lieu de bricker le compte.
|
||||
if LAlgo.EndsWith('-v2', True) then
|
||||
begin
|
||||
TJSONHelper.SendOK(AResponse, 'Already at target');
|
||||
Exit;
|
||||
end;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6c-5. 🔵 Fallback silencieux sur params Argon2 invalides
|
||||
|
||||
[Auth.pas:319](delphi-backend/Handlers/PM.Handler.Auth.pas:319) (register) et
|
||||
[Auth.pas:994](delphi-backend/Handlers/PM.Handler.Auth.pas:994) (rotation) :
|
||||
|
||||
```pascal
|
||||
if SameText(LReqAlgo, HASH_ALGO_ARGON2) and LArgon.Valid then ... else
|
||||
LRegAlgo := HASH_ALGO_DECOUPLED; // ← fallback muet
|
||||
```
|
||||
|
||||
Un client qui déclare `argon2id-v2` avec des params hors bornes est stocké en
|
||||
**PBKDF2** sans un mot, alors qu'il a dérivé sa clé en **Argon2id** → au login
|
||||
suivant `/login/challenge` renvoie `pbkdf2-sha256-v2`, le client dérive en PBKDF2,
|
||||
verifier différent → **compte brické**. Le JS envoie toujours
|
||||
`ARGON2_DEFAULT_PARAMS` (valides) donc ce n'est pas live ; échouer fort coûte
|
||||
5 lignes.
|
||||
|
||||
```pascal
|
||||
// Aux DEUX endroits : un client qui DEMANDE Argon2id avec des params hors
|
||||
// bornes doit être refusé, pas silencieusement rétrogradé en PBKDF2.
|
||||
if SameText(LReqAlgo, HASH_ALGO_ARGON2) and (not LArgon.Valid) then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 400, 'Invalid argon2 params');
|
||||
Exit;
|
||||
end;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6c-6. 🔵 Le port « aléatoire » dépend d'un `Randomize` qui n'est pas le nôtre
|
||||
|
||||
[PM.HTTPServer.pas:136](delphi-backend/Source/PM.HTTPServer.pas:136) utilise
|
||||
`Random()`, et le projet n'appelle **jamais** `Randomize` (vérifié : 0 occurrence
|
||||
dans tout le `.dpr` + `.pas`). `RandSeed` vaut 0 par défaut → séquence
|
||||
déterministe.
|
||||
|
||||
Empiriquement le port **varie** bien (le CLAUDE.md documente le wipe de
|
||||
`localStorage` à chaque lancement, ce qui le prouve) → une unité RTL/Indy sème
|
||||
`RandSeed` en initialisation. Ça marche donc **par accident** : dépendance
|
||||
implicite, sur ce que le design traite comme une mesure de sécurité, alors que
|
||||
`RandomBytes` (CNG) est déjà utilisé **6 lignes plus haut** pour le token.
|
||||
|
||||
```pascal
|
||||
// PM.HTTPServer.pas — Start (~ligne 136)
|
||||
// CSPRNG (CNG) plutôt que Random() : ne dépend pas d'un Randomize appelé par
|
||||
// une unité tierce. 49152..65535 = 16384 valeurs, et 65536 mod 16384 = 0
|
||||
// → pas de biais modulo.
|
||||
if LRequestedPort = 0 then
|
||||
begin
|
||||
var LRnd := PM.Crypto.RandomBytes(2);
|
||||
LRequestedPort := EphemeralPortMin +
|
||||
(((LRnd[0] shl 8) or LRnd[1]) mod (EphemeralPortMax - EphemeralPortMin + 1));
|
||||
end;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6c-7. 🔵 Comparaison du token d'accès : sous-chaîne + non constant-time
|
||||
|
||||
[PM.HTTPServer.pas:258](delphi-backend/Source/PM.HTTPServer.pas:258) :
|
||||
|
||||
```pascal
|
||||
if (CookieHeader <> '') and (Pos(CookieName + '=' + FAccessToken, CookieHeader) > 0)
|
||||
```
|
||||
|
||||
`Pos` matche **n'importe où** dans le header et accepte les **préfixes** :
|
||||
`pm_token=<token>junk` passe, et un cookie nommé `xpm_token=<token>` aussi. Ni ça
|
||||
ni le `=` de la ligne 262 ne sont constant-time, alors que `ConstantTimeEquals`
|
||||
existe et sert déjà pour le CSRF. Impact faible derrière le PID check (il faut
|
||||
déjà connaître le token), mais c'est une incohérence gratuite.
|
||||
|
||||
```pascal
|
||||
// PM.HTTPServer.pas — parse réel du header Cookie plutôt qu'un Pos() sur la
|
||||
// chaîne brute, + comparaison constant-time (comme RequireCSRF).
|
||||
function TryGetCookie(const AHeader, AName: string; out AValue: string): Boolean;
|
||||
var
|
||||
LPairs: TArray<string>;
|
||||
LPair, LName: string;
|
||||
LEq: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
AValue := '';
|
||||
LPairs := AHeader.Split([';']);
|
||||
for LPair in LPairs do
|
||||
begin
|
||||
LEq := Pos('=', LPair);
|
||||
if LEq <= 0 then Continue;
|
||||
LName := Trim(Copy(LPair, 1, LEq - 1));
|
||||
if SameText(LName, AName) then
|
||||
begin
|
||||
AValue := Trim(Copy(LPair, LEq + 1, MaxInt));
|
||||
Exit(True);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
```
|
||||
|
||||
```pascal
|
||||
// ValidateAccessToken — remplace les deux comparaisons
|
||||
if TryGetCookie(CookieHeader, CookieName, LCookieVal) and
|
||||
ConstantTimeEquals(LCookieVal, FAccessToken) then
|
||||
Exit(True);
|
||||
|
||||
QueryToken := ARequest.Params.Values[QueryParamName];
|
||||
if (QueryToken <> '') and ConstantTimeEquals(QueryToken, FAccessToken) then
|
||||
begin
|
||||
// ... Set-Cookie inchangé
|
||||
```
|
||||
|
||||
(`PM.Crypto` est déjà dans le `uses` de `PM.HTTPServer` → rien à ajouter.)
|
||||
|
||||
---
|
||||
|
||||
### 6c-8. 🔵 Fuite mémoire sur chemin d'erreur
|
||||
|
||||
[Attachments.pas:292](delphi-backend/Handlers/PM.Handler.Attachments.pas:292) :
|
||||
`LArr := TJSONArray.Create` est créé **avant** le `try/finally` → une exception
|
||||
dans le bloc DB le fuit. `SendJSON` ne le libère que sur le chemin nominal.
|
||||
|
||||
```pascal
|
||||
// PM.Handler.Attachments.pas — HandleListAllAttachments
|
||||
LArr := TJSONArray.Create;
|
||||
try
|
||||
DB.Lock;
|
||||
try
|
||||
// ... remplissage inchangé
|
||||
finally
|
||||
DB.Unlock;
|
||||
end;
|
||||
except
|
||||
LArr.Free;
|
||||
raise;
|
||||
end;
|
||||
TJSONHelper.SendJSON(AResponse, LArr); // libère LArr (AOwnsObj=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6c-9. ❓ `PRAGMA foreign_keys` — à vérifier, non tranché
|
||||
|
||||
> ✅ **RÉSOLU 2026-07-16.** `FConn.Params.Values['ForeignKeys'] := 'On'` ajouté
|
||||
> à la config de connexion FireDAC ([PM.Database.pas](delphi-backend/Source/PM.Database.pas)).
|
||||
> Correct dans les deux cas (no-op si c'était déjà le défaut). **Vérif base
|
||||
> avant fix : 0 attachment / 0 history orphelin** — soit le défaut FireDAC était
|
||||
> déjà On, soit aucun delete-permanent-avec-attachments n'a encore eu lieu ;
|
||||
> l'explicite lève le doute pour de bon.
|
||||
|
||||
`PRAGMA foreign_keys` n'est défini **nulle part**. Le schéma s'appuie pourtant sur
|
||||
`ON DELETE CASCADE` : [Entries.pas:704](delphi-backend/Handlers/PM.Handler.Entries.pas:704)
|
||||
(delete permanent) ne nettoie **ni** `entry_attachments` **ni**
|
||||
`entries_password_history` à la main, et le CLAUDE.md affirme « FK cascade on user
|
||||
+ entry delete ».
|
||||
|
||||
SQLite met `foreign_keys` à **OFF** par défaut. Le paramètre `ForeignKeys` de
|
||||
FireDAC est *censé* défauter à `On` (ce qui sauverait la mise), mais **je n'ai pas
|
||||
pu le confirmer** : les DCU ne sont pas greppables et il n'y avait pas de base pour
|
||||
tester. Si c'était Off, chaque suppression définitive laisserait des blobs chiffrés
|
||||
orphelins — exactement ce que `CompactIfBloated` tente de rattraper.
|
||||
|
||||
Deux façons de lever le doute :
|
||||
|
||||
```sql
|
||||
-- Sur un vault.db réel, après avoir supprimé définitivement une entry
|
||||
-- qui avait des attachments :
|
||||
SELECT COUNT(*) FROM entry_attachments a
|
||||
WHERE NOT EXISTS (SELECT 1 FROM vault_entries e WHERE e.id = a.entry_id);
|
||||
-- > 0 → la cascade ne s'applique pas.
|
||||
```
|
||||
|
||||
```pascal
|
||||
// PM.Database.pas — TPMDatabase.Create, avec les autres Params (~ligne 82).
|
||||
// Explicite = plus de dépendance à un défaut FireDAC non documenté chez nous.
|
||||
FConn.Params.Values['ForeignKeys'] := 'On';
|
||||
```
|
||||
|
||||
Le second est correct **dans les deux cas** (no-op si c'était déjà le défaut) →
|
||||
à appliquer sans attendre la vérification.
|
||||
|
||||
---
|
||||
|
||||
### Ordre d'application suggéré
|
||||
|
||||
| # | Sévérité | Effort | État (2026-07-16) |
|
||||
|---|---|---|---|
|
||||
| **6c-1** | 🔴 | — | ✅ **Fait** — chemin migrate-kdf désactivé (option ultra) au lieu de réparé. |
|
||||
| **6c-2** | 🟠 | — | ✅ **Fait** — `expires_at` en UTC. Ferme §2.2. |
|
||||
| **6c-3** | 🟡 | — | ✅ **Fait** — CSRF lié à la session présentée (token_hash). |
|
||||
| **6c-9** | ❓ | — | ✅ **Fait** — `ForeignKeys=On` explicite. |
|
||||
| **6c-4** | 🟡 | — | ✅ **Fait** — endpoint migrate-kdf vidé + reject 410 (ferme 6c-1 & 6c-4 côté serveur). |
|
||||
| **6c-5** | 🔵 | Trivial | À faire — 5 lignes × 2 endroits (fail-fast params Argon2). |
|
||||
| **6c-6/7/8** | 🔵 | Faible | À faire — cohérence / hygiène, aucune urgence. |
|
||||
|
||||
Rappel build : tout edit JS impose `delphi-backend\assets\BuildAssets.cmd` **puis**
|
||||
le build Delphi, sinon l'exe garde les anciens assets.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verdict
|
||||
|
||||
Pour un usage **personnel, local, mono-utilisateur**, le produit est
|
||||
@@ -425,3 +977,12 @@ verifier découplé (§1.1), Argon2id (§1.2), métadonnées chiffrées au repos
|
||||
navigateur, Windows Hello, import KeePass, i18n). Le produit est passé de
|
||||
« sûr pour du solo loopback » à « durci pour un modèle de menace DB/sync qui
|
||||
fuit ».
|
||||
|
||||
**MAJ 2026-07-16** : la revue backend Delphi (**§6c**) ne remet pas en cause ce
|
||||
verdict sur le **durcissement** — aucune injection, autorisation scopée partout,
|
||||
routes non authentifiées correctes, crypto saine. Le **🔴 de perte de données**
|
||||
(`/migrate-kdf` re-chiffrait 1 blob sur 8, **§6c-1**) est **traité** : chemin
|
||||
désactivé (les 2 comptes étaient déjà à 600k, jamais mordu). Faits aussi :
|
||||
expiry session UTC (**§6c-2**, clôt §2.2), CSRF lié à la bonne session
|
||||
(**§6c-3**), `foreign_keys` explicite (**§6c-9**). Reste du confort/hygiène
|
||||
(**§6c-5/6/7/8**), sans urgence.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Système de licences — scénario d'essai
|
||||
|
||||
Écrit le **2026-09-05**, après la séance qui a posé la vérification hors ligne,
|
||||
le liage au poste et l'écran d'activation.
|
||||
|
||||
Ce plan ne cherche pas à montrer que ça marche : **il cherche les endroits où ça
|
||||
casserait sans prévenir**. Un système de licences échoue de deux façons
|
||||
opposées, et les deux coûtent cher — refuser un client qui a payé, ou ouvrir à
|
||||
quelqu'un qui n'a rien. Chaque essai ci-dessous vise l'une des deux.
|
||||
|
||||
Ce que la séance a déjà appris, et qui vaut d'être gardé en tête : **l'empreinte
|
||||
du poste dépendait de l'ordre de démarrage.** Selon que WMI répondait ou non,
|
||||
la même machine se présentait sous deux identités et refusait sa propre licence.
|
||||
Rien dans une utilisation normale ne l'aurait montré. D'où la série D.
|
||||
|
||||
---
|
||||
|
||||
## Préparation
|
||||
|
||||
**L'outil de remise à zéro** : pose un fichier `config.txt` à côté de l'exe
|
||||
(n'importe quel contenu, même vide). Un bouton rouge **Deactivate (debug)**
|
||||
apparaît alors au bas de *Settings → Licence* : il efface la licence rangée et
|
||||
rien d'autre. Sans `config.txt` le bouton n'apparaît pas, et la commande
|
||||
sous-jacente est de toute façon inerte — un exe livré ne peut pas être
|
||||
désactivé par ce chemin.
|
||||
|
||||
Ne supprime `%LOCALAPPDATA%\PMServer\prefs.bin` qu'en dernier recours : ça
|
||||
désactive aussi, mais emporte le mode de déverrouillage, le nom retenu et les
|
||||
réglages de sync.
|
||||
|
||||
**Ce qu'il faut sous la main** :
|
||||
|
||||
- ta licence valable, liée à ce poste ;
|
||||
- l'Installation ID de ce poste (Settings → Licence, ou l'écran d'activation) ;
|
||||
- `tools/licence.ps1` et la clé privée.
|
||||
|
||||
**Ordre** : `A → B → C → D → E`. La série A établit l'état de référence ; les
|
||||
suivantes le cassent volontairement.
|
||||
|
||||
---
|
||||
|
||||
## A. Le chemin normal
|
||||
|
||||
1. Coffre déjà activé, lancer l'application.
|
||||
**À voir** : l'écran d'activation **n'apparaît pas**. On tombe directement
|
||||
sur l'écran de connexion.
|
||||
2. Settings → Licence.
|
||||
**À voir** : *Licensed to*, *Email*, *Issued* et *Installation ID* remplis.
|
||||
Le nom affiché est celui que tu as tapé à l'émission — il vient de la charge
|
||||
utile signée, pas d'un réglage local.
|
||||
3. Fermer, relancer.
|
||||
**À voir** : toujours pas d'écran d'activation. C'est ce qui prouve que la
|
||||
licence est rangée et relue, pas seulement acceptée une fois.
|
||||
|
||||
---
|
||||
|
||||
## B. Ce qui doit être refusé
|
||||
|
||||
Chaque essai part de l'écran d'activation (licence effacée).
|
||||
|
||||
1. **Chaîne quelconque** : taper `nimportequoi`.
|
||||
**À voir** : *This licence key is not valid*, **en rouge**. L'écran reste.
|
||||
2. **Licence tronquée** : coller ta licence valable en retirant les dix derniers
|
||||
caractères.
|
||||
**À voir** : même refus. La signature ne tolère pas un octet.
|
||||
3. **Charge utile trafiquée** : prendre ta licence, changer **un seul caractère
|
||||
dans la partie avant le point** (le nom, l'adresse), coller.
|
||||
**À voir** : même refus. C'est l'essai qui prouve que le contenu affiché dans
|
||||
Settings ne peut pas être maquillé.
|
||||
4. **Licence d'un autre poste** :
|
||||
```
|
||||
tools\licence.ps1 -For "x@y.z" -Name "Autre" -Machine "AAAA-BBBB-CCCC-DDDD"
|
||||
```
|
||||
coller le résultat.
|
||||
**À voir** : refus. **C'est le cœur du liage** — une licence parfaitement
|
||||
signée, mais pas pour cette machine.
|
||||
5. **Licence d'une autre paire de clés** :
|
||||
```
|
||||
tools\licence.ps1 -Init -KeyFile "%TEMP%\faux.key"
|
||||
tools\licence.ps1 -For "x@y.z" -Name "Faux" -Machine "<TON ID>" -KeyFile "%TEMP%\faux.key"
|
||||
```
|
||||
coller le résultat.
|
||||
**À voir** : refus. **C'est le cœur de la signature** — la bonne machine, la
|
||||
bonne forme, mais pas ta clé. Si cet essai passait, n'importe qui pourrait
|
||||
fabriquer des licences.
|
||||
Puis supprimer `%TEMP%\faux.key`.
|
||||
6. **Ta vraie licence**, pour finir.
|
||||
**À voir** : l'écran disparaît, on passe à la connexion.
|
||||
|
||||
---
|
||||
|
||||
## C. Ce qui ne doit PAS être refusé
|
||||
|
||||
Le risque symétrique, et le plus coûteux commercialement.
|
||||
|
||||
1. Coller ta licence **avec des espaces avant et après** (une sélection à la
|
||||
souris en attrape presque toujours).
|
||||
**À voir** : acceptée. Le code fait `Trim`.
|
||||
2. Coller ta licence **avec un retour à la ligne** collé au bout.
|
||||
**À voir** : acceptée.
|
||||
3. Après activation, **redémarrer la machine** (pas seulement l'application).
|
||||
**À voir** : toujours activée. Le port du serveur change à chaque
|
||||
lancement — si la licence avait vécu dans `localStorage`, elle serait
|
||||
perdue ici. C'est l'essai qui prouve le stockage DPAPI.
|
||||
|
||||
---
|
||||
|
||||
## D. L'empreinte du poste — la série qui a déjà attrapé un défaut
|
||||
|
||||
1. Noter l'Installation ID dans Settings → Licence.
|
||||
2. Fermer, relancer, relire.
|
||||
**À voir** : **exactement le même**. Un seul caractère de différence, et
|
||||
toutes les licences de cette machine tomberaient au prochain lancement.
|
||||
3. Relancer **cinq fois de suite**, en laissant la machine chargée (ouvrir
|
||||
quelques applications) pour varier les temps de démarrage.
|
||||
**À voir** : le même à chaque fois. C'est le défaut corrigé le 2026-09-05 :
|
||||
l'empreinte dépendait de l'initialisation de COM par WebView2, donc de la
|
||||
vitesse de démarrage.
|
||||
4. **Démarrage à froid** : redémarrer Windows, lancer l'application en premier.
|
||||
**À voir** : le même.
|
||||
|
||||
---
|
||||
|
||||
## E. La portée de l'activation
|
||||
|
||||
1. **Autre compte Windows** sur la même machine : ouvrir une session
|
||||
différente, lancer l'application.
|
||||
**À voir** : l'écran d'activation **réapparaît**, avec le **même**
|
||||
Installation ID. Comportement voulu : la licence est rangée par utilisateur
|
||||
(DPAPI), l'empreinte est celle de la machine. Ta licence y sera acceptée.
|
||||
2. **Copier l'exe dans un autre dossier** et le lancer.
|
||||
**À voir** : toujours activée. La licence ne vit pas à côté de l'exe.
|
||||
3. **La mise à jour** — le cas qu'on vivra réellement. Remplacer l'exe d'une
|
||||
installation existante par une nouvelle version, lancer.
|
||||
**À voir** : toujours activée. **C'est l'essai le plus utile de la série** :
|
||||
si une mise à jour désactivait, il faudrait réémettre une licence à chaque
|
||||
livraison, et on ne s'en apercevrait qu'au premier client mécontent.
|
||||
|
||||
---
|
||||
|
||||
## Résultat de la première campagne — 2026-09-05
|
||||
|
||||
`A` `B` `C` `D` `E2` `E3` **passés**. Rien à corriger : la campagne n'a rien
|
||||
trouvé, ce qui n'était pas acquis — la séance qui l'a précédée avait déjà sorti
|
||||
trois défauts (empreinte dépendante de l'ordre de démarrage, message d'erreur en
|
||||
cyan au lieu de rouge, licences flottantes acceptées).
|
||||
|
||||
**`E1` (autre compte Windows) n'a pas été joué** — faute d'un second compte sous
|
||||
la main. Ce que le code dit qu'il donnerait : écran d'activation de retour, même
|
||||
Installation ID, licence acceptée telle quelle. À éprouver le jour où un client
|
||||
le signale, ou avant de vendre à quelqu'un qui a plusieurs sessions.
|
||||
|
||||
Deux observations de la campagne, gardées parce qu'elles se reproduiront :
|
||||
|
||||
- **`C3` a été plus probant que prévu.** Le port avait été changé à la main entre
|
||||
les deux lancements (8766 → 51812), donc l'origine du navigateur a réellement
|
||||
changé — exactement ce que l'essai veut prouver, mais pour de bon.
|
||||
- **Un port éphémère dans le journal sans ligne de repli n'est pas un défaut** :
|
||||
c'est `port=0` ou un `config.txt` absent. Le repli, lui, écrit toujours
|
||||
*« Port N unavailable »* avant de se déplacer.
|
||||
|
||||
---
|
||||
|
||||
## Ce qu'aucun de ces essais ne prouve
|
||||
|
||||
À dire clairement, pour ne pas se croire protégé de ce qu'on ne l'est pas :
|
||||
|
||||
- **Rapiécer l'exe pour sauter la vérification reste possible.** C'est vrai de
|
||||
tout schéma hors ligne. La signature empêche de *fabriquer* des licences, pas
|
||||
de contourner le code qui les lit.
|
||||
- **Il n'y a pas de révocation.** Une licence émise vaut pour toujours sur sa
|
||||
machine. La seule annulation possible est de régénérer la paire et de
|
||||
réémettre — ce qui casse toutes les autres.
|
||||
- **Le brouillage des blocs embarqués n'est pas du chiffrement.** L'onglet
|
||||
*Memory* de Process Explorer, un débogueur ou la réponse HTTP rendent toujours
|
||||
le frontend en clair.
|
||||
|
||||
## Le défaut à reconnaître sans le provoquer
|
||||
|
||||
Si `$ObfSecret` (BuildAssets.ps1) et `AssetObfSecret` (PM.EmbeddedAssets.pas)
|
||||
divergent, la magie `PMA1` tombe et le serveur **refuse de servir** :
|
||||
`/index.html` en **404**, fenêtre vide. Ce n'est pas une panne de licence, c'est
|
||||
un désaccord de build. Symptôme à ne pas confondre.
|
||||
@@ -0,0 +1,706 @@
|
||||
# Scénarios de test — écriture concurrente & fermeture de l'éditeur
|
||||
|
||||
Sessions du 2026-07-22. Tous les tests sont **manuels** (ils portent sur des
|
||||
interactions UI que le harness `node:test` ne couvre pas). Rebuild obligatoire
|
||||
avant de commencer : `delphi-backend\assets\BuildAssets.cmd` puis build Delphi.
|
||||
|
||||
Convention : **PRÉ** = état de départ, **ACTION**, **ATTENDU**. Un test qui
|
||||
échoue = régression, noter le commit courant.
|
||||
|
||||
---
|
||||
|
||||
## Les 4 classes de bug
|
||||
|
||||
| # | Classe | Cause racine | Corrigé par |
|
||||
|---|---|---|---|
|
||||
| **A** | Écriture extérieure écrasée au Save | L'éditeur garde le payload chargé à l'ouverture et le re-poste **entier** ; toute écriture faite ailleurs entre-temps est perdue | `editorAbsorbChange` (d146fbe) |
|
||||
| **B** | Éditeur fermé sans garde-fou | `closeSlideOver()` appelé sans condition + click-outside non filtré | `closeEditorForDeleted` (3dbce78), whitelist (e1ddc65) |
|
||||
| **C** | PUT `/entries` sans `withEncryptedMeta` | Les colonnes en clair sont droppées côté serveur → le body ne contient rien de stockable et blanchit les `*_enc` | d146fbe |
|
||||
| **D** | UI construite sans regarder la vue | Le menu kebab est partagé entre vues ; la corbeille n'était pas testée | d146fbe |
|
||||
|
||||
---
|
||||
|
||||
## A — Éditeur ouvert vs écriture extérieure
|
||||
|
||||
Préparer : 3 entrées `A`, `B`, `C`, dont `A` dans le dossier `Work` avec le tag
|
||||
`old`.
|
||||
|
||||
### A1 · batch Add tag sur l'entrée éditée
|
||||
- **PRÉ** : `A` ouverte dans l'éditeur, taper du texte dans NOTE/PASSWORD (ne pas sauver)
|
||||
- **ACTION** : cocher `A` + `B` → barre de sélection → *Add tag* → `shared` → puis **Save** dans l'éditeur
|
||||
- **ATTENDU** : le chip `shared` apparaît **immédiatement** dans l'éditeur ; après Save, `A` porte `old, shared` **et** la modif tapée. Le bouton Save reste visible (l'entrée était déjà dirty) mais le tag seul ne rend pas dirty.✅
|
||||
|
||||
### A2 · batch Add tag sur une **autre** entrée
|
||||
- **PRÉ** : `A` ouverte + dirty
|
||||
- **ACTION** : cocher `B` + `C` seulement → *Add tag* → `x` → Save dans l'éditeur
|
||||
- **ATTENDU** : l'éditeur ne bouge pas (aucun chip ajouté), `A` conserve ses tags, `B`/`C` taguées.✅
|
||||
|
||||
### A3 · batch Move to folder sur l'entrée éditée
|
||||
- **ACTION** : `A` ouverte + dirty → cocher `A` → *Move to folder…* → `Perso` → Save
|
||||
- **ATTENDU** : le select Folder de l'éditeur bascule sur `Perso` tout seul ; après Save l'entrée est bien dans `Perso`.✅
|
||||
|
||||
### A4 · dossier choisi dans l'éditeur **puis** move extérieur
|
||||
- **ACTION** : `A` ouverte, changer Folder → `Archive` (ne pas sauver) → cocher `A` → *Move to folder…* → `Perso` → Save
|
||||
- **ATTENDU** : `Perso` gagne (action extérieure = intention la plus récente). Comportement documenté, pas un bug. ✅
|
||||
|
||||
### A5 · glisser une entrée sur un dossier (`moveEntryToFolder`)
|
||||
- **ACTION** : `A` ouverte + dirty → glisser la ligne `A` sur `Perso` dans la sidebar → Save
|
||||
- **ATTENDU** : identique à A3.✅
|
||||
|
||||
### A6 · glisser une **multi-sélection** sur un dossier (`moveEntriesToFolder`) ✅
|
||||
- **ACTION** : cocher `A` + `B` → glisser sur `Perso` → recharger (F5 impossible : relancer l'app)
|
||||
- **ATTENDU** : dossier changé **et** nom / username / site / tags **intacts**.
|
||||
- **Historique** : ce chemin n'appelait pas `withEncryptedMeta` → il blanchissait les métadonnées chiffrées. Si des entrées ont été déplacées ainsi avant le fix, elles sont peut-être déjà vides — vérifier.
|
||||
|
||||
### A7 · suppression du dossier de l'entrée éditée
|
||||
- **ACTION** : `A` (dossier `Work`) ouverte + dirty → sidebar → supprimer le dossier `Work` → Save
|
||||
- **ATTENDU** : le select de l'éditeur passe à `All` ; après Save l'entrée est dans `All`, le dossier supprimé **ne réapparaît pas**.✅
|
||||
|
||||
### A8 · tags édités des deux côtés
|
||||
- **ACTION** : `A` ouverte, ajouter le chip `mine` dans l'éditeur (ne pas sauver) → batch *Add tag* `shared` sur `A` → Save
|
||||
- **ATTENDU** : `old, mine, shared` — aucun des trois perdu.✅
|
||||
|
||||
### A9 · favori / épingle pendant l'édition (non-régression)
|
||||
- **ACTION** : `A` ouverte + dirty → cliquer ★ et 📌 sur la ligne `A` → Save
|
||||
- **ATTENDU** : l'entrée reste favorite et épinglée (ces colonnes ne sont pas dans le PUT de l'éditeur).✅
|
||||
|
||||
### A10 · icône / pièce jointe pendant l'édition (non-régression)
|
||||
- **ACTION** : `A` ouverte + dirty → Settings → *Fetch missing* favicons (ou upload d'icône) → Save
|
||||
- **ATTENDU** : l'icône survit au Save (endpoint dédié, hors payload éditeur).✅
|
||||
|
||||
### A10b · icône rafraîchie dans l'éditeur ouvert
|
||||
- **ACTION** : `A` ouverte → Settings → *Fetch missing* / *Re-fetch all* favicons
|
||||
- **ATTENDU** : l'aperçu d'icône **dans l'éditeur** se met à jour tout seul (pas seulement la carte). Idem après un import en mode overwrite qui apporte une icône.✅
|
||||
|
||||
### A11 · sync / import pendant que l'éditeur est ouvert ✅
|
||||
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou importer un JSON en mode overwrite) qui met `A` à jour → Save
|
||||
- **ATTENDU** : modale « Entry changed elsewhere ». **Annuler ⇒ rien n'est écrit** (la version du sync/import survit, l'éditeur reste ouvert le temps de recopier). **Overwrite ⇒** la version de l'éditeur gagne.✅
|
||||
|
||||
### A12 · pas de fausse alerte (non-régression d'A11)
|
||||
Aucun de ces enchaînements ne doit afficher « Entry changed elsewhere » :
|
||||
- **A12a** : `A` ouverte + dirty → Save → modifier encore → Save (le rechargement post-save re-cale la référence)✅
|
||||
- **A12b** : `A` ouverte + dirty → batch *Add tag* / *Move to folder* sur `A` → Save (l'éditeur a absorbé le changement)✅
|
||||
- **A12c** : `A` ouverte + dirty → supprimer son dossier → Save (cf. A7)✅
|
||||
- **A12d** : `A` ouverte + dirty → *Fetch missing* favicons → Save (l'icône est hors payload éditeur)✅
|
||||
- **A12e** : `A` ouverte + dirty → sync **sans** changement distant sur `A` → Save✅
|
||||
|
||||
### A13 · entrée supprimée ailleurs pendant l'édition ✅
|
||||
- **ACTION** : `A` ouverte + dirty → un sync distant supprime `A` (tombstone) → Save
|
||||
- **ATTENDU** : pas de plantage. L'entrée n'est plus en mémoire → le contrôle A11 passe la main ; le PUT échoue proprement avec un **toast d'erreur** — surtout pas « Saved ».
|
||||
- **Recette locale, sans second appareil** : ouvrir `A` et la rendre dirty → dans DB Browser, app lancée, `DELETE FROM vault_entries WHERE id = <id>` puis **Write Changes** (sans ça le WAL ne publie rien) → dans l'app, déclencher un `loadEntries()` qui ne concerne pas `A` (mettre une **autre** entrée à la corbeille puis *Empty trash*) → Save.
|
||||
- **Bug trouvé par ce test** : le serveur répondait **200 « Updated »** pour un `UPDATE` n'ayant touché **aucune** ligne — le client affichait « Saved » alors que rien n'était écrit. Perte de données silencieuse. `HandleUpdateEntry` teste maintenant `RowsAffected` et renvoie 404.
|
||||
- **Même classe, non corrigé** : delete / restore / favorite / pin / set-icon répondent aussi OK sur un id inexistant. Assumé — aucun ne prétend avoir enregistré un travail de l'utilisateur, et le client recharge la liste juste après.
|
||||
|
||||
### A13b · récupérer la saisie quand l'entrée a disparu ✅
|
||||
- **ACTION** : même mise en place qu'A13 → au Save, répondre **« Save as new entry »**.
|
||||
- **ATTENDU** : une nouvelle entrée est créée avec le contenu de l'éditeur, le panneau se rouvre dessus et la carte clignote. *Cancel* → rien n'est écrit, l'éditeur reste ouvert avec la saisie.
|
||||
- **Nouvel uuid, pas une résurrection** : le POST en génère un neuf, donc l'entrée recréée n'entre pas en conflit avec le tombstone laissé par la suppression — aucun risque de la voir re-supprimée au sync suivant, ni de ressusciter en boucle ce qu'un autre appareil a effacé volontairement.
|
||||
- **Piège du déclenchement** : le serveur HTTP répond aussi **404** à toute requête qu'il refuse (verrou par token). La bascule teste donc `status === 404` **et** le message « no longer exists » — le statut seul confondrait une requête rejetée avec une entrée supprimée.
|
||||
|
||||
---
|
||||
|
||||
## B — Fermeture de l'éditeur & message de discard
|
||||
|
||||
Le message n'apparaît que si `confirmOnUnsaved` est actif **et** que l'entrée
|
||||
est dirty. Tester avec `askBeforeDelete` **ON puis OFF** (Settings → Security).
|
||||
|
||||
### B1 · ✕ sur une **autre** ligne ✅
|
||||
- **PRÉ** : `A` ouverte + dirty
|
||||
- **ACTION** : cliquer ✕ (Move to trash) sur la ligne `B`
|
||||
- **ATTENDU** : `B` part à la corbeille, l'éditeur de `A` **reste ouvert** avec les modifs. Vrai dans les deux réglages de `askBeforeDelete`.
|
||||
|
||||
### B2 · ✕ sur la ligne éditée, `askBeforeDelete` = OFF ✅
|
||||
- **ACTION** : `A` ouverte + dirty → ✕ sur `A`
|
||||
- **ATTENDU** : modale « Discard unsaved changes? ». **Annuler ⇒ rien n'est supprimé** et l'éditeur reste ouvert. Confirmer ⇒ `A` à la corbeille + éditeur fermé.
|
||||
|
||||
### B3 · ✕ sur la ligne éditée, `askBeforeDelete` = ON ✅
|
||||
- **ATTENDU** : **une seule** modale (« Move to trash »). Pas de double prompt.
|
||||
|
||||
### B4 · batch Delete incluant l'entrée éditée ✅
|
||||
- **ACTION** : `A` ouverte + dirty → cocher `A` + `B` → *Delete*
|
||||
- **ATTENDU** : une seule modale (« 2 entries will be moved to trash »), puis l'éditeur se ferme.
|
||||
|
||||
### B5 · batch Delete **excluant** l'entrée éditée ✅
|
||||
- **ATTENDU** : l'éditeur reste ouvert, modifs intactes.
|
||||
|
||||
### B6 · corbeille — l'éditeur ne se ferme pas pour rien ✅
|
||||
**Prémisse corrigée** : on **ne peut pas** ouvrir une entrée de la corbeille dans
|
||||
l'éditeur. `openTrashActions` (js/app.js) est un corps vide, et le menu kebab de
|
||||
la corbeille n'a pas d'entrée *Edit* — les actions sont sur la carte. Une entrée
|
||||
purgée n'est donc jamais celle qui est éditée, sauf par le chemin distant décrit
|
||||
plus bas.
|
||||
- **ACTION** : `A` ouverte + dirty (vue normale) → aller dans la corbeille → *Delete forever* sur une autre entrée, puis un batch *Delete forever*, puis *Empty trash*.
|
||||
- **ATTENDU** : l'éditeur de `A` **reste ouvert** avec ses modifs dans les trois cas — la purge ne concerne jamais l'entrée éditée.
|
||||
- **Cas limite, seul moyen d'y arriver** : `A` ouverte + dirty → un sync distant met `A` à la corbeille → *Empty trash* local → là `closeEditorForDeleted` doit fermer l'éditeur proprement (même famille qu'A13).
|
||||
|
||||
### B7 · boutons de la barre de sélection ✅
|
||||
- **ACTION** : `A` ouverte + dirty → cocher `B` → cliquer successivement *Move to folder…*, *Add tag*, *Clear*
|
||||
- **ATTENDU** : **aucun** de ces clics ne ferme l'éditeur ni n'affiche de discard.
|
||||
|
||||
### B8 · bouton *Empty trash* (barre de titre) ✅
|
||||
- **ATTENDU** : idem, pas de fermeture parasite de l'éditeur.
|
||||
|
||||
### B9 · chemins de fermeture légitimes (non-régression) ✅
|
||||
- Esc, ✕ de l'éditeur, clic sur le fond de la grille, ouverture d'une **autre** entrée
|
||||
- **ATTENDU** : message de discard à chaque fois si dirty ; annuler garde l'éditeur ouvert sur la bonne entrée.
|
||||
|
||||
### B10 · verrouillage (comportement assumé) ✅
|
||||
- **ACTION** : `A` ouverte + dirty → auto-lock ou *Lock vault*
|
||||
- **ATTENDU** : fermeture **sans** message, modifs perdues. Volontaire : la sécurité prime.
|
||||
|
||||
### B11 · Esc au-dessus d'un modal ouvert par-dessus l'éditeur ✅
|
||||
- **ACTION** : `A` ouverte + dirty → ouvrir un modal → Esc → Esc
|
||||
- **ATTENDU** : 1ᵉʳ Esc ferme **le modal seul**, l'éditeur reste ouvert ; 2ᵉ Esc traite l'éditeur (discard).
|
||||
- Modals à passer un par un : **+ New folder** (sidebar), **Customize folder**, *Change master password*, générateur de mot de passe, cheatsheet (`?`), historique de mot de passe, palette (`Ctrl+K`), outil TOTP, quick search (`Ctrl+Shift+Q`), **reauth** (déclenché par un export), **picker d'autofill** (`Ctrl+Shift+L` avec 2 entrées qui matchent).
|
||||
- **Historique** : `#folderModal` (dynamique) n'était dans aucune liste → Esc fermait l'éditeur derrière. `#reauthModal`, `#quickSearchModal`, `#autofillPickerModal` (statiques) non plus.
|
||||
|
||||
### B12 · le modal reste fonctionnel après un Esc ✅
|
||||
- **ACTION** : ouvrir reauth (export) → Esc → **ré-ouvrir** (relancer l'export). Idem quick search et picker d'autofill.
|
||||
- **ATTENDU** : le modal se ré-affiche normalement. Un Esc ne doit jamais **supprimer** un modal statique du DOM (sinon la fonctionnalité est morte jusqu'au redémarrage).
|
||||
|
||||
### B13 · clic sur un toast / l'overlay d'attente ✅
|
||||
- **ACTION** : `A` ouverte + dirty → lancer une action longue (export, sync) → cliquer sur le toast ou sur le spinner
|
||||
- **ATTENDU** : l'éditeur ne se ferme pas, pas de discard.
|
||||
|
||||
### B15 · Esc pendant une opération bloquante ✅
|
||||
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou un export, une rotation de mot de passe maître) → presser `Esc` pendant que l'overlay « Encrypting… » est affiché.
|
||||
- **ATTENDU** : rien ne se passe. Pas de modale « Discard unsaved changes? ». Elle redevient possible une fois l'overlay parti.
|
||||
- **Historique** : la modale s'ouvrait bel et bien, mais **sous** l'overlay (`z-index 200`) — visible en flou, à demi cliquable. Deux problèmes en un : l'affichage, et surtout le fait d'arbitrer une fermeture d'éditeur pendant qu'une opération écrit sur la même entrée. Garde `isBusy()` posé dans la chaîne Esc de l'éditeur, avec les autres priorités (palette, Settings, modales).
|
||||
- **Non-régression** : le clic extérieur ne ferme déjà pas l'éditeur pendant l'attente — `#busyOverlay` est dans la liste blanche, et il couvre l'écran de toute façon.
|
||||
- **Tester les DEUX déclencheurs** : *Sync now* **et** *Export*. Il y a **deux** gestionnaires `Esc` distincts — celui en phase de capture dédié à l'éditeur, et la chaîne globale « une surface par frappe » (confirm → palette → change-master → cheatsheet → … → éditeur). Le premier correctif n'en couvrait qu'un, et l'export passait par l'autre : le bug semblait persister alors qu'il avait été corrigé sur le chemin du sync.
|
||||
|
||||
### B16 · hotkeys globaux pendant une opération bloquante ✅
|
||||
- **ACTION** : lancer un *Export* (ou *Sync now*) → pendant l'overlay, presser `Ctrl+Shift+A`, puis `Ctrl+Shift+Q`.
|
||||
- **ATTENDU** : ni l'éditeur ni la quick search ne s'ouvrent ; toast « Busy — try again in a moment ». Tout redevient normal une fois l'overlay parti.
|
||||
- **Pourquoi les hotkeys échappent aux gardes UI** : ils arrivent de Win32, **hors de la fenêtre**. L'overlay qui couvre l'application n'intercepte rien : le panneau s'ouvrait derrière lui, inaccessible.
|
||||
- **Où est posé le garde** : dans `openSlideOver` et `openQuickSearchModal`, pas sur chaque déclencheur — c'est le point de passage de *tous* les appelants (hotkey, palette de commandes, bouton *Fix* du vault health). Sûr parce qu'**aucun chemin de sauvegarde n'affiche l'overlay** : rien de légitime n'ouvre l'éditeur pendant une opération bloquante.
|
||||
- **ACTION 2 — autofill** : pendant l'overlay, `Ctrl+Shift+L`, `Ctrl+Shift+P`, `Ctrl+Shift+T`, avec un titre à **un seul** candidat puis à **plusieurs**.
|
||||
- **ATTENDU** : rien ne se remplit, aucun picker, même toast. Le garde est dans `autofillHandleRequest`, en amont du choix match-unique / picker, donc il couvre les trois hotkeys et les deux issues. Il appelle `Bridge.cancelAutofill(false)` pour que Delphi ne garde pas la fenêtre cible capturée en attente d'un remplissage qui ne viendra jamais.
|
||||
|
||||
### B17 · hotkeys pendant la boîte « Enregistrer sous » de l'export ✅
|
||||
- **ACTION** : lancer un *Export* → à l'apparition du dialogue natif **Enregistrer sous**, basculer sur une autre application (VS Code) → presser les hotkeys d'autofill → revenir à l'app.
|
||||
- **ATTENDU** : le dialogue est toujours là, au premier plan de l'application, et répond. L'app n'est cliquable qu'à travers lui — c'est normal, une boîte native modale désactive sa fenêtre propriétaire tant qu'on n'a pas répondu.
|
||||
- **Historique** : le refus « busy » appelait `cancelAutofill()`, dont le chemin Delphi fait un `SetForegroundWindow` vers la fenêtre capturée. Le dialogue passait **derrière** l'autre application, et l'app paraissait figée : bloquée par une modale devenue introuvable. `refocus=0` distingue désormais **refuser** une demande (ne rien déplacer) de **terminer** une interaction utilisateur (rendre le focus à la cible).
|
||||
- **Généralisation** : un refus ne doit jamais déplacer une fenêtre. Tout nouveau garde qui annule une action doit passer par ce chemin.
|
||||
- **Deuxième cause, côté Delphi** : `Ctrl+Shift+Q` et `Ctrl+Shift+A` appellent `RestoreWindowFromTray` **avant** que JS ne puisse refuser — un garde JS arrive trop tard par construction. Restaurer la fenêtre propriétaire d'une modale native laisse le dialogue en rade derrière tout le reste : l'app semble gelée alors qu'elle est simplement *désactivée* par une boîte devenue introuvable (au point qu'il fallait un utilitaire de capture d'écran pour la faire réapparaître).
|
||||
- **Garde** : `NativeModalActive` teste `IsWindowEnabled` sur notre fenêtre — Windows la désactive tant qu'une modale native lui appartient. Posé en tête des **quatre** points d'entrée hotkey (quick search hotkey + menu tray, new entry, autofill). Il ne se contente pas de refuser : il **ramène le dialogue au premier plan** via `GetLastActivePopup`, ce qui est précisément ce dont l'utilisateur a besoin.
|
||||
- **ATTENDU après correctif** : pendant l'« Enregistrer sous », n'importe quel hotkey **fait réapparaître le dialogue au premier plan, avec le focus**, et une **notification tray** explique que les raccourcis restent inactifs tant qu'il n'a pas répondu.
|
||||
- **Piège n°1, localiser le dialogue** : `GetLastActivePopup` renvoie **le formulaire lui-même** — la boîte « Enregistrer sous » n'est pas une popup possédée par notre fenêtre. Elle vit en revanche sur notre thread avec la classe standard `#32770`, donc `EnumThreadWindows` la trouve quel que soit son propriétaire. Mesuré : sans ça le bloc de remontée ne s'exécutait jamais, silencieusement.
|
||||
- **Piège n°2, le focus** : `SetForegroundWindow` est **refusé** à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la **fenêtre de premier plan** (VS Code). Réutiliser le helper d'autofill ne marche **pas** : il attache au thread de la *cible*, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus.
|
||||
- **Vérifié au log** : le `WM_HOTKEY` **est** bien distribué pendant la boucle modale du dialogue, et `IsWindowEnabled` détecte correctement l'état. Ces deux points étaient les hypothèses coûteuses à écarter avant de chercher ailleurs.
|
||||
- **Piège du message** : une **balloon tray**, pas un toast — notre fenêtre est désactivée et derrière le dialogue, rien de rendu dans la page ne serait visible. Elle suit le réglage « alertes tray » : désactivé, tu auras le focus sans le message.
|
||||
|
||||
### B18 · clavier pendant une opération bloquante ✅
|
||||
- **ACTION** : lancer un *Sync now* (ou un export) → pendant l'overlay, presser `Tab` plusieurs fois puis `Espace` / `Entrée`.
|
||||
- **ATTENDU** : rien ne se passe. Aucun bouton derrière l'overlay ne prend le focus ni ne s'active. Tout redevient normal ensuite, focus compris.
|
||||
- **ACTION 2 — les raccourcis** : pendant l'overlay, `Ctrl+A` (tout sélectionner), `Ctrl+K` (palette), `j`/`k` (curseur), **`Suppr`** (suppression par lot).
|
||||
- **ATTENDU** : aucun effet. Le plus important est `Suppr` : supprimer des entrées pendant qu'un sync ou une rotation écrit sur les mêmes lignes est exactement ce que l'overlay doit empêcher.
|
||||
- **Historique** : l'overlay ne bloque que la **souris** — il couvre la page. Le clavier passait au travers : `Tab` entrait dans les contrôles derrière et `Espace` les activait. Corrigé avec l'attribut natif `inert` (retire un sous-arbre du focus, du hit-testing et de l'arbre d'accessibilité), posé sur chaque enfant de `<body>` **sauf** l'overlay.
|
||||
- **`inert` ne couvre pas les raccourcis** : ils sont écoutés sur le **document**, donc ils ne traversent aucun élément et rien dans l'arbre ne peut les arrêter. Il a fallu un garde `isBusy()` explicite sur chacun — même famille que les hotkeys Win32, qui arrivent eux de l'extérieur de la fenêtre.
|
||||
- **Point de vigilance** : `hideBusy()` retire l'inertie **inconditionnellement**. Une opération qui échouerait sans le rétablir laisserait une application qui semble gelée.
|
||||
- **`inert` ne suffit pas seul** : il retire les contrôles du focus, mais **pas** le défilement. `Espace`, `Page haut/bas`, `Début/Fin` et les flèches font défiler le document **sans aucun élément focalisé** — la page bougeait encore sous l'overlay. Ces touches sont donc avalées en phase de capture tant que l'opération tourne.
|
||||
- **Vérifier aussi le défilement** : pendant l'overlay, `Espace` et `Page bas` ne doivent rien bouger ; après, le défilement clavier doit refonctionner.
|
||||
|
||||
### B19 · cliquer dans le vide ferme l'éditeur, y compris près des barres ✅
|
||||
- **ACTION** : `A` ouverte → cliquer dans l'**espace vide** à droite du titre « All items », puis dans l'espace vide de la barre de pagination.
|
||||
- **ATTENDU** : l'éditeur se ferme (avec la modale de discard s'il est *dirty*), comme n'importe quel clic hors du panneau.
|
||||
- **ATTENDU (non-régression)** : les **commandes** de ces barres ne ferment toujours pas l'éditeur — boutons de page, sélecteur `25 / page`, tri, barre de sélection, *Empty trash*.
|
||||
- **Historique** : `.content-header` et `.pagination` étaient en liste blanche **en entier**, pour éviter que la barre de sélection et *Empty trash* ne ferment l'éditeur. Effet de bord : leurs grandes zones vides devenaient inertes, et un clic à côté du titre ne faisait plus rien. La liste blanche ne retient plus que les contrôles.
|
||||
|
||||
### B14 · dialogues natifs ouverts par un clic programmatique ✅
|
||||
- **ACTION** : `A` ouverte + **dirty** → (a) Settings → *Import vault* ; (b) télécharger une pièce jointe (fallback web) ; (c) *Upload icon* ; (d) joindre un fichier.
|
||||
- **ATTENDU** : **seul** le dialogue natif s'ouvre. Pas de « Discard unsaved changes? » par-dessus, l'éditeur reste ouvert et modifié.
|
||||
- **Historique** : le `<input type=file>` (et le `<a download>`) est attaché à `<body>` puis `.click()` → le clic synthétique remontait au handler click-outside qui le prenait pour un clic dehors. Gardé par `e.isTrusted`.
|
||||
|
||||
---
|
||||
|
||||
## F — Round-trip export / import (drapeaux & provenance)
|
||||
|
||||
### F1 · favori + épingle survivent au restore ✅
|
||||
- **F1a — entrée supprimée puis réimportée** ✅ : épingler **et** mettre en favori une entrée → *Export vault* → supprimer l'entrée (corbeille + purge) → *Import vault*.
|
||||
**ATTENDU** : elle revient **épinglée et favorite** (chemin INSERT / bulk-import).
|
||||
- **F1b — import en mode écrasement** ✅ : même export, mais **sans** supprimer l'entrée. Retirer l'épingle et le favori localement, réimporter, choisir **Overwrite**.✅
|
||||
**ATTENDU** : les deux drapeaux du fichier sont **réappliqués**.
|
||||
- **Historique** : `pinned` n'était dans aucun container ; `favorite` y était écrit mais **jamais relu** (ni parser, ni body, ni INSERT) → les deux perdus au restore. Puis F1a a marché sans F1b : le mode écrasement passe par `PUT`, qui ignore volontairement `favorite`/`pinned` pour qu'un **sync** ne puisse jamais écraser les drapeaux locaux (device-local par design). L'import les applique donc via les endpoints de bascule dédiés, uniquement quand ils diffèrent.
|
||||
|
||||
### F2 · même chose depuis une auto-backup ✅
|
||||
- **ACTION** : *Backup now* → supprimer l'entrée → importer le fichier `vault-autobackup-*.json`.
|
||||
- **ATTENDU** : identique à F1.
|
||||
- **La présence du fichier ne prouve RIEN** : si le mot de passe de backup remontait vide de la pref, le conteneur serait produit quand même — chiffré avec une chaîne vide, donc parfaitement valide et impossible à ouvrir avec le vrai mot de passe. Le seul critère est que l'**import le déchiffre** : la modale doit lister le bon nombre d'entrées avec leurs sites et identifiants réels.
|
||||
- **Pourquoi ce test compte depuis 2026-08-02** : c'est le seul chemin qui lit `autoBackupPwd` depuis la pref DPAPI. Il casserait si `loadAutoBackupConfig(true)` n'était pas passé là où le chiffrement a lieu (cf. E9).
|
||||
|
||||
### F3 · CSV étranger → drapeaux à 0 ✅
|
||||
- **ACTION** : importer un CSV sans colonnes favorite/pinned.
|
||||
- **ATTENDU** : entrées importées ni épinglées ni favorites (défaut 0), pas d'erreur.
|
||||
|
||||
### F9 · la corbeille voyage dans les sauvegardes ✅
|
||||
- **ACTION** : vault avec par exemple 4 entrées vivantes et 2 en corbeille → *Export vault* → *Clear vault* → réimporter.
|
||||
- **ATTENDU** : état **identique** — 4 dans la liste, 2 en corbeille. Le compteur *Trash* est juste sans avoir à ouvrir la vue.
|
||||
- **`deleted_at` est préservé**, pas réinitialisé : sinon chaque entrée restaurée repartirait pour un délai d'auto-purge complet, et une entrée supprimée il y a 29 jours recommencerait à zéro. D'où son ajout à l'écho de `GET /entries`.
|
||||
- **Deuxième test, celui qui a trouvé le bug** : mettre une entrée à la corbeille → exporter → réimporter **sans rien vider**. L'entrée doit être comptée dans les « déjà présentes » (*Overwrite* / *Skip*), **jamais** ajoutée.
|
||||
La déduplication de l'import ne construisait sa table que depuis `state.entries` : une entrée dont l'`uuid` n'existait **que** dans la corbeille passait pour nouvelle et créait une **seconde ligne partageant son uuid** — l'identité sur laquelle repose toute la sync. Même angle mort que F8.
|
||||
- **Ne PAS vider la corbeille pour nettoyer des doublons d'uuid** : *Empty trash* et *Delete forever* écrivent un tombstone portant cet `uuid`, et l'autre appareil supprimerait alors le jumeau vivant. Retirer les lignes en trop directement en base.
|
||||
- **Le sync, lui, n'emporte pas la corbeille** — voir F8 et `buildSyncSnapshot`.
|
||||
|
||||
### F8 · mettre à la corbeille puis synchroniser ne duplique pas ✅
|
||||
- **ACTION** : synchroniser une première fois (l'instantané distant contient les entrées) → mettre **plusieurs** entrées à la corbeille → **Sync now**.
|
||||
- **ATTENDU** : les entrées restent en corbeille, et **rien n'est recréé** dans la liste vivante. Le résumé ne doit pas annoncer `pulled N new`.
|
||||
- **Le bug** : 30 entrées mises à la corbeille puis un sync → 30 **doublons vivants** à côté des originaux en corbeille, soit deux lignes partageant un même `uuid`.
|
||||
- **Cause** : la corbeille est une suppression **douce**, sans tombstone (seul le hard-delete en écrit un), et les entrées supprimées sont absentes de `state.entries`. Le merge voyait donc leurs uuid comme « absents en local » et les repostait comme des entrées neuves.
|
||||
- **Correctif** : `applyRemoteSnapshot` charge aussi la corbeille et saute tout uuid qui s'y trouve. La décision locale de mettre à la corbeille l'emporte ; c'est le **vidage** de la corbeille qui écrit le tombstone et propage la suppression.
|
||||
- **Nettoyage si le bug a déjà frappé** : les doublons vivants sont à supprimer à la main. Vérifier aussi l'autre appareil, où ils ont pu être poussés.
|
||||
|
||||
### F4 · le sync ne propage PAS les drapeaux (device-local, décidé 2026-07-11) ✅
|
||||
- **ACTION** : épingler/favoriser une entrée sur le device A → *Sync now* → *Sync now* sur le device B.
|
||||
- **ATTENDU** : sur B, l'entrée **n'est pas** épinglée/favorite. Et un sync ne doit jamais **retirer** un drapeau posé localement sur B (le PUT serveur ne touche pas ces colonnes).
|
||||
|
||||
### F5 · icône custom : provenance préservée ✅
|
||||
- **ACTION** : upload d'une icône custom → export → supprimer l'entrée → import → puis **Re-fetch all**.
|
||||
- **ATTENDU** : l'icône custom revient **et** survit au Re-fetch all (le flag `icon_custom` a fait le round-trip).
|
||||
|
||||
### F7 · arbitrage last-write-wins du sync ✅
|
||||
- **PRÉ** : sync configuré (un serveur WebDAV local suffit : `python -m wsgidav.server.server_cli --host=127.0.0.1 --port=8080 --root=<dir> --auth=anonymous`, URL pointant un **fichier**, ex. `http://127.0.0.1:8080/vault-sync.json`).
|
||||
- **ACTION** : sync (le distant porte `aa`) → changer le username en `C`, Save → fermer l'app → reculer `updated_at` de l'entrée sous la date du distant, directement en base → fermer la base → relancer → **sync sans ouvrir l'entrée**.
|
||||
- **ATTENDU** : le username revient à `aa`, toast `1 updated`. L'inverse (`updated_at` local plus récent) garde `C`.
|
||||
- **Piège n°1 — ne rien toucher entre les deux** : ouvrir l'entrée et sauver quoi que ce soit émet un PUT, et **tout PUT rebumpe `updated_at`**. Le recul manuel est alors annulé juste avant le sync et le local regagne : le test s'invalide lui-même.
|
||||
- **Piège n°2** : c'est bien `updated_at` qui arbitre ([app.sync.js:423](js/app.sync.js:423)), pas `created_at`.
|
||||
- **Corollaire** : quand le distant gagne, la branche PUT réécrit **toute** l'entrée. Une modification locale non poussée est donc perdue, pas fusionnée champ par champ — c'est le principe du last-write-wins par entrée.
|
||||
- **Éditeur ouvert pendant le sync** : il ne se rafraîchit **pas** en direct, délibérément (ça écraserait la saisie en cours). Le garde-fou est au Save — modale « Entry changed elsewhere », cf. A11. Fermer sans sauver puis rouvrir montre la version issue du merge.
|
||||
|
||||
### F6 · champ Notes multiligne — la forme survit ✅
|
||||
- **ACTION** : sur un **login**, *Add note* → saisir plusieurs lignes → Save → **rouvrir** l'entrée.
|
||||
- **ATTENDU** : toujours multiligne et **masqué** ; l'œil révèle. Le contenu conserve ses retours à la ligne.
|
||||
- **ACTION 2** : dupliquer l'entrée, puis export → import. **ACTION 3** : *Sync now* (aller-retour vérifié — pousser, supprimer localement, re-synchroniser pour faire revenir l'entrée du distant).
|
||||
- **Trois choses à vérifier séparément** : le contenu avec ses retours à la ligne, la clé `multiline` (rouvre en zone de texte), la clé `is_secret` (masqué, œil présent). Une perte se produirait à l'**ouverture** sur le device receveur, pas pendant le transfert — la sync porte `custom_fields` comme un blob opaque.
|
||||
- **ATTENDU** : le champ reste multiligne partout — rien à ajouter dans ces chemins, il voyage dans `custom_fields`.
|
||||
- **Pourquoi ce test existe** : les champs personnalisés sont **reconstruits clé par clé** à deux endroits, la normalisation à l'ouverture et la reconstruction au Save, et chacun **jette ce qu'il ne nomme pas**. `multiline` est donc porté explicitement dans les deux ; sans ça le champ revient sur une ligne à la première réouverture (le contenu survit, mais illisible). Même piège que `options` pour les combobox.
|
||||
- **Choix de conception** : pas de colonne `notes_enc` dédiée. Le corps d'une entrée *note* **est** `encrypted_password` ; un vrai champ notes aurait voulu une nouvelle colonne chiffrée et les 7 points de passage de CLAUDE.md. `custom_fields` est déjà porté par la rotation, la duplication, l'export/import et la sync.
|
||||
|
||||
---
|
||||
|
||||
## C — `withEncryptedMeta` sur tout write `/entries`
|
||||
|
||||
Test générique, à rejouer après **toute** modif touchant un body POST/PUT
|
||||
`/entries` : faire l'action, **relancer l'app** (pour relire depuis SQLite),
|
||||
vérifier que nom / site / username / tags s'affichent encore.
|
||||
|
||||
Chemins à couvrir : Save éditeur (neuf + existant), duplicate, import
|
||||
(bulk + overwrite), sync (`applyRemoteSnapshot`), `addTagToEntry`,
|
||||
`batchAddTag`, `moveEntryToFolder`, `moveEntriesToFolder`, `batchMoveToFolder`,
|
||||
`migrateMetadataAtRest` (au unlock), rotation du master password.
|
||||
|
||||
Symptôme du bug : après relance, lignes au nom vide / `(no name)`.
|
||||
|
||||
---
|
||||
|
||||
## D — Vue corbeille
|
||||
|
||||
### D4 · les cibles de dépôt n'acceptent que nos glissers ✅
|
||||
- **ACTION** : dans la **corbeille** (vue tableau), sélectionner du texte dans une cellule et le glisser sur un dossier, sur *(no folder)*, sur un tag, sur *Trash*.
|
||||
- **ATTENDU** : **aucune** cible ne s'allume. Les lignes de corbeille ne sont pas déplaçables (`draggable=false`), mais glisser du **texte** reste possible partout dans le navigateur.
|
||||
- **Historique** : les cibles s'allumaient sur n'importe quel glisser puis lisaient `text/plain` pour y trouver des identifiants — le texte d'une cellule donnait `NaN`, filtré, donc rien ne se passait : une invitation à déposer suivie d'aucune action. Nos glissers d'entrées portent désormais leur propre type `application/x-pm-entries` (comme les dossiers avec `application/x-pm-folder`), et les quatre cibles le vérifient **avant** de s'allumer.
|
||||
- **Non-régression** : glisser une carte ou une ligne (hors corbeille) sur un dossier, sur *(no folder)*, sur un tag et sur *Trash* fonctionne toujours, sélection multiple comprise.
|
||||
|
||||
### D5 · cocher une carte ne bouge ni la grille ni le défilement ✅
|
||||
- **ACTION** : liste longue → **défiler vers le bas** → cocher une carte (clic sur la case, ou `Ctrl`+clic).
|
||||
- **ATTENDU** : la position de défilement **ne bouge pas**, et la grille ne se décale pas verticalement. La case se coche, la barre de sélection apparaît.
|
||||
- **Tester les trois vues** : cartes, **liste** et **tableau**. Le décalage a survécu un premier correctif en vues liste et tableau seulement.
|
||||
- **Deux causes distinctes, corrigées ensemble** : (1) la barre de sélection dépassait la hauteur réservée par `.content-header` et poussait toute la grille. Fixer la hauteur de la **barre** n'a pas suffi : `min-height` sur l'en-tête n'est qu'un plancher, n'importe quel enfant plus haut fait encore grandir la rangée. L'en-tête a donc une hauteur **fixe** (`62px` = 24 de padding + 38 de barre ; `54px` sur écran étroit), ce qui rend tout débordement purement visuel. (2) `toggleChecked` appelait `renderGrid()`, qui reconstruit **toutes** les cartes — le conteneur de défilement repartait donc à zéro et l'utilisateur était renvoyé en haut de la liste. Le basculement se fait désormais **sur place** sur la seule carte concernée.
|
||||
- **À vérifier aussi** : le ✓ apparaît / disparaît (il est ajouté au rendu, pas par CSS), l'info-bulle bascule *Select* / *Deselect*, et le compteur de la barre est juste. Idem en vue **tableau** et en vue **liste**.
|
||||
|
||||
### D6 · l'espacement de l'en-tête ne change pas au défilement ✅
|
||||
- **ACTION** : liste longue, dans les **trois** vues → comparer l'écart entre le titre « All items » et la barre de pagination, en haut de liste puis après avoir défilé (en-tête collé).
|
||||
- **ATTENDU** : écart identique dans les deux cas, et aucune ligne visible **entre** les deux barres. En vue tableau, l'en-tête de colonnes reste garé juste sous la pagination.
|
||||
- **Historique** : l'écart était une **marge** basse, et `--hdr-h` — qui dit à la pagination où se garer — est mesuré avec `offsetHeight`, **qui ignore les marges**. Une fois collée, la pagination remontait donc de 20 px. Corriger la seule mesure aurait laissé une bande **transparente** traversée par les lignes ; passer la marge en **padding** la rend à la fois mesurable et opaque.
|
||||
|
||||
### D1 · menu kebab en table view ✅
|
||||
- **ACTION** : corbeille + vue table → kebab d'une ligne
|
||||
- **ATTENDU** : `Restore` / `Copy…` / `Delete forever` **uniquement**. Pas de Pin / Favorite / Edit / Duplicate / Move to trash.
|
||||
|
||||
### D2 · vues cartes et liste ✅
|
||||
- **ATTENDU** : boutons restore + purge (déjà le cas avant), pas de drag possible.
|
||||
|
||||
### D3 · clic sur une ligne de corbeille ✅
|
||||
- **ATTENDU** : ouvre *Trash actions*, **jamais** l'éditeur (une entrée supprimée ne doit pas pouvoir être sauvegardée).
|
||||
|
||||
---
|
||||
|
||||
## E — Auth / recovery / mémoire (série 2026-07-25)
|
||||
|
||||
Ces flux touchent la dérivation de clé et l'anti-lockout : tester sur un compte
|
||||
**argon2id-v2** (défaut courant). Rebuild **Delphi** requis (fixes serveur) +
|
||||
BuildAssets pour le JS.
|
||||
|
||||
### E1 · reauth — mauvais master pw reste inline (les 4 flux) ✅
|
||||
- **ACTION** : sur chacun de *Set/Change PIN*, *Enable Quick Unlock*,
|
||||
*Generate recovery code*, *Export* → taper un **mauvais** master pw.
|
||||
- **ATTENDU** : le modal **reste ouvert** avec « Wrong master password. Attempt
|
||||
N / 5. » ; au 5ᵉ échec ou 429 → toast lockout + fermeture. **Annuler** à tout
|
||||
moment = action abandonnée, rien fait.
|
||||
- **Historique** : PIN / Quick Unlock / recovery fermaient + toast au 1ᵉʳ essai.
|
||||
|
||||
### E2 · recovery setup sur compte argon2id-v2 ✅
|
||||
- **ACTION** : *Generate recovery code* → bon master pw.
|
||||
- **ATTENDU** : code généré, **pas** de « invalid password ».
|
||||
- **Historique** : `/recovery-key/setup` rejetait tout compte -v2 / argon2id
|
||||
(vérif serveur ne connaissait que pbkdf2 / pbkdf2-sha256).
|
||||
|
||||
### E3 · recovery generate → annuler n'écrit rien ✅
|
||||
- **PRÉ** : un code de recovery **existe déjà**.
|
||||
- **ACTION** : *Regenerate code* → bon master pw → à l'affichage du code,
|
||||
**Cancel** (ou Esc).
|
||||
- **ATTENDU** : toast « Cancelled — no recovery code was set » ; l'**ancien**
|
||||
code fonctionne toujours au redeem. Refaire avec **I saved it** → nouveau
|
||||
code actif, ancien invalidé.
|
||||
- **Historique** : le setup était POSTé avant l'affichage → annuler écrasait
|
||||
quand même l'ancien code (chemin silencieux vers le lockout).
|
||||
|
||||
### E4 · recovery redeem sur compte argon2id-v2 ✅
|
||||
- **ACTION** : générer un code, se déconnecter, *Use a recovery code* →
|
||||
username + code.
|
||||
- **ATTENDU** : déverrouillage OK, redirection vers *Change master password*.
|
||||
Le coffre se déchiffre (clé dérivée avec les **bons** params Argon2 renvoyés
|
||||
par le serveur).
|
||||
- **Historique** : la réponse redeem n'echoait pas `argon2` → params par
|
||||
défaut (correct aujourd'hui, lockout latent si params non-défaut).
|
||||
|
||||
### E8 · le mode de déverrouillage survit au redémarrage ✅
|
||||
- **ACTION** : créer un PIN → *Unlock method* = **PIN** → verrouiller (le PIN est bien demandé) → **fermer et relancer l'app**.
|
||||
- **ATTENDU** : le PIN est demandé, pas le mot de passe maître.
|
||||
- **Variantes** : mode `both` → mot de passe **puis** PIN au démarrage à froid ; retour en mode `pw` → mot de passe seul ; 5 PIN faux (le blob DPAPI est effacé) → relancer → retour au mot de passe, sans blocage.
|
||||
- **Historique** : `unlockMode` n'existait que dans `localStorage` — vidé à **chaque** lancement, le port éphémère changeant l'origine — et dans `settings_json`, illisible avant d'être connecté. Au démarrage à froid l'écran d'auth retombait donc toujours sur `'pw'`, alors que le blob PIN était bien présent. Écrit désormais aussi en pref DPAPI (`Bridge.setPref`) et relu par `showAuth`, comme `rememberedUsername`. **Le réglage est device-local par nature** (le blob PIN est lié à ce Windows) : il reste dans `settings_json` pour l'historique, mais c'est la copie DPAPI qui fait autorité au boot.
|
||||
- **Auto-correction** : si la pref dit `pin` alors que le blob n'existe plus, `applyAuthScreenMode` force `pw` — pas de verrouillage possible.
|
||||
|
||||
### E9 · aucun secret au repos dans la mémoire du renderer ✅
|
||||
Contrôle par dump mémoire, à refaire après toute modification touchant les prefs
|
||||
ou le déchiffrement.
|
||||
- **Mise en scène** : rebuild complet → lancer → **Quick Unlock** → **ne rien ouvrir** (aucune entrée, aucun réglage) → dumper le processus.
|
||||
- **Quel processus** : le *renderer*, pas le plus gros. Le Gestionnaire des tâches le nomme d'après l'onglet — « **WebView2 : Vault** » ; dans Process Explorer, ajouter la colonne *Command Line* et chercher `--type=renderer`. Le processus GPU est souvent 10× plus volumineux et ne contient **aucun** JavaScript : un scan qui n'y trouve rien ne prouve rien.
|
||||
- **Témoin de validité** : `soState` doit apparaître (~120 en UTF-16). À 0, ce n'est pas le renderer et le test est nul.
|
||||
- **ATTENDU** : `this_is_password` (mot de passe d'entrée) à **0** — le déchiffrement est à la demande — et le mot de passe d'auto-backup à **0**. Le **nom** de la pref (`autoBackupPwd`) apparaît, c'est un littéral du code source, pas sa valeur.
|
||||
- **Historique** : le mot de passe d'auto-backup était présent en clair dans cette exact mise en scène, visible dans `Bridge.onPrefResult("autoBackupPwd","…")` **et** dans les tampons du protocole DevTools — donc deux processus — et il **survivait au verrouillage automatique** qui efface pourtant la clé du coffre. Cause : `loadAutoBackupConfig` / `loadSyncConfig` récupéraient les secrets alors que l'interface n'a besoin que de savoir s'ils existent. Voir `prefs/has`.
|
||||
- **Limite assumée** : après un backup ou un sync **réellement exécuté**, le secret est de nouveau en mémoire — il faut bien le passer à la fonction de chiffrement. Ce qui est supprimé, c'est sa présence permanente sans qu'aucune opération ne l'ait demandée.
|
||||
- **Après le test** : supprimer les dumps. Ils contiennent des secrets en clair, sans chiffrement.
|
||||
- **Validation fonctionnelle obligatoire** — les **quatre** chemins qui lisent encore un secret, à repasser après toute modification de `loadAutoBackupConfig` / `loadSyncConfig` : *Backup now* **puis import du fichier produit** (le fichier seul ne prouve rien, cf. F2) · *Test connection* WebDAV **avec authentification** · *Sync now* (le compteur `updated` prouve que l'instantané distant a été **déchiffré**, pas seulement téléchargé) · le backup planifié au déverrouillage.
|
||||
- **Monter un WebDAV authentifié pour le test** : `python -m wsgidav.server.server_cli -c <config.json>` avec `simple_dc.user_mapping` et **`accept_basic: true`, `accept_digest: false`** — notre client n'envoie que du Basic. Le partage ne doit plus être marqué `(anonymous)` au démarrage. Un premier 401 suivi d'un 200 authentifié dans le journal est **normal** : WinHTTP n'envoie les identifiants qu'après le défi.
|
||||
- **Piège de saisie** : les champs identifiant / mot de passe n'enregistrent que sur `change` (perte du focus). Taper puis cliquer aussitôt sur *Test connection* peut lire la préférence avant son écriture — les deux passent par des navigations `cmd://`, qui se coalescent. Faire Tab avant de cliquer. **python -m wsgidav.server.server_cli -c "e:\webdav.json"**
|
||||
|
||||
### E5 · la clé du coffre n'est plus en clair dans le web storage ✅
|
||||
- **ACTION** : coffre déverrouillé → console WebView2 :
|
||||
`sessionStorage.getItem('cryptoKey')`.
|
||||
- **ATTENDU** : `null`. (Avant : une string base64 de 44 car = la clé AES.)
|
||||
- **Vérifié par le code, 2026-08-03** — la console est indisponible en prod (DevTools désactivé), mais le résultat est structurel : plus aucun `persistCryptoKey` / `restoreCryptoKey` (seul subsiste le commentaire qui documente leur retrait), et `sessionStorage` ne porte que `username`, `salt`, `kdfIterations`, `hashAlgo`, `authToken`, `csrfToken`, `argon2Params`. Ni sel ni itérations ne sont des secrets — `/login/challenge` les publie.
|
||||
- **`sessionStorage.removeItem('cryptoKey')` est volontaire** ([js/app.js](js/app.js)) : il efface le blob laissé par une **ancienne** version de l'app. Ne pas le supprimer en croyant à du code mort.
|
||||
- **Ne pas confondre avec E9** : celui-ci porte sur le web storage (fichiers sur disque), E9 sur la mémoire du processus. Une clé peut être absente de l'un et présente dans l'autre.
|
||||
- **Bonus** : dump mémoire du renderer → `this_is_password` peut persister
|
||||
(strings V8, non corrigeable) ; c'est la **clé** qu'on a retirée du storage.
|
||||
|
||||
### E6 · non-régression — les 4 exporteurs de clé marchent toujours ✅
|
||||
Après le retrait du cache sessionStorage, re-tester que ces flux (qui
|
||||
`exportKey` la clé) fonctionnent : **login**, **Quick Unlock**, **PIN**,
|
||||
**recovery redeem**, **change master password**.
|
||||
|
||||
### E7 · non-régression — éditeur slideover après suppression du modal mort ✅
|
||||
Le générateur, les tags et la barre de force étaient partagés. Vérifier dans
|
||||
l'éditeur slideover : créer + éditer une entrée, ajouter/retirer des tags,
|
||||
**Generate → Use** insère le mot de passe, la barre de force réagit.
|
||||
- **Bug trouvé par ce test** : la barre restait figée sur le mot de passe précédent. Affecter `.value` en JavaScript **ne déclenche aucun événement** ; le code appelait `soDirtyCheck()` à la main, mais la barre de force écoute `input` et n'était donc jamais prévenue.
|
||||
- **Correctif** : émettre `new Event('input')` sur le champ plutôt que d'appeler un gestionnaire nommé. Tous les auditeurs du champ sont couverts, y compris ceux qu'on y branchera plus tard — c'est le même piège chaque fois qu'un bouton remplit un champ à la place de l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## G — Vault health (série 2026-07-25)
|
||||
|
||||
### G1 · bouton *Fix* quand le changement d'entrée est annulé ✅
|
||||
- **ACTION** : `A` ouverte + **dirty** → aller sur *Vault health* → cliquer **Fix** sur une **autre** entrée `B` → **Cancel** au message discard.
|
||||
- **ATTENDU** : **rien ne bouge**. `A` reste ouverte, son mot de passe reste **masqué**, aucun clignotement du bouton Generate.
|
||||
- Puis refaire → **Discard** : `B` s'ouvre, c'est **le mot de passe de B** qui est démasqué + sélectionné, et c'est le Generate de `B` qui pulse.
|
||||
- **Le focus RESTE sur le mot de passe** (~1 s après le clic, vérifier que le curseur n'a pas sauté sur le titre) : `openSlideOver` programme un focus différé de 80 ms sur `#soTitle`, neutralisé ici par `{ noFocus: true }`.
|
||||
- **Historique** : `openSlideOver` ne renvoyait rien, donc `openEntryForFix` continuait après une annulation et démasquait en clair le mot de passe de l'entrée **restée ouverte**.
|
||||
|
||||
### G2 · *Fix* sur une entrée supprimée entre-temps — **sans objet**
|
||||
- **ACTION prévue** : ouvrir *Vault health* → mettre à la corbeille une entrée listée → revenir sur health sans *Recompute* → **Fix** sur cette entrée.
|
||||
- **Pourquoi c'est irréalisable** : G3 a rendu ce cas impossible à mettre en scène — le cache est invalidé à **chaque** mutation, donc revenir sur le tableau de bord recalcule toujours et l'entrée supprimée n'y figure plus. Le garde reste en place dans le code (`openEntryForFix` sort si `openSlideOver` renvoie `false`), il n'est simplement plus atteignable par l'interface.
|
||||
- **Tentative la plus proche, vérifiée** : cocher une entrée dans la liste (la barre de sélection apparaît) → passer en *Vault health* → *Delete* depuis cette barre. Le tableau de bord se **recalcule tout seul** et l'entrée disparaît de la liste : toujours pas de liste périmée.
|
||||
- **Défaut trouvé au passage** : la sélection **survivait au changement de vue**. Visible en santé / authenticator / journal d'audit, qui n'affichent aucune case à cocher tout en gardant la barre d'actions. **Bien plus grave vers la corbeille** : les identifiants cochés désignent des entrées **vivantes**, alors que la barre de la corbeille propose *Delete forever* — on pouvait donc purger définitivement des entrées qui n'y étaient jamais entrées. La sélection est maintenant vidée dès que la vue change, quelle qu'elle soit.
|
||||
|
||||
### G6 · la sélection ne franchit pas les vues ✅
|
||||
- **ACTION** : cocher deux entrées dans *All items* → passer à la **corbeille**.
|
||||
- **ATTENDU** : plus rien de coché, barre d'actions disparue. Surtout pas *Delete forever* actif sur une sélection d'entrées vivantes.
|
||||
- **Variantes** : vers un dossier, un tag, *Notes*, *Recently used*, *Vault health*, *Authenticator*, *Audit log* — même résultat partout. Et au retour dans la vue d'origine, la sélection reste vide (elle n'est pas mémorisée).
|
||||
|
||||
### G5 · activer / désactiver HIBP depuis la vue santé ✅
|
||||
- **ACTION** : dans *Vault health*, activer la vérification HIBP → attendre la fin du scan **sans quitter la vue**.
|
||||
- **ATTENDU** : la carte *Pwned* et le score se mettent à jour **tout seuls** quand le scan finit.
|
||||
- **ACTION 2** : désactiver HIBP → la catégorie *Pwned* retombe à 0 immédiatement.
|
||||
- **ACTION 3** : relancer un scan et **désactiver HIBP en plein milieu**.
|
||||
- **ATTENDU** : la pastille de progression (en bas, à droite de la barre latérale) **disparaît aussitôt** ; plus aucune requête réseau. Elle continuait à compter après « Breach check disabled », le scan poursuivant un aller-retour réseau par entrée.
|
||||
- **Historique** : le cache santé était calculé **avant** l'arrivée des résultats et rien ne l'invalidait ensuite. La vue affichait `0 pwned` pendant que les cartes de la liste portaient déjà le drapeau ; il fallait sortir et revenir pour voir `2`. Symétrique à la désactivation, qui vidait `hibpResults` en laissant le tableau de bord compter des brèches disparues. `hibpCheckAllEntries` et le toggle invalident désormais `healthCache`.
|
||||
|
||||
### G4 · le score bouge par paliers de 10 — comportement attendu
|
||||
- **OBSERVÉ** : après correction de deux mots de passe, `30 → 40 → 50`.
|
||||
- **C'EST NORMAL** : le barème est `-5` par mot de passe **faible**, `-10` par **groupe de réutilisation**, `-2` par entrée **ancienne**, `-15` par entrée **compromise**. Corriger un mot de passe partagé par deux entrées casse le groupe → `+10`.
|
||||
- **Effet de bord à connaître** : chaque catégorie est **plafonnée** (`-40` faible, `-30` réutilisé, `-20` ancien, `-50` compromis). Au-delà de 8 mots de passe faibles, en corriger un ne change **rien** au score tant qu'on n'est pas repassé sous le plafond. Le plafond existe pour qu'une seule catégorie ne puisse pas écraser les autres, mais il rend les premières corrections invisibles quand le coffre en compte beaucoup.
|
||||
|
||||
### G3 · le tableau de bord reflète les mutations ✅
|
||||
- **ACTION** : ouvrir *Vault health* (noter le score) → revenir à la liste → faire une mutation : **import**, **sync**, **suppression**, **batch delete**, **duplicate**, **restore**, ou **changement de master pw** → rouvrir *Vault health*.
|
||||
- **ATTENDU** : score et listes **recalculés** (les entrées supprimées ont disparu, les importées apparaissent) **sans** cliquer *Recompute*.
|
||||
- **Historique** : le cache n'était vidé qu'au lock / au save / via *Recompute* — après un import ou une suppression le dashboard restait figé sur l'état précédent.
|
||||
|
||||
---
|
||||
|
||||
## H — Hotkey 2FA (`Ctrl+Shift+T`, série 2026-07-25)
|
||||
|
||||
### H1 · remplissage simple ✅
|
||||
- **ACTION** : entrée avec TOTP → ouvrir son site → **cliquer le champ 2FA** → `Ctrl+Shift+T`.
|
||||
- **ATTENDU** : le code à 6 chiffres est tapé dans le champ focalisé, toast « 2FA code filled: … ».
|
||||
|
||||
### H2 · entrée sans TOTP ✅
|
||||
- **ACTION** : fenêtre dont le titre matche une entrée **sans** secret TOTP → `Ctrl+Shift+T`.
|
||||
- **ATTENDU** : « No 2FA code stored for "…" » (et **pas** le « no match » générique, ni un remplissage du mot de passe).
|
||||
|
||||
### H3 · picker multi-match ✅
|
||||
- **PRÉ** : 2 entrées matchent le titre, **une seule** a un TOTP.
|
||||
- **ATTENDU** : le picker s'ouvre uniquement s'il reste ≥ 2 candidats **avec** TOTP ; ici il ne doit pas s'ouvrir (1 seul candidat éligible → remplissage direct). En-tête du picker : « Pick entry (2FA code) — ».
|
||||
|
||||
### H4 · combo configurable ✅
|
||||
- **ACTION** : Settings → Autofill → cliquer le bouton `Ctrl+Shift+T` → capturer un autre combo.
|
||||
- **ATTENDU** : accepté et actif immédiatement ; **refusé** s'il duplique un des 4 autres hotkeys ; *Reset to defaults* le remet à `Ctrl+Shift+T` ; le réglage survit à un redémarrage (synced via `settings_json`).
|
||||
|
||||
### H5 · coffre verrouillé ✅
|
||||
- **ACTION** : verrouiller → `Ctrl+Shift+T`, puis **`Ctrl+Shift+Q`**, depuis une autre application (Notepad).
|
||||
- **ATTENDU** : l'app revient au premier plan **et y reste**. Pas de no-op silencieux, et **aucun clignotement** : elle ne doit pas apparaître puis rendre la main à l'application de départ.
|
||||
- **Où atterrit le curseur** : sur l'**identifiant** s'il est vide, sinon sur le mot de passe — même règle pour les quatre hotkeys. La quick search forçait le mot de passe, ce qui plaçait le curseur dans le second champ lors d'un premier déverrouillage ou sans identifiant mémorisé.
|
||||
- **Historique** : les deux chemins « coffre verrouillé » annulaient l'autofill avec le refocus **par défaut**, qui rend volontairement le premier plan à la fenêtre cible — juste après que Delphi ait affiché notre fenêtre. Sur `Ctrl+Shift+Q` ça se voyait comme un flash d'une frame avant le retour à Notepad ; sur `Ctrl+Shift+L` le `focusApp()` qui suit reprenait le focus, donc deux déplacements de fenêtre pour finir là où un seul suffit. Les deux passent maintenant `refocus=false` (cf. B17 : **un refus ne doit jamais déplacer une fenêtre**).
|
||||
|
||||
### H9 · retour au tray après un pick dans le picker ✅
|
||||
- **ACTION** : app **au tray** → `Ctrl+Shift+L` depuis une autre app sur un titre à plusieurs candidats → le picker sort la fenêtre du tray → choisir une entrée.
|
||||
- **ATTENDU** : le remplissage se fait puis l'app **repart au tray**, comme elle est venue.
|
||||
- **Variantes** : match unique (pas de picker) → reste au tray ; app déjà visible → reste visible ; app **minimisée dans la barre des tâches** → n'est **pas** envoyée au tray (`not Self.Visible` est faux pour une fenêtre iconique) ; picker fermé par Esc sans choisir → l'app **repart au tray** elle aussi (symétrique de quick-search, qui se cache sur Esc) et le hotkey suivant n'hérite pas de l'état capturé.
|
||||
- **Historique** : seul le chemin quick-search capturait « était cachée » (`LWasHidden`) et le faisait remonter via `hide_after` ; JS n'envoie jamais ce paramètre depuis le picker d'autofill, donc l'app restait sur le bureau. Capturé désormais côté Delphi dans `BridgeAutofillRequest`, au moment du hotkey — après, le picker a déjà détruit l'information.
|
||||
|
||||
### H10 · le focus revient à l'application précédente ✅
|
||||
Cacher une fenêtre ne transfère le premier plan à personne (contrairement à une
|
||||
minimisation). On restait donc au premier plan **en étant invisible** : le
|
||||
hotkey suivant capturait **notre propre fenêtre** comme cible et tapait le mot
|
||||
de passe dedans, et `Ctrl+Shift+A` ouvrait un « New entry » au titre vide (le
|
||||
garde refusait, à raison, de pré-remplir avec « Password Manager »).
|
||||
- **ACTION** : depuis une application X → ouvrir l'app (icône du tray, hotkey, ou lancement à froid) → fermer par la croix.
|
||||
- **ATTENDU** : X reprend le focus. Puis un hotkey d'autofill depuis X doit cibler X, et `Ctrl+Shift+A` pré-remplir son titre.
|
||||
- **ACTION 2** : enchaîner plusieurs allers-retours en changeant d'application entre deux — c'est **toujours la dernière réellement utilisée** qui récupère le focus.
|
||||
- **Non-régression** : après un autofill (pick ou Esc), le focus reste sur la fenêtre **remplie** — ces deux chemins passent `AReturnForeground = False`, ayant déjà placé le premier plan eux-mêmes.
|
||||
- **Historique — quatre tentatives, toutes tranchées par le log** : (1) rendre le focus *après* avoir caché la fenêtre — Windows n'autorise que le propriétaire courant du premier plan à le céder, l'appel était silencieusement refusé ; (2) échantillonner le premier plan au moment du besoin — il vaut alors *notre* fenêtre, la capture se rejetait elle-même et n'apprenait jamais rien (`prev=<none>` à chaque fois) ; (3) capturer au clic sur l'icône du tray — on obtient une fenêtre du **shell** (`NotifyIconOverflowWindow`, « Fenêtre de dépassement de capacité de la barre d'état système ») ; (4) consommer la valeur après usage — le premier masquage marchait, les suivants plus. **Solution** : un hook `EVENT_SYSTEM_FOREGROUND` (`WINEVENT_OUTOFCONTEXT`, aucune injection) note *chaque* changement et garde le dernier qui n'est ni à nous ni au shell ; la restitution se fait **avant** de cacher.
|
||||
|
||||
### H13 · pas de `Ctrl+A` après le Tab (formulaires type Reddit) ✅
|
||||
- **ACTION** : `Ctrl+Shift+L` sur un formulaire où **Tab depuis le username ne mène pas au champ mot de passe** (page de connexion Reddit : le Tab tombe à côté).
|
||||
- **ATTENDU** : plus de **sélection de toute la page**. Le mot de passe, lui, n'est toujours pas saisi sur ce formulaire — et ce n'est pas corrigeable : **vérifié au Tab manuel, sans autofill**, le Tab depuis le username va sur le **bouton œil** (révéler le mot de passe), que Reddit place avant le champ dans l'ordre de tabulation. On envoie des frappes sans jamais voir où le focus atterrit.
|
||||
- **Contournement, seul chemin fiable sur ces formulaires** : cliquer le champ mot de passe puis `Ctrl+Shift+P` (password seul, aucun Tab, aucune supposition) — **testé sur Reddit, fonctionne**.
|
||||
- **Ne pas « corriger » en envoyant deux Tab** : l'ordre de tabulation dépend de chaque site, un second Tab casserait tous les formulaires normaux.
|
||||
- **Historique** : la séquence envoyait `Ctrl+A + Del` avant **chaque** champ. Après le Tab c'était doublement mauvais : redondant (arriver par Tab sélectionne déjà tout le contenu du champ, cf. K1) et destructeur quand le Tab tombe sur un lien, un bouton ou le document — `Ctrl+A` sélectionne alors la page entière et la frappe se perd. Retiré ; celui d'avant le username reste (l'utilisateur y a placé le curseur par un clic, rien n'est sélectionné).
|
||||
|
||||
### H11 · l'application précédente réduite reste réduite ✅
|
||||
- **ACTION** : depuis X, **réduire X** dans la barre des tâches → ouvrir le coffre (icône du tray) → le fermer par la croix.
|
||||
- **ATTENDU** : X **reste réduite**. Le focus part où Windows décide ; on ne rouvre pas une fenêtre que l'utilisateur a rangée.
|
||||
- **Pourquoi c'est fragile** : `ForceForegroundWindow` fait `ShowWindow(SW_RESTORE)` sur une cible iconique — il est écrit pour l'autofill, où sortir la fenêtre cible est justement le but. Réutilisé tel quel pour la restitution du focus, il changeait l'état de la fenêtre d'une autre application. Garde `not IsIconic` posé côté `MinimizeToTray`, pas dans `ForceForegroundWindow` (l'autofill en a toujours besoin).
|
||||
|
||||
### H12 · démarrage `-tray` — l'application qui lance garde le focus ✅
|
||||
- **ACTION** : depuis une console PowerShell, lancer `.\PMServer.exe -tray`.
|
||||
- **ATTENDU** : le coffre démarre caché **et la console garde son focus** (rectangle actif dans la barre des tâches).
|
||||
- **ACTION 2** : « Start with Windows » / redémarrage → rien ne passe au premier plan.
|
||||
- **Historique** : d'abord codé « rien à rendre, on n'a rien pris ». Faux : FMX affiche son proxy de barre des tâches avant qu'on le cache, donc le premier plan **est** pris un instant, et la console lançeuse restait inactive. La valeur utile est `GStartupForeground`, capturée à l'initialisation de l'unité — avant toute fenêtre à nous. Au démarrage de Windows, le premier plan est une fenêtre du shell, que la capture filtre : rien n'est activé, ce qui est le comportement voulu là.
|
||||
- **Les quatre réglages de `MinimizeToTray`** (`AReturnForeground`) : fermeture par la croix = **rend** ; quick-search copie-puis-cache = **rend** (c'est ce qui permet le Ctrl+V dans l'app d'origine) ; démarrage `-tray` = **rend** ; autofill et annulation du picker = **ne rendent pas**, seuls cas où le focus a déjà été placé volontairement (sur la fenêtre remplie).
|
||||
|
||||
### H7 · combo déjà pris par une autre application ✅
|
||||
- **ACTION** : assigner à un hotkey un combo qu'une autre app tient déjà en global (ex. `Ctrl+Shift+Q` si Quick Assist / un lanceur l'occupe ; sinon, tester en assignant le même combo dans deux apps).
|
||||
- **ATTENDU** : toast **nommant le combo refusé** — « Already used by another app: 2FA code — pick another combo » — **et** un ⚠ ambre persistant sur cette ligne de Settings, **et** une pastille ambre sur l'entrée *Settings* de la sidebar (seul indice hors du panneau). Le toast d'avertissement reste ~10 s et se ferme au clic. Les **autres** hotkeys restent fonctionnels (enregistrement best-effort, pas de tout-ou-rien).
|
||||
- **ACTION 2** : fermer l'app concurrente → *Reset to defaults* (ou re-capturer le combo) → le hotkey redevient actif **et le ⚠ disparaît**.
|
||||
- **Piège de test** : une app qui capture via un *hook* bas niveau (et non `RegisterHotKey`) avale la frappe sans faire échouer notre enregistrement → hotkey inerte **sans** ⚠. Utiliser AutoHotkey (`^+t::MsgBox`) ou Lightshot pour un vrai conflit.
|
||||
- **Historique** : le message disait « one or both hotkeys » (faux depuis qu'il y en a 3) sans dire lequel ; et un échec sur **Quick search** ou **New entry** n'était écrit que dans le panel debug — invisible en prod, le hotkey semblait juste cassé.
|
||||
|
||||
### H8 · capturer un combo déjà pris par une autre app ✅
|
||||
- **ACTION** : Settings → cliquer le bouton du hotkey 2FA → presser un combo qu'une autre app détient (ex. `Ctrl+Shift+T`).
|
||||
- **ATTENDU** : **au relâchement** (pas après un délai), toast « No key registered after Ctrl+Shift. If you did press one, another app already owns that combo — pick a different one. » **et** le bouton reprend son libellé précédent.
|
||||
- **ACTION 2** : presser un combo libre (ex. `Ctrl+Win+T`) → accepté, actif immédiatement, pas de ⚠.
|
||||
- **ACTION 3** : cliquer le bouton puis ne rien presser pendant 10 s → « Hotkey capture cancelled — no key was pressed. » et le bouton se restaure (sinon la capture avalerait toutes les frappes jusqu'à Échap).
|
||||
- **ACTION 4** : cliquer le bouton puis presser un combo **déjà assigné à un autre de nos slots** (ex. `Ctrl+Shift+L`) → « That combo is already used by another hotkey ». Doit fonctionner **sans** déclencher l'autofill : nos 5 hotkeys sont libérés pendant la capture.
|
||||
- **Historique** : un combo tenu par `RegisterHotKey` (le nôtre **ou** celui d'une autre app) n'atteint jamais WebView2 → la capture restait figée sur « Press combo… » sans message, et la vérif de collision interne était du code mort.
|
||||
|
||||
### H6 · code en fin de période ✅
|
||||
- **ACTION** : presser le hotkey juste avant la fin du compte à rebours de 30 s.
|
||||
- **ATTENDU** : le code tapé est celui de la période **courante** au moment de la frappe (généré à la demande, pas mis en cache).
|
||||
|
||||
---
|
||||
|
||||
## I — État de la fenêtre (maximisé, série 2026-07-25)
|
||||
|
||||
L'app démarre maximisée (`WindowState = wsMaximized` dans la `.fmx`).
|
||||
|
||||
### I1 · maximisé survit à Show desktop ✅
|
||||
- **ACTION** : fenêtre **maximisée** → `Win+D` (ou *Show desktop*) → revenir à l'app (barre des tâches, tray ou Alt+Tab).
|
||||
- **ATTENDU** : elle revient **maximisée**, **sans passer par une frame en taille normale**.
|
||||
- **Historique** : FMX oublie l'état maximisé quand Windows minimise la fenêtre de l'extérieur, et aucun de nos chemins de restore ne tourne pour un clic sur la barre des tâches.
|
||||
- **Historique (flash)** : une fois l'état final correct, il restait un scintillement — le cadre apparaissait en taille normale puis grandissait. **C'est un bug FMX**, reproduit sur une application FMX vide : FMX perd le maximisé quand Windows minimise le formulaire de l'extérieur. Windows choisit alors `rcNormalPosition` comme cible **avant** d'animer, donc aucune correction réactive ne peut arriver à temps — trois ont été essayées et rejetées par la mesure : `ForceQueue` (un tour de boucle trop tard), appel inline dans `MINIMIZEEND` (le hook est délivré après le `WM_SIZE`), correction dans le `WM_SIZE` lui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poser `WPF_RESTORETOMAXIMIZED`, que `GetWindowPlacement` rapporte déjà à 2 sur une fenêtre maximisée (no-op).
|
||||
**Statut : non corrigé, assumé.** Une cinquième piste a été tentée puis **annulée** : garer `rcNormalPosition` et le remplacer par `rcWork` du moniteur pendant que la fenêtre est en bas, pour que « restaurer en normal » vaille déjà plein écran. Ça supprimait bien le flash, mais tout `SetWindowPlacement` sur une fenêtre en cours de minimisation — ou déjà iconique — laissait **un moignon de fenêtre dans un coin de l'écran**, y compris en différant l'écriture jusqu'à `IsIconic`. Un artefact visible permanent est pire que l'animation. Reverté.
|
||||
**Ce qui reste** : l'état final est correct (la fenêtre revient bien maximisée), seule l'animation de restauration est laide. Ne pas rouvrir sans une vraie raison — la seule route restante est de sous-classer le `WndProc` de la fenêtre FMX pour corriger le rectangle dans `WM_WINDOWPOSCHANGING`, ce qui insère du code dans la boucle de messages d'une fenêtre qu'on ne contrôle pas, avec le tray, l'autofill et le démarrage `-tray` en aval.
|
||||
|
||||
### I2 · normal survit aussi ✅
|
||||
- **ACTION** : dé-maximiser la fenêtre → `Win+D` → revenir.
|
||||
- **ATTENDU** : elle reste en **taille normale** — le correctif ne doit jamais forcer le maximise.
|
||||
|
||||
### I3 · maximiser / dé-maximiser reste enregistré ✅
|
||||
- **ACTION** : dé-maximiser → `Win+D` → retour (normal) → maximiser → `Win+D` → retour.
|
||||
- **ATTENDU** : chaque retour respecte le **dernier geste** de l'utilisateur.
|
||||
|
||||
### I4 · retour depuis le tray ✅
|
||||
- **ACTION** : fenêtre maximisée → fermer (part au tray) → clic sur l'icône du tray.
|
||||
- **ATTENDU** : revient maximisée (chemin distinct : `RestoreFromTray` rejoue son propre placement).
|
||||
|
||||
### I5 · le garde de minimisation ne reste pas bloqué (autofill → tray) ✅
|
||||
Le chemin le plus subtil : `ExecuteAutofill` **minimise réellement** la fenêtre
|
||||
(`SW_MINIMIZE`) avant de taper, ce qui lève le garde ; si elle est ensuite
|
||||
**cachée** dans le tray au lieu d'être restaurée, `MINIMIZEEND` peut ne jamais
|
||||
arriver.
|
||||
- **ACTION** : depuis le tray, `Ctrl+Shift+Q` → choisir une entrée → laisser le
|
||||
remplissage se faire (l'app repart au tray) → rouvrir l'app par l'icône du
|
||||
tray → **dé-maximiser** (ou maximiser) → `Win+D` → revenir.
|
||||
- **ATTENDU** : le dernier geste est respecté. S'il ne l'est pas, le garde est
|
||||
resté levé et le suivi de l'état est gelé pour toute la session.
|
||||
- **Variante** : même chose avec `Ctrl+Shift+L` depuis une autre application.
|
||||
|
||||
### I6 · le hook n'affecte pas la corbeille ni le tray (non-régression) ✅
|
||||
- **ACTION** : minimiser dans le tray (fermeture), restaurer ; `app/focus` via
|
||||
le picker d'autofill multi-match ; « Lock vault » depuis le menu du tray.
|
||||
- **ATTENDU** : comportements inchangés. Le hook n'écoute que **notre** process
|
||||
et `MinimizeToTray` utilise `SW_HIDE`, qui n'émet aucun événement de
|
||||
minimisation.
|
||||
|
||||
### Diagnostic
|
||||
Avec `config.txt` contenant `debug=true`, `Ctrl+Shift+D` affiche le journal.
|
||||
Lignes utiles : `[winstate] minimise hook installed=True` au démarrage (un
|
||||
`False` signifierait que le hook Win32 ne s'installe pas → préservation
|
||||
inactive) et `[winstate] re-maximising after restore` quand la correction agit.
|
||||
|
||||
---
|
||||
|
||||
## J — Quick search depuis le tray (série 2026-07-25)
|
||||
|
||||
Le flux le plus fragile : la fenêtre revient du tray, un modal s'ouvre, et la
|
||||
fermeture doit **renvoyer l'app au tray**. Deux `cmd://` s'y enchaînent, ce qui
|
||||
est précisément le piège de coalescence documenté dans CLAUDE.md.
|
||||
|
||||
### J1 · Esc dès l'ouverture, sans cliquer ✅
|
||||
- **PRÉ** : app **dans le tray**, coffre déverrouillé.
|
||||
- **ACTION** : `Ctrl+Shift+Q` → **ne rien cliquer** → `Esc` immédiatement.
|
||||
- **ATTENDU** : le modal se ferme **du premier coup**, et l'app **repart au tray**.
|
||||
- **Historique** : deux bugs distincts ici. (1) WebView2 n'avait pas le focus
|
||||
clavier de l'OS après la restauration → aucune frappe n'atteignait la page,
|
||||
Esc ne faisait rien tant qu'on n'avait pas cliqué dans le champ. (2) En mode
|
||||
fill, `cancelAutofill` puis `minimizeToTray` = deux `window.location.href`
|
||||
consécutifs → **coalescés**, la minimisation était perdue.
|
||||
|
||||
### J2 · remplissage puis retour au tray ✅ ✅
|
||||
- **ACTION** : app dans le tray → `Ctrl+Shift+Q` → choisir une entrée (Enter).
|
||||
- **ATTENDU** : le mot de passe est tapé dans la fenêtre cible **et** l'app
|
||||
repart au tray — **une seule fois** (c'est Delphi qui minimise ici, via
|
||||
`hide_after`, pas le JS : vérifier qu'il n'y a pas de double minimisation).
|
||||
- **Variantes** : `Shift+Enter` (user+pwd), `Ctrl+Enter` (username seul).
|
||||
|
||||
### J3 · copie depuis le menu du tray ✅
|
||||
- **ACTION** : menu du tray → *Quick search…* → choisir une entrée (Enter).
|
||||
- **ATTENDU** : mot de passe copié, app de retour au tray, et le presse-papiers
|
||||
**conserve** la valeur (`keepclip=1` — c'est le rôle du paramètre).
|
||||
|
||||
### J4 · app déjà visible (non-régression) ✅
|
||||
- **ACTION** : app **ouverte à l'écran** → `Ctrl+Shift+Q` → `Esc`.
|
||||
- **ATTENDU** : le modal se ferme et l'app **reste à l'écran** — elle ne doit
|
||||
**pas** partir au tray (elle n'en venait pas). Idem si elle était seulement
|
||||
minimisée dans la barre des tâches.
|
||||
|
||||
---
|
||||
|
||||
## K — Autofill : comportements intentionnels (à ne pas « corriger »)
|
||||
|
||||
### K1 · « Clear the field before typing » désactivé → le password est quand même remplacé
|
||||
- **ACTION** : Settings → Autofill → décocher *Clear the field before typing*.
|
||||
Puis remplissage complet (`Ctrl+Shift+L`, ou clic-droit dans la quick search)
|
||||
sur un formulaire dont les deux champs contiennent déjà quelque chose.
|
||||
- **OBSERVÉ** : le **username** n'est pas effacé (la frappe s'insère), mais
|
||||
l'ancien **password** disparaît et est remplacé.
|
||||
- **C'EST NORMAL** — vérifié de bout en bout : le JS envoie `&clear=0`, Delphi
|
||||
met `FAutofillPendingClear := False`. Notre code n'efface rien. (Depuis le
|
||||
correctif H13 il n'y a de toute façon **plus qu'un seul** `ClearFieldIfWanted`,
|
||||
avant le username : celui d'avant le password a été retiré.)
|
||||
La cause est le **Tab** du pipeline `username → Tab → password` : arriver
|
||||
dans un champ par Tab **sélectionne tout son contenu** (comportement standard
|
||||
Windows / navigateur), et la frappe remplace la sélection. Le username, lui,
|
||||
a été focalisé par un **clic** de l'utilisateur → curseur sans sélection →
|
||||
insertion.
|
||||
- **Ce que le réglage fait réellement** : empêcher l'envoi de `Ctrl+A`. Il
|
||||
existe pour les terminaux / bureaux distants où `Ctrl+A` signifie « début de
|
||||
ligne » ou déclenche le préfixe tmux, ce qui casserait le remplissage. Il ne
|
||||
promet pas de préserver le contenu des champs.
|
||||
- **Ne pas « corriger »** : désélectionner après le Tab ferait **concaténer** le
|
||||
nouveau mot de passe à l'ancien — jamais souhaitable.
|
||||
- **Test qui isole la cause** : `Ctrl+Shift+P` (password seul) en cliquant
|
||||
directement dans le champ → pas de Tab → l'ancienne valeur **reste**.
|
||||
|
||||
---
|
||||
|
||||
## L — Démarrage avec Windows (`-tray`, série 2026-07-26)
|
||||
|
||||
Le flag `-tray` est ce que « Start with Windows » écrit dans `HKCU\…\Run`
|
||||
(`"<exe>" -tray`). Pour tester sans redémarrer : `PMServer.exe -tray` depuis le
|
||||
dossier de sortie. **Ne pas** laisser `-tray` dans *Run → Parameters* de l'IDE,
|
||||
sinon chaque F9 démarre caché.
|
||||
|
||||
### L1 · démarrage caché, sans bouton de barre des tâches ✅
|
||||
- **ACTION** : lancer avec `-tray` (l'app n'étant pas déjà lancée — sinon le
|
||||
mutex single-instance fait sortir la nouvelle instance immédiatement).
|
||||
- **ATTENDU** : **aucune fenêtre**, **aucun bouton** dans la barre des tâches,
|
||||
icône présente dans le tray, pas de balloon.
|
||||
- **Historique** : le bouton restait 1-2 s. La fenêtre du formulaire n'était en
|
||||
fait **jamais** visible — c'est le proxy taskbar FMX (`ApplicationHWND`) qui
|
||||
s'affichait, et **avant** même que `FormCreate` soit atteint, ce qui rendait
|
||||
inopérant tout correctif posé depuis le formulaire. Le masquer ne suffit pas
|
||||
non plus : il faut retirer `WS_EX_APPWINDOW` et poser `WS_EX_TOOLWINDOW`,
|
||||
depuis le `.dpr`, juste après `Application.Initialize`.
|
||||
|
||||
### L2 · le bouton revient à la restauration ✅
|
||||
- **ACTION** : après L1, clic sur l'icône du tray.
|
||||
- **ATTENDU** : la fenêtre s'ouvre **et** le bouton réapparaît dans la barre
|
||||
des tâches.
|
||||
- **Historique** : `WS_EX_TOOLWINDOW` est permanent — sans le rétablissement
|
||||
(`TrayProxyShowInTaskbar` dans `RestoreWindowFromTray`) l'app tournait
|
||||
ensuite sans aucune entrée de barre des tâches.
|
||||
|
||||
### L3 · lancement normal (non-régression) ✅
|
||||
- **ACTION** : lancer **sans** `-tray`.
|
||||
- **ATTENDU** : fenêtre visible et bouton de barre des tâches présents, comme
|
||||
avant. Le correctif est entièrement gardé par le flag.
|
||||
|
||||
---
|
||||
|
||||
## Reste à traiter (constaté pendant l'audit, non corrigé)
|
||||
|
||||
1. **Attachments hors garde A11.** Le contrôle « changed elsewhere » couvre le
|
||||
payload de l'entrée, pas ses pièces jointes (table séparée, écrites par
|
||||
leurs propres endpoints). Un sync qui remplace une pièce jointe pendant
|
||||
l'édition n'est pas signalé. Faible risque : l'éditeur ne re-ship jamais
|
||||
les pièces jointes existantes, donc il n'y a rien à écraser.
|
||||
|
||||
2. **La corbeille n'est jamais exportée, et rien ne le dit.** `doExport`
|
||||
parcourt `state.entries` ; la corbeille vit dans `state.trashed`, une liste
|
||||
séparée — donc absente de l'export manuel, de l'auto-backup **et** du
|
||||
snapshot de sync. Le choix se défend (la corbeille est une antichambre de
|
||||
suppression), mais il est **silencieux** : exporter → vider le coffre →
|
||||
réimporter fait disparaître définitivement les entrées en corbeille, alors
|
||||
que l'utilisateur croyait faire une sauvegarde complète.
|
||||
- **Correctif minimal** : une ligne dans le dialogue d'export — « la
|
||||
corbeille n'est pas incluse ». Une phrase, aucun risque.
|
||||
- **Correctif complet** : exporter les entrées supprimées avec leur
|
||||
`deleted_at` et les réimporter en corbeille. Nouveau champ à porter de
|
||||
bout en bout (parseur, `encryptImportEntry`, INSERT create **et**
|
||||
bulk-import), plus le comportement à choisir quand l'entrée existe déjà
|
||||
côté vivant. C'est la classe de modification aux 7 points de passage
|
||||
décrite dans CLAUDE.md.
|
||||
- Constaté le 2026-08-03 en testant export → *Clear vault* → import.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
unit PMA.Biometric;
|
||||
{
|
||||
Fine couche au-dessus de FMX.BiometricAuth.
|
||||
|
||||
On passe par le service FMX plutot que par BiometricPrompt en JNI direct :
|
||||
le rappel de BiometricPrompt exige une classe Java implementant une
|
||||
interface, donc un .jar a compiler et embarquer. FMX fournit deja ce pont.
|
||||
|
||||
MODELE RETENU : la cle du Keystore est creee avec une validite APRES
|
||||
authentification (quelques dizaines de secondes). L'utilisateur s'authentifie,
|
||||
puis la cle devient utilisable pendant ce delai. C'est plus simple qu'un
|
||||
CryptoObject lie a chaque operation, et suffisant ici : la fenetre est courte
|
||||
et l'application est au premier plan pendant ce temps.
|
||||
|
||||
L'APPEL DOIT SE FAIRE SUR LE THREAD PRINCIPAL - il affiche un dialogue
|
||||
systeme. Le travail lourd (dechiffrement) part ensuite dans un thread.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils;
|
||||
|
||||
type
|
||||
/// boUnavailable : l'appareil ne PEUT PAS authentifier - aucune empreinte,
|
||||
/// aucun code. La cle sous authentification est alors definitivement
|
||||
/// inutilisable, et le cache qu'elle protege aussi.
|
||||
/// boFailed : la tentative a echoue pour une autre raison, y compris une
|
||||
/// exception de la couche Java. Ne dit RIEN sur l'appareil, donc ne
|
||||
/// justifie jamais de detruire quoi que ce soit.
|
||||
TBioOutcome = (boOK, boUnavailable, boFailed, boCancelled);
|
||||
|
||||
/// L'appareil peut-il authentifier (empreinte, visage, ou code de
|
||||
/// verrouillage) ? False signifie qu'aucune protection n'est configuree.
|
||||
function BiometricAvailable: Boolean;
|
||||
|
||||
/// Pourquoi la derniere tentative a echoue. Vide apres un succes.
|
||||
///
|
||||
/// Existe parce qu'un echec avale sinon sa propre cause : un TBioOutcome ne
|
||||
/// distingue pas « l'appareil n'a aucune protection » de « la couche Java a
|
||||
/// leve quelque chose », et l'appelant prend des decisions opposees dans les
|
||||
/// deux cas - dont celle d'effacer le cache.
|
||||
function LastBiometricError: string;
|
||||
|
||||
/// Demande l'authentification puis appelle AThen. Non bloquant : le resultat
|
||||
/// arrive par le rappel, sur le thread principal.
|
||||
/// Si l'appareil ne sait pas authentifier, AThen est appele avec boUnavailable
|
||||
/// SANS refuser l'acces - a l'appelant de decider. Un telephone sans
|
||||
/// verrouillage ne peut de toute facon pas proteger la cle.
|
||||
procedure AuthenticateThen(const APrompt: string; const AThen: TProc<TBioOutcome>);
|
||||
|
||||
implementation
|
||||
|
||||
// La clause uses de l'implementation doit venir AVANT toute declaration :
|
||||
// placee apres, Delphi ne s'en plaint qu'a la derniere ligne du fichier.
|
||||
{$IF DEFINED(ANDROID)}
|
||||
uses
|
||||
FMX.BiometricAuth, FMX.Platform;
|
||||
{$ENDIF}
|
||||
|
||||
var
|
||||
FLastError: string = '';
|
||||
|
||||
function LastBiometricError: string;
|
||||
begin
|
||||
Result := FLastError;
|
||||
end;
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
const
|
||||
// DeviceCredential inclus : un telephone protege par un schema ou un code,
|
||||
// sans empreinte enrolee, doit rester utilisable. C'est le cas courant.
|
||||
WANTED_STRENGTHS = [TBiometricStrength.Strong,
|
||||
TBiometricStrength.DeviceCredential];
|
||||
// Repli quand la combinaison ci-dessus est refusee. Observe sur un appareil
|
||||
// avec schema et AUCUNE empreinte : selon les versions, demander
|
||||
// BIOMETRIC_STRONG en meme temps que DEVICE_CREDENTIAL fait echouer
|
||||
// l'ensemble au lieu de retenir ce qui existe. On redemande alors juste ce
|
||||
// dont on a besoin.
|
||||
CREDENTIAL_ONLY = [TBiometricStrength.DeviceCredential];
|
||||
|
||||
var
|
||||
// Le jeu retenu par la derniere interrogation reussie. AuthenticateThen doit
|
||||
// presenter EXACTEMENT celui-la : demander autre chose que ce qui a repondu
|
||||
// « disponible » ramenerait le meme refus.
|
||||
FStrengths: TBiometricStrengths = WANTED_STRENGTHS;
|
||||
|
||||
/// Recupere le service ET pose les forces d'authentification.
|
||||
///
|
||||
/// L'ORDRE N'EST PAS COSMETIQUE. Le service Android construit son masque
|
||||
/// d'authentificateurs a partir de ces forces, et son champ demarre a
|
||||
/// l'ensemble VIDE - le constructeur ne l'initialise pas. Interroger
|
||||
/// CanAuthenticate avant de les poser revient donc a demander
|
||||
/// canAuthenticate(0), qui ne repond jamais « disponible », quel que soit ce
|
||||
/// que l'appareil sait faire.
|
||||
function AcquireService(out ASvc: IFMXBiometricAuthService): Boolean;
|
||||
begin
|
||||
Result := TPlatformServices.Current.SupportsPlatformService(
|
||||
IFMXBiometricAuthService, ASvc);
|
||||
if Result then
|
||||
ASvc.SetBiometricStrengths(FStrengths);
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
function BiometricAvailable: Boolean;
|
||||
{$IF DEFINED(ANDROID)}
|
||||
var
|
||||
LSvc: IFMXBiometricAuthService;
|
||||
begin
|
||||
// try/except et non un simple test : CanAuthenticate LEVE une
|
||||
// java.lang.SecurityException si USE_BIOMETRIC manque au manifeste, au lieu
|
||||
// de repondre False. Sans cette garde, un manifeste incomplet ne se traduit
|
||||
// pas par « pas de biometrie » mais par une boite d'exception en pleine
|
||||
// ouverture du coffre.
|
||||
FLastError := '';
|
||||
try
|
||||
FStrengths := WANTED_STRENGTHS;
|
||||
if not AcquireService(LSvc) then
|
||||
begin
|
||||
FLastError := 'biometric service missing from the platform';
|
||||
Exit(False);
|
||||
end;
|
||||
Result := LSvc.CanAuthenticate;
|
||||
if not Result then
|
||||
begin
|
||||
// Second essai sans la biometrie forte : le schema seul suffit a
|
||||
// deverrouiller la cle, et c'est tout ce qu'on demande.
|
||||
FStrengths := CREDENTIAL_ONLY;
|
||||
LSvc.SetBiometricStrengths(FStrengths);
|
||||
Result := LSvc.CanAuthenticate;
|
||||
if Result then
|
||||
FLastError := 'device credential only (no biometric enrolled)'
|
||||
else
|
||||
begin
|
||||
FStrengths := WANTED_STRENGTHS;
|
||||
FLastError := 'CanAuthenticate: no usable protection';
|
||||
end;
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
FLastError := E.ClassName + ': ' + E.Message;
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
begin
|
||||
Result := False;
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
procedure AuthenticateThen(const APrompt: string; const AThen: TProc<TBioOutcome>);
|
||||
{$IF DEFINED(ANDROID)}
|
||||
var
|
||||
LSvc: IFMXBiometricAuthService;
|
||||
LProps: TBiometricPromptProperties;
|
||||
begin
|
||||
if not Assigned(AThen) then Exit;
|
||||
|
||||
// AcquireService pose les forces ; CanAuthenticate n'a de sens qu'apres.
|
||||
FLastError := '';
|
||||
if not AcquireService(LSvc) then
|
||||
begin
|
||||
FLastError := 'biometric service missing from the platform';
|
||||
AThen(boFailed); // pas boUnavailable : on ne sait rien de l'appareil
|
||||
Exit;
|
||||
end;
|
||||
try
|
||||
if not LSvc.CanAuthenticate then
|
||||
begin
|
||||
// Reponse NETTE du systeme : l'appareil n'a rien pour authentifier.
|
||||
FLastError := 'CanAuthenticate: no usable protection';
|
||||
AThen(boUnavailable);
|
||||
Exit;
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
FLastError := E.ClassName + ': ' + E.Message;
|
||||
AThen(boFailed);
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
LProps := Default(TBiometricPromptProperties);
|
||||
LProps.Title := 'Unlock the vault';
|
||||
LProps.Description := APrompt;
|
||||
LProps.CancelButtonText := 'Cancel';
|
||||
LSvc.SetPromptProperties(LProps);
|
||||
|
||||
// Meme raison que plus haut : ce qui remonte de la couche Java doit
|
||||
// ressortir en TBioOutcome, jamais en exception - l'appelant a un chemin
|
||||
// pour chaque issue, il n'en a aucun pour une exception.
|
||||
try
|
||||
LSvc.Authenticate(
|
||||
procedure
|
||||
begin
|
||||
AThen(boOK);
|
||||
end,
|
||||
procedure(const AFailReason: TBiometricFailReason; const AMessage: string)
|
||||
begin
|
||||
if AFailReason = TBiometricFailReason.Cancelled then
|
||||
AThen(boCancelled)
|
||||
else
|
||||
AThen(boFailed);
|
||||
end);
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
FLastError := E.ClassName + ': ' + E.Message;
|
||||
AThen(boFailed);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
begin
|
||||
if Assigned(AThen) then AThen(boUnavailable);
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,158 @@
|
||||
unit PMA.Clipboard;
|
||||
{
|
||||
Copie dans le presse-papiers, en marquant le contenu SENSIBLE.
|
||||
|
||||
POURQUOI PAS IFMXClipboardService : depuis Android 13, le systeme affiche un
|
||||
apercu de ce qui vient d'etre copie, en bas de l'ecran. Pour un mot de passe,
|
||||
c'est exactement ce qu'on cherche a eviter - il s'affiche en clair, hors de
|
||||
l'application, et reste visible quelques secondes.
|
||||
|
||||
Le drapeau qui supprime cet apercu est un extra du ClipDescription, que le
|
||||
service FMX n'expose pas. D'ou ce passage direct par ClipboardManager.
|
||||
|
||||
Cela ne remplace pas l'effacement differe : le drapeau cache l'apercu, il ne
|
||||
vide pas le presse-papiers.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
/// Copie ATexte. Retourne False si la plateforme n'a rien fourni - l'appelant
|
||||
/// peut alors retomber sur le service FMX.
|
||||
function CopySensitive(const AText: string): Boolean;
|
||||
|
||||
/// Contenu courant, ou chaine vide. Sert a ne vider le presse-papiers que
|
||||
/// s'il contient toujours ce qu'on y a mis.
|
||||
function CurrentClipboard: string;
|
||||
|
||||
/// Vide le presse-papiers.
|
||||
procedure ClearClipboard;
|
||||
|
||||
implementation
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
uses
|
||||
System.SysUtils, Androidapi.Helpers, Androidapi.JNI.JavaTypes,
|
||||
Androidapi.JNI.GraphicsContentViewText, Androidapi.JNI.Os, Androidapi.JNIBridge;
|
||||
|
||||
const
|
||||
// android.content.ClipDescription.EXTRA_IS_SENSITIVE, ecrit en dur : la
|
||||
// constante n'existe qu'a partir de l'API 33 dans les en-tetes, alors que la
|
||||
// valeur est reconnue par le systeme des qu'il sait la lire, et ignoree
|
||||
// ailleurs. Un extra inconnu ne derange personne.
|
||||
EXTRA_IS_SENSITIVE = 'android.content.extra.IS_SENSITIVE';
|
||||
|
||||
/// Jcontent_ClipboardManager et NON JClipboardManager : ce dernier est
|
||||
/// l'android.text.ClipboardManager deprecie, qui ne connait que du texte nu et
|
||||
/// aucun ClipData - donc aucun extra ou poser le drapeau.
|
||||
function Manager: Jcontent_ClipboardManager;
|
||||
var
|
||||
LObj: JObject;
|
||||
begin
|
||||
LObj := TAndroidHelper.Context.getSystemService(
|
||||
TJContext.JavaClass.CLIPBOARD_SERVICE);
|
||||
if LObj = nil then Exit(nil);
|
||||
Result := TJcontent_ClipboardManager.Wrap((LObj as ILocalObject).GetObjectID);
|
||||
end;
|
||||
|
||||
function CopySensitive(const AText: string): Boolean;
|
||||
var
|
||||
LMgr: Jcontent_ClipboardManager;
|
||||
LClip: JClipData;
|
||||
LExtras: JPersistableBundle;
|
||||
begin
|
||||
Result := False;
|
||||
try
|
||||
LMgr := Manager;
|
||||
if LMgr = nil then Exit;
|
||||
// L'etiquette est visible du systeme : elle ne doit rien decrire du
|
||||
// contenu, seulement son origine.
|
||||
// Les DEUX arguments sont des CharSequence : passer un JString au premier
|
||||
// compile ailleurs mais pas ici.
|
||||
LClip := TJClipData.JavaClass.newPlainText(StrToJCharSequence('vault'),
|
||||
StrToJCharSequence(AText));
|
||||
LExtras := TJPersistableBundle.JavaClass.init;
|
||||
LExtras.putBoolean(StringToJString(EXTRA_IS_SENSITIVE), True);
|
||||
LClip.getDescription.setExtras(LExtras);
|
||||
LMgr.setPrimaryClip(LClip);
|
||||
Result := True;
|
||||
except
|
||||
Result := False; // l'appelant retombera sur le service FMX
|
||||
end;
|
||||
end;
|
||||
|
||||
function CurrentClipboard: string;
|
||||
var
|
||||
LMgr: Jcontent_ClipboardManager;
|
||||
LClip: JClipData;
|
||||
begin
|
||||
Result := '';
|
||||
try
|
||||
LMgr := Manager;
|
||||
if (LMgr = nil) or not LMgr.hasPrimaryClip then Exit;
|
||||
LClip := LMgr.getPrimaryClip;
|
||||
if (LClip = nil) or (LClip.getItemCount < 1) then Exit;
|
||||
Result := JCharSequenceToStr(LClip.getItemAt(0).getText);
|
||||
except
|
||||
Result := '';
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure ClearClipboard;
|
||||
var
|
||||
LMgr: Jcontent_ClipboardManager;
|
||||
begin
|
||||
// clearPrimaryClip QUAND IL EXISTE, et un contenu vide seulement en dessous
|
||||
// de l'API 28.
|
||||
//
|
||||
// Le resultat n'est PAS le meme, contrairement a ce qui etait ecrit ici :
|
||||
// ecrire un contenu vide reste une COPIE aux yeux du systeme, et Android
|
||||
// affiche donc sa vignette "copie" - quarante-cinq secondes apres le geste
|
||||
// de l'utilisateur, sans que rien ne l'explique. Vider n'est pas copier, et
|
||||
// l'API le distingue depuis 2018.
|
||||
//
|
||||
// Elle ne se montre pas a chaque fois : le minuteur ne vide que si le
|
||||
// presse-papiers contient TOUJOURS notre texte, donc copier autre chose
|
||||
// entre-temps la supprime. D'ou une apparition sur deux.
|
||||
try
|
||||
LMgr := Manager;
|
||||
if LMgr = nil then Exit;
|
||||
if TJBuild_VERSION.JavaClass.SDK_INT >= 28 then
|
||||
LMgr.clearPrimaryClip
|
||||
else
|
||||
CopySensitive('');
|
||||
except
|
||||
// Un presse-papiers qu'on n'a pas pu vider vaut mieux qu'une exception :
|
||||
// le contenu partira au prochain copier de l'utilisateur.
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
uses
|
||||
System.SysUtils, System.Rtti, FMX.Platform;
|
||||
|
||||
function CopySensitive(const AText: string): Boolean;
|
||||
var
|
||||
LClip: IFMXClipboardService;
|
||||
begin
|
||||
// Hors Android il n'y a pas d'apercu systeme a supprimer : le service
|
||||
// standard suffit.
|
||||
Result := TPlatformServices.Current.SupportsPlatformService(
|
||||
IFMXClipboardService, LClip);
|
||||
if Result then LClip.SetClipboard(AText);
|
||||
end;
|
||||
|
||||
function CurrentClipboard: string;
|
||||
var
|
||||
LClip: IFMXClipboardService;
|
||||
begin
|
||||
Result := '';
|
||||
if TPlatformServices.Current.SupportsPlatformService(IFMXClipboardService, LClip) then
|
||||
Result := LClip.GetClipboard.ToString;
|
||||
end;
|
||||
|
||||
procedure ClearClipboard;
|
||||
begin
|
||||
CopySensitive('');
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,829 @@
|
||||
unit PMA.Crypto;
|
||||
{
|
||||
Opens a pm-encrypted-export-v1 container (the vault snapshot) on Android.
|
||||
|
||||
Format, produced by js/app.import.js encryptExportPayload():
|
||||
kdf 'pbkdf2-sha256'
|
||||
kdf_iterations 600000
|
||||
kdf_salt base64, 32 bytes
|
||||
iv base64, 12 bytes
|
||||
ciphertext base64, AES-GCM output = ciphertext || 16-byte tag
|
||||
(payload) UTF-8 JSON
|
||||
|
||||
TWO DIFFERENT ROUTES, on purpose:
|
||||
|
||||
- AES-GCM goes through JNI to javax.crypto. One call, no loop, and the
|
||||
platform implementation is hardware-accelerated. Java's Cipher expects the
|
||||
tag appended to the ciphertext, which is exactly what WebCrypto produces -
|
||||
the buffers are byte-identical, nothing to reshape.
|
||||
|
||||
On Windows the same primitive comes from bcrypt.dll (CNG) - no DLL to
|
||||
ship, no OpenSSL. It exists so the client can be exercised on the desktop:
|
||||
deploying an APK to see a list redraw is a slow way to build a UI.
|
||||
|
||||
- PBKDF2 is implemented HERE rather than via SecretKeyFactory, because
|
||||
javax.crypto.spec.PBEKeySpec takes a char[] and the provider decides how
|
||||
those chars become bytes: UTF-8 on some, low-byte on others. For a
|
||||
non-ASCII sync password the derived key would differ from the browser's,
|
||||
and the failure is silent - "wrong password" on a correct password. Doing
|
||||
it here fixes the encoding at UTF-8 and makes the match exact by
|
||||
construction. The cost is 600k HMAC-SHA256 rounds in Pascal; measure it on
|
||||
a real device (see DeriveKeyPBKDF2's note) before considering the JNI
|
||||
route again.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils, System.Classes, System.JSON, System.NetEncoding, System.Hash,
|
||||
// System.DateUtils : TTimeZone, pour horodater le conteneur en UTC.
|
||||
System.DateUtils;
|
||||
|
||||
type
|
||||
EPMACrypto = class(Exception);
|
||||
|
||||
/// Derive a key the same way WebCrypto's deriveExportKey does: PBKDF2-SHA256
|
||||
/// over the UTF-8 bytes of the password. AKeyLenBytes = 32 for AES-256.
|
||||
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
|
||||
AIterations, AKeyLenBytes: Integer): TBytes;
|
||||
|
||||
/// Same derivation, delegated to the platform - natively implemented, so
|
||||
/// faster than the Pascal loop. Android: SecretKeyFactory. Windows: CNG's
|
||||
/// BCryptDeriveKeyPBKDF2, which takes BYTES and so carries none of the doubt
|
||||
/// below.
|
||||
///
|
||||
/// NOT used by DecryptContainer, and deliberately so: PBEKeySpec takes a
|
||||
/// char[] and the provider decides how those chars become bytes. If Android
|
||||
/// encodes them as anything other than UTF-8, this returns a DIFFERENT key for
|
||||
/// a non-ASCII password - silently, reported later as a wrong password.
|
||||
/// The self-test compares it against the known vector on a real device; adopt
|
||||
/// it only if they match there. Returns nil on any failure.
|
||||
function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
|
||||
AIterations, AKeyLenBytes: Integer): TBytes;
|
||||
|
||||
/// Why the last DeriveKeyPBKDF2Native returned nil. Empty when it succeeded.
|
||||
function LastNativeError: string;
|
||||
|
||||
/// AES-256-GCM decrypt. ACipherText carries the 16-byte tag at the end, as
|
||||
/// produced by WebCrypto. Raises EPMACrypto when the tag does not verify -
|
||||
/// which is what a wrong password looks like.
|
||||
function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes;
|
||||
|
||||
/// Full container -> plaintext JSON payload. Validates the format marker so a
|
||||
/// truncated download or an HTML error page fails with a clear message rather
|
||||
/// than as "wrong password".
|
||||
function DecryptContainer(const AContainerJson, APassword: string): string;
|
||||
|
||||
/// N octets d'un generateur CRYPTOGRAPHIQUE - SecureRandom sur Android,
|
||||
/// BCryptGenRandom sur Windows. JAMAIS Random : un sel ou un IV previsible
|
||||
/// ruine le chiffrement qui les emploie, et rien dans le resultat ne le
|
||||
/// montrerait. Leve plutot que de rendre des octets faibles.
|
||||
function SecureRandomBytes(ACount: Integer): TBytes;
|
||||
|
||||
/// AES-256-GCM chiffrement. Le tag de 16 octets est colle A LA FIN du
|
||||
/// resultat, comme le fait WebCrypto - c'est ce que DecryptContainer et le
|
||||
/// poste Windows attendent tous les deux.
|
||||
function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes;
|
||||
|
||||
/// Texte JSON -> conteneur complet, dans le format exact que le poste ecrit
|
||||
/// (`pm-encrypted-export-v1`). Sel et IV tires a chaque appel : deux
|
||||
/// chiffrements du meme coffre ne se ressemblent pas, et c'est voulu.
|
||||
function EncryptContainer(const APlainJson, APassword: string;
|
||||
AIterations: Integer = 600000): string;
|
||||
|
||||
/// Comme DecryptContainer, mais rend AUSSI la cle derivee, son sel et le
|
||||
/// nombre d'iterations. Sert au seul cas ou l'on rechiffre juste apres avoir
|
||||
/// dechiffre : deriver deux fois le meme mot de passe coute deux fois quatre a
|
||||
/// six secondes sur un telephone, pour un resultat identique.
|
||||
function DecryptContainerEx(const AContainerJson, APassword: string;
|
||||
out AKey, ASalt: TBytes; out AIterations: Integer): string;
|
||||
|
||||
/// Le sel et le nombre d'iterations du conteneur, SANS rien deriver.
|
||||
///
|
||||
/// Sert a repondre a une seule question : la cle qu'on tient deja convient-elle
|
||||
/// a ce conteneur ? Y repondre coute une lecture de JSON, alors que la deriver
|
||||
/// pour rien coute quatre a six secondes.
|
||||
function ContainerKdfParams(const AContainerJson: string;
|
||||
out ASalt: TBytes; out AIterations: Integer): Boolean;
|
||||
|
||||
/// Dechiffre avec une cle DEJA derivee - le pendant d'EncryptContainerWithKey.
|
||||
/// L'APPELANT DOIT AVOIR VERIFIE que le sel et les iterations du conteneur sont
|
||||
/// ceux de cette cle : sans quoi le dechiffrement echoue sur la marque de GCM,
|
||||
/// et le message parlera d'un mot de passe faux alors qu'il est bon.
|
||||
function DecryptContainerWithKey(const AContainerJson: string;
|
||||
const AKey: TBytes): string;
|
||||
|
||||
/// Rechiffre avec une cle DEJA derivee. L'IV, lui, est retire a chaque appel :
|
||||
/// reutiliser un IV avec la meme cle casse GCM entierement, alors que
|
||||
/// reutiliser le sel ne fait que redonner la meme cle - ce qui est justement
|
||||
/// le but.
|
||||
function EncryptContainerWithKey(const APlainJson: string;
|
||||
const AKey, ASalt: TBytes; AIterations: Integer): string;
|
||||
|
||||
implementation
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
uses
|
||||
// Androidapi.Jni : sans elle, TJavaArray.Length reste un appel non inline
|
||||
// et le compilateur emet un avertissement a chaque usage.
|
||||
Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers;
|
||||
{$ENDIF}
|
||||
|
||||
{$IF DEFINED(MSWINDOWS)}
|
||||
// CNG, declare ici : Delphi ne livre pas d'unite Winapi.Bcrypt, et six
|
||||
// fonctions ne justifient pas d'en importer une d'ailleurs.
|
||||
const
|
||||
BCRYPT_DLL = 'bcrypt.dll';
|
||||
BCRYPT_ALG_HANDLE_HMAC_FLAG = $00000008;
|
||||
AUTH_MODE_INFO_VERSION = 1;
|
||||
|
||||
type
|
||||
// Disposition identique au C : champs dans l'ordre, alignement par defaut
|
||||
// ({$A8}) - les pointeurs et le UInt64 tombent sur 8, comme chez MSVC.
|
||||
TBCryptAuthCipherModeInfo = record
|
||||
cbSize: Cardinal;
|
||||
dwInfoVersion: Cardinal;
|
||||
pbNonce: PByte;
|
||||
cbNonce: Cardinal;
|
||||
pbAuthData: PByte;
|
||||
cbAuthData: Cardinal;
|
||||
pbTag: PByte;
|
||||
cbTag: Cardinal;
|
||||
pbMacContext: PByte;
|
||||
cbMacContext: Cardinal;
|
||||
cbAAD: Cardinal;
|
||||
cbData: UInt64;
|
||||
dwFlags: Cardinal;
|
||||
end;
|
||||
|
||||
function BCryptOpenAlgorithmProvider(var phAlgorithm: Pointer;
|
||||
pszAlgId, pszImplementation: PWideChar; dwFlags: Cardinal): Integer; stdcall;
|
||||
external BCRYPT_DLL;
|
||||
function BCryptCloseAlgorithmProvider(hAlgorithm: Pointer;
|
||||
dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL;
|
||||
function BCryptSetProperty(hObject: Pointer; pszProperty: PWideChar;
|
||||
pbInput: PByte; cbInput, dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL;
|
||||
function BCryptGenerateSymmetricKey(hAlgorithm: Pointer; var phKey: Pointer;
|
||||
pbKeyObject: PByte; cbKeyObject: Cardinal; pbSecret: PByte;
|
||||
cbSecret, dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL;
|
||||
function BCryptDestroyKey(hKey: Pointer): Integer; stdcall; external BCRYPT_DLL;
|
||||
function BCryptEncrypt(hKey: Pointer; pbInput: PByte; cbInput: Cardinal;
|
||||
pPaddingInfo: Pointer; pbIV: PByte; cbIV: Cardinal; pbOutput: PByte;
|
||||
cbOutput: Cardinal; var pcbResult: Cardinal; dwFlags: Cardinal): Integer;
|
||||
stdcall; external BCRYPT_DLL;
|
||||
function BCryptGenRandom(hAlgorithm: Pointer; pbBuffer: PByte;
|
||||
cbBuffer: Cardinal; dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL;
|
||||
function BCryptDecrypt(hKey: Pointer; pbInput: PByte; cbInput: Cardinal;
|
||||
pPaddingInfo: Pointer; pbIV: PByte; cbIV: Cardinal; pbOutput: PByte;
|
||||
cbOutput: Cardinal; var pcbResult: Cardinal; dwFlags: Cardinal): Integer;
|
||||
stdcall; external BCRYPT_DLL;
|
||||
function BCryptDeriveKeyPBKDF2(hPrf: Pointer; pbPassword: PByte;
|
||||
cbPassword: Cardinal; pbSalt: PByte; cbSalt: Cardinal; cIterations: UInt64;
|
||||
pbDerivedKey: PByte; cbDerivedKey, dwFlags: Cardinal): Integer; stdcall;
|
||||
external BCRYPT_DLL;
|
||||
{$ENDIF}
|
||||
|
||||
var
|
||||
FLastNativeError: string = '';
|
||||
|
||||
function LastNativeError: string;
|
||||
begin
|
||||
Result := FLastNativeError;
|
||||
end;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PBKDF2-SHA256
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function XorBytes(const A, B: TBytes): TBytes;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
SetLength(Result, Length(A));
|
||||
for I := 0 to High(A) do
|
||||
Result[I] := A[I] xor B[I];
|
||||
end;
|
||||
|
||||
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
|
||||
AIterations, AKeyLenBytes: Integer): TBytes;
|
||||
// Straight RFC 2898. One block is enough for a 32-byte key with SHA-256
|
||||
// (hLen = 32), so there is no block loop to get wrong.
|
||||
//
|
||||
// PERFORMANCE: 600k HMAC rounds. Budget a few seconds on a mid-range phone and
|
||||
// run it OFF the UI thread. If it proves unacceptable, the answer is to cache
|
||||
// the derived key in the Android Keystore behind biometrics - NOT to lower the
|
||||
// iteration count, which is shared with the desktop and defines the format.
|
||||
var
|
||||
LPwd, LBlock, LU, LT: TBytes;
|
||||
I: Integer;
|
||||
begin
|
||||
if AKeyLenBytes > 32 then
|
||||
raise EPMACrypto.Create('KeyLen > one SHA-256 block is not implemented');
|
||||
|
||||
LPwd := TEncoding.UTF8.GetBytes(APassword);
|
||||
|
||||
// U1 = HMAC(pwd, salt || INT32BE(1))
|
||||
SetLength(LBlock, Length(ASalt) + 4);
|
||||
if Length(ASalt) > 0 then
|
||||
Move(ASalt[0], LBlock[0], Length(ASalt));
|
||||
LBlock[Length(ASalt)] := 0;
|
||||
LBlock[Length(ASalt) + 1] := 0;
|
||||
LBlock[Length(ASalt) + 2] := 0;
|
||||
LBlock[Length(ASalt) + 3] := 1;
|
||||
|
||||
LU := THashSHA2.GetHMACAsBytes(LBlock, LPwd, THashSHA2.TSHA2Version.SHA256);
|
||||
LT := Copy(LU, 0, Length(LU));
|
||||
for I := 2 to AIterations do
|
||||
begin
|
||||
LU := THashSHA2.GetHMACAsBytes(LU, LPwd, THashSHA2.TSHA2Version.SHA256);
|
||||
LT := XorBytes(LT, LU);
|
||||
end;
|
||||
|
||||
Result := Copy(LT, 0, AKeyLenBytes);
|
||||
end;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AES-GCM through javax.crypto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
type
|
||||
// Minimal JNI imports. Delphi ships no headers for javax.crypto, and the
|
||||
// bridge builds its method signatures from these declarations - hence the
|
||||
// real Java types (Key, AlgorithmParameterSpec) rather than JObject, which
|
||||
// would generate a signature no method matches.
|
||||
JKey = interface;
|
||||
JAlgorithmParameterSpec = interface;
|
||||
JSecretKeySpec = interface;
|
||||
JGCMParameterSpec = interface;
|
||||
JCipher = interface;
|
||||
|
||||
JKeyClass = interface(IJavaClass)
|
||||
['{6E0F5B9A-1C2D-4E3F-8A7B-9C0D1E2F3A4B}']
|
||||
end;
|
||||
[JavaSignature('java/security/Key')]
|
||||
JKey = interface(IJavaInstance)
|
||||
['{7F1A6CAB-2D3E-4F50-9B8C-AD1E2F3A4B5C}']
|
||||
end;
|
||||
TJKey = class(TJavaGenericImport<JKeyClass, JKey>) end;
|
||||
|
||||
JAlgorithmParameterSpecClass = interface(IJavaClass)
|
||||
['{8A2B7DBC-3E4F-5061-AC9D-BE2F3A4B5C6D}']
|
||||
end;
|
||||
[JavaSignature('java/security/spec/AlgorithmParameterSpec')]
|
||||
JAlgorithmParameterSpec = interface(IJavaInstance)
|
||||
['{9B3C8ECD-4F50-6172-BDAE-CF3A4B5C6D7E}']
|
||||
end;
|
||||
TJAlgorithmParameterSpec = class(TJavaGenericImport<JAlgorithmParameterSpecClass, JAlgorithmParameterSpec>) end;
|
||||
|
||||
JSecretKeySpecClass = interface(JKeyClass)
|
||||
['{AC4D9FDE-5061-7283-CEBF-DA4B5C6D7E8F}']
|
||||
function init(key: TJavaArray<Byte>; algorithm: JString): JSecretKeySpec; cdecl;
|
||||
end;
|
||||
[JavaSignature('javax/crypto/spec/SecretKeySpec')]
|
||||
JSecretKeySpec = interface(JKey)
|
||||
['{BD5EA0EF-6172-8394-DFCA-EB5C6D7E8F90}']
|
||||
end;
|
||||
TJSecretKeySpec = class(TJavaGenericImport<JSecretKeySpecClass, JSecretKeySpec>) end;
|
||||
|
||||
JGCMParameterSpecClass = interface(JAlgorithmParameterSpecClass)
|
||||
['{CE6FB1F0-7283-94A5-E0DB-FC6D7E8F9001}']
|
||||
function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpec; cdecl;
|
||||
end;
|
||||
[JavaSignature('javax/crypto/spec/GCMParameterSpec')]
|
||||
JGCMParameterSpec = interface(JAlgorithmParameterSpec)
|
||||
['{DF70C201-8394-A5B6-F1EC-0D7E8F900112}']
|
||||
end;
|
||||
TJGCMParameterSpec = class(TJavaGenericImport<JGCMParameterSpecClass, JGCMParameterSpec>) end;
|
||||
|
||||
JKeySpec = interface;
|
||||
JPBEKeySpec = interface;
|
||||
JSecretKey = interface;
|
||||
JSecretKeyFactory = interface;
|
||||
|
||||
// generateSecret takes a KeySpec, not an Object. Declaring the real
|
||||
// interface is what makes the bridge emit
|
||||
// (Ljava/security/spec/KeySpec;)Ljavax/crypto/SecretKey; - with JObject it
|
||||
// emitted a signature no method matches, and the call failed at runtime.
|
||||
JKeySpecClass = interface(IJavaClass)
|
||||
['{0A1B2C3D-4E5F-6071-8293-A4B5C6D7E8F9}']
|
||||
end;
|
||||
[JavaSignature('java/security/spec/KeySpec')]
|
||||
JKeySpec = interface(IJavaInstance)
|
||||
['{1B2C3D4E-5F60-7182-93A4-B5C6D7E8F901}']
|
||||
end;
|
||||
TJKeySpec = class(TJavaGenericImport<JKeySpecClass, JKeySpec>) end;
|
||||
|
||||
JPBEKeySpecClass = interface(JKeySpecClass)
|
||||
['{1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F801}']
|
||||
function init(password: TJavaArray<Char>; salt: TJavaArray<Byte>;
|
||||
iterationCount: Integer; keyLength: Integer): JPBEKeySpec; cdecl;
|
||||
end;
|
||||
[JavaSignature('javax/crypto/spec/PBEKeySpec')]
|
||||
JPBEKeySpec = interface(JKeySpec)
|
||||
['{2B3C4D5E-6F70-8192-A3B4-C5D6E7F80112}']
|
||||
end;
|
||||
TJPBEKeySpec = class(TJavaGenericImport<JPBEKeySpecClass, JPBEKeySpec>) end;
|
||||
|
||||
JSecretKeyClass = interface(JKeyClass)
|
||||
['{3C4D5E6F-7081-92A3-B4C5-D6E7F8011223}']
|
||||
end;
|
||||
[JavaSignature('javax/crypto/SecretKey')]
|
||||
JSecretKey = interface(JKey)
|
||||
['{4D5E6F70-8192-A3B4-C5D6-E7F801122334}']
|
||||
function getEncoded: TJavaArray<Byte>; cdecl;
|
||||
end;
|
||||
TJSecretKey = class(TJavaGenericImport<JSecretKeyClass, JSecretKey>) end;
|
||||
|
||||
JSecretKeyFactoryClass = interface(JObjectClass)
|
||||
['{5E6F7081-92A3-B4C5-D6E7-F80112233445}']
|
||||
function getInstance(algorithm: JString): JSecretKeyFactory; cdecl;
|
||||
end;
|
||||
[JavaSignature('javax/crypto/SecretKeyFactory')]
|
||||
JSecretKeyFactory = interface(JObject)
|
||||
['{6F708192-A3B4-C5D6-E7F8-011223344556}']
|
||||
function generateSecret(keySpec: JKeySpec): JSecretKey; cdecl;
|
||||
end;
|
||||
TJSecretKeyFactory = class(TJavaGenericImport<JSecretKeyFactoryClass, JSecretKeyFactory>) end;
|
||||
|
||||
JCipherClass = interface(JObjectClass)
|
||||
['{E081D312-94A5-B6C7-02FD-1E8F90011223}']
|
||||
function _GetDECRYPT_MODE: Integer; cdecl;
|
||||
function _GetENCRYPT_MODE: Integer; cdecl;
|
||||
function getInstance(transformation: JString): JCipher; cdecl;
|
||||
property DECRYPT_MODE: Integer read _GetDECRYPT_MODE;
|
||||
property ENCRYPT_MODE: Integer read _GetENCRYPT_MODE;
|
||||
end;
|
||||
[JavaSignature('javax/crypto/Cipher')]
|
||||
JCipher = interface(JObject)
|
||||
['{F192E423-A5B6-C7D8-130E-2F9001122334}']
|
||||
procedure init(opmode: Integer; key: JKey; params: JAlgorithmParameterSpec); cdecl;
|
||||
function doFinal(input: TJavaArray<Byte>): TJavaArray<Byte>; cdecl;
|
||||
end;
|
||||
TJCipher = class(TJavaGenericImport<JCipherClass, JCipher>) end;
|
||||
|
||||
// Declare ICI plutot qu'en important Androidapi.JNI.Java.Security : cette
|
||||
// unite redeclarerait JKey et consorts, et Delphi resout par le dernier uses
|
||||
// - de quoi relier silencieusement le code existant a d'autres interfaces.
|
||||
JSecureRandom = interface;
|
||||
JSecureRandomClass = interface(JObjectClass)
|
||||
['{9C1D2E3F-4A5B-6C7D-8E9F-0A1B2C3D4E5F}']
|
||||
function init: JSecureRandom; cdecl;
|
||||
end;
|
||||
[JavaSignature('java/security/SecureRandom')]
|
||||
JSecureRandom = interface(JObject)
|
||||
['{AD2E3F40-5B6C-7D8E-9F0A-1B2C3D4E5F60}']
|
||||
procedure nextBytes(bytes: TJavaArray<Byte>); cdecl;
|
||||
end;
|
||||
TJSecureRandom = class(TJavaGenericImport<JSecureRandomClass, JSecureRandom>) end;
|
||||
|
||||
function ToJavaBytes(const B: TBytes): TJavaArray<Byte>;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := TJavaArray<Byte>.Create(Length(B));
|
||||
for I := 0 to High(B) do
|
||||
Result.Items[I] := B[I];
|
||||
end;
|
||||
|
||||
function FromJavaBytes(const A: TJavaArray<Byte>): TBytes;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
SetLength(Result, A.Length);
|
||||
for I := 0 to A.Length - 1 do
|
||||
Result[I] := A.Items[I];
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
|
||||
AIterations, AKeyLenBytes: Integer): TBytes;
|
||||
{$IF DEFINED(ANDROID)}
|
||||
var
|
||||
LChars: TJavaArray<Char>;
|
||||
LSpec: JPBEKeySpec;
|
||||
LFactory: JSecretKeyFactory;
|
||||
I: Integer;
|
||||
begin
|
||||
Result := nil;
|
||||
try
|
||||
// The password goes in as chars - the whole point of the doubt. Delphi
|
||||
// strings are UTF-16, so this hands the provider the code units and lets
|
||||
// it apply whatever encoding it wants. That is exactly what the test
|
||||
// measures against the vector.
|
||||
LChars := TJavaArray<Char>.Create(Length(APassword));
|
||||
for I := 1 to Length(APassword) do
|
||||
LChars.Items[I - 1] := APassword[I];
|
||||
|
||||
LSpec := TJPBEKeySpec.JavaClass.init(LChars, ToJavaBytes(ASalt),
|
||||
AIterations, AKeyLenBytes * 8); // keyLength is in BITS
|
||||
LFactory := TJSecretKeyFactory.JavaClass.getInstance(
|
||||
StringToJString('PBKDF2WithHmacSHA256'));
|
||||
Result := FromJavaBytes(LFactory.generateSecret(LSpec).getEncoded);
|
||||
except
|
||||
// Keep the reason: "unavailable" hid a signature mismatch of mine for a
|
||||
// whole test round. A message here says whether Android refused, or
|
||||
// whether the JNI declaration is wrong again.
|
||||
on E: Exception do
|
||||
begin
|
||||
FLastNativeError := E.ClassName + ': ' + E.Message;
|
||||
Result := nil;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
{$ELSEIF DEFINED(MSWINDOWS)}
|
||||
var
|
||||
LPrf: Pointer;
|
||||
LPwd: TBytes;
|
||||
begin
|
||||
Result := nil;
|
||||
LPwd := TEncoding.UTF8.GetBytes(APassword);
|
||||
LPrf := nil;
|
||||
// HMAC flag : sans lui le handle sert au hachage simple, et la derivation
|
||||
// echoue au lieu de silencieusement produire autre chose.
|
||||
if BCryptOpenAlgorithmProvider(LPrf, 'SHA256', nil,
|
||||
BCRYPT_ALG_HANDLE_HMAC_FLAG) <> 0 then
|
||||
begin
|
||||
FLastNativeError := 'BCryptOpenAlgorithmProvider(SHA256) failed';
|
||||
Exit;
|
||||
end;
|
||||
try
|
||||
SetLength(Result, AKeyLenBytes);
|
||||
// Le mot de passe part en OCTETS UTF-8 : contrairement au char[] de Java,
|
||||
// aucun choix d'encodage n'est laisse au fournisseur.
|
||||
if BCryptDeriveKeyPBKDF2(LPrf, PByte(LPwd), Length(LPwd),
|
||||
PByte(ASalt), Length(ASalt), AIterations,
|
||||
PByte(Result), AKeyLenBytes, 0) <> 0 then
|
||||
begin
|
||||
FLastNativeError := 'BCryptDeriveKeyPBKDF2 failed';
|
||||
Result := nil;
|
||||
end;
|
||||
finally
|
||||
BCryptCloseAlgorithmProvider(LPrf, 0);
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
begin
|
||||
Result := nil; // no platform provider here
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes;
|
||||
{$IF DEFINED(ANDROID)}
|
||||
var
|
||||
LCipher: JCipher;
|
||||
LKeySpec: JSecretKeySpec;
|
||||
LGcm: JGCMParameterSpec;
|
||||
begin
|
||||
try
|
||||
LKeySpec := TJSecretKeySpec.JavaClass.init(ToJavaBytes(AKey),
|
||||
StringToJString('AES'));
|
||||
// 128 = tag length in BITS. WebCrypto's default is the same 16 bytes; a
|
||||
// mismatch here fails authentication on otherwise valid data.
|
||||
LGcm := TJGCMParameterSpec.JavaClass.init(128, ToJavaBytes(AIv));
|
||||
LCipher := TJCipher.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
|
||||
LCipher.init(TJCipher.JavaClass.DECRYPT_MODE, LKeySpec, LGcm);
|
||||
Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(ACipherText)));
|
||||
except
|
||||
// AEADBadTagException surfaces here: wrong password, or altered bytes.
|
||||
// Both are indistinguishable by design - that is what authentication means.
|
||||
on E: Exception do
|
||||
// Message en pur ASCII : un tiret cadratin dans une source lue en ANSI
|
||||
// par Delphi ressort en mojibake sur l'appareil.
|
||||
raise EPMACrypto.Create('Decryption failed - wrong password or corrupt file');
|
||||
end;
|
||||
end;
|
||||
{$ELSEIF DEFINED(MSWINDOWS)}
|
||||
var
|
||||
LAlg, LKey: Pointer;
|
||||
LInfo: TBCryptAuthCipherModeInfo;
|
||||
LTag, LCt: TBytes;
|
||||
LOutLen: Cardinal;
|
||||
LMode: string;
|
||||
begin
|
||||
// GCM porte son tag a la fin, colle par WebCrypto. CNG le veut a part, dans
|
||||
// la structure - c'est la seule difference avec le chemin Java, qui l'accepte
|
||||
// concatene.
|
||||
if Length(ACipherText) < 16 then
|
||||
raise EPMACrypto.Create('Ciphertext shorter than its GCM tag');
|
||||
|
||||
LAlg := nil;
|
||||
LKey := nil;
|
||||
if BCryptOpenAlgorithmProvider(LAlg, 'AES', nil, 0) <> 0 then
|
||||
raise EPMACrypto.Create('BCryptOpenAlgorithmProvider(AES) failed');
|
||||
try
|
||||
LMode := 'ChainingModeGCM';
|
||||
// Longueur AVEC le zero terminal : CNG compare la chaine complete.
|
||||
if BCryptSetProperty(LAlg, 'ChainingMode', PByte(PWideChar(LMode)),
|
||||
(Length(LMode) + 1) * SizeOf(WideChar), 0) <> 0 then
|
||||
raise EPMACrypto.Create('BCryptSetProperty(GCM) failed');
|
||||
|
||||
if BCryptGenerateSymmetricKey(LAlg, LKey, nil, 0,
|
||||
PByte(AKey), Length(AKey), 0) <> 0 then
|
||||
raise EPMACrypto.Create('BCryptGenerateSymmetricKey failed');
|
||||
|
||||
LTag := Copy(ACipherText, Length(ACipherText) - 16, 16);
|
||||
LCt := Copy(ACipherText, 0, Length(ACipherText) - 16);
|
||||
|
||||
FillChar(LInfo, SizeOf(LInfo), 0);
|
||||
LInfo.cbSize := SizeOf(LInfo);
|
||||
LInfo.dwInfoVersion := AUTH_MODE_INFO_VERSION;
|
||||
LInfo.pbNonce := PByte(AIv);
|
||||
LInfo.cbNonce := Length(AIv);
|
||||
LInfo.pbTag := PByte(LTag);
|
||||
LInfo.cbTag := Length(LTag);
|
||||
|
||||
SetLength(Result, Length(LCt));
|
||||
LOutLen := 0;
|
||||
// pbIV a nil : en mode authentifie le nonce passe par la structure, et
|
||||
// remplir les deux fait echouer l'appel.
|
||||
if BCryptDecrypt(LKey, PByte(LCt), Length(LCt), @LInfo, nil, 0,
|
||||
PByte(Result), Length(Result), LOutLen, 0) <> 0 then
|
||||
// STATUS_AUTH_TAG_MISMATCH arrive ici : mot de passe faux, ou octets
|
||||
// alteres. Indiscernables par construction - c'est ce qu'authentifier
|
||||
// veut dire. Meme message que la branche Android.
|
||||
raise EPMACrypto.Create('Decryption failed - wrong password or corrupt file');
|
||||
SetLength(Result, LOutLen);
|
||||
finally
|
||||
if LKey <> nil then BCryptDestroyKey(LKey);
|
||||
BCryptCloseAlgorithmProvider(LAlg, 0);
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
begin
|
||||
raise EPMACrypto.Create('AES-GCM is not wired for this platform');
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alea et chiffrement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SecureRandomBytes(ACount: Integer): TBytes;
|
||||
{$IF DEFINED(ANDROID)}
|
||||
var
|
||||
LRnd: JSecureRandom;
|
||||
LArr: TJavaArray<Byte>;
|
||||
begin
|
||||
if ACount <= 0 then Exit(nil);
|
||||
// Le constructeur sans argument s'ensemence tout seul depuis le systeme ;
|
||||
// fournir une graine soi-meme ne ferait que l'affaiblir.
|
||||
LRnd := TJSecureRandom.JavaClass.init;
|
||||
LArr := TJavaArray<Byte>.Create(ACount);
|
||||
try
|
||||
LRnd.nextBytes(LArr);
|
||||
Result := FromJavaBytes(LArr);
|
||||
finally
|
||||
LArr.Free;
|
||||
end;
|
||||
if Length(Result) <> ACount then
|
||||
raise EPMACrypto.Create('SecureRandom returned the wrong length');
|
||||
end;
|
||||
{$ELSEIF DEFINED(MSWINDOWS)}
|
||||
const
|
||||
BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;
|
||||
begin
|
||||
if ACount <= 0 then Exit(nil);
|
||||
SetLength(Result, ACount);
|
||||
// Handle nul + drapeau systeme : on prend le generateur par defaut de
|
||||
// Windows sans avoir a ouvrir puis fermer un fournisseur.
|
||||
if BCryptGenRandom(nil, PByte(Result), ACount,
|
||||
BCRYPT_USE_SYSTEM_PREFERRED_RNG) <> 0 then
|
||||
raise EPMACrypto.Create('BCryptGenRandom failed');
|
||||
end;
|
||||
{$ELSE}
|
||||
begin
|
||||
raise EPMACrypto.Create('No secure random source on this platform');
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes;
|
||||
{$IF DEFINED(ANDROID)}
|
||||
var
|
||||
LCipher: JCipher;
|
||||
LKeySpec: JSecretKeySpec;
|
||||
LGcm: JGCMParameterSpec;
|
||||
begin
|
||||
LKeySpec := TJSecretKeySpec.JavaClass.init(ToJavaBytes(AKey),
|
||||
StringToJString('AES'));
|
||||
// 128 bits de tag, comme au dechiffrement et comme WebCrypto.
|
||||
LGcm := TJGCMParameterSpec.JavaClass.init(128, ToJavaBytes(AIv));
|
||||
LCipher := TJCipher.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
|
||||
LCipher.init(TJCipher.JavaClass.ENCRYPT_MODE, LKeySpec, LGcm);
|
||||
// Java rend deja le tag colle a la fin - meme disposition que WebCrypto.
|
||||
Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(APlainText)));
|
||||
end;
|
||||
{$ELSEIF DEFINED(MSWINDOWS)}
|
||||
var
|
||||
LAlg, LKey: Pointer;
|
||||
LInfo: TBCryptAuthCipherModeInfo;
|
||||
LTag, LCt: TBytes;
|
||||
LOutLen: Cardinal;
|
||||
LMode: string;
|
||||
begin
|
||||
LAlg := nil;
|
||||
LKey := nil;
|
||||
if BCryptOpenAlgorithmProvider(LAlg, 'AES', nil, 0) <> 0 then
|
||||
raise EPMACrypto.Create('BCryptOpenAlgorithmProvider(AES) failed');
|
||||
try
|
||||
LMode := 'ChainingModeGCM';
|
||||
if BCryptSetProperty(LAlg, 'ChainingMode', PByte(PWideChar(LMode)),
|
||||
(Length(LMode) + 1) * SizeOf(WideChar), 0) <> 0 then
|
||||
raise EPMACrypto.Create('BCryptSetProperty(GCM) failed');
|
||||
if BCryptGenerateSymmetricKey(LAlg, LKey, nil, 0,
|
||||
PByte(AKey), Length(AKey), 0) <> 0 then
|
||||
raise EPMACrypto.Create('BCryptGenerateSymmetricKey failed');
|
||||
|
||||
// CNG veut le tag DANS la structure, pas dans le flux : on l'y recueille,
|
||||
// puis on le colle a la fin pour rendre la meme forme que WebCrypto.
|
||||
SetLength(LTag, 16);
|
||||
SetLength(LCt, Length(APlainText));
|
||||
FillChar(LInfo, SizeOf(LInfo), 0);
|
||||
LInfo.cbSize := SizeOf(LInfo);
|
||||
LInfo.dwInfoVersion := AUTH_MODE_INFO_VERSION;
|
||||
LInfo.pbNonce := PByte(AIv);
|
||||
LInfo.cbNonce := Length(AIv);
|
||||
LInfo.pbTag := PByte(LTag);
|
||||
LInfo.cbTag := Length(LTag);
|
||||
|
||||
LOutLen := 0;
|
||||
if BCryptEncrypt(LKey, PByte(APlainText), Length(APlainText), @LInfo,
|
||||
nil, 0, PByte(LCt), Length(LCt), LOutLen, 0) <> 0 then
|
||||
raise EPMACrypto.Create('BCryptEncrypt failed');
|
||||
SetLength(LCt, LOutLen);
|
||||
Result := Concat(LCt, LTag);
|
||||
finally
|
||||
if LKey <> nil then BCryptDestroyKey(LKey);
|
||||
BCryptCloseAlgorithmProvider(LAlg, 0);
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
begin
|
||||
raise EPMACrypto.Create('AES-GCM is not wired for this platform');
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Base64 SANS retour a la ligne. TNetEncoding.Base64 coupe a 76 caracteres,
|
||||
/// comme le courrier electronique ; WebCrypto n'en met aucun. Les decodeurs
|
||||
/// tolerants ignorent ces retours - `atob` retire les blancs, Node aussi - mais
|
||||
/// produire exactement la meme forme que l'autre implementation retire une
|
||||
/// dependance a leur tolerance.
|
||||
function B64NoWrap(const ABytes: TBytes): string;
|
||||
var
|
||||
LEnc: TBase64Encoding;
|
||||
begin
|
||||
LEnc := TBase64Encoding.Create(0); // 0 = jamais de coupure
|
||||
try
|
||||
Result := LEnc.EncodeBytesToString(ABytes);
|
||||
finally
|
||||
LEnc.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function EncryptContainer(const APlainJson, APassword: string;
|
||||
AIterations: Integer): string;
|
||||
var
|
||||
LSalt, LKey: TBytes;
|
||||
begin
|
||||
if APassword = '' then
|
||||
raise EPMACrypto.Create('Refusing to encrypt with an empty password');
|
||||
LSalt := SecureRandomBytes(32);
|
||||
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, AIterations, 32);
|
||||
if LKey = nil then
|
||||
LKey := DeriveKeyPBKDF2(APassword, LSalt, AIterations, 32);
|
||||
Result := EncryptContainerWithKey(APlainJson, LKey, LSalt, AIterations);
|
||||
end;
|
||||
|
||||
function EncryptContainerWithKey(const APlainJson: string;
|
||||
const AKey, ASalt: TBytes; AIterations: Integer): string;
|
||||
var
|
||||
LObj: TJSONObject;
|
||||
LIv, LCt: TBytes;
|
||||
begin
|
||||
if (Length(AKey) <> 32) or (Length(ASalt) = 0) then
|
||||
raise EPMACrypto.Create('Cle ou sel absent pour le rechiffrement');
|
||||
|
||||
// L'IV EST TIRE A CHAQUE APPEL, le sel non. Reutiliser un IV avec la meme
|
||||
// cle casse GCM entierement ; reutiliser le sel ne fait que redonner la meme
|
||||
// cle, ce qui est justement l'economie recherchee - le sel ralentit une
|
||||
// attaque sur le mot de passe, il n'a pas a differer d'une version a l'autre.
|
||||
LIv := SecureRandomBytes(12);
|
||||
LCt := AesGcmEncrypt(AKey, LIv, TEncoding.UTF8.GetBytes(APlainJson));
|
||||
|
||||
LObj := TJSONObject.Create;
|
||||
try
|
||||
// Memes cles, memes valeurs que encryptExportPayload cote poste : c'est ce
|
||||
// conteneur-la que les deux clients savent relire.
|
||||
LObj.AddPair('format', 'pm-encrypted-export-v1');
|
||||
LObj.AddPair('kdf', 'pbkdf2-sha256');
|
||||
LObj.AddPair('kdf_iterations', TJSONNumber.Create(AIterations));
|
||||
LObj.AddPair('kdf_salt', B64NoWrap(ASalt));
|
||||
LObj.AddPair('iv', B64NoWrap(LIv));
|
||||
LObj.AddPair('ciphertext', B64NoWrap(LCt));
|
||||
LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss"Z"',
|
||||
TTimeZone.Local.ToUniversalTime(Now)));
|
||||
Result := LObj.ToJSON;
|
||||
finally
|
||||
LObj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function DecryptContainer(const AContainerJson, APassword: string): string;
|
||||
var
|
||||
LKey, LSalt: TBytes;
|
||||
LIter: Integer;
|
||||
begin
|
||||
Result := DecryptContainerEx(AContainerJson, APassword, LKey, LSalt, LIter);
|
||||
end;
|
||||
|
||||
/// L'enveloppe du conteneur, lue UNE SEULE FOIS pour les trois entrees
|
||||
/// publiques. Elle a ete recopiee tant qu'il n'y en avait qu'une ; a trois, une
|
||||
/// divergence entre les copies se paierait par un dechiffrement qui accuse le
|
||||
/// mot de passe.
|
||||
procedure ReadContainer(const AContainerJson: string;
|
||||
out ASalt, AIv, ACt: TBytes; out AIterations: Integer);
|
||||
var
|
||||
LObj: TJSONObject;
|
||||
LFormat, LKdf: string;
|
||||
begin
|
||||
LObj := TJSONObject.ParseJSONValue(AContainerJson) as TJSONObject;
|
||||
if LObj = nil then
|
||||
raise EPMACrypto.Create('Not a JSON container (truncated download?)');
|
||||
try
|
||||
// Checked before anything else: an HTML error page or a half-written file
|
||||
// would otherwise reach the cipher and be reported as a wrong password.
|
||||
LFormat := LObj.GetValue<string>('format', '');
|
||||
if LFormat <> 'pm-encrypted-export-v1' then
|
||||
raise EPMACrypto.CreateFmt('Unknown container format: "%s"', [LFormat]);
|
||||
|
||||
LKdf := LObj.GetValue<string>('kdf', '');
|
||||
if LKdf <> 'pbkdf2-sha256' then
|
||||
raise EPMACrypto.CreateFmt('Unsupported KDF: "%s"', [LKdf]);
|
||||
|
||||
AIterations := LObj.GetValue<Integer>('kdf_iterations', 600000);
|
||||
ASalt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('kdf_salt', ''));
|
||||
AIv := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('iv', ''));
|
||||
ACt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('ciphertext', ''));
|
||||
|
||||
if (Length(ASalt) = 0) or (Length(AIv) = 0) or (Length(ACt) = 0) then
|
||||
raise EPMACrypto.Create('Container is missing salt, iv or ciphertext');
|
||||
finally
|
||||
LObj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function ContainerKdfParams(const AContainerJson: string;
|
||||
out ASalt: TBytes; out AIterations: Integer): Boolean;
|
||||
var
|
||||
LIv, LCt: TBytes;
|
||||
begin
|
||||
Result := True;
|
||||
try
|
||||
ReadContainer(AContainerJson, ASalt, LIv, LCt, AIterations);
|
||||
except
|
||||
// Une enveloppe illisible n'est pas une erreur ICI : l'appelant va tenter
|
||||
// le dechiffrement, qui dira ce qui ne va pas avec son propre message.
|
||||
Result := False;
|
||||
ASalt := nil;
|
||||
AIterations := 0;
|
||||
end;
|
||||
end;
|
||||
|
||||
function DecryptContainerWithKey(const AContainerJson: string;
|
||||
const AKey: TBytes): string;
|
||||
var
|
||||
LSalt, LIv, LCt: TBytes;
|
||||
LIterations: Integer;
|
||||
begin
|
||||
ReadContainer(AContainerJson, LSalt, LIv, LCt, LIterations);
|
||||
Result := TEncoding.UTF8.GetString(AesGcmDecrypt(AKey, LIv, LCt));
|
||||
end;
|
||||
|
||||
function DecryptContainerEx(const AContainerJson, APassword: string;
|
||||
out AKey, ASalt: TBytes; out AIterations: Integer): string;
|
||||
var
|
||||
LIterations: Integer;
|
||||
LSalt, LIv, LCt, LKey: TBytes;
|
||||
begin
|
||||
ReadContainer(AContainerJson, LSalt, LIv, LCt, LIterations);
|
||||
// Android's provider first - measured identical to WebCrypto on a
|
||||
// non-ASCII password (so it does encode as UTF-8), and it is platform
|
||||
// code rather than ours. Barely faster though: 3869 ms against 4139 ms,
|
||||
// PBKDF2 gets no hardware acceleration. The Pascal path stays as the
|
||||
// fallback, and is the only one outside Android.
|
||||
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, LIterations, 32);
|
||||
if LKey = nil then
|
||||
LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32);
|
||||
Result := TEncoding.UTF8.GetString(AesGcmDecrypt(LKey, LIv, LCt));
|
||||
// Rendus a l'appelant : ils lui evitent une seconde derivation s'il
|
||||
// rechiffre juste apres, ou s'il rouvre le meme conteneur plus tard.
|
||||
AKey := LKey;
|
||||
ASalt := LSalt;
|
||||
AIterations := LIterations;
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,168 @@
|
||||
unit PMA.Favicon;
|
||||
{
|
||||
L'icone d'un site, par le relais de DuckDuckGo.
|
||||
|
||||
MEME SOURCE QUE LE POSTE, et pour la meme raison. Aller chercher
|
||||
`https://<site>/favicon.ico` en direct ferait resoudre un DNS - et ouvrir une
|
||||
connexion - vers CHAQUE domaine range dans le coffre. La liste des sites ou
|
||||
quelqu'un a un compte est precisement ce que ce coffre protege : la donner au
|
||||
reseau pour un dessin de seize pixels serait un mauvais marche.
|
||||
|
||||
Le relais, lui, ne voit qu'une chose : que quelqu'un s'interesse a ce domaine.
|
||||
C'est deja quelque chose, d'ou le caractere facultatif de l'appel - il ne part
|
||||
que lorsqu'on le demande, entree par entree.
|
||||
|
||||
DEUX ESSAIS : l'hote complet, puis le domaine de second niveau. `chat.qwen.ai`
|
||||
n'a pas d'icone chez le relais, `qwen.ai` en a une.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils;
|
||||
|
||||
/// Rend une URI `data:` prete a etre rangee dans `icon_b64`, ou '' si rien
|
||||
/// n'a ete trouve - AError dit alors pourquoi.
|
||||
function FetchFaviconDataUri(const ASite: string; out AError: string): string;
|
||||
|
||||
/// L'hote d'une adresse, sans schema ni chemin. Public parce que c'est aussi
|
||||
/// ce qu'il faut pour decider s'il y a quelque chose a chercher.
|
||||
function HostOf(const ASite: string): string;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.Classes, System.NetEncoding, System.Net.HttpClient,
|
||||
System.Net.URLClient;
|
||||
|
||||
const
|
||||
// 64 Ko : une icone de site en fait quelques-uns. Au-dela, ce n'est pas une
|
||||
// icone, et l'accepter ferait gonfler l'instantane que chaque enregistrement
|
||||
// republie en entier.
|
||||
MAX_BYTES = 64 * 1024;
|
||||
TIMEOUT_MS = 5000;
|
||||
|
||||
function HostOf(const ASite: string): string;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := Trim(ASite);
|
||||
if Result = '' then Exit;
|
||||
I := Pos('://', Result);
|
||||
if I > 0 then Result := Copy(Result, I + 3, MaxInt);
|
||||
// Chemin, requete, ancre, et les identifiants d'une URL a la user@host.
|
||||
I := Pos('/', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
|
||||
I := Pos('?', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
|
||||
I := Pos('#', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
|
||||
I := LastDelimiter('@', Result);
|
||||
if I > 0 then Result := Copy(Result, I + 1, MaxInt);
|
||||
I := Pos(':', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
|
||||
Result := LowerCase(Trim(Result));
|
||||
// Un site note "Gitea" plutot qu'en adresse n'a pas d'hote : sans point, il
|
||||
// n'y a rien a demander au relais.
|
||||
if Pos('.', Result) = 0 then Result := '';
|
||||
end;
|
||||
|
||||
/// Le domaine de second niveau, ou '' s'il n'y a rien de plus court a essayer.
|
||||
function SecondLevel(const AHost: string): string;
|
||||
var
|
||||
LParts: TArray<string>;
|
||||
begin
|
||||
Result := '';
|
||||
LParts := AHost.Split(['.']);
|
||||
if Length(LParts) < 3 then Exit; // deja un SLD
|
||||
Result := LParts[High(LParts) - 1] + '.' + LParts[High(LParts)];
|
||||
end;
|
||||
|
||||
/// Le type d'apres les premiers octets, jamais d'apres l'extension : le relais
|
||||
/// rend du PNG sous une adresse en .ico, et un type faux ferait afficher un
|
||||
/// cadre vide sans rien dire.
|
||||
function SniffMime(const AData: TBytes): string;
|
||||
begin
|
||||
Result := '';
|
||||
if Length(AData) < 4 then Exit;
|
||||
if (AData[0] = $89) and (AData[1] = Ord('P')) then Result := 'image/png'
|
||||
else if (AData[0] = $FF) and (AData[1] = $D8) then Result := 'image/jpeg'
|
||||
else if (AData[0] = Ord('G')) and (AData[1] = Ord('I')) then Result := 'image/gif'
|
||||
else if (AData[0] = 0) and (AData[1] = 0) and (AData[2] = 1) then
|
||||
Result := 'image/x-icon'
|
||||
else if (AData[0] = Ord('<')) then Result := 'image/svg+xml';
|
||||
end;
|
||||
|
||||
function TryHost(const AHost: string; out AData: TBytes): Boolean;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LStream: TBytesStream;
|
||||
LResp: IHTTPResponse;
|
||||
begin
|
||||
Result := False;
|
||||
AData := nil;
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := TIMEOUT_MS;
|
||||
LHttp.ResponseTimeout := TIMEOUT_MS;
|
||||
LHttp.MaxRedirects := 3;
|
||||
LStream := TBytesStream.Create;
|
||||
try
|
||||
try
|
||||
LResp := LHttp.Get('https://icons.duckduckgo.com/ip3/' + AHost + '.ico',
|
||||
LStream);
|
||||
except
|
||||
Exit;
|
||||
end;
|
||||
if LResp.StatusCode <> 200 then Exit;
|
||||
if (LStream.Size = 0) or (LStream.Size > MAX_BYTES) then Exit;
|
||||
AData := Copy(LStream.Bytes, 0, LStream.Size);
|
||||
Result := True;
|
||||
finally
|
||||
LStream.Free;
|
||||
end;
|
||||
finally
|
||||
LHttp.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function FetchFaviconDataUri(const ASite: string; out AError: string): string;
|
||||
var
|
||||
LHost, LSld, LMime: string;
|
||||
LData: TBytes;
|
||||
LB64: TBase64Encoding;
|
||||
begin
|
||||
Result := '';
|
||||
AError := '';
|
||||
LHost := HostOf(ASite);
|
||||
if LHost = '' then
|
||||
begin
|
||||
AError := 'this entry has no web address';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if not TryHost(LHost, LData) then
|
||||
begin
|
||||
LSld := SecondLevel(LHost);
|
||||
if (LSld = '') or not TryHost(LSld, LData) then
|
||||
begin
|
||||
AError := 'no icon found for ' + LHost;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
LMime := SniffMime(LData);
|
||||
if LMime = '' then
|
||||
begin
|
||||
AError := 'what came back is not an image';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// SANS COUPURE DE LIGNE. L'encodeur de Delphi coupe a 76 caracteres par
|
||||
// defaut ; une URI data: coupee ne s'affiche pas, et le piege s'est deja
|
||||
// paye une fois sur les conteneurs.
|
||||
LB64 := TBase64Encoding.Create(0);
|
||||
try
|
||||
Result := 'data:' + LMime + ';base64,' + LB64.EncodeBytesToString(LData);
|
||||
finally
|
||||
LB64.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,14 @@
|
||||
// Modele. Copier en PMA.LocalConfig.inc (ignore par git) et remplir.
|
||||
//
|
||||
// VALEURS DE PREMIER LANCEMENT SEULEMENT. Une fois que l'appareil a enregistre
|
||||
// ses reglages, elles ne sont plus lues. Laisser vide est parfaitement viable :
|
||||
// l'application demande alors de saisir jeton et mot de passe, ce qui est le
|
||||
// comportement d'une vraie installation.
|
||||
//
|
||||
// Le jeton authentifie l'appareil aupres de l'endpoint ; le mot de passe de
|
||||
// sync ouvre TOUT le coffre une fois le conteneur telecharge. Aucun des deux
|
||||
// n'a sa place dans un depot.
|
||||
|
||||
const
|
||||
FIRSTRUN_TOKEN = '';
|
||||
TEST_PWD = '';
|
||||
@@ -0,0 +1,136 @@
|
||||
unit PMA.SaveFile;
|
||||
{
|
||||
Deposer un fichier la ou l'utilisateur le retrouvera.
|
||||
|
||||
LA QUESTION N'ETAIT PAS LE CODE, C'ETAIT L'ENDROIT. Le repertoire prive de
|
||||
l'application ne sert a rien : aucun gestionnaire de fichiers ne l'ouvre, et
|
||||
il part avec la desinstallation. Le stockage externe direct est ferme depuis
|
||||
Android 10. Restent trois portes, et une seule ne demande ni permission ni
|
||||
ecran supplementaire :
|
||||
|
||||
- MediaStore -> Telechargements : le systeme cree l'entree, nous ecrivons
|
||||
dedans, le fichier apparait dans "Telechargements" avec les autres. AUCUNE
|
||||
permission depuis l'API 29, parce que l'application n'ecrit que ce qu'elle
|
||||
a elle-meme insere.
|
||||
- Le selecteur de destination (ACTION_CREATE_DOCUMENT) donnerait le choix,
|
||||
mais il exige un retour d'activite, que FMX rend penible.
|
||||
- Le partage (ACTION_SEND) exigerait un FileProvider declare au manifeste.
|
||||
|
||||
Donc MediaStore. En dessous de l'API 29 il faudrait WRITE_EXTERNAL_STORAGE,
|
||||
une permission qu'on vient justement de retirer des dix defauts de Delphi :
|
||||
on refuse et on le dit, plutot que de la reprendre pour des versions
|
||||
d'Android que personne ici n'utilise.
|
||||
|
||||
CE QUI SORT DE L'APPLICATION SORT DE SA PROTECTION. Le fichier depose est
|
||||
en clair, lisible par tout ce qui lit les telechargements. C'est ce que
|
||||
l'utilisateur demande en appuyant sur "enregistrer", et c'est pour cela que
|
||||
l'appel le dit dans son nom plutot que de s'appeler "export".
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils;
|
||||
|
||||
/// Ecrit ADonnees dans Telechargements sous AFileName.
|
||||
/// Rend le nom retenu par le systeme, ou '' en cas d'echec - AError dit alors
|
||||
/// pourquoi.
|
||||
function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes;
|
||||
out AError: string): string;
|
||||
|
||||
implementation
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
// UNE SEULE CLAUSE uses PAR SECTION : deux d'affilee, meme separees par une
|
||||
// directive, sont une erreur de syntaxe et non un ajout.
|
||||
uses
|
||||
Androidapi.Helpers, Androidapi.JNI.JavaTypes, Androidapi.JNI.Os,
|
||||
Androidapi.JNI.GraphicsContentViewText, Androidapi.JNI.Provider,
|
||||
// Androidapi.JNI.Net pour Jnet_Uri : le resolveur rend une URI, pas un
|
||||
// chemin - c'est elle qui designe la ligne que le systeme vient de creer.
|
||||
Androidapi.JNI.Net, Androidapi.JNIBridge;
|
||||
|
||||
const
|
||||
// MediaStore.MediaColumns, ecrites en dur comme EXTRA_IS_SENSITIVE ailleurs :
|
||||
// les noms de colonnes sont stables depuis toujours, et les chercher dans les
|
||||
// en-tetes coute plus que de les lire ici.
|
||||
COL_NAME = '_display_name';
|
||||
COL_MIME = 'mime_type';
|
||||
COL_PATH = 'relative_path';
|
||||
|
||||
function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes;
|
||||
out AError: string): string;
|
||||
var
|
||||
LValues: JContentValues;
|
||||
LUri: Jnet_Uri;
|
||||
LOut: JOutputStream;
|
||||
LArr: TJavaArray<Byte>;
|
||||
LMime: string;
|
||||
begin
|
||||
Result := '';
|
||||
AError := '';
|
||||
if Length(AData) = 0 then
|
||||
begin
|
||||
AError := 'nothing to write';
|
||||
Exit;
|
||||
end;
|
||||
if TJBuild_VERSION.JavaClass.SDK_INT < 29 then
|
||||
begin
|
||||
AError := 'saving files needs Android 10 or later';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LMime := AMime;
|
||||
// Un type vide ferait ranger le fichier sans que rien ne sache l'ouvrir.
|
||||
if LMime = '' then LMime := 'application/octet-stream';
|
||||
|
||||
try
|
||||
LValues := TJContentValues.JavaClass.init;
|
||||
LValues.put(StringToJString(COL_NAME), StringToJString(AFileName));
|
||||
LValues.put(StringToJString(COL_MIME), StringToJString(LMime));
|
||||
// Le systeme se charge des doublons : deux fichiers du meme nom deviennent
|
||||
// "nom" et "nom (1)". Rien a gerer de notre cote.
|
||||
LValues.put(StringToJString(COL_PATH), StringToJString('Download'));
|
||||
|
||||
LUri := TAndroidHelper.Context.getContentResolver.insert(
|
||||
TJMediaStore_Downloads.JavaClass.EXTERNAL_CONTENT_URI, LValues);
|
||||
if LUri = nil then
|
||||
begin
|
||||
AError := 'the system refused to create the file';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LOut := TAndroidHelper.Context.getContentResolver.openOutputStream(LUri);
|
||||
if LOut = nil then
|
||||
begin
|
||||
AError := 'the system gave no way to write';
|
||||
Exit;
|
||||
end;
|
||||
LArr := TJavaArray<Byte>.Create(Length(AData));
|
||||
try
|
||||
Move(AData[0], LArr.Data^, Length(AData));
|
||||
LOut.write(LArr);
|
||||
LOut.flush;
|
||||
finally
|
||||
LOut.close;
|
||||
LArr.Free;
|
||||
end;
|
||||
Result := AFileName;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
AError := E.ClassName + ': ' + E.Message;
|
||||
Result := '';
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes;
|
||||
out AError: string): string;
|
||||
begin
|
||||
Result := '';
|
||||
AError := 'Downloads are Android-only';
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,129 @@
|
||||
unit PMA.ScreenGuard;
|
||||
{
|
||||
FLAG_SECURE : interdit la capture d'ecran et l'apparition du contenu dans
|
||||
l'apercu des applications recentes.
|
||||
|
||||
COMPLEMENTAIRE, pas redondant, avec le masquage sur WillBecomeInactive :
|
||||
celui-ci traite l'apercu des recentes, celui-la traite en plus les captures
|
||||
manuelles, l'enregistrement d'ecran et la duplication vers un ecran externe -
|
||||
qu'aucun code applicatif ne peut intercepter.
|
||||
|
||||
ACTIF EN RELEASE SEULEMENT. En debug le drapeau empeche aussi les captures
|
||||
d'ecran de developpement, ce qui rend le diagnostic penible pour un gain nul
|
||||
sur un appareil de test. Le verifier demande donc une compilation Release.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.UITypes;
|
||||
|
||||
/// A appeler une fois, au demarrage, depuis le thread principal.
|
||||
procedure SecureScreen;
|
||||
|
||||
/// Teinte les barres systeme - etat en haut, navigation en bas.
|
||||
///
|
||||
/// Sans cela l'application flotte dans un cadre qui ne lui appartient pas :
|
||||
/// barre d'etat grise, barre de navigation BLANCHE sous un fond bleu. La
|
||||
/// reference les fait baigner dans le meme degrade, et c'est ce qui donne
|
||||
/// l'impression que l'application occupe l'ecran plutot que d'y etre posee.
|
||||
procedure TintSystemBars(ATop, ABottom: TAlphaColor);
|
||||
|
||||
/// Replie le clavier, et dit s'il etait bien deplie.
|
||||
///
|
||||
/// LA REPONSE COMPTE AUTANT QUE L'ACTION. La touche retour arrive chez nous
|
||||
/// avant le clavier - la vue web n'est pas un champ que le systeme connait -
|
||||
/// et notre gestionnaire la consommait toujours, verrouillant le coffre ou
|
||||
/// remontant a la liste pendant que le clavier restait deplie, sans plus aucun
|
||||
/// moyen de le refermer. Savoir s'il y avait quelque chose a replier permet de
|
||||
/// rendre a la touche son premier sens et de ne consommer que dans ce cas.
|
||||
///
|
||||
/// A appeler depuis le thread d'interface, et sans differer : celui qui appelle
|
||||
/// decide de la suite d'apres la reponse.
|
||||
function HideSoftKeyboard: Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
uses
|
||||
// System.SysUtils : sans elle, TAndroidHelper.GetJActivity reste un appel
|
||||
// non expanse et le compilateur le signale a chaque usage.
|
||||
System.SysUtils, Androidapi.JNI.JavaTypes, Androidapi.Helpers, Androidapi.JNI.App,
|
||||
Androidapi.JNI.GraphicsContentViewText, FMX.Helpers.Android;
|
||||
|
||||
procedure SecureScreen;
|
||||
begin
|
||||
// La condition est ICI, dans le corps, et non sur la clause uses : celle-ci
|
||||
// sert aussi a TintSystemBars, qui doit fonctionner en debug.
|
||||
{$IFNDEF DEBUG}
|
||||
// setFlags touche la fenetre : thread d'interface obligatoire.
|
||||
CallInUIThread(
|
||||
procedure
|
||||
begin
|
||||
TAndroidHelper.Activity.getWindow.setFlags(
|
||||
TJWindowManager_LayoutParams.JavaClass.FLAG_SECURE,
|
||||
TJWindowManager_LayoutParams.JavaClass.FLAG_SECURE);
|
||||
end);
|
||||
{$ENDIF}
|
||||
end;
|
||||
{$ELSE}
|
||||
procedure SecureScreen;
|
||||
begin
|
||||
// Rien a faire : ce n'est pas Android.
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
procedure TintSystemBars(ATop, ABottom: TAlphaColor);
|
||||
begin
|
||||
CallInUIThread(
|
||||
procedure
|
||||
var
|
||||
LWindow: JWindow;
|
||||
begin
|
||||
try
|
||||
LWindow := TAndroidHelper.Activity.getWindow;
|
||||
// Les deux couleurs different : le haut prend le depart du degrade,
|
||||
// le bas son arrivee, pour que la continuite soit exacte.
|
||||
LWindow.setStatusBarColor(Integer(ATop));
|
||||
LWindow.setNavigationBarColor(Integer(ABottom));
|
||||
except
|
||||
// Une barre non teintee n'empeche rien de fonctionner.
|
||||
end;
|
||||
end);
|
||||
end;
|
||||
{$ELSE}
|
||||
procedure TintSystemBars(ATop, ABottom: TAlphaColor);
|
||||
begin
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
function HideSoftKeyboard: Boolean;
|
||||
var
|
||||
LService: JObject;
|
||||
LView: JView;
|
||||
begin
|
||||
Result := False;
|
||||
try
|
||||
LView := TAndroidHelper.Activity.getWindow.getDecorView;
|
||||
if LView = nil then Exit;
|
||||
LService := TAndroidHelper.Context.getSystemService(
|
||||
TJContext.JavaClass.INPUT_METHOD_SERVICE);
|
||||
if LService = nil then Exit;
|
||||
// Rend False si rien n'etait deplie : c'est notre reponse.
|
||||
Result := TJInputMethodManager.Wrap(LService)
|
||||
.hideSoftInputFromWindow(LView.getWindowToken, 0);
|
||||
except
|
||||
// Un clavier qui ne se replie pas ne doit pas emporter la touche retour.
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
function HideSoftKeyboard: Boolean;
|
||||
begin
|
||||
Result := False;
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
end.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
unit PMA.SyncClient;
|
||||
{
|
||||
Lecture de l'instantane chiffre. DEUX TRANSPORTS, reconnus a l'URL :
|
||||
|
||||
- un depot Gitea prive, via l'API des contenus. C'est la destination : plus
|
||||
rien a heberger, HTTPS et certificat deja en place, acces depuis
|
||||
l'exterieur gratuit.
|
||||
- l'endpoint PHP historique (server-php/sync.php), garde le temps que le
|
||||
poste Windows publie aussi dans le depot. Sans lui, la migration exigerait
|
||||
de tout basculer d'un coup, des deux cotes, sans retour possible.
|
||||
|
||||
Ne dechiffre rien : il ramene des octets opaques et l'empreinte qui les
|
||||
identifie. Le dechiffrement est le travail de PMA.Crypto, et il coute
|
||||
4 secondes - d'ou l'importance de cette empreinte, qui evite de le refaire
|
||||
quand rien n'a change.
|
||||
|
||||
CE QUE L'EMPREINTE DESIGNE DIFFERE selon le transport, mais son role est le
|
||||
meme et l'appelant n'a pas a le savoir : l'ETag HTTP pour PHP, le sha du blob
|
||||
pour Gitea. Sur Gitea la comparaison se fait ICI, apres reception : l'API ne
|
||||
connait pas If-None-Match, donc le corps est retelecharge - ce qui est
|
||||
bon marche - mais la derivation, elle, est bien evitee.
|
||||
|
||||
Le PHP repond 404 aussi bien pour un jeton invalide que pour un instantane
|
||||
absent : c'est voulu (un appelant non authentifie n'apprend pas ce qui
|
||||
existe), et le client ne peut donc pas les distinguer. Gitea repond de meme
|
||||
sur un depot prive. Le message utilisateur doit couvrir les deux cas.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils, System.Classes, System.Net.HttpClient,
|
||||
System.Net.URLClient, System.Net.HttpClientComponent;
|
||||
|
||||
type
|
||||
TSyncStatus = (
|
||||
ssOK, // 200 : nouveau contenu dans Container
|
||||
ssNotModified, // 304 : l'ETag detenu est toujours le bon
|
||||
ssNoSnapshot, // 404 : pas d'instantane, OU jeton invalide
|
||||
ssRefused, // 403 : HTTPS exige par le serveur
|
||||
ssHttpError, // autre code
|
||||
ssNetworkError, // rien n'a repondu
|
||||
/// 409 ou 422 : le sha envoye n'est plus celui du fichier - un autre appareil a
|
||||
/// publie entre notre lecture et notre ecriture. L'appelant doit relire,
|
||||
/// refusionner et renvoyer, pas reessayer tel quel.
|
||||
ssConflict
|
||||
);
|
||||
|
||||
TSyncResult = record
|
||||
Status: TSyncStatus;
|
||||
Container: string; // le JSON chiffre, vide sauf sur ssOK
|
||||
/// Ce qui identifie ce contenu : ETag HTTP sur PHP, sha du blob sur Gitea.
|
||||
/// A ranger avec le clair et a repasser au prochain appel.
|
||||
ETag: string;
|
||||
HttpCode: Integer;
|
||||
Error: string; // message brut, pour le journal
|
||||
end;
|
||||
|
||||
TSyncClient = class
|
||||
private
|
||||
FBaseUrl: string;
|
||||
FToken: string;
|
||||
FTimeoutMs: Integer;
|
||||
public
|
||||
constructor Create(const ABaseUrl, AToken: string);
|
||||
|
||||
/// GET de l'instantane. AKnownETag non vide -> ssNotModified si le serveur
|
||||
/// rend le meme contenu, ce qui epargne la derivation de la cle.
|
||||
function Fetch(const AKnownETag: string = ''): TSyncResult;
|
||||
|
||||
/// Vrai si l'URL designe l'API des contenus de Gitea. C'est le seul
|
||||
/// discriminant : le chemin de cette API est fixe, et une URL de dépôt qui
|
||||
/// n'y ressemble pas est de toute facon inutilisable.
|
||||
function IsGitea: Boolean;
|
||||
|
||||
/// Publie un conteneur. ASha est celui du fichier qu'on a lu : Gitea s'en
|
||||
/// sert pour refuser (409) une ecriture si quelqu'un d'autre a publie
|
||||
/// entre-temps. Vide = creation d'un fichier qui n'existait pas.
|
||||
///
|
||||
/// GITEA SEULEMENT pour l'instant. L'endpoint PHP repond 405 au PUT, et
|
||||
/// lui ajouter l'ecriture n'a plus d'interet maintenant que le depot fait
|
||||
/// le meme travail avec un controle de concurrence eprouve.
|
||||
function Put(const AContainerJson, ASha, AMessage: string): TSyncResult;
|
||||
|
||||
property BaseUrl: string read FBaseUrl write FBaseUrl;
|
||||
property Token: string read FToken write FToken;
|
||||
/// Volontairement court : sur mobile, un reseau qui ne repond pas doit
|
||||
/// rendre la main vite plutot que de figer l'ecran.
|
||||
property TimeoutMs: Integer read FTimeoutMs write FTimeoutMs;
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.JSON, System.NetEncoding;
|
||||
|
||||
function TSyncClient.IsGitea: Boolean;
|
||||
begin
|
||||
Result := FBaseUrl.ToLower.Contains('/api/v1/repos/');
|
||||
end;
|
||||
|
||||
/// Le sha du fichier ecrit, tire de la reponse au PUT : Gitea rend
|
||||
/// { "content": { "sha": ... }, "commit": { ... } }. Vide si la forme change -
|
||||
/// l'appelant relira, ce qui coute un appel mais ne perd rien.
|
||||
function ShaFromPutResponse(const ABody: string): string;
|
||||
var
|
||||
LRoot, LContent: TJSONObject;
|
||||
begin
|
||||
Result := '';
|
||||
LRoot := TJSONObject.ParseJSONValue(ABody) as TJSONObject;
|
||||
if LRoot = nil then Exit;
|
||||
try
|
||||
if LRoot.TryGetValue<TJSONObject>('content', LContent) then
|
||||
Result := LContent.GetValue<string>('sha', '');
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
/// Extrait le conteneur et le sha de la reponse de l'API des contenus. Rend
|
||||
/// faux, avec la raison, si la reponse n'a pas la forme attendue - une URL de
|
||||
/// dépôt juste a cote rend du JSON, mais pas celui-la.
|
||||
function ParseGiteaContents(const ABody: string;
|
||||
out AContainer, ASha, AError: string): Boolean;
|
||||
var
|
||||
LRoot: TJSONObject;
|
||||
LEncoded, LEncoding: string;
|
||||
begin
|
||||
Result := False;
|
||||
AContainer := '';
|
||||
ASha := '';
|
||||
LRoot := TJSONObject.ParseJSONValue(ABody) as TJSONObject;
|
||||
if LRoot = nil then
|
||||
begin
|
||||
AError := 'reponse Gitea illisible (pas du JSON)';
|
||||
Exit;
|
||||
end;
|
||||
try
|
||||
ASha := LRoot.GetValue<string>('sha', '');
|
||||
LEncoding := LRoot.GetValue<string>('encoding', '');
|
||||
LEncoded := LRoot.GetValue<string>('content', '');
|
||||
if (LEncoded = '') or (ASha = '') then
|
||||
begin
|
||||
AError := 'reponse Gitea sans content/sha - URL de contenu correcte ?';
|
||||
Exit;
|
||||
end;
|
||||
if not SameText(LEncoding, 'base64') then
|
||||
begin
|
||||
AError := 'encodage Gitea inattendu : ' + LEncoding;
|
||||
Exit;
|
||||
end;
|
||||
// Le base64 de Gitea est decoupe en lignes ; le decodeur s'en accommode,
|
||||
// mais on ne compte pas dessus.
|
||||
LEncoded := LEncoded.Replace(#13, '').Replace(#10, '');
|
||||
try
|
||||
AContainer := TEncoding.UTF8.GetString(TNetEncoding.Base64.DecodeStringToBytes(LEncoded));
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
AError := 'base64 Gitea invalide : ' + E.Message;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
Result := AContainer.Trim <> '';
|
||||
if not Result then AError := 'fichier vide dans le depot';
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TSyncClient.Put(const AContainerJson, ASha, AMessage: string): TSyncResult;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LResp: IHTTPResponse;
|
||||
LEnv: TJSONObject;
|
||||
LReq, LResync: TStringStream;
|
||||
LB64: TBase64Encoding;
|
||||
begin
|
||||
Result := Default(TSyncResult);
|
||||
Result.Status := ssNetworkError;
|
||||
|
||||
if not IsGitea then
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := 'Ecriture disponible seulement vers un depot Gitea';
|
||||
Exit;
|
||||
end;
|
||||
if (FBaseUrl = '') or (FToken = '') then
|
||||
begin
|
||||
Result.Error := 'URL ou jeton non configure';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LEnv := TJSONObject.Create;
|
||||
LB64 := TBase64Encoding.Create(0); // pas de coupure : voir PMA.Crypto
|
||||
try
|
||||
LEnv.AddPair('message', AMessage);
|
||||
LEnv.AddPair('content',
|
||||
LB64.EncodeBytesToString(TEncoding.UTF8.GetBytes(AContainerJson)));
|
||||
// OMIS, PAS VIDE, quand on cree le fichier : Gitea lit la presence de la
|
||||
// cle comme « remplace exactement cette version-la », et une chaine vide
|
||||
// n'est pas la meme chose que son absence.
|
||||
if ASha <> '' then LEnv.AddPair('sha', ASha);
|
||||
// `branch` reste absent : le depot utilise alors sa branche par defaut,
|
||||
// plutot qu'un nom qu'on devinerait.
|
||||
LReq := TStringStream.Create(LEnv.ToJSON, TEncoding.UTF8);
|
||||
LResync := TStringStream.Create('', TEncoding.UTF8);
|
||||
try
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := FTimeoutMs;
|
||||
LHttp.ResponseTimeout := FTimeoutMs;
|
||||
LHttp.HandleRedirects := False;
|
||||
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
|
||||
LHttp.CustomHeaders['Content-Type'] := 'application/json';
|
||||
try
|
||||
LResp := LHttp.Put(FBaseUrl, LReq, LResync);
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Result.Status := ssNetworkError;
|
||||
Result.Error := E.ClassName + ': ' + E.Message;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
Result.HttpCode := LResp.StatusCode;
|
||||
case LResp.StatusCode of
|
||||
200, 201:
|
||||
begin
|
||||
Result.Status := ssOK;
|
||||
// Le sha du fichier VENANT D'ETRE ECRIT, a garder pour la
|
||||
// prochaine ecriture - sans lui il faudrait relire pour publier.
|
||||
Result.ETag := ShaFromPutResponse(LResync.DataString);
|
||||
end;
|
||||
// 409 ET 422 : DANS LES DEUX CAS LE SHA N'EST PLUS LE BON.
|
||||
//
|
||||
// Gitea rend 409 quand le sha envoye ne correspond plus, et 422 quand
|
||||
// il manque ou qu'il juge la demande irrecevable - dont le cas « ce
|
||||
// fichier existe deja » d'une creation. Mesure a l'appui : un poste
|
||||
// qui publie entre notre lecture et notre ecriture nous a valu un
|
||||
// 422, pas un 409, et l'enregistrement echouait au lieu de reprendre.
|
||||
//
|
||||
// Relire et recommencer est la bonne reponse aux deux, et elle est
|
||||
// sans danger : la reprise republie avec le sha frais, donc elle
|
||||
// n'ecrase jamais rien. Un vrai corps mal forme echouerait de nouveau
|
||||
// et sortirait au bout de trois essais, avec le texte de Gitea.
|
||||
409, 422: Result.Status := ssConflict;
|
||||
else
|
||||
Result.Status := ssHttpError;
|
||||
// LE CORPS, TOUJOURS. Il etait remplace par un message fige sur 401
|
||||
// et 403 - « jeton sans droit d'ecriture » - ce qui est une
|
||||
// interpretation, pas une observation : le meme code couvre un depot
|
||||
// en lecture seule, une branche protegee, un jeton d'un autre compte.
|
||||
// Gitea dit lequel dans son corps, et le taire envoie chercher a
|
||||
// cote.
|
||||
Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode,
|
||||
LResp.StatusText, Copy(LResync.DataString, 1, 300)]);
|
||||
end;
|
||||
finally
|
||||
LHttp.Free;
|
||||
end;
|
||||
finally
|
||||
LResync.Free;
|
||||
LReq.Free;
|
||||
end;
|
||||
finally
|
||||
LB64.Free;
|
||||
LEnv.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
constructor TSyncClient.Create(const ABaseUrl, AToken: string);
|
||||
begin
|
||||
inherited Create;
|
||||
FBaseUrl := ABaseUrl.Trim;
|
||||
FToken := AToken.Trim;
|
||||
FTimeoutMs := 15000;
|
||||
end;
|
||||
|
||||
function TSyncClient.Fetch(const AKnownETag: string): TSyncResult;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LResp: IHTTPResponse;
|
||||
LStream: TStringStream;
|
||||
begin
|
||||
Result := Default(TSyncResult);
|
||||
Result.Status := ssNetworkError;
|
||||
|
||||
if (FBaseUrl = '') or (FToken = '') then
|
||||
begin
|
||||
Result.Error := 'URL ou jeton non configure';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := FTimeoutMs;
|
||||
LHttp.ResponseTimeout := FTimeoutMs;
|
||||
// Pas de redirection suivie : une redirection vers un autre hote nous
|
||||
// ferait envoyer le jeton ailleurs.
|
||||
LHttp.HandleRedirects := False;
|
||||
// Gitea attend "token <PAT>" pour un jeton d'acces personnel ; le PHP
|
||||
// attend "Bearer". Envoyer l'un a la place de l'autre donne un 401 qui ne
|
||||
// dit pas pourquoi.
|
||||
if IsGitea then
|
||||
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
|
||||
else
|
||||
begin
|
||||
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken;
|
||||
// Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait
|
||||
// repondre 200 avec un corps complet de toute facon.
|
||||
if AKnownETag <> '' then
|
||||
LHttp.CustomHeaders['If-None-Match'] := AKnownETag;
|
||||
end;
|
||||
|
||||
LStream := TStringStream.Create('', TEncoding.UTF8);
|
||||
try
|
||||
try
|
||||
LResp := LHttp.Get(FBaseUrl, LStream);
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Result.Status := ssNetworkError;
|
||||
Result.Error := E.ClassName + ': ' + E.Message;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
Result.HttpCode := LResp.StatusCode;
|
||||
Result.ETag := LResp.HeaderValue['ETag'];
|
||||
|
||||
case LResp.StatusCode of
|
||||
200:
|
||||
if IsGitea then
|
||||
begin
|
||||
if not ParseGiteaContents(LStream.DataString, Result.Container,
|
||||
Result.ETag, Result.Error) then
|
||||
Result.Status := ssHttpError
|
||||
// Meme sha : le contenu est celui qu'on detient deja. On rend la
|
||||
// meme reponse qu'un 304 pour que l'appelant epargne la derivation,
|
||||
// et on jette le conteneur - il ne servirait a rien.
|
||||
//
|
||||
// COMPARAISON INSENSIBLE A LA CASSE ET AUX BLANCS. Une empreinte
|
||||
// hexadecimale ne peut pas differer seulement par la casse ou par
|
||||
// un espace : trancher ainsi ne peut donc pas confondre deux
|
||||
// instantanes distincts, alors qu'une egalite stricte se laisse
|
||||
// battre par un caractere invisible - un guillemet d'en-tete, une
|
||||
// fin de ligne - et fait payer une derivation complete pour rien.
|
||||
else if (AKnownETag <> '') and
|
||||
SameText(Result.ETag.Trim, AKnownETag.Trim) then
|
||||
begin
|
||||
Result.Status := ssNotModified;
|
||||
Result.Container := '';
|
||||
end
|
||||
else
|
||||
Result.Status := ssOK;
|
||||
end
|
||||
else
|
||||
begin
|
||||
Result.Status := ssOK;
|
||||
Result.Container := LStream.DataString;
|
||||
// Un corps vide avec un 200 signale un proxy ou une erreur de
|
||||
// configuration, pas un instantane : le signaler ici evite un
|
||||
// "mot de passe incorrect" trompeur plus loin.
|
||||
if Result.Container.Trim = '' then
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := '200 avec un corps vide';
|
||||
end;
|
||||
end;
|
||||
304: Result.Status := ssNotModified;
|
||||
403:
|
||||
begin
|
||||
Result.Status := ssRefused;
|
||||
Result.Error := 'Le serveur exige HTTPS';
|
||||
end;
|
||||
// Gitea repond 404 sur un depot prive qu'on n'a pas le droit de voir,
|
||||
// comme sur un fichier absent - exactement l'ambiguite du PHP.
|
||||
404: Result.Status := ssNoSnapshot;
|
||||
401:
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := '401 : jeton refuse';
|
||||
end;
|
||||
else
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := 'HTTP ' + LResp.StatusCode.ToString + ' ' + LResp.StatusText;
|
||||
end;
|
||||
finally
|
||||
LStream.Free;
|
||||
end;
|
||||
finally
|
||||
LHttp.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,39 @@
|
||||
unit PMA.TestVector;
|
||||
{
|
||||
The testvector.json container, embedded verbatim.
|
||||
|
||||
Generated by a script - do not edit by hand. It exists for the very first
|
||||
run on a device, where the point is to isolate the crypto: no network, no
|
||||
file to deploy, so a failure can only come from the decryption itself.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
const
|
||||
// Vector password. NON-ASCII on purpose: that is the only case telling a
|
||||
// correct PBKDF2 from an approximate one (see testvector.md). Written as
|
||||
// escapes so this file stays pure ASCII and no editor can mangle it.
|
||||
// e-acute = U+00E9, a-grave = U+00E0, u-diaeresis = U+00FC
|
||||
TESTVECTOR_PASSWORD = 'sync-test-password-'#$00E9#$00E0#$00FC;
|
||||
|
||||
// Expected values, to be checked IN THIS ORDER: the key first, the plaintext
|
||||
// second. A wrong key points at PBKDF2 or the password encoding; a correct
|
||||
// key with a failed decryption points at AES-GCM.
|
||||
TESTVECTOR_KEY_HEX = '6bd7683ea78e163062f02681b03f2906fcf5a3a25025005af4e71982e6e2b286';
|
||||
TESTVECTOR_PLAIN_SHA = 'ddaee44f8ac8374f1ece6d9ee2abd104d59cd283556f1c4c07afa95ae1dbd2a7';
|
||||
TESTVECTOR_PLAIN_LEN = 572; // UTF-8 bytes
|
||||
|
||||
TESTVECTOR_JSON =
|
||||
'{' +
|
||||
' "format": "pm-encrypted-export-v1",' +
|
||||
' "kdf": "pbkdf2-sha256",' +
|
||||
' "kdf_iterations": 600000,' +
|
||||
' "kdf_salt": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",' +
|
||||
' "iv": "yMnKy8zNzs/Q0dLT",' +
|
||||
' "ciphertext": "31AqOJ83L2TEFOcYaKEdpqKWGY42I4NUq+jdO7pA3j8+v023ECxjSaEnB0NvbufW6dMCgkK9oFwRvoQlLVSvK6bNBwBinJ4ltlbwKqATR2HGHh5eqiqGZayNNdRIFNJZw/jw9lYy28R6uK421uaMRx+mUBs4LGX9U+F2ayFfVF/VlsaHb5I3YMhLCN2zyMxs3j7Y1s69N09Uonm0LGKQ+SiteuXTHWJzAvHFIduLlxPJhCfEKUap4PolQ5PMohJeJYt+sutg7UY6i0QclxpmF2MY2qpbh3gA2mmSntoI2LN8Q41NMqyVn6JY6kXXpEfD1aNVw6dDCqAw2FQJ9HxE+/GBirt009rdS/l59KEnIYRvgBFnQqRTJZNNncHXXh3TsnQ3opKEyYe5qCxnX6E7wGzRawD/sQn2NNWjR8wwQQ68Cs74/RR8stAeuD/VJhJnkwxXPh8eBXHWFjLgFZuan+Ja2FwkjMhkHvIqbfJpbNwnu1mJ8ABtbGqmEHYSGUvLovIwCoQWjBqewlCFvRg0l4h63mUxuLaXx0SrIA3xhS7HqnYVTBp5/EVAu715jKmeDHviiZw6iws5ujd/u0IDUtJY1uANaF1xTBwcfkCnuonBlI8eMbIlcndrLSUN2slm6KxeUjZ3RSpiE9Izh5ix4kann8lygCAZVGkjswB83LmB1dFNH3oqG3+4qUgv7qR0TRZUSMoopCaSxhsVRd6/uBWu+82Nkbij1jMIzaB1+vTxBEHi5DGBNDfV/0evTgoIkC9wJtlEbz/OVCwC",' +
|
||||
' "created_at": "2026-08-14T12:00:00.000Z"' +
|
||||
'}';
|
||||
|
||||
implementation
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,42 @@
|
||||
unit PMA.Theme;
|
||||
{
|
||||
La palette, et le fond de la forme.
|
||||
|
||||
CE QU'IL RESTE D'UN THEME. Cette unite habillait autrefois des boutons, des
|
||||
surfaces et des libelles peints a la main, et chargeait un style pour les
|
||||
controles natifs qu'on ne reecrivait pas. L'interface est passee au moteur du
|
||||
systeme (android-client/web) : il n'y a plus un seul controle FMX a habiller,
|
||||
donc tout cela est parti - avec le style vendu, qui n'avait plus rien a styler.
|
||||
|
||||
Ce qui subsiste sert au CADRE, que la page ne peut pas atteindre : le fond de
|
||||
la forme, visible un instant avant le premier rendu du moteur, et les deux
|
||||
couleurs que prennent les barres systeme. Elles doivent suivre celles de
|
||||
app.html, sinon la jonction se voit.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
// FMX.Graphics pour TBrush, que PaintBackground prend en parametre.
|
||||
System.UITypes, FMX.Types, FMX.Graphics;
|
||||
|
||||
const
|
||||
// Releve sur la recherche globale du telephone : un degrade bleu, des blocs
|
||||
// qui ne sont que du blanc translucide pose dessus, aucune bordure.
|
||||
COL_BG_TOP = TAlphaColor($FF3E6394); // haut du degrade
|
||||
COL_BG = TAlphaColor($FF1B3563); // bas du degrade
|
||||
|
||||
/// Applique le degrade bleu du fond. A poser sur le Fill de la forme.
|
||||
procedure PaintBackground(AFill: TBrush);
|
||||
|
||||
implementation
|
||||
|
||||
procedure PaintBackground(AFill: TBrush);
|
||||
begin
|
||||
AFill.Kind := TBrushKind.Gradient;
|
||||
AFill.Gradient.Color := COL_BG_TOP;
|
||||
AFill.Gradient.Color1 := COL_BG;
|
||||
// Rien de plus : un TGradient neuf descend deja du haut vers le bas.
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,152 @@
|
||||
unit PMA.Totp;
|
||||
{
|
||||
Codes TOTP (RFC 6238) - la raison principale de sortir son telephone.
|
||||
|
||||
Rien de specifique a Android : base32, HMAC-SHA1, troncature dynamique. Le
|
||||
poste Windows a deja son implementation en JavaScript ; celle-ci est la
|
||||
meme mecanique, verifiee contre les memes vecteurs de la RFC.
|
||||
|
||||
Le secret arrive soit en base32 nu, soit en URI otpauth:// - les deux
|
||||
circulent dans les instantanes selon la facon dont l'entree a ete creee.
|
||||
On accepte les deux, et l'URI fournit en prime sa periode et son nombre de
|
||||
chiffres quand ils different des valeurs par defaut.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils;
|
||||
|
||||
/// Code courant pour ASecret, et le temps restant avant renouvellement.
|
||||
/// Retourne '' si le secret est vide ou illisible - jamais d'exception : un
|
||||
/// mauvais secret ne doit pas empecher l'affichage du reste de l'entree.
|
||||
function TotpNow(const ASecret: string; out ASecondsLeft, APeriod: Integer): string;
|
||||
|
||||
/// Meme chose a un instant donne. Existe pour que les vecteurs de la RFC
|
||||
/// soient verifiables : ils sont tous exprimes a une date fixe.
|
||||
function TotpAt(const ASecret: string; AUnix: Int64;
|
||||
out ASecondsLeft, APeriod: Integer): string;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.Classes, System.Hash, System.DateUtils, System.NetEncoding;
|
||||
|
||||
const
|
||||
B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
function Base32Decode(const S: string): TBytes;
|
||||
var
|
||||
I, LBits, LValue, LIdx, LPos: Integer;
|
||||
LCh: Char;
|
||||
begin
|
||||
SetLength(Result, Length(S)); // majorant : 5 bits par caractere
|
||||
LBits := 0; LValue := 0; LIdx := 0;
|
||||
for I := 1 to Length(S) do
|
||||
begin
|
||||
LCh := UpCase(S[I]);
|
||||
// Espaces, tirets et remplissage sont ignores : les secrets se
|
||||
// recopient souvent par groupes de quatre.
|
||||
LPos := Pos(LCh, B32);
|
||||
if LPos = 0 then Continue;
|
||||
LValue := (LValue shl 5) or (LPos - 1);
|
||||
Inc(LBits, 5);
|
||||
if LBits >= 8 then
|
||||
begin
|
||||
Result[LIdx] := (LValue shr (LBits - 8)) and $FF;
|
||||
Inc(LIdx);
|
||||
Dec(LBits, 8);
|
||||
end;
|
||||
end;
|
||||
SetLength(Result, LIdx);
|
||||
end;
|
||||
|
||||
/// Valeur d'un parametre de requete dans une URI otpauth://, ou '' s'il
|
||||
/// manque. Assez pour secret/period/digits, sans tirer un analyseur d'URI.
|
||||
function UriParam(const AUri, AName: string): string;
|
||||
var
|
||||
LStart, LEnd: Integer;
|
||||
LKey: string;
|
||||
begin
|
||||
Result := '';
|
||||
LKey := '?' + AName + '=';
|
||||
LStart := Pos(LKey, AUri);
|
||||
if LStart = 0 then
|
||||
begin
|
||||
LKey := '&' + AName + '=';
|
||||
LStart := Pos(LKey, AUri);
|
||||
if LStart = 0 then Exit;
|
||||
end;
|
||||
Inc(LStart, Length(LKey));
|
||||
LEnd := LStart;
|
||||
while (LEnd <= Length(AUri)) and (AUri[LEnd] <> '&') do Inc(LEnd);
|
||||
Result := Copy(AUri, LStart, LEnd - LStart);
|
||||
end;
|
||||
|
||||
function TotpNow(const ASecret: string; out ASecondsLeft, APeriod: Integer): string;
|
||||
begin
|
||||
// Toujours en UTC : un decalage horaire ferait un code faux tout en
|
||||
// paraissant parfaitement valide.
|
||||
Result := TotpAt(ASecret,
|
||||
DateTimeToUnix(TTimeZone.Local.ToUniversalTime(Now), True),
|
||||
ASecondsLeft, APeriod);
|
||||
end;
|
||||
|
||||
function TotpAt(const ASecret: string; AUnix: Int64;
|
||||
out ASecondsLeft, APeriod: Integer): string;
|
||||
var
|
||||
LSecret: string;
|
||||
LDigits, LOffset, LBinary, LMod, I: Integer;
|
||||
LKey, LMsg, LHash: TBytes;
|
||||
LCounter: Int64;
|
||||
begin
|
||||
Result := '';
|
||||
ASecondsLeft := 0;
|
||||
APeriod := 30;
|
||||
LDigits := 6;
|
||||
|
||||
LSecret := ASecret.Trim;
|
||||
if LSecret = '' then Exit;
|
||||
|
||||
if LSecret.ToLower.StartsWith('otpauth://') then
|
||||
begin
|
||||
if UriParam(LSecret, 'period') <> '' then
|
||||
APeriod := StrToIntDef(UriParam(LSecret, 'period'), 30);
|
||||
if UriParam(LSecret, 'digits') <> '' then
|
||||
LDigits := StrToIntDef(UriParam(LSecret, 'digits'), 6);
|
||||
LSecret := UriParam(LSecret, 'secret');
|
||||
if LSecret = '' then Exit;
|
||||
end;
|
||||
|
||||
if (APeriod <= 0) or (LDigits < 6) or (LDigits > 8) then Exit;
|
||||
|
||||
LKey := Base32Decode(LSecret);
|
||||
if Length(LKey) = 0 then Exit;
|
||||
|
||||
LCounter := AUnix div APeriod;
|
||||
ASecondsLeft := APeriod - (AUnix mod APeriod);
|
||||
|
||||
// Compteur sur 8 octets, gros-boutiste.
|
||||
SetLength(LMsg, 8);
|
||||
for I := 7 downto 0 do
|
||||
begin
|
||||
LMsg[I] := Byte(LCounter and $FF);
|
||||
LCounter := LCounter shr 8;
|
||||
end;
|
||||
|
||||
LHash := THashSHA1.GetHMACAsBytes(LMsg, LKey);
|
||||
if Length(LHash) < 20 then Exit;
|
||||
|
||||
// Troncature dynamique : les 4 derniers bits designent l'offset.
|
||||
LOffset := LHash[19] and $0F;
|
||||
LBinary := ((LHash[LOffset] and $7F) shl 24) or
|
||||
((LHash[LOffset + 1] and $FF) shl 16) or
|
||||
((LHash[LOffset + 2] and $FF) shl 8) or
|
||||
(LHash[LOffset + 3] and $FF);
|
||||
|
||||
LMod := 1;
|
||||
for I := 1 to LDigits do LMod := LMod * 10;
|
||||
Result := Format('%.*d', [LDigits, LBinary mod LMod]);
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,327 @@
|
||||
unit PMA.Vault;
|
||||
{
|
||||
Le coffre en memoire : lecture de l'instantane dechiffre, et recherche.
|
||||
|
||||
Volontairement des ENREGISTREMENTS et un tableau, pas une hierarchie
|
||||
d'objets : l'instantane est lu d'un bloc, consulte, puis jete au
|
||||
verrouillage. Rien ne justifie de la mutabilite ni des references croisees.
|
||||
|
||||
CE QUE CETTE UNITE MANIPULE EST EN CLAIR. Elle ne doit rien ecrire sur le
|
||||
disque : la persistance passe par PMA.Store, qui chiffre.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils, System.Classes, System.JSON, System.Generics.Collections;
|
||||
|
||||
type
|
||||
/// Un champ personnalise. Le poste Windows nomme la cle "is_secret" - c'est
|
||||
/// lui qui decide si la valeur s'affiche masquee.
|
||||
TVaultField = record
|
||||
Caption, Value: string;
|
||||
Secret: Boolean;
|
||||
end;
|
||||
|
||||
/// Metadonnees d'une piece jointe. LE CONTENU N'EST PAS RETENU : il voyage
|
||||
/// en base64 dans l'instantane et pese lourd - une seule piece jointe a fait
|
||||
/// passer le cache de 13 a 174 Ko. Le garder en memoire pour tout le coffre
|
||||
/// alors que rien ne l'affiche serait payer sans rien acheter. Le jour ou
|
||||
/// l'enregistrement existera, il se relira depuis le JSON brut.
|
||||
TVaultAttachment = record
|
||||
FileName, Mime: string;
|
||||
SizeBytes: Int64;
|
||||
end;
|
||||
|
||||
TVaultEntry = record
|
||||
Uuid, Title, Site, Username, Password, Folder, Tags, Kind, Totp: string;
|
||||
/// Sous-type (carte, cle SSH, serveur...). Vide pour un identifiant ou
|
||||
/// une note ordinaire.
|
||||
Template: string;
|
||||
/// Icone en data URI. Non chiffree, purement cosmetique.
|
||||
IconB64: string;
|
||||
/// ISO 8601 UTC. Sert d'abord a l'affichage ; ce sera l'arbitre de la
|
||||
/// fusion le jour ou le client ecrira.
|
||||
UpdatedAt: string;
|
||||
Fields: TArray<TVaultField>;
|
||||
Attachments: TArray<TVaultAttachment>;
|
||||
Deleted: Boolean;
|
||||
/// Le corps d'une note EST son mot de passe cote stockage : il n'y a pas
|
||||
/// de colonne separee. L'affichage doit donc l'intituler autrement.
|
||||
function IsNote: Boolean;
|
||||
/// Ce qu'on affiche en tete de ligne : le nom donne par l'utilisateur,
|
||||
/// sinon le site, sinon l'identifiant. Une entree sans aucun des trois
|
||||
/// existe (note vide) et ne doit pas apparaitre comme une ligne muette.
|
||||
function Display: string;
|
||||
end;
|
||||
|
||||
TVault = class
|
||||
private
|
||||
FEntries: TArray<TVaultEntry>;
|
||||
FFolders: TArray<string>;
|
||||
FUsername: string;
|
||||
FExportedAt: string;
|
||||
public
|
||||
/// Remplit depuis le JSON dechiffre. Leve si la structure est absente.
|
||||
procedure LoadFromJson(const APlainJson: string);
|
||||
/// Efface le contenu. Appele au verrouillage.
|
||||
procedure Clear;
|
||||
|
||||
/// Indices des entrees correspondant a AQuery, TRIES par nom affiche -
|
||||
/// l'ordre de l'instantane est celui de la base, donc arbitraire a l'oeil.
|
||||
/// Requete vide = tout ce qui n'est pas en corbeille.
|
||||
///
|
||||
/// La recherche porte sur le nom, le site, l'identifiant et les tags -
|
||||
/// PAS sur le mot de passe : personne ne cherche par mot de passe, et le
|
||||
/// comparer allongerait inutilement son sejour en memoire de travail.
|
||||
/// Comme les etiquettes sont couvertes par la recherche texte, il n'y a
|
||||
/// pas de filtre d'etiquette separe : taper son nom suffit.
|
||||
///
|
||||
/// AFolder vide = tous les dossiers.
|
||||
function Search(const AQuery: string; const AFolder: string = '';
|
||||
AIncludeTrash: Boolean = False): TArray<Integer>;
|
||||
|
||||
/// Dossiers declares par l'instantane, dans son ordre.
|
||||
property Folders: TArray<string> read FFolders;
|
||||
|
||||
function Count: Integer;
|
||||
function LiveCount: Integer;
|
||||
function TrashCount: Integer;
|
||||
function Entry(AIndex: Integer): TVaultEntry;
|
||||
|
||||
property Username: string read FUsername;
|
||||
property ExportedAt: string read FExportedAt;
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.Character, System.Generics.Defaults;
|
||||
|
||||
{ TVaultEntry }
|
||||
|
||||
function TVaultEntry.IsNote: Boolean;
|
||||
begin
|
||||
Result := SameText(Kind, 'note');
|
||||
end;
|
||||
|
||||
function TVaultEntry.Display: string;
|
||||
begin
|
||||
Result := Title.Trim;
|
||||
if Result = '' then Result := Site.Trim;
|
||||
if Result = '' then Result := Username.Trim;
|
||||
if Result = '' then Result := '(no name)';
|
||||
end;
|
||||
|
||||
{ TVault }
|
||||
|
||||
procedure TVault.Clear;
|
||||
begin
|
||||
SetLength(FEntries, 0);
|
||||
SetLength(FFolders, 0);
|
||||
FUsername := '';
|
||||
FExportedAt := '';
|
||||
end;
|
||||
|
||||
/// Les tags arrivent en tableau JSON depuis nos conteneurs, mais en chaine
|
||||
/// separee par des virgules depuis un import CSV etranger. On accepte les deux.
|
||||
function TagsToString(const AVal: TJSONValue): string;
|
||||
var
|
||||
LArr: TJSONArray;
|
||||
I: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
if AVal = nil then Exit;
|
||||
if AVal is TJSONArray then
|
||||
begin
|
||||
LArr := AVal as TJSONArray;
|
||||
for I := 0 to LArr.Count - 1 do
|
||||
begin
|
||||
if Result <> '' then Result := Result + ', ';
|
||||
Result := Result + LArr.Items[I].Value;
|
||||
end;
|
||||
end
|
||||
else
|
||||
Result := AVal.Value;
|
||||
end;
|
||||
|
||||
/// Les champs personnalises : tableau d'objets {label, value, is_secret}.
|
||||
/// Un champ sans intitule ET sans valeur ne vaut pas une ligne a l'ecran.
|
||||
function ParseFields(const AVal: TJSONValue): TArray<TVaultField>;
|
||||
var
|
||||
LArr: TJSONArray;
|
||||
LObj: TJSONObject;
|
||||
I, N: Integer;
|
||||
begin
|
||||
Result := nil;
|
||||
if not (AVal is TJSONArray) then Exit;
|
||||
LArr := AVal as TJSONArray;
|
||||
SetLength(Result, LArr.Count);
|
||||
N := 0;
|
||||
for I := 0 to LArr.Count - 1 do
|
||||
begin
|
||||
if not (LArr.Items[I] is TJSONObject) then Continue;
|
||||
LObj := LArr.Items[I] as TJSONObject;
|
||||
Result[N].Caption := LObj.GetValue<string>('label', '');
|
||||
Result[N].Value := LObj.GetValue<string>('value', '');
|
||||
Result[N].Secret := LObj.GetValue<Boolean>('is_secret', False);
|
||||
if (Result[N].Caption = '') and (Result[N].Value = '') then Continue;
|
||||
Inc(N);
|
||||
end;
|
||||
SetLength(Result, N);
|
||||
end;
|
||||
|
||||
/// Pieces jointes : {filename, mime, size_bytes, content_b64}. On lit tout
|
||||
/// SAUF content_b64 - voir TVaultAttachment.
|
||||
function ParseAttachments(const AVal: TJSONValue): TArray<TVaultAttachment>;
|
||||
var
|
||||
LArr: TJSONArray;
|
||||
LObj: TJSONObject;
|
||||
I, N: Integer;
|
||||
begin
|
||||
Result := nil;
|
||||
if not (AVal is TJSONArray) then Exit;
|
||||
LArr := AVal as TJSONArray;
|
||||
SetLength(Result, LArr.Count);
|
||||
N := 0;
|
||||
for I := 0 to LArr.Count - 1 do
|
||||
begin
|
||||
if not (LArr.Items[I] is TJSONObject) then Continue;
|
||||
LObj := LArr.Items[I] as TJSONObject;
|
||||
Result[N].FileName := LObj.GetValue<string>('filename', '');
|
||||
Result[N].Mime := LObj.GetValue<string>('mime', '');
|
||||
Result[N].SizeBytes := LObj.GetValue<Int64>('size_bytes', 0);
|
||||
if Result[N].FileName = '' then Continue;
|
||||
Inc(N);
|
||||
end;
|
||||
SetLength(Result, N);
|
||||
end;
|
||||
|
||||
procedure TVault.LoadFromJson(const APlainJson: string);
|
||||
var
|
||||
LRoot, LObj: TJSONObject;
|
||||
LArr: TJSONArray;
|
||||
I, N: Integer;
|
||||
begin
|
||||
Clear;
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then
|
||||
raise Exception.Create('Snapshot unreadable: this is not JSON');
|
||||
try
|
||||
FUsername := LRoot.GetValue<string>('username', '');
|
||||
FExportedAt := LRoot.GetValue<string>('exported_at',
|
||||
LRoot.GetValue<string>('snapshot_at', ''));
|
||||
|
||||
// Dossiers : l'instantane porte {name, color, icon}, seul le nom sert ici.
|
||||
// Un instantane ancien peut ne pas avoir le tableau du tout.
|
||||
if LRoot.TryGetValue<TJSONArray>('folders', LArr) then
|
||||
begin
|
||||
SetLength(FFolders, LArr.Count);
|
||||
N := 0;
|
||||
for I := 0 to LArr.Count - 1 do
|
||||
begin
|
||||
if not (LArr.Items[I] is TJSONObject) then Continue;
|
||||
FFolders[N] := (LArr.Items[I] as TJSONObject).GetValue<string>('name', '');
|
||||
if FFolders[N] <> '' then Inc(N);
|
||||
end;
|
||||
SetLength(FFolders, N);
|
||||
end;
|
||||
|
||||
if not LRoot.TryGetValue<TJSONArray>('entries', LArr) then
|
||||
raise Exception.Create('Snapshot has no "entries" array');
|
||||
|
||||
SetLength(FEntries, LArr.Count);
|
||||
N := 0;
|
||||
for I := 0 to LArr.Count - 1 do
|
||||
begin
|
||||
if not (LArr.Items[I] is TJSONObject) then Continue;
|
||||
LObj := LArr.Items[I] as TJSONObject;
|
||||
FEntries[N].Uuid := LObj.GetValue<string>('uuid', '');
|
||||
FEntries[N].Title := LObj.GetValue<string>('title', '');
|
||||
FEntries[N].Site := LObj.GetValue<string>('site', '');
|
||||
FEntries[N].Username := LObj.GetValue<string>('username', '');
|
||||
FEntries[N].Password := LObj.GetValue<string>('password', '');
|
||||
FEntries[N].Folder := LObj.GetValue<string>('folder', '');
|
||||
FEntries[N].Kind := LObj.GetValue<string>('kind', 'login');
|
||||
FEntries[N].Totp := LObj.GetValue<string>('totp_secret', '');
|
||||
FEntries[N].Tags := TagsToString(LObj.GetValue('tags'));
|
||||
FEntries[N].Fields := ParseFields(LObj.GetValue('custom_fields'));
|
||||
FEntries[N].Attachments := ParseAttachments(LObj.GetValue('attachments'));
|
||||
FEntries[N].Template := LObj.GetValue<string>('template', '');
|
||||
FEntries[N].IconB64 := LObj.GetValue<string>('icon_b64', '');
|
||||
FEntries[N].UpdatedAt := LObj.GetValue<string>('updated_at', '');
|
||||
// deleted arrive en 0/1 depuis nos conteneurs ; absent = vivant.
|
||||
FEntries[N].Deleted := LObj.GetValue<Integer>('deleted', 0) = 1;
|
||||
Inc(N);
|
||||
end;
|
||||
SetLength(FEntries, N);
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TVault.Count: Integer;
|
||||
begin
|
||||
Result := Length(FEntries);
|
||||
end;
|
||||
|
||||
function TVault.LiveCount: Integer;
|
||||
var E: TVaultEntry;
|
||||
begin
|
||||
Result := 0;
|
||||
for E in FEntries do if not E.Deleted then Inc(Result);
|
||||
end;
|
||||
|
||||
function TVault.TrashCount: Integer;
|
||||
begin
|
||||
Result := Count - LiveCount;
|
||||
end;
|
||||
|
||||
function TVault.Entry(AIndex: Integer): TVaultEntry;
|
||||
begin
|
||||
Result := FEntries[AIndex];
|
||||
end;
|
||||
|
||||
function TVault.Search(const AQuery: string; const AFolder: string;
|
||||
AIncludeTrash: Boolean): TArray<Integer>;
|
||||
var
|
||||
LQ: string;
|
||||
LRes: TList<Integer>;
|
||||
I: Integer;
|
||||
E: TVaultEntry;
|
||||
|
||||
function Matches(const E: TVaultEntry): Boolean;
|
||||
begin
|
||||
Result := (LQ = '')
|
||||
or (Pos(LQ, E.Title.ToLower) > 0)
|
||||
or (Pos(LQ, E.Site.ToLower) > 0)
|
||||
or (Pos(LQ, E.Username.ToLower) > 0)
|
||||
or (Pos(LQ, E.Tags.ToLower) > 0);
|
||||
end;
|
||||
|
||||
begin
|
||||
LQ := AQuery.Trim.ToLower;
|
||||
LRes := TList<Integer>.Create;
|
||||
try
|
||||
for I := 0 to High(FEntries) do
|
||||
begin
|
||||
E := FEntries[I];
|
||||
if E.Deleted and not AIncludeTrash then Continue;
|
||||
if (AFolder <> '') and not SameText(E.Folder, AFolder) then Continue;
|
||||
if Matches(E) then LRes.Add(I);
|
||||
end;
|
||||
Result := LRes.ToArray;
|
||||
finally
|
||||
LRes.Free;
|
||||
end;
|
||||
|
||||
// Tri sur le nom affiche, insensible a la casse et a l'accentuation faible
|
||||
// de CompareText - suffisant pour une liste qu'on parcourt a l'oeil.
|
||||
TArray.Sort<Integer>(Result, TComparer<Integer>.Construct(
|
||||
function(const A, B: Integer): Integer
|
||||
begin
|
||||
Result := CompareText(FEntries[A].Display, FEntries[B].Display);
|
||||
end));
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,630 @@
|
||||
unit PMA.VaultPatch;
|
||||
{
|
||||
Modifier UN champ d'UNE entree dans l'instantane, sans toucher au reste.
|
||||
|
||||
LA CONTRAINTE QUI GOUVERNE CETTE UNITE. TVault ne lit qu'une partie des
|
||||
champs : il ignore `content_b64` des pieces jointes, `created_at`, l'avatar du
|
||||
compte, les tombstones, et tout ce que le poste ajoutera demain. Reserialiser
|
||||
le coffre a partir de ce qu'il a compris EFFACERAIT tout cela - et le poste
|
||||
adopterait cette version amputee au tirage suivant, la fusion etant un
|
||||
dernier-ecrit-gagne sur `updated_at`. La perte serait silencieuse et
|
||||
definitive.
|
||||
|
||||
Donc on ne reconstruit rien : on prend le JSON RECU, on change une valeur
|
||||
dedans, et on rend le reste tel quel. Ce que le telephone ne comprend pas
|
||||
voyage intact, par construction plutot que par vigilance.
|
||||
|
||||
COROLLAIRE SUR LA FUSION. Comme le patch s'applique a l'instantane qu'on
|
||||
vient de tirer, il n'y a pas de fusion a ecrire : ce qui n'est pas touche est
|
||||
deja la version la plus recente du serveur. Le seul conflit possible est
|
||||
qu'un autre appareil publie entre notre lecture et notre ecriture, et Gitea
|
||||
le refuse par le sha (409) - il suffit alors de recommencer.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils, System.JSON, System.DateUtils;
|
||||
|
||||
type
|
||||
EPMAPatch = class(Exception);
|
||||
|
||||
/// Remplace AField de l'entree AUuid par AValue, et bump `updated_at`.
|
||||
/// Rend le JSON complet, tout le reste inchange. Leve si l'entree est absente -
|
||||
/// ce qui veut dire que le distant ne la contient pas (supprimee ailleurs), et
|
||||
/// 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;
|
||||
|
||||
/// Ajoute une entree a l'instantane et rend le JSON complet.
|
||||
///
|
||||
/// LA MEME FORME QUE CE QU'ECRIT LE POSTE, cles vides comprises : une entree
|
||||
/// nee ici sera relue la-bas, et une cle absente ne se comporte pas toujours
|
||||
/// comme une cle vide. `folder` vaut 'All' parce que c'est le dossier par
|
||||
/// defaut du poste, pas parce que c'est joli.
|
||||
///
|
||||
/// Un uuid neuf, et lui seul : pas de tombstone, on ne supprime pas encore.
|
||||
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
|
||||
AKind, AFolder, ATotp: string): string;
|
||||
|
||||
/// Retire une entree de l'instantane et laisse une PIERRE TOMBALE a sa place.
|
||||
///
|
||||
/// LES DEUX, SINON RIEN. Retirer la ligne sans la tombstone ne supprime pas :
|
||||
/// le poste tient toujours la sienne, et le prochain tirage la ramenerait comme
|
||||
/// une entree qu'on n'a jamais eue. C'est la tombstone qui dit "celle-ci a ete
|
||||
/// supprimee, et a telle heure" - et l'heure compte, parce que l'arbitrage du
|
||||
/// poste garde une entree modifiee APRES la suppression plutot que de la tuer
|
||||
/// une seconde fois.
|
||||
///
|
||||
/// SANS RETOUR depuis le telephone : il n'y a pas de corbeille ici. Celle du
|
||||
/// poste est un drapeau `deleted` qui ne voyage pas dans la synchronisation ;
|
||||
/// une suppression qui s'y arreterait ne se propagerait donc nulle part.
|
||||
function DeleteEntry(const APlainJson, AUuid: string): string;
|
||||
|
||||
/// Duplique une entree : meme contenu, uuid neuf, nom suffixe.
|
||||
///
|
||||
/// COPIE INTEGRALE, pieces jointes comprises. Le poste fait de meme, et une
|
||||
/// copie qui perdrait le document attache serait une surprise le jour ou l'on
|
||||
/// s'en apercevrait - c'est-a-dire trop tard. Le prix est un instantane plus
|
||||
/// lourd a publier ; il se voit tout de suite, lui.
|
||||
function DuplicateEntry(const APlainJson, AUuid: string): string;
|
||||
|
||||
/// Le contenu base64 d'une piece jointe, relu dans l'instantane BRUT.
|
||||
///
|
||||
/// TVault le jette expres a la lecture - une seule piece jointe faisait passer
|
||||
/// le cache de 13 a 174 Ko, et le garder en memoire pour tout le coffre alors
|
||||
/// que rien ne l'affiche serait payer sans rien acheter. Il ne se relit donc
|
||||
/// qu'ici, au moment ou quelqu'un demande a l'enregistrer, et pour une seule
|
||||
/// entree.
|
||||
///
|
||||
/// Rend '' si l'entree, le rang ou le contenu manquent - une piece jointe
|
||||
/// listee sans contenu existe : le poste peut avoir echoue a le joindre a
|
||||
/// l'instantane.
|
||||
function AttachmentContent(const APlainJson, AUuid: string;
|
||||
AIndex: Integer): string;
|
||||
|
||||
/// Pose l'icone d'une entree, et le drapeau de provenance avec elle.
|
||||
///
|
||||
/// PAS PatchEntryField : `icon_custom` est un NOMBRE dans l'instantane, et
|
||||
/// l'ecrire comme une chaine donnerait `"icon_custom": "0"` - que le poste lit
|
||||
/// avec GetValue<Boolean>, qui echouerait sur une chaine. Deux champs de types
|
||||
/// differents, donc une fonction qui les connait tous les deux.
|
||||
///
|
||||
/// AUri vide efface l'icone. Le drapeau retombe alors a zero comme sur le
|
||||
/// poste : une icone absente n'est pas une icone choisie.
|
||||
function PatchEntryIcon(const APlainJson, AUuid, AUri: string;
|
||||
ACustom: Boolean): string;
|
||||
|
||||
/// Les anciens mots de passe d'une entree, relus dans l'instantane BRUT.
|
||||
///
|
||||
/// A LA DEMANDE, ET JAMAIS DANS TVault. Les charger avec le coffre les
|
||||
/// mettrait en clair en memoire pour TOUTES les entrees des l'ouverture, alors
|
||||
/// qu'on en regarde une de temps en temps. Meme raison que pour les pieces
|
||||
/// jointes, mais pour l'exposition plutot que pour le poids : un ancien mot de
|
||||
/// passe est souvent encore valide ailleurs.
|
||||
///
|
||||
/// Rend une ligne par version, la plus recente d'abord, sous la forme
|
||||
/// `date<TAB>mot de passe`. Vide s'il n'y en a pas - le poste ne l'envoie que
|
||||
/// depuis peu, et un instantane plus ancien n'en porte aucun.
|
||||
function PasswordHistory(const APlainJson, AUuid: string): TArray<string>;
|
||||
|
||||
/// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat.
|
||||
///
|
||||
/// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne
|
||||
/// sait pas reconstruire - le contenu des pieces jointes, l'avatar du compte,
|
||||
/// la couleur des dossiers, et tout ce que le poste ajoutera demain. Repartir
|
||||
/// du local amputerait tout cela, et le poste adopterait la version amputee au
|
||||
/// tirage suivant : la meme perte silencieuse et definitive contre laquelle
|
||||
/// cette unite entiere est ecrite.
|
||||
///
|
||||
/// L'arbitrage est celui du poste, terme a terme : dernier ecrit gagnant sur
|
||||
/// `updated_at`, et une pierre tombale l'emporte sur une entree plus ancienne
|
||||
/// qu'elle. Les horodatages sont de l'ISO 8601 UTC des deux cotes,
|
||||
/// millisecondes comprises, donc l'ordre lexicographique EST l'ordre
|
||||
/// chronologique - aucune date a analyser.
|
||||
///
|
||||
/// Une entree sans uuid est ignoree : rien ne permettrait de la reconnaitre
|
||||
/// chez l'autre, et l'ajouter en creerait un doublon a chaque fusion.
|
||||
function MergeSnapshots(const ALocalJson, ARemoteJson: 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.
|
||||
function NowUtcIso: string;
|
||||
|
||||
implementation
|
||||
|
||||
function NowUtcIso: string;
|
||||
begin
|
||||
Result := FormatDateTime('yyyy-mm-dd"T"hh:nn:ss.zzz"Z"',
|
||||
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;
|
||||
|
||||
/// L'heure de suppression retenue pour un uuid, ou '' s'il n'en a pas.
|
||||
/// La plus recente des deux listes : une entree ressuscitee puis resupprimee
|
||||
/// doit rester supprimee.
|
||||
function TombstoneTime(const AStones: TJSONArray; const AUuid: string): string;
|
||||
var
|
||||
I: Integer;
|
||||
LObj: TJSONObject;
|
||||
LWhen: string;
|
||||
begin
|
||||
Result := '';
|
||||
if AStones = nil then Exit;
|
||||
for I := 0 to AStones.Count - 1 do
|
||||
begin
|
||||
if not (AStones.Items[I] is TJSONObject) then Continue;
|
||||
LObj := AStones.Items[I] as TJSONObject;
|
||||
if LObj.GetValue<string>('uuid', '') <> AUuid then Continue;
|
||||
LWhen := LObj.GetValue<string>('deleted_at', '');
|
||||
if LWhen > Result then Result := LWhen;
|
||||
end;
|
||||
end;
|
||||
|
||||
/// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et
|
||||
/// le seul qui n'oublie aucun champ.
|
||||
/// Inscrit un dossier dans la liste du coffre s'il n'y figure pas deja.
|
||||
///
|
||||
/// Ranger une entree dans un dossier ne suffit pas : c'est `folders` qui peuple
|
||||
/// la colonne du poste, et un nom qui n'y figure pas donne une entree rangee
|
||||
/// dans un dossier que rien n'affiche - elle semble disparue. Les deux vont
|
||||
/// donc ensemble, et 'All' ne s'inscrit jamais : c'est le defaut, pas un
|
||||
/// dossier.
|
||||
procedure EnsureFolder(const ARoot: TJSONObject; const AName: string);
|
||||
var
|
||||
LFolders: TJSONArray;
|
||||
LObj: TJSONObject;
|
||||
I: Integer;
|
||||
begin
|
||||
if (AName = '') or (AName = 'All') then Exit;
|
||||
if not ARoot.TryGetValue<TJSONArray>('folders', LFolders) then
|
||||
begin
|
||||
LFolders := TJSONArray.Create;
|
||||
ARoot.AddPair('folders', LFolders);
|
||||
end;
|
||||
for I := 0 to LFolders.Count - 1 do
|
||||
if (LFolders.Items[I] is TJSONObject) and
|
||||
((LFolders.Items[I] as TJSONObject).GetValue<string>('name', '') = AName) then
|
||||
Exit;
|
||||
LObj := TJSONObject.Create;
|
||||
LObj.AddPair('name', AName);
|
||||
// Le poste lit aussi 'color' et 'icon' ; vides, il met ses defauts.
|
||||
LObj.AddPair('color', '');
|
||||
LObj.AddPair('icon', '');
|
||||
LFolders.AddElement(LObj);
|
||||
end;
|
||||
|
||||
function CloneJson(const AObj: TJSONObject): TJSONObject;
|
||||
begin
|
||||
Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject;
|
||||
end;
|
||||
|
||||
function MergeSnapshots(const ALocalJson, ARemoteJson: string): string;
|
||||
var
|
||||
LLocal, LMerged: TJSONObject;
|
||||
LLocalEntries, LOutEntries, LLocalStones, LOutStones,
|
||||
LLocalFolders: TJSONArray;
|
||||
LEntry, LOther: TJSONObject;
|
||||
I, J: Integer;
|
||||
LUuid, LMine, LTheirs, LKilled: string;
|
||||
LFound: Boolean;
|
||||
begin
|
||||
LLocal := TJSONObject.ParseJSONValue(ALocalJson) as TJSONObject;
|
||||
if LLocal = nil then raise EPMAPatch.Create('Instantane local illisible');
|
||||
try
|
||||
LMerged := TJSONObject.ParseJSONValue(ARemoteJson) as TJSONObject;
|
||||
if LMerged = nil then raise EPMAPatch.Create('Instantane distant illisible');
|
||||
try
|
||||
if not LMerged.TryGetValue<TJSONArray>('entries', LOutEntries) then
|
||||
raise EPMAPatch.Create('Instantane distant sans tableau "entries"');
|
||||
if not LMerged.TryGetValue<TJSONArray>('tombstones', LOutStones) then
|
||||
begin
|
||||
LOutStones := TJSONArray.Create;
|
||||
LMerged.AddPair('tombstones', LOutStones);
|
||||
end;
|
||||
|
||||
// 0. LES DOSSIERS DU LOCAL REJOIGNENT CEUX DU DISTANT.
|
||||
//
|
||||
// Oubli trouve a la relecture : la fusion ne portait qu'entrees et
|
||||
// pierres tombales. Un dossier cree ici - hors connexion, ou juste avant
|
||||
// qu'un autre appareil publie - disparaissait donc de `folders`, tandis
|
||||
// que les entrees gardaient son nom. Elles se retrouvaient rangees dans
|
||||
// un dossier que rien n'affiche : introuvables sans le savoir.
|
||||
//
|
||||
// Additif, jamais destructif : on n'enleve pas un dossier du distant
|
||||
// parce qu'il manque ici, c'est aussi la regle du poste.
|
||||
if LLocal.TryGetValue<TJSONArray>('folders', LLocalFolders) then
|
||||
for I := 0 to LLocalFolders.Count - 1 do
|
||||
if LLocalFolders.Items[I] is TJSONObject then
|
||||
EnsureFolder(LMerged,
|
||||
(LLocalFolders.Items[I] as TJSONObject).GetValue<string>('name', ''));
|
||||
|
||||
// 1. Les pierres tombales locales rejoignent celles du distant. Avant les
|
||||
// entrees : l'etape suivante s'en sert pour arbitrer.
|
||||
if LLocal.TryGetValue<TJSONArray>('tombstones', LLocalStones) then
|
||||
for I := 0 to LLocalStones.Count - 1 do
|
||||
if LLocalStones.Items[I] is TJSONObject then
|
||||
LOutStones.AddElement(CloneJson(LLocalStones.Items[I] as TJSONObject));
|
||||
|
||||
// 2. Chaque entree locale : absente du distant on l'ajoute, presente on
|
||||
// garde la plus recente des deux.
|
||||
if LLocal.TryGetValue<TJSONArray>('entries', LLocalEntries) then
|
||||
for I := 0 to LLocalEntries.Count - 1 do
|
||||
begin
|
||||
if not (LLocalEntries.Items[I] is TJSONObject) then Continue;
|
||||
LEntry := LLocalEntries.Items[I] as TJSONObject;
|
||||
LUuid := LEntry.GetValue<string>('uuid', '');
|
||||
if LUuid = '' then Continue;
|
||||
LMine := LEntry.GetValue<string>('updated_at', '');
|
||||
|
||||
LFound := False;
|
||||
for J := 0 to LOutEntries.Count - 1 do
|
||||
begin
|
||||
if not (LOutEntries.Items[J] is TJSONObject) then Continue;
|
||||
LOther := LOutEntries.Items[J] as TJSONObject;
|
||||
if LOther.GetValue<string>('uuid', '') <> LUuid then Continue;
|
||||
LFound := True;
|
||||
LTheirs := LOther.GetValue<string>('updated_at', '');
|
||||
if LMine > LTheirs then
|
||||
begin
|
||||
LOutEntries.Remove(J).Free;
|
||||
LOutEntries.AddElement(CloneJson(LEntry));
|
||||
end;
|
||||
Break;
|
||||
end;
|
||||
if not LFound then LOutEntries.AddElement(CloneJson(LEntry));
|
||||
end;
|
||||
|
||||
// 3. Une pierre tombale posterieure a l'entree la retire. Posterieure
|
||||
// SEULEMENT : une entree modifiee apres coup a ete ressuscitee
|
||||
// volontairement, et la retuer effacerait ce geste.
|
||||
for J := LOutEntries.Count - 1 downto 0 do
|
||||
begin
|
||||
if not (LOutEntries.Items[J] is TJSONObject) then Continue;
|
||||
LOther := LOutEntries.Items[J] as TJSONObject;
|
||||
LKilled := TombstoneTime(LOutStones, LOther.GetValue<string>('uuid', ''));
|
||||
if (LKilled <> '') and (LKilled > LOther.GetValue<string>('updated_at', '')) then
|
||||
LOutEntries.Remove(J).Free;
|
||||
end;
|
||||
|
||||
Result := LMerged.ToJSON;
|
||||
finally
|
||||
LMerged.Free;
|
||||
end;
|
||||
finally
|
||||
LLocal.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function PasswordHistory(const APlainJson, AUuid: string): TArray<string>;
|
||||
var
|
||||
LRoot, LEntry, LRow: TJSONObject;
|
||||
LEntries, LHist: TJSONArray;
|
||||
I, J: Integer;
|
||||
begin
|
||||
Result := nil;
|
||||
if AUuid = '' then Exit;
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then Exit;
|
||||
try
|
||||
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then Exit;
|
||||
for I := 0 to LEntries.Count - 1 do
|
||||
begin
|
||||
if not (LEntries.Items[I] is TJSONObject) then Continue;
|
||||
LEntry := LEntries.Items[I] as TJSONObject;
|
||||
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
|
||||
if not LEntry.TryGetValue<TJSONArray>('password_history', LHist) then Exit;
|
||||
// J, PAS I : la boucle exterieure cherche encore l'entree, et Delphi
|
||||
// refuse - a juste titre - qu'on lui reecrive son compteur.
|
||||
for J := 0 to LHist.Count - 1 do
|
||||
begin
|
||||
if not (LHist.Items[J] is TJSONObject) then Continue;
|
||||
LRow := LHist.Items[J] as TJSONObject;
|
||||
Result := Result + [LRow.GetValue<string>('changed_at', '') + #9 +
|
||||
LRow.GetValue<string>('password', '')];
|
||||
end;
|
||||
Exit;
|
||||
end;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function PatchEntryIcon(const APlainJson, AUuid, AUri: string;
|
||||
ACustom: Boolean): string;
|
||||
var
|
||||
LRoot, LEntry: TJSONObject;
|
||||
LEntries: TJSONArray;
|
||||
I: Integer;
|
||||
LFound: Boolean;
|
||||
begin
|
||||
if AUuid = '' then
|
||||
raise EPMAPatch.Create('Entree sans uuid : rien a retrouver');
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then
|
||||
raise EPMAPatch.Create('Instantane illisible');
|
||||
try
|
||||
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
|
||||
raise EPMAPatch.Create('Instantane sans tableau "entries"');
|
||||
LFound := False;
|
||||
for I := 0 to LEntries.Count - 1 do
|
||||
begin
|
||||
if not (LEntries.Items[I] is TJSONObject) then Continue;
|
||||
LEntry := LEntries.Items[I] as TJSONObject;
|
||||
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
|
||||
|
||||
LEntry.RemovePair('icon_b64').Free;
|
||||
LEntry.AddPair('icon_b64', AUri);
|
||||
LEntry.RemovePair('icon_custom').Free;
|
||||
LEntry.AddPair('icon_custom',
|
||||
TJSONNumber.Create(Ord(ACustom and (AUri <> ''))));
|
||||
LEntry.RemovePair('updated_at').Free;
|
||||
LEntry.AddPair('updated_at', NowUtcIso);
|
||||
|
||||
LFound := True;
|
||||
Break;
|
||||
end;
|
||||
if not LFound then
|
||||
raise EPMAPatch.CreateFmt('Entree %s introuvable', [AUuid]);
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function AttachmentContent(const APlainJson, AUuid: string;
|
||||
AIndex: Integer): string;
|
||||
var
|
||||
LRoot, LEntry: TJSONObject;
|
||||
LEntries, LAtt: TJSONArray;
|
||||
I: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
if (AUuid = '') or (AIndex < 0) then Exit;
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then Exit;
|
||||
try
|
||||
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then Exit;
|
||||
for I := 0 to LEntries.Count - 1 do
|
||||
begin
|
||||
if not (LEntries.Items[I] is TJSONObject) then Continue;
|
||||
LEntry := LEntries.Items[I] as TJSONObject;
|
||||
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
|
||||
if not LEntry.TryGetValue<TJSONArray>('attachments', LAtt) then Exit;
|
||||
if AIndex >= LAtt.Count then Exit;
|
||||
if not (LAtt.Items[AIndex] is TJSONObject) then Exit;
|
||||
Result := (LAtt.Items[AIndex] as TJSONObject)
|
||||
.GetValue<string>('content_b64', '');
|
||||
Exit;
|
||||
end;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function DuplicateEntry(const APlainJson, AUuid: string): string;
|
||||
var
|
||||
LRoot, LEntry, LCopy: TJSONObject;
|
||||
LEntries: TJSONArray;
|
||||
I: Integer;
|
||||
LNow, LTitle: string;
|
||||
begin
|
||||
if AUuid = '' then
|
||||
raise EPMAPatch.Create('Entree sans uuid : rien a dupliquer');
|
||||
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then
|
||||
raise EPMAPatch.Create('Instantane illisible');
|
||||
try
|
||||
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
|
||||
raise EPMAPatch.Create('Instantane sans tableau "entries"');
|
||||
|
||||
LCopy := nil;
|
||||
for I := 0 to LEntries.Count - 1 do
|
||||
begin
|
||||
if not (LEntries.Items[I] is TJSONObject) then Continue;
|
||||
LEntry := LEntries.Items[I] as TJSONObject;
|
||||
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
|
||||
// Reparser plutot que recopier champ par champ : c'est le seul moyen qui
|
||||
// n'oublie rien de ce que le telephone ne comprend pas.
|
||||
LCopy := CloneJson(LEntry);
|
||||
Break;
|
||||
end;
|
||||
if LCopy = nil then
|
||||
raise EPMAPatch.CreateFmt('Entree %s introuvable', [AUuid]);
|
||||
|
||||
LNow := NowUtcIso;
|
||||
LCopy.RemovePair('uuid').Free;
|
||||
LCopy.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36)));
|
||||
LCopy.RemovePair('created_at').Free;
|
||||
LCopy.AddPair('created_at', LNow);
|
||||
LCopy.RemovePair('updated_at').Free;
|
||||
LCopy.AddPair('updated_at', LNow);
|
||||
// Le nom change, sinon les deux lignes sont indiscernables dans la liste.
|
||||
// Retirer PUIS ajouter, comme partout ici : AddPair seul laisserait deux
|
||||
// paires 'title', et le lecteur prendrait la premiere - donc l'ancienne.
|
||||
LTitle := Trim(LCopy.GetValue<string>('title', '') + ' (copy)');
|
||||
LCopy.RemovePair('title').Free;
|
||||
LCopy.AddPair('title', LTitle);
|
||||
LEntries.AddElement(LCopy);
|
||||
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function DeleteEntry(const APlainJson, AUuid: string): string;
|
||||
var
|
||||
LRoot, LEntry, LStone: TJSONObject;
|
||||
LEntries, LStones: TJSONArray;
|
||||
I: Integer;
|
||||
LFound: Boolean;
|
||||
begin
|
||||
if AUuid = '' then
|
||||
raise EPMAPatch.Create('Entree sans uuid : rien a supprimer chez le distant');
|
||||
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then
|
||||
raise EPMAPatch.Create('Instantane illisible');
|
||||
try
|
||||
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
|
||||
raise EPMAPatch.Create('Instantane sans tableau "entries"');
|
||||
|
||||
LFound := False;
|
||||
for I := 0 to LEntries.Count - 1 do
|
||||
begin
|
||||
if not (LEntries.Items[I] is TJSONObject) then Continue;
|
||||
LEntry := LEntries.Items[I] as TJSONObject;
|
||||
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
|
||||
// Remove REND la valeur retiree : a l'appelant de la liberer.
|
||||
LEntries.Remove(I).Free;
|
||||
LFound := True;
|
||||
Break;
|
||||
end;
|
||||
|
||||
if not LFound then
|
||||
raise EPMAPatch.CreateFmt(
|
||||
'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]);
|
||||
|
||||
// Le tableau peut manquer : un conteneur ecrit par un poste qui n'avait
|
||||
// encore rien supprime n'a aucune raison de le porter.
|
||||
if not LRoot.TryGetValue<TJSONArray>('tombstones', LStones) then
|
||||
begin
|
||||
LStones := TJSONArray.Create;
|
||||
LRoot.AddPair('tombstones', LStones);
|
||||
end;
|
||||
LStone := TJSONObject.Create;
|
||||
LStone.AddPair('uuid', AUuid);
|
||||
LStone.AddPair('deleted_at', NowUtcIso);
|
||||
LStones.AddElement(LStone);
|
||||
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
|
||||
AKind, AFolder, ATotp: string): string;
|
||||
var
|
||||
LRoot, LEntry: TJSONObject;
|
||||
LEntries: TJSONArray;
|
||||
LNow: string;
|
||||
begin
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then
|
||||
raise EPMAPatch.Create('Snapshot is not a JSON object');
|
||||
try
|
||||
LEntries := LRoot.GetValue('entries') as TJSONArray;
|
||||
if LEntries = nil then
|
||||
raise EPMAPatch.Create('Snapshot has no entries array');
|
||||
|
||||
LNow := NowUtcIso;
|
||||
LEntry := TJSONObject.Create;
|
||||
// Minuscules et sans accolades : la forme que tout le reste emploie.
|
||||
LEntry.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36)));
|
||||
LEntry.AddPair('title', ATitle);
|
||||
LEntry.AddPair('site', ASite);
|
||||
LEntry.AddPair('username', AUsername);
|
||||
// Le corps d'une note EST son mot de passe cote stockage - il n'y a pas de
|
||||
// colonne separee, ni ici ni sur le poste.
|
||||
LEntry.AddPair('password', APassword);
|
||||
if AFolder <> '' then LEntry.AddPair('folder', AFolder)
|
||||
else LEntry.AddPair('folder', 'All');
|
||||
EnsureFolder(LRoot, AFolder);
|
||||
LEntry.AddPair('tags', TJSONArray.Create);
|
||||
LEntry.AddPair('totp_secret', ATotp);
|
||||
LEntry.AddPair('kind', AKind);
|
||||
LEntry.AddPair('template', '');
|
||||
LEntry.AddPair('custom_fields', TJSONArray.Create);
|
||||
LEntry.AddPair('attachments', TJSONArray.Create);
|
||||
LEntry.AddPair('icon_b64', '');
|
||||
LEntry.AddPair('icon_custom', TJSONNumber.Create(0));
|
||||
LEntry.AddPair('created_at', LNow);
|
||||
LEntry.AddPair('updated_at', LNow);
|
||||
LEntries.AddElement(LEntry);
|
||||
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
|
||||
var
|
||||
LRoot, LEntry: TJSONObject;
|
||||
LEntries: TJSONArray;
|
||||
I: Integer;
|
||||
LFound: Boolean;
|
||||
begin
|
||||
if AUuid = '' then
|
||||
raise EPMAPatch.Create('Entree sans uuid : rien a retrouver chez le distant');
|
||||
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then
|
||||
raise EPMAPatch.Create('Instantane illisible');
|
||||
try
|
||||
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
|
||||
raise EPMAPatch.Create('Instantane sans tableau "entries"');
|
||||
|
||||
LFound := False;
|
||||
for I := 0 to LEntries.Count - 1 do
|
||||
begin
|
||||
if not (LEntries.Items[I] is TJSONObject) then Continue;
|
||||
LEntry := LEntries.Items[I] as TJSONObject;
|
||||
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
|
||||
|
||||
// RemovePair puis AddPair : AddPair seul ajouterait un DOUBLON, et le
|
||||
// lecteur prendrait le premier - donc l'ancienne valeur.
|
||||
LEntry.RemovePair(AField).Free;
|
||||
LEntry.AddPair(AField, AValue);
|
||||
|
||||
// Sans ce bump, la modification voyage mais les autres appareils
|
||||
// l'ignorent : leur arbitrage compare `updated_at`, et le notre serait
|
||||
// reste plus ancien que le leur.
|
||||
LEntry.RemovePair('updated_at').Free;
|
||||
LEntry.AddPair('updated_at', NowUtcIso);
|
||||
if AField = 'folder' then EnsureFolder(LRoot, AValue);
|
||||
|
||||
LFound := True;
|
||||
Break;
|
||||
end;
|
||||
|
||||
if not LFound then
|
||||
raise EPMAPatch.CreateFmt(
|
||||
'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]);
|
||||
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,304 @@
|
||||
unit PMA.WebUI;
|
||||
{
|
||||
La page HTML et les donnees qu'on lui envoie.
|
||||
|
||||
POURQUOI HTML. Les controles FMX n'ont pas de gabarit fiable sur mobile :
|
||||
la journee a vu disparaitre tour a tour la ligne de liste, l'invite de
|
||||
recherche, la ligne de detail, selon le style charge. Le reste - arrondis,
|
||||
ordre d'alignement, HitTest qui avale un appui, vues natives qui se peignent
|
||||
par-dessus - se re-decouvre a chaque ecran. Le moteur du systeme rend la
|
||||
meme interface en quelques lignes de CSS, gere l'arabe et le bidirectionnel
|
||||
seul, et c'est deja le langage de l'interface du poste Windows.
|
||||
|
||||
CE QUI RESTE EN DELPHI : Keystore, biometrie, presse-papiers sensible,
|
||||
FLAG_SECURE, reseau et dechiffrement. Le moteur n'est qu'une surface
|
||||
d'affichage - il ne detient jamais de quoi ouvrir le coffre.
|
||||
|
||||
CE QUI PART VERS LA PAGE. La liste porte les noms, sites et identifiants,
|
||||
soit ce qui est deja a l'ecran, et AUCUN mot de passe : copier depuis une
|
||||
ligne envoie un indice, Delphi copie depuis le coffre. Seule l'entree
|
||||
ouverte a ses valeurs dans le tas JS, une a la fois.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
PMA.Vault;
|
||||
|
||||
/// La page, telle qu'embarquee dans le .res. ASCII pur - voir l'en-tete de
|
||||
/// app.html : le chemin de chargement de FMX ne transporte pas l'encodage.
|
||||
function PageHtml: string;
|
||||
|
||||
/// Ce que la liste affiche et cherche. Les indices sont ceux du coffre, pas
|
||||
/// ceux de la liste : la page filtre et trie sans rien redemander, donc les
|
||||
/// deux ne coincident plus des la premiere frappe.
|
||||
function VaultToJson(AVault: TVault): string;
|
||||
|
||||
/// Les lignes d'une entree, dans l'ordre d'affichage.
|
||||
function DetailToJson(const AEntry: TVaultEntry): string;
|
||||
|
||||
/// Une ligne du detail par son rang, dans le MEME ordre que DetailToJson : la
|
||||
/// page renvoie un rang, jamais une valeur, et Delphi relit le clair chez lui.
|
||||
/// Faux si le rang ne designe rien.
|
||||
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
||||
out ACaption, AValue: string): Boolean; overload;
|
||||
/// Variante qui rend aussi le rang de la piece jointe, ou -1.
|
||||
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
||||
out ACaption, AValue: string; out AAttachIndex: Integer): Boolean; overload;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
// System.Types pour RT_RCDATA, que Winapi.Windows fournirait sur un poste.
|
||||
System.SysUtils, System.Classes, System.Types, System.JSON, System.Character;
|
||||
|
||||
{$R 'web\web.res' 'web\web.rc'}
|
||||
|
||||
function PageHtml: string;
|
||||
var
|
||||
LStream: TResourceStream;
|
||||
LBytes: TBytes;
|
||||
begin
|
||||
LStream := TResourceStream.Create(HInstance, 'PMAPP', RT_RCDATA);
|
||||
try
|
||||
SetLength(LBytes, LStream.Size);
|
||||
LStream.ReadBuffer(LBytes, LStream.Size);
|
||||
Result := TEncoding.UTF8.GetString(LBytes);
|
||||
finally
|
||||
LStream.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function HumanSize(ABytes: Int64): string;
|
||||
begin
|
||||
if ABytes >= 1024 * 1024 then
|
||||
Result := Format('%.1f MB', [ABytes / (1024 * 1024)])
|
||||
else if ABytes >= 1024 then
|
||||
Result := Format('%.0f KB', [ABytes / 1024])
|
||||
else
|
||||
Result := Format('%d B', [ABytes]);
|
||||
end;
|
||||
|
||||
/// Premiere lettre du nom affiche, pour la pastille sans icone.
|
||||
function Initial(const AName: string): string;
|
||||
var
|
||||
LCh: Char;
|
||||
begin
|
||||
Result := '?';
|
||||
for LCh in AName do
|
||||
if LCh.IsLetterOrDigit then Exit(LCh.ToUpper);
|
||||
end;
|
||||
|
||||
function VaultToJson(AVault: TVault): string;
|
||||
var
|
||||
LRoot: TJSONObject;
|
||||
LEntries, LFolders: TJSONArray;
|
||||
LObj: TJSONObject;
|
||||
LIdx: TArray<Integer>;
|
||||
I: Integer;
|
||||
E: TVaultEntry;
|
||||
LFolder: string;
|
||||
begin
|
||||
LRoot := TJSONObject.Create;
|
||||
try
|
||||
LEntries := TJSONArray.Create;
|
||||
LRoot.AddPair('entries', LEntries);
|
||||
|
||||
// Search('') rend les vivantes, deja triees par nom affiche. La page
|
||||
// conserve cet ordre : trier de nouveau en JS demanderait de repliquer
|
||||
// la meme convention de comparaison pour rien.
|
||||
LIdx := AVault.Search('');
|
||||
for I := 0 to High(LIdx) do
|
||||
begin
|
||||
E := AVault.Entry(LIdx[I]);
|
||||
LObj := TJSONObject.Create;
|
||||
LObj.AddPair('i', TJSONNumber.Create(LIdx[I]));
|
||||
LObj.AddPair('name', E.Display);
|
||||
// Le site s'il existe, sinon l'identifiant : deux fois la meme chaine
|
||||
// sur deux lignes n'apprendrait rien.
|
||||
if E.Site <> '' then LObj.AddPair('sub', E.Site)
|
||||
else if E.Username <> E.Display then LObj.AddPair('sub', E.Username)
|
||||
else LObj.AddPair('sub', '');
|
||||
LObj.AddPair('folder', E.Folder);
|
||||
// Corpus de recherche pre-assemble et en minuscules : la page cherche a
|
||||
// chaque frappe, autant ne pas recomposer la meme chaine a chaque fois.
|
||||
// Il couvre ce que couvrait TVault.Search - nom, site, identifiant,
|
||||
// etiquettes - donc l'identifiant reste trouvable meme quand la ligne
|
||||
// affiche le site.
|
||||
LObj.AddPair('s', (E.Title + ' ' + E.Site + ' ' + E.Username + ' ' +
|
||||
E.Tags).ToLower);
|
||||
LObj.AddPair('icon', E.IconB64);
|
||||
LObj.AddPair('initial', Initial(E.Display));
|
||||
LObj.AddPair('hasUser', TJSONBool.Create(E.Username <> ''));
|
||||
LObj.AddPair('isNote', TJSONBool.Create(E.IsNote));
|
||||
LEntries.AddElement(LObj);
|
||||
end;
|
||||
|
||||
LFolders := TJSONArray.Create;
|
||||
LRoot.AddPair('folders', LFolders);
|
||||
for LFolder in AVault.Folders do
|
||||
LFolders.Add(LFolder);
|
||||
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
/// Les lignes d'une entree. UN SEUL ENDROIT decide de leur composition et de
|
||||
/// leur ordre : DetailToJson et DetailValues en dependent tous les deux, et
|
||||
/// un ecart entre eux ferait copier la valeur de la ligne voisine.
|
||||
type
|
||||
TDetailRow = record
|
||||
Caption, Value: string;
|
||||
Secret: Boolean;
|
||||
/// LE NOM DU CHAMP DANS L'INSTANTANE, vide si la ligne ne se modifie pas.
|
||||
///
|
||||
/// Il porte les deux choses a la fois : qu'on puisse editer, et quoi
|
||||
/// editer. Marque ICI plutot que devine du libelle cote page : un intitule
|
||||
/// est de la prose, et le jour ou il change la page enverrait un nom de
|
||||
/// champ inexistant - ou, pire, cesserait silencieusement de proposer la
|
||||
/// modification.
|
||||
///
|
||||
/// Supprimer une entree n'y figure pas : cela demande des tombstones, et
|
||||
/// c'est l'etape 3 de la §7.
|
||||
Field: string;
|
||||
/// Rang de la piece jointe dans l'entree, ou -1. Il ne se deduit pas du
|
||||
/// rang de la ligne : les champs personnalises s'intercalent avant.
|
||||
AttachIndex: Integer;
|
||||
/// La valeur tient sur plusieurs lignes. Marque ICI, pas devine du libelle
|
||||
/// ni du contenu : un <input> APPLIQUE la sanitisation de HTML et retire
|
||||
/// les retours a la ligne en silence. Une note modifiee depuis le telephone
|
||||
/// y perdait sa mise en forme, et la perte partait au depot.
|
||||
Multiline: Boolean;
|
||||
end;
|
||||
|
||||
function BuildRows(const E: TVaultEntry): TArray<TDetailRow>;
|
||||
var
|
||||
LRows: TArray<TDetailRow>;
|
||||
I: Integer;
|
||||
|
||||
procedure Add(const ACaption, AValue: string; ASecret: Boolean;
|
||||
const AField: string = ''; AAttach: Integer = -1;
|
||||
AMultiline: Boolean = False);
|
||||
begin
|
||||
// Une ligne vide n'apprend rien - SAUF si elle se remplit. Un identifiant
|
||||
// absent qu'on ne peut pas ajouter serait une impasse.
|
||||
if (AValue = '') and (AField = '') then Exit;
|
||||
SetLength(LRows, Length(LRows) + 1);
|
||||
LRows[High(LRows)].Caption := ACaption;
|
||||
LRows[High(LRows)].Value := AValue;
|
||||
LRows[High(LRows)].Secret := ASecret;
|
||||
LRows[High(LRows)].Field := AField;
|
||||
LRows[High(LRows)].AttachIndex := AAttach;
|
||||
LRows[High(LRows)].Multiline := AMultiline;
|
||||
end;
|
||||
|
||||
begin
|
||||
Add('Name', E.Title, False, 'title');
|
||||
// Le corps d'une note est range dans le champ mot de passe : meme donnee,
|
||||
// autre intitule, et pas de raison de la masquer comme un secret.
|
||||
//
|
||||
// Un site et un identifiant n'ont aucun sens pour une note. Les proposer
|
||||
// vides reviendrait a inviter a remplir ce que rien n'affichera jamais.
|
||||
if E.IsNote then
|
||||
Add('Content', E.Password, False, 'password', -1, True)
|
||||
else
|
||||
begin
|
||||
Add('Site', E.Site, False, 'site');
|
||||
Add('Username', E.Username, False, 'username');
|
||||
Add('Password', E.Password, True, 'password');
|
||||
// LE SECRET, pas le code. Le code a sa propre case en haut, calculee et
|
||||
// renouvelee toutes les trente secondes ; c'est le secret qui se pose, se
|
||||
// change et se retire, et sans cette ligne il n'y avait aucun moyen de le
|
||||
// faire depuis le telephone. Masque comme un mot de passe : il vaut le
|
||||
// second facteur en entier.
|
||||
Add('2FA secret', E.Totp, True, 'totp_secret');
|
||||
end;
|
||||
|
||||
for I := 0 to High(E.Fields) do
|
||||
Add(E.Fields[I].Caption, E.Fields[I].Value, E.Fields[I].Secret);
|
||||
|
||||
// Les pieces jointes ne sont pas encore enregistrables sur le telephone,
|
||||
// mais taire leur existence serait pire : on saurait sur le poste qu'une
|
||||
// entree porte un document, et pas ici.
|
||||
for I := 0 to High(E.Attachments) do
|
||||
Add('Attachment - ' + HumanSize(E.Attachments[I].SizeBytes),
|
||||
E.Attachments[I].FileName, False, '', I);
|
||||
|
||||
// DOSSIER ET ETIQUETTES SEPARES, parce qu'ils ne se modifient pas pareil.
|
||||
// Le dossier est une chaine simple, choisie parmi celles qui existent - la
|
||||
// page en fait une liste plutot qu'un champ libre, sinon une faute de frappe
|
||||
// rangerait l'entree dans un dossier que rien n'affiche.
|
||||
// Les etiquettes, elles, sont un TABLEAU dans l'instantane : les ecrire comme
|
||||
// une chaine casserait leur forme pour le poste. Lecture seule tant que le
|
||||
// patch ne sait pas ecrire un tableau.
|
||||
Add('Folder', E.Folder, False, 'folder');
|
||||
Add('Tags', E.Tags, False);
|
||||
Add('Type', E.Template, False);
|
||||
// Horodatage tel quel : de l'ISO 8601 UTC, lisible, et le reformater
|
||||
// demanderait de decider d'un fuseau alors qu'il n'y a rien a arbitrer.
|
||||
Add('Updated', E.UpdatedAt, False);
|
||||
|
||||
Result := LRows;
|
||||
end;
|
||||
|
||||
function DetailToJson(const AEntry: TVaultEntry): string;
|
||||
var
|
||||
LRoot: TJSONObject;
|
||||
LArr: TJSONArray;
|
||||
LObj: TJSONObject;
|
||||
LRows: TArray<TDetailRow>;
|
||||
I: Integer;
|
||||
begin
|
||||
LRows := BuildRows(AEntry);
|
||||
LRoot := TJSONObject.Create;
|
||||
try
|
||||
LRoot.AddPair('title', AEntry.Display);
|
||||
LRoot.AddPair('totp', TJSONBool.Create(AEntry.Totp <> ''));
|
||||
LArr := TJSONArray.Create;
|
||||
LRoot.AddPair('rows', LArr);
|
||||
for I := 0 to High(LRows) do
|
||||
begin
|
||||
LObj := TJSONObject.Create;
|
||||
LObj.AddPair('c', LRows[I].Caption);
|
||||
LObj.AddPair('v', LRows[I].Value);
|
||||
LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret));
|
||||
// 'e' porte le nom du champ, et sa presence vaut "modifiable".
|
||||
if LRows[I].Field <> '' then LObj.AddPair('e', LRows[I].Field);
|
||||
// 'a' porte le rang de la piece jointe : sa presence vaut
|
||||
// "enregistrable", et la page y met le bouton de telechargement.
|
||||
if LRows[I].AttachIndex >= 0 then
|
||||
LObj.AddPair('a', TJSONNumber.Create(LRows[I].AttachIndex));
|
||||
if LRows[I].Multiline then LObj.AddPair('m', TJSONBool.Create(True));
|
||||
LArr.AddElement(LObj);
|
||||
end;
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
||||
out ACaption, AValue: string): Boolean;
|
||||
var
|
||||
LAttach: Integer;
|
||||
begin
|
||||
Result := DetailRowAt(AEntry, AIndex, ACaption, AValue, LAttach);
|
||||
end;
|
||||
|
||||
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
||||
out ACaption, AValue: string; out AAttachIndex: Integer): Boolean;
|
||||
var
|
||||
LRows: TArray<TDetailRow>;
|
||||
begin
|
||||
AAttachIndex := -1;
|
||||
LRows := BuildRows(AEntry);
|
||||
Result := (AIndex >= 0) and (AIndex <= High(LRows));
|
||||
if not Result then Exit;
|
||||
ACaption := LRows[AIndex].Caption;
|
||||
AValue := LRows[AIndex].Value;
|
||||
AAttachIndex := LRows[AIndex].AttachIndex;
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,325 @@
|
||||
# Client Android — état et décisions
|
||||
|
||||
> Ce qui reste à faire est dans [PLAN.md](PLAN.md).
|
||||
|
||||
Lecture seule de l'instantané chiffré du coffre. La crypto est **validée sur
|
||||
appareil réel** contre un vecteur produit par WebCrypto.
|
||||
|
||||
## Ce qui est mesuré, pas supposé
|
||||
|
||||
| Question | Réponse | Mesure |
|
||||
|---|---|---|
|
||||
| Le conteneur s'ouvre-t-il en Delphi ? | oui | clair de 572 octets, empreinte identique |
|
||||
| AES-GCM par JNI fonctionne-t-il ? | oui | `javax.crypto`, tag concaténé, rien à découper |
|
||||
| Android encode-t-il le `char[]` en UTF-8 ? | **oui** | clé identique sur un mot de passe non-ASCII |
|
||||
| Le PBKDF2 natif est-il plus rapide ? | **à peine** | 3869 ms contre 4139 ms |
|
||||
|
||||
Le natif est adopté — c'est du code de plateforme plutôt que le nôtre — avec le
|
||||
Pascal en repli, seul disponible hors Android.
|
||||
|
||||
## La conséquence architecturale
|
||||
|
||||
**4 secondes pour ouvrir un instantané, et c'est incompressible.** PBKDF2 ne
|
||||
bénéficie d'aucune accélération matérielle, et on ne peut pas baisser les
|
||||
600 000 itérations : elles sont inscrites dans le conteneur produit par le
|
||||
poste Windows.
|
||||
|
||||
Le piège serait de payer ces 4 secondes à **chaque ouverture de
|
||||
l'application**. Ce n'est pas nécessaire, parce que le coût est lié au
|
||||
**conteneur**, pas à la session — et chaque export génère un **nouveau sel**,
|
||||
donc une mise en cache par mot de passe ne servirait à rien.
|
||||
|
||||
L'architecture qui en découle :
|
||||
|
||||
1. **À la synchronisation** — télécharger, dériver (4 s, une fois), déchiffrer.
|
||||
2. **Puis stocker le coffre localement** chiffré sous une clé du **Android
|
||||
Keystore**, adossée à la biométrie. AES est accéléré matériellement : la
|
||||
relecture est instantanée.
|
||||
3. **À l'ouverture** — biométrie, puis lecture locale. Aucune dérivation.
|
||||
|
||||
Les 4 secondes ne se paient donc qu'à l'arrivée d'un instantané **nouveau**,
|
||||
ce qui est acceptable et compréhensible pour l'utilisateur.
|
||||
|
||||
> Le coffre déchiffré contient **tous les mots de passe en clair**. Il ne doit
|
||||
> jamais toucher le disque autrement que chiffré sous la clé du Keystore, et la
|
||||
> sauvegarde automatique Android doit être désactivée pour l'application.
|
||||
|
||||
## Chaîne validée de bout en bout
|
||||
|
||||
Sur appareil : `GET` authentifié (80 ms) → déchiffrement (3893 ms) → lecture du
|
||||
JSON, entrées vivantes et corbeille comptées séparément. Second appel : **304**,
|
||||
rien de retéléchargé, rien de re-déchiffré.
|
||||
|
||||
**L'ETag n'est retenu que si le déchiffrement a réussi.** Le conserver dès la
|
||||
réception paraît naturel et c'est un piège : après un échec, le prochain appel
|
||||
répond 304 et l'application se retrouve sans données tout en se croyant à jour.
|
||||
L'ETag appartient au **clair détenu**, pas au téléchargement.
|
||||
|
||||
## L'interface
|
||||
|
||||
Trois écrans dans une seule forme, construits en code (`mainApk.pas`) :
|
||||
déverrouillage, liste, détail. Une forme par écran aurait coûté trois `.fmx`
|
||||
pour des dispositions triviales.
|
||||
|
||||
**Afficher d'abord, synchroniser ensuite.** Au lancement, le coffre local
|
||||
s'ouvre tout seul (biométrie si la clé l'exige) et la liste apparaît sans
|
||||
réseau ni dérivation ; le `GET` part derrière, en silence. Un 304 ne se voit
|
||||
pas, un échec réseau non plus — hors ligne est un état normal, et une alerte à
|
||||
chaque lancement apprendrait surtout à ignorer les alertes. Seul un instantané
|
||||
**nouveau** se manifeste, en rafraîchissant la liste.
|
||||
|
||||
C'est ce qui justifie de ranger le **mot de passe de sync** dans le fichier
|
||||
chiffré, à côté du coffre : sans lui, un instantané nouveau ne peut pas être
|
||||
ouvert, et chaque modification faite sur le poste ferait apparaître une invite
|
||||
au lancement. Le fichier contient déjà tous les mots de passe en clair ;
|
||||
celui-ci n'y ajoute presque rien en exposition. Ce qui est tapé dans le champ
|
||||
l'emporte sur ce qui est rangé — c'est ainsi qu'on corrige un mot de passe qui
|
||||
ne convient plus.
|
||||
|
||||
L'écran de déverrouillage s'adapte à ce que l'appareil détient : coffre local
|
||||
présent → *Ouvrir* (biométrie si la clé l'exige) ; absent → seul le
|
||||
téléchargement peut en produire un. La liste filtre à la frappe sur le nom, le
|
||||
site, l'identifiant et les étiquettes — **pas** sur le mot de passe : personne
|
||||
ne cherche par mot de passe, et le comparer allongerait son séjour en mémoire.
|
||||
|
||||
Le détail est construit **par entrée**, pas d'après un gabarit fixe : site,
|
||||
identifiant, mot de passe et champs personnalisés se ramènent tous à un
|
||||
intitulé, une valeur, et le fait qu'elle soit secrète. Les traiter séparément
|
||||
aurait dupliqué révélation et copie autant de fois qu'il y a de sortes de
|
||||
champ. Deux exceptions assumées : le corps d'une **note**, qui est stocké dans
|
||||
le champ mot de passe et s'affiche donc sous un autre intitulé, sans masque ;
|
||||
et le **code 2FA**, seule ligne vivante, rafraîchie chaque seconde et placée en
|
||||
tête — c'est ce qu'on vient chercher sur un téléphone.
|
||||
|
||||
La liste est triée par nom — l'ordre de l'instantané est celui de la base,
|
||||
arbitraire à l'œil — et porte l'icône de l'entrée quand elle en a une, décodée
|
||||
une fois par coffre et non à chaque frappe. Une liste déroulante filtre par
|
||||
dossier ; il n'y a **pas** de filtre d'étiquette, la recherche texte les
|
||||
couvrant déjà.
|
||||
|
||||
Le TOTP ([PMA.Totp.pas](PMA.Totp.pas)) accepte le base32 nu comme l'URI
|
||||
`otpauth://`, les deux circulant dans les instantanés selon la façon dont
|
||||
l'entrée a été créée. Vérifié contre les six vecteurs de la RFC 6238.
|
||||
|
||||
Deux règles tenues dans l'interface, pas ailleurs :
|
||||
|
||||
- **L'écran est masqué dès que l'application passe derrière**, sur
|
||||
`WillBecomeInactive`, qui précède la capture de l'aperçu des applications
|
||||
récentes — sans quoi la liste des comptes y resterait visible. C'est cette
|
||||
partie qui protège, et elle est immédiate et sans condition.
|
||||
|
||||
Le **clair**, lui, bénéficie d'un sursis de trente secondes. Verrouiller
|
||||
instantanément rendait le geste le plus courant pénible : copier un mot de
|
||||
passe, basculer pour le coller, revenir, et devoir se réauthentifier avec le
|
||||
code 2FA de nouveau derrière une invite. L'écran étant déjà masqué, garder le
|
||||
clair le temps d'un aller-retour n'expose rien de visible.
|
||||
|
||||
- **Rien ne détruit le cache.** Seule une écriture réussie le remplace. Aucune
|
||||
routine de lecture, aucun diagnostic d'indisponibilité ne l'efface — trois
|
||||
fois dans la même journée un verdict erroné a coûté le cache, et avec lui la
|
||||
trace de sa propre cause. Un fichier devenu illisible ne coûte que sa taille ;
|
||||
le prochain téléchargement l'écrase.
|
||||
- **Le presse-papiers se vide après 45 s**, et seulement si son contenu est
|
||||
toujours celui qu'on y a mis : l'utilisateur a pu copier autre chose
|
||||
entre-temps.
|
||||
|
||||
L'écriture n'existe pas. Le poste Windows reste la source ; modifier depuis le
|
||||
téléphone demanderait le `PUT` côté serveur et une fusion côté client.
|
||||
|
||||
### Tester sans téléphone
|
||||
|
||||
Tout sauf le Keystore et la biométrie tourne en **Win64**. `PMA.Crypto` a une
|
||||
branche Windows adossée à **CNG** (`bcrypt.dll`) — même primitive, aucune DLL à
|
||||
livrer, et la clé dérivée est identique à celle d'Android (vérifié sur le
|
||||
vecteur, mot de passe non-ASCII compris). `PMA.Store` répond « pas de coffre »
|
||||
hors Android, donc chaque lancement retélécharge et redérive.
|
||||
|
||||
```bash
|
||||
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" pmserverApk.dpr
|
||||
```
|
||||
|
||||
`selftest.dpr` vérifie la chaîne crypto contre le vecteur en console — c'est ce
|
||||
qui a remplacé le bouton *Test crypto* du banc d'essai. Code de sortie = nombre
|
||||
d'échecs :
|
||||
|
||||
```bash
|
||||
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" android-client\selftest.dpr
|
||||
```
|
||||
|
||||
Mesures sur ce poste : PBKDF2 Pascal 1670 ms, CNG **528 ms**, clés identiques.
|
||||
|
||||
## Diagnostiquer un échec de déchiffrement
|
||||
|
||||
`try-open.js` ouvre un conteneur avec Node, donc avec les primitives du
|
||||
navigateur :
|
||||
|
||||
```bash
|
||||
node try-open.js <fichier> <mot-de-passe>
|
||||
```
|
||||
|
||||
Il sépare deux causes que l'application ne peut pas distinguer — un tag GCM qui
|
||||
ne vérifie pas signifie *mot de passe faux OU fichier altéré*, sans plus. Si
|
||||
Node ouvre le fichier et pas le client, le défaut est dans le client.
|
||||
|
||||
> Le mot de passe de sync n'est **pas** le mot de passe maître. Il n'est affiché
|
||||
> nulle part : `prefs.bin` le garde chiffré par DPAPI, et l'application sait
|
||||
> seulement le définir. En cas d'oubli, le redéfinir puis relancer un sync pour
|
||||
> rechiffrer l'instantané distant.
|
||||
|
||||
## Fichiers
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `PMA.Crypto.pas` | PBKDF2 (Pascal + natif JNI), AES-GCM, ouverture du conteneur |
|
||||
| `PMA.SyncClient.pas` | `GET` authentifié, ETag, statuts distingués |
|
||||
| `PMA.Store.pas` | coffre local chiffré sous une clé du Keystore |
|
||||
| `PMA.Biometric.pas` | authentification via `FMX.BiometricAuth` |
|
||||
| `PMA.Vault.pas` | l'instantané en mémoire : lecture et recherche |
|
||||
| `PMA.Totp.pas` | codes TOTP (RFC 6238), base32 ou `otpauth://` |
|
||||
| `PMA.ScreenGuard.pas` | `FLAG_SECURE` (Release seulement) |
|
||||
| `selftest.dpr` | vérification console : conteneur + TOTP |
|
||||
| `PMA.TestVector.pas` | le vecteur embarqué, plus référencé par l'interface |
|
||||
| `make-testvector.js` | régénère le vecteur avec WebCrypto |
|
||||
| `testvector.md` | valeurs attendues, étage par étage |
|
||||
|
||||
L'interface est `mainApk.pas`, à la racine du dépôt.
|
||||
|
||||
## Fabriquer l'APK en ligne de commande
|
||||
|
||||
L'EDI y arrive seul ; MSBuild non, et l'erreur ne dit pas pourquoi.
|
||||
|
||||
Le `.dproj` déclare les **classes** de déploiement Android (`AndroidSplashStyles`,
|
||||
`Android_LauncherIcon96`…) mais **aucun fichier** rattaché à ces classes : l'EDI
|
||||
les fournit implicitement, depuis son gestionnaire de déploiement. Hors EDI,
|
||||
`aapt` est donc appelé sur un dossier `res` qui n'a jamais été rempli, et se
|
||||
plaint d'un répertoire absent — pas d'une configuration manquante.
|
||||
|
||||
De quoi il a besoin, mis en place à la main sous `Android64\Debug\pmserverApk\` :
|
||||
|
||||
| Destination | Source |
|
||||
|---|---|
|
||||
| `res\` (arbre complet) | produit par **une compilation depuis l'EDI**, n'importe quelle plateforme Android — c'est la seule chose que MSBuild ne sait pas régénérer. Il ne pèse que 60 Ko ; s'il disparaît d'un nettoyage, une compilation EDI le refait |
|
||||
| `res\values\styles.xml`, `values-v21`, `values-v31` | `Android64\Debug\styles*.xml` |
|
||||
| `res\values\colors.xml`, `values-night-v21\colors.xml` | `Android64\Debug\colors*.xml` |
|
||||
| `res\values\strings.xml` | `Android64\Debug\strings.xml` |
|
||||
| `res\drawable\splash_image_def.xml`, `drawable-anydpi-v21` | `Android64\Debug\splash_image_def*.xml` |
|
||||
| `res\drawable-anydpi-v26\ic_launcher.xml`, `-v33` | `Android64\Debug\ic_launcher*.xml` |
|
||||
| `AndroidManifest.xml` | `Android64\Debug\AndroidManifest.xml` |
|
||||
| `classes\*.dex` | `Android64\Debug\pmserverApk.classes\*.dex` |
|
||||
| `library\lib\arm64-v8a\libpmserverApk.so` | `Android64\Debug\libpmserverApk.so` |
|
||||
|
||||
Le thème a besoin de sa ressource, **une fois** (MSBuild ne compile pas les
|
||||
`.rc` référencés par `{$R}` — c'est une commodité de l'EDI, pas du compilateur) :
|
||||
|
||||
```bash
|
||||
cd android-client\theme && brcc32 theme.rc -fotheme.res
|
||||
```
|
||||
|
||||
Ensuite `msbuild pmserverApk.dproj /p:Platform=Android64 /p:Config=Debug
|
||||
/t:Deploy` produit et signe `bin\pmserverApk.apk`, et `adb install -r` l'installe.
|
||||
Le correctif durable serait d'écrire ces entrées une fois dans le `.dproj`.
|
||||
|
||||
Enfin, **le rendu du texte passe par Skia**, ce qui ajoute `libsk4d.so` à
|
||||
livrer. `Deploy` reconstruit `library\lib` juste avant d'appeler `aapt`, donc
|
||||
toute bibliothèque posée à la main y disparaît — et les entrées de déploiement
|
||||
ne fonctionnent pas ici. D'où un dernier passage qui refait *aapt → zipalign →
|
||||
signature* sur le dossier déjà prêt, sans le vider :
|
||||
|
||||
```bash
|
||||
android-client\repack-apk.ps1
|
||||
```
|
||||
|
||||
> **Place disque.** Le `.so` de debug pèse **91 Mo** (symboles), et le volume
|
||||
> `Z:` n'en fait que 2 Go. Le dépouiller avec le `strip` du NDK le ramène à
|
||||
> 54 Mo, et l'APK final à 16 Mo — mais `strip` écrit sa copie temporaire **à
|
||||
> côté de la cible**, donc il faut écrire vers un autre volume puis recopier.
|
||||
> Prévoir ~200 Mo libres avant une compilation Android, sinon l'édition de
|
||||
> liens échoue sans que rien n'indique la vraie cause.
|
||||
|
||||
Pour que le téléphone voie l'endpoint local :
|
||||
|
||||
```bash
|
||||
adb reverse tcp:8080 tcp:80
|
||||
```
|
||||
|
||||
> **Le tunnel est volatil.** Il disparaît dès que le lien USB se réinitialise —
|
||||
> câble rebranché, appareil re-énuméré, serveur adb relancé — et l'application
|
||||
> ne dit alors que « failed to connect ». À vérifier en premier :
|
||||
> `adb reverse --list` doit répondre `UsbFfs tcp:8080 tcp:80`. Un test direct
|
||||
> depuis l'appareil tranche entre le tunnel et le serveur :
|
||||
> `adb shell curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/server-php/sync.php`
|
||||
> — `404` signifie que la chaîne est bonne (c'est la réponse voulue sans jeton),
|
||||
> `000` que rien n'écoute.
|
||||
|
||||
## Le cache : écrire sans invite, lire sous authentification
|
||||
|
||||
Validé sur appareil le 2026-08-15, sur un téléphone protégé par un **schéma**
|
||||
et aucune empreinte enrôlée :
|
||||
|
||||
```
|
||||
cache ecrit - protege par biometrie/code (aucune invite à l'écriture)
|
||||
--- redémarrage à froid, saisie du schéma ---
|
||||
4 entrees, 2 en corbeille (pas de ligne « dechiffre en … ms »)
|
||||
HTTP 304 en 136 ms · 304 : inchange, coffre deja ouvert
|
||||
```
|
||||
|
||||
Une clé AES du Keystore ne pouvait pas convenir : `setUserAuthenticationRequired`
|
||||
exige une authentification récente **pour chiffrer autant que pour déchiffrer**,
|
||||
et l'écriture suit une synchronisation silencieuse. Le cache ne s'écrivait donc
|
||||
jamais, sauf dans les secondes suivant un déverrouillage — un test qui passe,
|
||||
puis ne passe plus.
|
||||
|
||||
C'est une **paire RSA** : la partie publique enveloppe une clé AES jetable sans
|
||||
invite, la partie privée — protégée — la déballe. L'authentification n'est
|
||||
exigée qu'à la lecture.
|
||||
|
||||
Trois pièges, aucun visible à la compilation :
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `USE_BIOMETRIC` absente de la liste Delphi | `CanAuthenticate` **lève** au lieu de répondre `False` |
|
||||
| L'invite vit dans `BiometricFragmentActivity` | à déclarer au manifeste, sinon `ActivityNotFoundException` |
|
||||
| `setDigests()` n'autorise que le digest **du message** | celui du MGF1 est distinct, défaut SHA-1 |
|
||||
|
||||
Le troisième est le plus retors : le chiffrement passe, parce que la clé
|
||||
publique reconstruite est servie par le fournisseur logiciel qui accepte
|
||||
n'importe quel MGF1. On produit un chiffré valide que le matériel refuse de
|
||||
lire, et le symptôme n'apparaît qu'au lancement suivant.
|
||||
|
||||
> **Aucune routine de lecture n'efface le cache.** `LoadVault` triait
|
||||
> « clé perdue » contre « pas authentifié » sur le texte de l'exception et
|
||||
> supprimait le fichier quand il penchait pour la première. Un tri erroné
|
||||
> détruisait le cache *en emportant la seule trace de sa cause*. Elle rapporte
|
||||
> désormais via `LastStoreError` ; l'appelant décide.
|
||||
|
||||
## Le piège de la porte sans clé
|
||||
|
||||
`USE_BIOMETRIC` **ne fait pas partie de la liste de permissions par défaut de
|
||||
Delphi**. Sans elle, `CanAuthenticate` ne répond pas « pas de biométrie » : il
|
||||
lève une `java.lang.SecurityException`. Le défaut ne se voit donc ni à la
|
||||
compilation, ni au démarrage, mais à la première tentative d'ouverture — après
|
||||
que le cache a déjà été écrit sous une clé protégée par authentification.
|
||||
|
||||
Le résultat est le pire cas possible : **un cache fermé derrière une porte que
|
||||
l'application ne sait pas ouvrir**. Trois corrections, dont une seule est la
|
||||
permission :
|
||||
|
||||
1. Les deux `uses-permission` dans le modèle de manifeste.
|
||||
2. `BiometricAvailable` et `AuthenticateThen` attrapent ce qui remonte de la
|
||||
couche Java. L'appelant a un chemin pour chaque `TBioOutcome`, il n'en a
|
||||
aucun pour une exception.
|
||||
3. **`SaveVault` ne reçoit plus `True` en dur** mais `BiometricAvailable`. On
|
||||
ne demande une clé sous authentification que si l'invite peut réellement
|
||||
s'afficher — sinon le cache serait illisible pour toujours, et la seule
|
||||
sortie serait de l'effacer.
|
||||
|
||||
## Piège de compilation rencontré
|
||||
|
||||
Cette installation de Delphi ne produisait **aucun** APK FireMonkey
|
||||
fonctionnel : `fmx.dex.jar` avait été renommé sur disque **et** retiré de la
|
||||
configuration de l'EDI. Symptôme : l'application s'installe puis se ferme
|
||||
aussitôt, avec `ClassNotFoundException: FMXNativeActivity` dans `adb logcat`.
|
||||
Le projet référence désormais explicitement `fmx.dex.jar` dans `EnabledSysJars`.
|
||||
|
||||
Après un tel changement, **supprimer les `classes*.dex` intermédiaires** : la
|
||||
compilation les réutilise sans les régénérer, et le correctif semble sans effet.
|
||||
@@ -0,0 +1,356 @@
|
||||
# Client Android — relecture et scénario d'essai
|
||||
|
||||
Réécrit le **2026-08-28**, après une séance qui a remué les deux secrets du
|
||||
coffre, l'écran des réglages, le code de déverrouillage et le clavier. La
|
||||
version précédente décrivait une application qui n'existe plus.
|
||||
|
||||
Ce que cette séance a confirmé, et qui vaut plus que n'importe quelle liste :
|
||||
**les défauts se tiennent par la main.** Corriger le clavier qui volait le
|
||||
premier appui a laissé le clavier ouvert ; le clavier ouvert recouvrait le
|
||||
message ; et le message invisible cachait qu'une action avait réussi. Aucun des
|
||||
deux derniers n'était observable tant que le premier tenait. D'où la règle qui
|
||||
suit : **quand un essai échoue, lire le journal AVANT de retenter**, parce que la
|
||||
synchronisation le vide.
|
||||
|
||||
---
|
||||
|
||||
## Ce que la séance a corrigé, et pourquoi c'est éprouvable
|
||||
|
||||
### 1. Le journal ne perdait plus les causes — CORRIGÉ, et c'est le plus important
|
||||
|
||||
Trois trous, tous du même genre : une faute arrivait, et rien ne la nommait.
|
||||
|
||||
- **Les gestionnaires d'erreur lisaient l'exception après sa libération.**
|
||||
`RegisterLocal` et `LoginLocal` mettaient leur handler en file avec
|
||||
`TThread.Queue`, puis y lisaient `E.Message` — or Delphi libère `E` en sortant
|
||||
du `except`, bien avant que la file s'exécute. Résultat : un message vide, et
|
||||
parfois une violation d'accès. **Le vrai motif n'a jamais été affiché de toute
|
||||
l'histoire de ce chemin.**
|
||||
- **Le dispatch du pont n'était gardé par rien** : une faute dans un handler
|
||||
remontait à FMX, qui affichait « Access violation » avec une adresse pendant
|
||||
que le journal montrait la commande reçue suivie de rien.
|
||||
- **Le retour sur le thread principal** (`TThread.Queue`) n'était couvert ni par
|
||||
le `except` du thread, ni par celui du pont : il s'exécute après que les deux
|
||||
ont rendu la main.
|
||||
|
||||
**Ce que ça change pour les essais** : un échec porte désormais un nom. Si un
|
||||
essai ci-dessous échoue sans laisser de ligne au journal, c'est un quatrième
|
||||
trou du même genre, et il faut le traiter avant de chercher le défaut.
|
||||
|
||||
### 2. Deux secrets, longtemps confondus — CORRIGÉ
|
||||
|
||||
Le mot de passe de création scellait le conteneur local **et** prétendait
|
||||
sceller l'instantané du dépôt. Un coffre créé ici publiait donc sous son propre
|
||||
mot de passe, que le poste ne savait plus ouvrir.
|
||||
|
||||
- `FVaultPwd` ouvre `vault-local.json`, et rien d'autre.
|
||||
- Le secret du dépôt vit dans les réglages, doit être identique sur tous les
|
||||
appareils, et lui seul ouvre et rescelle l'instantané.
|
||||
- **Ils ne se rencontrent qu'à un endroit**, la réconciliation : le conteneur
|
||||
local s'ouvre sous le sien, puis la clé est jetée avant qu'on touche au dépôt.
|
||||
|
||||
Migration : rien à rechiffrer. Les coffres existants ont simplement les deux
|
||||
valeurs égales, et cessent d'être forcés de le rester.
|
||||
|
||||
### 3. « Coffre ouvert » se lisait sur le nombre d'entrées — CORRIGÉ
|
||||
|
||||
Un coffre **vide** en compte zéro, comme un coffre fermé. Le garde de `DoSync`
|
||||
restait donc vrai après l'ouverture, réarmait la reprise, et l'on tournait en
|
||||
rond. Le même idiome coupait `OnIdleTimer` : un coffre vide ne s'auto-verrouillait
|
||||
jamais. Les trois lisent `FPlain`, le clair en main.
|
||||
|
||||
### 4. Ce qui reste assumé, sans correction
|
||||
|
||||
- **`SaveAttachment`, `RegisterLocal` et `LoginLocal`** libèrent l'indicateur
|
||||
d'occupation dans leur `except` plutôt que dans un `finally`. Le minuteur de
|
||||
25 s le lève de toute façon, et c'est son rôle.
|
||||
- **`ParseJSONValue(...) as TJSONObject`** fuit sur une entrée qui n'est pas un
|
||||
objet — le transtypage lève avant l'affectation. N'arrive que sur un conteneur
|
||||
corrompu, où l'on a de plus gros ennuis.
|
||||
- **Le nom d'utilisateur ne sert à rien dans l'APK.** Il est écrit dans
|
||||
l'instantané à la création, et c'est le **poste** qui s'en sert pour refuser de
|
||||
fondre deux coffres différents. À la connexion il ne protège rien : le mot de
|
||||
passe seul ouvre, et le vrai nom est lu dans le conteneur.
|
||||
|
||||
---
|
||||
|
||||
## Scénario d'essai
|
||||
|
||||
Chaque étape dit **ce qu'il faut voir**, pas seulement ce qu'il faut faire : un
|
||||
test dont on ne sait pas ce qu'il doit produire ne prouve rien.
|
||||
|
||||
**L'ordre n'est pas alphabétique** : `Préparation → C → A → B → D → E → …`. La
|
||||
série C réconcilie, et c'est elle qui fait du coffre né ici un coffre **publié**.
|
||||
Tant qu'il ne l'est pas, `CommitChange` voit `LLocalOnly`, écrit *« save: written
|
||||
locally »* et ne publie rien — il n'y a pas de dépôt. La série A guetterait donc
|
||||
un point orange qui ne peut pas paraître, et conclurait à un défaut là où il n'y
|
||||
a que l'ordre.
|
||||
|
||||
**Outil de remise à zéro** : *Settings → Debug → Delete the local vault* (deux
|
||||
appuis). Il efface cache, conteneur local et drapeau d'attente, **et garde les
|
||||
réglages du dépôt** — on retélécharge sans rien retaper. Absent en release : la
|
||||
commande n'y est même pas compilée.
|
||||
|
||||
### Préparation — l'état de départ, et il compte
|
||||
|
||||
Plusieurs séries agissent sur une entrée existante. Partir d'un coffre vide les
|
||||
rendrait ininterprétables : on ne sait pas distinguer « la modification n'a pas
|
||||
voyagé » de « il n'y avait rien à faire voyager ».
|
||||
|
||||
1. *Settings → Debug → Delete the local vault*, puis **Create vault**. Le coffre
|
||||
naît vide : zéro entrée, **et aucun dossier** — `NewEmptySnapshot` écrit un
|
||||
tableau `folders` vide.
|
||||
2. Créer **une seule entrée**, avec un mot de passe reconnaissable. Elle se range
|
||||
dans **`All`**, et c'est ce premier enregistrement qui fait apparaître le
|
||||
dossier : `AddEntry` pose `folder = 'All'` parce que c'est le dossier par
|
||||
défaut du poste, pas parce que c'est joli.
|
||||
3. Vérifier qu'il n'y a **qu'un dossier** dans la barre. Deux au départ voudrait
|
||||
dire qu'un instantané traîne encore.
|
||||
|
||||
C'est le point de départ de tout ce qui suit. Y revenir entre deux séries qui se
|
||||
gênent coûte deux appuis.
|
||||
|
||||
**Un coffre vide reste un cas à éprouver**, mais explicitement : c'est lui qui a
|
||||
révélé que « coffre ouvert » se lisait sur le nombre d'entrées (défaut n° 3), et
|
||||
que l'auto-verrouillage se coupait de lui-même. À reprendre au moins une fois
|
||||
sans entrée : *Sync* ne doit pas boucler, et le coffre doit finir par se
|
||||
verrouiller seul.
|
||||
|
||||
### A. Enregistrement hors connexion — le plus important
|
||||
|
||||
**Exige un coffre PUBLIÉ** — donc la série C d'abord. Sur un coffre né ici et
|
||||
jamais réconcilié, il n'y a rien à publier et rien ne se met en attente.
|
||||
|
||||
1. Coffre ouvert, **couper les données** (mode avion).
|
||||
2. Modifier un mot de passe → *« saved here, not published yet »*, et **point
|
||||
orange** sur le cadenas.
|
||||
3. Verrouiller → le point doit être **sur le bouton Sync** de l'accueil.
|
||||
4. **Fermer complètement l'application** (glisser depuis les récentes), rouvrir,
|
||||
ouvrir le coffre → le point doit **être encore là**. C'est l'étape qui éprouve
|
||||
la persistance du drapeau ; sans elle, le reste ne prouve rien.
|
||||
5. Rétablir le réseau, *Sync* → *« Offline changes published »*, point éteint.
|
||||
6. Sur le poste, synchroniser → la modification doit y être.
|
||||
|
||||
### B. La fusion, qui est le cœur du sujet
|
||||
|
||||
1. Téléphone **hors ligne**, modifier l'entrée **X**.
|
||||
2. Sur le poste, modifier une **autre** entrée **Y**, synchroniser.
|
||||
3. Téléphone en ligne, *Sync*.
|
||||
4. **Les deux modifications doivent survivre.** Si une seule survit, c'est
|
||||
l'arbitrage sur `updated_at` qu'il faut regarder.
|
||||
5. Variante avec un **dossier créé hors connexion** : il doit apparaître dans la
|
||||
barre de dossiers après la fusion, et sur le poste après sa synchronisation.
|
||||
|
||||
### C. Les deux secrets — nouveau, et jamais éprouvé de bout en bout
|
||||
|
||||
1. Effacer le coffre local (bouton Debug). Les trois champs du dépôt doivent
|
||||
**rester remplis** — c'est la moitié de ce que ce bouton promet.
|
||||
2. *Create vault* avec un mot de passe **différent** de celui du dépôt.
|
||||
3. Ouvrir les réglages : le champ *sync password* doit porter **celui du dépôt**,
|
||||
pas celui qu'on vient de choisir. C'est toute la séparation en une capture.
|
||||
4. *Sync* → la réconciliation lit le dépôt et republie **sous le secret du
|
||||
dépôt**. Contre-épreuve décisive : **le poste doit relire le dépôt sans se
|
||||
plaindre.** S'il échoue, c'est que la clé du conteneur local a servi à
|
||||
resceller l'instantané.
|
||||
5. Après la réconciliation, `vault-local.json` doit avoir disparu — c'est ce
|
||||
geste qui passe l'autorité au dépôt.
|
||||
|
||||
### D. Ouvrir demande quelque chose — nouveau
|
||||
|
||||
1. Effacer le coffre, puis *Download vault*.
|
||||
2. **Fermer complètement l'application**, rouvrir → le champ de code doit
|
||||
paraître et réclamer **le mot de passe de sync**. Le cache est scellé sous
|
||||
lui, parce qu'il le contient déjà : l'ouvrir sans rien livrait le coffre *et*
|
||||
le secret du dépôt.
|
||||
3. Vérification par le fichier, si adb est à portée :
|
||||
`run-as com.embarcadero.pmserverApk od -An -tu1 -N1 files/vault.bin` doit
|
||||
rendre **4** (`VER_PIN`) et non 1.
|
||||
4. Un **mauvais code** → *« Wrong unlock code »* **en rouge**, et *Open vault*
|
||||
reste offert : le cache n'est pas déclaré illisible.
|
||||
5. Sur un appareil **avec biométrie**, rien de tout cela : `VER_WRAP` garde déjà
|
||||
la porte, et imposer une frappe serait un recul. C'est volontaire.
|
||||
|
||||
### E. Le dépôt a bougé — témoin bleu
|
||||
|
||||
**Cette série était impossible telle qu'elle était écrite**, et la mesure le dit :
|
||||
`AUTOLOCK_MINUTES = 3` verrouille après trois minutes d'inactivité, tandis que
|
||||
`PROBE_EVERY_TICKS = 40` — à quinze secondes le battement — demande **dix minutes
|
||||
de coffre ouvert**. Le compteur n'avance que tant que le coffre l'est, et
|
||||
`LockVault` sort avant de l'incrémenter. « Laisser ouvert dix minutes sans y
|
||||
toucher » verrouillait donc à la troisième, et le sondage ne partait jamais.
|
||||
|
||||
Ce n'est pas un défaut de l'application : le témoin sert **pendant qu'on se sert
|
||||
du coffre**, et quelqu'un qui s'en sert le touche. Mais il faut le dire, sinon on
|
||||
cherche un point qui ne peut pas paraître.
|
||||
|
||||
1. Coffre ouvert sur le téléphone. **Le toucher toutes les deux minutes** —
|
||||
faire défiler la liste suffit, il ne faut surtout pas synchroniser : un
|
||||
*Sync*, comme une ouverture, remet le compteur à zéro.
|
||||
2. Sur le poste, modifier une entrée et synchroniser.
|
||||
3. Continuer à toucher jusqu'à **dix minutes de coffre ouvert**. Le journal doit
|
||||
dire `remote has moved - sync to bring it in`, et un **point bleu** paraître
|
||||
sur *Sync* et sur le cadenas.
|
||||
4. **Rien ne doit changer à l'écran de soi-même.** Cette condition compte autant
|
||||
que le témoin : si l'entrée qu'on regarde se met à jour toute seule, le
|
||||
sondage adopte au lieu de signaler, ce qui est plus grave qu'un témoin absent.
|
||||
5. *Sync* → le point bleu s'éteint.
|
||||
6. Les deux ensemble : provoquer A puis E sans synchroniser → **orange et bleu
|
||||
côte à côte**. Ils demandent deux gestes différents.
|
||||
|
||||
**Si tu préfères ne pas veiller dix minutes**, la contre-épreuve courte : mets
|
||||
`PROBE_EVERY_TICKS` à 8 — deux minutes — recompile, éprouve les points 3 à 6,
|
||||
puis remets 40. C'est la seule série du plan où le réglage de production rend
|
||||
l'observation coûteuse.
|
||||
|
||||
### F. Réglages — largement réécrits, à reprendre en entier
|
||||
|
||||
1. **Coffre fermé**, ouvrir les réglages depuis l'accueil → la section *Unlock
|
||||
code* doit être **absente**. Poser comme retirer exigent le clair en main :
|
||||
coffre fermé, les deux ne pourraient que refuser.
|
||||
2. Ouvrir le coffre, roue dentée → la section paraît.
|
||||
3. **Poser un code** : taper, *Set this code* → *« Unlock code set »*
|
||||
**immédiatement, sans quitter l'écran**. Plus de « ouvre le coffre pour
|
||||
l'appliquer » : le code s'applique sur-le-champ.
|
||||
4. Le message doit se voir **au-dessus du clavier resté ouvert**. C'est le
|
||||
défaut le plus discret de la séance : le toast vivait sous le clavier.
|
||||
5. **Un seul appui** doit suffire sur *Set this code*. S'il en faut deux, le
|
||||
premier est mangé par le clavier et la correction du focus a régressé.
|
||||
6. **Retirer le code** : *Remove the unlock code* sans rien taper → refus
|
||||
*« Type the current unlock code to remove it »*. Puis taper le code courant,
|
||||
deux appuis → *« Unlock code removed »*. Fermer l'application, rouvrir → **le
|
||||
code n'est plus demandé**.
|
||||
7. Contre-épreuve : *Set this code* avec un champ **vide** ne doit **pas** retirer
|
||||
le code — c'était une porte dérobée vers le retrait sans le connaître.
|
||||
8. Le champ *sync password* doit porter le mot de passe du coffre **détenu**, pas
|
||||
une valeur de premier lancement.
|
||||
9. Touche **retour** dans les réglages → referme **sans** verrouiller ni changer
|
||||
de vue.
|
||||
|
||||
### G. Icônes
|
||||
|
||||
1. Ouvrir une entrée dont le *Site* est une adresse — `git.ai-agents4you.com`.
|
||||
2. Trois points → **Fetch icon** → l'icône doit apparaître dans la liste.
|
||||
3. Une entrée dont le site n'est **pas** une adresse (*Gitea*, ou vide) → refus
|
||||
*« no web address to look up »*. Sans point dans le nom, il n'y a rien à
|
||||
demander au relais.
|
||||
4. Un domaine à trois niveaux (`chat.qwen.ai`) doit retomber sur le second
|
||||
niveau : c'est le deuxième essai.
|
||||
5. **Remove icon** → l'entrée reprend son initiale.
|
||||
6. Synchroniser depuis le poste : l'icône doit y arriver, et le poste ne doit
|
||||
**pas** la traiter comme une icône choisie — c'est le rôle du drapeau de
|
||||
provenance, mis à zéro par une recherche automatique.
|
||||
|
||||
### H. Anciens mots de passe
|
||||
|
||||
**Le poste doit synchroniser d'abord** : il ne les envoie que depuis peu, et un
|
||||
instantané publié avant ne porte aucun historique.
|
||||
|
||||
1. Sur le poste, changer le mot de passe d'une entrée, puis synchroniser.
|
||||
2. Téléphone : *Sync*, ouvrir l'entrée, trois points → **Previous passwords**.
|
||||
L'ancienne version doit être là, avec sa date.
|
||||
3. Copier l'une d'elles → *« Previous password copied »*.
|
||||
4. Touche **retour** → le voile se referme sans toucher au coffre.
|
||||
5. Une entrée jamais modifiée → *« No previous password recorded »*, et non un
|
||||
écran vide.
|
||||
6. Contre-épreuve : verrouiller, rouvrir, **Previous passwords** → la liste doit
|
||||
être reconstruite, jamais celle d'avant restée à l'écran.
|
||||
|
||||
### I. Clavier, mise en page, retour d'application — nouveau
|
||||
|
||||
1. Taper dans un champ, appuyer sur un bouton **hors de sa rangée** (*Open
|
||||
vault*, *Sign in*, *Set this code*) → **un seul appui**, et le clavier se
|
||||
replie ensuite.
|
||||
2. Taper dans un champ, appuyer sur un bouton **de sa rangée** (l'œil d'un champ
|
||||
secret) → le clavier **reste ouvert** et le curseur en place. Les deux moitiés
|
||||
comptent : la première sans la seconde laisse le clavier dressé.
|
||||
3. **Réglages ouverts coffre FERMÉ**, verrouiller le téléphone, déverrouiller →
|
||||
la page doit revenir. C'était un aplat bleu sans rien dessus : le masquage ne
|
||||
demandait rien, la restitution exigeait un coffre ouvert.
|
||||
4. Même chose **coffre ouvert** : passé le délai de grâce, le verrouillage doit
|
||||
avoir lieu et ramener sur l'accueil — pas sur la liste.
|
||||
5. Accueil sans coffre : l'ordre doit être **Download vault → Settings →
|
||||
username / password → Create vault**, espacements réguliers, et *Download
|
||||
vault* doit porter la couleur d'accent quand un dépôt est configuré.
|
||||
6. Les boutons du bandeau (roue, +, cadenas) et ceux de la barre de sélection
|
||||
sont **ronds**.
|
||||
|
||||
### J. Sélection et actions de liste
|
||||
|
||||
1. Appui long → barre contextuelle, compteur.
|
||||
2. Deuxième ligne → compteur à 2.
|
||||
3. **Dupliquer** → deux copies, suffixées *(copy)*.
|
||||
4. **Supprimer** → confirmation en deux temps, puis les lignes disparaissent.
|
||||
5. Touche **retour** pendant une sélection → elle s'annule **sans verrouiller**.
|
||||
6. Sur le poste, synchroniser → les suppressions doivent y être répercutées.
|
||||
|
||||
### K. Notes multilignes
|
||||
|
||||
1. **+** → *Note* → zone de texte pleine largeur, *Site* et *Username* disparus.
|
||||
2. Taper plusieurs lignes — Entrée doit **insérer un saut**, pas replier le
|
||||
clavier.
|
||||
3. Enregistrer, rouvrir → les ruptures doivent être là **en lecture**.
|
||||
4. Basculer *Login* → *Note* après avoir tapé : la saisie doit **survivre**.
|
||||
5. Contre-épreuve : dans un champ d'une ligne, Entrée doit toujours **replier le
|
||||
clavier**.
|
||||
|
||||
### L. 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.
|
||||
|
||||
### M. Empaquetage — CLOSE le 2026-08-29, apres quatre obstacles
|
||||
|
||||
L'APK signe existe : `CN=R Zakarya, ..., C=DZ`, 12,1 Mo. Ce chemin n'avait
|
||||
jamais tourne, et il a butte quatre fois — toutes sur des choses que **seule une
|
||||
release partant d'un terrain vierge pouvait rencontrer**. Le debogage vit sur un
|
||||
arbre constitue au fil des mois, ou tout cela existait deja.
|
||||
|
||||
1. **Le dossier de mise en scene n'existait pas.** Les trois copies du script s'y
|
||||
deversent avant l'etape Deploy qui le creuse. Corrige : on le cree d'abord.
|
||||
2. **Le volume etait plein.** Debug et Release cote a cote ne tiennent pas sur
|
||||
ce disque ; le `strip` echouait a mi-fichier et empoisonnait tout ce qui
|
||||
suivait. A surveiller avant chaque release.
|
||||
3. **aapt refusait cinq doublons a la racine de `res\`**, deposes par le
|
||||
deploiement de Delphi alors que les vrais sont dans `drawable\`. Corrige :
|
||||
on les ecarte avant l'appel.
|
||||
4. **Les ressources generees n'etaient pas mises en place.** Le Build les depose
|
||||
a plat dans `Android64\<Config>\` et le Deploy ne les range dans `res\`
|
||||
qu'en DEBUG — d'ou `@style/AppTheme` introuvable. Corrige : le script les
|
||||
range lui-meme, avant les notres pour que notre `colors.xml` garde le dernier
|
||||
mot.
|
||||
|
||||
**Ce qu'il faut verifier a chaque release :**
|
||||
|
||||
1. La sortie doit afficher `Signer #1 certificate DN: CN=R Zakarya`. Si elle dit
|
||||
`CN=Android Debug`, la release ne vaut rien.
|
||||
2. Elle ne s'installe pas d'elle-meme, et c'est voulu : une signature differente
|
||||
obligerait a desinstaller d'abord, ce qui effacerait le coffre local.
|
||||
3. La section **Debug** ne doit pas etre atteignable. **Mesure exacte** : la
|
||||
chaine `DEBUG: local vault dropped` doit etre absente du `.so` — en UTF-16LE,
|
||||
comme toutes les chaines Delphi ; la chercher en ASCII ne prouve rien.
|
||||
Attention, `wipelocal` et `showDebug` s'y trouvent dans les DEUX
|
||||
configurations : ils viennent de la page HTML embarquee, qui porte toujours
|
||||
le bouton. Ce qui manque en release, c'est l'appel `PM.showDebug()` et le
|
||||
gestionnaire du pont — la section est donc inatteignable et sans effet, pas
|
||||
absente.
|
||||
4. **Le mot de passe du magasin ne se retrouve pas.** Un JKS ne s'ouvre pas sans
|
||||
lui. Aujourd'hui le recreer ne coute rien ; une fois une version distribuee,
|
||||
le perdre voudra dire ne plus jamais pouvoir mettre a jour l'application sur
|
||||
ces appareils - Android refuse une mise a jour dont la signature a change.
|
||||
|
||||
---
|
||||
|
||||
## Ce qui n'est toujours pas éprouvé
|
||||
|
||||
- **Le chemin `-Release`** du script n'a jamais produit d'APK signé. Il compile,
|
||||
c'est tout ce qu'on sait.
|
||||
- **L'invalidation de la clé par le système** (§ D.5 pour la biométrie) : le
|
||||
message *« Device protection changed »* existe et son drapeau est posé là où
|
||||
l'invalidation se constate, mais personne ne l'a vu s'afficher. Il faudrait
|
||||
retirer le verrouillage d'écran d'un appareil qui en a un.
|
||||
- **`MergeSelfCheck`** tourne au démarrage en débogage et écrit `merge selfcheck:
|
||||
the four rules hold` dans le journal. Il passe ; personne ne l'a lu en
|
||||
cherchant à le mettre en défaut.
|
||||
- **Le mode `login`** (conteneur local sans cache) ne s'atteint qu'en supprimant
|
||||
le cache à la main. C'est là qu'a été trouvée la lecture après libération : il
|
||||
n'avait jamais tourné.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 711 B |
Binary file not shown.
|
After Width: | Height: | Size: 922 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fond de l'icone adaptative : un aplat, dans le bleu de l'application.
|
||||
Le systeme le decoupe a la forme voulue par le lanceur (cercle, carre
|
||||
arrondi, goutte), donc il doit couvrir les 108 unites entieres.
|
||||
|
||||
Pas de degrade : aapt - la premiere version, celle que sert la chaine de
|
||||
ce projet - ne connait pas les degrades en ligne dans un vecteur. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#1B3563"
|
||||
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Avant-plan de l'icone adaptative : un bouclier a serrure.
|
||||
TOUT DOIT TENIR DANS LES 66 UNITES CENTRALES (soit 21..87) : le systeme
|
||||
recadre et anime la couche autour de cette zone sure, et ce qui deborde
|
||||
peut etre rogne selon le lanceur. Le dessin occupe 31..77 en x, 29..82
|
||||
en y.
|
||||
|
||||
UN SEUL CHEMIN, EN EVENODD. La serrure n'est pas peinte de la couleur du
|
||||
fond, elle est EVIDEE : le fond se voit au travers. Peinte, elle imposerait
|
||||
de tenir deux fichiers d'accord sur une couleur, et le moindre ecart se
|
||||
verrait comme une tache. Evidee, il n'y a rien a synchroniser.
|
||||
|
||||
Blanc plein plutot qu'un trait bleu : a 36 px un contour se boue, une
|
||||
silhouette pleine tient. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M54,29 L77,37 L77,56 C77,69.5 67,78 54,82 C41,78 31,69.5 31,56 L31,37 Z
|
||||
M54,50 m-7,0 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0 Z
|
||||
M51,55.5 L57,55.5 L58.6,70 L49.4,70 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Couche monochrome (icones thematiques d'Android 13+). Le systeme ne
|
||||
retient que la FORME : il repeint le dessin avec la couleur du theme de
|
||||
l'utilisateur, donc la couleur posee ici n'a aucune importance.
|
||||
Meme bouclier que l'avant-plan, serrure evidee de la meme facon. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M54,29 L77,37 L77,56 C77,69.5 67,78 54,82 C41,78 31,69.5 31,56 L31,37 Z
|
||||
M54,50 m-7,0 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0 Z
|
||||
M51,55.5 L57,55.5 L58.6,70 L49.4,70 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Ecran de demarrage pour Android 11 et anterieurs, qui n'ont pas l'API
|
||||
SplashScreen : le systeme se contente d'afficher windowBackground, donc
|
||||
c'est ici qu'il faut composer fond + logo.
|
||||
Remplace la version generee, qui centrait une image bitmap de Delphi. -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:opacity="opaque">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item
|
||||
android:gravity="center"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:drawable="@drawable/splash_vector" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Icone de l'ecran de demarrage.
|
||||
Sur Android 12+, le systeme l'affiche via windowSplashScreenAnimatedIcon
|
||||
dans un disque de 288 unites dont il ne garantit que les 192 centrales :
|
||||
le dessin occupe 103..186, bien a l'interieur.
|
||||
Meme bouclier que l'icone du lanceur, a l'echelle.
|
||||
|
||||
ICI LA SERRURE EST PEINTE, pas evidee : la couche de demarrage est posee
|
||||
a plat sur le fond du systeme, et un evidement y ferait un trou. Elle est
|
||||
entierement contenue dans le bouclier blanc, donc sa couleur n'a qu'a
|
||||
trancher sur du blanc - elle ne depend d'aucun autre fichier. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="288dp"
|
||||
android:height="288dp"
|
||||
android:viewportWidth="288"
|
||||
android:viewportHeight="288">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M144,96.3 L185.4,110.7 L185.4,144.9 C185.4,169.2 167.4,184.5 144,191.7 C120.6,184.5 102.6,169.2 102.6,144.9 L102.6,110.7 Z" />
|
||||
<path
|
||||
android:fillColor="#1B3563"
|
||||
android:pathData="M144,134.1 m-12.6,0 a12.6,12.6 0 1,0 25.2,0 a12.6,12.6 0 1,0 -25.2,0 Z" />
|
||||
<path
|
||||
android:fillColor="#1B3563"
|
||||
android:pathData="M138.6,144 L149.4,144 L152.3,170.1 L135.7,170.1 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Remplace le colors.xml genere, dont splash_background vaut #FFF : un
|
||||
ecran de demarrage blanc suivi d'une application sombre donne un flash
|
||||
desagreable a chaque lancement. -->
|
||||
<resources xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Le haut du degrade de l'application : l'ecran de demarrage se fond
|
||||
alors dans le premier ecran au lieu de le trancher. -->
|
||||
<color name="splash_background">#3E6394</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Le trafic en clair n'est permis que vers la BOUCLE LOCALE, et interdit
|
||||
partout ailleurs.
|
||||
|
||||
Il l'etait auparavant partout, via usesCleartextTraffic="true". C'etait pour
|
||||
une seule raison : l'essai local passe par "adb reverse", qui fait voir le
|
||||
poste comme 127.0.0.1 et parle HTTP. Mais l'attribut ne distingue pas la
|
||||
boucle locale du reste du monde - une URL mal saisie, ou un serveur repassant
|
||||
en HTTP, envoyait le jeton en clair sur le reseau sans que rien ne le signale.
|
||||
|
||||
Ici la permission est nominative. Le jeton ne peut plus sortir qu'en TLS, et
|
||||
une URL "http://" vers un vrai hote echoue tout de suite, ce qui est
|
||||
exactement le comportement voulu : bruyant, pas silencieux.
|
||||
|
||||
cleartextTrafficPermitted="false" a la racine n'est pas redondant avec le
|
||||
defaut d'Android 9+ : il vaut aussi pour les versions anterieures, et il dit
|
||||
l'intention noir sur blanc.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,120 @@
|
||||
# Compile, empaquette, installe et lance le client Android.
|
||||
#
|
||||
# POURQUOI CE SCRIPT. La chaine a quatre etapes qui ne s'appellent pas entre
|
||||
# elles, et en sauter une donne un APK qui s'installe sans porter la
|
||||
# modification - le symptome se lit alors comme un bug applicatif. La premiere,
|
||||
# web.res, s'est deja fait oublier une fois : la page embarquee etait celle
|
||||
# d'avant l'edition, et le pont echouait pour une raison qui n'existait plus.
|
||||
#
|
||||
# -NoInstall compile et empaquette sans toucher au telephone.
|
||||
# -Release configuration Release, signee avec VOTRE magasin de cles.
|
||||
#
|
||||
# LA RELEASE NE S'INSTALLE PAS PAR-DESSUS LE DEBOGAGE. Android refuse une
|
||||
# signature differente sur un paquet deja installe ; il faut desinstaller
|
||||
# d'abord, ce qui EFFACE le cache local et les reglages de l'appareil. Le
|
||||
# script ne le fait donc pas a votre place - il produit l'APK et s'arrete.
|
||||
#
|
||||
# .\android-client\build-apk.ps1 -Release `
|
||||
# -Keystore C:\cles\pmserver-release.jks -Alias pmserver
|
||||
|
||||
param(
|
||||
[switch]$NoInstall,
|
||||
[switch]$Release,
|
||||
[string]$Keystore,
|
||||
[string]$Alias = 'pmserver'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$bin = 'C:\Program Files (x86)\Embarcadero\Studio\23.0\bin'
|
||||
$sdk = 'C:\Users\Public\Documents\Embarcadero\Studio\23.0\CatalogRepository'
|
||||
$adb = "$sdk\AndroidSDK-2525-23.0.51511.6924\platform-tools\Adb.exe"
|
||||
$strip = "$sdk\AndroidNDK-21-23.0.51511.6924\android-ndk-r21\toolchains\" +
|
||||
'aarch64-linux-android-4.9\prebuilt\windows-x86_64\aarch64-linux-android\bin\strip.exe'
|
||||
$cfg = if ($Release) { 'Release' } else { 'Debug' }
|
||||
$out = "$root\Android64\$cfg"
|
||||
$stage = "$out\pmserverApk"
|
||||
|
||||
if ($Release) {
|
||||
if (-not $Keystore) { throw 'Chemin du magasin de cles requis : -Keystore' }
|
||||
if (-not (Test-Path $Keystore)) { throw "Magasin introuvable : $Keystore" }
|
||||
# LU ICI ET NULLE PART AILLEURS. Read-Host -AsSecureString n'affiche rien et
|
||||
# ne laisse pas la frappe dans l'historique de la console.
|
||||
$sec = Read-Host "Mot de passe du magasin $([IO.Path]::GetFileName($Keystore))" -AsSecureString
|
||||
$env:PM_KS_PASS = [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
|
||||
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
|
||||
}
|
||||
|
||||
# 1. La page HTML -> ressource liee dans l'executable.
|
||||
Push-Location "$PSScriptRoot\web"
|
||||
& "$bin\brcc32.exe" web.rc -foweb.res | Out-Null
|
||||
Pop-Location
|
||||
|
||||
# 2. Compilation Delphi.
|
||||
$msbuild = "`"$bin\rsvars.bat`" && msbuild `"$root\pmserverApk.dproj`""
|
||||
$log = cmd /c "$msbuild /p:Platform=Android64 /p:Config=$cfg /t:Build /v:minimal /nologo" 2>&1
|
||||
$err = $log | Select-String -Pattern 'error'
|
||||
if ($err) { $err | ForEach-Object { $_.Line }; throw 'compilation echouee' }
|
||||
$log | Select-String -Pattern 'lines,' | ForEach-Object { $_.Line.Trim() }
|
||||
|
||||
# 3. Mise en place a la main de ce que le deploiement ne place pas : manifeste,
|
||||
# .dex, et la bibliotheque native ALLEGEE - la version de debogage pese
|
||||
# 91 Mo, plus que la place libre sur ce volume.
|
||||
#
|
||||
# LES DOSSIERS D'ABORD, ET C'EST LA PREMIERE RELEASE QUI L'A APPRIS. Cet arbre
|
||||
# est normalement creuse par l'etape Deploy - qui vient APRES celle-ci. En
|
||||
# debogage il existait deja, laisse par un deploiement anterieur, et personne ne
|
||||
# s'en apercevait ; pour une configuration jamais empaquetee, les trois copies
|
||||
# ci-dessous tombaient sur un chemin absent. Les creer ne coute rien quand elles
|
||||
# existent, et evite qu'une configuration neuve echoue sur sa premiere ligne.
|
||||
New-Item -ItemType Directory -Force -Path "$stage",
|
||||
"$stage\classes", "$stage\library\lib\arm64-v8a" | Out-Null
|
||||
Copy-Item "$out\AndroidManifest.xml" "$stage\AndroidManifest.xml" -Force
|
||||
Copy-Item "$out\pmserverApk.classes\*.dex" "$stage\classes\" -Force
|
||||
& $strip --strip-debug -o "$stage\library\lib\arm64-v8a\libpmserverApk.so" "$out\libpmserverApk.so"
|
||||
|
||||
# L'AAPT DE DEPLOY ECHOUE, ET C'EST ATTENDU. Le manifeste reference
|
||||
# @xml/network_security_config, que la generation de ressources de Delphi ne
|
||||
# contient pas ; poser le fichier avant Deploy ne sert a rien, Deploy reconstruit
|
||||
# res\ juste avant d'appeler aapt. L'etape suivante repose nos ressources et
|
||||
# refait l'archive entiere, donc l'APK livre est complet - verifie a
|
||||
# "aapt list" : res/xml/network_security_config.xml y est, et l'attribut du
|
||||
# manifeste resout vers un identifiant reel.
|
||||
#
|
||||
# On ne masque donc que CETTE erreur, nommement, et tout autre message remonte.
|
||||
$deploy = cmd /c "$msbuild /p:Platform=Android64 /p:Config=$cfg /t:Deploy /v:minimal /nologo" 2>&1
|
||||
$deploy | Select-String -Pattern 'error' |
|
||||
Where-Object { $_.Line -notmatch 'network_security_config|Unable to execute.*Aapt\.exe' } |
|
||||
ForEach-Object { $_.Line }
|
||||
|
||||
# 4. aapt + zipalign + apksigner, et remise en place de ce que Deploy efface.
|
||||
try {
|
||||
if ($Release) {
|
||||
& "$PSScriptRoot\repack-apk.ps1" -Config Release -Keystore $Keystore -Alias $Alias
|
||||
} else {
|
||||
& "$PSScriptRoot\repack-apk.ps1" | Select-Object -Last 1
|
||||
}
|
||||
} finally {
|
||||
# Le mot de passe ne survit pas au script, meme si celui-ci echoue.
|
||||
Remove-Item Env:PM_KS_PASS -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($Release) {
|
||||
"APK signe : $stage\bin\pmserverApk.apk"
|
||||
'Non installe : desinstaller d''abord la version de debogage effacerait le' +
|
||||
' coffre local et les reglages.'
|
||||
return
|
||||
}
|
||||
|
||||
if ($NoInstall) { return }
|
||||
|
||||
# adb ecrit sa progression sur la sortie d'erreur, ce que PowerShell prend pour
|
||||
# un echec. Sans ce relachement, une installation reussie interrompt le script.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
& $adb install -r "$stage\bin\pmserverApk.apk" 2>&1 |
|
||||
Select-String -Pattern 'Success|Failure' | ForEach-Object { $_.Line }
|
||||
# Le telephone voit le poste comme 127.0.0.1 par ce renvoi.
|
||||
& $adb reverse tcp:8080 tcp:80 | Out-Null
|
||||
& $adb shell am force-stop com.embarcadero.pmserverApk
|
||||
& $adb shell am start -n com.embarcadero.pmserverApk/com.embarcadero.firemonkey.FMXNativeActivity | Out-Null
|
||||
@@ -0,0 +1,76 @@
|
||||
# Genere les icones PNG du lanceur, pour Android < 8 qui ignore les icones
|
||||
# adaptatives. Meme dessin que les vecteurs de android-res\drawable :
|
||||
# bouclier blanc a serrure, sur le bleu de l'application.
|
||||
#
|
||||
# A relancer seulement si le dessin change ; les PNG sont suivis par git.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$root = Join-Path $PSScriptRoot 'android-res'
|
||||
$bg = [System.Drawing.Color]::FromArgb(255, 27, 53, 99) # #1B3563
|
||||
$fg = [System.Drawing.Color]::FromArgb(255, 255, 255, 255) # blanc
|
||||
|
||||
# densite -> cote en pixels, tel qu'Android les attend
|
||||
$sizes = @{ 'drawable-ldpi' = 36; 'drawable-mdpi' = 48; 'drawable-hdpi' = 72
|
||||
'drawable-xhdpi' = 96; 'drawable-xxhdpi' = 144; 'drawable-xxxhdpi' = 192 }
|
||||
|
||||
foreach ($dir in $sizes.Keys) {
|
||||
$n = $sizes[$dir]
|
||||
$out = Join-Path $root $dir
|
||||
New-Item -ItemType Directory -Force $out | Out-Null
|
||||
|
||||
$bmp = New-Object System.Drawing.Bitmap $n, $n
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.SmoothingMode = 'AntiAlias'
|
||||
|
||||
# Fond : carre arrondi. Les vieux lanceurs ne masquent pas, donc l'icone
|
||||
# doit porter sa propre forme.
|
||||
$r = [int]($n * 0.22)
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$path.AddArc(0, 0, 2*$r, 2*$r, 180, 90)
|
||||
$path.AddArc($n-2*$r-1, 0, 2*$r, 2*$r, 270, 90)
|
||||
$path.AddArc($n-2*$r-1, $n-2*$r-1, 2*$r, 2*$r, 0, 90)
|
||||
$path.AddArc(0, $n-2*$r-1, 2*$r, 2*$r, 90, 90)
|
||||
$path.CloseFigure()
|
||||
$g.FillPath((New-Object System.Drawing.SolidBrush $bg), $path)
|
||||
|
||||
# Echelle : le dessin vectoriel vit dans un carre de 108.
|
||||
$u = $n / 108.0
|
||||
$brush = New-Object System.Drawing.SolidBrush $fg
|
||||
|
||||
# Bouclier : sommet, epaules, montants, puis les deux courbes qui se
|
||||
# rejoignent en pointe. Memes coordonnees que ic_launcher_foreground.xml.
|
||||
$s = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$s.AddLines(@(
|
||||
(New-Object System.Drawing.PointF ([float](54*$u), [float](29*$u))),
|
||||
(New-Object System.Drawing.PointF ([float](77*$u), [float](37*$u))),
|
||||
(New-Object System.Drawing.PointF ([float](77*$u), [float](56*$u)))))
|
||||
$s.AddBezier(
|
||||
[float](77*$u), [float](56*$u), [float](77*$u), [float](69.5*$u),
|
||||
[float](67*$u), [float](78*$u), [float](54*$u), [float](82*$u))
|
||||
$s.AddBezier(
|
||||
[float](54*$u), [float](82*$u), [float](41*$u), [float](78*$u),
|
||||
[float](31*$u), [float](69.5*$u), [float](31*$u), [float](56*$u))
|
||||
$s.AddLine([float](31*$u), [float](56*$u), [float](31*$u), [float](37*$u))
|
||||
$s.CloseFigure()
|
||||
$g.FillPath($brush, $s)
|
||||
|
||||
# Serrure, evidee dans la couleur du fond. Le vecteur y arrive par un
|
||||
# remplissage pair-impair ; ici, repeindre par-dessus revient au meme,
|
||||
# l'icone ancienne etant de toute facon opaque.
|
||||
$bgBrush = New-Object System.Drawing.SolidBrush $bg
|
||||
$g.FillEllipse($bgBrush, [float](47*$u), [float](43*$u), [float](14*$u), [float](14*$u))
|
||||
$slot = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$slot.AddLines(@(
|
||||
(New-Object System.Drawing.PointF ([float](51*$u), [float](55.5*$u))),
|
||||
(New-Object System.Drawing.PointF ([float](57*$u), [float](55.5*$u))),
|
||||
(New-Object System.Drawing.PointF ([float](58.6*$u), [float](70*$u))),
|
||||
(New-Object System.Drawing.PointF ([float](49.4*$u), [float](70*$u)))))
|
||||
$slot.CloseFigure()
|
||||
$g.FillPath($bgBrush, $slot)
|
||||
$g.Dispose()
|
||||
$bmp.Save((Join-Path $out 'ic_launcher.png'), [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bmp.Dispose()
|
||||
"{0,-20} {1}x{1}" -f $dir, $n
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Produces a pm-encrypted-export-v1 container with WebCrypto — the exact
|
||||
// primitive the browser uses — so the Delphi/Android implementation can be
|
||||
// checked against a known answer instead of "it seems to work on my file".
|
||||
//
|
||||
// node make-testvector.js > testvector.json
|
||||
//
|
||||
// The password and the payload are fixed below; keep them in sync with
|
||||
// testvector.md. Salt and IV are fixed too, so the output is reproducible and
|
||||
// a diff means a real behavioural change.
|
||||
|
||||
const { webcrypto } = require('node:crypto');
|
||||
const { subtle } = webcrypto;
|
||||
|
||||
const PASSWORD = 'sync-test-password-éàü'; // deliberately NON-ASCII: that is
|
||||
// where char[] vs UTF-8 diverges
|
||||
const PAYLOAD = {
|
||||
version: 1,
|
||||
exported_at: '2026-08-14T12:00:00.000Z',
|
||||
username: 'testvector',
|
||||
folders: [{ name: 'Work', color: '#3b82f6', icon: '' }],
|
||||
entries: [
|
||||
{
|
||||
uuid: '11111111-2222-3333-4444-555555555555',
|
||||
site: 'example.com',
|
||||
title: 'Example',
|
||||
username: 'alice',
|
||||
password: 'correct horse battery staple',
|
||||
folder: 'Work',
|
||||
tags: ['demo'],
|
||||
kind: 'login',
|
||||
deleted: 0,
|
||||
},
|
||||
{
|
||||
uuid: '66666666-7777-8888-9999-000000000000',
|
||||
site: 'trashed.example',
|
||||
title: 'Trashed',
|
||||
username: 'bob',
|
||||
password: 'in the bin',
|
||||
folder: 'All',
|
||||
tags: [],
|
||||
kind: 'login',
|
||||
deleted: 1,
|
||||
deleted_at: '2026-08-01 09:30:00',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Fixed, so the file is byte-stable across runs. NEVER do this in the app:
|
||||
// a reused salt/IV pair with the same key destroys GCM's security.
|
||||
const salt = Buffer.alloc(32);
|
||||
for (let i = 0; i < 32; i++) salt[i] = i;
|
||||
const iv = Buffer.alloc(12);
|
||||
for (let i = 0; i < 12; i++) iv[i] = 200 + i;
|
||||
|
||||
(async () => {
|
||||
const base = await subtle.importKey('raw', new TextEncoder().encode(PASSWORD),
|
||||
'PBKDF2', false, ['deriveKey']);
|
||||
const key = await subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
|
||||
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
|
||||
const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key,
|
||||
new TextEncoder().encode(JSON.stringify(PAYLOAD)));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
format: 'pm-encrypted-export-v1',
|
||||
kdf: 'pbkdf2-sha256',
|
||||
kdf_iterations: 600000,
|
||||
kdf_salt: salt.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
ciphertext: Buffer.from(ct).toString('base64'),
|
||||
created_at: '2026-08-14T12:00:00.000Z',
|
||||
}, null, 2));
|
||||
})();
|
||||
@@ -0,0 +1,139 @@
|
||||
# Refait les trois dernieres etapes de l'empaquetage Android, sur le dossier
|
||||
# deja prepare - sans le vider au prealable.
|
||||
#
|
||||
# POURQUOI : la cible Deploy de MSBuild reconstruit library\lib avant d'appeler
|
||||
# aapt, ce qui efface toute bibliotheque native ajoutee a la main. Or ce projet
|
||||
# n'a pas d'entrees de deploiement fonctionnelles (voir README), donc nos icones
|
||||
# ne peuvent y arriver autrement. On laisse Deploy faire son travail, puis on
|
||||
# remet ce qu'il a efface et on refait aapt / zipalign / signature.
|
||||
#
|
||||
# A lancer depuis la racine du depot, apres un Deploy reussi.
|
||||
#
|
||||
# -Config Release empaquette la sortie Release au lieu de Debug
|
||||
# -Keystore <chemin> -Alias signe avec CETTE cle au lieu de celle de debogage
|
||||
#
|
||||
# LE MOT DE PASSE NE PASSE PAS PAR LA LIGNE DE COMMANDE. paclient --apksign
|
||||
# l'y met en clair, ou n'importe quel processus de la machine peut le lire.
|
||||
# apksigner sait lire une variable d'environnement ; on l'appelle donc en
|
||||
# direct pour une vraie cle, et on ne garde paclient que pour la cle de
|
||||
# debogage, dont le mot de passe est public et vaut "android".
|
||||
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release')] [string]$Config = 'Debug',
|
||||
[string]$Keystore,
|
||||
[string]$Alias
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$sdk = 'C:\Users\Public\Documents\Embarcadero\Studio\23.0\CatalogRepository\AndroidSDK-2525-23.0.51511.6924'
|
||||
$bds = 'C:\Program Files (x86)\Embarcadero\Studio\23.0'
|
||||
$jdk = 'C:\Program Files\Eclipse Adoptium\jdk-11.0.15.10-hotspot'
|
||||
$pac = "$bds\bin\paclient.exe"
|
||||
$p = ".\Android64\$Config\pmserverApk"
|
||||
|
||||
# 0. LES RESSOURCES QUE LA COMPILATION GENERE, MISES EN PLACE A LA MAIN.
|
||||
#
|
||||
# Le Build les depose a plat dans Android64\<Config>\ - styles.xml,
|
||||
# strings.xml, les variantes -v21/-v31, l'icone adaptative - et c'est le Deploy
|
||||
# qui devrait les ranger dans res\. Il le fait en DEBUG et pas en RELEASE,
|
||||
# raison inconnue : les proprietes Android_* du .dproj sont communes aux deux
|
||||
# configurations, et aucune entree de deploiement ne les nomme.
|
||||
#
|
||||
# Sans elles, aapt refuse le paquet sur "@style/AppTheme" introuvable - le
|
||||
# manifeste reference un theme que plus rien ne definit.
|
||||
#
|
||||
# Le nommage porte la destination : un fichier "nom-qualificatif.xml" va dans
|
||||
# le sous-dossier "seau-qualificatif" sous le nom "nom.xml". On transcrit cette
|
||||
# regle plutot que de la deviner, pour qu'un fichier nouveau se signale au lieu
|
||||
# d'etre range au hasard.
|
||||
#
|
||||
# AVANT nos propres ressources, pour que celles-ci gardent le dernier mot -
|
||||
# notre colors.xml doit l'emporter sur celui de Delphi.
|
||||
$genRes = @{
|
||||
'strings.xml' = 'values\strings.xml'
|
||||
'styles.xml' = 'values\styles.xml'
|
||||
'styles-v21.xml' = 'values-v21\styles.xml'
|
||||
'styles-v31.xml' = 'values-v31\styles.xml'
|
||||
'colors.xml' = 'values\colors.xml'
|
||||
'colors-night-v21.xml' = 'values-night-v21\colors.xml'
|
||||
'ic_launcher.xml' = 'drawable-anydpi-v26\ic_launcher.xml'
|
||||
'ic_launcher-v33.xml' = 'drawable-anydpi-v33\ic_launcher.xml'
|
||||
'splash_image_def.xml' = 'drawable\splash_image_def.xml'
|
||||
'splash_image_def-v21.xml'= 'drawable-anydpi-v21\splash_image_def.xml'
|
||||
}
|
||||
$outDir = ".\Android64\$Config"
|
||||
foreach ($k in $genRes.Keys) {
|
||||
$src = "$outDir\$k"
|
||||
if (-not (Test-Path $src)) { continue } # Delphi ne genere pas tout partout
|
||||
$dst = Join-Path $p "res\$($genRes[$k])"
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $dst) | Out-Null
|
||||
Copy-Item $src $dst -Force
|
||||
}
|
||||
|
||||
# 1. Nos icones par-dessus celles de Delphi :
|
||||
# les proprietes Android_LauncherIcon* et Android_AdaptiveIcon* du .dproj
|
||||
# devraient suffire, mais rien ne les deploie ici.
|
||||
Copy-Item "$PSScriptRoot\android-res\*" "$p\res\" -Recurse -Force
|
||||
|
||||
# 1 ter. L'ecran de demarrage existe en plusieurs variantes de densite et de
|
||||
# theme, toutes identiques chez nous. Une seule source, recopiee : quatre
|
||||
# fichiers identiques dans le depot finiraient par diverger.
|
||||
$splash = "$p\res\drawable\splash_vector.xml"
|
||||
foreach ($v in 'drawable-anydpi-v31', 'drawable-night-anydpi-v21', 'drawable-night-anydpi-v31') {
|
||||
if (Test-Path "$p\res\$v") { Copy-Item $splash "$p\res\$v\splash_vector.xml" -Force }
|
||||
}
|
||||
if (Test-Path "$p\res\drawable-anydpi-v21") {
|
||||
Copy-Item "$p\res\drawable\splash_image_def.xml" "$p\res\drawable-anydpi-v21\splash_image_def.xml" -Force
|
||||
}
|
||||
# Le fond sombre vaut aussi en theme clair : l'application n'a qu'un theme.
|
||||
if (Test-Path "$p\res\values-night-v21") {
|
||||
Copy-Item "$PSScriptRoot\android-res\values\colors.xml" "$p\res\values-night-v21\colors.xml" -Force
|
||||
}
|
||||
|
||||
# 1 quater. AAPT N'ACCEPTE QUE DES DOSSIERS A LA RACINE DE res\.
|
||||
#
|
||||
# Le deploiement de Delphi y depose aussi une copie a plat de cinq de ses
|
||||
# ressources - ic_launcher_*, splash_* - alors que les vraies sont dans
|
||||
# res\drawable\. aapt refuse alors le paquet entier : "invalid resource
|
||||
# directory name", une fois par fichier. On les retire, et rien n'est perdu :
|
||||
# ce sont des doublons, verifies presents dans drawable.
|
||||
#
|
||||
# Ne se voyait pas en debogage, ou l'arbre traine depuis longtemps et ou ces
|
||||
# fichiers n'y avaient jamais ete deposes. C'est la premiere release qui l'a
|
||||
# montre, comme le dossier de mise en scene absent juste avant.
|
||||
Get-ChildItem "$p\res" -File -ErrorAction SilentlyContinue | Remove-Item -Force
|
||||
|
||||
# 2. aapt : reconstruit l'archive non alignee depuis library, classes, res.
|
||||
& $pac -u8 --aaptpackage="$sdk\build-tools\33.0.2\Aapt.exe,$p\library,$p\classes,$p\res,$p\assets,$p\AndroidManifest.xml,$sdk\platforms\android-33\android.jar,$p\bin\pmserverApk-unaligned.apk"
|
||||
if ($LASTEXITCODE -ne 0) { throw "aapt a echoue" }
|
||||
|
||||
# 3. zipalign refuse d'ecraser sa sortie : l'APK precedent doit disparaitre.
|
||||
Remove-Item "$p\bin\pmserverApk.apk" -Force -ErrorAction SilentlyContinue
|
||||
& $pac -u8 --zipalign="$sdk\build-tools\33.0.2\ZipAlign.exe,$p\bin\pmserverApk-unaligned.apk,$p\bin\pmserverApk.apk,4"
|
||||
if ($LASTEXITCODE -ne 0) { throw "zipalign a echoue" }
|
||||
|
||||
if ($Keystore) {
|
||||
# LA CLE DE L'UTILISATEUR. Le mot de passe arrive par PM_KS_PASS, pose par
|
||||
# l'appelant juste avant et retire juste apres : il ne figure ni dans le
|
||||
# depot, ni dans le .dproj, ni dans une ligne de commande.
|
||||
if (-not $env:PM_KS_PASS) { throw 'PM_KS_PASS absent - mot de passe du magasin' }
|
||||
& "$jdk\bin\java.exe" -jar "$sdk\build-tools\33.0.2\lib\apksigner.jar" sign `
|
||||
--ks "$Keystore" --ks-key-alias "$Alias" `
|
||||
--ks-pass env:PM_KS_PASS --key-pass env:PM_KS_PASS `
|
||||
"$p\bin\pmserverApk.apk"
|
||||
if ($LASTEXITCODE -ne 0) { throw "signature echouee" }
|
||||
# DIRE AVEC QUOI ON A SIGNE. Une release signee par megarde avec la cle de
|
||||
# debogage s'installe et fonctionne : rien ne la distingue a l'usage, et on
|
||||
# ne s'en apercoit qu'au refus de la premiere mise a jour.
|
||||
& "$jdk\bin\java.exe" -jar "$sdk\build-tools\33.0.2\lib\apksigner.jar" `
|
||||
verify --print-certs "$p\bin\pmserverApk.apk" |
|
||||
Select-String -Pattern 'Signer #1 certificate DN|SHA-256 digest' |
|
||||
ForEach-Object { $_.Line.Trim() }
|
||||
} else {
|
||||
& $pac -u8 --apksign="$jdk\bin\java.exe,$sdk\build-tools\33.0.2\lib\apksigner.jar,$p\bin\pmserverApk.apk,androiddebugkey,$env:APPDATA\Embarcadero\BDS\23.0\debug.keystore,android,android"
|
||||
if ($LASTEXITCODE -ne 0) { throw "signature echouee" }
|
||||
}
|
||||
|
||||
$apk = Get-Item "$p\bin\pmserverApk.apk"
|
||||
"APK {0:N1} Mo" -f ($apk.Length / 1MB)
|
||||
@@ -0,0 +1,150 @@
|
||||
program selftest;
|
||||
{
|
||||
Verification de la chaine crypto contre le vecteur connu, en console.
|
||||
|
||||
Remplace le bouton "Test crypto" qu'avait le banc d'essai : l'interface est
|
||||
devenue celle d'un vrai client, et un aller-retour d'APK pour verifier une
|
||||
constante est absurde. Ici la reponse arrive en quelques secondes, sur le
|
||||
poste, avec un code de sortie exploitable.
|
||||
|
||||
Ce qu'il isole, dans cet ordre - une etape fausse rend inutile de lire les
|
||||
suivantes :
|
||||
1. PBKDF2 en Pascal -> la cle attendue ?
|
||||
2. PBKDF2 natif (CNG) -> la MEME cle ? (l'encodage du mot de passe)
|
||||
3. AES-GCM -> le clair attendu ?
|
||||
|
||||
Compilation :
|
||||
dcc32 -B android-client\selftest.dpr
|
||||
}
|
||||
|
||||
{$APPTYPE CONSOLE}
|
||||
|
||||
uses
|
||||
System.SysUtils, System.Hash, System.NetEncoding, System.Diagnostics,
|
||||
PMA.Crypto in 'PMA.Crypto.pas',
|
||||
PMA.TestVector in 'PMA.TestVector.pas',
|
||||
PMA.Totp in 'PMA.Totp.pas';
|
||||
|
||||
var
|
||||
GFailures: Integer = 0;
|
||||
|
||||
procedure Check(const AName, AGot, AWant: string);
|
||||
begin
|
||||
if AGot = AWant then
|
||||
Writeln(' OK ', AName)
|
||||
else
|
||||
begin
|
||||
Writeln(' ECHEC ', AName);
|
||||
Writeln(' obtenu : ', AGot);
|
||||
Writeln(' attendu: ', AWant);
|
||||
Inc(GFailures);
|
||||
end;
|
||||
end;
|
||||
|
||||
// URI plutot que base32 nu : elle porte les 8 chiffres de la RFC, et fait
|
||||
// passer l'analyseur d'otpauth:// dans le meme mouvement.
|
||||
const
|
||||
RFC6238_URI = 'otpauth://totp/rfc?secret=' +
|
||||
'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits=8';
|
||||
|
||||
procedure CheckTotp(AUnix: Int64; const AWant: string);
|
||||
var
|
||||
LLeft, LPeriod: Integer;
|
||||
begin
|
||||
Check('T=' + AUnix.ToString, TotpAt(RFC6238_URI, AUnix, LLeft, LPeriod), AWant);
|
||||
end;
|
||||
|
||||
var
|
||||
LSalt, LKey: TBytes;
|
||||
LPlain: string;
|
||||
LSw: TStopwatch;
|
||||
begin
|
||||
try
|
||||
// Le sel vient du conteneur lui-meme : si le vecteur est regenere, le
|
||||
// test suit sans qu'on ait a recopier quoi que ce soit.
|
||||
LSalt := TNetEncoding.Base64.DecodeStringToBytes(
|
||||
'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=');
|
||||
|
||||
Writeln('Mot de passe : ',
|
||||
Length(TEncoding.UTF8.GetBytes(TESTVECTOR_PASSWORD)),
|
||||
' octets UTF-8 (attendu 25)');
|
||||
|
||||
Writeln('PBKDF2 Pascal 600k...');
|
||||
LSw := TStopwatch.StartNew;
|
||||
LKey := DeriveKeyPBKDF2(TESTVECTOR_PASSWORD, LSalt, 600000, 32);
|
||||
LSw.Stop;
|
||||
Writeln(' ', LSw.ElapsedMilliseconds, ' ms');
|
||||
Check('cle derivee', LowerCase(THash.DigestAsString(LKey)),
|
||||
TESTVECTOR_KEY_HEX);
|
||||
|
||||
Writeln('PBKDF2 natif...');
|
||||
LSw := TStopwatch.StartNew;
|
||||
LKey := DeriveKeyPBKDF2Native(TESTVECTOR_PASSWORD, LSalt, 600000, 32);
|
||||
LSw.Stop;
|
||||
if LKey = nil then
|
||||
begin
|
||||
Writeln(' indisponible : ', LastNativeError);
|
||||
Inc(GFailures);
|
||||
end
|
||||
else
|
||||
begin
|
||||
Writeln(' ', LSw.ElapsedMilliseconds, ' ms');
|
||||
// Le point sensible : le mot de passe est non-ASCII, donc une cle
|
||||
// identique prouve que le fournisseur encode bien en UTF-8.
|
||||
Check('cle native identique', LowerCase(THash.DigestAsString(LKey)),
|
||||
TESTVECTOR_KEY_HEX);
|
||||
end;
|
||||
|
||||
Writeln('Conteneur AES-GCM...');
|
||||
LPlain := DecryptContainer(TESTVECTOR_JSON, TESTVECTOR_PASSWORD);
|
||||
Check('longueur du clair',
|
||||
Length(TEncoding.UTF8.GetBytes(LPlain)).ToString,
|
||||
TESTVECTOR_PLAIN_LEN.ToString);
|
||||
Check('empreinte du clair',
|
||||
LowerCase(THashSHA2.GetHashString(LPlain, THashSHA2.TSHA2Version.SHA256)),
|
||||
TESTVECTOR_PLAIN_SHA);
|
||||
|
||||
// Un mot de passe faux DOIT echouer : sans cette verification, un
|
||||
// dechiffrement qui rendrait n'importe quoi passerait pour un succes.
|
||||
try
|
||||
DecryptContainer(TESTVECTOR_JSON, 'mauvais mot de passe');
|
||||
Writeln(' ECHEC un mot de passe faux a ete accepte');
|
||||
Inc(GFailures);
|
||||
except
|
||||
on E: EPMACrypto do Writeln(' OK mot de passe faux rejete');
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Writeln('ERREUR ', E.ClassName, ' : ', E.Message);
|
||||
Inc(GFailures);
|
||||
end;
|
||||
end;
|
||||
|
||||
// ---- TOTP, vecteurs de la RFC 6238 --------------------------------------
|
||||
// Secret "12345678901234567890" en base32, 8 chiffres, aux instants publies
|
||||
// par la RFC. C'est le seul moyen de verifier a la fois la base32, le HMAC
|
||||
// et la troncature - un code plausible peut etre faux.
|
||||
Writeln('TOTP (RFC 6238)...');
|
||||
try
|
||||
CheckTotp(59, '94287082');
|
||||
CheckTotp(1111111109, '07081804');
|
||||
CheckTotp(1111111111, '14050471');
|
||||
CheckTotp(1234567890, '89005924');
|
||||
CheckTotp(2000000000, '69279037');
|
||||
CheckTotp(20000000000, '65353130');
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Writeln(' ERREUR ', E.ClassName, ' : ', E.Message);
|
||||
Inc(GFailures);
|
||||
end;
|
||||
end;
|
||||
|
||||
Writeln;
|
||||
if GFailures = 0 then
|
||||
Writeln('Tout est bon.')
|
||||
else
|
||||
Writeln(GFailures, ' echec(s).');
|
||||
ExitCode := GFailures;
|
||||
end.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"format": "pm-encrypted-export-v1",
|
||||
"kdf": "pbkdf2-sha256",
|
||||
"kdf_iterations": 600000,
|
||||
"kdf_salt": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"iv": "yMnKy8zNzs/Q0dLT",
|
||||
"ciphertext": "31AqOJ83L2TEFOcYaKEdpqKWGY42I4NUq+jdO7pA3j8+v023ECxjSaEnB0NvbufW6dMCgkK9oFwRvoQlLVSvK6bNBwBinJ4ltlbwKqATR2HGHh5eqiqGZayNNdRIFNJZw/jw9lYy28R6uK421uaMRx+mUBs4LGX9U+F2ayFfVF/VlsaHb5I3YMhLCN2zyMxs3j7Y1s69N09Uonm0LGKQ+SiteuXTHWJzAvHFIduLlxPJhCfEKUap4PolQ5PMohJeJYt+sutg7UY6i0QclxpmF2MY2qpbh3gA2mmSntoI2LN8Q41NMqyVn6JY6kXXpEfD1aNVw6dDCqAw2FQJ9HxE+/GBirt009rdS/l59KEnIYRvgBFnQqRTJZNNncHXXh3TsnQ3opKEyYe5qCxnX6E7wGzRawD/sQn2NNWjR8wwQQ68Cs74/RR8stAeuD/VJhJnkwxXPh8eBXHWFjLgFZuan+Ja2FwkjMhkHvIqbfJpbNwnu1mJ8ABtbGqmEHYSGUvLovIwCoQWjBqewlCFvRg0l4h63mUxuLaXx0SrIA3xhS7HqnYVTBp5/EVAu715jKmeDHviiZw6iws5ujd/u0IDUtJY1uANaF1xTBwcfkCnuonBlI8eMbIlcndrLSUN2slm6KxeUjZ3RSpiE9Izh5ix4kann8lygCAZVGkjswB83LmB1dFNH3oqG3+4qUgv7qR0TRZUSMoopCaSxhsVRd6/uBWu+82Nkbij1jMIzaB1+vTxBEHi5DGBNDfV/0evTgoIkC9wJtlEbz/OVCwC",
|
||||
"created_at": "2026-08-14T12:00:00.000Z"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Vecteur de test — conteneur `pm-encrypted-export-v1`
|
||||
|
||||
`testvector.json` est produit par `make-testvector.js` avec **WebCrypto**, la
|
||||
primitive exacte du navigateur. Sel et IV sont figés, donc le fichier est
|
||||
reproductible : une différence signale un vrai changement de comportement, pas
|
||||
du bruit.
|
||||
|
||||
> Le fichier de test réutilise un couple sel/IV fixe. C'est acceptable pour un
|
||||
> vecteur, **jamais** dans l'application : réutiliser un IV avec la même clé
|
||||
> détruit la sécurité de GCM.
|
||||
|
||||
## Paramètres
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Mot de passe | `sync-test-password-éàü` |
|
||||
| KDF | PBKDF2-SHA256, 600 000 itérations, sel de 32 octets |
|
||||
| Chiffrement | AES-256-GCM, IV de 12 octets, tag de 16 octets **en fin de `ciphertext`** |
|
||||
|
||||
Le mot de passe est **volontairement non-ASCII**. C'est le seul cas qui
|
||||
distingue une implémentation correcte d'une implémentation approximative : Java
|
||||
`PBEKeySpec` prend un `char[]` et laisse le fournisseur choisir l'encodage, ce
|
||||
qui donne une clé différente pour `é`, `à`, `ü`. Un vecteur en pur ASCII aurait
|
||||
laissé passer le bug.
|
||||
|
||||
## Valeurs attendues
|
||||
|
||||
Vérifier **dans cet ordre** — chaque étage isole une cause :
|
||||
|
||||
**1. Clé dérivée** (`DeriveKeyPBKDF2`, avant tout déchiffrement)
|
||||
|
||||
```
|
||||
6bd7683ea78e163062f02681b03f2906fcf5a3a25025005af4e71982e6e2b286
|
||||
```
|
||||
|
||||
Si elle diffère, le problème est dans PBKDF2 ou dans l'encodage du mot de passe
|
||||
— surtout pas dans AES. Contrôler d'abord que le mot de passe est bien encodé
|
||||
en UTF-8 (`sync-test-password-éàü` fait **25 octets**, pas 22 caractères).
|
||||
|
||||
**2. Texte clair** (`AesGcmDecrypt` puis `DecryptContainer`)
|
||||
|
||||
```
|
||||
longueur UTF-8 : 572 octets
|
||||
SHA-256 : ddaee44f8ac8374f1ece6d9ee2abd104d59cd283556f1c4c07afa95ae1dbd2a7
|
||||
début : {"version":1,"exported_at":"2026-08-14T12:00:00.000Z","username":"test
|
||||
```
|
||||
|
||||
Si la clé est bonne et que le déchiffrement échoue, chercher du côté de la
|
||||
longueur du tag (128 **bits**) ou d'un tag qui aurait été séparé du corps —
|
||||
WebCrypto le concatène, `javax.crypto` l'attend concaténé, il n'y a rien à
|
||||
découper.
|
||||
|
||||
## Contenu
|
||||
|
||||
2 entrées, dont **une en corbeille** (`deleted: 1` avec son `deleted_at`), et
|
||||
un dossier avec sa couleur. De quoi vérifier que le client mobile affiche la
|
||||
corbeille séparément au lieu de la mélanger à la liste.
|
||||
|
||||
## Régénérer
|
||||
|
||||
```bash
|
||||
node make-testvector.js > testvector.json
|
||||
```
|
||||
|
||||
En PowerShell, la redirection ajoute un **BOM** qui rend le JSON illisible.
|
||||
Utiliser `cmd`, ou écrire le fichier sans BOM.
|
||||
@@ -0,0 +1,51 @@
|
||||
// Ouvre un conteneur pm-encrypted-export-v1 avec Node, pour distinguer un
|
||||
// mauvais mot de passe d'un defaut du client Delphi.
|
||||
//
|
||||
// node try-open.js <fichier> <mot-de-passe>
|
||||
//
|
||||
// Utilise les memes primitives que le navigateur. Si Node y arrive et pas
|
||||
// Delphi, le probleme est dans le client ; si les deux echouent, c'est le
|
||||
// mot de passe.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const c = require('node:crypto');
|
||||
|
||||
const [, , file, pwd] = process.argv;
|
||||
if (!file || pwd === undefined) {
|
||||
console.error('usage: node try-open.js <fichier> <mot-de-passe>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(file, 'utf8').replace(/^/, '');
|
||||
let v;
|
||||
try {
|
||||
v = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error('Ce fichier n\'est pas du JSON : ' + e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('format : ' + v.format);
|
||||
console.log('kdf : ' + v.kdf + ' / ' + v.kdf_iterations + ' iterations');
|
||||
console.log('mot de passe : ' + pwd.length + ' caracteres, ' +
|
||||
Buffer.byteLength(pwd, 'utf8') + ' octets UTF-8');
|
||||
|
||||
const salt = Buffer.from(v.kdf_salt, 'base64');
|
||||
const iv = Buffer.from(v.iv, 'base64');
|
||||
const ct = Buffer.from(v.ciphertext, 'base64');
|
||||
const key = c.pbkdf2Sync(Buffer.from(pwd, 'utf8'), salt, v.kdf_iterations || 600000, 32, 'sha256');
|
||||
console.log('cle derivee : ' + key.toString('hex'));
|
||||
|
||||
try {
|
||||
const d = c.createDecipheriv('aes-256-gcm', key, iv);
|
||||
d.setAuthTag(ct.subarray(ct.length - 16));
|
||||
const pt = Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString('utf8');
|
||||
const obj = JSON.parse(pt);
|
||||
const live = (obj.entries || []).filter(e => !e.deleted).length;
|
||||
const trash = (obj.entries || []).filter(e => e.deleted).length;
|
||||
console.log('==> OUVERT : ' + live + ' entrees, ' + trash + ' en corbeille');
|
||||
console.log(' exporte le ' + (obj.exported_at || obj.snapshot_at || '?'));
|
||||
} catch (e) {
|
||||
console.log('==> ECHEC : ' + e.message);
|
||||
console.log(' Le tag GCM ne verifie pas : mot de passe faux, ou fichier altere.');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
PMAPP RCDATA "app.html"
|
||||
+224
-61
@@ -236,6 +236,22 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
radial-gradient(ellipse at bottom right, rgba(167,139,250,0.08), transparent 50%),
|
||||
var(--bg);
|
||||
}
|
||||
#licenceScreen { z-index: 300; }
|
||||
/* L'empreinte se recopie a la main dans un courriel : chasse fixe, selectionnable,
|
||||
et assez grande pour qu'un 8 ne passe pas pour un B. */
|
||||
.licence-machine {
|
||||
margin-top: 18px; padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
.licence-machine span { display: block; color: var(--text-dim); font-size: 11px; margin-bottom: 6px; }
|
||||
.licence-machine code {
|
||||
display: inline-block; padding: 6px 10px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 14px; letter-spacing: 1px; color: var(--text);
|
||||
user-select: all;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%; max-width: 400px;
|
||||
padding: 32px;
|
||||
@@ -291,6 +307,14 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
text-align: center;
|
||||
}
|
||||
.auth-hint:empty { display: none; }
|
||||
/* Le cyan de .auth-hint dit "voici l'etat des choses" (session expiree,
|
||||
coffre verrouille). Un refus n'est pas un etat, c'est un echec : il se
|
||||
distingue a la couleur avant d'etre lu. */
|
||||
.auth-hint.is-error {
|
||||
background: var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Tag chip input */
|
||||
.chip-input {
|
||||
@@ -368,27 +392,6 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.chip button:hover { opacity: 1; background: rgba(0,0,0,0.1); }
|
||||
.chip button svg { width: 10px; height: 10px; }
|
||||
|
||||
/* Tag suggestions dropdown */
|
||||
.chip-suggestions {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
.chip-suggestion {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip-suggestion:hover, .chip-suggestion.is-active { background: var(--bg-elev-2); color: var(--text); }
|
||||
.chip-suggestion-empty { padding: 6px 10px; color: var(--text-faint); font-size: 12px; }
|
||||
|
||||
/* Settings rows */
|
||||
.setting-row {
|
||||
@@ -799,8 +802,28 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.dropdown-item:hover { background: var(--bg-elev-2); }
|
||||
.dropdown-item svg { color: var(--text-dim); }
|
||||
|
||||
.content { flex: 1; padding: 24px 32px; overflow-y: auto; }
|
||||
.content-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
|
||||
/* No padding-top: the sticky .content-header owns it, so its box starts flush
|
||||
with the scrollport and nothing can scroll through above it. */
|
||||
.content { flex: 1; padding: 0 32px 24px; overflow-y: auto; }
|
||||
/* Sticky title row — carries .content's top padding so its opaque box reaches
|
||||
the scrollport edge. Everything else that sticks (batch bar, pagination,
|
||||
table header) offsets by --hdr-h. */
|
||||
.content-header {
|
||||
display: flex; align-items: baseline; gap: 12px;
|
||||
/* padding, NOT margin: offsetHeight (which feeds --hdr-h, where the sticky
|
||||
pagination parks) ignores margins, so the 20px gap vanished as soon as
|
||||
the header stuck. As padding it is measured AND painted — the gap stays
|
||||
constant and stays opaque instead of letting rows scroll through it. */
|
||||
padding-bottom: 20px;
|
||||
/* FIXED, not min-height: 24 top padding + 38 bar + 20 bottom padding. A
|
||||
minimum still lets a child grow the row — a couple of stray pixels from
|
||||
the selection bar were enough to push the whole grid down in table and
|
||||
list view. With a fixed height nothing inside can move what follows. */
|
||||
height: 82px;
|
||||
position: sticky; top: 0; z-index: 12;
|
||||
background: var(--bg);
|
||||
padding-top: 24px;
|
||||
}
|
||||
.content-header h2 { font-size: 22px; font-weight: 600; margin: 0; }
|
||||
.content-meta { color: var(--text-faint); font-size: 12px; }
|
||||
|
||||
@@ -868,7 +891,9 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.entry-grid.is-list .entry-note-row { display: contents; }
|
||||
.entry-grid.is-list .entry-note-placeholder {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
/* Same track as .entry-pw so note rows line their copy button up with
|
||||
the password rows'. Full text on hover via the title attr. */
|
||||
flex: 0 0 110px;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
@@ -909,8 +934,9 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.entry-grid.is-list .entry-pw {
|
||||
order: 3;
|
||||
font-size: 11px;
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
/* Full shorthand, not width: the base .entry-pw is `flex: 1` (basis 0),
|
||||
which ignores width and let the pill stretch across the whole row. */
|
||||
flex: 0 0 110px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
@@ -925,7 +951,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
order: 5;
|
||||
/* Absorb the free space freed up by the now-fixed title, pushing tags/
|
||||
folder + the order-6 actions to the far right while pw stays put.
|
||||
(When a row has no meta, the order-6 rule below carries the auto.) */
|
||||
Sole auto margin in the row — see the note below the order-6 block. */
|
||||
margin: 0 0 0 auto;
|
||||
flex-shrink: 0;
|
||||
max-width: 220px;
|
||||
@@ -947,12 +973,10 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
order: 6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* First trailing action floats right too, so rows WITHOUT an entry-meta
|
||||
still push their actions to the far edge (margin-left:auto on the first
|
||||
flex item consumes the free space; later ones are no-ops). */
|
||||
.entry-grid.is-list .entry-fav,
|
||||
.entry-grid.is-list .entry-kebab-wrap,
|
||||
.entry-grid.is-list .entry-head .icon-btn { margin-left: auto; }
|
||||
/* NOTE: no margin-left:auto here. .entry-meta is always in the DOM (empty
|
||||
when the entry has no chips) and already carries the auto, so it absorbs
|
||||
all the free space. A second auto margin would SPLIT the free space
|
||||
between the two, drifting the chips left/right depending on row width. */
|
||||
/* Selection checkbox overlay — nested INSIDE the avatar (.entry-avatar
|
||||
is position:relative) so it covers the avatar 1:1, no overlap with the
|
||||
avatar's footprint. Hidden by default; appears on card hover OR when
|
||||
@@ -997,14 +1021,20 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Batch action bar (appears when selection is non-empty) */
|
||||
/* Batch action bar (appears when selection is non-empty). It lives INSIDE
|
||||
.content-header, to the right of the title: as a block of its own it pushed
|
||||
the whole list down on every selection. The header reserves its height (see
|
||||
.content-header min-height) so appearing/disappearing shifts nothing. */
|
||||
.batch-bar {
|
||||
position: sticky;
|
||||
top: -1px;
|
||||
z-index: 10;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
/* EXACTLY the height .content-header reserves. Left to grow, the bar was a
|
||||
few pixels taller than the reservation and pushed the whole grid down as
|
||||
soon as anything was selected — the shift the sticky header was meant to
|
||||
prevent in the first place. */
|
||||
height: 38px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 10px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
@@ -1358,6 +1388,38 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* Score dial + its per-category breakdown, stacked. */
|
||||
.health-score-col {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.health-score-breakdown {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Each row keeps its own box: a display:contents row would have none, and the
|
||||
title tooltip explaining the cap would never appear. */
|
||||
.health-bd-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 0 10px;
|
||||
cursor: help;
|
||||
}
|
||||
.health-bd-n { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.health-bd-pts {
|
||||
text-align: right;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* Nothing wrong in this category — dim it so the eye goes to what needs work. */
|
||||
.health-bd-row.is-clear .health-bd-lbl,
|
||||
.health-bd-row.is-clear .health-bd-n,
|
||||
.health-bd-row.is-clear .health-bd-pts { opacity: 0.45; }
|
||||
|
||||
.health-intro { flex: 1; }
|
||||
.health-intro h3 { margin: 0 0 6px; font-size: 16px; }
|
||||
.health-intro p {
|
||||
@@ -1599,15 +1661,54 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.so-custom-row {
|
||||
/* label | value (eye inside) | row menu */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr auto auto auto auto;
|
||||
grid-template-columns: 1fr 1.4fr auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
/* The reveal eye sits inside the field, like the TOTP one. */
|
||||
.so-custom-value-wrap { position: relative; display: flex; min-width: 0; }
|
||||
.so-custom-value-wrap > .so-input,
|
||||
.so-custom-value-wrap > .so-combo { width: 100%; }
|
||||
.so-custom-value-wrap > .so-input { padding-right: 28px; }
|
||||
.so-custom-value-eye {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
/* Multiline: pin it to the top corner, clear of the resize grip — and inside
|
||||
the scrollbar, which appears as soon as the content passes the auto-grow cap.
|
||||
scrollbar-gutter keeps that space reserved so the eye doesn't shift when the
|
||||
scrollbar shows up. */
|
||||
.so-custom-value-wrap.is-multiline .so-custom-value-eye {
|
||||
top: 3px;
|
||||
right: 16px;
|
||||
transform: none;
|
||||
}
|
||||
.so-custom-value-wrap.is-multiline > .so-input {
|
||||
padding-right: 44px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
/* The row menu is absolutely positioned inside its wrap; without this the
|
||||
slideover clips it or it lands under the next row. */
|
||||
.so-custom-row .entry-kebab-wrap { position: relative; }
|
||||
.so-custom-row .entry-kebab-menu { right: 0; }
|
||||
.so-custom-row .so-input {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
/* Multiline custom field (Notes). The row stays a grid, so the textarea just
|
||||
grows in its own column and the buttons keep their places. */
|
||||
.so-custom-multiline {
|
||||
min-height: 56px;
|
||||
resize: vertical;
|
||||
align-self: stretch;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
overflow-y: auto; /* JS caps the auto-grow; past that it scrolls */
|
||||
}
|
||||
/* Editable combobox for option-backed custom fields (card brand, expiry
|
||||
year…). Input fills the cell; the arrow drops a menu of ALL options. */
|
||||
.so-combo { position: relative; display: flex; align-items: center; }
|
||||
@@ -1641,22 +1742,10 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.so-combo-item:hover { background: var(--accent); color: #fff; }
|
||||
.so-custom-secret-toggle {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
width: 28px; height: 28px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.so-custom-secret-toggle.is-on {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* The eye carries the secret state itself — a second toggle beside it read as
|
||||
a duplicate of the reveal. Flipping the flag lives in the row menu. */
|
||||
/* No marker for "stored as secret": the eye only exists on a secret field —
|
||||
Make public removes it — so its mere presence already says so. */
|
||||
.so-custom-add {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -1880,6 +1969,11 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* A textarea has no type="password"; -webkit-text-security is the Chromium
|
||||
equivalent (WebView2 is Chromium, so it's always available here). */
|
||||
.so-note-body.is-masked,
|
||||
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
|
||||
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
/* ---- + New dropdown ---------------------------------- */
|
||||
.new-entry-wrap {
|
||||
@@ -1933,6 +2027,11 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
}
|
||||
|
||||
/* Table column picker dropdown — only rendered in table view. */
|
||||
.cols-wrap {
|
||||
position: relative; /* anchor for the absolutely-positioned menu */
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cols-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
@@ -2281,6 +2380,29 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
animation: busy-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes busy-spin { to { transform: rotate(360deg); } }
|
||||
/* Breach-scan progress. Non-blocking: no backdrop, no pointer capture.
|
||||
Lives INSIDE .toast-container, so it stacks with the toasts in the same
|
||||
corner instead of floating over the cards (pinned to the left edge it also
|
||||
covered the sidebar navigation). column-reverse puts it above them. */
|
||||
.hibp-progress {
|
||||
align-self: flex-end;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
font-size: 12px; color: var(--text-dim);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hibp-progress-spinner {
|
||||
width: 14px; height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: busy-spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.busy-text { font-size: 13px; color: var(--text-dim); text-align: center; }
|
||||
.slideover-field-label { font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||
.slideover-field-value {
|
||||
@@ -2549,6 +2671,19 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
min-width: 240px;
|
||||
animation: toastIn var(--t-base);
|
||||
}
|
||||
/* Errors / warnings linger — let the user clear them instead of waiting. */
|
||||
.toast.is-dismissable { cursor: pointer; }
|
||||
/* Settings entry point when a hotkey is dead: a dot the user meets in the
|
||||
normal UI, not only inside the panel they may never open. */
|
||||
#settingsBtn.has-alert { position: relative; }
|
||||
#settingsBtn.has-alert::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 6px; right: 8px;
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning, #e0a800);
|
||||
}
|
||||
.toast.is-success { border-left-color: var(--success); }
|
||||
.toast.is-error { border-left-color: var(--danger); }
|
||||
.toast.is-warning { border-left-color: var(--warning); }
|
||||
@@ -2575,7 +2710,8 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
.sidebar-section-header { display: none; }
|
||||
.nav-item { justify-content: center; padding: 8px; }
|
||||
.topbar { padding: 12px 16px; }
|
||||
.content { padding: 16px; }
|
||||
.content { padding: 0 16px 16px; }
|
||||
.content-header { padding-top: 16px; height: 74px; } /* 16 + 38 + 20 */
|
||||
.slideover { width: 100%; }
|
||||
/* On a narrow viewport the slideover fills the screen — no point
|
||||
pushing the shell, the table isn't visible anyway. */
|
||||
@@ -2614,6 +2750,16 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Combo refused by Windows (another app holds it) — the hotkey is dead, so the
|
||||
row has to say so long after the toast is gone. ⚠ via ::after so no markup
|
||||
change is needed when the state flips. */
|
||||
.hotkey-capture-btn.is-conflict {
|
||||
border-color: var(--warning, #e0a800);
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
.hotkey-capture-btn.is-conflict::after {
|
||||
content: " ⚠";
|
||||
}
|
||||
.hotkey-capture-btn.is-capturing {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg, #fff);
|
||||
@@ -2671,8 +2817,10 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
user-select: none;
|
||||
position: sticky; top: 0;
|
||||
z-index: 1;
|
||||
position: sticky; top: calc(var(--hdr-h, 0px) + var(--pag-h, 0px));
|
||||
/* Above .entry-check (2), below .pagination (4) — the row checkboxes were
|
||||
painted over this header for the same reason as in card view. */
|
||||
z-index: 3;
|
||||
}
|
||||
.entry-table th.is-sortable { cursor: pointer; }
|
||||
.entry-table th.is-sortable:hover { color: var(--text-dim); }
|
||||
@@ -2957,11 +3105,26 @@ input[type="password"]::-ms-clear {
|
||||
}
|
||||
.entry-card.is-flash { animation: entryFlash 2.5s ease-out; }
|
||||
.entry-row.is-flash { animation: entryFlash 2.5s ease-out; }
|
||||
/* Le meme battement pour un bloc de reglage vers lequel on vient d'etre
|
||||
conduit : "Sync now" sans configuration ouvre ce panneau et descend ici, et
|
||||
l'animation dit lequel des blocs on est venu chercher. Reutilisee plutot que
|
||||
redefinie - deux pulsations differentes pour le meme sens se remarqueraient. */
|
||||
.slideover-field.is-flash { animation: entryFlash 2.5s ease-out; }
|
||||
|
||||
/* Pagination footer (list + table views). */
|
||||
/* Pagination bar (all view modes). Sticky so the page buttons stay reachable
|
||||
while scrolling a long page; z-index beats the sticky table header, which
|
||||
is pushed down by --pag-h (measured in renderEntries). */
|
||||
.pagination {
|
||||
position: sticky;
|
||||
top: var(--hdr-h, 0px);
|
||||
/* Sticky layers, bottom to top: .entry-check 2 · table header 3 ·
|
||||
pagination 4 · .content-header 12. At EQUAL z-index the later element in
|
||||
the DOM wins, and the cards come after this bar — which is why the
|
||||
checkboxes of the rows scrolling underneath used to cover it. */
|
||||
z-index: 4;
|
||||
background: var(--bg);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 14px 12px 4px;
|
||||
padding: 14px 12px 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -206,8 +206,10 @@ begin
|
||||
LObj.AddPair('csrfToken', ACSRFToken);
|
||||
// kdfIterations is the iteration count the client must use when deriving
|
||||
// the AES-GCM key for THIS session — matches the count under which the
|
||||
// existing entries are encrypted. If the server signals migration, the
|
||||
// client should re-encrypt with the new target and call /migrate-kdf.
|
||||
// existing entries are encrypted. NOTE: the kdfMigration signal below is
|
||||
// now VESTIGIAL — /migrate-kdf was disabled (§6c-1) and the client ignores
|
||||
// it. Legacy accounts upgrade via /change-master-password instead. Kept to
|
||||
// avoid churning the login hot path; harmless (endpoint returns 410).
|
||||
LObj.AddPair('kdfIterations', TJSONNumber.Create(AKdfIterations));
|
||||
if ANeedsMigration then
|
||||
begin
|
||||
@@ -643,26 +645,18 @@ begin
|
||||
end;
|
||||
|
||||
// ===== /migrate-kdf ==========================================================
|
||||
// Atomic transition from an old PBKDF2 iteration count to the current target.
|
||||
// Client side: derive both old and new AES keys, decrypt each entry with old,
|
||||
// re-encrypt with new, then POST the new ciphertext blob to this endpoint
|
||||
// along with the master password (so we can recompute the new server hash).
|
||||
// Server side: verify the master pw with the old hash, then in a single
|
||||
// transaction: update users.password_hash to the new PBKDF2 output, set
|
||||
// kdf_iterations to TARGET, and replace each entry's encrypted_password/iv.
|
||||
// All-or-nothing: if anything fails, the user stays on the old config.
|
||||
// DISABLED 2026-07-16 (§6c-1 / §6c-4). This path re-derived the AES key
|
||||
// (100k -> 600k) but re-encrypted ONLY encrypted_password — totp_secret,
|
||||
// custom_fields, the *_enc metadata + attachments stayed under the old key =
|
||||
// data loss — and it would rewrite a -v2 account's hash_algo, permanently
|
||||
// locking it out. The client no longer calls it; any stray call is rejected so
|
||||
// it can neither corrupt nor brick a vault. Legacy <600k accounts upgrade
|
||||
// safely via /change-master-password (re-encrypts everything + moves to
|
||||
// Argon2id).
|
||||
procedure HandleMigrateKdf(ARequest: TIdHTTPRequestInfo;
|
||||
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||
var
|
||||
LUserId, LOldIters, I: Integer;
|
||||
LBody, LEntry: TJSONObject;
|
||||
LEntries: TJSONArray;
|
||||
LUser, LPwd, LOldVerifier, LNewVerifier, LSalt, LStoredHash, LAlgo, LIP,
|
||||
LComputed, LNewHash: string;
|
||||
LQ: TFDQuery;
|
||||
LValid: Boolean;
|
||||
LEntryId: Integer;
|
||||
LEncPwd, LIv: string;
|
||||
LUserId: Integer;
|
||||
begin
|
||||
try
|
||||
LUserId := Authenticate(ARequest, AResponse);
|
||||
@@ -670,166 +664,8 @@ begin
|
||||
except
|
||||
on ESessionRejected do Exit;
|
||||
end;
|
||||
|
||||
LIP := GetClientIP(ARequest);
|
||||
|
||||
LBody := TJSONHelper.ReadBody(ARequest);
|
||||
try
|
||||
LPwd := LBody.GetValue<string>('masterPassword', '');
|
||||
// ZK path: client provides PBKDF2 hex at the OLD iter count (oldVerifier,
|
||||
// for current-hash verification) AND at the new TARGET iter count
|
||||
// (newVerifier, for the post-migration stored hash).
|
||||
LOldVerifier := LBody.GetValue<string>('oldVerifier', '');
|
||||
LNewVerifier := LBody.GetValue<string>('newVerifier', '');
|
||||
LEntries := LBody.GetValue<TJSONArray>('entries');
|
||||
if LEntries = nil then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 400, 'Missing entries array');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
// Step 1: load current user state.
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text :=
|
||||
'SELECT username, password_hash, salt, hash_algo, kdf_iterations ' +
|
||||
'FROM users WHERE id = :uid';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.Open;
|
||||
if LQ.IsEmpty then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 401, 'User not found');
|
||||
Exit;
|
||||
end;
|
||||
LUser := LQ.FieldByName('username').AsString;
|
||||
LStoredHash := LQ.FieldByName('password_hash').AsString;
|
||||
LSalt := LQ.FieldByName('salt').AsString;
|
||||
LAlgo := LQ.FieldByName('hash_algo').AsString;
|
||||
LOldIters := LQ.FieldByName('kdf_iterations').AsInteger;
|
||||
if LAlgo = '' then LAlgo := 'pbkdf2';
|
||||
if LOldIters <= 0 then LOldIters := PBKDF2_ITERATIONS;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
|
||||
// Idempotency: nothing to do if BOTH iter count is at target AND
|
||||
// hash format is current. Previously we short-circuited on iter
|
||||
// count alone, which would have skipped the hash-format upgrade for
|
||||
// users who migrated KDF before this commit landed.
|
||||
if (LOldIters >= PBKDF2_ITERATIONS_TARGET) and
|
||||
SameText(LAlgo, HASH_ALGO_CURRENT) then
|
||||
begin
|
||||
TJSONHelper.SendOK(AResponse, 'Already at target');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// Step 2: verify the master pw against the CURRENT (old) hash. Prefer
|
||||
// the ZK verifier path; fall back to plaintext for legacy clients.
|
||||
LValid := False;
|
||||
if LOldVerifier <> '' then
|
||||
LValid := CheckVerifier(LOldVerifier, LStoredHash, LAlgo)
|
||||
else if SameText(LAlgo, HASH_ALGO_LEGACY) then
|
||||
begin
|
||||
LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LOldIters);
|
||||
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
||||
end
|
||||
else if SameText(LAlgo, HASH_ALGO_CURRENT) then
|
||||
begin
|
||||
LComputed := ComputeAuthHashCurrent(LPwd, LSalt, LOldIters);
|
||||
LValid := ConstantTimeEquals(LComputed, LStoredHash);
|
||||
end;
|
||||
if not LValid then
|
||||
begin
|
||||
RecordFailedAccountAttempt(LUser, LIP);
|
||||
LogAudit(LUserId, 'failed_migrate_kdf', LIP);
|
||||
TJSONHelper.SendError(AResponse, 401, 'Invalid password');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// Step 3: compute the new password hash under the current scheme
|
||||
// (SHA-256 wrap) at the target iter count. ZK path takes the
|
||||
// newVerifier (PBKDF2 at the target iters, computed client-side) and
|
||||
// just wraps it; plaintext path runs PBKDF2 server-side.
|
||||
if LNewVerifier <> '' then
|
||||
begin
|
||||
if not IsValidVerifier(LNewVerifier) then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 400, 'Malformed newVerifier');
|
||||
Exit;
|
||||
end;
|
||||
LNewHash := VerifierToStoredHash(LNewVerifier, HASH_ALGO_CURRENT);
|
||||
end
|
||||
else
|
||||
LNewHash := ComputeAuthHashCurrent(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET);
|
||||
|
||||
// Step 4: atomic transaction — update user hash AND every entry's
|
||||
// ciphertext together. Any failure rolls back, leaving the user on
|
||||
// the legacy config (safe to retry next login).
|
||||
DB.Connection.StartTransaction;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
// Update hash, iter count, AND hash_algo all in one row update.
|
||||
// hash_algo := HASH_ALGO_CURRENT is what completes the migration
|
||||
// away from the "stored hash IS the AES key" architectural flaw.
|
||||
LQ.SQL.Text :=
|
||||
'UPDATE users SET password_hash = :h, kdf_iterations = :it, ' +
|
||||
' hash_algo = :algo ' +
|
||||
'WHERE id = :uid';
|
||||
LQ.ParamByName('h').AsString := LNewHash;
|
||||
LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET;
|
||||
LQ.ParamByName('algo').AsString := HASH_ALGO_CURRENT;
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ExecSQL;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text :=
|
||||
'UPDATE vault_entries ' +
|
||||
'SET encrypted_password = :ep, iv = :iv, updated_at = CURRENT_TIMESTAMP ' +
|
||||
'WHERE id = :id AND user_id = :uid';
|
||||
|
||||
for I := 0 to LEntries.Count - 1 do
|
||||
begin
|
||||
LEntry := LEntries.Items[I] as TJSONObject;
|
||||
LEntryId := LEntry.GetValue<Integer>('id', 0);
|
||||
LEncPwd := LEntry.GetValue<string>('encrypted_password', '');
|
||||
LIv := LEntry.GetValue<string>('iv', '');
|
||||
if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
|
||||
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
|
||||
|
||||
LQ.ParamByName('id').AsInteger := LEntryId;
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ParamByName('ep').AsString := LEncPwd;
|
||||
LQ.ParamByName('iv').AsString := LIv;
|
||||
LQ.ExecSQL;
|
||||
end;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
|
||||
DB.Connection.Commit;
|
||||
except
|
||||
DB.Connection.Rollback;
|
||||
raise;
|
||||
end;
|
||||
finally
|
||||
DB.Unlock;
|
||||
end;
|
||||
finally
|
||||
LBody.Free;
|
||||
end;
|
||||
|
||||
LogAudit(LUserId, Format('migrate_kdf %d->%d', [LOldIters, PBKDF2_ITERATIONS_TARGET]), LIP);
|
||||
TJSONHelper.SendOK(AResponse, 'Migration complete');
|
||||
TJSONHelper.SendError(AResponse, 410,
|
||||
'KDF migration disabled — rotate the master password instead');
|
||||
end;
|
||||
|
||||
// ===== POST /change-master-password ==========================================
|
||||
@@ -852,9 +688,9 @@ end;
|
||||
// working past the pw change.
|
||||
// - Writes an audit_log entry.
|
||||
//
|
||||
// The /migrate-kdf endpoint exists for the same "re-encrypt all entries"
|
||||
// pattern when the master pw stays the same; this endpoint differs by
|
||||
// rotating the salt + pw too.
|
||||
// This is now the ONLY correct "re-encrypt all entries under a new key" path
|
||||
// (the old /migrate-kdf was disabled in §6c-1 for re-encrypting just 1 blob
|
||||
// of 8). Legacy <600k accounts reach 600k + Argon2id through here.
|
||||
procedure HandleChangeMasterPassword(ARequest: TIdHTTPRequestInfo;
|
||||
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||
var
|
||||
|
||||
@@ -164,6 +164,10 @@ begin
|
||||
LObj.AddPair('icon_b64', TJSONNull.Create)
|
||||
else
|
||||
LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString);
|
||||
// Icon provenance so the client knows which icons the automatic
|
||||
// fetch/clear may touch (0) vs a user upload to preserve (1).
|
||||
LObj.AddPair('icon_custom',
|
||||
TJSONBool.Create(LQ.FieldByName('icon_custom').AsInteger = 1));
|
||||
// Entry kind. Legacy / unset → 'login'.
|
||||
var LKindVal := LQ.FieldByName('kind').AsString;
|
||||
if LKindVal = '' then LKindVal := 'login';
|
||||
@@ -187,6 +191,12 @@ begin
|
||||
LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString);
|
||||
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
|
||||
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
|
||||
// Echoed so a backup can carry a trashed entry WITH its original
|
||||
// deletion date: restoring it must not restart the auto-purge clock.
|
||||
if LQ.FieldByName('deleted_at').IsNull then
|
||||
LObj.AddPair('deleted_at', TJSONNull.Create)
|
||||
else
|
||||
LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at')));
|
||||
if LQ.FieldByName('accessed_at').IsNull then
|
||||
LObj.AddPair('accessed_at', TJSONNull.Create)
|
||||
else
|
||||
@@ -327,8 +337,9 @@ var
|
||||
LBody, LObj: TJSONObject;
|
||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
|
||||
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid,
|
||||
LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt,
|
||||
LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt, LDeletedAt,
|
||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||
LIconCustom, LFavorite, LPinned, LDeleted: Boolean;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
try
|
||||
@@ -367,6 +378,11 @@ begin
|
||||
LCf := LBody.GetValue<string>('custom_fields', '');
|
||||
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
|
||||
LIcon := LBody.GetValue<string>('icon_b64', '');
|
||||
// Icon provenance (0 = fetched cache, 1 = user upload). Absent → 0.
|
||||
LIconCustom := LBody.GetValue<Boolean>('icon_custom', False);
|
||||
// Restore flags (import/backup carry them; sync omits them → 0).
|
||||
LFavorite := LBody.GetValue<Boolean>('favorite', False);
|
||||
LPinned := LBody.GetValue<Boolean>('pinned', False);
|
||||
LTemplateEnc := LBody.GetValue<string>('template_enc', '');
|
||||
LTemplateIv := LBody.GetValue<string>('template_iv', '');
|
||||
// Caller may bring its own UUID (sync restore / import preserving
|
||||
@@ -379,6 +395,13 @@ begin
|
||||
// (soSave, duplicate) don't send them, so nothing changes there.
|
||||
LCreatedAt := Trim(LBody.GetValue<string>('created_at', ''));
|
||||
LUpdatedAt := Trim(LBody.GetValue<string>('updated_at', ''));
|
||||
// A backup carries trashed entries too, so a restore rebuilds the trash
|
||||
// instead of silently dropping it. deleted_at is kept as-is so the
|
||||
// auto-purge clock resumes where it was rather than restarting.
|
||||
LDeleted := LBody.GetValue<Boolean>('deleted', False);
|
||||
LDeletedAt := Trim(LBody.GetValue<string>('deleted_at', ''));
|
||||
if LDeleted and (LDeletedAt = '') then LDeletedAt := NowUTCStr;
|
||||
if not LDeleted then LDeletedAt := '';
|
||||
finally
|
||||
LBody.Free;
|
||||
end;
|
||||
@@ -408,10 +431,12 @@ begin
|
||||
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
||||
' encrypted_password, iv, encryption_method, ' +
|
||||
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
||||
' icon_b64, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at) ' +
|
||||
' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at,' +
|
||||
' deleted, deleted_at) ' +
|
||||
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
|
||||
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
|
||||
' :cf, :cfiv, :ic, :tplenc, :tpliv, :uuid, :c, :c2, :c)';
|
||||
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2, :c,' +
|
||||
' :del, :delat)';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
// site/username/title/tags cleartext columns dropped — only *_enc written.
|
||||
BindNullable(LQ, 'uenc', LUserEnc);
|
||||
@@ -448,6 +473,12 @@ begin
|
||||
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
|
||||
LQ.ParamByName('ic').DataType := ftMemo;
|
||||
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
|
||||
if (LIcon <> '') and LIconCustom then LQ.ParamByName('iconcust').AsInteger := 1
|
||||
else LQ.ParamByName('iconcust').AsInteger := 0;
|
||||
if LFavorite then LQ.ParamByName('fav').AsInteger := 1
|
||||
else LQ.ParamByName('fav').AsInteger := 0;
|
||||
if LPinned then LQ.ParamByName('pin').AsInteger := 1
|
||||
else LQ.ParamByName('pin').AsInteger := 0;
|
||||
BindNullable(LQ, 'tplenc', LTemplateEnc);
|
||||
BindNullable(LQ, 'tpliv', LTemplateIv);
|
||||
LQ.ParamByName('uuid').AsString := LUuid;
|
||||
@@ -455,6 +486,11 @@ begin
|
||||
if LUpdatedAt = '' then LUpdatedAt := LNow;
|
||||
LQ.ParamByName('c').AsString := LCreatedAt;
|
||||
LQ.ParamByName('c2').AsString := LUpdatedAt;
|
||||
if LDeleted then LQ.ParamByName('del').AsInteger := 1
|
||||
else LQ.ParamByName('del').AsInteger := 0;
|
||||
LQ.ParamByName('delat').DataType := ftString;
|
||||
if LDeletedAt = '' then LQ.ParamByName('delat').Clear
|
||||
else LQ.ParamByName('delat').AsString := LDeletedAt;
|
||||
LQ.ExecSQL;
|
||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||
|
||||
@@ -498,6 +534,7 @@ var
|
||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||
LHasTemplate: Boolean;
|
||||
LQ: TFDQuery;
|
||||
LRows: Integer;
|
||||
begin
|
||||
try
|
||||
LUserId := Authenticate(ARequest, AResponse);
|
||||
@@ -648,6 +685,7 @@ begin
|
||||
LQ.ParamByName('id').AsInteger := LId;
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ExecSQL;
|
||||
LRows := LQ.RowsAffected;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
@@ -655,6 +693,15 @@ begin
|
||||
DB.Unlock;
|
||||
end;
|
||||
|
||||
// No row matched: the entry was deleted (another device's sync, a purge)
|
||||
// between the editor opening it and this save. Reporting 200 here made the
|
||||
// client show "Saved" for an edit that went nowhere — silent data loss.
|
||||
if LRows <= 0 then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 404, 'Entry no longer exists');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LogAudit(LUserId, 'edit_entry', GetClientIP(ARequest));
|
||||
TJSONHelper.SendOK(AResponse, 'Updated');
|
||||
end;
|
||||
@@ -915,6 +962,7 @@ var
|
||||
LUserId, LId: Integer;
|
||||
LBody: TJSONObject;
|
||||
LIcon: string;
|
||||
LIconCustom: Boolean;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
try
|
||||
@@ -934,6 +982,8 @@ begin
|
||||
LBody := TJSONHelper.ReadBody(ARequest);
|
||||
try
|
||||
LIcon := LBody.GetValue<string>('icon_b64', '');
|
||||
// 1 = user upload (preserve from auto fetch/clear), 0 = fetched/cleared.
|
||||
LIconCustom := LBody.GetValue<Boolean>('custom', False);
|
||||
finally
|
||||
LBody.Free;
|
||||
end;
|
||||
@@ -958,11 +1008,15 @@ begin
|
||||
// Bump updated_at (UTC) so the icon change wins last-write-wins on
|
||||
// sync — without it the new icon_b64 rides in the snapshot but other
|
||||
// devices skip it (timestamp unchanged → "not newer").
|
||||
'UPDATE vault_entries SET icon_b64 = :ic, updated_at = datetime(''now'') ' +
|
||||
'UPDATE vault_entries SET icon_b64 = :ic, icon_custom = :cust, ' +
|
||||
' updated_at = datetime(''now'') ' +
|
||||
'WHERE id=:id AND user_id=:uid';
|
||||
LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite
|
||||
if LIcon = '' then LQ.ParamByName('ic').Clear
|
||||
else LQ.ParamByName('ic').Value := LIcon;
|
||||
// A cleared icon (LIcon='') is never "custom" regardless of the flag.
|
||||
if (LIcon <> '') and LIconCustom then LQ.ParamByName('cust').AsInteger := 1
|
||||
else LQ.ParamByName('cust').AsInteger := 0;
|
||||
LQ.ParamByName('id').AsInteger := LId;
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ExecSQL;
|
||||
@@ -998,7 +1052,10 @@ begin
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text :=
|
||||
'UPDATE vault_entries SET icon_b64 = NULL WHERE user_id = :uid';
|
||||
// Only the auto-fetched cache (icon_custom=0). User uploads (=1) are
|
||||
// deliberate and survive — removed per entry, never by bulk clear.
|
||||
'UPDATE vault_entries SET icon_b64 = NULL ' +
|
||||
'WHERE user_id = :uid AND COALESCE(icon_custom, 0) = 0';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ExecSQL;
|
||||
finally
|
||||
@@ -1187,8 +1244,9 @@ var
|
||||
LArr, LIds: TJSONArray;
|
||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec,
|
||||
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid,
|
||||
LCreatedAt, LUpdatedAt,
|
||||
LCreatedAt, LUpdatedAt, LDeletedAt,
|
||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||
LIconCustom, LFavorite, LPinned, LDeleted: Boolean;
|
||||
LQ, LTomb: TFDQuery;
|
||||
begin
|
||||
try
|
||||
@@ -1245,10 +1303,12 @@ begin
|
||||
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
||||
' encrypted_password, iv, encryption_method, ' +
|
||||
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
||||
' icon_b64, template_enc, template_iv, uuid, created_at, updated_at) ' +
|
||||
' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at,' +
|
||||
' deleted, deleted_at) ' +
|
||||
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
|
||||
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
|
||||
' :cf, :cfiv, :ic, :tplenc, :tpliv, :uuid, :c, :c2)';
|
||||
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2,' +
|
||||
' :del, :delat)';
|
||||
// Declare optional param types ONCE — the prepared statement is
|
||||
// reused across every imported entry, and FireDAC needs the
|
||||
// type set before the first .Clear call would otherwise fail
|
||||
@@ -1270,6 +1330,8 @@ begin
|
||||
LQ.ParamByName('tiv2').DataType := ftMemo;
|
||||
LQ.ParamByName('genc').DataType := ftMemo;
|
||||
LQ.ParamByName('giv').DataType := ftMemo;
|
||||
// Same reason: a live entry clears it, so the type must be known.
|
||||
LQ.ParamByName('delat').DataType := ftString;
|
||||
|
||||
for I := 0 to LArr.Count - 1 do
|
||||
begin
|
||||
@@ -1296,6 +1358,9 @@ begin
|
||||
LCf := LEntry.GetValue<string>('custom_fields', '');
|
||||
LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
|
||||
LIcon := LEntry.GetValue<string>('icon_b64', '');
|
||||
LIconCustom := LEntry.GetValue<Boolean>('icon_custom', False);
|
||||
LFavorite := LEntry.GetValue<Boolean>('favorite', False);
|
||||
LPinned := LEntry.GetValue<Boolean>('pinned', False);
|
||||
LTemplateEnc := LEntry.GetValue<string>('template_enc', '');
|
||||
LTemplateIv := LEntry.GetValue<string>('template_iv', '');
|
||||
LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
|
||||
@@ -1306,6 +1371,13 @@ begin
|
||||
if LCreatedAt = '' then LCreatedAt := LNow;
|
||||
LUpdatedAt := Trim(LEntry.GetValue<string>('updated_at', ''));
|
||||
if LUpdatedAt = '' then LUpdatedAt := LNow;
|
||||
// Trashed entries travel in backups so a restore rebuilds the
|
||||
// trash. Their deletion date is preserved — restarting it would
|
||||
// give every restored entry a fresh auto-purge delay.
|
||||
LDeleted := LEntry.GetValue<Boolean>('deleted', False);
|
||||
LDeletedAt := Trim(LEntry.GetValue<string>('deleted_at', ''));
|
||||
if LDeleted and (LDeletedAt = '') then LDeletedAt := LNow;
|
||||
if not LDeleted then LDeletedAt := '';
|
||||
|
||||
// Ciphertext is always required. Site is required only for
|
||||
// logins — notes legitimately have no site (their body lives
|
||||
@@ -1339,11 +1411,21 @@ begin
|
||||
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf;
|
||||
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
|
||||
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
|
||||
if (LIcon <> '') and LIconCustom then LQ.ParamByName('iconcust').AsInteger := 1
|
||||
else LQ.ParamByName('iconcust').AsInteger := 0;
|
||||
if LFavorite then LQ.ParamByName('fav').AsInteger := 1
|
||||
else LQ.ParamByName('fav').AsInteger := 0;
|
||||
if LPinned then LQ.ParamByName('pin').AsInteger := 1
|
||||
else LQ.ParamByName('pin').AsInteger := 0;
|
||||
BindNullable(LQ, 'tplenc', LTemplateEnc);
|
||||
BindNullable(LQ, 'tpliv', LTemplateIv);
|
||||
LQ.ParamByName('uuid').AsString := LUuid;
|
||||
LQ.ParamByName('c').AsString := LCreatedAt;
|
||||
LQ.ParamByName('c2').AsString := LUpdatedAt;
|
||||
if LDeleted then LQ.ParamByName('del').AsInteger := 1
|
||||
else LQ.ParamByName('del').AsInteger := 0;
|
||||
if LDeletedAt = '' then LQ.ParamByName('delat').Clear
|
||||
else LQ.ParamByName('delat').AsString := LDeletedAt;
|
||||
LQ.ExecSQL;
|
||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||
LIds.AddElement(TJSONNumber.Create(LNewId));
|
||||
|
||||
@@ -82,11 +82,15 @@ begin
|
||||
|
||||
if AVerifier <> '' then
|
||||
begin
|
||||
// ZK: client computed PBKDF2 hex locally. Server only does the wrap.
|
||||
// ZK: client computed the verifier locally. Server only wraps it — legacy
|
||||
// pbkdf2 stores it raw, EVERY other algo (pbkdf2-sha256, pbkdf2-sha256-v2,
|
||||
// argon2id-v2) is SHA256-wrapped identically. Mirrors VerifierToStoredHash
|
||||
// in PM.Handler.Auth — keep in sync. The old `else if =CURRENT` silently
|
||||
// rejected -v2 / argon2id accounts (the current default) as wrong-password.
|
||||
if Length(AVerifier) <> 64 then Exit;
|
||||
if SameText(LAlgo, HASH_ALGO_LEGACY) then
|
||||
Result := ConstantTimeEquals(AVerifier, LStoredHash)
|
||||
else if SameText(LAlgo, HASH_ALGO_CURRENT) then
|
||||
else
|
||||
Result := ConstantTimeEquals(SHA256Hex(AVerifier), LStoredHash);
|
||||
Exit;
|
||||
end;
|
||||
@@ -275,6 +279,8 @@ var
|
||||
LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv,
|
||||
LSalt, LToken, LCSRF, LAlgo: string;
|
||||
LUserId, LKdfIters, LCurrentUses, LNewUses: Integer;
|
||||
LArgM, LArgT, LArgP: Integer;
|
||||
LArg: TJSONObject;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
LIP := GetClientIP(ARequest);
|
||||
@@ -310,6 +316,7 @@ begin
|
||||
// Join to users to look up by username + verify the code in one shot.
|
||||
LQ.SQL.Text :=
|
||||
'SELECT u.id, u.salt, u.kdf_iterations, u.hash_algo, ' +
|
||||
' u.argon2_m, u.argon2_t, u.argon2_p, ' +
|
||||
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' +
|
||||
'FROM users u ' +
|
||||
'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' +
|
||||
@@ -327,6 +334,9 @@ begin
|
||||
LSalt := LQ.FieldByName('salt').AsString;
|
||||
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
|
||||
LAlgo := LQ.FieldByName('hash_algo').AsString;
|
||||
LArgM := LQ.FieldByName('argon2_m').AsInteger;
|
||||
LArgT := LQ.FieldByName('argon2_t').AsInteger;
|
||||
LArgP := LQ.FieldByName('argon2_p').AsInteger;
|
||||
LStoredHash := LQ.FieldByName('code_hash').AsString;
|
||||
LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
|
||||
LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
|
||||
@@ -403,6 +413,18 @@ begin
|
||||
LObj.AddPair('salt', LSalt);
|
||||
LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters));
|
||||
LObj.AddPair('hashAlgo', LAlgo);
|
||||
// Echo the Argon2 params (as /login/challenge does) so the client derives
|
||||
// under the account's real params, not the default fallback. Harmless today
|
||||
// (all accounts use the default) but avoids a silent wrong-key derivation if
|
||||
// per-account params are ever tuned. Omitted for non-Argon2 accounts (m=0).
|
||||
if LArgM > 0 then
|
||||
begin
|
||||
LArg := TJSONObject.Create;
|
||||
LArg.AddPair('m', TJSONNumber.Create(LArgM));
|
||||
LArg.AddPair('t', TJSONNumber.Create(LArgT));
|
||||
LArg.AddPair('p', TJSONNumber.Create(LArgP));
|
||||
LObj.AddPair('argon2', LArg);
|
||||
end;
|
||||
LObj.AddPair('wrappedKey', LWrappedKey);
|
||||
LObj.AddPair('wrappedIv', LWrappedIv);
|
||||
LObj.AddPair('kdfSalt', LKdfSalt);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
program PMServer;
|
||||
program PMServer;
|
||||
|
||||
uses
|
||||
System.SysUtils, // FindCmdLineSwitch
|
||||
System.StartUpCopy,
|
||||
FMX.Forms,
|
||||
PM.SingleInstance in 'Source\PM.SingleInstance.pas',
|
||||
@@ -19,6 +20,8 @@ uses
|
||||
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
|
||||
PM.PinUnlock in 'Source\PM.PinUnlock.pas',
|
||||
PM.UserPrefs in 'Source\PM.UserPrefs.pas',
|
||||
PM.MachineId in 'Source\PM.MachineId.pas',
|
||||
PM.Licence in 'Source\PM.Licence.pas',
|
||||
PM.AutoStart in 'Source\PM.AutoStart.pas',
|
||||
PM.Favicon in 'Source\PM.Favicon.pas',
|
||||
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
|
||||
@@ -43,6 +46,13 @@ begin
|
||||
Exit;
|
||||
|
||||
Application.Initialize;
|
||||
// --tray ("Start with Windows"): FMX puts its taskbar proxy on screen before
|
||||
// FormCreate is ever reached — measured — so the watcher has to start here,
|
||||
// before the form is built. It only touches raw Win32 (FindWindowEx +
|
||||
// ShowWindow), never an FMX object: an earlier attempt that poked
|
||||
// MainForm.Visible/Handle at this point died with an access violation.
|
||||
if FindCmdLineSwitch('tray', True) then
|
||||
StartTrayProxyHider;
|
||||
Application.CreateForm(TMainForm, MainForm);
|
||||
Application.Run;
|
||||
end.
|
||||
|
||||
@@ -227,6 +227,8 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
||||
<DCCReference Include="Source\PM.QuickUnlock.pas"/>
|
||||
<DCCReference Include="Source\PM.PinUnlock.pas"/>
|
||||
<DCCReference Include="Source\PM.UserPrefs.pas"/>
|
||||
<DCCReference Include="Source\PM.MachineId.pas"/>
|
||||
<DCCReference Include="Source\PM.Licence.pas"/>
|
||||
<DCCReference Include="Source\PM.AutoStart.pas"/>
|
||||
<DCCReference Include="Source\PM.Favicon.pas"/>
|
||||
<DCCReference Include="Source\PM.ProcessLockdown.pas"/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,12 @@ begin
|
||||
FConn.Params.Values['Synchronous'] := 'Normal';
|
||||
FConn.Params.Values['BusyTimeout'] := '5000';
|
||||
FConn.Params.Values['JournalMode'] := 'WAL';
|
||||
// Explicit ON: SQLite defaults foreign_keys OFF per-connection, and the
|
||||
// schema relies on ON DELETE CASCADE (entry_attachments, entries_password_
|
||||
// history, recovery_keys on user/entry delete). FireDAC is *said* to default
|
||||
// this On but it's undocumented for us — set it so a permanent delete never
|
||||
// leaves orphaned encrypted blobs. No-op if it was already On (§6c-9).
|
||||
FConn.Params.Values['ForeignKeys'] := 'On';
|
||||
FConn.Open;
|
||||
CreateSchema;
|
||||
ApplyMigrations;
|
||||
@@ -379,6 +385,11 @@ begin
|
||||
// Fetched on demand by the Delphi favicon proxy when the user opts in.
|
||||
// NULL = no icon cached → JS falls back to the first-letter avatar.
|
||||
AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT');
|
||||
// Icon provenance: 0 = auto-fetched favicon (part of the disposable cache),
|
||||
// 1 = user-uploaded custom icon. Clear-cache and Re-fetch-all only touch
|
||||
// icon_custom=0 so a deliberate upload is never clobbered by an automatic
|
||||
// action — only the user changes/removes it, per entry.
|
||||
AddColumnIfMissing('vault_entries', 'icon_custom', 'INTEGER');
|
||||
// Tracked client-side via POST /entries/:id/touch on copy/open. Powers
|
||||
// the sidebar "Recent" view. NULL = never accessed since the column
|
||||
// landed (legacy rows).
|
||||
|
||||
@@ -36,7 +36,78 @@ function TryServeEmbedded(ARequest: TIdHTTPRequestInfo;
|
||||
implementation
|
||||
|
||||
uses
|
||||
Winapi.Windows;
|
||||
Winapi.Windows, System.Hash;
|
||||
|
||||
// ===== Brouillage des blocs embarques ========================================
|
||||
//
|
||||
// CE N'EST PAS DU SECRET, C'EST DE L'OPACITE, et il faut le dire pour ne pas
|
||||
// s'en croire protege. La cle est forcement dans l'exe, a quelques centaines
|
||||
// d'octets d'ici : quiconque tient le binaire peut retrouver le texte. Ce que
|
||||
// ca supprime, c'est l'onglet "Image" de Process Explorer et n'importe quel
|
||||
// `strings` — le frontend n'est plus lisible en survolant le fichier. L'onglet
|
||||
// "Memory", un debogueur, ou simplement la reponse HTTP le rendent toujours :
|
||||
// WebView2 doit recevoir du JavaScript en clair pour l'executer.
|
||||
//
|
||||
// D'ou le choix d'un flux XOR plutot que d'AES : a securite rigoureusement
|
||||
// egale (nulle, contre qui a l'exe), c'est dix lignes au lieu d'un appel CNG,
|
||||
// et surtout ca n'affiche pas une force qu'on n'a pas.
|
||||
//
|
||||
// Le flux depend du NOM DE LA RESSOURCE : deux fichiers qui commencent
|
||||
// pareil — et ils commencent tous par les memes lignes de commentaire — ne
|
||||
// produisent pas la meme sortie.
|
||||
|
||||
const
|
||||
// Doit correspondre EXACTEMENT a $ObfSecret dans BuildAssets.ps1.
|
||||
// Les deux se desaccordent = magie invalide = 404 sur /index.html.
|
||||
AssetObfSecret: array[0..31] of Byte = (
|
||||
$9A, $AC, $C6, $05, $DE, $D6, $FA, $E7,
|
||||
$4F, $95, $04, $E4, $2C, $1D, $DF, $26,
|
||||
$9B, $BA, $C9, $D2, $0A, $52, $D5, $90,
|
||||
$B1, $5E, $A8, $09, $12, $9B, $9E, $6C
|
||||
);
|
||||
// Prefixe le clair AVANT brouillage. Sans lui, une cle qui a derive entre le
|
||||
// script et l'exe servirait du binaire au navigateur : ecran blanc, aucune
|
||||
// trace. Avec lui, on sait refuser et le dire.
|
||||
AssetMagic = 'PMA1';
|
||||
|
||||
// Le XOR est son propre inverse : la meme fonction brouille et debrouille.
|
||||
// C'est BuildAssets.ps1 qui tient l'autre moitie, et les deux doivent produire
|
||||
// le meme flux — d'ou le detail du format, ecrit ici une fois pour toutes :
|
||||
//
|
||||
// bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
|
||||
//
|
||||
function DeobfuscateAsset(const AResName: string; const AData: TBytes): TBytes;
|
||||
var
|
||||
LSeed, LName, LBlock: TBytes;
|
||||
LSha: THashSHA2;
|
||||
LIdx: Cardinal;
|
||||
I, J: Integer;
|
||||
begin
|
||||
SetLength(Result, Length(AData));
|
||||
|
||||
LName := TEncoding.UTF8.GetBytes(AResName);
|
||||
SetLength(LSeed, 32 + Length(LName));
|
||||
Move(AssetObfSecret[0], LSeed[0], 32);
|
||||
if Length(LName) > 0 then
|
||||
Move(LName[0], LSeed[32], Length(LName));
|
||||
|
||||
LIdx := 0;
|
||||
I := 0;
|
||||
while I < Length(AData) do
|
||||
begin
|
||||
LSha := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256);
|
||||
LSha.Update(LSeed);
|
||||
LSha.Update(LIdx, SizeOf(LIdx));
|
||||
LBlock := LSha.HashAsBytes;
|
||||
for J := 0 to High(LBlock) do
|
||||
begin
|
||||
if I + J >= Length(AData) then Break;
|
||||
Result[I + J] := AData[I + J] xor LBlock[J];
|
||||
end;
|
||||
Inc(I, Length(LBlock));
|
||||
Inc(LIdx);
|
||||
end;
|
||||
end;
|
||||
|
||||
// The manifest is auto-generated. A stub is checked in so the project
|
||||
// compiles before BuildAssets.ps1 ever runs; running the script overwrites
|
||||
@@ -86,28 +157,44 @@ var
|
||||
LResName, LExt: string;
|
||||
LStream: TResourceStream;
|
||||
LMS: TMemoryStream;
|
||||
LRaw, LPlain: TBytes;
|
||||
begin
|
||||
Result := False;
|
||||
if not SameText(ARequest.Command, 'GET') then Exit;
|
||||
if not FindResourceFor(ARequest.Document, LResName) then Exit;
|
||||
if FindResource(HInstance, PChar(LResName), RT_RCDATA) = 0 then Exit;
|
||||
|
||||
LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA);
|
||||
try
|
||||
SetLength(LRaw, LStream.Size);
|
||||
if LStream.Size > 0 then
|
||||
LStream.ReadBuffer(LRaw[0], LStream.Size);
|
||||
finally
|
||||
LStream.Free;
|
||||
end;
|
||||
|
||||
LPlain := DeobfuscateAsset(LResName, LRaw);
|
||||
|
||||
// La magie tombe = le script et l'exe n'ont pas la meme cle. Refuser vaut
|
||||
// mieux que servir du binaire : un 404 se voit dans le journal, un ecran
|
||||
// blanc ne dit rien. Symptome a reconnaitre : /index.html en 404 juste apres
|
||||
// avoir touche AssetObfSecret d'un seul cote.
|
||||
if (Length(LPlain) < Length(AssetMagic)) or
|
||||
(TEncoding.ANSI.GetString(Copy(LPlain, 0, Length(AssetMagic))) <> AssetMagic) then
|
||||
Exit(False);
|
||||
|
||||
LExt := ExtractFileExt(ARequest.Document);
|
||||
if (LExt = '') and ((ARequest.Document = '') or (ARequest.Document = '/')) then
|
||||
LExt := '.html';
|
||||
AResponse.ContentType := MimeTypeFor(LExt);
|
||||
|
||||
LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA);
|
||||
try
|
||||
// Copy into a TMemoryStream so Indy can own and free it after the response.
|
||||
LMS := TMemoryStream.Create;
|
||||
LMS.CopyFrom(LStream, 0);
|
||||
if Length(LPlain) > Length(AssetMagic) then
|
||||
LMS.WriteBuffer(LPlain[Length(AssetMagic)], Length(LPlain) - Length(AssetMagic));
|
||||
LMS.Position := 0;
|
||||
AResponse.ContentStream := LMS;
|
||||
AResponse.FreeContentStream := True;
|
||||
finally
|
||||
LStream.Free;
|
||||
end;
|
||||
AResponse.ResponseNo := 200;
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
@@ -117,10 +117,31 @@ procedure TPMHTTPServer.Start(APort: Integer; SameFolder: Boolean;
|
||||
const
|
||||
EphemeralPortMin = 49152;
|
||||
EphemeralPortMax = 65535;
|
||||
// Trois essais : le port demande, puis deux replis. Au-dela, ce n'est plus
|
||||
// un port occupe mais une pile reseau qui ne veut pas de nous, et insister
|
||||
// ne ferait que retarder le message d'erreur.
|
||||
BindAttempts = 3;
|
||||
|
||||
/// Un port ephemere tire du generateur cryptographique de Windows.
|
||||
///
|
||||
/// PAS Random() : Randomize n'est appele nulle part dans ce programme, donc
|
||||
/// Random rend la MEME suite a chaque lancement - le port "aleatoire" de
|
||||
/// `port=0` etait en realite le meme tous les jours, ce qui vide de son sens
|
||||
/// l'aleatoire cense compliquer un balayage exterieur. RandomBytes passe par
|
||||
/// BCryptGenRandom, deja utilise pour le jeton d'acces.
|
||||
function PickEphemeralPort: Integer;
|
||||
var
|
||||
LRaw: TBytes;
|
||||
begin
|
||||
LRaw := PM.Crypto.RandomBytes(2);
|
||||
Result := EphemeralPortMin +
|
||||
((LRaw[0] shl 8 or LRaw[1]) mod (EphemeralPortMax - EphemeralPortMin + 1));
|
||||
end;
|
||||
|
||||
var
|
||||
LBinding: TIdSocketHandle;
|
||||
LDBPath, LWebRoot, LResolvedPort: string;
|
||||
LRequestedPort: Integer;
|
||||
LRequestedPort, LAttempt: Integer;
|
||||
begin
|
||||
if FServer.Active then Exit;
|
||||
|
||||
@@ -133,7 +154,7 @@ begin
|
||||
|
||||
LRequestedPort := APort;
|
||||
if LRequestedPort = 0 then
|
||||
LRequestedPort := EphemeralPortMin + Random(EphemeralPortMax - EphemeralPortMin);
|
||||
LRequestedPort := PickEphemeralPort;
|
||||
|
||||
var pathParent := '..\';
|
||||
if SameFolder then
|
||||
@@ -146,12 +167,41 @@ begin
|
||||
Log('Web root: ' + LWebRoot);
|
||||
InitStaticServer(LWebRoot);
|
||||
|
||||
// SE LIER EST LE TEST, ET IL N'Y EN A PAS D'AUTRE QUI VAILLE.
|
||||
//
|
||||
// On pourrait sonder le port avant, pour voir si quelqu'un repond. Ce serait
|
||||
// a la fois plus faible et plus fragile : un service qui ecoute sans repondre
|
||||
// passerait inapercu, et le port peut etre pris entre le sondage et le lien.
|
||||
// Tenter le lien tranche pour de bon, sans course.
|
||||
//
|
||||
// Le PREMIER essai honore le port demande - celui de config.txt, que
|
||||
// l'utilisateur a peut-etre choisi expres. Les suivants prennent un port
|
||||
// ephemere, exactement ce que fait un lancement sans port configure. Et on le
|
||||
// DIT a chaque fois : un serveur qui deplace son port en silence est pire
|
||||
// qu'un serveur qui refuse de demarrer, parce que rien n'explique ensuite
|
||||
// pourquoi une adresse notee quelque part ne repond plus.
|
||||
for LAttempt := 1 to BindAttempts do
|
||||
begin
|
||||
FServer.Bindings.Clear;
|
||||
LBinding := FServer.Bindings.Add;
|
||||
LBinding.IP := '127.0.0.1';
|
||||
LBinding.Port := LRequestedPort;
|
||||
|
||||
try
|
||||
FServer.Active := True;
|
||||
Break;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
FServer.Active := False; // Indy peut laisser le lien a moitie ouvert
|
||||
Log(Format('Port %d unavailable: %s', [LRequestedPort, E.Message]));
|
||||
// Le dernier echec remonte tel quel : l'appelant sait deja l'afficher.
|
||||
if LAttempt = BindAttempts then raise;
|
||||
LRequestedPort := PickEphemeralPort;
|
||||
Log(Format(' falling back to port %d (attempt %d of %d)',
|
||||
[LRequestedPort, LAttempt + 1, BindAttempts]));
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
FBoundPort := LRequestedPort;
|
||||
LResolvedPort := IntToStr(FBoundPort);
|
||||
Log(Format('Server started on http://127.0.0.1:%s (token:%s process_check:%s)',
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
unit PM.Licence;
|
||||
|
||||
{
|
||||
Verification hors ligne d'une licence signee ECDSA P-256.
|
||||
|
||||
Une licence est une chaine "charge-utile.signature", chacune en base64url,
|
||||
fabriquee par tools\licence.ps1 avec la cle privee qui reste chez l'editeur.
|
||||
La cle PUBLIQUE seule est ici, en dur : elle ne permet que de verifier.
|
||||
|
||||
CE QUE CA ARRETE, ET CE QUE CA N'ARRETE PAS. Sans la cle privee, personne ne
|
||||
fabrique de licence valable — c'est tout ce qu'un schema local peut promettre.
|
||||
Rien n'empeche de rapiecer l'exe pour sauter l'appel : c'est vrai de tous les
|
||||
schemas hors ligne, y compris ceux qui se vendent cher. La seule parade reelle
|
||||
serait de deporter une fonction sur un serveur, ce qui contredit le "100 %
|
||||
hors ligne" de cette application.
|
||||
|
||||
POURQUOI AUCUN ASN.1 NULLE PART. .NET sur Windows EST CNG : la signature que
|
||||
produit le script (r||s brut, 64 octets) est deja celle qu'attend
|
||||
BCryptVerifySignature, et les coordonnees X/Y exportees se rangent telles
|
||||
quelles dans un BCRYPT_ECCPUBLIC_BLOB. Le decodage DER, moitie penible de
|
||||
l'exercice d'habitude, n'existe pas ici.
|
||||
|
||||
La licence acceptee est rangee en pref DPAPI (PM.UserPrefs), donc liee au
|
||||
compte Windows et survivant au changement de port comme le reste.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils, System.Classes;
|
||||
|
||||
type
|
||||
TLicenceInfo = record
|
||||
Holder: string; // nom affichable
|
||||
Email: string;
|
||||
IssuedOn: string; // 'yyyy-mm-dd', date d'emission (PAS une expiration)
|
||||
Machine: string; // empreinte du poste, vide = licence flottante
|
||||
end;
|
||||
|
||||
// Verifie une chaine de licence sans rien ecrire. False = signature invalide,
|
||||
// charge utile trafiquee, format casse, ou cle publique pas encore posee.
|
||||
function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
|
||||
|
||||
// Verifie puis range la licence si elle tient. C'est le point d'entree du
|
||||
// bouton "Activer".
|
||||
function ActivateLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
|
||||
|
||||
// Etat courant : relit la licence rangee et la REVERIFIE. Ne se contente
|
||||
// jamais d'un drapeau booleen stocke — un drapeau se retourne, une signature
|
||||
// se refabrique seulement avec la cle privee.
|
||||
function IsLicensed: Boolean;
|
||||
function LicenceInfo: TLicenceInfo;
|
||||
procedure ClearLicence;
|
||||
|
||||
// L'empreinte de CE poste, a montrer sur l'ecran d'activation : sans elle
|
||||
// l'utilisateur n'a rien a envoyer pour qu'on lui emette une licence liee.
|
||||
function ThisMachineId: string;
|
||||
|
||||
// Verifie le chemin complet contre un vecteur de test fige. Appele a
|
||||
// l'initialisation de l'unite, comme SelfTestCrypto.
|
||||
procedure SelfTestLicence;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.JSON, System.Hash, System.NetEncoding, Winapi.Windows,
|
||||
PM.UserPrefs, PM.MachineId;
|
||||
|
||||
const
|
||||
LicencePrefKey = 'licenceKey';
|
||||
|
||||
// ===== LA CLE PUBLIQUE DE L'EDITEUR ========================================
|
||||
//
|
||||
// BCRYPT_ECCPUBLIC_BLOB : magic 'ECS1' + longueur(32) + X(32) + Y(32).
|
||||
//
|
||||
// Posee le 2026-09-05, REFAITE le meme jour : la premiere privee a ete
|
||||
// supprimee sans copie. Une paire perdue interdit toute nouvelle licence a
|
||||
// jamais, et la seule reparation est celle-ci — regenerer et reemettre.
|
||||
// C'etait sans douleur avec une licence en circulation ; ca ne le sera plus.
|
||||
//
|
||||
// NE JAMAIS REGENERER SANS RAISON : toutes les licences deja emises
|
||||
// tomberaient d'un coup. Un blob a zero refuse tout, ce qui est le bon
|
||||
// defaut — un defaut qui accepterait quelque chose serait pire.
|
||||
LicencePublicKeyBlob: array[0..71] of Byte = (
|
||||
$45, $43, $53, $31, $20, $00, $00, $00, $E3, $7A, $24, $23,
|
||||
$B3, $90, $E4, $5C, $02, $7F, $49, $17, $9F, $1E, $BA, $96,
|
||||
$18, $54, $BF, $9A, $F4, $42, $E9, $1E, $A4, $AD, $32, $DF,
|
||||
$82, $7E, $54, $F4, $E7, $04, $5E, $FE, $84, $AA, $FA, $A3,
|
||||
$3F, $78, $26, $6E, $C3, $67, $8A, $2A, $F2, $B4, $CD, $0D,
|
||||
$EA, $2E, $96, $CB, $99, $71, $70, $DC, $E7, $C5, $40, $E7
|
||||
);
|
||||
|
||||
// ===== CNG ===================================================================
|
||||
|
||||
const
|
||||
STATUS_SUCCESS = NTSTATUS(0);
|
||||
BCRYPT_ECDSA_P256 = 'ECDSA_P256';
|
||||
BCRYPT_ECCPUBLIC = 'ECCPUBLICBLOB';
|
||||
EccBlobLen = 72;
|
||||
P256SignatureLen = 64; // r||s, 32 octets chacun
|
||||
|
||||
function BCryptOpenAlgorithmProvider(out phAlgorithm: Pointer;
|
||||
pszAlgId, pszImplementation: PWideChar; dwFlags: ULONG): NTSTATUS; stdcall;
|
||||
external 'bcrypt.dll' name 'BCryptOpenAlgorithmProvider';
|
||||
|
||||
function BCryptCloseAlgorithmProvider(hAlgorithm: Pointer;
|
||||
dwFlags: ULONG): NTSTATUS; stdcall;
|
||||
external 'bcrypt.dll' name 'BCryptCloseAlgorithmProvider';
|
||||
|
||||
function BCryptImportKeyPair(hAlgorithm, hImportKey: Pointer;
|
||||
pszBlobType: PWideChar; out phKey: Pointer; pbInput: PByte;
|
||||
cbInput, dwFlags: ULONG): NTSTATUS; stdcall;
|
||||
external 'bcrypt.dll' name 'BCryptImportKeyPair';
|
||||
|
||||
function BCryptDestroyKey(hKey: Pointer): NTSTATUS; stdcall;
|
||||
external 'bcrypt.dll' name 'BCryptDestroyKey';
|
||||
|
||||
function BCryptVerifySignature(hKey, pPaddingInfo: Pointer;
|
||||
pbHash: PByte; cbHash: ULONG; pbSignature: PByte;
|
||||
cbSignature, dwFlags: ULONG): NTSTATUS; stdcall;
|
||||
external 'bcrypt.dll' name 'BCryptVerifySignature';
|
||||
|
||||
// ===== Outils ================================================================
|
||||
|
||||
function Base64UrlDecode(const AText: string): TBytes;
|
||||
var
|
||||
LStd: string;
|
||||
begin
|
||||
LStd := StringReplace(AText, '-', '+', [rfReplaceAll]);
|
||||
LStd := StringReplace(LStd, '_', '/', [rfReplaceAll]);
|
||||
while (Length(LStd) mod 4) <> 0 do
|
||||
LStd := LStd + '=';
|
||||
try
|
||||
Result := TNetEncoding.Base64.DecodeStringToBytes(LStd);
|
||||
except
|
||||
// Une chaine recopiee de travers n'est pas une panne : c'est un refus.
|
||||
Result := nil;
|
||||
end;
|
||||
end;
|
||||
|
||||
// Verifie contre UNE cle publique donnee. La production passe la constante
|
||||
// ci-dessus ; l'auto-test passe la sienne, ce qui evite de figer un second
|
||||
// jeu de constantes pour la meme mecanique.
|
||||
function VerifyAgainstKey(const AKeyBlob: TBytes; const AMessage,
|
||||
ASignature: TBytes): Boolean;
|
||||
var
|
||||
LAlg, LKey: Pointer;
|
||||
LHash: TBytes;
|
||||
LSha: THashSHA2;
|
||||
begin
|
||||
Result := False;
|
||||
if (Length(AKeyBlob) <> EccBlobLen) or
|
||||
(Length(ASignature) <> P256SignatureLen) or (Length(AMessage) = 0) then
|
||||
Exit;
|
||||
|
||||
if BCryptOpenAlgorithmProvider(LAlg, BCRYPT_ECDSA_P256, nil, 0) <> STATUS_SUCCESS then
|
||||
Exit;
|
||||
try
|
||||
if BCryptImportKeyPair(LAlg, nil, BCRYPT_ECCPUBLIC, LKey,
|
||||
@AKeyBlob[0], EccBlobLen, 0) <> STATUS_SUCCESS then
|
||||
Exit;
|
||||
try
|
||||
// BCryptVerifySignature attend l'EMPREINTE, pas le message.
|
||||
LSha := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256);
|
||||
LSha.Update(AMessage);
|
||||
LHash := LSha.HashAsBytes;
|
||||
Result := BCryptVerifySignature(LKey, nil, @LHash[0], Length(LHash),
|
||||
@ASignature[0], P256SignatureLen, 0) = STATUS_SUCCESS;
|
||||
finally
|
||||
BCryptDestroyKey(LKey);
|
||||
end;
|
||||
finally
|
||||
BCryptCloseAlgorithmProvider(LAlg, 0);
|
||||
end;
|
||||
end;
|
||||
|
||||
function ParsePayload(const APayload: TBytes; out AInfo: TLicenceInfo): Boolean;
|
||||
var
|
||||
LVal: TJSONValue;
|
||||
LObj: TJSONObject;
|
||||
begin
|
||||
Result := False;
|
||||
AInfo := Default(TLicenceInfo);
|
||||
LVal := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetString(APayload));
|
||||
if LVal = nil then
|
||||
Exit;
|
||||
try
|
||||
// Un transtypage direct leverait sur une charge utile qui n'est pas un
|
||||
// objet, et fuirait la valeur au passage.
|
||||
if not (LVal is TJSONObject) then
|
||||
Exit;
|
||||
LObj := TJSONObject(LVal);
|
||||
AInfo.Holder := LObj.GetValue<string>('n', '');
|
||||
AInfo.Email := LObj.GetValue<string>('e', '');
|
||||
AInfo.IssuedOn := LObj.GetValue<string>('d', '');
|
||||
AInfo.Machine := LowerCase(LObj.GetValue<string>('m', ''));
|
||||
Result := AInfo.Email <> '';
|
||||
finally
|
||||
LVal.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function SplitAndVerify(const AKeyBlob: TBytes; const ALicence: string;
|
||||
out AInfo: TLicenceInfo): Boolean;
|
||||
var
|
||||
LDot: Integer;
|
||||
LMsg, LSig: TBytes;
|
||||
begin
|
||||
Result := False;
|
||||
AInfo := Default(TLicenceInfo);
|
||||
|
||||
LDot := Pos('.', ALicence);
|
||||
if (LDot < 2) or (LDot = Length(ALicence)) then
|
||||
Exit;
|
||||
|
||||
LMsg := Base64UrlDecode(Copy(ALicence, 1, LDot - 1));
|
||||
LSig := Base64UrlDecode(Copy(ALicence, LDot + 1, MaxInt));
|
||||
if not VerifyAgainstKey(AKeyBlob, LMsg, LSig) then
|
||||
Exit;
|
||||
|
||||
// La signature d'abord, la lecture ensuite : on n'analyse jamais une charge
|
||||
// utile dont on ne sait pas encore qu'elle vient de nous.
|
||||
Result := ParsePayload(LMsg, AInfo);
|
||||
end;
|
||||
|
||||
// Le liage au poste, verifie APRES la signature — l'empreinte n'a de sens que
|
||||
// dans une charge utile dont on sait deja qu'elle vient de nous.
|
||||
//
|
||||
// UNE EMPREINTE ABSENTE EST REFUSEE. Les licences flottantes ont existe le
|
||||
// temps d'une seance, et l'une d'elles est sortie ; les refuser est la SEULE
|
||||
// facon de l'annuler. Un schema hors ligne n'a pas de revocation — rien ne
|
||||
// peut aller dire a un exe deja installe qu'une cle ne vaut plus — donc la
|
||||
// seule prise qui reste est de cesser d'accepter toute une classe de cles.
|
||||
//
|
||||
// Ce que ca coute : plus de licence de site, une cle par poste. Assume.
|
||||
//
|
||||
// Consequence de bord : un poste dont l'empreinte vaut 'unavailable' (ni WMI
|
||||
// ni MachineGuid) ne peut plus etre licencie du tout, puisque le script
|
||||
// d'emission n'accepte que 16 caracteres hexadecimaux. C'est le bon sens de
|
||||
// l'erreur : mieux vaut un poste qu'on ne peut pas licencier qu'une empreinte
|
||||
// degeneree qui ouvrirait partout.
|
||||
function MachineMatches(const AInfo: TLicenceInfo): Boolean;
|
||||
begin
|
||||
Result := (AInfo.Machine <> '') and
|
||||
SameText(AInfo.Machine, PM.MachineId.MachineFingerprint);
|
||||
end;
|
||||
|
||||
// ===== API ===================================================================
|
||||
|
||||
var
|
||||
CachedChecked: Boolean = False;
|
||||
CachedValid: Boolean = False;
|
||||
CachedInfo: TLicenceInfo;
|
||||
|
||||
function ProductionKey: TBytes;
|
||||
begin
|
||||
SetLength(Result, EccBlobLen);
|
||||
Move(LicencePublicKeyBlob[0], Result[0], EccBlobLen);
|
||||
end;
|
||||
|
||||
function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
|
||||
begin
|
||||
Result := SplitAndVerify(ProductionKey, Trim(ALicence), AInfo) and
|
||||
MachineMatches(AInfo);
|
||||
end;
|
||||
|
||||
function ActivateLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
|
||||
begin
|
||||
Result := VerifyLicence(ALicence, AInfo);
|
||||
if Result then
|
||||
begin
|
||||
SetPref(LicencePrefKey, Trim(ALicence));
|
||||
CachedChecked := True;
|
||||
CachedValid := True;
|
||||
CachedInfo := AInfo;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure Refresh;
|
||||
var
|
||||
LStored: string;
|
||||
begin
|
||||
if CachedChecked then
|
||||
Exit;
|
||||
CachedChecked := True;
|
||||
LStored := GetPref(LicencePrefKey);
|
||||
CachedValid := (LStored <> '') and VerifyLicence(LStored, CachedInfo);
|
||||
if not CachedValid then
|
||||
CachedInfo := Default(TLicenceInfo);
|
||||
end;
|
||||
|
||||
function IsLicensed: Boolean;
|
||||
begin
|
||||
Refresh;
|
||||
Result := CachedValid;
|
||||
end;
|
||||
|
||||
function LicenceInfo: TLicenceInfo;
|
||||
begin
|
||||
Refresh;
|
||||
Result := CachedInfo;
|
||||
end;
|
||||
|
||||
function ThisMachineId: string;
|
||||
begin
|
||||
Result := PM.MachineId.MachineFingerprintDisplay;
|
||||
end;
|
||||
|
||||
procedure ClearLicence;
|
||||
begin
|
||||
SetPref(LicencePrefKey, '');
|
||||
CachedChecked := True;
|
||||
CachedValid := False;
|
||||
CachedInfo := Default(TLicenceInfo);
|
||||
end;
|
||||
|
||||
// ===== Auto-test =============================================================
|
||||
|
||||
procedure SelfTestLicence;
|
||||
const
|
||||
// Paire de test, sans rapport avec la cle de production : sa privee a ete
|
||||
// jetee. Elle ne prouve qu'une chose, mais elle la prouve entierement — que
|
||||
// le blob, l'empreinte et le format r||s s'accordent avec CNG.
|
||||
TestKey: array[0..71] of Byte = (
|
||||
$45, $43, $53, $31, $20, $00, $00, $00, $27, $24, $81, $FF,
|
||||
$7E, $21, $27, $90, $91, $3D, $B7, $7E, $6E, $20, $18, $8B,
|
||||
$48, $51, $D2, $DD, $1A, $BB, $3D, $E8, $65, $06, $55, $AD,
|
||||
$A3, $93, $EE, $6D, $D4, $56, $42, $9D, $F7, $85, $3C, $C9,
|
||||
$98, $FA, $42, $43, $47, $E6, $2F, $2B, $9B, $30, $6B, $15,
|
||||
$88, $AA, $2D, $C3, $79, $2B, $1E, $A2, $4D, $1F, $E3, $55
|
||||
);
|
||||
TestLicence = 'eyJuIjoiTW9uIEFtaSIsImUiOiJhbWlAZXhlbXBsZS5jb20iLCJkIjoiMjAyNi0wOS0wNSJ9.' +
|
||||
'u9q3GvBByVU0pLbuRrgT98-kZqOBSnYptJ2Mx1B1OjXxJAFgNgnxyjj8SmYpkFt1GuP2PlzRXB3A3HYRMwW2gA';
|
||||
var
|
||||
LKey: TBytes;
|
||||
LInfo: TLicenceInfo;
|
||||
LTampered: string;
|
||||
begin
|
||||
SetLength(LKey, EccBlobLen);
|
||||
Move(TestKey[0], LKey[0], EccBlobLen);
|
||||
|
||||
if not SplitAndVerify(LKey, TestLicence, LInfo) then
|
||||
raise Exception.Create('Auto-test licence ECHOUE : une licence valable est refusee.');
|
||||
if LInfo.Email <> 'ami@exemple.com' then
|
||||
raise Exception.CreateFmt(
|
||||
'Auto-test licence ECHOUE : charge utile mal lue (got "%s").', [LInfo.Email]);
|
||||
|
||||
// Un octet retourne dans la charge utile doit faire tomber la signature.
|
||||
LTampered := 'e0JuIjoiTW9uIEFtaSIsImUiOiJhbWlAZXhlbXBsZS5jb20iLCJkIjoiMjAyNi0wOS0wNSJ9.' +
|
||||
'u9q3GvBByVU0pLbuRrgT98-kZqOBSnYptJ2Mx1B1OjXxJAFgNgnxyjj8SmYpkFt1GuP2PlzRXB3A3HYRMwW2gA';
|
||||
if SplitAndVerify(LKey, LTampered, LInfo) then
|
||||
raise Exception.Create('Auto-test licence ECHOUE : une licence trafiquee est acceptee.');
|
||||
|
||||
// Le liage au poste, eprouve sur l'enregistrement : on ne peut pas signer un
|
||||
// vecteur lie sans la cle privee de test, jetee — mais c'est MachineMatches
|
||||
// qui porte la regle, et c'est lui qu'il faut voir tomber.
|
||||
LInfo.Machine := '';
|
||||
if MachineMatches(LInfo) then
|
||||
raise Exception.Create('Auto-test licence ECHOUE : une licence flottante est acceptee.');
|
||||
LInfo.Machine := 'ffffffffffffffff';
|
||||
if MachineMatches(LInfo) then
|
||||
raise Exception.Create('Auto-test licence ECHOUE : une licence d''un AUTRE poste est acceptee.');
|
||||
LInfo.Machine := PM.MachineId.MachineFingerprint;
|
||||
if not MachineMatches(LInfo) then
|
||||
raise Exception.Create('Auto-test licence ECHOUE : la licence de CE poste est refusee.');
|
||||
end;
|
||||
|
||||
initialization
|
||||
SelfTestLicence;
|
||||
|
||||
end.
|
||||
@@ -0,0 +1,162 @@
|
||||
unit PM.MachineId;
|
||||
|
||||
{
|
||||
Empreinte de la machine : carte mere + disque systeme.
|
||||
|
||||
Sert a lier une licence a UN poste (PM.Licence). Deux sources, melangees puis
|
||||
hachees, dont on ne publie que 16 caracteres hexadecimaux :
|
||||
|
||||
- UUID de la carte mere, lu dans le SMBIOS via Win32_ComputerSystemProduct.
|
||||
- Numero de serie du volume systeme (GetVolumeInformation).
|
||||
|
||||
POURQUOI HACHER PLUTOT QUE CONCATENER. L'empreinte se recopie dans un
|
||||
courriel pour demander une licence. L'UUID brut de la carte mere est un
|
||||
identifiant materiel stable et mondialement unique : il n'a pas a circuler
|
||||
en clair, ni a se retrouver dans nos journaux. Le condense suffit a comparer,
|
||||
et ne se remonte pas.
|
||||
|
||||
CE QUI LA CHANGE, ET DONC CASSE LA LICENCE : remplacer la carte mere,
|
||||
remplacer le disque systeme, reinstaller Windows sur un autre volume, ou
|
||||
cloner vers une autre machine. C'est le prix assume du liage — la reparation
|
||||
est une reemission, pas un correctif.
|
||||
|
||||
QUAND LE SMBIOS MENT. Beaucoup de machines virtuelles et d'images clonees
|
||||
annoncent un UUID nul ou tout a FF. S'en contenter ferait tomber des postes
|
||||
differents sur la MEME empreinte, et une licence emise pour l'un ouvrirait
|
||||
chez l'autre. On retombe alors sur MachineGuid, pose par Windows a
|
||||
l'installation et distinct sur chaque installation.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
// 16 caracteres hexadecimaux, minuscules. Calcule une fois, puis retenu :
|
||||
// l'appel WMI coute quelques centaines de millisecondes.
|
||||
function MachineFingerprint: string;
|
||||
|
||||
// La meme, en groupes de quatre — c'est un code qu'un humain recopie a la
|
||||
// main dans un courriel, et le decoupage divise les fautes de transcription.
|
||||
function MachineFingerprintDisplay: string;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.SysUtils, System.Win.ComObj, System.Win.Registry, System.Variants,
|
||||
Winapi.Windows, Winapi.ActiveX, PM.Crypto;
|
||||
|
||||
var
|
||||
CachedPrint: string = '';
|
||||
|
||||
function BoardUUID: string;
|
||||
var
|
||||
LLocator, LServices, LSet, LItem: OleVariant;
|
||||
LEnum: IEnumVariant;
|
||||
LFetched: LongWord;
|
||||
begin
|
||||
Result := '';
|
||||
|
||||
// COM DOIT ETRE INITIALISE ICI, PAS AILLEURS PAR CHANCE.
|
||||
//
|
||||
// Sans ca, l'empreinte depend de qui a demarre avant nous : appelee apres
|
||||
// l'initialisation de WebView2 elle lit le vrai UUID de la carte mere,
|
||||
// appelee avant elle echoue et retombe sur MachineGuid — DEUX empreintes
|
||||
// differentes pour une seule machine, et une licence liee a l'une que
|
||||
// l'autre refuse. Un identifiant qui depend de l'ordre de demarrage n'est
|
||||
// pas un identifiant.
|
||||
//
|
||||
// ET ON NE LIBERE PAS. Un CoUninitialize symetrique parait propre et se
|
||||
// paie d'une violation d'acces : les mandataires WMI et le cache de
|
||||
// type-info de ComObj survivent a l'appel et se liberent plus tard, dans un
|
||||
// appartement qui n'existe plus. Laisser l'appartement ouvert pour la duree
|
||||
// du processus ne coute rien ici — cette application heberge WebView2, donc
|
||||
// COM y est de toute facon initialise et le reste.
|
||||
CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
|
||||
try
|
||||
LLocator := CreateOleObject('WbemScripting.SWbemLocator');
|
||||
LServices := LLocator.ConnectServer('.', 'root\CIMV2');
|
||||
LSet := LServices.ExecQuery('SELECT UUID FROM Win32_ComputerSystemProduct');
|
||||
LEnum := IUnknown(LSet._NewEnum) as IEnumVariant;
|
||||
if LEnum.Next(1, LItem, LFetched) = S_OK then
|
||||
if not VarIsNull(LItem.UUID) then
|
||||
Result := UpperCase(Trim(string(LItem.UUID)));
|
||||
except
|
||||
// WMI desactive, service arrete, droits refuses : ce n'est pas une panne,
|
||||
// c'est une source en moins. L'appelant retombe sur MachineGuid.
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
// Un UUID nul ou tout a FF n'identifie rien : il est partage par toutes les
|
||||
// machines qui portent le meme defaut de micrologiciel.
|
||||
if (Result = '00000000-0000-0000-0000-000000000000') or
|
||||
(Result = 'FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF') then
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function MachineGuid: string;
|
||||
var
|
||||
LReg: TRegistry;
|
||||
begin
|
||||
Result := '';
|
||||
LReg := TRegistry.Create(KEY_READ or KEY_WOW64_64KEY);
|
||||
try
|
||||
LReg.RootKey := HKEY_LOCAL_MACHINE;
|
||||
if LReg.OpenKeyReadOnly('SOFTWARE\Microsoft\Cryptography') then
|
||||
Result := LReg.ReadString('MachineGuid');
|
||||
except
|
||||
Result := '';
|
||||
end;
|
||||
LReg.Free;
|
||||
end;
|
||||
|
||||
function SystemVolumeSerial: string;
|
||||
var
|
||||
LRoot: array[0..MAX_PATH] of Char;
|
||||
LSerial, LMaxLen, LFlags: DWORD;
|
||||
begin
|
||||
Result := '';
|
||||
if GetWindowsDirectory(LRoot, MAX_PATH) = 0 then Exit;
|
||||
// "C:\" — la racine du volume qui porte Windows.
|
||||
var LDrive := Copy(string(LRoot), 1, 3);
|
||||
LSerial := 0;
|
||||
if GetVolumeInformation(PChar(LDrive), nil, 0, @LSerial, LMaxLen, LFlags, nil, 0) then
|
||||
Result := IntToHex(LSerial, 8);
|
||||
end;
|
||||
|
||||
function MachineFingerprint: string;
|
||||
var
|
||||
LBoard, LVolume, LSeed: string;
|
||||
begin
|
||||
if CachedPrint <> '' then
|
||||
Exit(CachedPrint);
|
||||
|
||||
LBoard := BoardUUID;
|
||||
if LBoard = '' then
|
||||
LBoard := MachineGuid;
|
||||
LVolume := SystemVolumeSerial;
|
||||
|
||||
// Les deux sources peuvent manquer sur un poste verrouille a l'extreme.
|
||||
// Une empreinte vide serait la MEME partout : mieux vaut une chaine qui ne
|
||||
// correspondra a aucune licence qu'une chaine qui les accepterait toutes.
|
||||
if (LBoard = '') and (LVolume = '') then
|
||||
Exit('unavailable');
|
||||
|
||||
LSeed := 'pmserver/machine/v1|' + LBoard + '|' + LVolume;
|
||||
CachedPrint := Copy(PM.Crypto.SHA256Hex(LSeed), 1, 16);
|
||||
Result := CachedPrint;
|
||||
end;
|
||||
|
||||
function MachineFingerprintDisplay: string;
|
||||
var
|
||||
LRaw: string;
|
||||
I: Integer;
|
||||
begin
|
||||
LRaw := UpperCase(MachineFingerprint);
|
||||
Result := '';
|
||||
for I := 1 to Length(LRaw) do
|
||||
begin
|
||||
if (I > 1) and ((I - 1) mod 4 = 0) then
|
||||
Result := Result + '-';
|
||||
Result := Result + LRaw[I];
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
@@ -86,7 +86,8 @@ begin
|
||||
LQ.Free;
|
||||
end;
|
||||
|
||||
if (LExpires <> 0) and (LExpires < Now) then
|
||||
// expires_at is now UTC (see CreateSession) — compare against UTC too.
|
||||
if (LExpires <> 0) and (LExpires < NowUTC) then
|
||||
begin
|
||||
DeleteSessionByTokenHash(LTokenHash);
|
||||
TJSONHelper.SendError(AResponse, 401, 'Session expired');
|
||||
@@ -100,7 +101,7 @@ end;
|
||||
procedure RequireCSRF(ARequest: TIdHTTPRequestInfo;
|
||||
AResponse: TIdHTTPResponseInfo; AUserId: Integer);
|
||||
var
|
||||
LSubmitted, LStored: string;
|
||||
LSubmitted, LStored, LTokenHash: string;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
if SameText(ARequest.Command, 'GET') then Exit;
|
||||
@@ -112,6 +113,14 @@ begin
|
||||
raise ESessionRejected.Create('missing csrf');
|
||||
end;
|
||||
|
||||
// Bind the CSRF check to the session actually PRESENTED (token_hash), not
|
||||
// "the user's most recent session" (§6c-3). The old ORDER BY created_at
|
||||
// could read a different session's token — and created_at's 1-second
|
||||
// resolution made two same-second sessions a coin-flip → phantom 403s.
|
||||
// user_id stays as a filter (defense in depth). Expiry isn't re-checked:
|
||||
// Authenticate always runs before RequireCSRF and already did it.
|
||||
LTokenHash := SHA256Hex(ExtractBearerToken(ARequest));
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
@@ -119,8 +128,8 @@ begin
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text :=
|
||||
'SELECT csrf_token FROM sessions ' +
|
||||
'WHERE user_id = :uid AND expires_at > datetime(''now'') ' +
|
||||
'ORDER BY created_at DESC LIMIT 1';
|
||||
'WHERE token_hash = :th AND user_id = :uid';
|
||||
LQ.ParamByName('th').AsString := LTokenHash;
|
||||
LQ.ParamByName('uid').AsInteger := AUserId;
|
||||
LQ.Open;
|
||||
if LQ.IsEmpty then
|
||||
@@ -152,7 +161,11 @@ begin
|
||||
ACSRFToken := RandomHex(32);
|
||||
LTokenHash := SHA256Hex(AToken);
|
||||
// YYYY-MM-DD HH:NN:SS, +24h, server local time (api.php uses date() = local)
|
||||
LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(Now, 24));
|
||||
// UTC like everywhere else — RequireCSRF + CleanupExpired compare against
|
||||
// SQLite datetime('now') which IS UTC. Writing local here made the CSRF
|
||||
// window fall short on negative-offset zones (§6c-2): "Invalid CSRF token"
|
||||
// on writes while GETs still authed.
|
||||
LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(NowUTC, 24));
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
|
||||
+532
-33
@@ -18,6 +18,7 @@ interface
|
||||
uses
|
||||
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
|
||||
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
|
||||
System.Diagnostics,
|
||||
System.Net.HttpClient, System.Net.URLClient,
|
||||
Winapi.Windows, Winapi.ShellAPI,
|
||||
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
|
||||
@@ -33,7 +34,7 @@ uses
|
||||
{$IFDEF USE_EDGE_BROWSER}
|
||||
FMX.TMSFNCEdgeWebBrowser,
|
||||
{$ENDIF}
|
||||
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.AutoStart,
|
||||
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.Licence, PM.AutoStart,
|
||||
PM.Favicon,
|
||||
FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
|
||||
const
|
||||
@@ -72,6 +73,24 @@ type
|
||||
procedure btnToggleLogClick(Sender: TObject);
|
||||
procedure btnReloadClick(Sender: TObject);
|
||||
private
|
||||
// Windows minimising the window from the outside (Win+D / Show desktop /
|
||||
// taskbar) makes FMX forget it was maximised: the window comes back
|
||||
// "normal". None of our restore paths run in that case — the taskbar
|
||||
// restore is pure Windows + FMX — so the state is re-applied on activate,
|
||||
// which happens however the user comes back. FWantMaximized mirrors the
|
||||
// user's LAST deliberate choice, so we never fight a normal-size window.
|
||||
FWantMaximized: Boolean;
|
||||
// Handle of the WinEvent hook that reports minimise start/end for our own
|
||||
// window. FMX gives no usable signal (no resize while iconic, and the
|
||||
// restore's resize is indistinguishable from a user gesture by placement
|
||||
// flags or foreground ownership — both were measured and rejected).
|
||||
FWinEventHook: THandle;
|
||||
// True from "minimise starts" until just after the restore settles, so the
|
||||
// resizes FMX fires while restoring are not recorded as user intent.
|
||||
FIgnoreResizes: Boolean;
|
||||
// Set from activate until the queued correction runs, so the restore's
|
||||
// own resize is not mistaken for the user resizing the window.
|
||||
FRestoring: Boolean;
|
||||
FServer: TPMHTTPServer;
|
||||
FBridge: TPMBridge;
|
||||
// Accumulates base64 chunks for large file saves (attachments too big
|
||||
@@ -91,6 +110,16 @@ type
|
||||
FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses
|
||||
// FormCloseQuery's minimize-to-tray intercept.
|
||||
FAutofillTargetHWND: HWND; // saved at hotkey time, consumed on /execute
|
||||
// CHRONOMETRE DU RACCOURCI, arme au moment ou l'on appelle la page.
|
||||
//
|
||||
// Un autofill lent apres une periode d'inactivite peut l'etre pour deux
|
||||
// raisons sans rapport : le moteur WebView2 qui se reveille et met des
|
||||
// secondes a repondre, ou la sequence de frappe elle-meme (prise du premier
|
||||
// plan, attente des modificateurs). Les deux se ressemblent parfaitement de
|
||||
// l'exterieur - l'utilisateur voit "ca a mis trois secondes" - et se
|
||||
// corrigent a des endroits opposes. On mesure donc les deux segments
|
||||
// separement plutot que de deviner lequel accuser.
|
||||
FAutofillClock: TStopwatch;
|
||||
// Pending payload for the 60ms delay timer (focus settle before SendInput).
|
||||
// Cleared inside AutofillTimerTick.
|
||||
FAutofillPendingHWND: HWND;
|
||||
@@ -102,6 +131,11 @@ type
|
||||
// SendInput because Win10/11 anti-focus-stealing rules then refuse to
|
||||
// hand focus to the target window.
|
||||
FAutofillPendingHide: Boolean;
|
||||
// Same idea for the Ctrl+Shift+L/P/T hotkeys: captured when the hotkey
|
||||
// fires, because the multi-match picker pulls the window out of the tray
|
||||
// before the user picks, so by execute time the state is already lost.
|
||||
// JS never sends hide_after on this path — only quick-search does.
|
||||
FAutofillHotkeyWasHidden: Boolean;
|
||||
// True when the pending autofill should type ONLY the username (quick
|
||||
// search "autofill username" — right-click / Shift+Enter in fill mode).
|
||||
FAutofillPendingUserOnly: Boolean;
|
||||
@@ -116,6 +150,7 @@ type
|
||||
// needing two .fmx variants. Aligned to Client to fill the remaining
|
||||
// space between PanelTop (top) and PanelLog/Splitter (bottom).
|
||||
WebBrowser: TWebBrowserClass;
|
||||
procedure FormResizeTrackState(Sender: TObject);
|
||||
procedure AutofillTimerTick(Sender: TObject);
|
||||
// Shared by cmd://file/save (single-shot) and file/save-commit
|
||||
// (chunked): decode the base64, show the Save dialog, write, and fire
|
||||
@@ -147,21 +182,107 @@ type
|
||||
procedure BridgeQuickSearchHotkey(AKind: TAutofillKind;
|
||||
ATargetHWND: HWND; const ATitle: string);
|
||||
procedure WebBrowserInitialized(Sender: TObject);
|
||||
public
|
||||
// Called from the WinEvent hook. WINEVENT_OUTOFCONTEXT delivers through
|
||||
// our own message queue, so these already run on the main thread.
|
||||
// Every tray restore goes through here so the minimise guard cannot get
|
||||
// stuck raised (see the implementation).
|
||||
// One-shot: hides the window the first time FMX shows it on a --tray
|
||||
// launch, then unhooks itself.
|
||||
// True when a native modal dialog owns us — see the implementation.
|
||||
function NativeModalActive: Boolean;
|
||||
procedure RestoreWindowFromTray;
|
||||
procedure WinStateMinimizeStart;
|
||||
procedure WinStateMinimizeEnd;
|
||||
procedure ApplyWantedWindowState;
|
||||
end;
|
||||
|
||||
var
|
||||
MainForm: TMainForm;
|
||||
|
||||
// Call from the .dpr right after Application.Initialize, BEFORE CreateForm,
|
||||
// for a --tray launch. FMX shows its taskbar proxy before FormCreate ever
|
||||
// runs — measured — so anything installed from inside the form is already too
|
||||
// late. Starts a worker that hides the proxy the instant it exists and keeps
|
||||
// watching until the tray startup completes; the main thread is blocked
|
||||
// loading the .fmx and starting the server, so only another thread can react.
|
||||
procedure StartTrayProxyHider;
|
||||
|
||||
implementation
|
||||
|
||||
{$R *.fmx}
|
||||
|
||||
// Result slot for EnumOwnDialogProc — the enumeration is main-thread only and
|
||||
// consumed immediately, so a unit variable is enough.
|
||||
var
|
||||
GFoundDialogHwnd: HWND = 0;
|
||||
|
||||
// Forward — used by WebBrowserInitialized (which sits above the actual
|
||||
// definition lower in the unit).
|
||||
function MaskAccessToken(const AUrl: string): string; forward;
|
||||
|
||||
// ---- WinEvent hook plumbing (see the maximised-state block further down) --
|
||||
const
|
||||
EVENT_SYSTEM_MINIMIZESTART = $0016;
|
||||
EVENT_SYSTEM_MINIMIZEEND = $0017;
|
||||
WINEVENT_OUTOFCONTEXT = $0000;
|
||||
OBJID_WINDOW = 0;
|
||||
|
||||
function SetWinEventHook(eventMin, eventMax: DWORD; hmodWinEventProc: HMODULE;
|
||||
lpfnWinEventProc: Pointer; idProcess, idThread, dwFlags: DWORD): THandle;
|
||||
stdcall; external 'user32.dll' name 'SetWinEventHook';
|
||||
function UnhookWinEvent(hWinEventHook: THandle): BOOL;
|
||||
stdcall; external 'user32.dll' name 'UnhookWinEvent';
|
||||
|
||||
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
|
||||
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall; forward;
|
||||
|
||||
procedure StartTrayProxyHider;
|
||||
begin
|
||||
// ApplicationHWND (FMX.Platform.Win) IS the taskbar proxy — no hunting for a
|
||||
// TFMAppClass window, and it is valid this early, before the form exists.
|
||||
ShowWindow(ApplicationHWND, SW_HIDE);
|
||||
// Hiding alone does not reliably drop the button: the taskbar had already
|
||||
// drawn it. Clearing WS_EX_APPWINDOW and setting WS_EX_TOOLWINDOW does —
|
||||
// a tool window is never listed. Undone by TrayProxyShowInTaskbar before any
|
||||
// restore, otherwise the app would come back with no taskbar button at all.
|
||||
SetWindowLong(ApplicationHWND, GWL_EXSTYLE,
|
||||
(GetWindowLong(ApplicationHWND, GWL_EXSTYLE) and (not WS_EX_APPWINDOW))
|
||||
or WS_EX_TOOLWINDOW);
|
||||
end;
|
||||
|
||||
// Give the proxy its normal styles back. Takes effect on the next show, which
|
||||
// is exactly what RestoreFromTray does right after.
|
||||
procedure TrayProxyShowInTaskbar;
|
||||
begin
|
||||
SetWindowLong(ApplicationHWND, GWL_EXSTYLE,
|
||||
(GetWindowLong(ApplicationHWND, GWL_EXSTYLE) and (not WS_EX_TOOLWINDOW))
|
||||
or WS_EX_APPWINDOW);
|
||||
end;
|
||||
|
||||
procedure TMainForm.FormCreate(Sender: TObject);
|
||||
begin
|
||||
// --tray ("Start with Windows"): the taskbar proxy was already dealt with in
|
||||
// the .dpr (StartTrayProxyHider) — it is shown before FormCreate is even
|
||||
// reached. This just keeps the form itself off screen.
|
||||
if FindCmdLineSwitch('tray', True) then
|
||||
Visible := False;
|
||||
|
||||
// The .fmx ships WindowState = wsMaximized, so that is the starting intent.
|
||||
// Wired here (not in the .fmx) to keep the designer file untouched.
|
||||
FWantMaximized := (WindowState = TWindowState.wsMaximized);
|
||||
FIgnoreResizes := False;
|
||||
// Scoped to our own process; WINEVENT_OUTOFCONTEXT means no DLL injection
|
||||
// and the callback arrives on this thread. Failure is non-fatal — we just
|
||||
// lose the maximised-state preservation.
|
||||
FWinEventHook := SetWinEventHook(
|
||||
EVENT_SYSTEM_MINIMIZESTART, EVENT_SYSTEM_MINIMIZEEND, 0,
|
||||
@WinStateEventProc, GetCurrentProcessId, 0, WINEVENT_OUTOFCONTEXT);
|
||||
LogLine('[winstate] minimise hook installed=' +
|
||||
BoolToStr(FWinEventHook <> 0, True));
|
||||
FRestoring := False;
|
||||
OnResize := FormResizeTrackState;
|
||||
|
||||
// Create the WebBrowser control programmatically so the {$IFDEF
|
||||
// USE_EDGE_BROWSER} directive can swap the concrete class without a
|
||||
// second .fmx variant. Align Client to fill the area below PanelTop
|
||||
@@ -202,6 +323,9 @@ begin
|
||||
FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey;
|
||||
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
|
||||
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
|
||||
// Apres le theme et apres OnTrayRestore : la barrette se peint elle-meme, et
|
||||
// son bouton Open passe par cet evenement.
|
||||
FBridge.RestoreLazyBar;
|
||||
FAutofillTargetHWND := 0;
|
||||
// Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores
|
||||
// Navigate() calls until Edge Chromium finishes its async init (~1-2s).
|
||||
@@ -236,13 +360,27 @@ begin
|
||||
FreeAndNil(configList);
|
||||
end;
|
||||
end;
|
||||
// Starting the server (DB open + Indy bind + WebView2 navigation) is what
|
||||
// makes FormCreate run for ~1 s. On a --tray launch it therefore runs AFTER
|
||||
// the tray hide, in the deferred block below, so FormCreate returns in
|
||||
// milliseconds and the app is tray-resident right away.
|
||||
//
|
||||
// NOTE: this was originally needed to shrink the window during which the FMX
|
||||
// taskbar proxy was visible. That is no longer the reason — the proxy is now
|
||||
// dealt with properly in the .dpr (StartTrayProxyHider, WS_EX_TOOLWINDOW).
|
||||
// The ordering is kept because it works and reaching the tray sooner is
|
||||
// desirable, NOT because anything depends on it. A normal launch keeps the
|
||||
// original order. FServer is constructed earlier (see above), so FServer.Active
|
||||
// reads False rather than faulting while the start is pending.
|
||||
if not FindCmdLineSwitch('tray', True) then
|
||||
btnStartClick(Nil);
|
||||
btnToggleLogClick(nil);
|
||||
|
||||
// CLI flag --tray (set by the "Start with Windows" registry entry):
|
||||
// launch directly into the tray instead of popping a window during
|
||||
// the Windows login. Defer to ForceQueue so the form has finished its
|
||||
// initial Show before we hide it — minimises the visible flash.
|
||||
// the Windows login. The window was already made invisible at the top of
|
||||
// FormCreate; this adds the tray icon and drops the taskbar entry. Still
|
||||
// deferred: it is also the safety net if FMX shows the main form anyway.
|
||||
if FindCmdLineSwitch('tray', True) then
|
||||
begin
|
||||
// Suppress the first-time tray balloon for autostart launches —
|
||||
@@ -252,14 +390,27 @@ begin
|
||||
TThread.ForceQueue(nil,
|
||||
procedure
|
||||
begin
|
||||
FBridge.MinimizeToTray;
|
||||
LogLine('Launched with --tray, minimised at startup (balloon suppressed)');
|
||||
// Hand the foreground back: a -tray launch DOES take it for a moment —
|
||||
// FMX puts its taskbar proxy up before we hide it — so the application
|
||||
// that launched us (a console, Explorer) would otherwise be left
|
||||
// inactive. GStartupForeground caught it before any window of ours
|
||||
// existed. Nothing to give back on a Windows-startup launch: the
|
||||
// foreground is then a shell window, which the capture filters out.
|
||||
FBridge.MinimizeToTray(True, True);
|
||||
// Hidden now — safe to pay the startup cost.
|
||||
btnStartClick(Nil);
|
||||
LogLine('Launched with --tray, started hidden (balloon suppressed)');
|
||||
end);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TMainForm.FormDestroy(Sender: TObject);
|
||||
begin
|
||||
if FWinEventHook <> 0 then
|
||||
begin
|
||||
UnhookWinEvent(FWinEventHook);
|
||||
FWinEventHook := 0;
|
||||
end;
|
||||
if Assigned(FFileSaveChunks) then
|
||||
begin
|
||||
for var LSB in FFileSaveChunks.Values do LSB.Free;
|
||||
@@ -340,22 +491,94 @@ begin
|
||||
// cmd://autofill/execute (fired after the user picks an entry) sends
|
||||
// the password into it. Then pop the modal in fill mode.
|
||||
if not FServer.Active then Exit;
|
||||
if NativeModalActive then Exit;
|
||||
FAutofillTargetHWND := ATargetHWND;
|
||||
// Tray-hidden ONLY (Visible=false). A window merely minimised to the
|
||||
// taskbar must NOT count: hide-after would send it to the TRAY on close —
|
||||
// it vanishes from the taskbar it came from. Restored-from-iconic just
|
||||
// stays open after the modal closes.
|
||||
LWasHidden := not Self.Visible;
|
||||
FBridge.RestoreFromTray;
|
||||
RestoreWindowFromTray;
|
||||
// The window is back but the WebView2 child does NOT hold the keyboard
|
||||
// focus, so nothing typed reaches the page — Esc did nothing until the user
|
||||
// clicked the search box. DOM .focus() alone cannot fix that: the OS-level
|
||||
// focus has to be on the browser control first.
|
||||
WebBrowser.SetFocus;
|
||||
LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower;
|
||||
// Second arg forFill=true tells JS to SendInput on pick instead of copy.
|
||||
// Second arg forFill tells JS to SendInput on pick instead of copy. The bridge
|
||||
// reports HWND 0 when the foreground was our own hidden window: there is
|
||||
// nothing to type into, so offer copy instead of a fill that goes nowhere.
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' +
|
||||
'Bridge.openQuickSearch(' + LWasHiddenJs + ',true)');
|
||||
'Bridge.openQuickSearch(' + LWasHiddenJs + ',' +
|
||||
BoolToStr(ATargetHWND <> 0, True).ToLower + ')');
|
||||
LogLine(Format('Quick-search hotkey — target=%s, title="%s"',
|
||||
[IntToHex(ATargetHWND, 8), ATitle]));
|
||||
end;
|
||||
|
||||
// A native modal dialog (Save As, folder picker) DISABLES its owner window for
|
||||
// as long as it is up. Restoring / focusing that owner then strands the dialog
|
||||
// behind everything: the app looks frozen, when it is merely disabled by a modal
|
||||
// the user can no longer find. Every hotkey entry point checks this first — and
|
||||
// rather than just refusing, it surfaces the dialog, which is what the user
|
||||
// actually needs.
|
||||
// The common Save/Open dialog is NOT an owned popup of the form —
|
||||
// GetLastActivePopup returns the form itself — but it does live on our thread
|
||||
// and carries the standard dialog class. Enumerating the thread's windows finds
|
||||
// it whoever its owner turns out to be.
|
||||
function EnumOwnDialogProc(AWnd: HWND; AParam: LPARAM): BOOL; stdcall;
|
||||
var
|
||||
LCls: array[0..63] of Char;
|
||||
begin
|
||||
Result := True; // keep enumerating
|
||||
if (AWnd = HWND(AParam)) or (not IsWindowVisible(AWnd)) then Exit;
|
||||
if GetClassName(AWnd, LCls, Length(LCls)) = 0 then Exit;
|
||||
if SameText(LCls, '#32770') then
|
||||
begin
|
||||
GFoundDialogHwnd := AWnd;
|
||||
Result := False; // stop
|
||||
end;
|
||||
end;
|
||||
|
||||
function TMainForm.NativeModalActive: Boolean;
|
||||
var
|
||||
LHwnd, LPopup: HWND;
|
||||
begin
|
||||
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
|
||||
Result := (LHwnd <> 0) and (not IsWindowEnabled(LHwnd));
|
||||
if not Result then Exit;
|
||||
GFoundDialogHwnd := 0;
|
||||
EnumThreadWindows(GetCurrentThreadId, @EnumOwnDialogProc, LPARAM(LHwnd));
|
||||
LPopup := GFoundDialogHwnd;
|
||||
if LPopup = 0 then LPopup := GetLastActivePopup(LHwnd); // fallback
|
||||
if (LPopup <> 0) and (LPopup <> LHwnd) and IsWindow(LPopup) then
|
||||
begin
|
||||
ShowWindow(LPopup, SW_SHOW);
|
||||
// Windows refuses SetForegroundWindow to a process that isn't already in
|
||||
// front, and the hotkey comes from ANOTHER application. The way through is
|
||||
// to attach our input queue to the CURRENT FOREGROUND thread — not to the
|
||||
// dialog's, which is our own thread and would make the attach a no-op
|
||||
// (that is why the autofill helper, written for cross-process targets,
|
||||
// left the dialog raised but unfocused).
|
||||
var LFgThread := GetWindowThreadProcessId(GetForegroundWindow, nil);
|
||||
var LOurThread := GetCurrentThreadId;
|
||||
var LAttached := (LFgThread <> 0) and (LFgThread <> LOurThread) and
|
||||
AttachThreadInput(LOurThread, LFgThread, True);
|
||||
try
|
||||
BringWindowToTop(LPopup);
|
||||
SetForegroundWindow(LPopup);
|
||||
finally
|
||||
if LAttached then AttachThreadInput(LOurThread, LFgThread, False);
|
||||
end;
|
||||
// A tray balloon, not a toast: our own window is disabled and sits behind
|
||||
// the dialog, so nothing rendered in the page would be seen.
|
||||
FBridge.ShowBalloon('Finish the open dialog',
|
||||
'A Save or folder dialog is waiting for an answer. Shortcuts stay ' +
|
||||
'disabled until you close it.', True);
|
||||
LogLine('Hotkey ignored — a native dialog is open; brought it to front.');
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TMainForm.BridgeQuickSearchRequest;
|
||||
var
|
||||
LWasHidden: Boolean;
|
||||
@@ -365,12 +588,14 @@ begin
|
||||
// see the modal, then ask JS to open it. JS handles the locked-vault
|
||||
// case (shows the auth screen with master-pw focused instead).
|
||||
if not FServer.Active then Exit;
|
||||
if NativeModalActive then Exit;
|
||||
// Remember whether the window was TRAY-hidden BEFORE we restore — after
|
||||
// the user picks an entry the JS layer asks us to minimize back so they
|
||||
// can paste into the target app without an extra alt-tab. Iconic-only
|
||||
// (taskbar) does NOT count — see BridgeQuickSearchHotkey.
|
||||
LWasHidden := not Self.Visible;
|
||||
FBridge.RestoreFromTray;
|
||||
RestoreWindowFromTray;
|
||||
WebBrowser.SetFocus; // see BridgeQuickSearchHotkey — keystrokes need it
|
||||
LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower;
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' +
|
||||
@@ -383,7 +608,8 @@ var
|
||||
EscapedTitle: string;
|
||||
begin
|
||||
if not FServer.Active then Exit;
|
||||
FBridge.RestoreFromTray;
|
||||
if NativeModalActive then Exit;
|
||||
RestoreWindowFromTray;
|
||||
EscapedTitle := StringReplace(AWindowTitle, '\', '\\', [rfReplaceAll]);
|
||||
EscapedTitle := StringReplace(EscapedTitle, '"', '\"', [rfReplaceAll]);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
@@ -863,6 +1089,10 @@ begin
|
||||
// we register both with the supplied combos (replacing any prior).
|
||||
else if ACmd = 'autofill/hotkeys' then
|
||||
begin
|
||||
// Slot ids of the combos Windows refused (already held by another app).
|
||||
// Ids, not labels: JS owns the wording AND uses them to flag the exact
|
||||
// row in Settings. A combo the user cleared (vk=0) is never listed.
|
||||
var LFailed := '';
|
||||
// Quick-search combo is set unconditionally (independent of the
|
||||
// autofill enabled flag) so the picker stays armed even when the
|
||||
// autofill hotkeys are turned off.
|
||||
@@ -871,9 +1101,21 @@ begin
|
||||
var LQsMods := Word(StrToIntDef(GetParam('qs_mods'), 6));
|
||||
var LQsVk := Word(StrToIntDef(GetParam('qs_vk'), Ord('Q')));
|
||||
var LQsOk := FBridge.SetQuickSearchHotkey(LQsMods, LQsVk);
|
||||
if (not LQsOk) and (LQsVk <> 0) then LFailed := LFailed + 'quickSearch,';
|
||||
LogLine(Format('Quick-search hotkey set — mods:%d vk:%d (ok=%s)',
|
||||
[LQsMods, LQsVk, BoolToStr(LQsOk, True)]));
|
||||
end;
|
||||
// New-entry combo — also independent of the autofill enabled flag
|
||||
// (Ctrl+Shift+A works even with autofill turned off).
|
||||
if GetParam('ne_vk') <> '' then
|
||||
begin
|
||||
var LNeMods := Word(StrToIntDef(GetParam('ne_mods'), 6));
|
||||
var LNeVk := Word(StrToIntDef(GetParam('ne_vk'), Ord('A')));
|
||||
var LNeOk := FBridge.SetNewEntryHotkey(LNeMods, LNeVk);
|
||||
if (not LNeOk) and (LNeVk <> 0) then LFailed := LFailed + 'newEntry,';
|
||||
LogLine(Format('New-entry hotkey set — mods:%d vk:%d (ok=%s)',
|
||||
[LNeMods, LNeVk, BoolToStr(LNeOk, True)]));
|
||||
end;
|
||||
if GetParam('enabled') <> '1' then
|
||||
begin
|
||||
FBridge.UnregisterAutofillHotkey;
|
||||
@@ -885,16 +1127,27 @@ begin
|
||||
var LFullVk := Word(StrToIntDef(GetParam('full_vk'), Ord('L')));
|
||||
var LPwdMods := Word(StrToIntDef(GetParam('pwd_mods'), 6));
|
||||
var LPwdVk := Word(StrToIntDef(GetParam('pwd_vk'), Ord('P')));
|
||||
// 2FA combo — vk 0 when the user cleared it, which skips registration.
|
||||
var LTotpMods := Word(StrToIntDef(GetParam('totp_mods'), 6));
|
||||
var LTotpVk := Word(StrToIntDef(GetParam('totp_vk'), Ord('T')));
|
||||
var LAllOk := FBridge.SetAutofillHotkeys(LFullMods, LFullVk,
|
||||
LPwdMods, LPwdVk);
|
||||
LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d (all_ok=%s)',
|
||||
[LFullMods, LFullVk, LPwdMods, LPwdVk, BoolToStr(LAllOk, True)]));
|
||||
// Notify JS of the result so the UI can flag a failed-to-register combo
|
||||
// (typically a clash with another app's global hotkey).
|
||||
LPwdMods, LPwdVk,
|
||||
LTotpMods, LTotpVk);
|
||||
LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d totp=mods:%d vk:%d (all_ok=%s)',
|
||||
[LFullMods, LFullVk, LPwdMods, LPwdVk, LTotpMods, LTotpVk, BoolToStr(LAllOk, True)]));
|
||||
if (not FBridge.AutofillFullActive) and (LFullVk <> 0) then
|
||||
LFailed := LFailed + 'full,';
|
||||
if (not FBridge.AutofillPwdActive) and (LPwdVk <> 0) then
|
||||
LFailed := LFailed + 'password,';
|
||||
if (not FBridge.AutofillTotpActive) and (LTotpVk <> 0) then
|
||||
LFailed := LFailed + 'totp,';
|
||||
end;
|
||||
// One notification for the whole batch, naming the combos that failed —
|
||||
// empty string = everything registered.
|
||||
LFailed := LFailed.TrimRight([',', ' ']);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
|
||||
'Bridge.onAutofillHotkeysResult(' + BoolToStr(LAllOk, True).ToLower + ')');
|
||||
end;
|
||||
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
|
||||
end
|
||||
|
||||
// execute: JS has matched an entry, decrypted the password, and is
|
||||
@@ -904,12 +1157,23 @@ begin
|
||||
FAutofillPendingUser := GetParam('username');
|
||||
FAutofillPendingPass := GetParam('password');
|
||||
FAutofillPendingHWND := FAutofillTargetHWND;
|
||||
FAutofillPendingHide := GetParam('hide_after') = '1';
|
||||
FAutofillPendingHide := (GetParam('hide_after') = '1') or FAutofillHotkeyWasHidden;
|
||||
FAutofillHotkeyWasHidden := False;
|
||||
FAutofillPendingUserOnly := GetParam('field') = 'user';
|
||||
FAutofillPendingClear := GetParam('clear') <> '0'; // absent = ON
|
||||
FAutofillPendingNotify := GetParam('notify') <> '0'; // absent = ON
|
||||
FAutofillTargetHWND := 0;
|
||||
|
||||
// PREMIER SEGMENT : combien la PAGE a mis a repondre.
|
||||
//
|
||||
// Tout ce qui precede est du JavaScript - reveil du moteur, appariement du
|
||||
// titre, dechiffrement du mot de passe. Si le chiffre est gros ici et petit
|
||||
// plus bas, c'est le renderer qu'il faut regarder, pas la sequence de
|
||||
// frappe. Le contraire designe SendInput et la prise du premier plan.
|
||||
if FAutofillClock.IsRunning then
|
||||
LogLine(Format('Autofill: page answered in %d ms',
|
||||
[FAutofillClock.ElapsedMilliseconds]));
|
||||
|
||||
// Small timer so SetForegroundWindow has time to take effect before
|
||||
// SendInput fires — avoids the first keystrokes going to our window.
|
||||
// TTimer.OnTimer is a TNotifyEvent (method, not anon proc) → we use a
|
||||
@@ -926,9 +1190,23 @@ begin
|
||||
// foreground process at that moment, so plain SetForegroundWindow works.
|
||||
else if ACmd = 'autofill/cancel' then
|
||||
begin
|
||||
if FAutofillTargetHWND <> 0 then
|
||||
// refocus=0 — JS is REFUSING the request, not ending a user interaction:
|
||||
// nothing must move on screen. During an export a native Save dialog owns
|
||||
// the foreground, and pushing it behind leaves the app disabled by a modal
|
||||
// the user can no longer find.
|
||||
var LRefocus := GetParam('refocus') <> '0';
|
||||
if LRefocus and (FAutofillTargetHWND <> 0) then
|
||||
SetForegroundWindow(FAutofillTargetHWND);
|
||||
FAutofillTargetHWND := 0;
|
||||
// The window only came out of the tray to show the picker, so dismissing it
|
||||
// puts it back — same as quick-search, which hides on Esc too. Done after
|
||||
// handing focus to the target above.
|
||||
if FAutofillHotkeyWasHidden and LRefocus then
|
||||
begin
|
||||
FAutofillHotkeyWasHidden := False;
|
||||
// Foreground already handed to the target above.
|
||||
FBridge.MinimizeToTray(True, False);
|
||||
end;
|
||||
LogLine('Autofill cancelled (no match or dismissed)');
|
||||
end
|
||||
|
||||
@@ -939,7 +1217,7 @@ begin
|
||||
// restores it later via ForceForegroundWindow.
|
||||
else if ACmd = 'app/focus' then
|
||||
begin
|
||||
FBridge.RestoreFromTray;
|
||||
RestoreWindowFromTray;
|
||||
LogLine('App brought to front (autofill picker)');
|
||||
end
|
||||
|
||||
@@ -1026,6 +1304,25 @@ begin
|
||||
'Bridge.onPrefResult("' + LEscapedKey + '","' + LEscapedVal + '")');
|
||||
end
|
||||
|
||||
// Existence probe. Some prefs ARE secrets (the auto-backup password, the sync
|
||||
// passwords) and the UI mostly only needs to know whether one is set. Asking
|
||||
// through prefs/get would interpolate the secret into an ExecuteJavaScript
|
||||
// string — measured in a renderer dump, where it also showed up in the
|
||||
// DevTools protocol buffers, and it stayed there after the vault auto-locked.
|
||||
// The answer rides the same callback under "<key>#has".
|
||||
else if ACmd = 'prefs/has' then
|
||||
begin
|
||||
var LHasKey := GetParam('key');
|
||||
if LHasKey = '' then Exit;
|
||||
var LEsc := StringReplace(LHasKey, '\', '\\', [rfReplaceAll]);
|
||||
LEsc := StringReplace(LEsc, '"', '\"', [rfReplaceAll]);
|
||||
var LSet := '';
|
||||
if PM.UserPrefs.GetPref(LHasKey) <> '' then LSet := '1';
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onPrefResult)' +
|
||||
'Bridge.onPrefResult("' + LEsc + '#has","' + LSet + '")');
|
||||
end
|
||||
|
||||
else if ACmd = 'prefs/set' then
|
||||
begin
|
||||
var LKey := GetParam('key');
|
||||
@@ -1033,6 +1330,63 @@ begin
|
||||
PM.UserPrefs.SetPref(LKey, GetParam('value'));
|
||||
end
|
||||
|
||||
// ---- Licence hors ligne (PM.Licence) ---------------------------------
|
||||
// cmd://licence/status -> Bridge.onLicenceResult(ok, porteur)
|
||||
// cmd://licence/activate?key=<...> -> idem, apres verification + rangement
|
||||
//
|
||||
// La cle est du base64url plus un point : que des caracteres surs dans une
|
||||
// URL, donc rien a encoder ni a decoder de special sur ce trajet.
|
||||
// Desactiver pour eprouver l'activation. Garde par la presence de
|
||||
// config.txt, comme Ctrl+Shift+D : un exe livre n'en a pas, la commande y
|
||||
// est donc inerte. L'alternative — supprimer prefs.bin — emporterait aussi
|
||||
// le mode de deverrouillage, le nom retenu et les reglages de sync.
|
||||
else if ACmd = 'licence/clear' then
|
||||
begin
|
||||
if not FileExists('config.txt') then Exit;
|
||||
PM.Licence.ClearLicence;
|
||||
LogLine('Licence cleared (debug)');
|
||||
end
|
||||
|
||||
else if (ACmd = 'licence/status') or (ACmd = 'licence/activate') then
|
||||
begin
|
||||
var LLicOk: Boolean;
|
||||
var LLicInfo: TLicenceInfo;
|
||||
if ACmd = 'licence/activate' then
|
||||
begin
|
||||
LLicOk := PM.Licence.ActivateLicence(GetParam('key'), LLicInfo);
|
||||
// On journalise le VERDICT, jamais la cle : le journal se copie et
|
||||
// s'envoie, et une licence valable est une valeur transmissible.
|
||||
LogLine('Licence activation: ' + BoolToStr(LLicOk, True));
|
||||
end
|
||||
else
|
||||
begin
|
||||
LLicOk := PM.Licence.IsLicensed;
|
||||
LLicInfo := PM.Licence.LicenceInfo;
|
||||
end;
|
||||
// Le nom, le courriel et la date viennent d'une charge utile QUE NOUS AVONS
|
||||
// SIGNEE, mais ils sont interpoles dans du JavaScript : on les echappe
|
||||
// comme n'importe quelle chaine etrangere. Signee ne veut pas dire
|
||||
// inoffensive — c'est nous qui les avons tapes au moment d'emettre.
|
||||
var LFields := TArray<string>.Create(
|
||||
LLicInfo.Holder, LLicInfo.Email, LLicInfo.IssuedOn);
|
||||
for var LI := 0 to High(LFields) do
|
||||
begin
|
||||
LFields[LI] := StringReplace(LFields[LI], '\', '\\', [rfReplaceAll]);
|
||||
LFields[LI] := StringReplace(LFields[LI], '"', '\"', [rfReplaceAll]);
|
||||
LFields[LI] := StringReplace(LFields[LI], #13, '\r', [rfReplaceAll]);
|
||||
LFields[LI] := StringReplace(LFields[LI], #10, '\n', [rfReplaceAll]);
|
||||
end;
|
||||
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onLicenceResult)' +
|
||||
'Bridge.onLicenceResult(' + BoolToStr(LLicOk, True).ToLower +
|
||||
',"' + LFields[0] +
|
||||
'","' + PM.Licence.ThisMachineId +
|
||||
'","' + LFields[1] +
|
||||
'","' + LFields[2] +
|
||||
'",' + BoolToStr(FileExists('config.txt'), True).ToLower + ')');
|
||||
end
|
||||
|
||||
// ---- Start with Windows (HKCU Run registry) --------------------------
|
||||
else if ACmd = 'autostart/get' then
|
||||
begin
|
||||
@@ -1146,6 +1500,11 @@ begin
|
||||
// send it as If-Match on the push so the server rejects (412) the
|
||||
// write when another device changed the file in between.
|
||||
var LIfMatch := GetParam('ifmatch');
|
||||
// GITEA, reconnu a l'URL. Meme transport, trois differences : le jeton
|
||||
// remplace l'authentification Basic, le corps d'un PUT est du JSON que JS
|
||||
// a deja mis en forme (le sha y voyage, pas dans un If-Match), et le test
|
||||
// de connexion se fait par GET - l'API des contenus ne repond pas a HEAD.
|
||||
var LGitea := LUrl.ToLower.Contains('/api/v1/repos/');
|
||||
TThread.CreateAnonymousThread(
|
||||
procedure
|
||||
var
|
||||
@@ -1168,13 +1527,17 @@ begin
|
||||
try
|
||||
LHttp.ConnectionTimeout := 10000;
|
||||
LHttp.ResponseTimeout := 30000;
|
||||
if (LUser <> '') then
|
||||
// "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend
|
||||
// d'un jeton d'acces personnel, et l'autre donne un 401 muet.
|
||||
if LGitea then
|
||||
LHttp.CustomHeaders['Authorization'] := 'token ' + LPwd
|
||||
else if (LUser <> '') then
|
||||
begin
|
||||
LHttp.CredentialsStorage.AddCredential(
|
||||
System.Net.URLClient.TCredentialsStorage.TCredential.Create(
|
||||
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
|
||||
end;
|
||||
if LMethod = 'webdav/get' then
|
||||
if (LMethod = 'webdav/get') or ((LMethod = 'webdav/test') and LGitea) then
|
||||
begin
|
||||
LBodyStream := TBytesStream.Create;
|
||||
try
|
||||
@@ -1197,15 +1560,31 @@ begin
|
||||
begin
|
||||
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
|
||||
LReqStream := TBytesStream.Create(LBytes);
|
||||
// La reponse est LUE meme en echec : Gitea explique un 409 ou un
|
||||
// 422 dans son corps, et sans cela le message d'erreur se
|
||||
// reduisait au code, ce qui ne dit pas quoi corriger.
|
||||
LBodyStream := TBytesStream.Create;
|
||||
try
|
||||
if LIfMatch <> '' then
|
||||
LResp := LHttp.Put(LUrl, LReqStream, nil,
|
||||
if LGitea then
|
||||
LResp := LHttp.Put(LUrl, LReqStream, LBodyStream,
|
||||
[System.Net.URLClient.TNetHeader.Create('Content-Type', 'application/json')])
|
||||
else if LIfMatch <> '' then
|
||||
LResp := LHttp.Put(LUrl, LReqStream, LBodyStream,
|
||||
[System.Net.URLClient.TNetHeader.Create('If-Match', LIfMatch)])
|
||||
else
|
||||
LResp := LHttp.Put(LUrl, LReqStream);
|
||||
LResp := LHttp.Put(LUrl, LReqStream, LBodyStream);
|
||||
LStatus := LResp.StatusCode;
|
||||
LEtag := LResp.HeaderValue['ETag'];
|
||||
if ((LStatus < 200) or (LStatus >= 300)) and (LBodyStream.Size > 0) then
|
||||
begin
|
||||
SetLength(LBytes, LBodyStream.Size);
|
||||
Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size);
|
||||
// Tronque : le corps d'une erreur tient en une phrase utile,
|
||||
// et le reste n'a rien a faire dans un appel JavaScript.
|
||||
LErr := Copy(TEncoding.UTF8.GetString(LBytes), 1, 300);
|
||||
end;
|
||||
finally
|
||||
LBodyStream.Free;
|
||||
LReqStream.Free;
|
||||
end;
|
||||
end
|
||||
@@ -1233,7 +1612,8 @@ begin
|
||||
// GET success path → ship body base64. Otherwise the field
|
||||
// carries either the empty string or the exception message
|
||||
// (for status=0 network errors).
|
||||
if (LMethod = 'webdav/get') and (LStatus >= 200) and (LStatus < 300) then
|
||||
if ((LMethod = 'webdav/get') or ((LMethod = 'webdav/test') and LGitea))
|
||||
and (LStatus >= 200) and (LStatus < 300) then
|
||||
LEscPayload := LBodyB64
|
||||
else
|
||||
LEscPayload := LErr;
|
||||
@@ -1431,7 +1811,7 @@ end;
|
||||
|
||||
procedure TMainForm.BridgeTrayRestore;
|
||||
begin
|
||||
FBridge.RestoreFromTray;
|
||||
RestoreWindowFromTray;
|
||||
// Notify the JS layer: the UI may want to reset the auto-lock timer,
|
||||
// refresh state, or show a "welcome back" toast.
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
@@ -1457,10 +1837,107 @@ begin
|
||||
// Restore the form first so the tray icon goes away and FormDestroy
|
||||
// executes from a normal (non-hidden) state. RestoreFromTray also
|
||||
// deletes the tray icon.
|
||||
FBridge.RestoreFromTray;
|
||||
RestoreWindowFromTray;
|
||||
Application.Terminate;
|
||||
end;
|
||||
|
||||
// ---- Window maximised-state preservation --------------------------------
|
||||
// FMX forgets a window was maximised when Windows minimises it from the
|
||||
// outside (Win+D / Show desktop / taskbar): it comes back "normal". None of
|
||||
// our own restore paths run for a taskbar restore, and three cheaper signals
|
||||
// were measured and rejected against real traces:
|
||||
// * FMX WindowState — does not follow a native un-maximise
|
||||
// * WPF_RESTORETOMAXIMIZED — set for ANY maximised window, not just a restore
|
||||
// * foreground ownership — already ours during the restore's resize
|
||||
// So observe the minimise itself, at the OS level, with a WinEvent hook.
|
||||
|
||||
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
|
||||
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall;
|
||||
begin
|
||||
if (idObject <> OBJID_WINDOW) or (AWnd = 0) or (MainForm = nil) then Exit;
|
||||
// No HWND filter: an FMX app owns TWO top-level windows — the form and the
|
||||
// taskbar proxy (see FindFMXAppWindow in PM.Bridge) — and Win+D minimises
|
||||
// the proxy, which an equality test against the form handle rejected. The
|
||||
// hook is already scoped to our own process, and WebView2 lives in separate
|
||||
// processes, so anything arriving here is ours.
|
||||
if AEvent = EVENT_SYSTEM_MINIMIZESTART then MainForm.WinStateMinimizeStart
|
||||
else if AEvent = EVENT_SYSTEM_MINIMIZEEND then MainForm.WinStateMinimizeEnd;
|
||||
end;
|
||||
|
||||
// Single entry point for every tray restore. ExecuteAutofill really minimises
|
||||
// the window (SW_MINIMIZE) before typing, which raises the guard; if the window
|
||||
// is then HIDDEN to the tray instead of being un-minimised, MINIMIZEEND may
|
||||
// never arrive and the guard would stay up forever, silently freezing the
|
||||
// tracked window state. RestoreFromTray replays its own saved placement, so
|
||||
// there is nothing left for the guard to protect by then.
|
||||
procedure TMainForm.RestoreWindowFromTray;
|
||||
begin
|
||||
FIgnoreResizes := False;
|
||||
// A --tray launch turned the proxy into a tool window to keep it off the
|
||||
// taskbar during startup; put it back before showing anything.
|
||||
TrayProxyShowInTaskbar;
|
||||
FBridge.RestoreFromTray;
|
||||
end;
|
||||
|
||||
// The window is going down: from here until the restore settles, every resize
|
||||
// FMX fires is part of that transition, never a user gesture.
|
||||
procedure TMainForm.WinStateMinimizeStart;
|
||||
begin
|
||||
FIgnoreResizes := True;
|
||||
end;
|
||||
|
||||
// Back up: re-apply what the user had.
|
||||
procedure TMainForm.WinStateMinimizeEnd;
|
||||
begin
|
||||
// Inline, not queued: by the time this hook fires Windows has already put the
|
||||
// window back at its normal size, and measurement showed nothing touches the
|
||||
// state afterwards — the extra message-loop turn a ForceQueue costs was the
|
||||
// whole flicker, one painted normal-sized frame before the correction landed.
|
||||
ApplyWantedWindowState;
|
||||
// Second pass anyway, in case FMX sizes the window after us on some path.
|
||||
// Idempotent: it only acts when the state still disagrees.
|
||||
TThread.ForceQueue(nil,
|
||||
procedure
|
||||
begin
|
||||
ApplyWantedWindowState;
|
||||
// Release the guard only after our own ShowWindow's resize has passed.
|
||||
TThread.ForceQueue(nil, procedure begin FIgnoreResizes := False; end);
|
||||
end);
|
||||
end;
|
||||
|
||||
procedure TMainForm.ApplyWantedWindowState;
|
||||
var
|
||||
LHwnd: HWND;
|
||||
begin
|
||||
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
|
||||
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
|
||||
if FWantMaximized and (not IsZoomed(LHwnd)) then
|
||||
begin
|
||||
LogLine('[winstate] re-maximising after restore');
|
||||
ShowWindow(LHwnd, SW_SHOWMAXIMIZED);
|
||||
end
|
||||
else if (not FWantMaximized) and IsZoomed(LHwnd) then
|
||||
begin
|
||||
LogLine('[winstate] restoring to normal after restore');
|
||||
ShowWindow(LHwnd, SW_RESTORE);
|
||||
end;
|
||||
end;
|
||||
|
||||
// Track the state the user settles on. Ignored entirely while a minimise /
|
||||
// restore is in flight — that is the whole point of the hook above.
|
||||
procedure TMainForm.FormResizeTrackState(Sender: TObject);
|
||||
var
|
||||
LHwnd: HWND;
|
||||
begin
|
||||
if FIgnoreResizes then Exit;
|
||||
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
|
||||
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
|
||||
if FWantMaximized <> IsZoomed(LHwnd) then
|
||||
begin
|
||||
FWantMaximized := IsZoomed(LHwnd);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TMainForm.AutofillTimerTick(Sender: TObject);
|
||||
var
|
||||
TargetHwnd: HWND;
|
||||
@@ -1492,9 +1969,18 @@ begin
|
||||
var LFillOk := FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass,
|
||||
UserOnly, not HideAfter, ClearFirst);
|
||||
ForegroundAfter := GetForegroundWindow;
|
||||
LogLine(Format('Autofill executed — target=%s, foreground_after=%s, ok=%s',
|
||||
// SECOND SEGMENT, et le total. La difference entre les deux chiffres est le
|
||||
// cout de la frappe : minimisation, prise du premier plan, attente de la
|
||||
// relache des modificateurs, puis SendInput.
|
||||
LogLine(Format('Autofill executed — target=%s, foreground_after=%s, ok=%s, ' +
|
||||
'total=%d ms',
|
||||
[IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8),
|
||||
BoolToStr(LFillOk, True)]));
|
||||
BoolToStr(LFillOk, True), FAutofillClock.ElapsedMilliseconds]));
|
||||
FAutofillClock.Stop;
|
||||
// Le detail des deux attentes internes : sans lui, un total de trois secondes
|
||||
// ne dit pas LAQUELLE a enfle, et les deux se corrigent differemment.
|
||||
if FBridge.LastFillWaits <> '' then
|
||||
LogLine('Autofill waits — ' + FBridge.LastFillWaits);
|
||||
// Tell JS whether the keystrokes were actually sent — the success toast
|
||||
// must not lie when the target runs elevated (UIPI drops our input).
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
@@ -1514,7 +2000,8 @@ begin
|
||||
// confusing the Win10/11 foreground-stealing watchdog.
|
||||
if HideAfter then
|
||||
begin
|
||||
FBridge.MinimizeToTray;
|
||||
// The filled window must keep the foreground we just handed it.
|
||||
FBridge.MinimizeToTray(True, False);
|
||||
LogLine('Hidden back to tray (post-autofill).');
|
||||
end;
|
||||
end;
|
||||
@@ -1525,19 +2012,31 @@ var
|
||||
LTitle, LKind: string;
|
||||
begin
|
||||
if not FServer.Active then Exit;
|
||||
if NativeModalActive then Exit;
|
||||
FAutofillTargetHWND := ATargetHWND;
|
||||
// Tray-hidden only, like the quick-search path: a window merely minimised to
|
||||
// the taskbar must not come back as a tray icon it never was.
|
||||
FAutofillHotkeyWasHidden := not Self.Visible;
|
||||
|
||||
// Escape the title for safe injection into a JS string literal.
|
||||
LTitle := ATitle;
|
||||
LTitle := LTitle.Replace('\', '\\');
|
||||
LTitle := LTitle.Replace('"', '\"');
|
||||
|
||||
if AKind = akPasswordOnly then LKind := 'password' else LKind := 'full';
|
||||
case AKind of
|
||||
akPasswordOnly: LKind := 'password';
|
||||
akTotp: LKind := 'totp';
|
||||
else LKind := 'full';
|
||||
end;
|
||||
|
||||
// Arme AVANT l'appel : ce qui suit se passe dans le moteur, et c'est
|
||||
// justement ce temps-la qu'on cherche a voir.
|
||||
FAutofillClock := TStopwatch.StartNew;
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' +
|
||||
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")');
|
||||
LogLine('Autofill hotkey (' + LKind + ') — foreground: "' + ATitle + '"');
|
||||
LogLine('Autofill ' + IfThen(FBridge.LastRequestFromBar, 'fill bar', 'hotkey') +
|
||||
' (' + LKind + ') — foreground: "' + ATitle + '"');
|
||||
end;
|
||||
|
||||
initialization
|
||||
|
||||
@@ -155,6 +155,59 @@ if ($jsFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Brouillage des blocs embarques -------------------------------------------
|
||||
#
|
||||
# CE N'EST PAS DU SECRET, C'EST DE L'OPACITE. La cle vit dans l'exe, donc qui
|
||||
# tient le binaire retrouve le texte. Ce que ca supprime, c'est l'onglet
|
||||
# "Image" de Process Explorer et n'importe quel `strings` : le frontend n'est
|
||||
# plus lisible en survolant le fichier. La memoire du processus, un debogueur
|
||||
# ou la reponse HTTP le rendent toujours - WebView2 doit recevoir du
|
||||
# JavaScript en clair pour l'executer.
|
||||
#
|
||||
# L'AUTRE MOITIE EST DANS PM.EmbeddedAssets.pas ET LES DEUX DOIVENT S'ACCORDER :
|
||||
#
|
||||
# bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
|
||||
# sortie = XOR( magie 'PMA1' + contenu du fichier )
|
||||
#
|
||||
# La magie prefixe le CLAIR : elle permet a l'exe de reconnaitre une cle qui a
|
||||
# derive et de refuser, au lieu de servir du binaire au navigateur (ecran blanc
|
||||
# sans trace). Symptome d'un desaccord : /index.html en 404.
|
||||
$ObfSecret = [byte[]]@(
|
||||
0x9A,0xAC,0xC6,0x05,0xDE,0xD6,0xFA,0xE7,
|
||||
0x4F,0x95,0x04,0xE4,0x2C,0x1D,0xDF,0x26,
|
||||
0x9B,0xBA,0xC9,0xD2,0x0A,0x52,0xD5,0x90,
|
||||
0xB1,0x5E,0xA8,0x09,0x12,0x9B,0x9E,0x6C
|
||||
)
|
||||
$ObfMagic = [Text.Encoding]::ASCII.GetBytes('PMA1')
|
||||
|
||||
function Obfuscate([byte[]] $plain, [string] $resName) {
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
$seed = $ObfSecret + [Text.Encoding]::UTF8.GetBytes($resName)
|
||||
$out = New-Object byte[] $plain.Length
|
||||
$i = 0; $idx = 0
|
||||
while ($i -lt $plain.Length) {
|
||||
$block = $sha.ComputeHash($seed + [BitConverter]::GetBytes([uint32]$idx))
|
||||
for ($j = 0; $j -lt $block.Length -and ($i + $j) -lt $plain.Length; $j++) {
|
||||
$out[$i + $j] = $plain[$i + $j] -bxor $block[$j]
|
||||
}
|
||||
$i += $block.Length; $idx++
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
# Les blocs brouilles vont dans un dossier a part : brcc32 lit ce que le .rc
|
||||
# lui montre, et on ne veut surtout pas ecraser les sources en clair.
|
||||
$ObfDir = Join-Path $ScriptDir 'obf'
|
||||
if (Test-Path $ObfDir) { Remove-Item $ObfDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $ObfDir | Out-Null
|
||||
foreach ($f in $files) {
|
||||
$plain = $ObfMagic + [System.IO.File]::ReadAllBytes($f.FullPath)
|
||||
$dest = Join-Path $ObfDir ($f.ResName + '.bin')
|
||||
[System.IO.File]::WriteAllBytes($dest, (Obfuscate $plain $f.ResName))
|
||||
$f | Add-Member -NotePropertyName ObfPath -NotePropertyValue $dest -Force
|
||||
}
|
||||
Log "Brouille $($files.Count) bloc(s) dans $ObfDir"
|
||||
|
||||
# --- Generate assets.rc -------------------------------------------------------
|
||||
$rc = New-Object System.Text.StringBuilder
|
||||
[void]$rc.AppendLine('// Auto-generated by BuildAssets.ps1 - do not edit by hand.')
|
||||
@@ -162,7 +215,7 @@ $rc = New-Object System.Text.StringBuilder
|
||||
[void]$rc.AppendLine('')
|
||||
foreach ($f in $files) {
|
||||
# brcc32 accepts forward slashes; backslashes need to be doubled in C strings
|
||||
$rcPath = $f.FullPath -replace '\\','\\'
|
||||
$rcPath = $f.ObfPath -replace '\\','\\'
|
||||
[void]$rc.AppendLine("$($f.ResName) RCDATA `"$rcPath`"")
|
||||
}
|
||||
[System.IO.File]::WriteAllText($OutRC, $rc.ToString(), [System.Text.Encoding]::ASCII)
|
||||
|
||||
+87
-66
@@ -85,6 +85,34 @@
|
||||
<!-- ============================================================ -->
|
||||
<!-- AUTH SCREEN (login + register) -->
|
||||
<!-- ============================================================ -->
|
||||
<!-- ============================================================ -->
|
||||
<!-- LICENCE GATE (avant tout, cf. ensureLicence) -->
|
||||
<!-- ============================================================ -->
|
||||
<section id="licenceScreen" class="auth-screen is-hidden">
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand">
|
||||
<svg class="auth-brand-icon"><use href="#i-lock"/></svg>
|
||||
<h1>Activate Vault</h1>
|
||||
<p>Paste the licence key you were sent.</p>
|
||||
</div>
|
||||
|
||||
<div id="licenceHint" class="auth-hint"></div>
|
||||
|
||||
<form id="licenceForm" class="auth-form" autocomplete="off">
|
||||
<label class="field">
|
||||
<span>Licence key</span>
|
||||
<input id="licenceKey" type="text" spellcheck="false" required>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-block">Activate</button>
|
||||
</form>
|
||||
|
||||
<div class="licence-machine">
|
||||
<span>Installation ID — send this to get your key</span>
|
||||
<code id="licenceMachine">—</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="authScreen" class="auth-screen">
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand">
|
||||
@@ -241,6 +269,14 @@
|
||||
<svg><use href="#i-list"/></svg>
|
||||
<span>Audit log</span>
|
||||
</button>
|
||||
<!-- SYNCHRONISER EST UNE ACTION, PAS UN REGLAGE. Elle n'existait
|
||||
qu'au fond du panneau des reglages, a cote de l'URL et du
|
||||
jeton : on l'y configure une fois, on la declenche tous les
|
||||
jours. Sa place est ici, avec les autres gestes. -->
|
||||
<button class="nav-item" id="sidebarSyncBtn">
|
||||
<svg><use href="#i-rotate-ccw"/></svg>
|
||||
<span>Sync now</span>
|
||||
</button>
|
||||
<button class="nav-item" id="sidebarImportBtn">
|
||||
<svg><use href="#i-log-in"/></svg>
|
||||
<span>Import vault</span>
|
||||
@@ -286,12 +322,6 @@
|
||||
<svg><use href="#i-table"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cols-wrap" id="colsWrap" style="display:none;position:relative">
|
||||
<button class="icon-btn" id="colsBtn" title="Show / hide columns">
|
||||
<svg><use href="#i-settings"/></svg>
|
||||
</button>
|
||||
<div class="cols-menu is-hidden" id="colsMenu"></div>
|
||||
</div>
|
||||
<div class="filters-wrap">
|
||||
<button class="icon-btn" id="filtersBtn" title="Filters">
|
||||
<svg><use href="#i-list"/></svg>
|
||||
@@ -681,12 +711,24 @@
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillPwdCombo">Ctrl+Shift+P</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span style="font-size:12px;color:var(--text-dim)">
|
||||
2FA code only (click the 2FA field first)
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillTotpCombo">Ctrl+Shift+T</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span style="font-size:12px;color:var(--text-dim)">
|
||||
Quick search picker (find entry, fill from anywhere)
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingQuickSearchCombo">Ctrl+Shift+Q</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span style="font-size:12px;color:var(--text-dim)">
|
||||
New entry from the foreground window title
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingNewEntryCombo">Ctrl+Shift+A</button>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
|
||||
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
|
||||
</p>
|
||||
@@ -750,10 +792,12 @@
|
||||
</div>
|
||||
|
||||
<div class="slideover-field" id="syncField" style="display:none">
|
||||
<div class="slideover-field-label">Sync (WebDAV)</div>
|
||||
<div class="slideover-field-label">Sync (WebDAV or Gitea)</div>
|
||||
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
|
||||
Bidirectional sync with a WebDAV server (Nextcloud,
|
||||
ownCloud, Apache mod_dav). Auto-merge by entry
|
||||
ownCloud, Apache mod_dav) <b>or a private Gitea
|
||||
repository</b> — paste a contents-API URL and it switches
|
||||
transport on its own. Auto-merge by entry
|
||||
timestamp. <b>Master password is never sent to the
|
||||
remote</b> — the snapshot is encrypted with a separate
|
||||
sync password you set once per device (same on each).
|
||||
@@ -764,21 +808,28 @@
|
||||
</label>
|
||||
<div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
|
||||
<div style="margin-bottom:6px">
|
||||
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">WebDAV URL (full path to the snapshot file)</div>
|
||||
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">URL — full path to the snapshot file, or a Gitea contents path</div>
|
||||
<input type="text" id="syncUrl" class="so-input"
|
||||
placeholder="https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json"
|
||||
placeholder="https://git.example.com/api/v1/repos/OWNER/REPO/contents/vault-sync.json"
|
||||
style="width:100%;font-size:12px">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px">
|
||||
<label style="flex:1">
|
||||
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Username</div>
|
||||
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Username <span style="opacity:.7">(WebDAV only)</span></div>
|
||||
<input type="text" id="syncUser" class="so-input" style="width:100%">
|
||||
</label>
|
||||
<label style="flex:1">
|
||||
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Password / app token</div>
|
||||
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Password / token</div>
|
||||
<input type="password" id="syncPwd" class="so-input" style="width:100%">
|
||||
</label>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
|
||||
On Gitea the username is ignored and the token needs
|
||||
<b>write:repository</b> — a read-only one can pull but
|
||||
never push. Each sync is a commit, and Git history is
|
||||
kept: an old sync password stays attackable in earlier
|
||||
versions, so prune the history if you rotate it.
|
||||
</p>
|
||||
<div style="margin-bottom:8px">
|
||||
<button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button>
|
||||
</div>
|
||||
@@ -939,6 +990,30 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slideover-field" id="settingLicenceSection">
|
||||
<div class="slideover-field-label">Licence</div>
|
||||
<div class="setting-row">
|
||||
<span>Licensed to</span>
|
||||
<strong id="settingLicenceHolder">—</strong>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span>Email</span>
|
||||
<span id="settingLicenceEmail" style="color:var(--text-dim)">—</span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span>Issued</span>
|
||||
<span id="settingLicenceIssued" style="color:var(--text-dim)">—</span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span>Installation ID</span>
|
||||
<code id="settingLicenceMachine" style="font-family:ui-monospace,Consolas,monospace;user-select:all">—</code>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm is-danger" id="settingLicenceClear" hidden
|
||||
style="margin-top:8px">
|
||||
Deactivate (debug)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1013,60 +1088,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MODAL: New / Edit Entry -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="entryModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel">
|
||||
<header class="modal-header">
|
||||
<h3 id="entryModalTitle">New entry</h3>
|
||||
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<form id="entryForm" class="modal-body" autocomplete="off">
|
||||
<input type="hidden" id="entryId">
|
||||
<label class="field">
|
||||
<span>Display name <small style="color:var(--text-faint);font-weight:400">— shown on cards, also used for autofill matching</small></span>
|
||||
<input id="entryTitle" type="text" placeholder="GitHub (work account)">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Website / App <small style="color:var(--text-faint);font-weight:400">— hostname or brand, used for autofill matching</small></span>
|
||||
<input id="entrySite" type="text" required placeholder="github.com">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Username / Email</span>
|
||||
<input id="entryUsername" type="text" placeholder="me@example.com">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<div class="input-with-action">
|
||||
<input id="entryPassword" type="password" required placeholder="••••••••">
|
||||
<button type="button" class="icon-btn" id="entryPwToggle"><svg><use href="#i-eye"/></svg></button>
|
||||
<button type="button" class="icon-btn" id="entryPwGen" title="Generate"><svg><use href="#i-dice"/></svg></button>
|
||||
</div>
|
||||
<div class="strength-bar" id="entryStrengthBar"></div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Folder</span>
|
||||
<select id="entryFolder"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Tags <span class="hint">enter to add</span></span>
|
||||
<div class="chip-input" id="entryTagsInput">
|
||||
<input id="entryTagsField" type="text" placeholder="add a tag…" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" id="entryTags">
|
||||
</label>
|
||||
</form>
|
||||
<footer class="modal-footer">
|
||||
<button class="btn btn-ghost" data-close>Cancel</button>
|
||||
<button class="btn btn-primary" id="entrySaveBtn">
|
||||
<svg><use href="#i-check"/></svg> Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MODAL: Password Generator -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
+107
-6
@@ -12,6 +12,43 @@
|
||||
// Win32 modifier flags for RegisterHotKey.
|
||||
const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 };
|
||||
|
||||
// The five configurable global hotkeys: slot id → Settings button + label.
|
||||
// Delphi reports registration failures by slot id; this is the single place
|
||||
// mapping them to UI, so a new hotkey only has to be added here.
|
||||
const HOTKEY_SLOTS = {
|
||||
full: { btn: '#settingAutofillFullCombo', label: 'Autofill (full)' },
|
||||
password: { btn: '#settingAutofillPwdCombo', label: 'Password only' },
|
||||
totp: { btn: '#settingAutofillTotpCombo', label: '2FA code' },
|
||||
quickSearch: { btn: '#settingQuickSearchCombo', label: 'Quick search' },
|
||||
newEntry: { btn: '#settingNewEntryCombo', label: 'New entry' },
|
||||
};
|
||||
|
||||
// Flag the Settings rows whose combo Windows refused. Re-applied whenever the
|
||||
// panel is (re)rendered, so the warning outlives the toast.
|
||||
function renderHotkeyConflicts() {
|
||||
const bad = new Set(state.hotkeyConflicts || []);
|
||||
for (const [slot, def] of Object.entries(HOTKEY_SLOTS)) {
|
||||
const btn = document.querySelector(def.btn);
|
||||
if (!btn) continue;
|
||||
const clash = bad.has(slot);
|
||||
btn.classList.toggle('is-conflict', clash);
|
||||
if (clash) btn.title = 'This combo is already used by another app — ' +
|
||||
'the hotkey does nothing. Pick another one.';
|
||||
else btn.removeAttribute('title');
|
||||
}
|
||||
// Dot on the Settings button: the ⚠ inside the panel only helps someone who
|
||||
// opens it, and the toast may well have been missed. This is the only cue
|
||||
// left in the main UI that a hotkey is silently dead.
|
||||
const gear = document.getElementById('settingsBtn');
|
||||
if (gear) {
|
||||
gear.classList.toggle('has-alert', bad.size > 0);
|
||||
if (bad.size > 0)
|
||||
gear.title = 'A hotkey combo is already used by another app — see Autofill settings';
|
||||
else if (gear.title.startsWith('A hotkey combo'))
|
||||
gear.title = 'Settings';
|
||||
}
|
||||
}
|
||||
|
||||
// Format a combo for human display: "Ctrl+Shift+L".
|
||||
function autofillComboLabel(c) {
|
||||
if (!c || !c.key) return '— not set —';
|
||||
@@ -78,15 +115,25 @@ function autofillCaptureFromEvent(e) {
|
||||
|
||||
// Push current state to Delphi (toggle + both combos) and persist.
|
||||
// Called after any change so Delphi's RegisterHotKey reflects state.
|
||||
function autofillPushHotkeys() {
|
||||
// Set by the startup push so the Delphi callback can stay quiet about
|
||||
// success — "Hotkeys updated" on every unlock is noise. A CLASH is still
|
||||
// reported: it's actionable and the user has no other way to learn about it.
|
||||
let hotkeyPushSilent = false;
|
||||
|
||||
function autofillPushHotkeys(silent) {
|
||||
hotkeyPushSilent = !!silent;
|
||||
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
|
||||
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
|
||||
localStorage.setItem('autofillHotkeyTotp', JSON.stringify(state.autofillHotkeyTotp));
|
||||
localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey));
|
||||
localStorage.setItem('newEntryHotkey', JSON.stringify(state.newEntryHotkey));
|
||||
if (Bridge.active) {
|
||||
Bridge.setAutofillHotkeys(state.autofillEnabled, {
|
||||
full: state.autofillHotkeyFull,
|
||||
password: state.autofillHotkeyPwd,
|
||||
totp: state.autofillHotkeyTotp,
|
||||
quickSearch: state.quickSearchHotkey,
|
||||
newEntry: state.newEntryHotkey,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -189,10 +236,27 @@ function autofillReportResult(ok) {
|
||||
async function autofillHandleRequest(windowTitle, kind) {
|
||||
if (!state.autofillEnabled) return;
|
||||
|
||||
// A blocking operation owns the screen (export, sync, rotation). Covers all
|
||||
// three hotkeys and both outcomes: the multi-match picker would open behind
|
||||
// the overlay, unreachable, and a single-match fill would minimise our
|
||||
// window and hand the foreground away mid-operation. cancelAutofill so
|
||||
// Delphi doesn't keep the captured target HWND for a fill that never comes.
|
||||
if (typeof isBusy === 'function' && isBusy()) {
|
||||
// refocus=false: refusing must not move any window. During an export a
|
||||
// native Save dialog holds the foreground, and shoving it behind leaves
|
||||
// the app disabled by a modal the user can no longer find.
|
||||
Bridge.cancelAutofill(false);
|
||||
toast('Busy — try again in a moment', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.cryptoKey || state.locked || !state.token) {
|
||||
// Vault is locked — bring the app to the front so the user can
|
||||
// unlock immediately, rather than silently no-op'ing the hotkey.
|
||||
Bridge.cancelAutofill();
|
||||
// refocus=false: the default hands the foreground back to the target,
|
||||
// which focusApp would then have to take away again — two window moves
|
||||
// to end where one gets us, and the round trip is visible.
|
||||
Bridge.cancelAutofill(false);
|
||||
Bridge.focusApp();
|
||||
setTimeout(() => {
|
||||
const pwd = document.getElementById('loginPassword');
|
||||
@@ -207,13 +271,22 @@ async function autofillHandleRequest(windowTitle, kind) {
|
||||
}
|
||||
|
||||
const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase();
|
||||
const scored = state.entries
|
||||
// The 2FA hotkey can only serve entries that actually carry a TOTP secret
|
||||
// — filtering here (not after scoring) also means the picker never lists a
|
||||
// candidate that would fail once chosen.
|
||||
const pool = (kind === 'totp')
|
||||
? state.entries.filter(e => e.totp_secret && e.totp_iv)
|
||||
: state.entries;
|
||||
const scored = pool
|
||||
.map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
|
||||
.filter(x => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (scored.length === 0) {
|
||||
toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning');
|
||||
toast(kind === 'totp'
|
||||
? 'No 2FA code stored for "' + windowTitle.slice(0, 40) + '"'
|
||||
: 'Autofill: no match for "' + windowTitle.slice(0, 40) + '"',
|
||||
'warning');
|
||||
Bridge.cancelAutofill();
|
||||
return;
|
||||
}
|
||||
@@ -228,8 +301,34 @@ async function autofillHandleRequest(windowTitle, kind) {
|
||||
openAutofillPicker(scored.map(x => x.entry), windowTitle, kind);
|
||||
}
|
||||
|
||||
// Decrypt and type an entry. kind = 'full' or 'password'.
|
||||
// Decrypt and type an entry. kind = 'full' | 'password' | 'totp'.
|
||||
async function autofillFillEntry(entry, kind) {
|
||||
// 2FA: type the current 6-digit code instead of the password. Sent in the
|
||||
// password slot with an empty username, so Delphi's password-only path
|
||||
// types it as-is — no new native code path.
|
||||
if (kind === 'totp') {
|
||||
const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv);
|
||||
if (!secret || secret === '[ERROR]') {
|
||||
toast('2FA: cannot decrypt the secret', 'error');
|
||||
Bridge.cancelAutofill();
|
||||
return;
|
||||
}
|
||||
let code;
|
||||
try { code = (await generateTOTP(secret)).code; }
|
||||
catch (e) {
|
||||
toast('2FA: invalid secret for ' + entryDisplayName(entry), 'error');
|
||||
Bridge.cancelAutofill();
|
||||
return;
|
||||
}
|
||||
autofillPendingToast = '2FA code filled: ' + entryDisplayName(entry);
|
||||
Bridge.executeAutofill('', code);
|
||||
fetch('' + '/audit', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ action: 'autofill_totp', site: entry.site }),
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const password = await decryptPwd(entry.encrypted_password, entry.iv);
|
||||
if (password === '[ERROR]') {
|
||||
toast('Autofill: decryption error', 'error');
|
||||
@@ -283,7 +382,9 @@ function openAutofillPicker(entries, windowTitle, kind) {
|
||||
}
|
||||
list.appendChild(btn);
|
||||
});
|
||||
const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ')
|
||||
const head = (kind === 'totp' ? 'Pick entry (2FA code) — '
|
||||
: kind === 'password' ? 'Pick entry (password only) — '
|
||||
: 'Pick entry — ')
|
||||
+ entries.length + ' match "' + windowTitle.slice(0, 30) + '…"';
|
||||
$('#autofillPickerTitle').textContent = head;
|
||||
$('#autofillPickerModal').classList.remove('is-hidden');
|
||||
|
||||
+21
-48
@@ -24,15 +24,21 @@ const ABK = {
|
||||
};
|
||||
const AUTO_BACKUP_PREFIX = 'vault-autobackup-';
|
||||
|
||||
async function loadAutoBackupConfig() {
|
||||
// withPwd defaults to FALSE on purpose: the UI only ever needs to know whether
|
||||
// a backup password exists. Pulling the value across the bridge leaves it in
|
||||
// the renderer for the life of the process — verified in a memory dump, where
|
||||
// it was still there after the vault had auto-locked and the vault key was
|
||||
// gone. Only the two paths that actually encrypt a backup ask for it.
|
||||
async function loadAutoBackupConfig(withPwd) {
|
||||
if (!Bridge.active) return null;
|
||||
const [enabled, dir, interval, keep, last, pwd] = await Promise.all([
|
||||
const [enabled, dir, interval, keep, last, pwd, hasPwd] = await Promise.all([
|
||||
Bridge.getPref(ABK.enabled),
|
||||
Bridge.getPref(ABK.dir),
|
||||
Bridge.getPref(ABK.interval),
|
||||
Bridge.getPref(ABK.keep),
|
||||
Bridge.getPref(ABK.last),
|
||||
Bridge.getPref(ABK.pwd),
|
||||
withPwd ? Bridge.getPref(ABK.pwd) : Promise.resolve(''),
|
||||
Bridge.hasPref(ABK.pwd),
|
||||
]);
|
||||
return {
|
||||
enabled: enabled === '1',
|
||||
@@ -40,7 +46,7 @@ async function loadAutoBackupConfig() {
|
||||
interval: Math.max(1, parseInt(interval, 10) || 7),
|
||||
keep: Math.max(1, parseInt(keep, 10) || 10),
|
||||
last: last || '',
|
||||
hasPwd: !!pwd,
|
||||
hasPwd: !!hasPwd,
|
||||
pwd,
|
||||
};
|
||||
}
|
||||
@@ -139,7 +145,8 @@ async function changeBackupPwd() {
|
||||
}
|
||||
|
||||
async function runAutoBackupNow(silent) {
|
||||
const cfg = await loadAutoBackupConfig();
|
||||
// withPwd: this is the one place that encrypts a backup.
|
||||
const cfg = await loadAutoBackupConfig(true);
|
||||
if (!cfg) return silent || toast('Bridge not available', 'error');
|
||||
if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning');
|
||||
if (!cfg.hasPwd) return silent || toast('Backup password not set', 'warning');
|
||||
@@ -168,53 +175,17 @@ async function runAutoBackupNow(silent) {
|
||||
avatar_b64: state.avatarDataUri || '',
|
||||
entries: [],
|
||||
};
|
||||
// Same container as the manual export, trash included — see
|
||||
// buildBackupEntry (app.import.js), which both paths share.
|
||||
await loadTrash();
|
||||
const toBackup = state.entries.concat(state.trashed || []);
|
||||
let _bkDone = 0;
|
||||
const _bkTotal = state.entries.length;
|
||||
for (const e of state.entries) {
|
||||
const _bkTotal = toBackup.length;
|
||||
for (const e of toBackup) {
|
||||
_bkDone++;
|
||||
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
|
||||
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
let plainTotp = '';
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||||
}
|
||||
let plainCustom = [];
|
||||
if (e.custom_fields && e.custom_fields_iv) {
|
||||
try { plainCustom = await decryptCustomFields(
|
||||
e.custom_fields, e.custom_fields_iv); }
|
||||
catch (_) { plainCustom = []; }
|
||||
}
|
||||
let attachments = [];
|
||||
try {
|
||||
const metas = await api('/entries/' + e.id + '/attachments',
|
||||
{ headers: authHeaders() });
|
||||
for (const m of (metas || [])) {
|
||||
const full = await api('/attachments/' + m.id,
|
||||
{ headers: authHeaders() });
|
||||
const bytes = await decryptBlobBytes(
|
||||
full.encrypted_blob, full.iv);
|
||||
attachments.push({
|
||||
filename: m.filename,
|
||||
mime: m.mime,
|
||||
size_bytes: m.size_bytes,
|
||||
content_b64: bytesToBase64(bytes),
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
payload.entries.push({
|
||||
uuid: e.uuid || '',
|
||||
site: e.site, title: e.title || '', username: e.username,
|
||||
password: plain, folder: e.folder, tags: parseTags(e.tags),
|
||||
favorite: !!e.favorite, totp_secret: plainTotp,
|
||||
kind: e.kind || 'login',
|
||||
template: e.template || '',
|
||||
custom_fields: plainCustom,
|
||||
attachments: attachments,
|
||||
icon_b64: e.icon_b64 || '',
|
||||
created_at: e.created_at, updated_at: e.updated_at,
|
||||
});
|
||||
payload.entries.push(await buildBackupEntry(e));
|
||||
}
|
||||
if (!silent) updateBusy('Encrypting backup…');
|
||||
const container = await encryptExportPayload(payload, cfg.pwd);
|
||||
@@ -262,6 +233,8 @@ async function applyAutoBackupRetention(dir, keep) {
|
||||
|
||||
async function runAutoBackupIfDue() {
|
||||
if (!Bridge.active) return;
|
||||
// No password yet: the due-check reads only metadata. runAutoBackupNow
|
||||
// fetches the secret itself, and only if a run is actually starting.
|
||||
const cfg = await loadAutoBackupConfig();
|
||||
if (!cfg || !cfg.enabled || !cfg.dir || !cfg.hasPwd) return;
|
||||
if (!state.cryptoKey) return;
|
||||
|
||||
+12
-18
@@ -118,9 +118,14 @@ async function deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams) {
|
||||
|
||||
async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams) {
|
||||
const keyBytes = await deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams);
|
||||
// extractable:true is REQUIRED — Quick Unlock, PIN, recovery-code and
|
||||
// master-password change all exportKey('raw') the vault key to (re)wrap or
|
||||
// re-derive it. The CryptoKey keeps its own copy of the bytes; scrub ours
|
||||
// from the heap so the raw key doesn't linger as a stray Uint8Array.
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
||||
const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo);
|
||||
keyBytes.fill(0);
|
||||
return { cryptoKey, verifier };
|
||||
}
|
||||
|
||||
@@ -152,21 +157,10 @@ async function decryptPwd(encB64, ivB64) {
|
||||
}
|
||||
}
|
||||
|
||||
async function persistCryptoKey() {
|
||||
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
|
||||
sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw))));
|
||||
}
|
||||
|
||||
async function restoreCryptoKey() {
|
||||
const saved = sessionStorage.getItem('cryptoKey');
|
||||
if (!saved) return false;
|
||||
try {
|
||||
const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
|
||||
state.cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// NB: the vault key is deliberately NOT cached anywhere it outlives the live
|
||||
// CryptoKey. An earlier persistCryptoKey() stored the raw key base64 in
|
||||
// sessionStorage to survive a page reload — but the WebView2 shell has no
|
||||
// reload path (F5/Ctrl+R blocked, all navigations are cmd:// bridge calls),
|
||||
// so that copy only ever served as a plaintext, grep-able duplicate of the
|
||||
// master key in renderer memory. A cold start re-derives via Quick Unlock /
|
||||
// PIN / master-password instead.
|
||||
|
||||
+24
-4
@@ -38,12 +38,15 @@ function faviconHost(siteRaw) {
|
||||
// Save the icon for one entry via the dedicated endpoint (no full PUT,
|
||||
// no re-encryption). Fire-and-forget: failures are silent so a flaky
|
||||
// network doesn't break the user's flow.
|
||||
async function saveEntryIcon(entryId, dataUri) {
|
||||
// isCustom = true for a user upload (preserved from automatic fetch/clear),
|
||||
// false for an auto-fetched favicon or a clear. The server stores it in
|
||||
// icon_custom so bulk fetch/clear can skip deliberate uploads.
|
||||
async function saveEntryIcon(entryId, dataUri, isCustom) {
|
||||
try {
|
||||
const r = await fetch(API + '/entries/' + entryId + '/icon', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ icon_b64: dataUri || '' }),
|
||||
body: JSON.stringify({ icon_b64: dataUri || '', custom: !!isCustom }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
// Surface the server's reason so silent persistence failures
|
||||
@@ -52,7 +55,15 @@ async function saveEntryIcon(entryId, dataUri) {
|
||||
try { const b = await r.json(); if (b && b.error) msg = b.error; }
|
||||
catch (_) {}
|
||||
toast('Icon NOT saved: ' + msg, 'error');
|
||||
return;
|
||||
}
|
||||
// Keep the in-memory row's provenance in sync so backfill / clear
|
||||
// filters see the right value before the next reload.
|
||||
const ent = state.entries.find(x => x.id === entryId);
|
||||
if (ent) ent.icon_custom = (dataUri && isCustom) ? 1 : 0;
|
||||
// Choke point for every icon write (favicon backfill, upload, remove,
|
||||
// import overwrite) — refresh the editor if it's open on this entry.
|
||||
editorAbsorbChange(entryId, { icon: dataUri || '' });
|
||||
} catch (e) {
|
||||
toast('Icon save failed: ' + (e && e.message || e), 'error');
|
||||
}
|
||||
@@ -85,7 +96,9 @@ async function backfillFavicons(force) {
|
||||
const all = state.entries;
|
||||
const eligible = all.filter(e => faviconHost(e.site));
|
||||
const skipped = all.length - eligible.length;
|
||||
const targets = eligible.filter(e => force || !e.icon_b64);
|
||||
// Never overwrite a user upload — not even on "Re-fetch all". Only the
|
||||
// user changes a custom icon, per entry. Missing (no icon) still fetches.
|
||||
const targets = eligible.filter(e => !e.icon_custom && (force || !e.icon_b64));
|
||||
if (targets.length === 0) {
|
||||
if (skipped > 0) {
|
||||
toast('No icons to fetch — ' + skipped +
|
||||
@@ -118,7 +131,14 @@ async function clearAllFavicons() {
|
||||
toast('Failed to clear icons', 'error');
|
||||
return;
|
||||
}
|
||||
state.entries.forEach(e => { e.icon_b64 = null; });
|
||||
// Only the auto-fetched cache is cleared locally — user uploads (custom)
|
||||
// are preserved, mirroring the server's WHERE icon_custom = 0.
|
||||
state.entries.forEach(e => { if (!e.icon_custom) e.icon_b64 = null; });
|
||||
// Refresh the open editor's icon preview if it lost its icon.
|
||||
if (soState && soState.id != null) {
|
||||
const ent = state.entries.find(x => x.id === soState.id);
|
||||
if (ent && !ent.icon_custom) editorAbsorbChange(ent.id, { icon: '' });
|
||||
}
|
||||
render();
|
||||
toast('Cached icons cleared');
|
||||
}
|
||||
|
||||
+40
-2
@@ -93,7 +93,13 @@ function healthScoreBand(score) {
|
||||
// dismiss. Auto-opening the generator modal hid the entry context and
|
||||
// forced an extra Save click — worse UX than this lighter nudge.
|
||||
async function openEntryForFix(entryId) {
|
||||
await openSlideOver(entryId);
|
||||
// Bail if the editor didn't actually switch (user cancelled the discard
|
||||
// prompt): the steps below would unmask + select the password of whatever
|
||||
// entry is still open, and pulse Generate on it — the wrong entry.
|
||||
// noFocus: openSlideOver otherwise defers a focus+select on the title
|
||||
// (setTimeout 80ms) which lands AFTER the password focus below and steals
|
||||
// it back. Fix targets the password — it owns the focus here.
|
||||
if (!(await openSlideOver(entryId, { noFocus: true }))) return;
|
||||
const pwd = document.getElementById('soPassword');
|
||||
if (pwd) {
|
||||
pwd.type = 'text'; // unmask so the user sees what they're replacing
|
||||
@@ -123,7 +129,39 @@ async function renderHealthDashboard(grid) {
|
||||
const scoreEl = el('div', { class: 'health-score ' + band.cls });
|
||||
scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score)));
|
||||
scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label));
|
||||
header.appendChild(scoreEl);
|
||||
// Per-category breakdown. Each category is capped, so once a cap is reached
|
||||
// fixing one more password leaves the total untouched — which reads as "my
|
||||
// work did nothing". Showing the raw deduction next to the capped one makes
|
||||
// the progress visible even when the score sits still.
|
||||
const parts = [
|
||||
{ lbl: 'Weak', n: h.weak.length, per: 5, cap: 40 },
|
||||
{ lbl: 'Reused', n: h.reused.length, per: 10, cap: 30 },
|
||||
{ lbl: 'Old', n: h.old.length, per: 2, cap: 20 },
|
||||
{ lbl: 'Pwned', n: h.pwned.length, per: 15, cap: 50 },
|
||||
];
|
||||
const breakdown = el('div', { class: 'health-score-breakdown' });
|
||||
parts.forEach(p => {
|
||||
const raw = p.n * p.per;
|
||||
const applied = Math.min(p.cap, raw);
|
||||
const row = el('div', {
|
||||
class: 'health-bd-row' + (p.n === 0 ? ' is-clear' : ''),
|
||||
title: raw > applied
|
||||
? p.lbl + ': ' + p.n + ' × ' + p.per + ' = −' + raw +
|
||||
', capped at −' + p.cap + '. Fixing one more won\'t move the ' +
|
||||
'score until you drop below the cap.'
|
||||
: p.lbl + ': ' + p.n + ' × ' + p.per + ' points',
|
||||
});
|
||||
row.appendChild(el('span', { class: 'health-bd-lbl' }, p.lbl));
|
||||
row.appendChild(el('span', { class: 'health-bd-n' }, String(p.n)));
|
||||
row.appendChild(el('span', { class: 'health-bd-pts' },
|
||||
applied === 0 ? '0' : '−' + applied + (raw > applied ? ' (max)' : '')));
|
||||
breakdown.appendChild(row);
|
||||
});
|
||||
// Under the dial, not inside it — the circle is a fixed 110px.
|
||||
const scoreCol = el('div', { class: 'health-score-col' });
|
||||
scoreCol.appendChild(scoreEl);
|
||||
scoreCol.appendChild(breakdown);
|
||||
header.appendChild(scoreCol);
|
||||
const intro = el('div', { class: 'health-intro' });
|
||||
intro.appendChild(el('h3', null, 'How healthy is your vault?'));
|
||||
intro.appendChild(el('p', null,
|
||||
|
||||
+147
-88
@@ -414,8 +414,17 @@ function parseEntriesFromJSON(text) {
|
||||
custom_fields: cf,
|
||||
attachments: atts,
|
||||
icon_b64: String(e.icon_b64 || '').trim(),
|
||||
icon_custom: e.icon_custom ? 1 : 0,
|
||||
// Flags are part of the vault's state: a restore should give the
|
||||
// list back the way it was. Absent (foreign CSV) → 0.
|
||||
favorite: e.favorite ? 1 : 0,
|
||||
pinned: e.pinned ? 1 : 0,
|
||||
created_at: String(e.created_at || '').trim(),
|
||||
updated_at: String(e.updated_at || '').trim(),
|
||||
// Trash state, so a restore rebuilds the trash rather than
|
||||
// resurrecting everything into the live list. Absent → live.
|
||||
deleted: e.deleted ? 1 : 0,
|
||||
deleted_at: String(e.deleted_at || '').trim(),
|
||||
});
|
||||
}
|
||||
return { entries, skipped, columns: null, folders,
|
||||
@@ -439,6 +448,34 @@ function normalizeImportTimestamp(s) {
|
||||
return d.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
/// Rend à `enc` le chiffré d'origine quand le mot de passe n'a pas changé.
|
||||
///
|
||||
/// POURQUOI CE N'EST PAS COSMÉTIQUE. Un conteneur — instantané de sync ou
|
||||
/// sauvegarde — transporte le mot de passe EN CLAIR ; `encryptImportEntry` le
|
||||
/// re-chiffre avec un IV neuf, donc le chiffré diffère TOUJOURS, même quand
|
||||
/// rien n'a bougé. Or le serveur décide d'archiver une version d'historique en
|
||||
/// comparant les chiffrés (`encrypted_password <> :newenc`) : il ne voit que du
|
||||
/// chiffré et croit à un changement. Chaque synchronisation, chaque restauration
|
||||
/// déposait donc un doublon daté du jour, et l'historique montrait la même
|
||||
/// valeur plusieurs fois à des jours d'écart.
|
||||
///
|
||||
/// Seul le client connaît les deux clairs. Il compare ici, et rend le chiffré
|
||||
/// stocké quand ils sont égaux — ce que le commentaire du handler appelle déjà
|
||||
/// « JS reuses originalEncrypted bit-for-bit ». Le garde du serveur redevient
|
||||
/// exact sans qu'il ait à déchiffrer quoi que ce soit.
|
||||
///
|
||||
/// À appeler AVANT tout PUT construit par encryptImportEntry sur une entrée qui
|
||||
/// existe déjà. Les re-publications partielles (tag, dossier, migration) sont
|
||||
/// hors de cause : elles réutilisent déjà le chiffré stocké.
|
||||
async function keepCiphertextIfSamePassword(enc, local, plainPassword) {
|
||||
if (!local || !local.encrypted_password || !local.iv) return enc;
|
||||
const before = await decryptPwd(local.encrypted_password, local.iv);
|
||||
if (before === '[ERROR]' || before !== (plainPassword || '')) return enc;
|
||||
enc.encrypted_password = local.encrypted_password;
|
||||
enc.iv = local.iv;
|
||||
return enc;
|
||||
}
|
||||
|
||||
async function encryptImportEntry(plain) {
|
||||
const pw = await encryptPwd(plain.password);
|
||||
let totpEnc = '', totpIv = '';
|
||||
@@ -487,11 +524,21 @@ async function encryptImportEntry(plain) {
|
||||
custom_fields: cfEnc,
|
||||
custom_fields_iv: cfIv,
|
||||
icon_b64: plain.icon_b64 || '',
|
||||
icon_custom: plain.icon_custom ? 1 : 0,
|
||||
// Passthrough: present in the export/backup container (restore keeps
|
||||
// the flags), absent from the sync snapshot (favorite/pinned stay
|
||||
// device-local by design) → undefined there, so the server writes 0.
|
||||
favorite: plain.favorite ? 1 : 0,
|
||||
pinned: plain.pinned ? 1 : 0,
|
||||
template: plain.template || '',
|
||||
// Preserve original timestamps on restore — bulk-import falls back
|
||||
// to now only when these are absent (foreign CSV imports).
|
||||
created_at: normalizeImportTimestamp(plain.created_at),
|
||||
updated_at: normalizeImportTimestamp(plain.updated_at),
|
||||
// Same passthrough contract: our containers carry the trash, the sync
|
||||
// snapshot never does, a foreign CSV has no such notion → 0 = live.
|
||||
deleted: plain.deleted ? 1 : 0,
|
||||
deleted_at: plain.deleted ? normalizeImportTimestamp(plain.deleted_at) : '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -661,8 +708,15 @@ async function doImport() {
|
||||
// exists — the user is either re-importing a backup or rolling
|
||||
// back to an earlier version). Ask what to do with overlapping
|
||||
// entries so a restore isn't silently blocked by the dedup.
|
||||
// The TRASH counts as "already in the vault". Its rows are absent from
|
||||
// state.entries, so without this a container entry whose uuid is only
|
||||
// in the trash was treated as fresh and inserted a SECOND row with the
|
||||
// same uuid — the identity the whole sync engine relies on. Same blind
|
||||
// spot as the resurrection bug in applyRemoteSnapshot (F8).
|
||||
await loadTrash();
|
||||
const localByUuid = new Map();
|
||||
for (const e of state.entries) if (e && e.uuid) localByUuid.set(e.uuid, e);
|
||||
for (const e of (state.trashed || [])) if (e && e.uuid) localByUuid.set(e.uuid, e);
|
||||
const fresh = [];
|
||||
const overlaps = [];
|
||||
for (const e of parsed.entries) {
|
||||
@@ -707,7 +761,8 @@ async function doImport() {
|
||||
try {
|
||||
const local = localByUuid.get(src.uuid);
|
||||
if (!local) continue;
|
||||
const enc = await encryptImportEntry(src);
|
||||
const enc = await keepCiphertextIfSamePassword(
|
||||
await encryptImportEntry(src), local, src.password);
|
||||
await api('/entries/' + local.id, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -716,7 +771,20 @@ async function doImport() {
|
||||
// PUT ignores icon_b64 (dedicated endpoint owns it), so
|
||||
// restore the file's icon separately — else overwriting
|
||||
// an entry whose icon was cleared never brings it back.
|
||||
if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64);
|
||||
if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64, !!src.icon_custom);
|
||||
// favorite / pinned are the same story: PUT deliberately
|
||||
// ignores them so a SYNC update can never clobber the local
|
||||
// flags (they're device-local by design). But a restore
|
||||
// must bring them back, so apply them here through their
|
||||
// own toggle endpoints — only when they actually differ.
|
||||
if (!!src.favorite !== !!local.favorite) {
|
||||
await api('/entries/' + local.id + '/favorite',
|
||||
{ method: 'POST', headers: authHeaders() });
|
||||
}
|
||||
if (!!src.pinned !== !!local.pinned) {
|
||||
await api('/entries/' + local.id + '/pin',
|
||||
{ method: 'POST', headers: authHeaders() });
|
||||
}
|
||||
overwritten++;
|
||||
} catch (_) { /* skip the single row on failure */ }
|
||||
}
|
||||
@@ -777,6 +845,9 @@ async function doImport() {
|
||||
if (attachCount > 0) toast(attachCount + ' attachment(s) restored');
|
||||
|
||||
await loadEntries();
|
||||
// Trash too: a container can now restore trashed entries, and
|
||||
// without this the Trash count stays stale until the user opens it.
|
||||
await loadTrash();
|
||||
render();
|
||||
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
|
||||
} catch (err) {
|
||||
@@ -787,43 +858,76 @@ async function doImport() {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
// One entry, decrypted and shaped for a backup container. Shared by the manual
|
||||
// export and the auto-backup, which carried two copies of this loop — and would
|
||||
// now need the trash handling added twice.
|
||||
// `deleted` rides along so a restore rebuilds the trash instead of dropping it;
|
||||
// the sync snapshot deliberately does NOT use this (see buildSyncSnapshot: the
|
||||
// trash is device-local, and re-creating trashed rows duplicates them).
|
||||
async function buildBackupEntry(e) {
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
let plainTotp = '';
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||||
}
|
||||
let plainCustom = [];
|
||||
if (e.custom_fields && e.custom_fields_iv) {
|
||||
try {
|
||||
plainCustom = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
|
||||
} catch (_) { plainCustom = []; }
|
||||
}
|
||||
// Attachments cost one extra GET per entry that has them, so vaults
|
||||
// without any pay nothing. A failure here degrades to a partial export
|
||||
// rather than losing the whole file.
|
||||
let attachments = [];
|
||||
try {
|
||||
const metas = await api('/entries/' + e.id + '/attachments',
|
||||
{ headers: authHeaders() });
|
||||
for (const m of (metas || [])) {
|
||||
const full = await api('/attachments/' + m.id, { headers: authHeaders() });
|
||||
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
|
||||
attachments.push({
|
||||
filename: m.filename,
|
||||
mime: m.mime,
|
||||
size_bytes: m.size_bytes,
|
||||
content_b64: bytesToBase64(bytes),
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
uuid: e.uuid || '',
|
||||
site: e.site,
|
||||
title: e.title || '',
|
||||
username: e.username,
|
||||
password: plain,
|
||||
folder: e.folder,
|
||||
tags: parseTags(e.tags),
|
||||
favorite: !!e.favorite,
|
||||
pinned: !!e.pinned,
|
||||
totp_secret: plainTotp,
|
||||
kind: e.kind || 'login',
|
||||
template: e.template || '',
|
||||
custom_fields: plainCustom,
|
||||
attachments: attachments,
|
||||
icon_b64: e.icon_b64 || '',
|
||||
icon_custom: e.icon_custom ? 1 : 0,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
deleted: e.deleted ? 1 : 0,
|
||||
deleted_at: e.deleted_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
// Step 1: reauth — verifies the human in front of the screen is the
|
||||
// vault owner before we hand them every plaintext password. Defense
|
||||
// against a stranger reaching the open laptop and exfiltrating data.
|
||||
// Up to 5 retries with inline error in the modal — beyond that the
|
||||
// server-side rate limiter takes over (429 with retry_after).
|
||||
let lastError = null;
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
let reauthed = false;
|
||||
while (!reauthed) {
|
||||
const masterPwd = await askReauth(
|
||||
'Enter your master password to start an encrypted export.',
|
||||
{ error: lastError });
|
||||
if (!masterPwd) return; // user cancelled
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ verifier: verifier }),
|
||||
});
|
||||
reauthed = true;
|
||||
} catch (err) {
|
||||
if (err.status === 429 && err.body && err.body.retry_after) {
|
||||
return toast('Account locked. Try again in ' +
|
||||
Math.ceil(err.body.retry_after / 60) + ' min', 'warning');
|
||||
}
|
||||
attempts++;
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
return toast('Too many wrong attempts — try again later', 'error');
|
||||
}
|
||||
lastError = 'Wrong master password. Attempt ' +
|
||||
attempts + ' / ' + MAX_ATTEMPTS + '.';
|
||||
}
|
||||
}
|
||||
// reauthLoop re-prompts inline (up to 5 tries) then defers to the
|
||||
// server rate limiter; null = cancelled or capped (already toasted).
|
||||
if (!(await reauthLoop('Enter your master password to start an encrypted export.')))
|
||||
return;
|
||||
|
||||
// Step 2: ask for an INDEPENDENT export password. Decoupled from the
|
||||
// master pw so a master-pw change later doesn't invalidate the backup,
|
||||
@@ -886,63 +990,18 @@ async function doExport() {
|
||||
})),
|
||||
entries: [],
|
||||
};
|
||||
// Trash included: a backup that silently drops it is not a backup.
|
||||
// loadTrash first — state.trashed is only populated when the user has
|
||||
// visited the trash view.
|
||||
await loadTrash();
|
||||
const toExport = state.entries.concat(state.trashed || []);
|
||||
let _expDone = 0;
|
||||
const _expTotal = state.entries.length;
|
||||
for (const e of state.entries) {
|
||||
const _expTotal = toExport.length;
|
||||
for (const e of toExport) {
|
||||
_expDone++;
|
||||
if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal))
|
||||
updateBusy('Reading vault… ' + _expDone + '/' + _expTotal);
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
let plainTotp = '';
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||||
}
|
||||
let plainCustom = [];
|
||||
if (e.custom_fields && e.custom_fields_iv) {
|
||||
try {
|
||||
plainCustom = await decryptCustomFields(
|
||||
e.custom_fields, e.custom_fields_iv);
|
||||
} catch (_) { plainCustom = []; }
|
||||
}
|
||||
// Attachments are fetched separately (one extra GET per entry
|
||||
// that has them) so this branch stays cheap on vaults without.
|
||||
let attachments = [];
|
||||
try {
|
||||
const metas = await api('/entries/' + e.id + '/attachments',
|
||||
{ headers: authHeaders() });
|
||||
for (const m of (metas || [])) {
|
||||
const full = await api('/attachments/' + m.id,
|
||||
{ headers: authHeaders() });
|
||||
const bytes = await decryptBlobBytes(
|
||||
full.encrypted_blob, full.iv);
|
||||
attachments.push({
|
||||
filename: m.filename,
|
||||
mime: m.mime,
|
||||
size_bytes: m.size_bytes,
|
||||
content_b64: bytesToBase64(bytes),
|
||||
});
|
||||
}
|
||||
} catch (_) { /* partial export beats a failed one */ }
|
||||
|
||||
payload.entries.push({
|
||||
uuid: e.uuid || '',
|
||||
site: e.site,
|
||||
title: e.title || '',
|
||||
username: e.username,
|
||||
password: plain,
|
||||
folder: e.folder,
|
||||
tags: parseTags(e.tags),
|
||||
favorite: !!e.favorite,
|
||||
totp_secret: plainTotp,
|
||||
kind: e.kind || 'login',
|
||||
template: e.template || '',
|
||||
custom_fields: plainCustom,
|
||||
attachments: attachments,
|
||||
icon_b64: e.icon_b64 || '',
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
});
|
||||
payload.entries.push(await buildBackupEntry(e));
|
||||
}
|
||||
|
||||
const attachTotal = payload.entries.reduce(
|
||||
|
||||
+14
-1
@@ -187,6 +187,7 @@ const CHEATSHEET_GROUPS = [
|
||||
items: [
|
||||
{ keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' },
|
||||
{ keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' },
|
||||
{ keys: ['Ctrl', 'Shift', 'T'], desc: 'Autofill the 2FA code (click the 2FA field first)' },
|
||||
{ keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' },
|
||||
{ keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' },
|
||||
],
|
||||
@@ -464,6 +465,13 @@ function endTour() {
|
||||
}
|
||||
|
||||
function openQuickSearchModal(hideAfter, forFill) {
|
||||
// Same reason as the editor: the hotkey fires from outside the window, so
|
||||
// the busy overlay covering the app stops nothing — the modal would open
|
||||
// behind it, unreachable.
|
||||
if (typeof isBusy === 'function' && isBusy()) {
|
||||
toast('Busy — try again in a moment', 'warning');
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('quickSearchModal');
|
||||
const input = document.getElementById('quickSearchInput');
|
||||
modal.classList.remove('is-hidden');
|
||||
@@ -501,7 +509,12 @@ function closeQuickSearchModal(keepClipboard) {
|
||||
if (quickSearchHideAfter) {
|
||||
quickSearchHideAfter = false;
|
||||
if (Bridge.active && typeof Bridge.minimizeToTray === 'function')
|
||||
Bridge.minimizeToTray(!!keepClipboard);
|
||||
// Deferred: in fill mode the cancelAutofill above already fired a
|
||||
// cmd:// navigation, and two back-to-back window.location.href
|
||||
// assignments COALESCE — only one survives, so the minimise was
|
||||
// silently dropped and the app stayed on screen. Same pitfall as
|
||||
// the quickunlock/store + pin/clear pair in doChangeMasterPassword.
|
||||
setTimeout(() => Bridge.minimizeToTray(!!keepClipboard), 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+197
-18
@@ -43,8 +43,54 @@ Bridge.onWebdavResult = function(reqId, status, payload, etag) {
|
||||
delete _webdavResolvers[reqId];
|
||||
r({ status: status | 0, payload: payload || '', etag: etag || '' });
|
||||
};
|
||||
// A Gitea repository, recognised by its contents-API path — the same
|
||||
// discriminant the Android client uses, so one URL tells both ends which
|
||||
// transport to speak. Everything below that differs is confined to the two
|
||||
// call sites in runSyncNow(); the bridge itself is shared.
|
||||
function syncIsGitea(url) {
|
||||
return (url || '').toLowerCase().includes('/api/v1/repos/');
|
||||
}
|
||||
|
||||
// Unwraps a Gitea contents response into { container, sha }. The API answers
|
||||
// JSON whose `content` is the file in base64 — so the payload is base64 twice
|
||||
// over: once by our bridge, once by Gitea. Returns null if the JSON is not
|
||||
// the shape we expect, which is what a slightly-wrong URL produces: valid
|
||||
// JSON, but not this.
|
||||
function giteaUnwrap(bodyText) {
|
||||
let obj;
|
||||
try { obj = JSON.parse(bodyText); } catch (e) { return null; }
|
||||
if (!obj || !obj.content || !obj.sha) return null;
|
||||
if (obj.encoding && obj.encoding !== 'base64') return null;
|
||||
try {
|
||||
const raw = obj.content.replace(/[\r\n]/g, ''); // Gitea wraps its base64
|
||||
return { container: new TextDecoder().decode(base64ToBytes(raw)), sha: obj.sha };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// Do these two snapshots carry the same vault? Compared as plaintext, because
|
||||
// the ciphertext never repeats — a fresh salt and IV per encryption make two
|
||||
// containers of an identical vault differ completely.
|
||||
//
|
||||
// `snapshot_at` is stripped: it is the time of the build, not part of what the
|
||||
// vault contains, and keeping it would make every comparison fail. Everything
|
||||
// else is compared verbatim, which is deliberately strict — a difference in key
|
||||
// order or in a field we have not thought about falls on the side of pushing,
|
||||
// which costs a commit rather than losing a change.
|
||||
function syncSnapshotsEqual(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const strip = s => {
|
||||
const c = Object.assign({}, s);
|
||||
delete c.snapshot_at;
|
||||
delete c.exported_at;
|
||||
return JSON.stringify(c);
|
||||
};
|
||||
try { return strip(a) === strip(b); } catch (e) { return false; }
|
||||
}
|
||||
|
||||
// method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so
|
||||
// the server rejects (412) a write when the remote changed since our pull.
|
||||
// On Gitea the sha travels inside the PUT body instead, and the bridge sends
|
||||
// no If-Match.
|
||||
function _webdavCall(method, url, user, pwd, dataB64, opts) {
|
||||
opts = opts || {};
|
||||
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||
@@ -106,10 +152,16 @@ function refreshSyncUI(cfg) {
|
||||
const pre = document.getElementById('syncPreBackup');
|
||||
if (url) url.value = cfg.url || '';
|
||||
if (user) user.value = cfg.user || '';
|
||||
if (pwd) pwd.value = cfg.pwd || '';
|
||||
if (pwd) {
|
||||
// Left EMPTY on purpose — the settings panel no longer pulls the stored
|
||||
// password across. The field only saves on `change`, so an untouched
|
||||
// empty field never overwrites anything; the placeholder says so.
|
||||
pwd.value = cfg.pwd || '';
|
||||
pwd.placeholder = cfg.hasPwd ? 'Saved — type to replace' : '';
|
||||
}
|
||||
if (pre) pre.checked = !!cfg.preBackup;
|
||||
const pwdStatus = document.getElementById('syncPwdStatus');
|
||||
if (pwdStatus) pwdStatus.textContent = cfg.encPwd ? 'Set.' : 'Not set.';
|
||||
if (pwdStatus) pwdStatus.textContent = cfg.hasEncPwd ? 'Set.' : 'Not set.';
|
||||
const last = document.getElementById('syncLast');
|
||||
if (last) last.textContent = cfg.last
|
||||
? ' · Last: ' + cfg.last.replace('T', ' ').slice(0, 16)
|
||||
@@ -146,16 +198,23 @@ async function syncSetEncPwdFlow() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSyncConfig() {
|
||||
// withSecrets defaults to FALSE: the settings panel only needs to know whether
|
||||
// the two passwords are set. Fetching them plants the values in the renderer
|
||||
// for the life of the process — seen in a memory dump, still present after the
|
||||
// vault had auto-locked. Only the paths that actually talk to the server or
|
||||
// encrypt a snapshot ask for them.
|
||||
async function loadSyncConfig(withSecrets) {
|
||||
if (!Bridge.active) return null;
|
||||
const [enabled, url, user, pwd, encPwd, pre, last] = await Promise.all([
|
||||
const [enabled, url, user, pwd, encPwd, pre, last, hasPwd, hasEnc] = await Promise.all([
|
||||
Bridge.getPref(SYNC_PREFS.enabled),
|
||||
Bridge.getPref(SYNC_PREFS.url),
|
||||
Bridge.getPref(SYNC_PREFS.user),
|
||||
Bridge.getPref(SYNC_PREFS.pwd),
|
||||
Bridge.getPref(SYNC_PREFS.encPwd),
|
||||
withSecrets ? Bridge.getPref(SYNC_PREFS.pwd) : Promise.resolve(''),
|
||||
withSecrets ? Bridge.getPref(SYNC_PREFS.encPwd) : Promise.resolve(''),
|
||||
Bridge.getPref(SYNC_PREFS.preBackup),
|
||||
Bridge.getPref(SYNC_PREFS.last),
|
||||
Bridge.hasPref(SYNC_PREFS.pwd),
|
||||
Bridge.hasPref(SYNC_PREFS.encPwd),
|
||||
]);
|
||||
return {
|
||||
enabled: enabled === '1',
|
||||
@@ -163,16 +222,31 @@ async function loadSyncConfig() {
|
||||
user: user || '',
|
||||
pwd: pwd || '',
|
||||
encPwd: encPwd || '',
|
||||
hasPwd: !!hasPwd,
|
||||
hasEncPwd: !!hasEnc,
|
||||
preBackup: pre === '1',
|
||||
last: last || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function syncTestConnection() {
|
||||
const cfg = await loadSyncConfig();
|
||||
const cfg = await loadSyncConfig(true); // needs the WebDAV credentials
|
||||
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
|
||||
toast('Testing connection…');
|
||||
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
|
||||
// A reachable Gitea with a valid token is not enough: a URL pointing one
|
||||
// path segment off answers 200 with JSON that is not a file. Checking the
|
||||
// shape here is what makes the test worth pressing.
|
||||
if (syncIsGitea(cfg.url) && r.status >= 200 && r.status < 300) {
|
||||
let un = null;
|
||||
try {
|
||||
un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || '')));
|
||||
} catch (e) { /* traite comme une forme inattendue */ }
|
||||
return un
|
||||
? toast('Connection OK · file found in the repository')
|
||||
: toast('Reached Gitea, but this URL does not return a file — ' +
|
||||
'expected .../api/v1/repos/OWNER/REPO/contents/FILE', 'error');
|
||||
}
|
||||
if (r.status >= 200 && r.status < 400) {
|
||||
toast('Connection OK (' + r.status + ')');
|
||||
} else if (r.status === 404) {
|
||||
@@ -229,6 +303,29 @@ async function buildSyncSnapshot() {
|
||||
e.custom_fields, e.custom_fields_iv); }
|
||||
catch (_) {}
|
||||
}
|
||||
// TROIS ANCIENNES VERSIONS, PAS VINGT.
|
||||
//
|
||||
// La table en garde vingt, et c'est bien sur le poste : elles y restent
|
||||
// chiffrees colonne par colonne et ne bougent jamais. Dans l'instantane
|
||||
// tout voyage EN CLAIR a l'interieur du conteneur, et redescend dans le
|
||||
// cache d'un telephone que seule la cle de l'appareil scelle - pas le
|
||||
// mot de passe maitre. Un ancien mot de passe reste sensible : il est
|
||||
// souvent encore valide ailleurs.
|
||||
//
|
||||
// L'usage reel de cette liste est court - "je l'ai change recemment,
|
||||
// quel etait l'ancien ?" - et trois le couvrent. Les dix-sept autres
|
||||
// seraient des copies supplementaires sans besoin en face.
|
||||
let history = [];
|
||||
try {
|
||||
const hist = await api('/entries/' + e.id + '/history',
|
||||
{ headers: authHeaders() });
|
||||
for (const h of (hist || []).slice(0, 3)) {
|
||||
const old = await decryptPwd(h.encrypted_password, h.iv);
|
||||
if (old === '[ERROR]') continue;
|
||||
history.push({ password: old, changed_at: h.changed_at });
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
let attachments = [];
|
||||
try {
|
||||
const metas = await api('/entries/' + e.id + '/attachments',
|
||||
@@ -252,15 +349,24 @@ async function buildSyncSnapshot() {
|
||||
password: plain === '[ERROR]' ? '' : plain,
|
||||
folder: e.folder || 'All',
|
||||
tags: parseTags(e.tags),
|
||||
favorite: !!e.favorite,
|
||||
// favorite / pinned deliberately OMITTED — device-local by design
|
||||
// (decided 2026-07-11, like the avatar). encryptImportEntry passes
|
||||
// through whatever the container holds, so leaving them out here
|
||||
// is what keeps them from propagating. Adding them back = they sync.
|
||||
totp_secret: plainTotp,
|
||||
kind: e.kind || 'login',
|
||||
template: e.template || '',
|
||||
custom_fields: plainCustom,
|
||||
attachments,
|
||||
icon_b64: e.icon_b64 || '',
|
||||
icon_custom: e.icon_custom ? 1 : 0,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
// Lecture seule pour les autres appareils : la table du poste est
|
||||
// la source, et applyRemoteSnapshot ignore ce champ. Il survit aux
|
||||
// ecritures du telephone parce que celui-ci modifie le JSON recu
|
||||
// sans jamais le reserialiser.
|
||||
password_history: history,
|
||||
});
|
||||
}
|
||||
try {
|
||||
@@ -330,6 +436,17 @@ async function applyRemoteSnapshot(remote) {
|
||||
const byUuid = new Map();
|
||||
for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e);
|
||||
|
||||
// Entries sitting in the TRASH are soft-deleted: no tombstone is written
|
||||
// (that only happens on a hard delete), and they are absent from
|
||||
// state.entries. Without this set the merge sees their uuids as "missing
|
||||
// locally" and POSTs them back as fresh live rows — moving 30 entries to
|
||||
// the trash then syncing produced 30 duplicates, live copies alongside the
|
||||
// trashed originals, two rows sharing one uuid. The local decision to trash
|
||||
// wins; emptying the trash writes the tombstone that propagates the delete.
|
||||
await loadTrash();
|
||||
const trashedUuids = new Set();
|
||||
for (const e of (state.trashed || [])) if (e.uuid) trashedUuids.add(e.uuid);
|
||||
|
||||
// Local tombstones must veto ANY remote entry with a matching uuid —
|
||||
// otherwise a perm-delete on this device gets undone by the next
|
||||
// pull ("48 added" after wiping the vault, because the remote
|
||||
@@ -382,6 +499,9 @@ async function applyRemoteSnapshot(remote) {
|
||||
// Skip entries this device has already tombstoned — never
|
||||
// resurrect a deleted entry.
|
||||
if (localTombstones.has(r.uuid)) continue;
|
||||
// Same for the trash: the entry exists locally, just not in the live
|
||||
// list. Re-creating it would duplicate it, not restore it.
|
||||
if (trashedUuids.has(r.uuid)) continue;
|
||||
const local = byUuid.get(r.uuid);
|
||||
if (!local) {
|
||||
// New entry on remote — encrypt locally + POST keeping the uuid.
|
||||
@@ -419,7 +539,11 @@ async function applyRemoteSnapshot(remote) {
|
||||
const remoteWins = (r.updated_at || '') > (local.updated_at || '');
|
||||
if (!remoteWins) continue;
|
||||
try {
|
||||
const enc = await encryptImportEntry(r);
|
||||
// Le chiffré d'origine si le mot de passe n'a pas bougé :
|
||||
// sans cela le serveur archive une version d'historique à
|
||||
// chaque synchronisation. Voir keepCiphertextIfSamePassword.
|
||||
const enc = await keepCiphertextIfSamePassword(
|
||||
await encryptImportEntry(r), local, r.password);
|
||||
// PUT keeps the existing id but accepts the encrypted blobs.
|
||||
// template + uuid not touched here (server preserves columns
|
||||
// when fields aren't in body — uuid is immutable anyway).
|
||||
@@ -460,7 +584,7 @@ function syncStatus(text) {
|
||||
|
||||
async function runSyncNow(_attempt) {
|
||||
_attempt = _attempt || 0;
|
||||
const cfg = await loadSyncConfig();
|
||||
const cfg = await loadSyncConfig(true); // talks to the server + encrypts
|
||||
if (!cfg) return toast('Bridge not available', 'error');
|
||||
if (!cfg.url) return toast('Sync not configured', 'warning');
|
||||
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
|
||||
@@ -493,14 +617,32 @@ async function runSyncNow(_attempt) {
|
||||
// ETag of the version we just pulled — sent back as If-Match on the
|
||||
// push so the server rejects our write if another device changed the
|
||||
// file in between (optimistic concurrency, avoids lost updates).
|
||||
const remoteEtag = pullResp.etag || '';
|
||||
let remoteEtag = pullResp.etag || '';
|
||||
|
||||
// Decrypt + cross-account guard BEFORE touching local state, so a
|
||||
// wrong sync password or a foreign account aborts cleanly.
|
||||
let remoteSnap = null;
|
||||
if (pullResp.status !== 404 && pullResp.payload) {
|
||||
let jsonText;
|
||||
try {
|
||||
jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
|
||||
} catch (e) {
|
||||
syncStatus('');
|
||||
return toast('Unreadable reply from the remote', 'error');
|
||||
}
|
||||
if (syncIsGitea(cfg.url)) {
|
||||
const un = giteaUnwrap(jsonText);
|
||||
if (!un) {
|
||||
syncStatus('');
|
||||
return toast('Gitea replied without content/sha — is the URL a ' +
|
||||
'contents-API path to the file?', 'error');
|
||||
}
|
||||
jsonText = un.container;
|
||||
// The blob sha takes the ETag's place, and plays exactly its role:
|
||||
// it goes back on the push, and a stale one is refused.
|
||||
remoteEtag = un.sha;
|
||||
}
|
||||
try {
|
||||
const jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
|
||||
const container = JSON.parse(jsonText);
|
||||
remoteSnap = await decryptExportContainer(container, cfg.encPwd);
|
||||
} catch (e) {
|
||||
@@ -578,19 +720,56 @@ async function runSyncNow(_attempt) {
|
||||
await loadEntries(); // pull latest after applying remote changes
|
||||
const snap = await buildSyncSnapshot();
|
||||
pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0;
|
||||
|
||||
// RIEN A DIRE, RIEN A ECRIRE. Un push a lieu meme quand la fusion n'a
|
||||
// rien change, et il ne passe pas inapercu : le conteneur est rechiffre
|
||||
// avec un sel et un IV neufs, donc son contenu differe a chaque fois
|
||||
// meme a coffre identique. Sur Gitea cela vaut un commit de plus et un
|
||||
// nouveau sha ; les autres appareils voient alors un instantane
|
||||
// "nouveau" et paient une derivation de cle complete - mesuree a 5,7 s
|
||||
// sur le telephone - pour retrouver exactement ce qu'ils avaient deja.
|
||||
//
|
||||
// La comparaison porte sur le CLAIR, seul endroit ou l'egalite est
|
||||
// observable, et ignore `snapshot_at`, qui change par construction.
|
||||
if (remoteSnap && syncSnapshotsEqual(remoteSnap, snap)) {
|
||||
syncStatus('');
|
||||
// La synchronisation a bien eu lieu, et l'horodatage le dit : ne
|
||||
// rien avoir a ecrire est une reussite, pas une absence.
|
||||
Bridge.setPref(SYNC_PREFS.last, new Date().toISOString());
|
||||
render();
|
||||
return toast('Already up to date · ' + pushedCount + ' entries');
|
||||
}
|
||||
|
||||
syncStatus('Encrypting…');
|
||||
const container = await encryptExportPayload(snap, cfg.encPwd);
|
||||
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
||||
const containerBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
||||
syncStatus('Pushing…');
|
||||
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
||||
bytesToBase64(bodyBytes),
|
||||
{ ifMatch: remoteEtag });
|
||||
let bodyB64, opts;
|
||||
if (syncIsGitea(cfg.url)) {
|
||||
// The file rides inside a JSON envelope, base64 again. `sha` is
|
||||
// OMITTED rather than empty on a first push: Gitea reads its
|
||||
// presence as "replace that exact version", and an empty one is
|
||||
// not the same as none. `branch` is omitted too, so the repo's
|
||||
// default branch is used instead of a name we would be guessing.
|
||||
const env = {
|
||||
message: 'sync from ' + (state.username || 'desktop'),
|
||||
content: bytesToBase64(containerBytes)
|
||||
};
|
||||
if (remoteEtag) env.sha = remoteEtag;
|
||||
bodyB64 = bytesToBase64(new TextEncoder().encode(JSON.stringify(env)));
|
||||
opts = {};
|
||||
} else {
|
||||
bodyB64 = bytesToBase64(containerBytes);
|
||||
opts = { ifMatch: remoteEtag };
|
||||
}
|
||||
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd, bodyB64, opts);
|
||||
// 412 Precondition Failed = the remote changed since our pull
|
||||
// (another device pushed). Re-run the whole pull→merge→push so we
|
||||
// fold in their changes instead of clobbering them. Bounded to a
|
||||
// few tries to avoid a livelock against a device syncing in a hot
|
||||
// loop.
|
||||
if (r.status === 412) {
|
||||
// loop. Gitea says the same thing with 409: the sha we sent is no
|
||||
// longer the file's.
|
||||
if (r.status === 412 || r.status === 409) {
|
||||
syncStatus('');
|
||||
if (_attempt < 3) {
|
||||
toast('Remote changed — re-syncing…');
|
||||
|
||||
+37
-52
@@ -277,20 +277,9 @@ async function pinSetupFlow() {
|
||||
// Setting/changing a PIN creates a new unlock path → treat as a
|
||||
// sensitive operation. An unattended unlocked vault must not be
|
||||
// possible for a passerby to pin-backdoor.
|
||||
const masterPwd = await askReauth(
|
||||
const masterPwd = await reauthLoop(
|
||||
'Confirm your master password to set or change the PIN.');
|
||||
if (!masterPwd) return;
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ verifier: verifier }),
|
||||
});
|
||||
} catch (err) {
|
||||
return toast('Wrong master password', 'error');
|
||||
}
|
||||
|
||||
let lastError = '';
|
||||
let attempts = 0;
|
||||
@@ -401,7 +390,6 @@ async function loginViaPin(pin) {
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
sessionStorage.setItem('authToken', state.token);
|
||||
sessionStorage.setItem('csrfToken', state.csrf);
|
||||
await persistCryptoKey();
|
||||
|
||||
state.locked = false;
|
||||
state.justRecovered = false;
|
||||
@@ -420,20 +408,9 @@ async function enableQuickUnlock() {
|
||||
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
|
||||
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
|
||||
|
||||
const masterPwd = await askReauth(
|
||||
const masterPwd = await reauthLoop(
|
||||
'Confirm your master password to enable Quick unlock on this device.');
|
||||
if (!masterPwd) return;
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ verifier: verifier }),
|
||||
});
|
||||
} catch (err) {
|
||||
return toast('Wrong master password', 'error');
|
||||
}
|
||||
|
||||
// Export the raw key + identity. We don't store the session token —
|
||||
// tryQuickUnlock re-logs in with a verifier derived from the key,
|
||||
@@ -547,7 +524,6 @@ async function tryQuickUnlock() {
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
sessionStorage.setItem('authToken', state.token);
|
||||
sessionStorage.setItem('csrfToken', state.csrf);
|
||||
await persistCryptoKey();
|
||||
|
||||
state.locked = false;
|
||||
return true;
|
||||
@@ -676,7 +652,10 @@ async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCod
|
||||
async function doGenerateRecoveryKey() {
|
||||
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
|
||||
|
||||
const masterPwd = await askReauth(
|
||||
// Pre-verify via reauthLoop (inline retry) so a wrong master pw re-prompts
|
||||
// instead of failing the setup POST and closing the modal. The setup call
|
||||
// below still carries the verifier — the server re-checks it.
|
||||
const masterPwd = await reauthLoop(
|
||||
'Confirm your master password to generate a recovery key.');
|
||||
if (!masterPwd) return;
|
||||
|
||||
@@ -693,29 +672,12 @@ async function doGenerateRecoveryKey() {
|
||||
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
|
||||
rawKey, code, kdfSalt);
|
||||
|
||||
try {
|
||||
// Send a verifier instead of the master pw — server proves the
|
||||
// user still knows the master pw without ever seeing the plaintext.
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/recovery-key/setup', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
verifier: verifier,
|
||||
codeHash: codeHash,
|
||||
kdfSalt: kdfSalt,
|
||||
wrappedKey: wrappedKey,
|
||||
wrappedIv: wrappedIv,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
return toast('Setup failed: ' + (err.message || ''), 'error');
|
||||
}
|
||||
|
||||
// Refresh the Settings button label
|
||||
state.recoveryConfigured = true;
|
||||
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
|
||||
// Everything above is local — NOTHING is written server-side yet. The
|
||||
// setup POST (which deletes any existing recovery row and inserts the new
|
||||
// one) is deferred until AFTER the user acknowledges they saved the code.
|
||||
// Committing before the modal meant backing out still erased the old code
|
||||
// and activated a new one the user never saw → lockout. Cancel now leaves
|
||||
// the account exactly as it was.
|
||||
|
||||
// Show the code ONCE. Use the confirm modal so the user has to
|
||||
// explicitly click "I saved it" before the value vanishes.
|
||||
@@ -748,7 +710,7 @@ async function doGenerateRecoveryKey() {
|
||||
}
|
||||
}, 0);
|
||||
|
||||
await confirmDialog({
|
||||
const saved = await confirmDialog({
|
||||
title: 'Your recovery code',
|
||||
message: '<p>Save this code somewhere safe (password manager, ' +
|
||||
'safe deposit box, printed copy). It will <b>not</b> be ' +
|
||||
@@ -772,7 +734,31 @@ async function doGenerateRecoveryKey() {
|
||||
'your master password — so set a new one right after ' +
|
||||
'recovering.</p>',
|
||||
okText: 'I saved it',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
if (!saved) return toast('Cancelled — no recovery code was set');
|
||||
|
||||
// Acknowledged → commit. reauthLoop already verified the master pw; the
|
||||
// server re-checks the verifier below.
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/recovery-key/setup', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
verifier: verifier,
|
||||
codeHash: codeHash,
|
||||
kdfSalt: kdfSalt,
|
||||
wrappedKey: wrappedKey,
|
||||
wrappedIv: wrappedIv,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
return toast('Setup failed: ' + (err.message || ''), 'error');
|
||||
}
|
||||
state.recoveryConfigured = true;
|
||||
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
|
||||
toast('Recovery code generated');
|
||||
}
|
||||
|
||||
@@ -891,7 +877,6 @@ async function doRecoveryRedeem() {
|
||||
// so master-pw change can later re-export and re-wrap as needed).
|
||||
state.cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
||||
await persistCryptoKey();
|
||||
|
||||
const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0;
|
||||
if (remaining <= 0) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Le transport Gitea : reconnaissance de l'URL, et lecture de la reponse de
|
||||
// l'API des contenus.
|
||||
//
|
||||
// POURQUOI CES DEUX-LA. `giteaUnwrap` est le seul endroit du chemin de sync qui
|
||||
// interprete du JSON venu du reseau, et il decide de ce qui sera ensuite
|
||||
// dechiffre. Une URL a un segment pres rend du JSON valide qui n'est PAS un
|
||||
// fichier ; s'il passait, l'erreur ne se verrait qu'au message
|
||||
// « mot de passe de sync incorrect », qui envoie chercher la cause ailleurs.
|
||||
//
|
||||
// Le `sha` compte autant que le contenu : il repart en `sha` au push, et c'est
|
||||
// lui qui fait refuser une ecriture concurrente. Le rendre vide plutot
|
||||
// qu'absent ferait ecraser la version d'un autre appareil.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadApp } = require('./harness');
|
||||
|
||||
function b64(text) {
|
||||
return Buffer.from(text, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
test('syncIsGitea: recognises a contents-API URL, ignores WebDAV', async () => {
|
||||
const { __test: t } = await loadApp();
|
||||
assert.equal(t.syncIsGitea(
|
||||
'https://git.example.com/api/v1/repos/Zaki/pm_sync/contents/vault-sync.json'), true);
|
||||
// Casse indifferente : l'URL est saisie a la main.
|
||||
assert.equal(t.syncIsGitea(
|
||||
'https://git.example.com/API/V1/REPOS/Zaki/pm_sync/contents/v.json'), true);
|
||||
// Une URL de depot n'est pas une URL d'API, et elle ne rendrait pas de sha.
|
||||
assert.equal(t.syncIsGitea('https://git.example.com/Zaki/pm_sync'), false);
|
||||
assert.equal(t.syncIsGitea('https://dav.example.com/remote.php/vault.json'), false);
|
||||
assert.equal(t.syncIsGitea(''), false);
|
||||
assert.equal(t.syncIsGitea(undefined), false);
|
||||
});
|
||||
|
||||
test('giteaUnwrap: returns the file and its sha', async () => {
|
||||
const { __test: t } = await loadApp();
|
||||
const container = '{"version":1,"cipher":"AAAA"}';
|
||||
const un = t.giteaUnwrap(JSON.stringify({
|
||||
content: b64(container), encoding: 'base64', sha: 'abc123', name: 'vault-sync.json'
|
||||
}));
|
||||
assert.ok(un, 'devrait accepter une reponse bien formee');
|
||||
assert.equal(un.container, container);
|
||||
assert.equal(un.sha, 'abc123');
|
||||
});
|
||||
|
||||
test('giteaUnwrap: accepts the line-wrapped base64 Gitea sends', async () => {
|
||||
const { __test: t } = await loadApp();
|
||||
const container = '{"version":1}';
|
||||
// Gitea coupe son base64 en lignes ; un decodeur strict s'y casse.
|
||||
const wrapped = b64(container).replace(/(.{4})/, '$1\r\n');
|
||||
const un = t.giteaUnwrap(JSON.stringify({
|
||||
content: wrapped, encoding: 'base64', sha: 'deadbeef'
|
||||
}));
|
||||
assert.ok(un, 'les retours a la ligne ne doivent pas faire echouer le decodage');
|
||||
assert.equal(un.container, container);
|
||||
});
|
||||
|
||||
test('giteaUnwrap: refuses anything that is not a file', async () => {
|
||||
const { __test: t } = await loadApp();
|
||||
// Ce que rend une URL a un segment pres : du JSON valide, sans fichier.
|
||||
assert.equal(t.giteaUnwrap('{"message":"Not found"}'), null);
|
||||
// Un repertoire : l'API rend un TABLEAU, pas un objet.
|
||||
assert.equal(t.giteaUnwrap('[{"name":"vault-sync.json","sha":"x"}]'), null);
|
||||
// Un sha sans contenu ne permet rien de dechiffrer.
|
||||
assert.equal(t.giteaUnwrap('{"sha":"abc"}'), null);
|
||||
// Un contenu sans sha interdirait l'ecriture concurrente : refuse aussi.
|
||||
assert.equal(t.giteaUnwrap(JSON.stringify({ content: b64('{}') })), null);
|
||||
// Un encodage que nous ne savons pas lire.
|
||||
assert.equal(t.giteaUnwrap(JSON.stringify({
|
||||
content: 'x', encoding: 'utf-8', sha: 'abc' })), null);
|
||||
// Pas du JSON du tout - une page d'erreur HTML, par exemple.
|
||||
assert.equal(t.giteaUnwrap('<html>502</html>'), null);
|
||||
assert.equal(t.giteaUnwrap(''), null);
|
||||
});
|
||||
@@ -152,6 +152,8 @@ function loadApp(overrides = {}) {
|
||||
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
|
||||
// merge (async, coupled — tests stub the io seams below)
|
||||
applyRemoteSnapshot, buildSyncSnapshot,
|
||||
// gitea transport
|
||||
syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
|
||||
// metadata-at-rest
|
||||
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// L'egalite de deux instantanes, qui decide si un push a lieu.
|
||||
//
|
||||
// POURQUOI C'EST TESTE. Un faux positif est le cas dangereux : il ferait taire
|
||||
// un push qui portait une modification, et la perdrait pour les autres
|
||||
// appareils. La comparaison est donc volontairement stricte, et ces cas fixent
|
||||
// ou est la limite. Un faux negatif ne coute qu'un commit inutile.
|
||||
//
|
||||
// `snapshot_at` doit etre ignore : il change a chaque construction, et sans cela
|
||||
// deux instantanes identiques ne seraient jamais egaux - le correctif ne
|
||||
// servirait a rien.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadApp } = require('./harness');
|
||||
|
||||
function snap(extra) {
|
||||
return Object.assign({
|
||||
version: 1,
|
||||
snapshot_at: '2026-08-17T10:00:00.000Z',
|
||||
username: 'test',
|
||||
folders: [{ name: 'folder test' }],
|
||||
entries: [{ uuid: 'u1', title: 'a', updated_at: '2026-08-01T00:00:00Z' }],
|
||||
tombstones: []
|
||||
}, extra || {});
|
||||
}
|
||||
|
||||
test('syncSnapshotsEqual: identical vaults are equal despite the build time', async () => {
|
||||
const { __test: t } = await loadApp();
|
||||
const a = snap();
|
||||
const b = snap({ snapshot_at: '2026-08-17T15:36:12.000Z' });
|
||||
assert.equal(t.syncSnapshotsEqual(a, b), true);
|
||||
});
|
||||
|
||||
test('syncSnapshotsEqual: exported_at is ignored too', async () => {
|
||||
const { __test: t } = await loadApp();
|
||||
assert.equal(t.syncSnapshotsEqual(
|
||||
snap({ exported_at: '2026-01-01T00:00:00Z' }),
|
||||
snap({ exported_at: '2026-08-17T00:00:00Z' })), true);
|
||||
});
|
||||
|
||||
test('syncSnapshotsEqual: any real difference must push', async () => {
|
||||
const { __test: t } = await loadApp();
|
||||
// Une entree modifiee.
|
||||
assert.equal(t.syncSnapshotsEqual(snap(), snap({
|
||||
entries: [{ uuid: 'u1', title: 'b', updated_at: '2026-08-01T00:00:00Z' }] })), false);
|
||||
// Un horodatage d'entree plus recent, meme titre : c'est ce qui arbitre la
|
||||
// fusion chez les autres, donc ca doit voyager.
|
||||
assert.equal(t.syncSnapshotsEqual(snap(), snap({
|
||||
entries: [{ uuid: 'u1', title: 'a', updated_at: '2026-08-17T00:00:00Z' }] })), false);
|
||||
// Une entree en plus, une en moins.
|
||||
assert.equal(t.syncSnapshotsEqual(snap(), snap({ entries: [] })), false);
|
||||
// Un dossier renomme.
|
||||
assert.equal(t.syncSnapshotsEqual(snap(), snap({ folders: [{ name: 'autre' }] })), false);
|
||||
// Une pierre tombale : la suppression doit se propager.
|
||||
assert.equal(t.syncSnapshotsEqual(snap(), snap({
|
||||
tombstones: [{ uuid: 'u9', deleted_at: '2026-08-17T00:00:00Z' }] })), false);
|
||||
});
|
||||
|
||||
test('syncSnapshotsEqual: a missing side is never equal', async () => {
|
||||
const { __test: t } = await loadApp();
|
||||
// 404 distant : il n'y a rien la-bas, donc il faut ecrire.
|
||||
assert.equal(t.syncSnapshotsEqual(null, snap()), false);
|
||||
assert.equal(t.syncSnapshotsEqual(snap(), null), false);
|
||||
assert.equal(t.syncSnapshotsEqual(null, null), false);
|
||||
});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
object Form8: TForm8
|
||||
Left = 0
|
||||
Top = 0
|
||||
Caption = 'Form8'
|
||||
ClientHeight = 480
|
||||
ClientWidth = 640
|
||||
FormFactor.Width = 320
|
||||
FormFactor.Height = 480
|
||||
FormFactor.Devices = [Desktop, iPhone, iPad]
|
||||
DesignerMasterStyle = 0
|
||||
end
|
||||
+3233
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Condition="Exists('$(BDS)\bin\CodeGear.Deployment.targets')" Project="$(BDS)\bin\CodeGear.Deployment.targets"/>
|
||||
<ProjectExtensions>
|
||||
<ProjectFileVersion>12</ProjectFileVersion>
|
||||
</ProjectExtensions>
|
||||
<PropertyGroup>
|
||||
<DeviceId Condition="'$(Platform)'=='Android'">13c0f9d8</DeviceId>
|
||||
<DeviceId Condition="'$(Platform)'=='Android64'">13c0f9d8</DeviceId>
|
||||
<DeviceId Condition="'$(Platform)'=='iOSDevice64'"/>
|
||||
<DeviceId Condition="'$(Platform)'=='iOSSimARM64'"/>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="'$(Platform)'=='OSX64'"/>
|
||||
<ItemGroup Condition="'$(Platform)'=='Win32'">
|
||||
<DeployFile Include="Z:\Win32\Debug\pmserverApk.exe" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\</RemoteDir>
|
||||
<RemoteName>pmserverApk.exe</RemoteName>
|
||||
<DeployClass>ProjectOutput</DeployClass>
|
||||
<Operation>0</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
<Required>True</Required>
|
||||
</DeployFile>
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(Platform)'=='OSXARM64'"/>
|
||||
<ItemGroup Condition="'$(Platform)'=='Android'"/>
|
||||
<ItemGroup Condition="'$(Platform)'=='Win64'"/>
|
||||
<ItemGroup Condition="'$(Platform)'=='Android64'">
|
||||
<DeployFile Include="Android64\Debug\pmserverApk.classes" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\classes\</RemoteDir>
|
||||
<RemoteName>pmserverApk.classes</RemoteName>
|
||||
<DeployClass>AndroidClasses</DeployClass>
|
||||
<Operation>64</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedSplashV31.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-anydpi-v31\</RemoteDir>
|
||||
<RemoteName>splash_vector.xml</RemoteName>
|
||||
<DeployClass>Android_VectorizedSplashV31</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\ic_launcher.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-anydpi-v26\</RemoteDir>
|
||||
<RemoteName>ic_launcher.xml</RemoteName>
|
||||
<DeployClass>Android_AdaptiveIcon</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_72x72.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-hdpi\</RemoteDir>
|
||||
<RemoteName>ic_launcher.png</RemoteName>
|
||||
<DeployClass>Android_LauncherIcon72</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedSplashDark.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-night-anydpi-v21\</RemoteDir>
|
||||
<RemoteName>splash_vector.xml</RemoteName>
|
||||
<DeployClass>Android_VectorizedSplashDark</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\AndroidManifest.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\</RemoteDir>
|
||||
<RemoteName>AndroidManifest.xml</RemoteName>
|
||||
<DeployClass>ProjectAndroidManifest</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_48x48.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-mdpi\</RemoteDir>
|
||||
<RemoteName>ic_launcher.png</RemoteName>
|
||||
<DeployClass>Android_LauncherIcon48</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_144x144.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-xxhdpi\</RemoteDir>
|
||||
<RemoteName>ic_launcher.png</RemoteName>
|
||||
<DeployClass>Android_LauncherIcon144</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-xxxhdpi\</RemoteDir>
|
||||
<RemoteName>ic_launcher.png</RemoteName>
|
||||
<DeployClass>Android_LauncherIcon192</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\colors-night-v21.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\values-night-v21\</RemoteDir>
|
||||
<RemoteName>colors.xml</RemoteName>
|
||||
<DeployClass>Android_ColorsDark</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_48x48.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-xhdpi\</RemoteDir>
|
||||
<RemoteName>ic_notification.png</RemoteName>
|
||||
<DeployClass>Android_NotificationIcon48</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_SplashImage_640x480.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-large\</RemoteDir>
|
||||
<RemoteName>splash_image.png</RemoteName>
|
||||
<DeployClass>Android_SplashImage640</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_AdaptiveIcon_Monochrome.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
|
||||
<RemoteName>ic_launcher_monochrome.xml</RemoteName>
|
||||
<DeployClass>Android_AdaptiveIconMonochrome</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\splash_image_def-v21.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-anydpi-v21\</RemoteDir>
|
||||
<RemoteName>splash_image_def.xml</RemoteName>
|
||||
<DeployClass>AndroidSplashImageDefV21</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\lib\android\debug\armeabi\libnative-activity.so" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\library\lib\armeabi\</RemoteDir>
|
||||
<RemoteName>libpmserverApk.so</RemoteName>
|
||||
<DeployClass>AndroidLibnativeArmeabiFile</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\styles-v31.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\values-v31\</RemoteDir>
|
||||
<RemoteName>styles.xml</RemoteName>
|
||||
<DeployClass>AndroidSplashStylesV31</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\colors.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\values\</RemoteDir>
|
||||
<RemoteName>colors.xml</RemoteName>
|
||||
<DeployClass>Android_Colors</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\splash_image_def.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
|
||||
<RemoteName>splash_image_def.xml</RemoteName>
|
||||
<DeployClass>AndroidSplashImageDef</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_36x36.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-hdpi\</RemoteDir>
|
||||
<RemoteName>ic_notification.png</RemoteName>
|
||||
<DeployClass>Android_NotificationIcon36</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_96x96.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-xxxhdpi\</RemoteDir>
|
||||
<RemoteName>ic_notification.png</RemoteName>
|
||||
<DeployClass>Android_NotificationIcon96</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\lib\android\debug\mips\libnative-activity.so" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\library\lib\mips\</RemoteDir>
|
||||
<RemoteName>libpmserverApk.so</RemoteName>
|
||||
<DeployClass>AndroidLibnativeMipsFile</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_96x96.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-xhdpi\</RemoteDir>
|
||||
<RemoteName>ic_launcher.png</RemoteName>
|
||||
<DeployClass>Android_LauncherIcon96</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedSplash.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
|
||||
<RemoteName>splash_vector.xml</RemoteName>
|
||||
<DeployClass>Android_VectorizedSplash</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\lib\android\debug\armeabi-v7a\libnative-activity.so" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\library\lib\armeabi-v7a\</RemoteDir>
|
||||
<RemoteName>libpmserverApk.so</RemoteName>
|
||||
<DeployClass>AndroidLibnativeArmeabiv7aFile</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_24x24.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-mdpi\</RemoteDir>
|
||||
<RemoteName>ic_notification.png</RemoteName>
|
||||
<DeployClass>Android_NotificationIcon24</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-ldpi\</RemoteDir>
|
||||
<RemoteName>ic_launcher.png</RemoteName>
|
||||
<DeployClass>Android_LauncherIcon36</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_SplashImage_960x720.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-xlarge\</RemoteDir>
|
||||
<RemoteName>splash_image.png</RemoteName>
|
||||
<DeployClass>Android_SplashImage960</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedSplashV31Dark.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-night-anydpi-v31\</RemoteDir>
|
||||
<RemoteName>splash_vector.xml</RemoteName>
|
||||
<DeployClass>Android_VectorizedSplashV31Dark</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\styles.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\values\</RemoteDir>
|
||||
<RemoteName>styles.xml</RemoteName>
|
||||
<DeployClass>AndroidSplashStyles</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_SplashImage_426x320.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-small\</RemoteDir>
|
||||
<RemoteName>splash_image.png</RemoteName>
|
||||
<DeployClass>Android_SplashImage426</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedNotificationIcon.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-anydpi-v24\</RemoteDir>
|
||||
<RemoteName>ic_notification.xml</RemoteName>
|
||||
<DeployClass>Android_VectorizedNotificationIcon</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\strings.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\values\</RemoteDir>
|
||||
<RemoteName>strings.xml</RemoteName>
|
||||
<DeployClass>Android_Strings</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\styles-v21.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\values-v21\</RemoteDir>
|
||||
<RemoteName>styles.xml</RemoteName>
|
||||
<DeployClass>AndroidSplashStylesV21</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\libpmserverApk.so" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\library\lib\arm64-v8a\</RemoteDir>
|
||||
<RemoteName>libpmserverApk.so</RemoteName>
|
||||
<DeployClass>ProjectOutput</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
<Required>True</Required>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_72x72.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-xxhdpi\</RemoteDir>
|
||||
<RemoteName>ic_notification.png</RemoteName>
|
||||
<DeployClass>Android_NotificationIcon72</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_AdaptiveIcon_Foreground.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
|
||||
<RemoteName>ic_launcher_foreground.xml</RemoteName>
|
||||
<DeployClass>Android_AdaptiveIconForeground</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_AdaptiveIcon_Background.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
|
||||
<RemoteName>ic_launcher_background.xml</RemoteName>
|
||||
<DeployClass>Android_AdaptiveIconBackground</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="Android64\Debug\ic_launcher-v33.xml" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-anydpi-v33\</RemoteDir>
|
||||
<RemoteName>ic_launcher.xml</RemoteName>
|
||||
<DeployClass>Android_AdaptiveIconV33</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_SplashImage_470x320.png" Condition="'$(Config)'=='Debug'">
|
||||
<RemoteDir>pmserverApk\res\drawable-normal\</RemoteDir>
|
||||
<RemoteName>splash_image.png</RemoteName>
|
||||
<DeployClass>Android_SplashImage470</DeployClass>
|
||||
<Operation>1</Operation>
|
||||
<LocalCommand/>
|
||||
<RemoteCommand/>
|
||||
<Overwrite>True</Overwrite>
|
||||
</DeployFile>
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(Platform)'=='iOSDevice64'"/>
|
||||
<ItemGroup Condition="'$(Platform)'=='iOSSimARM64'"/>
|
||||
</Project>
|
||||
@@ -0,0 +1,34 @@
|
||||
program pmserverApk;
|
||||
|
||||
uses
|
||||
System.StartUpCopy,
|
||||
FMX.Forms,
|
||||
mainApk in 'mainApk.pas' {Form8},
|
||||
PMA.Crypto in 'android-client\PMA.Crypto.pas',
|
||||
PMA.TestVector in 'android-client\PMA.TestVector.pas',
|
||||
PMA.SyncClient in 'android-client\PMA.SyncClient.pas',
|
||||
PMA.Store in 'android-client\PMA.Store.pas',
|
||||
PMA.Biometric in 'android-client\PMA.Biometric.pas',
|
||||
PMA.Vault in 'android-client\PMA.Vault.pas',
|
||||
PMA.Totp in 'android-client\PMA.Totp.pas',
|
||||
PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas',
|
||||
PMA.Clipboard in 'android-client\PMA.Clipboard.pas',
|
||||
PMA.Theme in 'android-client\PMA.Theme.pas',
|
||||
PMA.WebUI in 'android-client\PMA.WebUI.pas',
|
||||
PMA.VaultPatch in 'android-client\PMA.VaultPatch.pas',
|
||||
PMA.SaveFile in 'android-client\PMA.SaveFile.pas',
|
||||
PMA.Favicon in 'android-client\PMA.Favicon.pas';
|
||||
|
||||
{$R *.res}
|
||||
|
||||
begin
|
||||
// NI SKIA NI STYLE. Les deux servaient les controles FMX : Skia pour mettre
|
||||
// en forme l'arabe dans les libelles - le moteur d'origine ne lie pas les
|
||||
// lettres et ne reordonne pas le bidirectionnel - et le style pour que les
|
||||
// champs et les listes ne s'affichent pas en clair. L'interface est passee au
|
||||
// moteur du systeme, qui fait les deux lui-meme ; il ne reste plus un seul
|
||||
// controle FMX a habiller. Au passage, libsk4d.so ne pese plus dans l'APK.
|
||||
Application.Initialize;
|
||||
Application.CreateForm(TForm8, Form8);
|
||||
Application.Run;
|
||||
end.
|
||||
+1324
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
# Endpoint de synchronisation PHP (lecture seule)
|
||||
|
||||
Alternative au WebDAV pour distribuer l'instantané chiffré du coffre. Le serveur
|
||||
**ne déchiffre rien** : il sert des octets opaques.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copier `config.example.php` en `config.php`, y mettre un jeton :
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
|
||||
```
|
||||
2. Créer le dossier de données **hors racine web** et y déposer l'instantané
|
||||
produit par l'application Windows (`vault-sync.json`).
|
||||
3. Servir `sync.php` en HTTPS. Sans TLS, l'endpoint refuse la requête : le jeton
|
||||
circule dans un en-tête à chaque appel.
|
||||
|
||||
## Utilisation
|
||||
|
||||
```
|
||||
GET /sync.php
|
||||
Authorization: Bearer <jeton>
|
||||
```
|
||||
|
||||
- **200** — le conteneur chiffré, avec un `ETag` fort.
|
||||
- **304** — si le client renvoie `If-None-Match` avec l'ETag qu'il détient.
|
||||
- **404** — pas encore d'instantané, *ou* jeton invalide. Les deux répondent
|
||||
pareil : un appelant non authentifié n'apprend pas si quelque chose existe ici.
|
||||
- **405** — toute autre méthode. L'écriture viendra avec le client bidirectionnel.
|
||||
|
||||
## Ce que le serveur voit
|
||||
|
||||
Le conteneur est chiffré côté client (PBKDF2-SHA256 600 k + AES-GCM) avec le
|
||||
**mot de passe de sync**, qui n'est jamais transmis. Un serveur compromis livre
|
||||
donc du chiffré, plus ces métadonnées : la taille du fichier, sa date de
|
||||
modification, et la fréquence des synchronisations.
|
||||
|
||||
Le jeton authentifie l'**appareil**, pas le coffre. Le voler donne accès au
|
||||
chiffré, pas au contenu.
|
||||
|
||||
## Étape suivante
|
||||
|
||||
L'écriture demandera `PUT` avec `If-Match: <etag>` — c'est ce qui empêche deux
|
||||
appareils de s'écraser mutuellement. Le client Windows sait déjà le faire : il
|
||||
capture l'ETag à la lecture et rejoue le cycle complet sur un **412**
|
||||
(`runSyncNow`, borné à 3 tentatives).
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* Copy to config.php and fill in. config.php is gitignored — it holds a secret.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Bearer token the clients send. Generate one, never reuse a password:
|
||||
// php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
|
||||
// It authenticates the DEVICE, not the vault: it grants access to the
|
||||
// ciphertext only. The sync password (which decrypts it) never leaves the
|
||||
// clients and is never known to this server.
|
||||
const SYNC_TOKEN = 'PUT-A-64-HEX-TOKEN-HERE';
|
||||
|
||||
// Where the snapshot lives. Keep it OUTSIDE the web root: served through this
|
||||
// script only, never fetchable as a static file.
|
||||
const SYNC_FILE = __DIR__ . '/../vault-data/vault-sync.json';
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
/**
|
||||
* Read-only sync endpoint for the vault snapshot.
|
||||
*
|
||||
* The snapshot is an OPAQUE encrypted container (pm-encrypted-export-v1):
|
||||
* PBKDF2-SHA256 600k + AES-GCM, produced and read by the clients. This script
|
||||
* never decrypts anything and never needs to — it stores and serves bytes.
|
||||
* A compromised server yields ciphertext plus the metadata below, nothing more.
|
||||
*
|
||||
* Deliberately read-only for now: GET only. The write path (PUT with If-Match)
|
||||
* comes with the two-way client and is refused explicitly rather than silently
|
||||
* ignored, so a client that tries it fails loudly instead of believing it wrote.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/config.php'; // defines SYNC_TOKEN and SYNC_FILE
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The token travels in a header on every request; without TLS it is readable by
|
||||
// anyone on the path, and so is the ciphertext. Refuse rather than leak.
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|
||||
|| in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
|
||||
if (!$https) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'HTTPS required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
// This endpoint is for native clients, never a browser page: no CORS, no cache.
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bearer token, compared in constant time so a wrong guess leaks no timing.
|
||||
*
|
||||
* Three sources on purpose: Apache commonly strips Authorization before PHP
|
||||
* sees it (it is only forwarded with CGIPassAuth on, or through a rewrite), so
|
||||
* $_SERVER alone answers empty on a stock XAMPP and every request looks
|
||||
* unauthenticated. getallheaders() reads what the client actually sent.
|
||||
*/
|
||||
function client_token(): string {
|
||||
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
|
||||
if ($h === '' && function_exists('getallheaders')) {
|
||||
foreach (getallheaders() as $name => $value) {
|
||||
if (strcasecmp($name, 'Authorization') === 0) { $h = $value; break; }
|
||||
}
|
||||
}
|
||||
if (stripos($h, 'Bearer ') === 0) return trim(substr($h, 7));
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!hash_equals(SYNC_TOKEN, client_token())) {
|
||||
// 404, not 401: an unauthenticated caller learns nothing about what lives
|
||||
// here — same reasoning as the desktop server's loopback lockdown.
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
|
||||
if ($method === 'HEAD' || $method === 'GET') {
|
||||
if (!is_file(SYNC_FILE)) {
|
||||
// 404 = "no snapshot yet". The desktop client already treats this as
|
||||
// "first sync, remote is empty" rather than an error.
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'No snapshot yet']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$blob = file_get_contents(SYNC_FILE);
|
||||
if ($blob === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Read failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Strong ETag over the bytes. The write path will require If-Match against
|
||||
// it, which is what stops two devices from clobbering each other.
|
||||
$etag = '"' . hash('sha256', $blob) . '"';
|
||||
header('ETag: ' . $etag);
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', (int)filemtime(SYNC_FILE)) . ' GMT');
|
||||
|
||||
// Save the round trip when the client already holds this version.
|
||||
// Compared on the bare hash: a validator that loses its quotes or arrives
|
||||
// as a weak tag (W/"…") would otherwise never match, and every request
|
||||
// would silently download the whole snapshot again — costly on mobile data
|
||||
// and invisible in the logs, since a 200 looks perfectly normal.
|
||||
$sent = trim($_SERVER['HTTP_IF_NONE_MATCH'] ?? '');
|
||||
if ($sent !== '' && trim($sent, 'W/" ') === trim($etag, '" ')) {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Length: ' . strlen($blob));
|
||||
if ($method === 'HEAD') exit;
|
||||
echo $blob;
|
||||
exit;
|
||||
}
|
||||
|
||||
// Everything else — including PUT — is not implemented yet.
|
||||
http_response_code(405);
|
||||
header('Allow: GET, HEAD');
|
||||
echo json_encode(['error' => 'Read-only endpoint']);
|
||||
@@ -0,0 +1,130 @@
|
||||
# Fabrique des licences signees. A LANCER CHEZ TOI, JAMAIS LIVRE.
|
||||
#
|
||||
# .\tools\licence.ps1 -Init cree la paire de cles (une seule fois)
|
||||
# et imprime la constante Delphi a coller
|
||||
# .\tools\licence.ps1 -For "a@b.com" -Name "Ami" fabrique une licence
|
||||
#
|
||||
# POURQUOI ECDSA P-256 ET PAS AUTRE CHOSE : .NET sur Windows EST CNG. La
|
||||
# signature que produit ce script (r||s brut, 64 octets) est exactement celle
|
||||
# qu'attend BCryptVerifySignature cote Delphi, et les coordonnees exportees ici
|
||||
# se rangent telles quelles dans un BCRYPT_ECCPUBLIC_BLOB. Aucun ASN.1 a lire
|
||||
# d'un cote ni de l'autre - c'est ce qui rend la verification courte.
|
||||
|
||||
param(
|
||||
[switch]$Init,
|
||||
[string]$For,
|
||||
[string]$Name = '',
|
||||
[string]$Machine,
|
||||
# HORS DU DEPOT PAR CONSTRUCTION. Elle a d'abord vecu dans tools\, ou elle
|
||||
# n'attendait qu'un menage pour disparaitre - ce qui est arrive, et une cle
|
||||
# privee perdue interdit toute nouvelle licence a jamais. Le .gitignore la
|
||||
# protegeait du depot, rien ne la protegeait de son proprietaire.
|
||||
[string]$KeyFile = "$env:USERPROFILE\Documents\pmserver-licence-private.key"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Security
|
||||
|
||||
function ToB64Url([byte[]]$b) {
|
||||
[Convert]::ToBase64String($b).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
}
|
||||
|
||||
if ($Init) {
|
||||
if (Test-Path $KeyFile) {
|
||||
throw "$KeyFile existe deja. L'ecraser invaliderait TOUTES les licences deja emises. Deplace-le d'abord si c'est voulu."
|
||||
}
|
||||
|
||||
$ecdsa = New-Object System.Security.Cryptography.ECDsaCng 256
|
||||
$p = $ecdsa.ExportParameters($true)
|
||||
|
||||
# La cle privee : D, plus le point public pour pouvoir re-signer sans le
|
||||
# recalculer. 96 octets, en hexa pour rester lisible dans un gestionnaire
|
||||
# de mots de passe - c'est la ou elle doit finir, pas dans ce dossier.
|
||||
$priv = [byte[]]($p.D + $p.Q.X + $p.Q.Y)
|
||||
[System.IO.File]::WriteAllText($KeyFile, ([BitConverter]::ToString($priv) -replace '-', ''))
|
||||
|
||||
# BCRYPT_ECCPUBLIC_BLOB : magic ECS1 + longueur + X + Y.
|
||||
$blob = [byte[]]([BitConverter]::GetBytes([uint32]0x31534345) +
|
||||
[BitConverter]::GetBytes([uint32]32) + $p.Q.X + $p.Q.Y)
|
||||
|
||||
''
|
||||
'Cle privee ecrite dans : ' + $KeyFile
|
||||
'METS-LA A L ABRI ET SUPPRIME-LA D ICI. Qui la detient fabrique des licences.'
|
||||
''
|
||||
'--- a coller dans PM.Licence.pas ---'
|
||||
''
|
||||
'const'
|
||||
' LicencePublicKeyBlob: array[0..71] of Byte = ('
|
||||
for ($i = 0; $i -lt $blob.Length; $i += 12) {
|
||||
$row = ($blob[$i..([Math]::Min($i + 11, $blob.Length - 1))] |
|
||||
ForEach-Object { '$' + $_.ToString('X2') }) -join ', '
|
||||
if ($i + 12 -lt $blob.Length) { ' ' + $row + ',' } else { ' ' + $row }
|
||||
}
|
||||
' );'
|
||||
''
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $For) { throw 'Donne -For <email> (ou -Init pour creer la paire de cles).' }
|
||||
if (-not (Test-Path $KeyFile)) { throw "Pas de cle privee dans $KeyFile. Lance -Init, ou remets la cle mise a l'abri." }
|
||||
|
||||
$hex = (Get-Content $KeyFile -Raw).Trim()
|
||||
$priv = [byte[]]::new($hex.Length / 2)
|
||||
for ($i = 0; $i -lt $priv.Length; $i++) { $priv[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16) }
|
||||
|
||||
$p = New-Object System.Security.Cryptography.ECParameters
|
||||
$p.Curve = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName('nistP256')
|
||||
$p.D = $priv[0..31]
|
||||
$q = New-Object System.Security.Cryptography.ECPoint
|
||||
$q.X = $priv[32..63]
|
||||
$q.Y = $priv[64..95]
|
||||
$p.Q = $q
|
||||
|
||||
$ecdsa = [System.Security.Cryptography.ECDsa]::Create($p)
|
||||
|
||||
# LE LIAGE AU POSTE EST OBLIGATOIRE, ET IL N'Y A PLUS DE DEROGATION.
|
||||
#
|
||||
# Les licences flottantes (sans champ m) existaient ; l'exe les refuse
|
||||
# desormais, ce qui etait la seule facon d'annuler celle qui etait sortie -
|
||||
# un schema hors ligne n'a pas de revocation. En emettre une aujourd'hui
|
||||
# produirait une cle que l'application rejettera : autant ne pas pouvoir.
|
||||
#
|
||||
# L'empreinte est celle qu'affiche l'ecran d'activation sur le poste du
|
||||
# client : il faut donc qu'il ait installe l'application AVANT qu'on emette
|
||||
# sa licence. C'est le prix du liage.
|
||||
if (-not $Machine) {
|
||||
throw @'
|
||||
Il manque -Machine <empreinte>.
|
||||
|
||||
Fais-toi envoyer l'empreinte affichee sur l'ecran d'activation du poste
|
||||
(format XXXX-XXXX-XXXX-XXXX), puis :
|
||||
|
||||
.\licence.ps1 -For "a@b.com" -Name "Nom" -Machine "XXXX-XXXX-XXXX-XXXX"
|
||||
|
||||
Une licence non liee n'est plus emettable : l'application la refuserait.
|
||||
'@
|
||||
}
|
||||
|
||||
# Charge utile courte : elle voyage dans la cle que l'utilisateur recopie.
|
||||
# Pas d'expiration - une date qu'on fait respecter avec l'horloge de la
|
||||
# machine du client ne prouve rien. A ajouter le jour ou on vend un abonnement.
|
||||
$payload = [ordered]@{ n = $Name; e = $For; d = (Get-Date).ToString('yyyy-MM-dd') }
|
||||
# Les tirets ne sont qu'une aide a la recopie ; Delphi compare le condense
|
||||
# nu, en minuscules.
|
||||
$payload.m = ($Machine -replace '[^0-9A-Fa-f]', '').ToLower()
|
||||
if ($payload.m.Length -ne 16) {
|
||||
throw "Empreinte attendue : 16 caracteres hexadecimaux (recu $($payload.m.Length))."
|
||||
}
|
||||
$json = ($payload | ConvertTo-Json -Compress)
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
|
||||
$sig = $ecdsa.SignData($bytes, [System.Security.Cryptography.HashAlgorithmName]::SHA256)
|
||||
|
||||
$licence = (ToB64Url $bytes) + '.' + (ToB64Url $sig)
|
||||
|
||||
''
|
||||
'Licence pour ' + $For + ' — liee au poste ' + $Machine
|
||||
''
|
||||
$licence
|
||||
''
|
||||
'Verification locale : ' + $ecdsa.VerifyData($bytes, $sig, [System.Security.Cryptography.HashAlgorithmName]::SHA256)
|
||||
''
|
||||
Reference in New Issue
Block a user