fix(app): no sheet is left busy by an error nothing expected

An instance's salt of another length made the device's cryptography throw
an ArgumentError, a wrapping's nonce a RangeError, and libsodium or the
keyring may throw their own; none was a refusal, so the step's error
escaped and the sheet stayed busy, which can't be closed. halkyon_keys
now checks the lengths of what comes from elsewhere before libsodium sees
it (KeyRefusal.malformed: a salt, a wrapping, a sealed key's box, a sealed
payload), the device's cryptography turns anything else into the general
failure, and so does every password cubit, the error logged by its type
alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 14:41:28 +02:00
commit cf69e62ad4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 318 additions and 32 deletions

View file

@ -157,6 +157,16 @@ AccountKeys unwrapAccountKeys(
signingPublicKey.length != sodium.crypto.sign.publicKeyBytes) {
throw const KeyRefused(KeyRefusal.notTheAccountsKeys);
}
// Checked before libsodium sees them: of another length it throws a
// RangeError, which nothing above expects.
final aead = sodium.crypto.aeadXChaCha20Poly1305IETF;
if (wrapped.nonce.length != aead.nonceBytes ||
wrapped.cipherText.length !=
sodium.crypto.box.secretKeyBytes +
sodium.crypto.sign.secretKeyBytes +
aead.aBytes) {
throw const KeyRefused(KeyRefusal.malformed);
}
final Uint8List plainText;
try {
plainText = sodium.crypto.aeadXChaCha20Poly1305IETF.decrypt(

View file

@ -88,6 +88,11 @@ DataKey openDataKey(
throw const KeyRefused(KeyRefusal.forgedSignature);
}
final box = Uint8List.sublistView(sealed, signatureBytes);
// Of a box's least length or longer, as libsodium throws a RangeError for
// shorter ones, which nothing above expects.
if (box.length < sodium.crypto.box.sealBytes) {
throw const KeyRefused(KeyRefusal.malformed);
}
if (!sodium.crypto.sign.verifyDetached(
message: _signed(keys.encryptionPublicKey, box),
signature: Uint8List.sublistView(sealed, 0, signatureBytes),

View file

@ -27,6 +27,11 @@ enum KeyRefusal() {
/// isn't the author's.
forgedSignature,
/// Bytes from elsewhere (an instance's salt, a wrapping, a sealed key)
/// aren't of the length they must have: refused before anything reads
/// them, as a device must never trust what an instance sends.
malformed,
/// Argon2id's costs named for an account are below what any account may
/// have, or of another algorithm.
weakPasswordKdf,

View file

@ -129,11 +129,7 @@ PasswordKeys derivePasswordKeys(
PasswordKdf kdf = PasswordKdf.current,
}) {
if (salt.length != sodium.crypto.pwhash.saltBytes) {
throw ArgumentError.value(
salt.length,
'salt',
'must be ${sodium.crypto.pwhash.saltBytes} bytes',
);
throw const KeyRefused(KeyRefusal.malformed);
}
final masterKey = sodium.crypto.pwhash.callStr(
outLen: _keyBytes,

View file

@ -86,7 +86,8 @@ Uint8List openContent(
throw const KeyRefused(KeyRefusal.forgedSignature);
}
final aead = sodium.crypto.aeadXChaCha20Poly1305IETF;
if (sealed.nonce.length != aead.nonceBytes) {
if (sealed.nonce.length != aead.nonceBytes ||
sealed.payload.length < aead.aBytes) {
throw const KeyRefused(KeyRefusal.wrongKeyOrTampered);
}
try {