Skip to content

Commit 5e5248a

Browse files
authored
feat: add support for SSE on S3 bucket (#150)
1 parent d0b7185 commit 5e5248a

5 files changed

Lines changed: 213 additions & 31 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ AWS_DEFAULT_REGION=us-east-1
44
AWS_ACCESS_KEY_ID=000000000000
55
AWS_SECRET_ACCESS_KEY=your_secret_access_key
66
BACKUP_S3_BUCKET=backup-service-bucket
7+
BACKUP_S3_BUCKET_KMS_KEY_ARN=
78
DYNAMODB_TABLE_FACTOR_LOOKUP=backup-service-factor-lookup
89
DYNAMODB_GSI_FACTOR_LOOKUP=GSI_BackupId
910

README.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
# Backup Service
22

3-
* Staging: https://tfh-backup-api.dev-nethermind.xyz/docs
4-
* Production: https://api-tfh-backup-prod.nethermind.io/docs
5-
* Mobile flows: https://excalidraw.com/#json=pJTLrSff6hYYAI0fztR2v,F8_Z-kkzbVN1Icd37CMV6Q
3+
- Staging: https://tfh-backup-api.dev-nethermind.xyz/docs
4+
- Production: https://api-tfh-backup-prod.nethermind.io/docs
5+
- Mobile flows: https://excalidraw.com/#json=pJTLrSff6hYYAI0fztR2v,F8_Z-kkzbVN1Icd37CMV6Q
66

77
### High-level description
88

99
Backup Service stores and manages authentication for encrypted backups represented as binary blobs. The data is stored
1010
on S3. The service also uses DynamoDB for mapping between factors and backups, as well as for some ephemeral data (e.g. used challenges).
1111

1212
A typical backup lifecycle:
13+
1314
1. **Creation** (`/create`): Client creates a backup with an authentication factor (passkey, OIDC, or keypair) and a sync factor (EC keypair)
14-
3. **Retrieval** (`/retrieve/from-challenge`): Client retrieves backup using an authentication factor
15-
4. **Add sync factor** (`/add-sync-factor`): Client adds new sync factor after performing recovery.
15+
2. **Retrieval** (`/retrieve/from-challenge`): Client retrieves backup using an authentication factor
16+
3. **Add sync factor** (`/add-sync-factor`): Client adds new sync factor after performing recovery.
1617
4. **Sync** (`/sync`): Client updates backup content using a sync factor
1718
5. **Management**: Client can add factors (`/add-factor`), or delete factors (`/delete-factor`). It can also view backup metadata (`/retrieve-metadata`).
1819

1920
### Definitions
2021

21-
* **Sealed Backup**: Binary blob from user device with backup ciphertext.
22-
* **Backup Metadata**: Information about a backup including its ID, authentication factors, sync factors, and encrypted keys
23-
* **Main Factor**: Authentication method that can access a backup and **manage the backup** (add new factors, and perform recovery). It is a passkey, OIDC account, or EC keypair.
24-
* **Sync Factor**: Special factor (EC keypair) that can update backup content, delete factors and read metadata, but cannot add new factors or perform recovery
25-
* **Encrypted Backup Key**: Encryption key for the backup data, encrypted separately for each factor kind. The encrypted key is coming from user's device and is stored in the backup metadata.
26-
* **Turnkey Shared Passkey Challenge**: A passkey challenge that is a valid [Webauthn Turnkey stamp](https://docs.turnkey.com/developer-reference/api-overview/stamps#webauthn) and can be used to add a new factor to backup-service. Allows to add new factor with authorization to Turnkey & backup-service in a single passkey prompt.
22+
- **Sealed Backup**: Binary blob from user device with backup ciphertext.
23+
- **Backup Metadata**: Information about a backup including its ID, authentication factors, sync factors, and encrypted keys
24+
- **Main Factor**: Authentication method that can access a backup and **manage the backup** (add new factors, and perform recovery). It is a passkey, OIDC account, or EC keypair.
25+
- **Sync Factor**: Special factor (EC keypair) that can update backup content, delete factors and read metadata, but cannot add new factors or perform recovery
26+
- **Encrypted Backup Key**: Encryption key for the backup data, encrypted separately for each factor kind. The encrypted key is coming from user's device and is stored in the backup metadata.
27+
- **Turnkey Shared Passkey Challenge**: A passkey challenge that is a valid [Webauthn Turnkey stamp](https://docs.turnkey.com/developer-reference/api-overview/stamps#webauthn) and can be used to add a new factor to backup-service. Allows to add new factor with authorization to Turnkey & backup-service in a single passkey prompt.
2728

2829
### Running Locally
2930

@@ -53,10 +54,9 @@ docker compose down
5354

5455
An end-to-end Python test script is available to test the complete backup flow against remote environments:
5556

56-
5757
```bash
5858
export ATTESTATION_TOKEN="dummy-token-for-testing-purposes-xxxxx"
5959
python3 tests/e2e/create-and-retrieve-backup.py \
6060
--url "https://tfh-backup-api.dev-nethermind.xyz" \
6161
--attestation-token "$ATTESTATION_TOKEN"
62-
```
62+
```

src/backup_storage.rs

Lines changed: 176 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ impl BackupStorage {
3838
backup_metadata: &BackupMetadata,
3939
) -> Result<(), BackupManagerError> {
4040
// Save encrypted backup to S3
41-
self.s3_client
42-
.put_object()
41+
self.put_object()
4342
.bucket(self.environment.s3_bucket())
4443
.key(get_backup_key(&backup_metadata.id))
4544
.body(ByteStream::from(backup))
@@ -48,8 +47,7 @@ impl BackupStorage {
4847
.await?;
4948

5049
// Save metadata to S3
51-
self.s3_client
52-
.put_object()
50+
self.put_object()
5351
.bucket(self.environment.s3_bucket())
5452
.key(get_metadata_key(&backup_metadata.id))
5553
.body(ByteStream::from(serde_json::to_vec(backup_metadata)?))
@@ -176,8 +174,7 @@ impl BackupStorage {
176174

177175
metadata.manifest_hash = new_manifest_hash;
178176

179-
self.s3_client
180-
.put_object()
177+
self.put_object()
181178
.bucket(self.environment.s3_bucket())
182179
.key(get_backup_key(backup_id))
183180
.body(ByteStream::from(backup))
@@ -187,8 +184,7 @@ impl BackupStorage {
187184
// Save the new metadata
188185
// NOTE: There's a possibility of a conflict here, where saving the metadata fails but the backup is updated.
189186
// For this case, the client will get an error on the update, and will be able to retry the update. Recovery is also possible from the previous state.
190-
self.s3_client
191-
.put_object()
187+
self.put_object()
192188
.bucket(self.environment.s3_bucket())
193189
.key(get_metadata_key(backup_id))
194190
.if_match(e_tag)
@@ -244,8 +240,7 @@ impl BackupStorage {
244240
}
245241

246242
// Save the updated metadata
247-
self.s3_client
248-
.put_object()
243+
self.put_object()
249244
.bucket(self.environment.s3_bucket())
250245
.key(get_metadata_key(backup_id))
251246
.if_match(e_tag)
@@ -299,8 +294,7 @@ impl BackupStorage {
299294
metadata.sync_factors.push(sync_factor);
300295

301296
// Save the updated metadata
302-
self.s3_client
303-
.put_object()
297+
self.put_object()
304298
.bucket(self.environment.s3_bucket())
305299
.key(get_metadata_key(backup_id))
306300
.if_match(e_tag)
@@ -407,8 +401,7 @@ impl BackupStorage {
407401
}
408402
}
409403

410-
self.s3_client
411-
.put_object()
404+
self.put_object()
412405
.bucket(self.environment.s3_bucket())
413406
.key(get_metadata_key(backup_id))
414407
.if_match(e_tag)
@@ -445,8 +438,7 @@ impl BackupStorage {
445438

446439
metadata.sync_factors.remove(index);
447440

448-
self.s3_client
449-
.put_object()
441+
self.put_object()
450442
.bucket(self.environment.s3_bucket())
451443
.key(get_metadata_key(backup_id))
452444
.if_match(e_tag)
@@ -518,6 +510,17 @@ impl BackupStorage {
518510
}
519511
}
520512
}
513+
514+
/// Helper to create a `PutObject` builder with SSE-C if configured
515+
fn put_object(&self) -> aws_sdk_s3::operation::put_object::builders::PutObjectFluentBuilder {
516+
let mut builder = self.s3_client.put_object();
517+
if let Some(key_arn) = self.environment.s3_sse_kms_key_arn() {
518+
builder = builder
519+
.ssekms_key_id(key_arn)
520+
.server_side_encryption(aws_sdk_s3::types::ServerSideEncryption::AwsKms);
521+
}
522+
builder
523+
}
521524
}
522525

523526
pub struct FoundBackup {
@@ -1348,4 +1351,161 @@ mod tests {
13481351
_ => panic!("Expected only PRF key to remain"),
13491352
}
13501353
}
1354+
1355+
#[allow(clippy::too_many_lines)]
1356+
#[tokio::test]
1357+
async fn test_sse_encryption_with_localstack() {
1358+
dotenvy::from_filename(".env.example").unwrap();
1359+
1360+
let kms_key_id =
1361+
"arn:aws:kms:us-east-1:000000000000:key/00000000-f510-7227-9b63-da8e18607616";
1362+
std::env::set_var("BACKUP_S3_BUCKET_KMS_KEY_ARN", kms_key_id);
1363+
let environment = Environment::development(None);
1364+
let kms_client = aws_sdk_kms::Client::new(&environment.aws_config().await);
1365+
1366+
kms_client
1367+
.enable_key()
1368+
.key_id(kms_key_id)
1369+
.send()
1370+
.await
1371+
.unwrap();
1372+
1373+
let s3_client = Arc::new(S3Client::from_conf(environment.s3_client_config().await));
1374+
let backup_storage = BackupStorage::new(environment, s3_client.clone());
1375+
1376+
let test_backup_id = gen_backup_id();
1377+
let test_backup_data = vec![1, 2, 3, 4, 5];
1378+
let test_metadata = BackupMetadata {
1379+
id: test_backup_id.clone(),
1380+
factors: vec![],
1381+
sync_factors: vec![],
1382+
keys: vec![],
1383+
manifest_hash: hex::encode([1u8; 32]),
1384+
};
1385+
1386+
// Test 1: Create backup with SSE-KMS
1387+
backup_storage
1388+
.create(test_backup_data.clone(), &test_metadata)
1389+
.await
1390+
.unwrap();
1391+
1392+
// Verify the object is encrypted with SSE-KMS
1393+
let head_result = s3_client
1394+
.head_object()
1395+
.bucket(environment.s3_bucket())
1396+
.key(get_backup_key(&test_backup_id))
1397+
.send()
1398+
.await
1399+
.unwrap();
1400+
assert_eq!(
1401+
head_result.server_side_encryption(),
1402+
Some(&aws_sdk_s3::types::ServerSideEncryption::AwsKms)
1403+
);
1404+
assert_eq!(head_result.ssekms_key_id(), Some(kms_key_id));
1405+
1406+
// Test 2: Get backup with SSE-KMS (should succeed with correct key)
1407+
let found_backup = backup_storage
1408+
.get_by_backup_id(&test_backup_id)
1409+
.await
1410+
.unwrap()
1411+
.expect("Backup not found");
1412+
assert_eq!(found_backup.backup, test_backup_data);
1413+
assert_eq!(found_backup.metadata, test_metadata);
1414+
1415+
// Test 3: Get metadata with SSE-KMS
1416+
let (metadata, _) = backup_storage
1417+
.get_metadata_by_backup_id(&test_backup_id)
1418+
.await
1419+
.unwrap()
1420+
.expect("Metadata not found");
1421+
assert_eq!(metadata, test_metadata);
1422+
1423+
// Test 4: Update backup with SSE-KMS
1424+
let updated_backup_data = vec![6, 7, 8, 9, 10];
1425+
backup_storage
1426+
.update_backup(
1427+
&test_backup_id,
1428+
updated_backup_data.clone(),
1429+
hex::encode([1u8; 32]),
1430+
hex::encode([2u8; 32]),
1431+
)
1432+
.await
1433+
.unwrap();
1434+
1435+
let found_backup = backup_storage
1436+
.get_by_backup_id(&test_backup_id)
1437+
.await
1438+
.unwrap()
1439+
.expect("Backup not found");
1440+
assert_eq!(found_backup.backup, updated_backup_data);
1441+
assert_eq!(found_backup.metadata.manifest_hash, hex::encode([2u8; 32]));
1442+
1443+
// Test 5: Add factor with SSE-KMS
1444+
let new_factor = Factor::new_oidc_account(
1445+
OidcAccountKind::Google {
1446+
sub: "12345".to_string(),
1447+
masked_email: "test@example.com".to_string(),
1448+
},
1449+
"turnkey_provider_id".to_string(),
1450+
);
1451+
backup_storage
1452+
.add_factor(&test_backup_id, new_factor.clone(), None)
1453+
.await
1454+
.unwrap();
1455+
1456+
let (metadata, _) = backup_storage
1457+
.get_metadata_by_backup_id(&test_backup_id)
1458+
.await
1459+
.unwrap()
1460+
.expect("Metadata not found");
1461+
assert_eq!(metadata.factors.len(), 1);
1462+
1463+
// Test 6: Add sync factor with SSE-KMS
1464+
let sync_factor = Factor::new_ec_keypair("public-key".to_string());
1465+
backup_storage
1466+
.add_sync_factor(&test_backup_id, sync_factor.clone())
1467+
.await
1468+
.unwrap();
1469+
1470+
let (metadata, _) = backup_storage
1471+
.get_metadata_by_backup_id(&test_backup_id)
1472+
.await
1473+
.unwrap()
1474+
.expect("Metadata not found");
1475+
assert_eq!(metadata.sync_factors.len(), 1);
1476+
1477+
// Test 7: Remove sync factor with SSE-KMS
1478+
backup_storage
1479+
.remove_sync_factor(&test_backup_id, &sync_factor.id)
1480+
.await
1481+
.unwrap();
1482+
1483+
let (metadata, _) = backup_storage
1484+
.get_metadata_by_backup_id(&test_backup_id)
1485+
.await
1486+
.unwrap()
1487+
.expect("Metadata not found");
1488+
assert_eq!(metadata.sync_factors.len(), 0);
1489+
1490+
// Test 8: Head object with SSE-KMS
1491+
let exists = backup_storage
1492+
.does_backup_exist(&test_backup_id)
1493+
.await
1494+
.unwrap();
1495+
assert!(exists);
1496+
1497+
// Test 9: Verify encryption is applied to all objects
1498+
let metadata_head_result = s3_client
1499+
.head_object()
1500+
.bucket(environment.s3_bucket())
1501+
.key(get_metadata_key(&test_backup_id))
1502+
.send()
1503+
.await
1504+
.unwrap();
1505+
assert_eq!(
1506+
metadata_head_result.server_side_encryption(),
1507+
Some(&aws_sdk_s3::types::ServerSideEncryption::AwsKms)
1508+
);
1509+
assert_eq!(metadata_head_result.ssekms_key_id(), Some(kms_key_id));
1510+
}
13511511
}

src/types/environment.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ impl Environment {
5151
Self::Production | Self::Staging => env::var("BACKUP_S3_BUCKET")
5252
.expect("BACKUP_S3_BUCKET environment variable is not set")
5353
.to_string(),
54-
Self::Development { .. } => "backup-service-bucket".to_string(),
54+
Self::Development { .. } => {
55+
env::var("BACKUP_S3_BUCKET").unwrap_or_else(|_| "backup-service-bucket".to_string())
56+
}
5557
}
5658
}
5759

@@ -102,6 +104,20 @@ impl Environment {
102104
builder.build()
103105
}
104106

107+
/// Optional KMS key configuration if the S3 bucket has SSE-KMS encryption enabled.
108+
///
109+
/// If **both** are set, all S3 operations will use SSE-KMS encryption
110+
/// `BACKUP_S3_BUCKET_KMS_KEY_ARN` should be the ARN of a KMS key that is used to encrypt the data in the S3 bucket
111+
#[must_use]
112+
pub fn s3_sse_kms_key_arn(&self) -> Option<String> {
113+
let val = env::var("BACKUP_S3_BUCKET_KMS_KEY_ARN").ok()?;
114+
if val.is_empty() {
115+
None
116+
} else {
117+
Some(val)
118+
}
119+
}
120+
105121
/// Returns whether the API docs should be visible
106122
#[must_use]
107123
pub fn show_api_docs(&self) -> bool {

tests/aws-seed.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
#!/bin/bash
2-
# Create S3 bucket for backup storage
2+
# kms key for SSE (must be created before the bucket to enable default encryption)
3+
awslocal kms create-key --key-usage ENCRYPT_DECRYPT --region us-east-1 --key-spec SYMMETRIC_DEFAULT --tags '[{"TagKey":"_custom_id_","TagValue":"00000000-f510-7227-9b63-da8e18607616"}]'
4+
5+
# s3 bucket
36
awslocal s3 mb s3://backup-service-bucket
47
awslocal s3api put-bucket-versioning --bucket backup-service-bucket --versioning-configuration Status=Enabled
58

9+
# kms key for `ChallengeToken` encryption
610
awslocal kms create-key --key-usage ENCRYPT_DECRYPT --region us-east-1 --key-spec SYMMETRIC_DEFAULT --tags '[{"TagKey":"_custom_id_","TagValue":"01926dd6-f510-7227-9b63-da8e18607615"}]'
711

812
# "wrong" key for tests
913
awslocal kms create-key --key-usage ENCRYPT_DECRYPT --region us-east-1 --key-spec SYMMETRIC_DEFAULT --tags '[{"TagKey":"_custom_id_","TagValue":"01926dd6-f510-7227-9b63-da8e18607614"}]'
1014

15+
1116
# dynamodb for factor lookup
1217
awslocal dynamodb create-table \
1318
--table-name backup-service-factor-lookup \

0 commit comments

Comments
 (0)