diff --git a/go/crypto.go b/go/crypto.go index 630c9fc..da5c35c 100644 --- a/go/crypto.go +++ b/go/crypto.go @@ -20,6 +20,14 @@ var ( ErrInvalidPoint = errors.New("samp: invalid ristretto255 point") ) +func RandomNonce() (Nonce, error) { + var bytes [12]byte + if _, err := io.ReadFull(rand.Reader, bytes[:]); err != nil { + return Nonce{}, err + } + return NonceFromBytes(bytes), nil +} + // WHY: the single crypto boundary that turns a 32-byte ViewScalar into a // ristretto255.Scalar. Every decrypt path funnels through here. func viewScalarToRistretto(v ViewScalar) *ristretto255.Scalar { @@ -159,6 +167,18 @@ func Encrypt(plaintext Plaintext, recipientPub Pubkey, nonce Nonce, senderSeed S return Ciphertext{out}, nil } +func EncryptRandom(plaintext Plaintext, recipientPub Pubkey, senderSeed Seed) (Nonce, Ciphertext, error) { + nonce, err := RandomNonce() + if err != nil { + return Nonce{}, Ciphertext{}, err + } + ciphertext, err := Encrypt(plaintext, recipientPub, nonce, senderSeed) + if err != nil { + return Nonce{}, Ciphertext{}, err + } + return nonce, ciphertext, nil +} + func Decrypt(ct Ciphertext, nonce Nonce, signingScalar ViewScalar) (Plaintext, error) { if len(ct.b) < EncryptedOverhead { return Plaintext{}, ErrInsufficientData @@ -307,7 +327,7 @@ func EncryptForGroup(plaintext Plaintext, members []Pubkey, nonce Nonce, senderS ephPub := new(ristretto255.Element).ScalarBaseMult(ephScalar) var ck [32]byte - if _, err := rand.Read(ck[:]); err != nil { + if _, err := io.ReadFull(rand.Reader, ck[:]); err != nil { return EphPubkey{}, Capsules{}, Ciphertext{}, err } contentKey := ContentKey{ck} @@ -325,6 +345,18 @@ func EncryptForGroup(plaintext Plaintext, members []Pubkey, nonce Nonce, senderS return EphPubkey{ephArr}, capsules, Ciphertext{ct}, nil } +func EncryptForGroupRandom(plaintext Plaintext, members []Pubkey, senderSeed Seed) (Nonce, EphPubkey, Capsules, Ciphertext, error) { + nonce, err := RandomNonce() + if err != nil { + return Nonce{}, EphPubkey{}, Capsules{}, Ciphertext{}, err + } + ephPub, capsules, ciphertext, err := EncryptForGroup(plaintext, members, nonce, senderSeed) + if err != nil { + return Nonce{}, EphPubkey{}, Capsules{}, Ciphertext{}, err + } + return nonce, ephPub, capsules, ciphertext, nil +} + func DecryptFromGroup(content []byte, myScalar ViewScalar, nonce Nonce, knownN int) (Plaintext, error) { if len(content) < 32 { return Plaintext{}, ErrInsufficientData diff --git a/go/crypto_test.go b/go/crypto_test.go index 2230404..7d049d5 100644 --- a/go/crypto_test.go +++ b/go/crypto_test.go @@ -2,11 +2,43 @@ package samp import ( "crypto/rand" + "errors" + "io" "testing" "github.com/stretchr/testify/require" ) +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { + return 0, errors.New("rng unavailable") +} + +type nonceThenFailingReader struct { + calls int +} + +func (r *nonceThenFailingReader) Read(p []byte) (int, error) { + if r.calls > 0 { + return 0, errors.New("content key rng unavailable") + } + r.calls++ + for i := range p { + p[i] = byte(i + 1) + } + return len(p), nil +} + +func withRandomReader(t *testing.T, reader io.Reader) { + t.Helper() + original := rand.Reader + rand.Reader = reader + t.Cleanup(func() { + rand.Reader = original + }) +} + func randomSeed(t *testing.T) Seed { t.Helper() var b [32]byte @@ -23,6 +55,111 @@ func randomNonce(t *testing.T) Nonce { return NonceFromBytes(b) } +func TestRandomNonceSamplesAreWellFormedAndDistinct(t *testing.T) { + seen := make(map[[12]byte]bool) + for i := 0; i < 32; i++ { + nonce, err := RandomNonce() + require.NoError(t, err) + raw := nonce.Bytes() + require.False(t, seen[raw]) + seen[raw] = true + } +} + +func TestRandomNonceReturnsRngFailure(t *testing.T) { + withRandomReader(t, failingReader{}) + + _, err := RandomNonce() + require.ErrorContains(t, err, "rng unavailable") +} + +func TestEncryptRandomReturnsNonceNeededForDecrypt(t *testing.T) { + sender := randomSeed(t) + recipientSeed := randomSeed(t) + recipientPub := PublicFromSeed(recipientSeed) + recipientScalar := Sr25519SigningScalar(recipientSeed) + + nonce, ciphertext, err := EncryptRandom(PlaintextFromBytes([]byte("secret")), recipientPub, sender) + require.NoError(t, err) + plaintext, err := Decrypt(ciphertext, nonce, recipientScalar) + require.NoError(t, err) + require.Equal(t, []byte("secret"), plaintext.Bytes()) +} + +func TestEncryptRandomReturnsRngFailure(t *testing.T) { + sender := randomSeed(t) + recipientPub := PublicFromSeed(randomSeed(t)) + withRandomReader(t, failingReader{}) + + _, _, err := EncryptRandom( + PlaintextFromBytes([]byte("secret")), + recipientPub, + sender, + ) + require.ErrorContains(t, err, "rng unavailable") +} + +func TestEncryptRandomReturnsEncryptFailure(t *testing.T) { + var bad [32]byte + for i := range bad { + bad[i] = 0xFF + } + + _, _, err := EncryptRandom( + PlaintextFromBytes([]byte("secret")), + PubkeyFromBytes(bad), + randomSeed(t), + ) + require.ErrorIs(t, err, ErrInvalidPoint) +} + +func TestEncryptForGroupRandomReturnsNonceNeededForDecrypt(t *testing.T) { + sender := randomSeed(t) + recipientSeed := randomSeed(t) + recipientPub := PublicFromSeed(recipientSeed) + recipientScalar := Sr25519SigningScalar(recipientSeed) + + nonce, ephPub, capsules, ciphertext, err := EncryptForGroupRandom( + PlaintextFromBytes([]byte("group secret")), + []Pubkey{recipientPub}, + sender, + ) + require.NoError(t, err) + ephBytes := ephPub.Bytes() + content := append([]byte{}, ephBytes[:]...) + content = append(content, capsules.Bytes()...) + content = append(content, ciphertext.Bytes()...) + plaintext, err := DecryptFromGroup(content, recipientScalar, nonce, 1) + require.NoError(t, err) + require.Equal(t, []byte("group secret"), plaintext.Bytes()) +} + +func TestEncryptForGroupRandomReturnsNonceFailure(t *testing.T) { + sender := randomSeed(t) + recipientPub := PublicFromSeed(randomSeed(t)) + withRandomReader(t, failingReader{}) + + _, _, _, _, err := EncryptForGroupRandom( + PlaintextFromBytes([]byte("group secret")), + []Pubkey{recipientPub}, + sender, + ) + require.ErrorContains(t, err, "rng unavailable") +} + +func TestEncryptForGroupRandomReturnsContentKeyFailure(t *testing.T) { + sender := randomSeed(t) + recipientPub := PublicFromSeed(randomSeed(t)) + withRandomReader(t, &nonceThenFailingReader{}) + + _, _, _, _, err := EncryptForGroupRandom( + PlaintextFromBytes([]byte("group secret")), + []Pubkey{recipientPub}, + sender, + ) + require.ErrorContains(t, err, "content key rng unavailable") +} + func TestDecryptCorruptedCiphertextBody(t *testing.T) { sender := randomSeed(t) recipientSeed := randomSeed(t) diff --git a/python/samp/__init__.py b/python/samp/__init__.py index 713b380..daae7dc 100644 --- a/python/samp/__init__.py +++ b/python/samp/__init__.py @@ -9,7 +9,10 @@ derive_group_ephemeral, encrypt, encrypt_for_group, + encrypt_for_group_random, + encrypt_random, public_from_seed, + random_nonce, sr25519_sign, sr25519_signing_scalar, unseal_recipient, @@ -160,6 +163,7 @@ "sr25519_signing_scalar", "public_from_seed", "encrypt", + "encrypt_random", "decrypt", "decrypt_as_sender", "compute_view_tag", @@ -168,7 +172,9 @@ "derive_group_ephemeral", "build_capsules", "encrypt_for_group", + "encrypt_for_group_random", "decrypt_from_group", + "random_nonce", "decode_compact", "encode_compact", "decode_bytes", diff --git a/python/samp/encryption.py b/python/samp/encryption.py index 2631e4a..bb685cf 100644 --- a/python/samp/encryption.py +++ b/python/samp/encryption.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import Optional import samp_crypto @@ -18,6 +19,7 @@ capsules_from_bytes, ciphertext_from_bytes, eph_pubkey_from_bytes, + nonce_from_bytes, plaintext_from_bytes, pubkey_from_bytes, signature_from_bytes, @@ -27,6 +29,13 @@ ENCRYPTED_OVERHEAD = 80 +def random_nonce() -> Nonce: + try: + return nonce_from_bytes(os.urandom(12)) + except OSError as e: + raise SampError(f"secure random source unavailable: {e}") from e + + def sr25519_sign(seed: Seed, message: bytes) -> Signature: return signature_from_bytes(samp_crypto.sr25519_sign(seed.expose_secret(), message)) @@ -50,6 +59,15 @@ def encrypt( ) +def encrypt_random( + plaintext: Plaintext, + recipient: Pubkey, + sender_seed: Seed, +) -> tuple[Nonce, Ciphertext]: + nonce = random_nonce() + return nonce, encrypt(plaintext, recipient, nonce, sender_seed) + + def decrypt( ciphertext: Ciphertext, nonce: Nonce, @@ -133,6 +151,16 @@ def encrypt_for_group( ) +def encrypt_for_group_random( + plaintext: Plaintext, + member_pubkeys: list[Pubkey], + sender_seed: Seed, +) -> tuple[Nonce, EphPubkey, Capsules, Ciphertext]: + nonce = random_nonce() + eph, caps, ct = encrypt_for_group(plaintext, member_pubkeys, nonce, sender_seed) + return nonce, eph, caps, ct + + def decrypt_from_group( content: bytes, my_scalar: ViewScalar, diff --git a/python/tests/test_encryption.py b/python/tests/test_encryption.py index 1d08e93..c7db0c7 100644 --- a/python/tests/test_encryption.py +++ b/python/tests/test_encryption.py @@ -18,6 +18,44 @@ def test_sr25519_sign_differs_for_different_messages() -> None: assert bytes(a) != bytes(b) +def test_random_nonce_samples_are_well_formed_and_distinct() -> None: + nonces = {bytes(samp.random_nonce()) for _ in range(32)} + assert len(nonces) == 32 + assert all(len(n) == 12 for n in nonces) + + +def test_random_nonce_reports_os_random_failure(monkeypatch: pytest.MonkeyPatch) -> None: + def fail_urandom(size: int) -> bytes: + raise OSError("rng unavailable") + + monkeypatch.setattr("os.urandom", fail_urandom) + with pytest.raises(samp.SampError, match="secure random source unavailable"): + samp.random_nonce() + + +def test_encrypt_random_returns_nonce_needed_for_decrypt() -> None: + sender = samp.Seed.from_bytes(bytes([0xAA] * 32)) + recipient = samp.Seed.from_bytes(bytes([0xBB] * 32)) + plaintext = samp.plaintext_from_bytes(b"secret") + nonce, ciphertext = samp.encrypt_random(plaintext, samp.public_from_seed(recipient), sender) + decrypted = samp.decrypt(ciphertext, nonce, samp.sr25519_signing_scalar(recipient)) + assert decrypted == plaintext + + +def test_encrypt_for_group_random_returns_nonce_needed_for_decrypt() -> None: + sender = samp.Seed.from_bytes(bytes([0xAA] * 32)) + recipient = samp.Seed.from_bytes(bytes([0xBB] * 32)) + plaintext = samp.plaintext_from_bytes(b"group secret") + nonce, eph, capsules, ciphertext = samp.encrypt_for_group_random( + plaintext, [samp.public_from_seed(recipient)], sender + ) + content = bytes(eph) + bytes(capsules) + bytes(ciphertext) + decrypted = samp.decrypt_from_group( + content, samp.sr25519_signing_scalar(recipient), nonce, 1 + ) + assert decrypted == 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)) diff --git a/rust/src/encryption.rs b/rust/src/encryption.rs index 304f0c3..06ab187 100644 --- a/rust/src/encryption.rs +++ b/rust/src/encryption.rs @@ -14,6 +14,7 @@ use sha2::Sha256; use zeroize::Zeroize; pub type GroupEncrypted = (EphPubkey, Capsules, Ciphertext); +pub type GroupEncryptedWithNonce = (Nonce, EphPubkey, Capsules, Ciphertext); const MESSAGE_KEY_INFO: &[u8] = b"samp-message"; const VIEW_TAG_INFO: &[u8] = b"samp-view-tag"; @@ -23,6 +24,12 @@ const KEY_WRAP_INFO: &[u8] = b"samp-key-wrap"; pub const ENCRYPTED_OVERHEAD: usize = 80; +pub fn random_nonce() -> Result { + let mut bytes = [0u8; 12]; + getrandom::fill(&mut bytes).map_err(SampError::RandomUnavailable)?; + Ok(Nonce::from_bytes(bytes)) +} + // WHY: the single crypto boundary that turns a 32-byte ViewScalar back into a // ristretto255 scalar. Every decrypt path funnels through here. pub(crate) fn view_scalar_to_ristretto(vs: &ViewScalar) -> Scalar { @@ -180,6 +187,16 @@ pub fn encrypt( Ok(Ciphertext::from_bytes(content)) } +pub fn encrypt_random( + plaintext: &Plaintext, + recipient_pubkey: &Pubkey, + sender_seed: &Seed, +) -> Result<(Nonce, Ciphertext), SampError> { + let nonce = random_nonce()?; + let ciphertext = encrypt(plaintext, recipient_pubkey, &nonce, sender_seed)?; + Ok((nonce, ciphertext)) +} + pub fn decrypt( ciphertext: &Ciphertext, nonce: &Nonce, @@ -357,6 +374,17 @@ pub fn encrypt_for_group( )) } +pub fn encrypt_for_group_random( + plaintext: &Plaintext, + member_pubkeys: &[Pubkey], + sender_seed: &Seed, +) -> Result { + let nonce = random_nonce()?; + let (eph_pubkey, capsules, ciphertext) = + encrypt_for_group(plaintext, member_pubkeys, &nonce, sender_seed)?; + Ok((nonce, eph_pubkey, capsules, ciphertext)) +} + pub fn decrypt_from_group( content: &[u8], nonce: &Nonce, diff --git a/rust/src/error.rs b/rust/src/error.rs index 645886c..47f0daa 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -5,6 +5,7 @@ pub enum SampError { InvalidVersion(u8), ReservedContentType(u8), DecryptionFailed, + RandomUnavailable(getrandom::Error), InvalidUtf8, InsufficientData, InvalidChannelName, @@ -24,6 +25,7 @@ impl fmt::Display for SampError { Self::InvalidVersion(v) => write!(f, "unsupported version: 0x{v:02x}"), Self::ReservedContentType(ct) => write!(f, "reserved content type: 0x{ct:02x}"), Self::DecryptionFailed => write!(f, "decryption failed"), + Self::RandomUnavailable(e) => write!(f, "secure random source unavailable: {e}"), Self::InvalidUtf8 => write!(f, "content is not valid UTF-8"), Self::InsufficientData => write!(f, "insufficient data"), Self::InvalidChannelName => write!(f, "channel name must be 1-32 bytes"), @@ -44,4 +46,11 @@ impl fmt::Display for SampError { } } -impl std::error::Error for SampError {} +impl std::error::Error for SampError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::RandomUnavailable(e) => Some(e), + _ => None, + } + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 29acf83..a1f8ed6 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -10,8 +10,10 @@ pub mod wire; pub use encryption::{ build_capsules, check_view_tag, compute_view_tag, decrypt, decrypt_as_sender, - decrypt_from_group, derive_group_ephemeral, encrypt, encrypt_for_group, public_from_seed, - sr25519_sign, sr25519_signing_scalar, unseal_recipient, GroupEncrypted, ENCRYPTED_OVERHEAD, + decrypt_from_group, derive_group_ephemeral, encrypt, encrypt_for_group, + encrypt_for_group_random, encrypt_random, public_from_seed, random_nonce, sr25519_sign, + sr25519_signing_scalar, unseal_recipient, GroupEncrypted, GroupEncryptedWithNonce, + ENCRYPTED_OVERHEAD, }; pub use error::SampError; pub use extrinsic::{ diff --git a/rust/tests/conformance.rs b/rust/tests/conformance.rs index ae5fdad..03dcc8b 100644 --- a/rust/tests/conformance.rs +++ b/rust/tests/conformance.rs @@ -1021,6 +1021,7 @@ fn error_display_all_variants() { SampError::InvalidVersion(0x20), SampError::ReservedContentType(0x16), SampError::DecryptionFailed, + SampError::RandomUnavailable(getrandom::Error::UNSUPPORTED), SampError::InvalidUtf8, SampError::InsufficientData, SampError::InvalidChannelName, @@ -1047,6 +1048,9 @@ fn error_display_all_variants() { assert!(e.contains("777")); let p = format!("{}", SampError::Ss58PrefixUnsupported(100)); assert!(p.contains("100")); + let r = SampError::RandomUnavailable(getrandom::Error::UNSUPPORTED); + assert!(std::error::Error::source(&r).is_some()); + assert!(std::error::Error::source(&SampError::InsufficientData).is_none()); } #[test] diff --git a/rust/tests/round_trip.rs b/rust/tests/round_trip.rs index e88d96b..e34b9bd 100644 --- a/rust/tests/round_trip.rs +++ b/rust/tests/round_trip.rs @@ -1,6 +1,6 @@ use samp::encryption::{ compute_view_tag, decrypt, decrypt_as_sender, decrypt_from_group, encrypt, encrypt_for_group, - sr25519_signing_scalar, + encrypt_for_group_random, encrypt_random, random_nonce, sr25519_signing_scalar, }; use samp::{ decode_channel_content, decode_group_content, decode_group_members, decode_remark, @@ -58,6 +58,39 @@ fn n(b: u8) -> Nonce { Nonce::from_bytes([b; 12]) } +#[test] +fn random_nonce_samples_are_well_formed_and_distinct() { + let mut seen = std::collections::BTreeSet::new(); + for _ in 0..32 { + let nonce = random_nonce().unwrap(); + assert!(seen.insert(nonce.into_bytes())); + } +} + +#[test] +fn encrypt_random_returns_nonce_needed_for_decrypt() { + let plaintext = pt(b"secret message"); + let (nonce, ciphertext) = encrypt_random(&plaintext, &bob_pubkey(), &alice_seed()).unwrap(); + let scalar = sr25519_signing_scalar(&bob_seed()); + let decrypted = decrypt(&ciphertext, &nonce, &scalar).unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn encrypt_for_group_random_returns_nonce_needed_for_decrypt() { + let plaintext = pt(b"group secret"); + let recipient = bob_pubkey(); + let (nonce, eph_pubkey, capsules, ciphertext) = + encrypt_for_group_random(&plaintext, &[recipient], &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()); + let scalar = sr25519_signing_scalar(&bob_seed()); + let decrypted = decrypt_from_group(&content, &nonce, &scalar, Some(1)).unwrap(); + assert_eq!(decrypted, plaintext); +} + // Public message (0x10) #[test] diff --git a/typescript/src/crypto.ts b/typescript/src/crypto.ts index 395ab6e..7e3bb9e 100644 --- a/typescript/src/crypto.ts +++ b/typescript/src/crypto.ts @@ -25,6 +25,14 @@ function mod(n: bigint, m: bigint): bigint { return ((n % m) + m) % m; } +export function randomNonce(): Nonce { + const cryptoApi = globalThis.crypto; + if (cryptoApi === undefined || cryptoApi.getRandomValues === undefined) { + throw new SampError("secure random source unavailable"); + } + return Nonce.fromBytes(cryptoApi.getRandomValues(new Uint8Array(12))); +} + function divideScalarByCofactor(s: Uint8Array): void { let low = 0; for (let i = s.length - 1; i >= 0; i--) { @@ -128,6 +136,15 @@ export function encrypt( return Ciphertext.fromBytes(out); } +export function encryptRandom( + plaintext: Plaintext, + recipient: Pubkey, + senderSeed: Seed, +): { nonce: Nonce; ciphertext: Ciphertext } { + const nonce = randomNonce(); + return { nonce, ciphertext: encrypt(plaintext, recipient, nonce, senderSeed) }; +} + export function decrypt(ciphertext: Ciphertext, nonce: Nonce, signingScalar: ViewScalar): Plaintext { const content = Ciphertext.asBytes(ciphertext); if (content.length < ENCRYPTED_OVERHEAD) throw new SampError("insufficient data"); @@ -255,6 +272,15 @@ export function encryptForGroup( return { ephPubkey, capsules, ciphertext: Ciphertext.fromBytes(ct) }; } +export function encryptForGroupRandom( + plaintext: Plaintext, + memberPubkeys: Pubkey[], + senderSeed: Seed, +): { nonce: Nonce; ephPubkey: EphPubkey; capsules: Capsules; ciphertext: Ciphertext } { + const nonce = randomNonce(); + return { nonce, ...encryptForGroup(plaintext, memberPubkeys, nonce, senderSeed) }; +} + export function decryptFromGroup( content: Uint8Array, myScalar: ViewScalar, diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 172f893..71c809e 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -58,7 +58,9 @@ export { sr25519Sign, sr25519SigningScalar, publicFromSeed, + randomNonce, encrypt, + encryptRandom, decrypt, decryptAsSender, computeViewTag, @@ -67,6 +69,7 @@ export { deriveGroupEphemeral, buildCapsules, encryptForGroup, + encryptForGroupRandom, decryptFromGroup, } from "./crypto.js"; export { decodeBytes, decodeCompact, encodeCompact } from "./scale.js"; diff --git a/typescript/test/crypto.test.ts b/typescript/test/crypto.test.ts index d47713c..8781a9a 100644 --- a/typescript/test/crypto.test.ts +++ b/typescript/test/crypto.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { Nonce, Plaintext, @@ -9,8 +9,11 @@ import { decryptFromGroup, deriveGroupEphemeral, encrypt, + encryptForGroupRandom, encryptForGroup, + encryptRandom, publicFromSeed, + randomNonce, sr25519SigningScalar, unsealRecipient, } from "../src/index.js"; @@ -41,6 +44,53 @@ describe("encrypt/decrypt as sender", () => { }); }); +describe("random nonce helpers", () => { + it("returns distinct 12-byte nonces", () => { + const seen = new Set(); + for (let i = 0; i < 32; i++) { + const nonce = randomNonce(); + expect(nonce.length).toBe(12); + seen.add(Buffer.from(nonce).toString("hex")); + } + expect(seen.size).toBe(32); + }); + + it("throws when Web Crypto is unavailable", () => { + vi.stubGlobal("crypto", undefined); + try { + expect(() => randomNonce()).toThrow("secure random source unavailable"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("encryptRandom returns the nonce needed to decrypt", () => { + const recipientPub = publicFromSeed(RECIPIENT_SEED); + const recipientScalar = sr25519SigningScalar(RECIPIENT_SEED); + const pt = Plaintext.fromBytes(new TextEncoder().encode("secret")); + const { nonce, ciphertext } = encryptRandom(pt, recipientPub, SENDER_SEED); + const recovered = decrypt(ciphertext, nonce, recipientScalar); + expect(new TextDecoder().decode(recovered)).toBe("secret"); + }); + + it("encryptForGroupRandom returns the nonce needed to decrypt", () => { + const recipientPub = publicFromSeed(RECIPIENT_SEED); + const recipientScalar = sr25519SigningScalar(RECIPIENT_SEED); + const pt = Plaintext.fromBytes(new TextEncoder().encode("group secret")); + const { nonce, ephPubkey, capsules, ciphertext } = encryptForGroupRandom( + pt, + [recipientPub], + 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); + const recovered = decryptFromGroup(content, recipientScalar, nonce, 1); + expect(new TextDecoder().decode(recovered)).toBe("group secret"); + }); +}); + describe("unseal recipient", () => { it("recovers recipient pubkey", () => { const recipientPub = publicFromSeed(RECIPIENT_SEED);