Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion go/crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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
Expand Down
137 changes: 137 additions & 0 deletions go/crypto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions python/samp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -160,6 +163,7 @@
"sr25519_signing_scalar",
"public_from_seed",
"encrypt",
"encrypt_random",
"decrypt",
"decrypt_as_sender",
"compute_view_tag",
Expand All @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions python/samp/encryption.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
from typing import Optional

import samp_crypto
Expand All @@ -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,
Expand All @@ -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))

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions python/tests/test_encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading