fix: 1.0.2 hotfix — heal wallets broken by per-build random crypto secrets

CI ran tool/generate_new_secrets.dart unpinned, so every release binary
shipped different AES keys/salts for the stored PIN and wallet passwords.
Updating the app then failed to decrypt what the previous build stored:
'Invalid or corrupted pad block' at PIN entry and the 'Corrupted seeds'
dialog, with wallet files fully intact.

- entities/encrypt.dart: legacy-key fallback decode for PIN and wallet
  password blobs (candidate sets recovered from the shipped 1.0.1 binary;
  output validated to reject PKCS7 false positives)
- key_service/auth_service: on successful legacy decode, re-encrypt under
  the pinned key — installs self-heal at first unlock
- all build workflows: inject the eight pinned CRYPTO_* repo secrets after
  secret generation and hard-fail if any is missing, so crypto salts can
  never silently rotate again (values pinned via
  ~keys/set-gitea-crypto-secrets.sh)
- bump 1.0.2+12
This commit is contained in:
jwinterm
2026-08-29 14:53:18 -04:00
parent 8db45c5842
commit d8aab70fa7
9 changed files with 282 additions and 3 deletions
+21 -1
View File
@@ -89,12 +89,31 @@ class AuthService with Store {
Future<bool> authenticate(String pin) async {
final regularKey = generateStoreKeyFor(key: SecretStoreKey.pinCodePassword);
final encodedRegularPin = await secureStorage.read(key: regularKey);
final decodedRegularPin = decodedPinCode(pin: encodedRegularPin!);
if (encodedRegularPin == null) {
return false;
}
String? decodedRegularPin;
try {
decodedRegularPin = decodedPinCode(pin: encodedRegularPin);
} catch (e) {
printV("Primary pin decode failed (legacy-build blob?): $e");
}
if (decodedRegularPin == pin) {
return true;
}
// Legacy-build fallback: the stored blob was encrypted by a build whose
// generated crypto secrets differed (pre-1.0.2 CI rotated them every
// build). If a legacy key decodes to the entered PIN, accept it and
// re-encode the blob under the current key so this install heals.
final legacyDecodedPin = decodedPinCodeLegacy(pin: encodedRegularPin);
if (legacyDecodedPin != null && legacyDecodedPin == pin) {
await secureStorage.write(key: regularKey, value: encodedPinCode(pin: pin));
return true;
}
// Check for duress pin
final duressKey = generateStoreKeyFor(key: SecretStoreKey.duressPinCodePassword);
final encodedDuressPin = await secureStorage.read(key: duressKey);
@@ -106,6 +125,7 @@ class AuthService with Store {
} catch (e) {
printV("Failed to decode duress pin: $e");
}
decodedDuressPin ??= decodedPinCodeLegacy(pin: encodedDuressPin);
}
if (decodedDuressPin == pin) {
+13 -1
View File
@@ -43,7 +43,19 @@ class KeyService {
'restore this wallet from its seed.');
}
return decodeWalletPassword(password: encodedPassword);
try {
return decodeWalletPassword(password: encodedPassword);
} catch (e) {
// Legacy-build fallback: blobs written by a build whose generated
// crypto secrets differed (pre-1.0.2 CI rotated them every build).
// On success, re-encode under the current key so this install heals.
final legacy = decodeWalletPasswordLegacy(password: encodedPassword);
if (legacy != null) {
await saveWalletPassword(walletName: walletName, password: legacy);
return legacy;
}
rethrow;
}
}
Future<void> saveWalletPassword({required String walletName, required String password}) async {
+67
View File
@@ -54,3 +54,70 @@ String decodeWalletPassword({required String password}) {
return decrypt(source: source, key: _key);
}
// ---------------------------------------------------------------------------
// Legacy-build compatibility (1.0.2 hotfix).
//
// CI builds before 1.0.2 regenerated the crypto secrets on EVERY build
// (tool/generate_new_secrets.dart runs unpinned in the workflow), so each
// shipped binary encrypted the stored PIN / wallet passwords under different
// keys. Updating the app then broke decryption of everything the previous
// build had stored: "Invalid or corrupted pad block" at PIN entry and the
// "Corrupted seeds" dialog, with wallet files themselves perfectly intact.
//
// The values below are recovered from shipped release binaries (string table
// of libapp.so). On a primary decrypt failure the callers retry with these
// and re-encrypt under the current pinned key on success, so every install
// self-heals on its first successful unlock. A wrong key can slip past
// PKCS7 unpadding (~0.4%), so decoded output is validated before acceptance.
// 32-hex `key` candidates (PIN blobs). Includes every plausible constant from
// the 1.0.1 Android string table; wrong entries fail validation harmlessly.
const _legacyPinKeys = [
'51f918406bc771b5827e2479ce24dae0',
'5a0e830aa102994045c19a4fef0d3156',
'76d489cc1eb5704e6e4bea65bb2618b3',
'bba3031d10f37f741c9bcd2b26227cba',
'd6031998d1b3bbfebf59cc9bbff9aee1',
'5eeefca380d02919dc2c6558bb6d8a5d',
];
// shortKey+walletSalt concatenations (wallet-password blobs), 1.0.1 Android.
const _legacyWalletPasswordKeys = [
'b77ed95e692db912ff0463821f37e159',
'be2a818b190ab41010f0e2871f37e159',
];
String? _tryDecrypt({required String source, required String key}) {
try {
final decrypted = decrypt(source: source, key: key);
final printable =
decrypted.isNotEmpty && decrypted.codeUnits.every((c) => c >= 0x20 && c < 0x7f);
return printable ? decrypted : null;
} catch (_) {
return null;
}
}
/// Decode a stored wallet password written by a build with rotated secrets.
/// Returns null when no legacy key fits.
String? decodeWalletPasswordLegacy({required String password}) {
for (final key in _legacyWalletPasswordKeys) {
final decrypted = _tryDecrypt(source: password, key: key);
if (decrypted != null) return decrypted;
}
return null;
}
/// Decode a stored PIN blob written by a build with rotated secrets.
/// The plaintext layout is 32-char salt + digits; returns null if nothing fits.
String? decodedPinCodeLegacy({required String pin}) {
for (final key in _legacyPinKeys) {
final decrypted = _tryDecrypt(source: pin, key: key);
if (decrypted == null || decrypted.length <= 32) continue;
final candidate = decrypted.substring(32);
final digitsOnly = candidate.codeUnits.every((c) => c >= 0x30 && c <= 0x39);
if (digitsOnly) return candidate;
}
return null;
}