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
8 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 {

View file

@ -137,10 +137,6 @@ Future<void> main() async {
for (final tampered in [
WrappedAccountKeys(nonce: wrapped.nonce, cipherText: cipherText),
WrappedAccountKeys(nonce: nonce, cipherText: wrapped.cipherText),
WrappedAccountKeys(
nonce: wrapped.nonce,
cipherText: wrapped.cipherText.sublist(1),
),
]) {
expect(
() => unwrap(tampered, password.keyEncryptionKey),
@ -177,6 +173,33 @@ Future<void> main() async {
refusedFor(KeyRefusal.notTheAccountsKeys),
);
});
test('wrappings of other lengths, as an instance might send them, are '
'refused rather than thrown on', () {
for (final (nonce, cipherText) in [
(Uint8List(23), wrapped.cipherText),
(Uint8List(25), wrapped.cipherText),
(Uint8List(0), Uint8List(0)),
(wrapped.nonce, Uint8List(15)),
(wrapped.nonce, wrapped.cipherText.sublist(1)),
(wrapped.nonce, Uint8List(wrapped.cipherText.length + 1)),
]) {
expect(
() => unwrap(
WrappedAccountKeys(nonce: nonce, cipherText: cipherText),
password.keyEncryptionKey,
),
throwsA(
isA<KeyRefused>().having(
(refused) => refused.reason,
'reason',
KeyRefusal.malformed,
),
),
reason: '${nonce.length} / ${cipherText.length}',
);
}
});
});
test('wrapped again under the recovery key, the phrase opens them', () {

View file

@ -109,6 +109,24 @@ Future<void> main() async {
throwsArgumentError,
);
});
test(
'a sealed key too short to hold a box is refused, never thrown on',
() {
// Signed by the sealer, so only the box's own length can fail it.
final signature = sodium.crypto.sign.detached(
message: Uint8List(0),
secretKey: account.signing.secretKey,
);
for (final box in [Uint8List(1), Uint8List(47)]) {
expect(
() => open(Uint8List.fromList([...signature, ...box])),
throwsA(isA<KeyRefused>()),
reason: '${box.length}',
);
}
},
);
});
group('content, sealed and signed', () {

View file

@ -54,17 +54,29 @@ Future<void> main() async {
expect(keys.srpPrivateValue, greaterThan(BigInt.zero));
});
test('a salt of another length is refused', () {
expect(
() => derivePasswordKeys(
sodium,
password: 'a password',
salt: Uint8List(15),
kdf: cheap,
),
throwsArgumentError,
);
});
test(
'a salt of another length, as an instance might send one, is refused',
() {
for (final length in [0, 15, 17]) {
expect(
() => derivePasswordKeys(
sodium,
password: 'a password',
salt: Uint8List(length),
kdf: cheap,
),
throwsA(
isA<KeyRefused>().having(
(refused) => refused.reason,
'reason',
KeyRefusal.malformed,
),
),
reason: '$length',
);
}
},
);
test('new salts are 16 random bytes', () {
final first = newPasswordSalt(sodium);