diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index f5c53b0..b67b716 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -83,8 +83,16 @@ function HasVault: Boolean; /// protection vient de reapparaitre sur l'appareil : le changement qui l'a /// ramenee a tres probablement invalide l'ancienne cle, et ca ne se verrait /// qu'a la prochaine lecture - trop tard, le fichier serait deja scelle. +/// APin non vide : le cache est scelle sous ce code (VER_PIN), et ARequireAuth +/// est alors ignore - les deux protections ne se cumulent pas, et celle du code +/// est la seule qui survive a un changement de verrouillage d'ecran. procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; - ARequireAuth: Boolean = False; AFreshKey: Boolean = False); + ARequireAuth: Boolean = False; AFreshKey: Boolean = False; + const APin: string = ''); + +/// Le fichier local est-il scelle sous un code d'application ? Lu dans +/// l'en-tete, sans rien dechiffrer. +function VaultNeedsPin: Boolean; /// Le coffre local exige-t-il une authentification pour etre lu ? Lu dans /// l'en-tete du fichier, sans dechiffrer quoi que ce soit. @@ -94,7 +102,8 @@ function VaultNeedsAuth: Boolean; /// reinstallation, fichier altere) - dans ce cas il faut retelecharger. /// ASyncPwd est vide pour un fichier ecrit avant que le mot de passe y soit /// range : l'appelant redemande alors, et le prochain enregistrement le pose. -function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean; +function LoadVault(out APlainJson, AETag, ASyncPwd: string; + const APin: string = ''): Boolean; /// Pourquoi la derniere lecture a echoue. Vide apres un succes. /// @@ -129,7 +138,11 @@ implementation {$IF DEFINED(ANDROID)} uses - Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers; + // PMA.Crypto pour PBKDF2 et SecureRandomBytes : le code de l'application + // derive sa cle exactement comme le conteneur de sync, avec la meme + // implementation eprouvee contre le vecteur de test. + Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers, + PMA.Crypto; {$ENDIF} const @@ -155,6 +168,22 @@ const // et s'il faut authentifier d'abord. VER_PLAIN = 1; // AES Keystore sans contrainte VER_WRAP = 3; // AES aleatoire enveloppee par la cle publique RSA + // CODE CHOISI DANS L'APPLICATION, deux couches superposees : + // interieur : AES-GCM sous PBKDF2(code) -> il faut le code + // exterieur : cle AES du Keystore SANS contrainte -> il faut l'appareil + // + // C'est le seul schema qui survive a un changement de verrouillage d'ecran. + // VER_WRAP y meurt par construction : Android invalide une cle exigeant + // l'authentification des que le code de l'ecran change, precisement pour + // qu'un nouveau code n'ouvre pas d'anciennes donnees. La cle sans contrainte, + // elle, n'est pas invalidee - et le code de l'application rend la protection + // que cela retire. + VER_PIN = 4; + // 100k et non 600k : l'entropie d'un code a quatre ou six chiffres est + // faible, et multiplier les tours ralentit surtout l'usage honnete. La vraie + // barriere est la couche exterieure, qui exige l'appareil. + PIN_ITERS = 100000; + SALT_LEN = 16; RSA_TRANSFORM = 'RSA/ECB/OAEPWithSHA-256AndMGF1Padding'; var @@ -718,6 +747,26 @@ begin end; {$ENDIF} +function VaultNeedsPin: Boolean; +{$IF DEFINED(ANDROID)} +var + LRaw: TBytes; +begin + Result := False; + if not TFile.Exists(VaultPath) then Exit; + try + LRaw := TFile.ReadAllBytes(VaultPath); + Result := (Length(LRaw) > 0) and (LRaw[0] = VER_PIN); + except + // Illisible : l'appelant le decouvrira en tentant de l'ouvrir. + end; +end; +{$ELSE} +begin + Result := False; +end; +{$ENDIF} + function VaultNeedsAuth: Boolean; var LFs: TFileStream; @@ -736,11 +785,11 @@ begin end; procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; - ARequireAuth, AFreshKey: Boolean); + ARequireAuth, AFreshKey: Boolean; const APin: string); {$IF DEFINED(ANDROID)} var LWrap: TJSONObject; - LPlain, LIv, LCt, LOut, LAesRaw, LWrapped: TBytes; + LPlain, LIv, LCt, LOut, LAesRaw, LWrapped, LSalt, LPinKey, LInner: TBytes; LCipher: JCipherX; LGotAuth: Boolean; LOfs: Integer; @@ -758,6 +807,48 @@ begin LWrap.Free; end; + if APin <> '' then + begin + // COUCHE INTERIEURE : le code. Sel neuf a chaque ecriture - il ne coute + // rien et evite qu'un meme code produise deux fois la meme cle. + LSalt := SecureRandomBytes(SALT_LEN); + LPinKey := DeriveKeyPBKDF2Native(APin, LSalt, PIN_ITERS, 32); + if LPinKey = nil then + LPinKey := DeriveKeyPBKDF2(APin, LSalt, PIN_ITERS, 32); + LIv := RandomBytes(IV_LEN); + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, + TJSecretKeySpecX.JavaClass.init(ToJava(LPinKey), StringToJString('AES')), + TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); + LCt := FromJava(LCipher.doFinal(ToJava(LPlain))); + + // Sel et iterations voyagent EN CLAIR avec la charge : ils ne sont pas + // secrets, et sans eux le code ne redonnerait pas la meme cle. + SetLength(LInner, SALT_LEN + 4 + IV_LEN + Length(LCt)); + LOfs := 0; + Move(LSalt[0], LInner[LOfs], SALT_LEN); Inc(LOfs, SALT_LEN); + LInner[LOfs] := Byte(PIN_ITERS shr 24); LInner[LOfs+1] := Byte(PIN_ITERS shr 16); + LInner[LOfs+2] := Byte(PIN_ITERS shr 8); LInner[LOfs+3] := Byte(PIN_ITERS); + Inc(LOfs, 4); + Move(LIv[0], LInner[LOfs], IV_LEN); Inc(LOfs, IV_LEN); + Move(LCt[0], LInner[LOfs], Length(LCt)); + + // COUCHE EXTERIEURE : la cle AES du Keystore, sans contrainte. Elle lie le + // fichier a l'appareil sans jamais etre invalidee par un changement de + // verrouillage d'ecran - c'est tout l'objet de ce format. + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureAesKey); + LIv := FromJava(LCipher.getIV); + LCt := FromJava(LCipher.doFinal(ToJava(LInner))); + + SetLength(LOut, 1 + Length(LIv) + Length(LCt)); + LOut[0] := VER_PIN; + Move(LIv[0], LOut[1], Length(LIv)); + Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt)); + TFile.WriteAllBytes(VaultPath, LOut); + Exit; + end; + LGotAuth := False; LWrapped := nil; if ARequireAuth then @@ -839,14 +930,15 @@ begin end; {$ENDIF} -function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean; +function LoadVault(out APlainJson, AETag, ASyncPwd: string; + const APin: string = ''): Boolean; {$IF DEFINED(ANDROID)} var - LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw: TBytes; + LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw, LInner, LSalt, LPinKey: TBytes; LCipher: JCipherX; LWrap: TJSONObject; LStore: JKeyStoreX; - LLen, LOfs: Integer; + LLen, LOfs, LIter: Integer; begin Result := False; APlainJson := ''; @@ -859,7 +951,47 @@ begin LRaw := TFile.ReadAllBytes(VaultPath); if Length(LRaw) < 1 then Exit; - if LRaw[0] = VER_WRAP then + if LRaw[0] = VER_PIN then + begin + if APin = '' then + begin + FLastStoreError := 'Code requis'; + Exit; + end; + // Couche exterieure d'abord : la cle de l'appareil, sans authentification. + SetLength(LIv, IV_LEN); + Move(LRaw[1], LIv[0], IV_LEN); + SetLength(LCt, Length(LRaw) - 1 - IV_LEN); + Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt)); + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureAesKey, + TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); + LInner := FromJava(LCipher.doFinal(ToJava(LCt))); + + if Length(LInner) < SALT_LEN + 4 + IV_LEN + 16 then Exit; + LOfs := 0; + SetLength(LSalt, SALT_LEN); + Move(LInner[LOfs], LSalt[0], SALT_LEN); Inc(LOfs, SALT_LEN); + LIter := (LInner[LOfs] shl 24) or (LInner[LOfs+1] shl 16) + or (LInner[LOfs+2] shl 8) or LInner[LOfs+3]; + Inc(LOfs, 4); + SetLength(LIv, IV_LEN); + Move(LInner[LOfs], LIv[0], IV_LEN); Inc(LOfs, IV_LEN); + SetLength(LCt, Length(LInner) - LOfs); + Move(LInner[LOfs], LCt[0], Length(LCt)); + + // Couche interieure : le code. Un code faux echoue ICI, sur le tag GCM, + // et se lit donc comme une alteration - c'est le meme evenement. + LPinKey := DeriveKeyPBKDF2Native(APin, LSalt, LIter, 32); + if LPinKey = nil then + LPinKey := DeriveKeyPBKDF2(APin, LSalt, LIter, 32); + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, + TJSecretKeySpecX.JavaClass.init(ToJava(LPinKey), StringToJString('AES')), + TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); + LPlain := FromJava(LCipher.doFinal(ToJava(LCt))); + end + else if LRaw[0] = VER_WRAP then begin if Length(LRaw) < 3 then Exit; LLen := (LRaw[1] shl 8) or LRaw[2]; diff --git a/android-client/web/app.html b/android-client/web/app.html index c1018b8..2aac1a5 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -261,6 +261,14 @@ type="password" autocomplete="off">