From d062148d4d9932af6745e1148b0d0df2eeb0f54f Mon Sep 17 00:00:00 2001 From: Maciej Kula Date: Sun, 7 Jun 2026 21:37:14 +0900 Subject: [PATCH 1/8] samp(rust): handle group capsule tag collisions --- rust/src/encryption.rs | 114 +++++++++++++++++++-------------------- rust/tests/round_trip.rs | 29 ++++++++++ 2 files changed, 85 insertions(+), 58 deletions(-) diff --git a/rust/src/encryption.rs b/rust/src/encryption.rs index 8c26ab2..84b9b03 100644 --- a/rust/src/encryption.rs +++ b/rust/src/encryption.rs @@ -318,35 +318,18 @@ pub fn build_capsules( Capsules::from_bytes(out).expect("len is multiple of CAPSULE_SIZE by construction") } -pub(crate) fn scan_capsules( - data: &[u8], - eph_pubkey: &EphPubkey, - my_scalar: &Scalar, - nonce: &Nonce, -) -> Option<(usize, ContentKey)> { - let eph_point = eph_pubkey.to_compressed_ristretto(); - let mut shared = ecdh_shared_secret(my_scalar, &eph_point).ok()?; - let my_tag = derive_view_tag(&shared); - let mut kek = derive_key_wrap(&shared, nonce); +fn content_key_from_capsule(data: &[u8], offset: usize, kek: &[u8; 32]) -> ContentKey { + let mut wrapped = [0u8; 32]; + wrapped.copy_from_slice(&data[offset + 1..offset + CAPSULE_SIZE]); + ContentKey::from_bytes(xor32(&wrapped, kek)) +} - let mut offset = 0; - let mut idx = 0; - while offset + CAPSULE_SIZE <= data.len() { - let tag = data[offset]; - if tag == my_tag { - let mut wrapped = [0u8; 32]; - wrapped.copy_from_slice(&data[offset + 1..offset + 33]); - let content_key = ContentKey::from_bytes(xor32(&wrapped, &kek)); - shared.zeroize(); - kek.zeroize(); - return Some((idx, content_key)); - } - offset += CAPSULE_SIZE; - idx += 1; - } - shared.zeroize(); - kek.zeroize(); - None +fn decrypt_group_at(content_key: &ContentKey, nonce: &Nonce, data: &[u8]) -> Option { + let cipher = ChaCha20Poly1305::new(content_key.expose_secret().into()); + cipher + .decrypt(ChaChaNonce::from_slice(nonce.as_bytes()), data) + .map(Plaintext::from_bytes) + .ok() } pub fn encrypt_for_group( @@ -404,38 +387,53 @@ pub fn decrypt_from_group( let after_eph = &content[32..]; let scalar = view_scalar_to_ristretto(my_scalar); - let (capsule_idx, content_key) = - scan_capsules(after_eph, &eph_pubkey, &scalar, nonce).ok_or(SampError::DecryptionFailed)?; - - let cipher = ChaCha20Poly1305::new(content_key.expose_secret().into()); - - if let Some(n) = known_member_count { - let ct_start = n * CAPSULE_SIZE; - if ct_start > after_eph.len() { - return Err(SampError::InsufficientData); - } - let result = cipher - .decrypt( - ChaChaNonce::from_slice(nonce.as_bytes()), - &after_eph[ct_start..], - ) - .map(Plaintext::from_bytes) - .map_err(|_| SampError::DecryptionFailed); - return result; - } + let eph_point = eph_pubkey.to_compressed_ristretto(); + let mut shared = + ecdh_shared_secret(&scalar, &eph_point).map_err(|_| SampError::DecryptionFailed)?; + let my_tag = derive_view_tag(&shared); + let mut kek = derive_key_wrap(&shared, nonce); - let min_n = capsule_idx + 1; - let max_n = (after_eph.len().saturating_sub(16)) / CAPSULE_SIZE; - for n in min_n..=max_n { - let ct_start = n * CAPSULE_SIZE; - if let Ok(plaintext) = cipher.decrypt( - ChaChaNonce::from_slice(nonce.as_bytes()), - &after_eph[ct_start..], - ) { - return Ok(Plaintext::from_bytes(plaintext)); + let result = (|| { + let mut offset = 0; + let mut capsule_idx = 0; + while offset + CAPSULE_SIZE <= after_eph.len() { + if after_eph[offset] == my_tag { + let content_key = content_key_from_capsule(after_eph, offset, &kek); + if let Some(n) = known_member_count { + let ct_start = n + .checked_mul(CAPSULE_SIZE) + .ok_or(SampError::InsufficientData)?; + if ct_start > after_eph.len() { + return Err(SampError::InsufficientData); + } + if let Some(plaintext) = + decrypt_group_at(&content_key, nonce, &after_eph[ct_start..]) + { + return Ok(plaintext); + } + } else { + let max_n = (after_eph.len().saturating_sub(16)) / CAPSULE_SIZE; + for n in capsule_idx + 1..=max_n { + let ct_start = n * CAPSULE_SIZE; + if ct_start >= after_eph.len() { + break; + } + if let Some(plaintext) = + decrypt_group_at(&content_key, nonce, &after_eph[ct_start..]) + { + return Ok(plaintext); + } + } + } + } + offset += CAPSULE_SIZE; + capsule_idx += 1; } - } - Err(SampError::DecryptionFailed) + Err(SampError::DecryptionFailed) + })(); + shared.zeroize(); + kek.zeroize(); + result } #[cfg(test)] diff --git a/rust/tests/round_trip.rs b/rust/tests/round_trip.rs index 1d4952a..561b8b0 100644 --- a/rust/tests/round_trip.rs +++ b/rust/tests/round_trip.rs @@ -7,6 +7,7 @@ use samp::{ decode_thread_content, encode_channel_content, encode_channel_msg, encode_encrypted, encode_group, encode_group_members, encode_public, encode_thread_content, BlockRef, ChannelDescription, ChannelName, ContentType, Nonce, Plaintext, Pubkey, Remark, Seed, + CAPSULE_SIZE, }; use schnorrkel::keys::{ExpansionMode, MiniSecretKey}; @@ -327,6 +328,34 @@ fn group_trial_aead_without_known_n() { assert_eq!(body, b"trial aead test"); } +#[test] +fn group_decrypt_skips_colliding_view_tag_capsules() { + let alice_pk = pubkey_from_seed(&alice_seed()); + let bob_pk = pubkey_from_seed(&bob_seed()); + let members = vec![alice_pk, bob_pk]; + + let nonce = n(0xF1); + let plaintext = pt(b"view tag collision"); + let (eph_pubkey, capsules, ciphertext) = + encrypt_for_group(&plaintext, &members, &nonce, &alice_seed()).unwrap(); + + let mut content = Vec::new(); + content.extend_from_slice(eph_pubkey.as_bytes()); + content.extend_from_slice(capsules.as_bytes()); + content.extend_from_slice(ciphertext.as_bytes()); + content[32] = content[32 + CAPSULE_SIZE]; + + let bob_scalar = sr25519_signing_scalar(&bob_seed()); + assert_eq!( + decrypt_from_group(&content, &nonce, &bob_scalar, Some(2)).unwrap(), + plaintext + ); + assert_eq!( + decrypt_from_group(&content, &nonce, &bob_scalar, None).unwrap(), + plaintext + ); +} + // 1:1 encryption edge cases #[test] From e2f7aaf245873cfae6f1657d6f95b9771f5e9a4b Mon Sep 17 00:00:00 2001 From: Maciej Kula <github@mcjkula.com> Date: Sun, 7 Jun 2026 21:37:40 +0900 Subject: [PATCH 2/8] samp(py): handle group capsule tag collisions --- python/samp-crypto/src/lib.rs | 76 ++++++++++++++++++++------------- python/tests/test_encryption.py | 21 +++++++++ 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/python/samp-crypto/src/lib.rs b/python/samp-crypto/src/lib.rs index c82ae15..a86182a 100644 --- a/python/samp-crypto/src/lib.rs +++ b/python/samp-crypto/src/lib.rs @@ -329,6 +329,12 @@ fn xor32(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { out } +fn content_key_from_capsule(data: &[u8], offset: usize, kek: &[u8; 32]) -> [u8; 32] { + let mut wrapped = [0u8; 32]; + wrapped.copy_from_slice(&data[offset + 1..offset + CAPSULE_SIZE]); + xor32(&wrapped, kek) +} + fn derive_view_tag(shared_secret: &[u8; 32]) -> u8 { let hk = Hkdf::<Sha256>::new(None, shared_secret); let mut tag = [0u8; 1]; @@ -449,9 +455,20 @@ fn encrypt_for_group(plaintext: &[u8], member_pubkeys: Vec<Vec<u8>>, nonce: &[u8 } #[pyfunction] -fn decrypt_from_group(content: &[u8], my_scalar: &[u8], nonce: &[u8], known_n: Option<usize>) -> PyResult<Vec<u8>> { - let ms = Scalar::from_bytes_mod_order(my_scalar.try_into().map_err(|_| err("my_scalar must be 32 bytes"))?); - let n: [u8; 12] = nonce.try_into().map_err(|_| err("nonce must be 12 bytes"))?; +fn decrypt_from_group( + content: &[u8], + my_scalar: &[u8], + nonce: &[u8], + known_n: Option<usize>, +) -> PyResult<Vec<u8>> { + let ms = Scalar::from_bytes_mod_order( + my_scalar + .try_into() + .map_err(|_| err("my_scalar must be 32 bytes"))?, + ); + let n: [u8; 12] = nonce + .try_into() + .map_err(|_| err("nonce must be 12 bytes"))?; if content.len() < 32 { return Err(err("content too short")); } @@ -464,38 +481,39 @@ fn decrypt_from_group(content: &[u8], my_scalar: &[u8], nonce: &[u8], known_n: O let mut offset = 0; let mut capsule_idx = 0; - let mut content_key: Option<[u8; 32]> = None; while offset + CAPSULE_SIZE <= after_eph.len() { if after_eph[offset] == my_tag { - let mut wrapped = [0u8; 32]; - wrapped.copy_from_slice(&after_eph[offset + 1..offset + 33]); - content_key = Some(xor32(&wrapped, &kek)); - break; + let ck = content_key_from_capsule(after_eph, offset, &kek); + let cipher = ChaCha20Poly1305::new((&ck).into()); + if let Some(member_count) = known_n { + let ct_start = member_count + .checked_mul(CAPSULE_SIZE) + .ok_or_else(|| err("content too short"))?; + if ct_start > after_eph.len() { + return Err(err("content too short")); + } + if let Ok(plaintext) = cipher.decrypt(Nonce::from_slice(&n), &after_eph[ct_start..]) + { + return Ok(plaintext); + } + } else { + let max_n = after_eph.len().saturating_sub(16) / CAPSULE_SIZE; + for trial_n in capsule_idx + 1..=max_n { + let ct_start = trial_n * CAPSULE_SIZE; + if ct_start >= after_eph.len() { + break; + } + if let Ok(plaintext) = + cipher.decrypt(Nonce::from_slice(&n), &after_eph[ct_start..]) + { + return Ok(plaintext); + } + } + } } offset += CAPSULE_SIZE; capsule_idx += 1; } - let ck = content_key.ok_or_else(|| err("decryption failed"))?; - let cipher = ChaCha20Poly1305::new((&ck).into()); - - if let Some(member_count) = known_n { - let ct_start = member_count * CAPSULE_SIZE; - if ct_start > after_eph.len() { - return Err(err("content too short")); - } - return cipher.decrypt(Nonce::from_slice(&n), &after_eph[ct_start..]) - .map_err(|_| err("decryption failed")); - } - - let min_n = capsule_idx + 1; - let max_n = after_eph.len().saturating_sub(16) / CAPSULE_SIZE; - for trial_n in min_n..=max_n { - let ct_start = trial_n * CAPSULE_SIZE; - if ct_start >= after_eph.len() { break; } - if let Ok(plaintext) = cipher.decrypt(Nonce::from_slice(&n), &after_eph[ct_start..]) { - return Ok(plaintext); - } - } Err(err("decryption failed")) } diff --git a/python/tests/test_encryption.py b/python/tests/test_encryption.py index 2421ee3..bb5b817 100644 --- a/python/tests/test_encryption.py +++ b/python/tests/test_encryption.py @@ -62,6 +62,27 @@ def test_encrypt_for_group_random_returns_nonce_needed_for_decrypt() -> None: assert decrypted == plaintext +def test_decrypt_from_group_skips_colliding_view_tag_capsules() -> None: + sender = samp.Seed.from_bytes(bytes([0xAA] * 32)) + alice = samp.Seed.from_bytes(bytes([0xAA] * 32)) + bob = samp.Seed.from_bytes(bytes([0xBB] * 32)) + nonce = samp.nonce_from_bytes(bytes([0xF1] * 12)) + plaintext = samp.plaintext_from_bytes(b"view tag collision") + eph, capsules, ciphertext = samp.encrypt_for_group( + plaintext, + [samp.public_from_seed(alice), samp.public_from_seed(bob)], + nonce, + sender, + ) + + content = bytearray(bytes(eph) + bytes(capsules) + bytes(ciphertext)) + content[32] = content[32 + samp.CAPSULE_SIZE] + scalar = samp.sr25519_signing_scalar(bob) + + assert samp.decrypt_from_group(bytes(content), scalar, nonce, 2) == plaintext + assert samp.decrypt_from_group(bytes(content), scalar, nonce) == plaintext + + def test_derive_group_ephemeral_returns_bytes() -> None: seed = samp.Seed.from_bytes(bytes([0xAA] * 32)) nonce = samp.nonce_from_bytes(bytes([0x01] * 12)) From b126dcb93495d268a3286d2f1f27e200ba2444a6 Mon Sep 17 00:00:00 2001 From: Maciej Kula <github@mcjkula.com> Date: Sun, 7 Jun 2026 21:38:33 +0900 Subject: [PATCH 3/8] samp(ts): handle group capsule tag collisions --- typescript/src/crypto.ts | 82 ++++++++++++++++------------------ typescript/test/crypto.test.ts | 30 +++++++++++++ 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/typescript/src/crypto.ts b/typescript/src/crypto.ts index 7e3bb9e..e4f5508 100644 --- a/typescript/src/crypto.ts +++ b/typescript/src/crypto.ts @@ -232,27 +232,19 @@ export function buildCapsules( return Capsules.fromBytes(out); } -function scanCapsules( - data: Uint8Array, - ephPubkey: EphPubkey, - myScalar: ViewScalar, - nonce: Nonce, -): { index: number; contentKey: ContentKey } | null { - const shared = ecdhSharedSecret(viewScalarToBigInt(myScalar), ephPubkey); - const myTag = deriveViewTagByte(shared); - const kek = deriveKeyWrap(shared, nonce); - let offset = 0; - let idx = 0; - while (offset + CAPSULE_SIZE <= data.length) { - if (data[offset] === myTag) { - const out = new Uint8Array(32); - for (let j = 0; j < 32; j++) out[j] = data[offset + 1 + j]! ^ kek[j]!; - return { index: idx, contentKey: ContentKey.fromBytes(out) }; - } - offset += CAPSULE_SIZE; - idx++; +function contentKeyFromCapsule(data: Uint8Array, offset: number, kek: Uint8Array): ContentKey { + const out = new Uint8Array(32); + for (let j = 0; j < 32; j++) out[j] = data[offset + 1 + j]! ^ kek[j]!; + return ContentKey.fromBytes(out); +} + +function tryDecryptGroup(contentKey: ContentKey, nonce: Nonce, data: Uint8Array): Plaintext | null { + const cipher = chacha20poly1305(ContentKey.exposeSecret(contentKey), Nonce.chachaBytes(nonce)); + try { + return Plaintext.fromBytes(cipher.decrypt(data)); + } catch { + return null; } - return null; } export function encryptForGroup( @@ -290,30 +282,34 @@ export function decryptFromGroup( if (content.length < 32) throw new SampError("insufficient data"); const ephPubkey = EphPubkey.fromBytes(content.slice(0, 32)); const afterEph = content.slice(32); - const scan = scanCapsules(afterEph, ephPubkey, myScalar, nonce); - if (scan === null) throw new SampError("decryption failed"); - const ckRaw = ContentKey.exposeSecret(scan.contentKey); - const cipher = chacha20poly1305(ckRaw, Nonce.chachaBytes(nonce)); - if (knownN !== undefined) { - const ctStart = knownN * CAPSULE_SIZE; - if (ctStart > afterEph.length) throw new SampError("insufficient data"); - try { - return Plaintext.fromBytes(cipher.decrypt(afterEph.slice(ctStart))); - } catch { - throw new SampError("decryption failed"); - } - } - const minN = scan.index + 1; - const maxN = Math.floor((afterEph.length - 16) / CAPSULE_SIZE); - for (let n = minN; n <= maxN; n++) { - const ctStart = n * CAPSULE_SIZE; - if (ctStart >= afterEph.length) break; - const c = chacha20poly1305(ckRaw, Nonce.chachaBytes(nonce)); - try { - return Plaintext.fromBytes(c.decrypt(afterEph.slice(ctStart))); - } catch { - continue; + + const shared = ecdhSharedSecret(viewScalarToBigInt(myScalar), ephPubkey); + const myTag = deriveViewTagByte(shared); + const kek = deriveKeyWrap(shared, nonce); + let offset = 0; + let capsuleIdx = 0; + while (offset + CAPSULE_SIZE <= afterEph.length) { + if (afterEph[offset] === myTag) { + const contentKey = contentKeyFromCapsule(afterEph, offset, kek); + if (knownN !== undefined) { + const ctStart = knownN * CAPSULE_SIZE; + if (!Number.isSafeInteger(ctStart) || ctStart > afterEph.length) { + throw new SampError("insufficient data"); + } + const plaintext = tryDecryptGroup(contentKey, nonce, afterEph.slice(ctStart)); + if (plaintext !== null) return plaintext; + } else { + const maxN = Math.floor((afterEph.length - 16) / CAPSULE_SIZE); + for (let n = capsuleIdx + 1; n <= maxN; n++) { + const ctStart = n * CAPSULE_SIZE; + if (ctStart >= afterEph.length) break; + const plaintext = tryDecryptGroup(contentKey, nonce, afterEph.slice(ctStart)); + if (plaintext !== null) return plaintext; + } + } } + offset += CAPSULE_SIZE; + capsuleIdx++; } throw new SampError("decryption failed"); } diff --git a/typescript/test/crypto.test.ts b/typescript/test/crypto.test.ts index c56277a..ede4647 100644 --- a/typescript/test/crypto.test.ts +++ b/typescript/test/crypto.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { + CAPSULE_SIZE, Nonce, Plaintext, SampError, @@ -132,6 +133,35 @@ describe("group encrypt single member", () => { }); }); +describe("decryptFromGroup view tag collision", () => { + it("continues until a matching capsule authenticates", () => { + const senderPub = publicFromSeed(SENDER_SEED); + const recipientPub = publicFromSeed(RECIPIENT_SEED); + const recipientScalar = sr25519SigningScalar(RECIPIENT_SEED); + const pt = Plaintext.fromBytes(new TextEncoder().encode("view tag collision")); + + const { ephPubkey, capsules, ciphertext } = encryptForGroup( + pt, + [senderPub, recipientPub], + NONCE, + SENDER_SEED, + ); + + const content = new Uint8Array(ephPubkey.length + capsules.length + ciphertext.length); + content.set(ephPubkey, 0); + content.set(capsules, ephPubkey.length); + content.set(ciphertext, ephPubkey.length + capsules.length); + content[32] = content[32 + CAPSULE_SIZE]!; + + expect(new TextDecoder().decode(decryptFromGroup(content, recipientScalar, NONCE, 2))).toBe( + "view tag collision", + ); + expect(new TextDecoder().decode(decryptFromGroup(content, recipientScalar, NONCE))).toBe( + "view tag collision", + ); + }); +}); + describe("deriveGroupEphemeral", () => { it("returns 32-byte scalar", () => { const result = deriveGroupEphemeral(SENDER_SEED, NONCE); From ac91f8a1aeab86f02dcb314cb4758c28baf4d29a Mon Sep 17 00:00:00 2001 From: Maciej Kula <github@mcjkula.com> Date: Sun, 7 Jun 2026 21:39:03 +0900 Subject: [PATCH 4/8] samp(go): handle group capsule tag collisions --- go/crypto.go | 94 +++++++++++++++++++++-------------------------- go/crypto_test.go | 73 +++++++++++++++++++++--------------- 2 files changed, 86 insertions(+), 81 deletions(-) diff --git a/go/crypto.go b/go/crypto.go index da5c35c..7c151fe 100644 --- a/go/crypto.go +++ b/go/crypto.go @@ -296,29 +296,12 @@ func buildCapsules(contentKey ContentKey, members []Pubkey, ephScalar *ristretto return Capsules{out} } -func scanCapsules(data []byte, ephPubkey EphPubkey, myScalar ViewScalar, nonce Nonce) (int, ContentKey, bool) { - epb := ephPubkey.b - shared, err := ecdhSharedSecret(viewScalarToRistretto(myScalar), epb[:]) - if err != nil { - return 0, ContentKey{}, false - } - myTag := deriveViewTagByte(shared) - kek := deriveKeyWrap(shared, nonce) - - offset := 0 - idx := 0 - for offset+CapsuleSize <= len(data) { - if data[offset] == myTag { - var ck [32]byte - for i := 0; i < 32; i++ { - ck[i] = data[offset+1+i] ^ kek[i] - } - return idx, ContentKey{ck}, true - } - offset += CapsuleSize - idx++ +func contentKeyFromCapsule(data []byte, offset int, kek []byte) ContentKey { + var ck [32]byte + for i := 0; i < 32; i++ { + ck[i] = data[offset+1+i] ^ kek[i] } - return 0, ContentKey{}, false + return ContentKey{ck} } func EncryptForGroup(plaintext Plaintext, members []Pubkey, nonce Nonce, senderSeed Seed) (EphPubkey, Capsules, Ciphertext, error) { @@ -366,40 +349,47 @@ func DecryptFromGroup(content []byte, myScalar ViewScalar, nonce Nonce, knownN i ephPubkey := EphPubkey{ephArr} afterEph := content[32:] - capsuleIdx, contentKey, found := scanCapsules(afterEph, ephPubkey, myScalar, nonce) - if !found { - return Plaintext{}, ErrDecryptionFailed - } - ckRaw := contentKey.b - - aead, err := chacha20poly1305.New(ckRaw[:]) + epb := ephPubkey.b + shared, err := ecdhSharedSecret(viewScalarToRistretto(myScalar), epb[:]) if err != nil { - return Plaintext{}, err - } - - if knownN > 0 { - ctStart := knownN * CapsuleSize - if ctStart > len(afterEph) { - return Plaintext{}, ErrInsufficientData - } - pt, err := aead.Open(nil, nonce.chachaNonce(), afterEph[ctStart:], nil) - if err != nil { - return Plaintext{}, ErrDecryptionFailed - } - return Plaintext{pt}, nil + return Plaintext{}, ErrDecryptionFailed } + myTag := deriveViewTagByte(shared) + kek := deriveKeyWrap(shared, nonce) - minN := capsuleIdx + 1 - maxN := (len(afterEph) - 16) / CapsuleSize - for n := minN; n <= maxN; n++ { - ctStart := n * CapsuleSize - if ctStart >= len(afterEph) { - break - } - pt, err := aead.Open(nil, nonce.chachaNonce(), afterEph[ctStart:], nil) - if err == nil { - return Plaintext{pt}, nil + offset := 0 + capsuleIdx := 0 + for offset+CapsuleSize <= len(afterEph) { + if afterEph[offset] == myTag { + contentKey := contentKeyFromCapsule(afterEph, offset, kek) + ckRaw := contentKey.b + aead, err := chacha20poly1305.New(ckRaw[:]) + if err != nil { + return Plaintext{}, err + } + if knownN > 0 { + ctStart := knownN * CapsuleSize + if ctStart > len(afterEph) { + return Plaintext{}, ErrInsufficientData + } + if pt, err := aead.Open(nil, nonce.chachaNonce(), afterEph[ctStart:], nil); err == nil { + return Plaintext{pt}, nil + } + } else { + maxN := (len(afterEph) - 16) / CapsuleSize + for n := capsuleIdx + 1; n <= maxN; n++ { + ctStart := n * CapsuleSize + if ctStart >= len(afterEph) { + break + } + if pt, err := aead.Open(nil, nonce.chachaNonce(), afterEph[ctStart:], nil); err == nil { + return Plaintext{pt}, nil + } + } + } } + offset += CapsuleSize + capsuleIdx++ } return Plaintext{}, ErrDecryptionFailed } diff --git a/go/crypto_test.go b/go/crypto_test.go index c792940..4bb9c80 100644 --- a/go/crypto_test.go +++ b/go/crypto_test.go @@ -67,6 +67,22 @@ func randomNonce(t *testing.T) Nonce { return NonceFromBytes(b) } +func fixedSeed(value byte) Seed { + var b [32]byte + for i := range b { + b[i] = value + } + return SeedFromBytes(b) +} + +func fixedNonce(value byte) Nonce { + var b [12]byte + for i := range b { + b[i] = value + } + return NonceFromBytes(b) +} + func TestRandomNonceReadsTwelveBytesFromRandomSource(t *testing.T) { reader := &deterministicReader{} withRandomReader(t, reader) @@ -311,35 +327,6 @@ func TestBuildCapsulesWithInvalidMemberPoint(t *testing.T) { require.Equal(t, expected, capsules.Bytes()) } -func TestScanCapsulesNoMatchReturnsNotFound(t *testing.T) { - // Construct capsule data where all view tags are 0xAA. - capsuleData := make([]byte, CapsuleSize*2) - for i := range capsuleData { - capsuleData[i] = 0xAA - } - // Use a real ephemeral pubkey from a seed. - seed := randomSeed(t) - scalar := Sr25519SigningScalar(seed) - pub := PublicFromSeed(seed) - nonce := randomNonce(t) - - _, _, found := scanCapsules(capsuleData, EphPubkeyFromBytes(pub.Bytes()), scalar, nonce) - // Either the tag happens to match (1/256) or not -- just exercise the path. - _ = found -} - -func TestScanCapsulesInvalidEphPubkey(t *testing.T) { - capsuleData := make([]byte, CapsuleSize) - var badEph [32]byte - for i := range badEph { - badEph[i] = 0xFF - } - scalar := Sr25519SigningScalar(randomSeed(t)) - nonce := randomNonce(t) - _, _, found := scanCapsules(capsuleData, EphPubkeyFromBytes(badEph), scalar, nonce) - require.False(t, found) -} - func TestDecryptFromGroupCorruptedCiphertextBody(t *testing.T) { sender := randomSeed(t) nonce := randomNonce(t) @@ -464,6 +451,34 @@ func TestEncryptForGroupTrialDecryptWithoutKnownN(t *testing.T) { require.Equal(t, msg.Bytes(), pt.Bytes()) } +func TestDecryptFromGroupSkipsCollidingViewTagCapsules(t *testing.T) { + sender := fixedSeed(0xAA) + alice := fixedSeed(0xAA) + bob := fixedSeed(0xBB) + nonce := fixedNonce(0xF1) + members := []Pubkey{PublicFromSeed(alice), PublicFromSeed(bob)} + msg := PlaintextFromBytes([]byte("view tag collision")) + + ephPub, capsules, ct, err := EncryptForGroup(msg, members, nonce, sender) + require.NoError(t, err) + + content := make([]byte, 0) + epb := ephPub.Bytes() + content = append(content, epb[:]...) + content = append(content, capsules.Bytes()...) + content = append(content, ct.Bytes()...) + content[32] = content[32+CapsuleSize] + + scalar := Sr25519SigningScalar(bob) + pt, err := DecryptFromGroup(content, scalar, nonce, len(members)) + require.NoError(t, err) + require.Equal(t, msg.Bytes(), pt.Bytes()) + + pt, err = DecryptFromGroup(content, scalar, nonce, 0) + require.NoError(t, err) + require.Equal(t, msg.Bytes(), pt.Bytes()) +} + func TestDecryptFromGroupInvalidEphPubkeyPoint(t *testing.T) { nonce := randomNonce(t) scalar := Sr25519SigningScalar(randomSeed(t)) From 1aa81957c1a120178bba9bbaa3bbd643d3936e35 Mon Sep 17 00:00:00 2001 From: Maciej Kula <github@mcjkula.com> Date: Sun, 7 Jun 2026 21:45:36 +0900 Subject: [PATCH 5/8] samp(rust): prune unreachable group decrypt branch --- rust/src/encryption.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/rust/src/encryption.rs b/rust/src/encryption.rs index 84b9b03..b1b7df6 100644 --- a/rust/src/encryption.rs +++ b/rust/src/encryption.rs @@ -415,9 +415,6 @@ pub fn decrypt_from_group( let max_n = (after_eph.len().saturating_sub(16)) / CAPSULE_SIZE; for n in capsule_idx + 1..=max_n { let ct_start = n * CAPSULE_SIZE; - if ct_start >= after_eph.len() { - break; - } if let Some(plaintext) = decrypt_group_at(&content_key, nonce, &after_eph[ct_start..]) { From 61705ca10c0abe40c5028fc75f0a62bb77fae6ba Mon Sep 17 00:00:00 2001 From: Maciej Kula <github@mcjkula.com> Date: Sun, 7 Jun 2026 21:45:50 +0900 Subject: [PATCH 6/8] samp(py): prune unreachable group decrypt branch --- python/samp-crypto/src/lib.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/python/samp-crypto/src/lib.rs b/python/samp-crypto/src/lib.rs index a86182a..e79cf54 100644 --- a/python/samp-crypto/src/lib.rs +++ b/python/samp-crypto/src/lib.rs @@ -500,9 +500,6 @@ fn decrypt_from_group( let max_n = after_eph.len().saturating_sub(16) / CAPSULE_SIZE; for trial_n in capsule_idx + 1..=max_n { let ct_start = trial_n * CAPSULE_SIZE; - if ct_start >= after_eph.len() { - break; - } if let Ok(plaintext) = cipher.decrypt(Nonce::from_slice(&n), &after_eph[ct_start..]) { From 1e855b4a0de66e862aa3d77173ace3825f673f46 Mon Sep 17 00:00:00 2001 From: Maciej Kula <github@mcjkula.com> Date: Sun, 7 Jun 2026 21:46:05 +0900 Subject: [PATCH 7/8] samp(ts): prune unreachable group decrypt branch --- typescript/src/crypto.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/typescript/src/crypto.ts b/typescript/src/crypto.ts index e4f5508..de4149e 100644 --- a/typescript/src/crypto.ts +++ b/typescript/src/crypto.ts @@ -302,7 +302,6 @@ export function decryptFromGroup( const maxN = Math.floor((afterEph.length - 16) / CAPSULE_SIZE); for (let n = capsuleIdx + 1; n <= maxN; n++) { const ctStart = n * CAPSULE_SIZE; - if (ctStart >= afterEph.length) break; const plaintext = tryDecryptGroup(contentKey, nonce, afterEph.slice(ctStart)); if (plaintext !== null) return plaintext; } From d8ad926c537b3c8e07cb11935b27498271eeef7b Mon Sep 17 00:00:00 2001 From: Maciej Kula <github@mcjkula.com> Date: Sun, 7 Jun 2026 21:46:19 +0900 Subject: [PATCH 8/8] samp(go): prune unreachable group decrypt branches --- go/crypto.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/go/crypto.go b/go/crypto.go index 7c151fe..462943f 100644 --- a/go/crypto.go +++ b/go/crypto.go @@ -363,10 +363,7 @@ func DecryptFromGroup(content []byte, myScalar ViewScalar, nonce Nonce, knownN i if afterEph[offset] == myTag { contentKey := contentKeyFromCapsule(afterEph, offset, kek) ckRaw := contentKey.b - aead, err := chacha20poly1305.New(ckRaw[:]) - if err != nil { - return Plaintext{}, err - } + aead, _ := chacha20poly1305.New(ckRaw[:]) if knownN > 0 { ctStart := knownN * CapsuleSize if ctStart > len(afterEph) { @@ -379,9 +376,6 @@ func DecryptFromGroup(content []byte, myScalar ViewScalar, nonce Nonce, knownN i maxN := (len(afterEph) - 16) / CapsuleSize for n := capsuleIdx + 1; n <= maxN; n++ { ctStart := n * CapsuleSize - if ctStart >= len(afterEph) { - break - } if pt, err := aead.Open(nil, nonce.chachaNonce(), afterEph[ctStart:], nil); err == nil { return Plaintext{pt}, nil }