|
| 1 | +use std::collections::HashMap; |
| 2 | +use crate::test_utils; |
| 3 | +use aws_sdk_dynamodb::types::AttributeValue; |
| 4 | + |
| 5 | +use db_esdk::deps::aws_cryptography_materialProviders::types::material_providers_config::MaterialProvidersConfig; |
| 6 | +use db_esdk::deps::aws_cryptography_materialProviders::client; |
| 7 | +use db_esdk::deps::aws_cryptography_dbEncryptionSdk_structuredEncryption::types::CryptoAction; |
| 8 | + |
| 9 | +use db_esdk::deps::aws_cryptography_dbEncryptionSdk_dynamoDb::types::DynamoDbTableEncryptionConfig; |
| 10 | +use db_esdk::deps::aws_cryptography_dbEncryptionSdk_dynamoDb::types::StandardBeacon; |
| 11 | +use db_esdk::deps::aws_cryptography_materialProviders::types::DbeAlgorithmSuiteId; |
| 12 | +use db_esdk::intercept::DbEsdkInterceptor; |
| 13 | +use db_esdk::types::dynamo_db_tables_encryption_config::DynamoDbTablesEncryptionConfig; |
| 14 | +// use db_esdk::deps::aws_cryptography_keyStore::types::KeyStore; |
| 15 | +use db_esdk::deps::aws_cryptography_keyStore::client as keystore_client; |
| 16 | +use db_esdk::deps::aws_cryptography_keyStore::types::key_store_config::KeyStoreConfig; |
| 17 | + |
| 18 | +/* |
| 19 | + This example demonstrates how to set up a beacon on an encrypted attribute, |
| 20 | + put an item with the beacon, and query against that beacon. |
| 21 | + This example follows a use case of a database that stores unit inspection information. |
| 22 | +
|
| 23 | + Running this example requires access to a DDB table with the |
| 24 | + following key configuration: |
| 25 | + - Partition key is named "work_id" with type (S) |
| 26 | + - Sort key is named "inspection_date" with type (S) |
| 27 | + This table must have a Global Secondary Index (GSI) configured named "last4-unit-index": |
| 28 | + - Partition key is named "aws_dbe_b_inspector_id_last4" with type (S) |
| 29 | + - Sort key is named "aws_dbe_b_unit" with type (S) |
| 30 | +
|
| 31 | + In this example for storing unit inspection information, this schema is utilized for the data: |
| 32 | + - "work_id" stores a unique identifier for a unit inspection work order (v4 UUID) |
| 33 | + - "inspection_date" stores an ISO 8601 date for the inspection (YYYY-MM-DD) |
| 34 | + - "inspector_id_last4" stores the last 4 digits of the ID of the inspector performing the work |
| 35 | + - "unit" stores a 12-digit serial number for the unit being inspected |
| 36 | +
|
| 37 | + The example requires the following ordered input command line parameters: |
| 38 | + 1. DDB table name for table to put/query data from |
| 39 | + 2. Branch key ID for a branch key that was previously created in your key store. See the |
| 40 | + CreateKeyStoreKeyExample. |
| 41 | + 3. Branch key wrapping KMS key ARN for the KMS key used to create the branch key with ID |
| 42 | + provided in arg 2 |
| 43 | + 4. Branch key DDB table name for the DDB table representing the branch key store |
| 44 | + */ |
| 45 | + |
| 46 | + const GSI_NAME : &str = "last4-unit-index"; |
| 47 | + |
| 48 | + pub async fn put_and_query_with_beacon(branch_key_id : &str) |
| 49 | + { |
| 50 | + let ddb_table_name = test_utils::UNIT_INSPECTION_TEST_DDB_TABLE_NAME; |
| 51 | + let branch_key_wrapping_kms_key_arn = test_utils::TEST_BRANCH_KEY_WRAPPING_KMS_KEY_ARN; |
| 52 | + let branch_key_ddb_table_name = test_utils::TEST_BRANCH_KEYSTORE_DDB_TABLE_NAME; |
| 53 | + |
| 54 | + // 1. Configure Beacons. |
| 55 | + // The beacon name must be the name of a table attribute that will be encrypted. |
| 56 | + // The `length` parameter dictates how many bits are in the beacon attribute value. |
| 57 | + // The following link provides guidance on choosing a beacon length: |
| 58 | + // https://docs.aws.amazon.com/database-encryption-sdk/latest/devguide/choosing-beacon-length.html |
| 59 | + let mut standard_beacon_list = Vec::new(); |
| 60 | + |
| 61 | + // The configured DDB table has a GSI on the `aws_dbe_b_inspector_id_last4` AttributeName. |
| 62 | + // This field holds the last 4 digits of an inspector ID. |
| 63 | + // For our example, this field may range from 0 to 9,999 (10,000 possible values). |
| 64 | + // For our example, we assume a full inspector ID is an integer |
| 65 | + // ranging from 0 to 99,999,999. We do not assume that the full inspector ID's |
| 66 | + // values are uniformly distributed across its range of possible values. |
| 67 | + // In many use cases, the prefix of an identifier encodes some information |
| 68 | + // about that identifier (e.g. zipcode and SSN prefixes encode geographic |
| 69 | + // information), while the suffix does not and is more uniformly distributed. |
| 70 | + // We will assume that the inspector ID field matches a similar use case. |
| 71 | + // So for this example, we only store and use the last |
| 72 | + // 4 digits of the inspector ID, which we assume is uniformly distributed. |
| 73 | + // Since the full ID's range is divisible by the range of the last 4 digits, |
| 74 | + // then the last 4 digits of the inspector ID are uniformly distributed |
| 75 | + // over the range from 0 to 9,999. |
| 76 | + // See our documentation for why you should avoid creating beacons over non-uniform distributions |
| 77 | + // https://docs.aws.amazon.com/database-encryption-sdk/latest/devguide/searchable-encryption.html#are-beacons-right-for-me |
| 78 | + // A single inspector ID suffix may be assigned to multiple `work_id`s. |
| 79 | + // |
| 80 | + // This link provides guidance for choosing a beacon length: |
| 81 | + // https://docs.aws.amazon.com/database-encryption-sdk/latest/devguide/choosing-beacon-length.html |
| 82 | + // We follow the guidance in the link above to determine reasonable bounds |
| 83 | + // for the length of a beacon on the last 4 digits of an inspector ID: |
| 84 | + // - min: log(sqrt(10,000))/log(2) ~= 6.6, round up to 7 |
| 85 | + // - max: log((10,000/2))/log(2) ~= 12.3, round down to 12 |
| 86 | + // You will somehow need to round results to a nearby integer. |
| 87 | + // We choose to round to the nearest integer; you might consider a different rounding approach. |
| 88 | + // Rounding up will return fewer expected "false positives" in queries, |
| 89 | + // leading to fewer decrypt calls and better performance, |
| 90 | + // but it is easier to identify which beacon values encode distinct plaintexts. |
| 91 | + // Rounding down will return more expected "false positives" in queries, |
| 92 | + // leading to more decrypt calls and worse performance, |
| 93 | + // but it is harder to identify which beacon values encode distinct plaintexts. |
| 94 | + // We can choose a beacon length between 7 and 12: |
| 95 | + // - Closer to 7, we expect more "false positives" to be returned, |
| 96 | + // making it harder to identify which beacon values encode distinct plaintexts, |
| 97 | + // but leading to more decrypt calls and worse performance |
| 98 | + // - Closer to 12, we expect fewer "false positives" returned in queries, |
| 99 | + // leading to fewer decrypt calls and better performance, |
| 100 | + // but it is easier to identify which beacon values encode distinct plaintexts. |
| 101 | + // As an example, we will choose 10. |
| 102 | + // |
| 103 | + // Values stored in aws_dbe_b_inspector_id_last4 will be 10 bits long (0x000 - 0x3ff) |
| 104 | + // There will be 2^10 = 1,024 possible HMAC values. |
| 105 | + // With a sufficiently large number of well-distributed inspector IDs, |
| 106 | + // for a particular beacon we expect (10,000/1,024) ~= 9.8 4-digit inspector ID suffixes |
| 107 | + // sharing that beacon value. |
| 108 | + let last4_beacon = StandardBeacon::builder().name("inspector_id_last4").length(10).build(); |
| 109 | + standard_beacon_list.push(last4_beacon); |
| 110 | + |
| 111 | + // The configured DDB table has a GSI on the `aws_dbe_b_unit` AttributeName. |
| 112 | + // This field holds a unit serial number. |
| 113 | + // For this example, this is a 12-digit integer from 0 to 999,999,999,999 (10^12 possible values). |
| 114 | + // We will assume values for this attribute are uniformly distributed across this range. |
| 115 | + // A single unit serial number may be assigned to multiple `work_id`s. |
| 116 | + // |
| 117 | + // This link provides guidance for choosing a beacon length: |
| 118 | + // https://docs.aws.amazon.com/database-encryption-sdk/latest/devguide/choosing-beacon-length.html |
| 119 | + // We follow the guidance in the link above to determine reasonable bounds |
| 120 | + // for the length of a beacon on a unit serial number: |
| 121 | + // - min: log(sqrt(999,999,999,999))/log(2) ~= 19.9, round up to 20 |
| 122 | + // - max: log((999,999,999,999/2))/log(2) ~= 38.9, round up to 39 |
| 123 | + // We can choose a beacon length between 20 and 39: |
| 124 | + // - Closer to 20, we expect more "false positives" to be returned, |
| 125 | + // making it harder to identify which beacon values encode distinct plaintexts, |
| 126 | + // but leading to more decrypt calls and worse performance |
| 127 | + // - Closer to 39, we expect fewer "false positives" returned in queries, |
| 128 | + // leading to fewer decrypt calls and better performance, |
| 129 | + // but it is easier to identify which beacon values encode distinct plaintexts. |
| 130 | + // As an example, we will choose 30. |
| 131 | + // |
| 132 | + // Values stored in aws_dbe_b_unit will be 30 bits long (0x00000000 - 0x3fffffff) |
| 133 | + // There will be 2^30 = 1,073,741,824 ~= 1.1B possible HMAC values. |
| 134 | + // With a sufficiently large number of well-distributed inspector IDs, |
| 135 | + // for a particular beacon we expect (10^12/2^30) ~= 931.3 unit serial numbers |
| 136 | + // sharing that beacon value. |
| 137 | + let unit_beacon = StandardBeacon::builder().name("unit").length(30).build(); |
| 138 | + standard_beacon_list.push(unit_beacon); |
| 139 | + |
| 140 | + // 2. Configure Keystore. |
| 141 | + // The keystore is a separate DDB table where the client stores encryption and decryption materials. |
| 142 | + // In order to configure beacons on the DDB client, you must configure a keystore. |
| 143 | + // |
| 144 | + // This example expects that you have already set up a KeyStore with a single branch key. |
| 145 | + // See the "Create KeyStore Table Example" and "Create KeyStore Key Example" for how to do this. |
| 146 | + // After you create a branch key, you should persist its ID for use in this example. |
| 147 | + let sdk_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; |
| 148 | + let key_store_config = KeyStoreConfig::builder() |
| 149 | + .kms_client(aws_sdk_kms::Client::new(&sdk_config)) |
| 150 | + .ddb_client(aws_sdk_dynamodb::Client::new(&sdk_config)) |
| 151 | + .ddb_table_name(branch_key_ddb_table_name) |
| 152 | + .logical_key_store_name(branch_key_ddb_table_name) |
| 153 | + .kms_configuration(KMSConfiguration.builder().kms_key_arn(branch_key_wrapping_kms_key_arn).build().unwrap()) |
| 154 | + .build() |
| 155 | + .unwrap(); |
| 156 | + |
| 157 | +/* |
| 158 | + // 3. Create BeaconVersion. |
| 159 | + // The BeaconVersion inside the list holds the list of beacons on the table. |
| 160 | + // The BeaconVersion also stores information about the keystore. |
| 161 | + // BeaconVersion must be provided: |
| 162 | + // - keyStore: The keystore configured in step 2. |
| 163 | + // - keySource: A configuration for the key source. |
| 164 | + // For simple use cases, we can configure a 'singleKeySource' which |
| 165 | + // statically configures a single beaconKey. That is the approach this example takes. |
| 166 | + // For use cases where you want to use different beacon keys depending on the data |
| 167 | + // (for example if your table holds data for multiple tenants, and you want to use |
| 168 | + // a different beacon key per tenant), look into configuring a MultiKeyStore: |
| 169 | + // https://docs.aws.amazon.com/database-encryption-sdk/latest/devguide/searchable-encryption-multitenant.html |
| 170 | + var beaconVersions = new List<BeaconVersion> |
| 171 | + { |
| 172 | + new BeaconVersion |
| 173 | + { |
| 174 | + StandardBeacons = standardBeaconList, |
| 175 | + Version = 1, // MUST be 1 |
| 176 | + KeyStore = keyStore, |
| 177 | + KeySource = new BeaconKeySource |
| 178 | + { |
| 179 | + Single = new SingleKeyStore |
| 180 | + { |
| 181 | + // `keyId` references a beacon key. |
| 182 | + // For every branch key we create in the keystore, |
| 183 | + // we also create a beacon key. |
| 184 | + // This beacon key is not the same as the branch key, |
| 185 | + // but is created with the same ID as the branch key. |
| 186 | + KeyId = branchKeyId, |
| 187 | + CacheTTL = 6000 |
| 188 | + } |
| 189 | + } |
| 190 | + } |
| 191 | + }; |
| 192 | +
|
| 193 | + // 4. Create a Hierarchical Keyring |
| 194 | + // This is a KMS keyring that utilizes the keystore table. |
| 195 | + // This config defines how items are encrypted and decrypted. |
| 196 | + // NOTE: You should configure this to use the same keystore as your search config. |
| 197 | + var matProv = new MaterialProviders(new MaterialProvidersConfig()); |
| 198 | + var keyringInput = new CreateAwsKmsHierarchicalKeyringInput |
| 199 | + { |
| 200 | + BranchKeyId = branchKeyId, |
| 201 | + KeyStore = keyStore, |
| 202 | + TtlSeconds = 6000L |
| 203 | + }; |
| 204 | + var kmsKeyring = matProv.CreateAwsKmsHierarchicalKeyring(keyringInput); |
| 205 | +
|
| 206 | + // 5. Configure which attributes are encrypted and/or signed when writing new items. |
| 207 | + // For each attribute that may exist on the items we plan to write to our DynamoDbTable, |
| 208 | + // we must explicitly configure how they should be treated during item encryption: |
| 209 | + // - ENCRYPT_AND_SIGN: The attribute is encrypted and included in the signature |
| 210 | + // - SIGN_ONLY: The attribute not encrypted, but is still included in the signature |
| 211 | + // - DO_NOTHING: The attribute is not encrypted and not included in the signature |
| 212 | + // Any attributes that will be used in beacons must be configured as ENCRYPT_AND_SIGN. |
| 213 | + var attributeActionsOnEncrypt = new Dictionary<String, CryptoAction> |
| 214 | + { |
| 215 | + ["work_id"] = CryptoAction.SIGN_ONLY, // Our partition attribute must be SIGN_ONLY |
| 216 | + ["inspection_date"] = CryptoAction.SIGN_ONLY, // Our sort attribute must be SIGN_ONLY |
| 217 | + ["inspector_id_last4"] = CryptoAction.ENCRYPT_AND_SIGN, // Beaconized attributes must be encrypted |
| 218 | + ["unit"] = CryptoAction.ENCRYPT_AND_SIGN // Beaconized attributes must be encrypted |
| 219 | + }; |
| 220 | +
|
| 221 | + // 6. Create the DynamoDb Encryption configuration for the table we will be writing to. |
| 222 | + // The beaconVersions are added to the search configuration. |
| 223 | + var tableConfigs = new Dictionary<String, DynamoDbTableEncryptionConfig> |
| 224 | + { |
| 225 | + [ddbTableName] = new DynamoDbTableEncryptionConfig |
| 226 | + { |
| 227 | + LogicalTableName = ddbTableName, |
| 228 | + PartitionKeyName = "work_id", |
| 229 | + SortKeyName = "inspection_date", |
| 230 | + AttributeActionsOnEncrypt = attributeActionsOnEncrypt, |
| 231 | + Keyring = kmsKeyring, |
| 232 | + Search = new SearchConfig |
| 233 | + { |
| 234 | + WriteVersion = 1, // MUST be 1 |
| 235 | + Versions = beaconVersions |
| 236 | + } |
| 237 | + } |
| 238 | + }; |
| 239 | +
|
| 240 | + // 7. Create a new AWS SDK DynamoDb client using the TableEncryptionConfigs |
| 241 | + var ddb = new Client.DynamoDbClient( |
| 242 | + new DynamoDbTablesEncryptionConfig { TableEncryptionConfigs = tableConfigs }); |
| 243 | +
|
| 244 | + // 8. Put an item into our table using the above client. |
| 245 | + // Before the item gets sent to DynamoDb, it will be encrypted |
| 246 | + // client-side, according to our configuration. |
| 247 | + // Since our configuration includes beacons for `inspector_id_last4` and `unit`, |
| 248 | + // the client will add two additional attributes to the item. These attributes will have names |
| 249 | + // `aws_dbe_b_inspector_id_last4` and `aws_dbe_b_unit`. Their values will be HMACs |
| 250 | + // truncated to as many bits as the beacon's `length` parameter; e.g. |
| 251 | + // aws_dbe_b_inspector_id_last4 = truncate(HMAC("4321"), 10) |
| 252 | + // aws_dbe_b_unit = truncate(HMAC("123456789012"), 30) |
| 253 | + var item = new Dictionary<String, AttributeValue> |
| 254 | + { |
| 255 | + ["work_id"] = new AttributeValue("1313ba89-5661-41eb-ba6c-cb1b4cb67b2d"), |
| 256 | + ["inspection_date"] = new AttributeValue("2023-06-13"), |
| 257 | + ["inspector_id_last4"] = new AttributeValue("4321"), |
| 258 | + ["unit"] = new AttributeValue("123456789012") |
| 259 | + }; |
| 260 | +
|
| 261 | + var putRequest = new PutItemRequest |
| 262 | + { |
| 263 | + TableName = ddbTableName, |
| 264 | + Item = item |
| 265 | + }; |
| 266 | +
|
| 267 | + var putResponse = await ddb.PutItemAsync(putRequest); |
| 268 | + Debug.Assert(putResponse.HttpStatusCode == HttpStatusCode.OK); |
| 269 | +
|
| 270 | + // 10. Query for the item we just put. |
| 271 | + // Note that we are constructing the query as if we were querying on plaintext values. |
| 272 | + // However, the DDB encryption client will detect that this attribute name has a beacon configured. |
| 273 | + // The client will add the beaconized attribute name and attribute value to the query, |
| 274 | + // and transform the query to use the beaconized name and value. |
| 275 | + // Internally, the client will query for and receive all items with a matching HMAC value in the beacon field. |
| 276 | + // This may include a number of "false positives" with different ciphertext, but the same truncated HMAC. |
| 277 | + // e.g. if truncate(HMAC("123456789012"), 30) |
| 278 | + // == truncate(HMAC("098765432109"), 30), |
| 279 | + // the query will return both items. |
| 280 | + // The client will decrypt all returned items to determine which ones have the expected attribute values, |
| 281 | + // and only surface items with the correct plaintext to the user. |
| 282 | + // This procedure is internal to the client and is abstracted away from the user; |
| 283 | + // e.g. the user will only see "123456789012" and never |
| 284 | + // "098765432109", though the actual query returned both. |
| 285 | + var expressionAttributesNames = new Dictionary<String, String> |
| 286 | + { |
| 287 | + ["#last4"] = "inspector_id_last4", |
| 288 | + ["#unit"] = "unit" |
| 289 | + }; |
| 290 | +
|
| 291 | + var expressionAttributeValues = new Dictionary<String, AttributeValue> |
| 292 | + { |
| 293 | + [":last4"] = new AttributeValue("4321"), |
| 294 | + [":unit"] = new AttributeValue("123456789012") |
| 295 | + }; |
| 296 | +
|
| 297 | + var queryRequest = new QueryRequest |
| 298 | + { |
| 299 | + TableName = ddbTableName, |
| 300 | + IndexName = GSI_NAME, |
| 301 | + KeyConditionExpression = "#last4 = :last4 and #unit = :unit", |
| 302 | + ExpressionAttributeNames = expressionAttributesNames, |
| 303 | + ExpressionAttributeValues = expressionAttributeValues |
| 304 | + }; |
| 305 | +
|
| 306 | + // GSIs do not update instantly |
| 307 | + // so if the results come back empty |
| 308 | + // we retry after a short sleep |
| 309 | + for (int i = 0; i < 10; ++i) |
| 310 | + { |
| 311 | + var queryResponse = await ddb.QueryAsync(queryRequest); |
| 312 | + var attributeValues = queryResponse.Items; |
| 313 | + // Validate query was returned successfully |
| 314 | + Debug.Assert(queryResponse.HttpStatusCode == HttpStatusCode.OK); |
| 315 | +
|
| 316 | + // if no results, sleep and try again |
| 317 | + if (attributeValues.Count == 0) |
| 318 | + { |
| 319 | + Thread.Sleep(20); |
| 320 | + continue; |
| 321 | + } |
| 322 | +
|
| 323 | + // Validate only 1 item was returned: the item we just put |
| 324 | + Debug.Assert(attributeValues.Count == 1); |
| 325 | + var returnedItem = attributeValues[0]; |
| 326 | + // Validate the item has the expected attributes |
| 327 | + Debug.Assert(returnedItem["inspector_id_last4"].S.Equals("4321")); |
| 328 | + Debug.Assert(returnedItem["unit"].S.Equals("123456789012")); |
| 329 | + break; |
| 330 | + } |
| 331 | + */ |
| 332 | + } |
0 commit comments