Skip to main content

max / makenotwork

synckit-client: bump chacha20poly1305 to 0.11 Closes the crypto pair; sha2 landed in fb66fe9b. 477 tests green, clippy clean. 0.11 deprecates Nonce::from_slice in favour of TryFrom, so the four XNonce construction sites move over: three take a fixed-size array and one a length-validated slice. Passing by reference at the call sites is the only other change. This is the on-disk and on-wire ciphertext envelope, so the bump is gated on a known-answer test rather than on roundtrips, which would pass even if the envelope changed shape. The vector is computed outside RustCrypto entirely: HChaCha20 implemented against the draft-irtf-cfrg- xchacha-03 §2.2.1 vector, then the IETF ChaCha20-Poly1305 leg through python cryptography. Pinned in envelope_matches_independent_known_answer, covering both the v2 tagged and the legacy empty-AAD forms, plus a relocation check so the vector proves AAD binding and not just decryption. Verified both directions across the bump: the pinned 0.10-era ciphertext decrypts under 0.11, and a payload sealed under 0.11 decrypts under the independent implementation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-22 18:35 UTC
Commit: 32a25ffb1cd2591f886bd4e70001943dfcec7fb5
Parent: 10088c8
3 files changed, +100 insertions, -52 deletions
@@ -4,12 +4,12 @@ version = 4
4 4
5 5 [[package]]
6 6 name = "aead"
7 - version = "0.5.2"
7 + version = "0.6.1"
8 8 source = "registry+https://github.com/rust-lang/crates.io-index"
9 - checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
9 + checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
10 10 dependencies = [
11 - "crypto-common 0.1.7",
12 - "generic-array",
11 + "crypto-common 0.2.2",
12 + "inout 0.2.2",
13 13 ]
14 14
15 15 [[package]]
@@ -19,7 +19,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
19 19 checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
20 20 dependencies = [
21 21 "cfg-if",
22 - "cipher",
22 + "cipher 0.4.4",
23 23 "cpufeatures 0.2.17",
24 24 ]
25 25
@@ -314,7 +314,7 @@ version = "0.1.2"
314 314 source = "registry+https://github.com/rust-lang/crates.io-index"
315 315 checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
316 316 dependencies = [
317 - "cipher",
317 + "cipher 0.4.4",
318 318 ]
319 319
320 320 [[package]]
@@ -335,37 +335,26 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
335 335
336 336 [[package]]
337 337 name = "chacha20"
338 - version = "0.9.1"
339 - source = "registry+https://github.com/rust-lang/crates.io-index"
340 - checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
341 - dependencies = [
342 - "cfg-if",
343 - "cipher",
344 - "cpufeatures 0.2.17",
345 - ]
346 -
347 - [[package]]
348 - name = "chacha20"
349 338 version = "0.10.1"
350 339 source = "registry+https://github.com/rust-lang/crates.io-index"
351 340 checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
352 341 dependencies = [
353 342 "cfg-if",
343 + "cipher 0.5.2",
354 344 "cpufeatures 0.3.0",
355 345 "rand_core 0.10.1",
356 346 ]
357 347
358 348 [[package]]
359 349 name = "chacha20poly1305"
360 - version = "0.10.1"
350 + version = "0.11.0"
361 351 source = "registry+https://github.com/rust-lang/crates.io-index"
362 - checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
352 + checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
363 353 dependencies = [
364 354 "aead",
365 - "chacha20 0.9.1",
366 - "cipher",
355 + "chacha20",
356 + "cipher 0.5.2",
367 357 "poly1305",
368 - "zeroize",
369 358 ]
370 359
371 360 [[package]]
@@ -389,11 +378,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
389 378 checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
390 379 dependencies = [
391 380 "crypto-common 0.1.7",
392 - "inout",
393 - "zeroize",
381 + "inout 0.1.4",
382 + ]
383 +
384 + [[package]]
385 + name = "cipher"
386 + version = "0.5.2"
387 + source = "registry+https://github.com/rust-lang/crates.io-index"
388 + checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
389 + dependencies = [
390 + "block-buffer 0.12.1",
391 + "crypto-common 0.2.2",
392 + "inout 0.2.2",
394 393 ]
395 394
396 395 [[package]]
396 + name = "cmov"
397 + version = "0.5.4"
398 + source = "registry+https://github.com/rust-lang/crates.io-index"
399 + checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
400 +
401 + [[package]]
397 402 name = "concurrent-queue"
398 403 version = "2.5.0"
399 404 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -465,7 +470,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
465 470 checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
466 471 dependencies = [
467 472 "generic-array",
468 - "rand_core 0.6.4",
469 473 "typenum",
470 474 ]
471 475
@@ -475,7 +479,18 @@ version = "0.2.2"
475 479 source = "registry+https://github.com/rust-lang/crates.io-index"
476 480 checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
477 481 dependencies = [
482 + "getrandom 0.4.1",
478 483 "hybrid-array",
484 + "rand_core 0.10.1",
485 + ]
486 +
487 + [[package]]
488 + name = "ctutils"
489 + version = "0.4.2"
490 + source = "registry+https://github.com/rust-lang/crates.io-index"
491 + checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
492 + dependencies = [
493 + "cmov",
479 494 ]
480 495
481 496 [[package]]
@@ -1175,6 +1190,15 @@ dependencies = [
1175 1190 ]
1176 1191
1177 1192 [[package]]
1193 + name = "inout"
1194 + version = "0.2.2"
1195 + source = "registry+https://github.com/rust-lang/crates.io-index"
1196 + checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
1197 + dependencies = [
1198 + "hybrid-array",
1199 + ]
1200 +
1201 + [[package]]
1178 1202 name = "ipnet"
1179 1203 version = "2.11.0"
1180 1204 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1421,12 +1445,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1421 1445 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
1422 1446
1423 1447 [[package]]
1424 - name = "opaque-debug"
1425 - version = "0.3.1"
1426 - source = "registry+https://github.com/rust-lang/crates.io-index"
1427 - checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
1428 -
1429 - [[package]]
1430 1448 name = "openssl"
1431 1449 version = "0.10.75"
1432 1450 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1571,12 +1589,11 @@ dependencies = [
1571 1589
1572 1590 [[package]]
1573 1591 name = "poly1305"
1574 - version = "0.8.0"
1592 + version = "0.9.1"
1575 1593 source = "registry+https://github.com/rust-lang/crates.io-index"
1576 - checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
1594 + checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
1577 1595 dependencies = [
1578 - "cpufeatures 0.2.17",
1579 - "opaque-debug",
1596 + "cpufeatures 0.3.0",
1580 1597 "universal-hash",
1581 1598 ]
1582 1599
@@ -1638,7 +1655,7 @@ version = "0.10.2"
1638 1655 source = "registry+https://github.com/rust-lang/crates.io-index"
1639 1656 checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
1640 1657 dependencies = [
1641 - "chacha20 0.10.1",
1658 + "chacha20",
1642 1659 "getrandom 0.4.1",
1643 1660 "rand_core 0.10.1",
1644 1661 ]
@@ -1648,9 +1665,6 @@ name = "rand_core"
1648 1665 version = "0.6.4"
1649 1666 source = "registry+https://github.com/rust-lang/crates.io-index"
1650 1667 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1651 - dependencies = [
1652 - "getrandom 0.2.17",
1653 - ]
1654 1668
1655 1669 [[package]]
1656 1670 name = "rand_core"
@@ -2422,12 +2436,12 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2422 2436
2423 2437 [[package]]
2424 2438 name = "universal-hash"
2425 - version = "0.5.1"
2439 + version = "0.6.1"
2426 2440 source = "registry+https://github.com/rust-lang/crates.io-index"
2427 - checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
2441 + checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
2428 2442 dependencies = [
2429 - "crypto-common 0.1.7",
2430 - "subtle",
2443 + "crypto-common 0.2.2",
2444 + "ctutils",
2431 2445 ]
2432 2446
2433 2447 [[package]]
@@ -21,7 +21,7 @@ testing = []
21 21
22 22 [dependencies]
23 23 # Encryption
24 - chacha20poly1305 = "0.10"
24 + chacha20poly1305 = "0.11"
25 25 argon2 = "0.5"
26 26 rand = "0.10"
27 27 base64 = "0.22"
@@ -207,10 +207,10 @@ pub fn wrap_master_key(master_key: &[u8; KEY_SIZE], password: &str) -> Result<St
207 207 rand::rng().fill_bytes(&mut nonce_bytes);
208 208
209 209 let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
210 - let nonce = XNonce::from_slice(&nonce_bytes);
210 + let nonce = XNonce::from(nonce_bytes);
211 211
212 212 let ciphertext = cipher
213 - .encrypt(nonce, master_key.as_ref())
213 + .encrypt(&nonce, master_key.as_ref())
214 214 .map_err(|e| SyncKitError::Crypto(format!("wrap encrypt: {e}")))?;
215 215
216 216 let envelope = KeyEnvelope {
@@ -259,10 +259,11 @@ pub fn unwrap_master_key(envelope_json: &str, password: &str) -> Result<[u8; KEY
259 259 derive_wrapping_key_with_params(password, &salt, envelope.m, envelope.t, envelope.p)?;
260 260
261 261 let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
262 - let nonce = XNonce::from_slice(&nonce_bytes);
262 + let nonce =
263 + XNonce::try_from(&nonce_bytes[..]).expect("nonce length was validated as NONCE_SIZE above");
263 264
264 265 let mut plaintext = cipher
265 - .decrypt(nonce, ciphertext.as_ref())
266 + .decrypt(&nonce, ciphertext.as_ref())
266 267 .map_err(|_| SyncKitError::DecryptionFailed)?;
267 268
268 269 if plaintext.len() != KEY_SIZE {
@@ -354,11 +355,11 @@ fn seal(plaintext: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec
354 355 rand::rng().fill_bytes(&mut nonce_bytes);
355 356
356 357 let cipher = XChaCha20Poly1305::new(master_key.into());
357 - let nonce = XNonce::from_slice(&nonce_bytes);
358 + let nonce = XNonce::from(nonce_bytes);
358 359
359 360 let ciphertext = cipher
360 361 .encrypt(
361 - nonce,
362 + &nonce,
362 363 Payload {
363 364 msg: plaintext,
364 365 aad,
@@ -380,10 +381,11 @@ fn open(blob: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>>
380 381 }
381 382 let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE);
382 383 let cipher = XChaCha20Poly1305::new(master_key.into());
383 - let nonce = XNonce::from_slice(nonce_bytes);
384 + let nonce = XNonce::try_from(nonce_bytes)
385 + .expect("split_at(NONCE_SIZE) yields exactly NONCE_SIZE bytes");
384 386 cipher
385 387 .decrypt(
386 - nonce,
388 + &nonce,
387 389 Payload {
388 390 msg: ciphertext,
389 391 aad,
@@ -1600,6 +1602,38 @@ mod tests {
1600 1602 ));
1601 1603 }
1602 1604
1605 + /// Known-answer vectors for the XChaCha20-Poly1305 envelope, computed
1606 + /// outside RustCrypto: HChaCha20 implemented against the test vector in
1607 + /// draft-irtf-cfrg-xchacha-03 §2.2.1, then the IETF ChaCha20-Poly1305 leg
1608 + /// run through python `cryptography`. This is the ciphertext already on
1609 + /// disk and on the wire, so it must decrypt byte-for-byte forever — a
1610 + /// cipher-crate upgrade that silently changed the envelope would break
1611 + /// every existing user, and the roundtrip tests above would not notice.
1612 + ///
1613 + /// key = 00..1f, nonce = 40..57, plaintext = the string asserted below.
1614 + #[test]
1615 + fn envelope_matches_independent_known_answer() {
1616 + const V2_ENTRY: &str = "sk2:QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuJwl14RY250chudQ2lSbk6V";
1617 + const LEGACY: &str = "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuKvdRcL/9EeoK9LSYyzSJY7";
1618 + const PLAINTEXT: &[u8] = b"synckit envelope v2 known answer";
1619 +
1620 + let mut key = [0u8; KEY_SIZE];
1621 + for (i, b) in key.iter_mut().enumerate() {
1622 + *b = u8::try_from(i).unwrap();
1623 + }
1624 +
1625 + let ctx = AeadContext::entry("notes", "row-1");
1626 + assert_eq!(decrypt_data_aad(V2_ENTRY, &key, &ctx).unwrap(), PLAINTEXT);
1627 + assert_eq!(decrypt_data(LEGACY, &key).unwrap(), PLAINTEXT);
1628 +
1629 + // The AAD is genuinely bound: the same bytes under a different address
1630 + // must fail, or the vector above would prove nothing about binding.
1631 + assert!(matches!(
1632 + decrypt_data_aad(V2_ENTRY, &key, &AeadContext::entry("notes", "row-2")),
1633 + Err(SyncKitError::DecryptionFailed)
1634 + ));
1635 + }
1636 +
1603 1637 #[test]
1604 1638 fn legacy_untagged_still_decrypts_under_aad_reader() {
1605 1639 // A v1 (untagged, empty-AAD) payload must keep decrypting through the