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 {