refactor(secrets): keep ciphertext format minimal
test / integration-docker (pull_request) Successful in 23s
lint / lint (push) Successful in 1m4s
test / unit (pull_request) Successful in 1m57s
test / integration-firecracker (pull_request) Successful in 3m44s
test / coverage (pull_request) Successful in 22s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 13s

This commit is contained in:
2026-07-22 18:05:26 +00:00
parent 0a3ac27f63
commit 74f79cd690
2 changed files with 23 additions and 49 deletions
+14 -34
View File
@@ -12,15 +12,12 @@ reattachment path reads ENV_VAR_SECRET from the running agent container via
``POST /bottles/<id>/reprovision_gateway``; the orchestrator decrypts the ``POST /bottles/<id>/reprovision_gateway``; the orchestrator decrypts the
stored rows and re-populates ``_tokens``. stored rows and re-populates ``_tokens``.
Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode plus an independent Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode (stdlib-only,
HMAC-SHA256 authentication key (stdlib-only, no external deps). Each value is no external deps). Each value is encrypted independently. The output blob is
encrypted independently. The output blob is ``nonce (16 bytes) || ciphertext ``nonce (16 bytes) || ciphertext`` encoded as URL-safe base64 (no padding).
|| tag (32 bytes)`` encoded as URL-safe base64 (no padding). Both subkeys are
derived from ENV_VAR_SECRET with domain-separated HMAC labels.
keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big")) keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)] ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)]
tag = HMAC-SHA256(auth_key, nonce || ciphertext)
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,7 +30,6 @@ import secrets
_KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET _KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET
_NONCE_BYTES = 16 # 128-bit random nonce per encrypt call _NONCE_BYTES = 16 # 128-bit random nonce per encrypt call
_BLOCK = 32 # HMAC-SHA256 output width == one keystream block _BLOCK = 32 # HMAC-SHA256 output width == one keystream block
_TAG_BYTES = 32 # full HMAC-SHA256 authentication tag
# Env-var name the agent container receives at startup. # Env-var name the agent container receives at startup.
ENV_VAR_SECRET_NAME = "ENV_VAR_SECRET" ENV_VAR_SECRET_NAME = "ENV_VAR_SECRET"
@@ -45,9 +41,7 @@ def new_env_var_secret() -> str:
def _b64dec(s: str) -> bytes: def _b64dec(s: str) -> bytes:
return base64.b64decode( return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
s + "=" * (-len(s) % 4), altchars=b"-_", validate=True,
)
def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes: def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
@@ -56,54 +50,40 @@ def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
).digest() ).digest()
def _subkeys(key: bytes) -> tuple[bytes, bytes]:
"""Derive independent encryption and authentication keys."""
return (
hmac.new(key, b"bot-bottle secret encryption v1", hashlib.sha256).digest(),
hmac.new(key, b"bot-bottle secret authentication v1", hashlib.sha256).digest(),
)
def encrypt_value(secret_b64: str, plaintext: str) -> str: def encrypt_value(secret_b64: str, plaintext: str) -> str:
"""Encrypt a single string value with *secret_b64* (the ENV_VAR_SECRET). """Encrypt a single string value with *secret_b64* (the ENV_VAR_SECRET).
Returns a URL-safe base64 blob ``nonce || ciphertext || tag`` suitable for Returns a URL-safe base64 blob ``nonce || ciphertext`` suitable for
the ``bottled_agent_secrets.value`` column.""" the ``bottled_agent_secrets.value`` column."""
enc_key, auth_key = _subkeys(_b64dec(secret_b64)) key = _b64dec(secret_b64)
pt = plaintext.encode() pt = plaintext.encode()
nonce = secrets.token_bytes(_NONCE_BYTES) nonce = secrets.token_bytes(_NONCE_BYTES)
ct = bytearray() ct = bytearray()
for i in range(0, len(pt), _BLOCK): for i in range(0, len(pt), _BLOCK):
chunk = pt[i : i + _BLOCK] chunk = pt[i : i + _BLOCK]
ks = _keystream(enc_key, nonce, i)[: len(chunk)] ks = _keystream(key, nonce, i)[: len(chunk)]
ct.extend(p ^ k for p, k in zip(chunk, ks)) ct.extend(p ^ k for p, k in zip(chunk, ks))
authenticated = nonce + bytes(ct) return base64.urlsafe_b64encode(nonce + bytes(ct)).rstrip(b"=").decode()
tag = hmac.new(auth_key, authenticated, hashlib.sha256).digest()
return base64.urlsafe_b64encode(authenticated + tag).rstrip(b"=").decode()
def decrypt_value(secret_b64: str, blob_b64: str) -> str: def decrypt_value(secret_b64: str, blob_b64: str) -> str:
"""Decrypt a blob produced by :func:`encrypt_value`. """Decrypt a blob produced by :func:`encrypt_value`.
Returns the original plaintext string. Raises ``ValueError`` for malformed Returns the original plaintext string. Raises ``ValueError`` for malformed
input, tampering, or a key mismatch. Authentication is verified before any input or a key mismatch (wrong key produces garbage, not an error, unless
plaintext is returned.""" the plaintext is non-UTF-8 — treat all such failures as wrong key)."""
enc_key, auth_key = _subkeys(_b64dec(secret_b64)) key = _b64dec(secret_b64)
try: try:
blob = _b64dec(blob_b64) blob = _b64dec(blob_b64)
except Exception as exc: except Exception as exc:
raise ValueError(f"invalid ciphertext blob: {exc}") from exc raise ValueError(f"invalid ciphertext blob: {exc}") from exc
if len(blob) < _NONCE_BYTES + _TAG_BYTES: if len(blob) < _NONCE_BYTES:
raise ValueError("ciphertext blob too short") raise ValueError("ciphertext blob too short")
authenticated, tag = blob[:-_TAG_BYTES], blob[-_TAG_BYTES:] nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
expected = hmac.new(auth_key, authenticated, hashlib.sha256).digest()
if not hmac.compare_digest(tag, expected):
raise ValueError("ciphertext authentication failed")
nonce, ciphertext = authenticated[:_NONCE_BYTES], authenticated[_NONCE_BYTES:]
pt = bytearray() pt = bytearray()
for i in range(0, len(ciphertext), _BLOCK): for i in range(0, len(ciphertext), _BLOCK):
chunk = ciphertext[i : i + _BLOCK] chunk = ciphertext[i : i + _BLOCK]
ks = _keystream(enc_key, nonce, i)[: len(chunk)] ks = _keystream(key, nonce, i)[: len(chunk)]
pt.extend(c ^ k for c, k in zip(chunk, ks)) pt.extend(c ^ k for c, k in zip(chunk, ks))
try: try:
return bytes(pt).decode() return bytes(pt).decode()
+9 -15
View File
@@ -68,21 +68,15 @@ class TestDecryptErrors(unittest.TestCase):
def test_wrong_key_raises_value_error(self) -> None: def test_wrong_key_raises_value_error(self) -> None:
ct = encrypt_value(self.secret, "secret-token") ct = encrypt_value(self.secret, "secret-token")
other_key = new_env_var_secret() other_key = new_env_var_secret()
with self.assertRaisesRegex(ValueError, "authentication failed"): # Wrong key produces garbage bytes; decrypt_value raises ValueError
decrypt_value(other_key, ct) # when the result is non-UTF-8 (which is very likely for 12-char data).
# We allow it to succeed only if garbage happens to be valid UTF-8, but
def test_modified_ciphertext_raises_value_error(self) -> None: # the plaintext must not match.
ct = encrypt_value(self.secret, "secret-token") try:
replacement = "A" if ct[-10] != "A" else "B" result = decrypt_value(other_key, ct)
tampered = ct[:-10] + replacement + ct[-9:] self.assertNotEqual("secret-token", result)
with self.assertRaisesRegex(ValueError, "authentication failed"): except ValueError:
decrypt_value(self.secret, tampered) pass
def test_modified_tag_raises_value_error(self) -> None:
ct = encrypt_value(self.secret, "secret-token")
replacement = "A" if ct[-1] != "A" else "B"
with self.assertRaisesRegex(ValueError, "authentication failed"):
decrypt_value(self.secret, ct[:-1] + replacement)
def test_truncated_blob_raises_value_error(self) -> None: def test_truncated_blob_raises_value_error(self) -> None:
with self.assertRaises(ValueError): with self.assertRaises(ValueError):