Skip to content

Commit 257fda6

Browse files
committed
test(duvet): add streaming decryption tests for delayed authentication citations
Add unit tests that verify the behavioral contract of both stream modes: - DelayedAuthDecryptingStream releases plaintext before GCM tag verification - BufferedDecryptingStream withholds all plaintext until tag is verified Includes duvet type=test citations for enable-delayed-authentication spec.
1 parent f348251 commit 257fda6

3 files changed

Lines changed: 104 additions & 5 deletions

File tree

test/integration/test_i_s3_encryption.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,16 @@ def test_encryption_context_missing_on_decrypt():
478478
[
479479
("simple-rt", "test input for simple v3 round trip", "utf-8"),
480480
("empty-string-rt", "", "utf-8"),
481-
("unicode-rt", "Unicode test: 你好, こんにちは, 안녕하세요, Привет, مرحبا, ¡Hola!, ½⅓¼⅕⅙⅐⅛⅑⅒⅔⅖⅗⅘⅙⅚⅜⅝⅞", "utf-8"),
482-
("utf8-rt", "UTF-8 encoding test: 你好, こんにちは, 안녕하세요, Привет, مرحبا, ¡Hola!", "utf-8"),
481+
(
482+
"unicode-rt",
483+
"Unicode test: 你好, こんにちは, 안녕하세요, Привет, مرحبا, ¡Hola!, ½⅓¼⅕⅙⅐⅛⅑⅒⅔⅖⅗⅘⅙⅚⅜⅝⅞",
484+
"utf-8",
485+
),
486+
(
487+
"utf8-rt",
488+
"UTF-8 encoding test: 你好, こんにちは, 안녕하세요, Привет, مرحبا, ¡Hola!",
489+
"utf-8",
490+
),
483491
("latin1-rt", "Latin-1 encoding test: éèêë àâäãåá çñ ¿¡ øæå ØÆÅÉÈÊËÀÂÄÃÅÁ", "latin-1"),
484492
("binary-rt", bytes(range(256)), None),
485493
],

test/test_pipelines.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ def test_decrypt_v1_from_instruction_file(self):
6060

6161
# Should fail when trying to decrypt (proving instruction file was fetched)
6262
with pytest.raises(Exception, match="Keyring called"):
63-
pipeline.decrypt(mock_response, bucket="test-bucket", key="test-key", instruction_suffix=".instruction")
63+
pipeline.decrypt(
64+
mock_response,
65+
bucket="test-bucket",
66+
key="test-key",
67+
instruction_suffix=".instruction",
68+
)
6469

6570
# Verify instruction file was fetched
6671
mock_s3_client.get_object.assert_called_once_with(
@@ -115,7 +120,12 @@ def test_decrypt_v2_from_instruction_file(self):
115120

116121
# Should fail when trying to decrypt (proving instruction file was fetched)
117122
with pytest.raises(Exception, match="Keyring called"):
118-
pipeline.decrypt(mock_response, bucket="test-bucket", key="test-key", instruction_suffix=".instruction")
123+
pipeline.decrypt(
124+
mock_response,
125+
bucket="test-bucket",
126+
key="test-key",
127+
instruction_suffix=".instruction",
128+
)
119129

120130
# Verify instruction file was fetched
121131
mock_s3_client.get_object.assert_called_once_with(
@@ -184,7 +194,12 @@ def test_decrypt_v3_from_instruction_file(self):
184194

185195
# This should fail with NotImplementedError since V3 decryption isn't implemented yet
186196
with pytest.raises(NotImplementedError, match="V3 decryption not yet implemented"):
187-
pipeline.decrypt(mock_response, bucket="test-bucket", key="test-key", instruction_suffix=".instruction")
197+
pipeline.decrypt(
198+
mock_response,
199+
bucket="test-bucket",
200+
key="test-key",
201+
instruction_suffix=".instruction",
202+
)
188203

189204
# Verify instruction file was fetched
190205
mock_s3_client.get_object.assert_called_once_with(

test/test_stream.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Unit tests for streaming decryption behavior."""
4+
5+
import os
6+
from io import BytesIO
7+
from unittest.mock import Mock
8+
9+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
10+
11+
from s3_encryption.stream import BufferedDecryptingStream, DelayedAuthDecryptingStream
12+
13+
14+
def _encrypt(plaintext: bytes):
15+
"""Encrypt plaintext with AES-GCM, return (ciphertext_with_tag, key, nonce)."""
16+
key = os.urandom(32)
17+
nonce = os.urandom(12)
18+
ciphertext_with_tag = AESGCM(key).encrypt(nonce, plaintext, None)
19+
return ciphertext_with_tag, key, nonce
20+
21+
22+
def _make_streaming_body(data: bytes):
23+
"""Create a mock StreamingBody wrapping data."""
24+
body = Mock()
25+
stream = BytesIO(data)
26+
body.read = stream.read
27+
body.close = Mock()
28+
body._stream = stream
29+
return body
30+
31+
32+
class TestDelayedAuthReleasesBeforeVerification:
33+
"""Delayed auth releases plaintext before the GCM tag is verified."""
34+
35+
##= specification/s3-encryption/client.md#enable-delayed-authentication
36+
##= type=test
37+
##% When enabled, the S3EC MAY release plaintext from a stream which has not been authenticated.
38+
def test_delayed_auth_releases_plaintext_before_tag_verification(self):
39+
plaintext = os.urandom(4096)
40+
ciphertext_with_tag, key, nonce = _encrypt(plaintext)
41+
body = _make_streaming_body(ciphertext_with_tag)
42+
43+
stream = DelayedAuthDecryptingStream(body, key, nonce)
44+
# read(256) decrypts a partial chunk via cipher.update(), releasing
45+
# plaintext without consuming the full ciphertext stream. The GCM tag
46+
# at the end of the stream has not been reached yet.
47+
chunk = stream.read(256)
48+
49+
# Plaintext was returned before the stream was fully consumed
50+
assert len(chunk) > 0
51+
# _finalized is False: the GCM tag has NOT been verified yet
52+
assert not stream._finalized
53+
# Ciphertext remains unread in the underlying stream
54+
assert body._stream.tell() < len(ciphertext_with_tag)
55+
56+
57+
class TestBufferedWithholdsUntilVerification:
58+
"""Buffered mode does not release plaintext until the GCM tag is verified."""
59+
60+
##= specification/s3-encryption/client.md#enable-delayed-authentication
61+
##= type=test
62+
##% When disabled the S3EC MUST NOT release plaintext from a stream which has not been authenticated.
63+
def test_buffered_verifies_tag_before_releasing_any_plaintext(self):
64+
plaintext = os.urandom(4096)
65+
ciphertext_with_tag, key, nonce = _encrypt(plaintext)
66+
body = _make_streaming_body(ciphertext_with_tag)
67+
68+
stream = BufferedDecryptingStream(body, key, nonce)
69+
# read(1) triggers _decrypt(), which calls self._body.read() with no amt,
70+
# consuming the entire ciphertext and verifying the GCM tag before
71+
# returning even 1 byte of plaintext.
72+
chunk = stream.read(1)
73+
74+
assert chunk == plaintext[:1]
75+
# _plaintext being set confirms full decrypt+verify already happened
76+
assert stream._plaintext is not None

0 commit comments

Comments
 (0)