From 9e2f2d8175dd77e034830af2be611ebcd661a3c3 Mon Sep 17 00:00:00 2001 From: huntj88 Date: Sat, 26 Jun 2021 14:52:57 -0500 Subject: [PATCH] Updated readme, and check if users are friends before sending the message --- .../jameshunt/dhiffiechat/DhiffieChatApp.kt | 5 ++ .../me/jameshunt/dhiffiechat/SendMessage.kt | 9 ++ README.md | 84 +++++++++--------- .../stage/terraform.tfstate | 46 ++++++---- .../stage/terraform.tfstate.backup | 28 ++---- readme/androidWorkflowInputs.png | Bin 0 -> 14003 bytes 6 files changed, 93 insertions(+), 79 deletions(-) create mode 100644 readme/androidWorkflowInputs.png diff --git a/Android/app/src/main/java/me/jameshunt/dhiffiechat/DhiffieChatApp.kt b/Android/app/src/main/java/me/jameshunt/dhiffiechat/DhiffieChatApp.kt index 0c615bc..27296e7 100644 --- a/Android/app/src/main/java/me/jameshunt/dhiffiechat/DhiffieChatApp.kt +++ b/Android/app/src/main/java/me/jameshunt/dhiffiechat/DhiffieChatApp.kt @@ -6,6 +6,11 @@ import androidx.compose.material.darkColors import androidx.compose.material.lightColors import androidx.compose.ui.graphics.Color +// TODO: Limit media length to 60 seconds, put a max size in mb just in case, but with extra roominess +// TODO: global error dialog for network errors +// TODO: welcome screen +// TODO: empty state on home screen + @SuppressLint("ConflictingOnColor") // TODO: Resolve? class DhiffieChatApp : Application() { companion object { diff --git a/Lambda/src/main/kotlin/me/jameshunt/dhiffiechat/SendMessage.kt b/Lambda/src/main/kotlin/me/jameshunt/dhiffiechat/SendMessage.kt index c55a1aa..4dc86d3 100644 --- a/Lambda/src/main/kotlin/me/jameshunt/dhiffiechat/SendMessage.kt +++ b/Lambda/src/main/kotlin/me/jameshunt/dhiffiechat/SendMessage.kt @@ -14,6 +14,15 @@ import java.util.* class SendMessage : RequestHandler, GatewayResponse> { override fun handleRequest(request: Map, context: Context): GatewayResponse { return awsTransformAuthed(request, context) { body, identity -> + val table = Singletons.dynamoDB.userTable() + val item = table.getItem("userId", identity.userId) + + context.logger.log("checking if friends") + val friends = item?.getStringSet("friends") ?: emptySet() + if (!friends.contains(body.recipientUserId)) { + throw Unauthorized() + } + // TODO: check if friends val messageCreatedAt = Instant.now() diff --git a/README.md b/README.md index 8c35dc0..048f425 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,48 @@ End-To-End Encrypted Private Chat +I built this app because I wanted to play with encryption and experiment with building low cost automated cloud +environments. + +--- + +### Architecture, Automation, and Deploying Environments + +Due to the cost constraints I placed on myself, I wouldn't be able to use managed relational databases or provisioned +server instances. + +The backend is built with AWS lambda, dynamoDB, s3. All of these are pay as you go, which is perfect because I don't really +expect anybody to use this anyway `¯\_(ツ)_/¯` + +Creating new environments or modifying existing environments is automated through `Terraform`, and AWS costs are about +$0.06/month during development. + +Terraform helps automate creation, configuration, or execution of the following resources and tasks. + +* AWS Gateway +* AWS lambda functions +* AWS DynamoDB Tables +* AWS S3 Buckets +* Generating Credentials +* Configuring Access and Permissions +* Generating Config files for the app to connect to new environments + +--- + +### Android Automation + +![Input for CI build workflow](readme/androidWorkflowInputs.png) + +* The Config files to connect to the server are automatically generated when a new environment is created. + + +* CI workflow to make builds for any specified branch/environment. Provides easy download access for QA. Prod builds generated + from the master branch also generate a GitHub release. + + +* All CI builds are automatically tagged with a generated version name that makes it easy to see how the build was + generated, and where in the history of repository. + --- ### Adding a Contact / Message Exchange Process @@ -47,48 +89,6 @@ End-To-End Encrypted Private Chat --- -### Architecture, Automation, and Deploying Environments - -One of the main goals of this project was to have as much of the project fully automated as possible. - -Another goal of this project was to leave it running as cheaply as possible, so no managed relational databases, or -provisioned server instances. - -Currently, only a single CLI command is required to create a new environment, and AWS costs are about $0.06/month during -development. I'm optimizing costs for lower numbers of users (expected), but it will scale to more users just fine. - -The "Server" is actually AWS lambda + dynamoDB + s3. - -Entire server environments can be created/updated with `terraform apply`, or removed entirely with `terraform destroy` - -Terraform helps automate creation, configuration, or execution of the following resources and tasks - -* AWS Gateway -* AWS lambda functions -* DynamoDB Tables -* S3 Buckets -* Generating Credentials -* Configuring Access and Permissions -* Generating Config files for the app to connect to new environments - -To Create a new environment, use Terraform workspaces and apply it. - ---- - -### Android Automation - -* The Config files to connect to the server are automatically generated when a new environment is created - - -* CI workflow to make builds for any specified environment, with easy to access to download for QA. Prod builds also - generate a GitHub release. - - -* All CI builds are automatically tagged with a generated version name that makes it easy to see how the build was - generated, and where in the history of repository. - ---- - ### Coming soon * Ephemeral keys: Right now a user has one main private/public Diffie-Hellman key pair which is used for encrypting the diff --git a/Terraform/infrastructure/terraform.tfstate.d/stage/terraform.tfstate b/Terraform/infrastructure/terraform.tfstate.d/stage/terraform.tfstate index e507cda..2c0ad36 100644 --- a/Terraform/infrastructure/terraform.tfstate.d/stage/terraform.tfstate +++ b/Terraform/infrastructure/terraform.tfstate.d/stage/terraform.tfstate @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "0.14.7", - "serial": 295, + "serial": 306, "lineage": "64a2bad5-87d9-087d-fac4-f21cf3366664", "outputs": { "base_url": { @@ -19,16 +19,16 @@ { "schema_version": 0, "attributes": { - "created_date": "2021-06-23T01:52:13Z", + "created_date": "2021-06-26T04:50:24Z", "description": "", "execution_arn": "arn:aws:execute-api:us-east-1:654246770704:c71bjvgvs9/stage", - "id": "5f1936", + "id": "9pfh4l", "invoke_url": "https://c71bjvgvs9.execute-api.us-east-1.amazonaws.com/stage", "rest_api_id": "c71bjvgvs9", "stage_description": null, "stage_name": "stage", "triggers": { - "redeployment": "2d019e4721214854ed73780a803e59c71ea1d293" + "redeployment": "40d4e71a86d537c95d89a9c6d013fef1c3549ba4" }, "variables": null }, @@ -310,7 +310,7 @@ "image_uri": "", "invoke_arn": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:654246770704:function:stage_HandleS3Upload/invocations", "kms_key_arn": "", - "last_modified": "2021-06-23T01:52:38.918+0000", + "last_modified": "2021-06-26T04:50:01.553+0000", "layers": [], "memory_size": 1024, "package_type": "Zip", @@ -324,8 +324,8 @@ "s3_object_version": null, "signing_job_arn": "", "signing_profile_version_arn": "", - "source_code_hash": "GMX3KFIEkK3jq3RDkvPa2G7u/38Gdfjh/JzRarI8OOU=", - "source_code_size": 39967859, + "source_code_hash": "iq/O3NDmiDHZXIr+kkwIOVYFFDjD8FhJ46rUPX3Pkps=", + "source_code_size": 39968188, "tags": {}, "timeout": 30, "timeouts": null, @@ -532,15 +532,22 @@ { "schema_version": 0, "attributes": { - "id": "6348290143567022548", + "id": "7442287596277108343", "triggers": { - "run_always": "2021-06-23T01:53:22Z" + "run_always": "2021-06-26T04:50:25Z" } }, "sensitive_attributes": [], "private": "bnVsbA==", "dependencies": [ - "aws_api_gateway_deployment.chat_deployment" + "aws_api_gateway_deployment.chat_deployment", + "aws_api_gateway_rest_api.chat_gateway", + "aws_iam_role.function_role", + "module.perform_request.aws_api_gateway_integration.gateway_integration", + "module.perform_request.aws_api_gateway_method.gateway_method", + "module.perform_request.aws_api_gateway_resource.gateway_resource", + "module.perform_request.aws_lambda_function.lambda_func", + "module.perform_request.aws_lambda_permission.gw_permission" ] } ] @@ -554,16 +561,23 @@ { "schema_version": 0, "attributes": { - "id": "7241375145443709044", + "id": "2258871988646655610", "triggers": { "deployed_dependency": "https://c71bjvgvs9.execute-api.us-east-1.amazonaws.com/stage", - "run_always": "2021-06-23T01:53:22Z" + "run_always": "2021-06-26T04:50:25Z" } }, "sensitive_attributes": [], "private": "bnVsbA==", "dependencies": [ - "aws_api_gateway_deployment.chat_deployment" + "aws_api_gateway_deployment.chat_deployment", + "aws_api_gateway_rest_api.chat_gateway", + "aws_iam_role.function_role", + "module.perform_request.aws_api_gateway_integration.gateway_integration", + "module.perform_request.aws_api_gateway_method.gateway_method", + "module.perform_request.aws_api_gateway_resource.gateway_resource", + "module.perform_request.aws_lambda_function.lambda_func", + "module.perform_request.aws_lambda_permission.gw_permission" ] } ] @@ -695,7 +709,7 @@ "image_uri": "", "invoke_arn": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:654246770704:function:stage_PerformRequest/invocations", "kms_key_arn": "", - "last_modified": "2021-06-23T01:52:11.744+0000", + "last_modified": "2021-06-26T04:50:23.356+0000", "layers": [], "memory_size": 1024, "package_type": "Zip", @@ -709,8 +723,8 @@ "s3_object_version": null, "signing_job_arn": "", "signing_profile_version_arn": "", - "source_code_hash": "GMX3KFIEkK3jq3RDkvPa2G7u/38Gdfjh/JzRarI8OOU=", - "source_code_size": 39967859, + "source_code_hash": "iq/O3NDmiDHZXIr+kkwIOVYFFDjD8FhJ46rUPX3Pkps=", + "source_code_size": 39968188, "tags": {}, "timeout": 30, "timeouts": null, diff --git a/Terraform/infrastructure/terraform.tfstate.d/stage/terraform.tfstate.backup b/Terraform/infrastructure/terraform.tfstate.d/stage/terraform.tfstate.backup index 97e0c7a..e507cda 100644 --- a/Terraform/infrastructure/terraform.tfstate.d/stage/terraform.tfstate.backup +++ b/Terraform/infrastructure/terraform.tfstate.d/stage/terraform.tfstate.backup @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "0.14.7", - "serial": 288, + "serial": 295, "lineage": "64a2bad5-87d9-087d-fac4-f21cf3366664", "outputs": { "base_url": { @@ -532,22 +532,15 @@ { "schema_version": 0, "attributes": { - "id": "2914279040809621586", + "id": "6348290143567022548", "triggers": { - "run_always": "2021-06-23T01:52:14Z" + "run_always": "2021-06-23T01:53:22Z" } }, "sensitive_attributes": [], "private": "bnVsbA==", "dependencies": [ - "aws_api_gateway_deployment.chat_deployment", - "aws_api_gateway_rest_api.chat_gateway", - "aws_iam_role.function_role", - "module.perform_request.aws_api_gateway_integration.gateway_integration", - "module.perform_request.aws_api_gateway_method.gateway_method", - "module.perform_request.aws_api_gateway_resource.gateway_resource", - "module.perform_request.aws_lambda_function.lambda_func", - "module.perform_request.aws_lambda_permission.gw_permission" + "aws_api_gateway_deployment.chat_deployment" ] } ] @@ -561,23 +554,16 @@ { "schema_version": 0, "attributes": { - "id": "4090835379135105356", + "id": "7241375145443709044", "triggers": { "deployed_dependency": "https://c71bjvgvs9.execute-api.us-east-1.amazonaws.com/stage", - "run_always": "2021-06-23T01:52:14Z" + "run_always": "2021-06-23T01:53:22Z" } }, "sensitive_attributes": [], "private": "bnVsbA==", "dependencies": [ - "aws_api_gateway_deployment.chat_deployment", - "aws_api_gateway_rest_api.chat_gateway", - "aws_iam_role.function_role", - "module.perform_request.aws_api_gateway_integration.gateway_integration", - "module.perform_request.aws_api_gateway_method.gateway_method", - "module.perform_request.aws_api_gateway_resource.gateway_resource", - "module.perform_request.aws_lambda_function.lambda_func", - "module.perform_request.aws_lambda_permission.gw_permission" + "aws_api_gateway_deployment.chat_deployment" ] } ] diff --git a/readme/androidWorkflowInputs.png b/readme/androidWorkflowInputs.png new file mode 100644 index 0000000000000000000000000000000000000000..e39d3f202790dd13ca8a93c5505fefc5afaedcc5 GIT binary patch literal 14003 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 S8t~6(4-{loWh$iJg!~U!DaA|x literal 0 HcmV?d00001