Skip to content

Commit a4261de

Browse files
authored
feat: add aad for crypto (#203)
* feat: add aad for crypto * test: update template e2e for dashboard v3 * test: align encrypted fixtures with dashboard v3 * fix: align demo seed provenance with retained v2
1 parent 6b14034 commit a4261de

23 files changed

Lines changed: 909 additions & 71 deletions

dashboard_action/runtime/scripts/crypto_artifact.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@
2222
import storage
2323

2424

25-
VERSION = 1
25+
LEGACY_VERSION = 1
26+
VERSION = 2
2627
KDF_ITERATIONS = 600_000
2728
SALT_BYTES = 16
2829
IV_BYTES = 12
2930
ALLOWED_MEMBERS = set(storage.ARTIFACT_FILES)
31+
RETAINED_ARTIFACT_AAD_LABEL_V2 = "reponomics:retained-artifact:v2:dashboard-data"
32+
RETAINED_ARTIFACT_AAD_V2 = RETAINED_ARTIFACT_AAD_LABEL_V2.encode("utf-8")
3033

3134

3235
def _b64encode(data: bytes) -> str:
@@ -122,13 +125,14 @@ def encrypt(data_dir: Path, output: Path, secret_env: str) -> None:
122125
iv = os.urandom(IV_BYTES)
123126
key = _derive_key(secret, salt)
124127
plaintext = _pack_data_dir(data_dir)
125-
ciphertext = AESGCM(key).encrypt(iv, plaintext, None)
128+
ciphertext = AESGCM(key).encrypt(iv, plaintext, RETAINED_ARTIFACT_AAD_V2)
126129
payload = {
127130
"version": VERSION,
128131
"created_at": datetime.now(timezone.utc).isoformat(),
129132
"kdf": "PBKDF2-SHA256",
130133
"iterations": KDF_ITERATIONS,
131134
"algorithm": "AES-256-GCM",
135+
"aad": RETAINED_ARTIFACT_AAD_LABEL_V2,
132136
"salt": _b64encode(salt),
133137
"iv": _b64encode(iv),
134138
"ciphertext": _b64encode(ciphertext),
@@ -144,19 +148,30 @@ def decrypt(input_path: Path, data_dir: Path, secret_env: str) -> None:
144148
return
145149
secret = _load_secret(secret_env)
146150
payload = json.loads(input_path.read_text(encoding="utf-8"))
147-
if payload.get("version") != VERSION:
151+
version = payload.get("version")
152+
if version not in {LEGACY_VERSION, VERSION}:
148153
raise ValueError(f"Unsupported encrypted artifact version: {payload.get('version')}")
154+
aad = _aad_for_payload(payload)
149155
key = _derive_key(secret, _b64decode(payload["salt"]))
150156
plaintext = AESGCM(key).decrypt(
151157
_b64decode(payload["iv"]),
152158
_b64decode(payload["ciphertext"]),
153-
None,
159+
aad,
154160
)
155161
_safe_extract(plaintext, data_dir)
156162
input_path.unlink()
157163
print(f"Decrypted dashboard data artifact into {data_dir}")
158164

159165

166+
def _aad_for_payload(payload: dict[str, object]) -> bytes | None:
167+
version = payload.get("version")
168+
if version == LEGACY_VERSION:
169+
return None
170+
if payload.get("aad") != RETAINED_ARTIFACT_AAD_LABEL_V2:
171+
raise ValueError("Unsupported encrypted artifact AAD label.")
172+
return RETAINED_ARTIFACT_AAD_V2
173+
174+
160175
def main() -> None:
161176
parser = argparse.ArgumentParser()
162177
subparsers = parser.add_subparsers(dest="command", required=True)

dashboard_action/runtime/scripts/doctor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
)
4343
from doctor_support import (
4444
CHUNK_ID_RE,
45+
DASHBOARD_CHUNK_AAD_PREFIX,
46+
DASHBOARD_SUMMARY_AAD_LABEL,
4547
ENCRYPTED_DASHBOARD_SCRIPT_ID,
48+
EXPORT_AAD_LABEL,
4649
EXPECTED_DASHBOARD_DATA_VERSION,
4750
EXPECTED_EXPORT_MANIFEST_VERSION,
4851
EXPECTED_IV_BYTES,
@@ -80,8 +83,11 @@
8083

8184
__all__ = [
8285
"CHUNK_ID_RE",
86+
"DASHBOARD_CHUNK_AAD_PREFIX",
87+
"DASHBOARD_SUMMARY_AAD_LABEL",
8388
"ENCRYPTED_DASHBOARD_META_NAME",
8489
"ENCRYPTED_DASHBOARD_SCRIPT_ID",
90+
"EXPORT_AAD_LABEL",
8591
"EXPECTED_DASHBOARD_DATA_VERSION",
8692
"EXPECTED_EXPORT_MANIFEST_VERSION",
8793
"EXPECTED_IV_BYTES",

dashboard_action/runtime/scripts/doctor_modules/contracts.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
66

77
from doctor_support import (
88
CHUNK_ID_RE,
9-
EXPECTED_DASHBOARD_DATA_VERSION,
9+
EXPECTED_ENCRYPTED_DASHBOARD_DATA_VERSION,
1010
EXPECTED_KDF_HASH,
1111
EXPECTED_KDF_ITERATIONS,
1212
EXPECTED_KDF_NAME,
13+
EXPECTED_PLAINTEXT_DASHBOARD_DATA_VERSION,
1314
EXPECTED_SALT_BYTES,
1415
DashboardDoctorError as _DashboardDoctorError,
1516
DetectedDashboardMode,
1617
DoctorDataMode,
1718
DoctorStage,
1819
_b64_decode,
20+
_dashboard_aad_contract_valid,
1921
_object_dict,
2022
_stage,
2123
_validate_encrypted_blob_token,
@@ -208,7 +210,7 @@ def _encrypted_envelope_field_stages(data: dict[str, Any]) -> list[DoctorStage]:
208210
return [
209211
_value_stage(
210212
"browser_envelope_version_valid",
211-
data.get("version") == EXPECTED_DASHBOARD_DATA_VERSION,
213+
data.get("version") == EXPECTED_ENCRYPTED_DASHBOARD_DATA_VERSION,
212214
"dashboard data version is supported",
213215
"dashboard data version is unsupported",
214216
),
@@ -224,6 +226,12 @@ def _encrypted_envelope_field_stages(data: dict[str, Any]) -> list[DoctorStage]:
224226
"encoding is supported",
225227
"encrypted dashboard encoding is unsupported",
226228
),
229+
_value_stage(
230+
"browser_envelope_aad_valid",
231+
_dashboard_aad_contract_valid(data),
232+
"AAD contract is supported",
233+
"encrypted dashboard AAD contract is unsupported",
234+
),
227235
_value_stage(
228236
"browser_envelope_kdf_valid",
229237
_kdf_contract_valid(data.get("kdf")),
@@ -238,7 +246,7 @@ def _plain_envelope_field_stages(data: dict[str, Any]) -> list[DoctorStage]:
238246
return [
239247
_value_stage(
240248
"browser_envelope_version_valid",
241-
data.get("version") == EXPECTED_DASHBOARD_DATA_VERSION,
249+
data.get("version") == EXPECTED_PLAINTEXT_DASHBOARD_DATA_VERSION,
242250
"dashboard data version is supported",
243251
"dashboard data version is unsupported",
244252
),

dashboard_action/runtime/scripts/doctor_modules/data.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
_validate_summary_staged,
1414
)
1515
from doctor_support import (
16+
DASHBOARD_SUMMARY_AAD,
1617
DoctorSecretResult,
1718
DoctorStage,
19+
_dashboard_chunk_aad,
1820
_derive_key,
1921
_object_dict,
2022
_stage,
@@ -69,6 +71,7 @@ def _diagnose_encrypted_secret(
6971
summary, summary_stages = _decrypt_gzip_json_staged(
7072
data.get("summary"),
7173
key,
74+
aad=DASHBOARD_SUMMARY_AAD,
7275
subject=label,
7376
auth_stage="summary_authenticates",
7477
decompress_stage="summary_decompresses",
@@ -152,6 +155,7 @@ def _diagnose_encrypted_chunk(
152155
chunk, chunk_decode_stages = _decrypt_gzip_json_staged(
153156
token,
154157
key,
158+
aad=_dashboard_chunk_aad(chunk_id),
155159
subject=subject,
156160
auth_stage="chunk_authenticates",
157161
decompress_stage="chunk_decompresses",

dashboard_action/runtime/scripts/doctor_modules/decode.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def _decrypt_gzip_json_staged(
2626
token: Any,
2727
key: bytes,
2828
*,
29+
aad: bytes,
2930
subject: str,
3031
auth_stage: str,
3132
decompress_stage: str,
@@ -34,7 +35,7 @@ def _decrypt_gzip_json_staged(
3435
"""Decrypt, decompress, and parse one encrypted gzip+JSON object."""
3536
stage_names = _DecodeStages(auth=auth_stage, decompress=decompress_stage, json=json_stage)
3637
stages: list[DoctorStage] = []
37-
plaintext, auth_stages = _decrypt_blob_staged(token, key, subject, stage_names)
38+
plaintext, auth_stages = _decrypt_blob_staged(token, key, aad, subject, stage_names)
3839
stages.extend(auth_stages)
3940
if plaintext is None:
4041
return None, stages
@@ -52,13 +53,14 @@ def _decrypt_gzip_json_staged(
5253
def _decrypt_blob_staged(
5354
token: Any,
5455
key: bytes,
56+
aad: bytes,
5557
subject: str,
5658
stage_names: _DecodeStages,
5759
) -> tuple[bytes | None, list[DoctorStage]]:
5860
"""Decrypt a token and return authentication stages."""
5961
try:
6062
iv, ciphertext = _validate_encrypted_blob_token(token)
61-
plaintext = AESGCM(key).decrypt(iv, ciphertext, None)
63+
plaintext = AESGCM(key).decrypt(iv, ciphertext, aad)
6264
except InvalidTag:
6365
return None, [
6466
_stage(stage_names.auth, "failed", "AES-GCM authentication failed", subject),

dashboard_action/runtime/scripts/doctor_modules/export_asset.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
1111

1212
from doctor_support import (
13+
EXPORT_AAD,
1314
DoctorSecretResult,
1415
DoctorStage,
1516
DoctorStageStatus,
@@ -135,7 +136,7 @@ def _decrypt_export_with_secret(
135136
"""Decrypt export ciphertext with one accepted dashboard secret."""
136137
export_key = _derive_key(secret, salt)
137138
try:
138-
plaintext = AESGCM(export_key).decrypt(iv, ciphertext, None)
139+
plaintext = AESGCM(export_key).decrypt(iv, ciphertext, EXPORT_AAD)
139140
except InvalidTag:
140141
stages.append(_stage("export_decrypts", "failed", "AES-GCM authentication failed", label))
141142
return None

dashboard_action/runtime/scripts/doctor_modules/handoff.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def _ui_handoff_prerequisites(configured_mode: DoctorDataMode, repo_count: int)
116116
if configured_mode == "encrypted":
117117
prerequisites.update(
118118
{
119+
"browser_envelope_aad_valid",
119120
"browser_envelope_cipher_valid",
120121
"browser_envelope_kdf_valid",
121122
"browser_envelope_salt_valid",

dashboard_action/runtime/scripts/doctor_modules/manifest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from doctor_support import (
8+
EXPORT_AAD_LABEL,
89
EXPECTED_EXPORT_MANIFEST_VERSION,
910
EXPECTED_IV_BYTES,
1011
EXPECTED_KDF_HASH,
@@ -53,6 +54,8 @@ def _export_manifest_contract_errors(manifest: dict[str, Any]) -> list[str]:
5354
errors.append("invalid asset path")
5455
if not isinstance(manifest.get("filename"), str) or not manifest.get("filename"):
5556
errors.append("missing filename")
57+
if manifest.get("aad") != EXPORT_AAD_LABEL:
58+
errors.append("unsupported AAD")
5659
if not _positive_int(manifest.get("ciphertext_size")):
5760
errors.append("invalid ciphertext size")
5861
if not _sha256_value_valid(manifest.get("ciphertext_sha256")):

dashboard_action/runtime/scripts/doctor_modules/result.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"browser_envelope_version_valid",
2121
"browser_envelope_cipher_valid",
2222
"browser_envelope_kdf_valid",
23+
"browser_envelope_aad_valid",
2324
"browser_envelope_encoding_valid",
2425
"browser_envelope_salt_valid",
2526
"browser_envelope_summary_token_valid",

dashboard_action/runtime/scripts/doctor_retained.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929

3030

3131
RETAINED_ENCRYPTED_ARTIFACT_NAME = "dashboard-data.enc"
32+
RETAINED_ARTIFACT_LEGACY_VERSION = 1
33+
RETAINED_ARTIFACT_VERSION = 2
34+
RETAINED_ARTIFACT_AAD_LABEL_V2 = "reponomics:retained-artifact:v2:dashboard-data"
35+
RETAINED_ARTIFACT_AAD_V2 = RETAINED_ARTIFACT_AAD_LABEL_V2.encode("utf-8")
3236

3337

3438
def _retained_encrypted_candidates(retained_data_dir: Path | None) -> list[Path]:
@@ -148,17 +152,36 @@ def _object_dict(value: Any) -> dict[str, Any]:
148152
return {}
149153

150154

151-
def _load_retained_encrypted_payload(path: Path) -> tuple[list[DoctorStage], dict[str, Any] | None]:
155+
def _load_retained_encrypted_payload(
156+
path: Path,
157+
) -> tuple[list[DoctorStage], dict[str, Any] | None]:
152158
try:
153159
payload = json.loads(path.read_text(encoding="utf-8"))
154160
except Exception as exc:
155-
return [_stage("retained_artifact_readable", "failed", f"encrypted artifact was not readable JSON: {exc}")], None
161+
return [
162+
_stage(
163+
"retained_artifact_readable",
164+
"failed",
165+
f"encrypted artifact was not readable JSON: {exc}",
166+
)
167+
], None
156168
if not isinstance(payload, dict):
157-
return [_stage("retained_artifact_readable", "failed", "encrypted artifact payload was not a JSON object")], None
169+
return [
170+
_stage(
171+
"retained_artifact_readable",
172+
"failed",
173+
"encrypted artifact payload was not a JSON object",
174+
)
175+
], None
158176

159177
errors: list[str] = []
160-
if payload.get("version") != 1:
178+
if payload.get("version") not in {RETAINED_ARTIFACT_LEGACY_VERSION, RETAINED_ARTIFACT_VERSION}:
161179
errors.append("unsupported encrypted artifact version")
180+
if (
181+
payload.get("version") == RETAINED_ARTIFACT_VERSION
182+
and payload.get("aad") != RETAINED_ARTIFACT_AAD_LABEL_V2
183+
):
184+
errors.append("unsupported AAD")
162185
if payload.get("kdf") != "PBKDF2-SHA256":
163186
errors.append("unsupported KDF")
164187
if payload.get("iterations") != EXPECTED_KDF_ITERATIONS:
@@ -173,6 +196,12 @@ def _load_retained_encrypted_payload(path: Path) -> tuple[list[DoctorStage], dic
173196
return [_stage("retained_artifact_readable", "passed", "encrypted artifact payload is readable")], payload
174197

175198

199+
def _retained_artifact_aad(payload: dict[str, Any]) -> bytes | None:
200+
if payload.get("version") == RETAINED_ARTIFACT_LEGACY_VERSION:
201+
return None
202+
return RETAINED_ARTIFACT_AAD_V2
203+
204+
176205
def _diagnose_encrypted_retained_artifact(
177206
path: Path,
178207
*,
@@ -223,7 +252,7 @@ def _diagnose_encrypted_retained_artifact(
223252
for label, secret in accepted_secrets:
224253
key = _derive_key(secret, salt)
225254
try:
226-
plaintext = AESGCM(key).decrypt(iv, ciphertext, None)
255+
plaintext = AESGCM(key).decrypt(iv, ciphertext, _retained_artifact_aad(payload))
227256
except InvalidTag:
228257
stages.append(_stage("retained_artifact_decrypts", "failed", "AES-GCM authentication failed", label))
229258
continue

0 commit comments

Comments
 (0)