├── .gitignore ├── doc └── ephemera.png ├── client ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── assets │ │ ├── logo.png │ │ ├── chevron-right.svg │ │ ├── eye.svg │ │ ├── lock.svg │ │ ├── info.svg │ │ ├── help-circle.svg │ │ ├── link.svg │ │ ├── clipboard.svg │ │ ├── alert-triangle.svg │ │ └── loader.svg │ ├── main.js │ ├── router │ │ └── index.js │ ├── crypto.js │ ├── App.vue │ └── components │ │ ├── view.vue │ │ └── save.vue ├── babel.config.js ├── example.env ├── .gitignore ├── README.md └── package.json ├── terraform ├── cloudflare │ ├── main.tf │ ├── README.md │ ├── page_rule.tf │ ├── vars.tf │ └── record.tf ├── main.tf ├── kms.tf ├── outputs.tf ├── dynamodb.tf ├── vars.tf ├── lambda.tf ├── s3.tf ├── README.md ├── iam.tf └── api_gateway.tf ├── go.mod ├── dynamo_test.go ├── LICENSE.txt ├── Makefile ├── init.go ├── kms_test.go ├── kms.go ├── lambda ├── save │ └── save.go └── view │ └── view.go ├── go.sum ├── README.md └── dynamo.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | build/* -------------------------------------------------------------------------------- /doc/ephemera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blockpane/ephemera/HEAD/doc/ephemera.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blockpane/ephemera/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blockpane/ephemera/HEAD/client/src/assets/logo.png -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /client/example.env: -------------------------------------------------------------------------------- 1 | # rename this file to '.env' 2 | VUE_APP_API_URL=https://api.your-lambda-endpoints.local 3 | 4 | -------------------------------------------------------------------------------- /terraform/cloudflare/main.tf: -------------------------------------------------------------------------------- 1 | provider "cloudflare" { 2 | 3 | api_token = var.CLOUDFLARE_API_TOKEN 4 | account_id = var.CLOUDFLARE_ACCOUNT_ID 5 | } 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/blockpane/ephemera 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/aws/aws-lambda-go v1.18.0 7 | github.com/aws/aws-sdk-go v1.33.17 8 | ) 9 | -------------------------------------------------------------------------------- /terraform/main.tf: -------------------------------------------------------------------------------- 1 | # export shell environments with credenials 2 | provider "aws" { 3 | region = var.region 4 | profile = var.aws_profile 5 | } 6 | 7 | # get account id 8 | data "aws_caller_identity" "current" { 9 | } 10 | -------------------------------------------------------------------------------- /terraform/kms.tf: -------------------------------------------------------------------------------- 1 | resource "aws_kms_key" "key" { 2 | description = "${var.app_name} key" 3 | } 4 | 5 | resource "aws_kms_alias" "alias" { 6 | name = "alias/${var.app_name}-key" 7 | target_key_id = aws_kms_key.key.key_id 8 | } -------------------------------------------------------------------------------- /client/src/assets/chevron-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /terraform/cloudflare/README.md: -------------------------------------------------------------------------------- 1 | ### Cloudflare record 2 | 3 | This will add a dns `proxied` record and a `page_rule` for your Ephermera bucket. You need to have a zone hosted at Cloudflare. 4 | 5 | ## How to guide 6 | 7 | - Run `terraform init`, `terraform plan` to see the plan 8 | - Run `terraform apply` -------------------------------------------------------------------------------- /client/src/assets/eye.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/lock.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /client/src/assets/info.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/help-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/link.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/clipboard.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /terraform/outputs.tf: -------------------------------------------------------------------------------- 1 | # API Gateway invoke URL to be passed to ephemera .env file 2 | output "base_url" { 3 | value = aws_api_gateway_deployment.deployment.invoke_url 4 | } 5 | 6 | # S3 website endpoint 7 | output "s3_url" { 8 | value = aws_s3_bucket.this.website_endpoint 9 | } 10 | 11 | # S3 bucket name to be picked by Cloudflare 12 | output "s3_bucket" { 13 | value = aws_s3_bucket.this.bucket 14 | } -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import BootstrapVue from "bootstrap-vue" 3 | import App from './App.vue' 4 | import router from './router' 5 | import VueClipboard from 'vue-clipboard2' 6 | import './assets/bootstrap.css' 7 | 8 | Vue.use(BootstrapVue); 9 | Vue.use(VueClipboard); 10 | 11 | new Vue({ 12 | el: '#app', 13 | router, 14 | VueClipboard, 15 | render: h => h(App) 16 | }); 17 | 18 | -------------------------------------------------------------------------------- /dynamo_test.go: -------------------------------------------------------------------------------- 1 | package sharedpw 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestNewSecret(t *testing.T) { 9 | s := NewSecret() 10 | if s.Err != nil { 11 | t.Error(fmt.Sprintf("could not create new secret:\n%v\n", s.Err)) 12 | } 13 | j, err := s.ToJson() 14 | if err != nil || len(j) < 1 { 15 | t.Error(fmt.Sprintf("error marshalling to json got:\n%s\n%v\n", j, err)) 16 | } 17 | fmt.Println(j) 18 | } 19 | -------------------------------------------------------------------------------- /client/src/assets/alert-triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # my-project 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /terraform/cloudflare/page_rule.tf: -------------------------------------------------------------------------------- 1 | data "terraform_remote_state" "that" { 2 | backend = "local" 3 | config = { 4 | path = "${path.module}/../terraform.tfstate" 5 | } 6 | } 7 | 8 | resource "cloudflare_page_rule" "this" { 9 | zone_id = var.CLOUDFLARE_ZONE_ID 10 | target = "${data.terraform_remote_state.that.outputs.s3_bucket}$/*" 11 | 12 | priority = 1 13 | status = "active" 14 | actions { 15 | ssl = "flexible" 16 | } 17 | 18 | 19 | } 20 | -------------------------------------------------------------------------------- /terraform/cloudflare/vars.tf: -------------------------------------------------------------------------------- 1 | # export TF_VAR_CLOUDFLARE_API_TOKEN= 2 | variable "CLOUDFLARE_API_TOKEN" { 3 | description = "Cloudflare API token value" 4 | } 5 | 6 | # export TF_VAR_CLOUDFLARE_ACCOUNT_ID= 7 | variable "CLOUDFLARE_ACCOUNT_ID" { 8 | description = "Cloudflare Account ID" 9 | } 10 | 11 | # export TF_VAR_CLOUDFLARE_ACCOUNT_ID= 12 | variable "CLOUDFLARE_ZONE_ID" { 13 | description = "Cloudflare Zone ID to create record for ephemera" 14 | } 15 | 16 | -------------------------------------------------------------------------------- /terraform/cloudflare/record.tf: -------------------------------------------------------------------------------- 1 | data "terraform_remote_state" "this" { 2 | backend = "local" 3 | config = { 4 | path = "${path.module}/../terraform.tfstate" 5 | } 6 | } 7 | 8 | resource "cloudflare_record" "this" { 9 | zone_id = var.CLOUDFLARE_ZONE_ID 10 | 11 | name = data.terraform_remote_state.this.outputs.s3_bucket 12 | type = "CNAME" 13 | ttl = "1" 14 | proxied = "true" 15 | 16 | value = data.terraform_remote_state.this.outputs.s3_url 17 | } 18 | -------------------------------------------------------------------------------- /terraform/dynamodb.tf: -------------------------------------------------------------------------------- 1 | resource "aws_dynamodb_table" "dynamodb_table" { 2 | name = var.app_name 3 | billing_mode = "PROVISIONED" 4 | read_capacity = 1 5 | write_capacity = 1 6 | hash_key = "secret" 7 | 8 | attribute { 9 | name = "secret" 10 | type = "S" 11 | } 12 | 13 | ttl { 14 | attribute_name = "expire" 15 | enabled = true 16 | } 17 | 18 | tags = { 19 | Name = "Managed by Terraform" 20 | Environment = "${var.environment}" 21 | } 22 | } -------------------------------------------------------------------------------- /client/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import save from '../components/save.vue' 4 | import view from '../components/view.vue' 5 | 6 | Vue.use(Router); 7 | 8 | export default new Router({ 9 | routes: [ 10 | { 11 | path: '/', 12 | name: 'save', 13 | component: save 14 | }, 15 | { 16 | path: '/view*', 17 | name: 'view', 18 | component: view 19 | } 20 | ] 21 | }) 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright [2020] [Todd Garrison] 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /client/src/assets/loader.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /terraform/vars.tf: -------------------------------------------------------------------------------- 1 | variable "region" { 2 | description = "AWS region for instance deployment" 3 | } 4 | 5 | # export TF_VAR_aws_profile= 6 | variable "aws_profile" { 7 | description = "AWS profile name.\nFor more info check https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html" 8 | } 9 | 10 | variable "environment" { 11 | default = "prod" 12 | } 13 | 14 | variable "app_name" { 15 | description = "Application name" 16 | default = "ephemera" 17 | } 18 | 19 | variable "bucket_name" { 20 | description = "Name of an S3 bucket in website mode.\nIf you plan to proxy it through Cloudflare, bucket name must match FQDN of the DNS record.\nhttps://support.cloudflare.com/hc/en-us/articles/360037983412-Configuring-an-Amazon-Web-Services-static-site-to-use-Cloudflare\nThis is also needed to confiure CORS." 21 | } 22 | 23 | variable "scheme" { 24 | description = "http|https\nIf you plan to use ephemera purely through S3 website, put http." 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LDFLAGS = -s -w 2 | REGION = us-east-1 3 | NAME = ephemera 4 | 5 | all: 6 | make save view 7 | 8 | save: 9 | GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o build/main lambda/save/save.go 10 | cd build && zip save.zip main && rm main 11 | aws lambda update-function-code --function-name ${NAME}-rw --zip-file fileb://./build/save.zip --region ${REGION} 12 | rm build/save.zip 13 | 14 | view: 15 | GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o build/main lambda/view/view.go 16 | cd build && zip view.zip main && rm main 17 | aws lambda update-function-code --function-name ${NAME}-ro --zip-file fileb://./build/view.zip --region ${REGION} 18 | 19 | build: *.go 20 | rm -f ./build/save* ./build/view* 21 | GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o build/save lambda/save/save.go 22 | cd build && zip save.zip save 23 | GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o build/view lambda/view/view.go 24 | cd build && zip view.zip view 25 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ephemera", 3 | "description": "Secret Sharing", 4 | "version": "1.0.1", 5 | "author": "Todd Garrison ", 6 | "private": true, 7 | "scripts": { 8 | "serve": "vue-cli-service serve", 9 | "build": "vue-cli-service build", 10 | "lint": "vue-cli-service lint" 11 | }, 12 | "dependencies": { 13 | "axios-request-handler": "^1.0.4", 14 | "bootstrap-vue": "^2.16.0", 15 | "core-js": "^3.6.5", 16 | "vue": "^2.6.11", 17 | "vue-axios": "^2.1.5", 18 | "vue-clipboard2": "^0.3.1", 19 | "vue-router": "^3.3.4" 20 | }, 21 | "devDependencies": { 22 | "@vue/cli-plugin-babel": "~4.4.0", 23 | "@vue/cli-plugin-eslint": "~4.4.0", 24 | "@vue/cli-service": "~4.4.0", 25 | "babel-eslint": "^10.1.0", 26 | "eslint": "^6.7.2", 27 | "eslint-plugin-vue": "^6.2.2", 28 | "vue-template-compiler": "^2.6.11" 29 | }, 30 | "eslintConfig": { 31 | "root": true, 32 | "env": { 33 | "node": true 34 | }, 35 | "extends": [ 36 | "plugin:vue/essential", 37 | "eslint:recommended" 38 | ], 39 | "parserOptions": { 40 | "parser": "babel-eslint" 41 | }, 42 | "rules": {} 43 | }, 44 | "browserslist": [ 45 | "> 1%", 46 | "last 2 versions", 47 | "not dead" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /terraform/lambda.tf: -------------------------------------------------------------------------------- 1 | data "local_file" "view" { 2 | filename = "${path.module}/view.zip" 3 | } 4 | 5 | data "local_file" "save" { 6 | filename = "${path.module}/save.zip" 7 | } 8 | 9 | resource "aws_lambda_function" "lambda_view" { 10 | filename = data.local_file.view.filename 11 | function_name = "lambda_${var.app_name}_view" 12 | role = aws_iam_role.lambda_view_role.arn 13 | handler = "main" 14 | description = "Lambda for /view endpoint with read access to DynamoDB" 15 | 16 | runtime = "go1.x" 17 | timeout = 600 # 10 minutes 18 | 19 | environment { 20 | variables = { 21 | CORS = "${var.scheme}://${var.bucket_name}" 22 | KMS = aws_kms_key.key.key_id 23 | REGION = var.region 24 | ACCOUNT = data.aws_caller_identity.current.account_id 25 | APPLICATION = var.app_name 26 | } 27 | } 28 | } 29 | 30 | resource "aws_lambda_function" "lambda_save" { 31 | filename = data.local_file.save.filename 32 | function_name = "lambda_${var.app_name}_save" 33 | role = aws_iam_role.lambda_save_role.arn 34 | handler = "main" 35 | description = "Lambda for /save endpoint with read/write access to DynamoDB" 36 | 37 | runtime = "go1.x" 38 | timeout = 600 # 10 minutes 39 | 40 | environment { 41 | variables = { 42 | CORS = "${var.scheme}://${var.bucket_name}" 43 | KMS = aws_kms_key.key.key_id 44 | REGION = var.region 45 | ACCOUNT = data.aws_caller_identity.current.account_id 46 | APPLICATION = var.app_name 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /init.go: -------------------------------------------------------------------------------- 1 | package sharedpw 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "github.com/aws/aws-sdk-go/service/kms" 7 | "log" 8 | "os" 9 | ) 10 | 11 | var ( 12 | Region string 13 | AwsSession *session.Session 14 | KMS *kms.KMS 15 | AwsAccount string 16 | KmsKeyId string 17 | Application string 18 | ) 19 | 20 | // Headers are added to all outgoing responses from lambda 21 | var Headers = map[string]string{ 22 | `Content-Type`: `application/json`, 23 | `Cache-Control`: `max-age = 0, private, must-revalidate, no-store`, 24 | `Pragma`: `no-cache`, 25 | `X-Content-Type-Options`: `nosniff`, 26 | `X-Frame-Options`: `DENY`, 27 | `X-Xss-Protection`: `1; mode = block`, 28 | `Strict-Transport-Security`: `max-age = 31536000`, 29 | } 30 | 31 | func init() { 32 | log.SetFlags(log.Lshortfile | log.LstdFlags | log.LUTC) 33 | 34 | switch "" { 35 | case os.Getenv("CORS"): 36 | log.Fatal("Need CORS env var for access-control-allow-origin header") 37 | case os.Getenv("REGION"): 38 | log.Fatal("Need REGION env var") 39 | case os.Getenv("ACCOUNT"): 40 | log.Fatal("Need ACCOUNT env var") 41 | case os.Getenv("KMS"): 42 | log.Fatal("Need KMS env var") 43 | case os.Getenv("APPLICATION"): 44 | log.Fatal("Need APPLICATION env var") 45 | } 46 | 47 | Headers["Access-Control-Allow-Origin"] = os.Getenv("CORS") 48 | Region = os.Getenv("REGION") 49 | KmsKeyId = os.Getenv("KMS") 50 | AwsAccount = os.Getenv("ACCOUNT") 51 | Application = os.Getenv("APPLICATION") 52 | AwsSession = session.Must(session.NewSession(&aws.Config{Region: aws.String(Region)})) 53 | KMS = kms.New(AwsSession) 54 | } 55 | -------------------------------------------------------------------------------- /terraform/s3.tf: -------------------------------------------------------------------------------- 1 | # Create s3 backet 2 | # bucket name it's a domain name 3 | # https://support.cloudflare.com/hc/en-us/articles/360037983412-Configuring-an-Amazon-Web-Services-static-site-to-use-Cloudflare 4 | # If proxied through Cloudflare, remove 0.0.0.0/0 line from ACL to restrict direct access to the bucket 5 | resource "aws_s3_bucket" "this" { 6 | bucket = var.bucket_name 7 | force_destroy = true 8 | 9 | server_side_encryption_configuration { 10 | rule { 11 | apply_server_side_encryption_by_default { 12 | sse_algorithm = "AES256" 13 | } 14 | } 15 | } 16 | 17 | policy = < 2048: 36 | return "", errors.New(`plaintext to encrypt is too long`) 37 | } 38 | out, err := KMS.Encrypt(&kms.EncryptInput{ 39 | EncryptionContext: map[string]*string{ 40 | `account`: aws.String(AwsAccount), 41 | `application`: aws.String(Application), 42 | }, 43 | KeyId: aws.String(fmt.Sprintf("arn:aws:kms:%s:%s:key/%s", Region, AwsAccount, KmsKeyId)), 44 | Plaintext: decoded, 45 | }) 46 | if err != nil { 47 | return "", err 48 | } 49 | return base64.StdEncoding.EncodeToString(out.CiphertextBlob), nil 50 | } 51 | 52 | // DecryptSecret returns a bas64 encoded string of the plaintext from an encrypted base64 string using the KMS key 53 | // used to encrypt it. 54 | func DecryptSecret(b64PlainText string) (string, error) { 55 | decoded, err := base64.StdEncoding.DecodeString(b64PlainText) 56 | if err != nil { 57 | return "", errors.New("could not decode base64 string") 58 | } 59 | out, err := KMS.Decrypt(&kms.DecryptInput{ 60 | CiphertextBlob: decoded, 61 | EncryptionContext: map[string]*string{ 62 | `account`: aws.String(AwsAccount), 63 | `application`: aws.String(Application), 64 | }, 65 | }) 66 | if err != nil { 67 | return "", err 68 | } 69 | return base64.StdEncoding.EncodeToString(out.Plaintext), nil 70 | } 71 | -------------------------------------------------------------------------------- /lambda/save/save.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "github.com/aws/aws-lambda-go/events" 8 | "github.com/aws/aws-lambda-go/lambda" 9 | "github.com/blockpane/ephemera" 10 | "log" 11 | "net" 12 | ) 13 | 14 | type saveResp struct { 15 | Error string `json:"error"` 16 | Id string `json:"id"` 17 | } 18 | 19 | func (r saveResp) String() string { 20 | s, _ := json.Marshal(r) 21 | return string(s) 22 | } 23 | 24 | func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (response events.APIGatewayProxyResponse, err error) { 25 | response.Headers = sharedpw.Headers 26 | response.StatusCode = 200 27 | secret := &sharedpw.Secret{} 28 | err = json.Unmarshal([]byte(request.Body), secret) 29 | if err != nil { 30 | response.Body = pErr(`could not unmarshal body`, err) 31 | response.StatusCode = 400 32 | return 33 | } 34 | if len(secret.Message) < 1 { 35 | response.Body = pErr(`got empty secret message`, nil) 36 | response.StatusCode = 400 37 | return 38 | } 39 | if len(secret.Message) >= 4096 { 40 | response.Body = pErr(`secret too long'`, nil) 41 | response.StatusCode = 400 42 | return 43 | } 44 | err = secret.NewId() 45 | if err != nil { 46 | response.Body = pErr(`could not assign new ID`, err) 47 | response.StatusCode = 500 48 | return 49 | } 50 | err = secret.SetTimeout() 51 | if err != nil { 52 | response.Body = pErr(`could not set timeout`, err) 53 | response.StatusCode = 500 54 | return 55 | } 56 | cipherText, err := sharedpw.EncryptSecret(base64.StdEncoding.EncodeToString([]byte(secret.Message))) 57 | if err != nil { 58 | response.Body = pErr(`could not encrypt secret`, err) 59 | response.StatusCode = 500 60 | return 61 | } 62 | err = secret.Save(cipherText) 63 | if err != nil { 64 | response.Body = pErr(`could not save secret`, err) 65 | response.StatusCode = 500 66 | return 67 | } 68 | if len(secret.Ip) > 0 { 69 | if n := net.ParseIP(secret.Ip); n == nil { 70 | response.Body = pErr(`invalid IP address filter`, err) 71 | response.StatusCode = 400 72 | return 73 | } 74 | } 75 | response.Body = saveResp{Id: secret.Secret}.String() 76 | return 77 | } 78 | 79 | func main() { 80 | lambda.Start(handleRequest) 81 | } 82 | 83 | // pErr is a helper that prints the error to console and returns json to be appended to output 84 | func pErr(s string, e error) string { 85 | log.Println(s) 86 | log.Println(e) 87 | return saveResp{ 88 | Error: s, 89 | }.String() 90 | } 91 | -------------------------------------------------------------------------------- /client/src/crypto.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export function encrypt(plaintext) { 4 | if (plaintext.length < 128) { 5 | // add some length to the message 6 | plaintext = plaintext.padEnd(128, ' '); 7 | } 8 | const forge = require('node-forge'); 9 | const key = forge.random.getBytesSync(16); 10 | const iv = forge.util.bytesToHex(forge.random.getBytesSync(12)); 11 | let cipher = forge.cipher.createCipher("AES-GCM", key); 12 | cipher.start({ iv: iv }); 13 | cipher.update(forge.util.createBuffer(plaintext, 'utf8')); 14 | cipher.finish(); 15 | return { 16 | 'ciphertext': cipher.output.toHex(), 17 | 'key': Base64EncodeUrl(forge.util.encode64(key)), 18 | 'tag': cipher.mode.tag.toHex(), 19 | 'iv': iv, 20 | } 21 | } 22 | 23 | export function encryptPass(plaintext, pass) { 24 | const forge = require('node-forge'); 25 | const iv = forge.util.bytesToHex(forge.random.getBytesSync(12)); 26 | const key = forge.pkcs5.pbkdf2(pass, [], 100, 16); 27 | let cipher = forge.cipher.createCipher("AES-GCM", key); 28 | cipher.start({ iv: iv }); 29 | cipher.update(forge.util.createBuffer(plaintext, 'utf8')); 30 | cipher.finish(); 31 | return { 32 | 'ciphertext': cipher.output.toHex(), 33 | 'pw_tag': cipher.mode.tag.toHex(), 34 | 'pw_iv': iv, 35 | } 36 | } 37 | 38 | export function decrypt(ciphertext, key, tag, iv) { 39 | const forge = require('node-forge'); 40 | let cipher = forge.cipher.createDecipher("AES-GCM",forge.util.decode64(Base64DecodeUrl(key))); 41 | cipher.start({iv:iv, tag: forge.util.hexToBytes(tag)}); 42 | cipher.update(forge.util.createBuffer(forge.util.hexToBytes(ciphertext))); 43 | cipher.finish(); 44 | return cipher.output.toString('utf8').trim(); 45 | } 46 | 47 | export function decryptPass(ciphertext, key, tag, iv, pw_tag, pw_iv, pass) { 48 | const forge = require('node-forge'); 49 | const passKey = forge.pkcs5.pbkdf2(pass, [], 100, 16); 50 | let cipher = forge.cipher.createDecipher("AES-GCM", passKey); 51 | cipher.start({iv: pw_iv, tag: forge.util.hexToBytes(pw_tag) }); 52 | cipher.update(forge.util.createBuffer(forge.util.hexToBytes(ciphertext))); 53 | cipher.finish(); 54 | //return decrypt(cipher.output.toString('utf8'), key, tag, iv) 55 | return decrypt(cipher.output.toString('utf8'), key, tag, iv) 56 | } 57 | 58 | function Base64EncodeUrl(str){ 59 | return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 60 | } 61 | 62 | function Base64DecodeUrl(str){ 63 | str = (str + '===').slice(0, str.length + (str.length % 4)); 64 | return str.replace(/-/g, '+').replace(/_/g, '/'); 65 | } 66 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/aws/aws-lambda-go v1.18.0 h1:13AfxzFoPlFjOzXHbRnKuTbteCzHbu4YQgKONNhWcmo= 3 | github.com/aws/aws-lambda-go v1.18.0/go.mod h1:FEwgPLE6+8wcGBTe5cJN3JWurd1Ztm9zN4jsXsjzKKw= 4 | github.com/aws/aws-sdk-go v1.33.17 h1:vngPRchZs603qLtJH7lh2pBCDqiFxA9+9nDWJ5WYJ5A= 5 | github.com/aws/aws-sdk-go v1.33.17/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= 6 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 7 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 10 | github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= 11 | github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= 12 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 16 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 17 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 18 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 19 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 20 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 21 | github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= 22 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 23 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= 24 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 25 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 26 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 27 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 31 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 32 | -------------------------------------------------------------------------------- /lambda/view/view.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "github.com/aws/aws-lambda-go/events" 9 | "github.com/aws/aws-lambda-go/lambda" 10 | "github.com/blockpane/ephemera" 11 | "log" 12 | "net" 13 | ) 14 | 15 | type secretRequest struct { 16 | Id string `json:"id"` 17 | Retrieve bool `json:"retrieve"` 18 | } 19 | 20 | type errResp struct { 21 | Error string `json:"error"` 22 | Exists bool `json:"exists"` 23 | HasPass bool `json:"has_pass"` 24 | Secret string `json:"secret"` 25 | Hint string `json:"hint"` 26 | Tag string `json:"tag"` 27 | Iv string `json:"iv"` 28 | PwTag string `json:"pw_tag"` 29 | PwIv string `json:"pw_iv"` 30 | } 31 | 32 | func (r errResp) String() string { 33 | s, _ := json.Marshal(r) 34 | return string(s) 35 | } 36 | 37 | func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (response events.APIGatewayProxyResponse, err error) { 38 | response.Headers = sharedpw.Headers 39 | response.StatusCode = 200 40 | req := &secretRequest{} 41 | err = json.Unmarshal([]byte(request.Body), req) 42 | if err != nil { 43 | fmt.Println(request.Body) 44 | response.Body = pErr(`could not understand request`, err) 45 | response.StatusCode = 400 46 | return 47 | } 48 | if len(req.Id) != 16 { 49 | response.Body = pErr(`not found`, nil) 50 | return 51 | } 52 | ip := net.ParseIP(request.RequestContext.Identity.SourceIP) 53 | 54 | // is this a check for if the record exists? 55 | if !req.Retrieve { 56 | exists, err := sharedpw.Reveal(req.Id, ip, false) 57 | if err != nil { 58 | log.Println(err) 59 | response.Body = errResp{ 60 | Error: "could not query for secret", 61 | }.String() 62 | return response, nil 63 | } 64 | if exists.Exists { 65 | response.Body = errResp{ 66 | Exists: true, 67 | }.String() 68 | return response, nil 69 | } 70 | response.Body = errResp{ 71 | Exists: false, 72 | }.String() 73 | return response, nil 74 | } 75 | 76 | // Try to return the secret: 77 | secret, err := sharedpw.Reveal(req.Id, ip, true) 78 | if err != nil { 79 | log.Println(err) 80 | response.Body = errResp{ 81 | Error: "could not query for secret", 82 | }.String() 83 | return response, nil 84 | } 85 | plaintext, err := sharedpw.DecryptSecret(secret.Secret) 86 | if err != nil { 87 | log.Println(err) 88 | response.Body = errResp{ 89 | Error: "could not decrypt secret from database", 90 | }.String() 91 | response.StatusCode = 500 92 | return response, nil 93 | } 94 | p, err := base64.StdEncoding.DecodeString(plaintext) 95 | response.Body = errResp{ 96 | Exists: secret.Exists, 97 | HasPass: secret.HasPass, 98 | Secret: string(p), 99 | Hint: secret.Hint, 100 | Tag: secret.Tag, 101 | Iv: secret.Iv, 102 | PwTag: secret.PwTag, 103 | PwIv: secret.PwIv, 104 | }.String() 105 | return 106 | } 107 | 108 | func main() { 109 | lambda.Start(handleRequest) 110 | } 111 | 112 | // pErr is a helper that prints the error to console and returns json to be appended to output 113 | func pErr(s string, e error) string { 114 | log.Println(s) 115 | log.Println(e) 116 | return errResp{ 117 | Error: s, 118 | Exists: false, 119 | HasPass: false, 120 | Secret: ``, 121 | }.String() 122 | } 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ephemera 2 | 3 | This is an application for sharing a secret, the secret can only be accessed once, and then it is gone forever. 4 | 5 | [Check it out](https://ephemera.link/#/) 6 | 7 | ![screen shot](doc/ephemera.png) 8 | 9 | ## Basic Usage: 10 | Type in the secret you want to share, and click "Get Link", provide this to the recipient and they can retrieve it. 11 | 12 | In the advanced settings you can control more of the behavior: 13 | 14 | - How long the secret is valid, the default is 24 hours. 15 | - Restrict access to the secret for a specific internet address. 16 | - Add an additional layer of password protection that is neither stored in the database or as part of the link. 17 | 18 | ## Is this safe? 19 | 20 | This application was designed to offer a moderate level of security, better than pasting a password in a chat window. But, there is no provable way to know what is being done behind the scenes, and the only way to be entirely sure is to get the source from Github, audit the code, and deploy it under your own control. 21 | 22 | It runs as a serverless app in the AWS cloud, with the goal to be low cost, and maintenance free. Uses KMS to encrypt the secrets in the database, which costs ~$1/month, specifically so that it could be easily deployed within an organization for exclusive use with minimal cost impact. 23 | 24 | ## How does it work? 25 | 26 | - When clicking on "Get Link", the browser uses javascript to create an encryption key, and encrypts the secret using AES-128 GCM with this key. Short secrets have additional plaintext added to further obfuscate the length of the message. 27 | - The key is included as part of the URL that is displayed in the browser, but is *not* sent to the servers, a random IV, and the authentication tag are stored in the database. 28 | - The storage is handled by an AWS Lambda function that: Uses KMS to encrypt the secret with AES-256 a second time, sets a timeout value for the secret to be deleted, and stores it in DynamoDB. This means by default the secret is encrypted twice, only Amazon has one key, and whoever has the link gets the second. 29 | - When the recipient visits the site to retrieve the secret, the browser extracts the random ID for the stored secret and sends that to the Lambda function, which performs the first layer of decryption--immediately deleting the database record, and sends the result to the browser. 30 | - The recipient's browser then decrypts the ciphertext sent back from the lambda function. 31 | - If an optional passphrase was used, another key is created using a pbkdf2 algorithm, and will require the recipient to supply it for a third round of AES-128 GCM decryption. This could be something simple that is conveyed out of band to add a small additional layer of protection. 32 | 33 | ## Why Bother? 34 | 35 | - I appreciate that there are other similar services, such as viacry.pt and have used them, but ... if you want to ensure exclusive access for your organization for example, these rely on running a server full-time for the storage and back-end service. By using server-less technology in the cloud, anyone can deploy ephemera without committing to the cost of running, and maintaining a VPS. 36 | - Most of the existing solutions immediately display the link when visited, this creates problems with tools that prefetch an image of a website (like a chat program.) 37 | - Being able to limit by IP is crucial when users are on a known network. 38 | - I wanted to be sure the tool I was using used a stronger cipher, with random IV, and message authentication. 39 | - The ability to have an additional layer of encryption is nice. Not shared via a link, and unknown to the backend ... adds a distinct and important layer of security 40 | 41 | # Building 42 | 43 | Most recently built using Go 1.14 and Node 14.7.0 44 | 45 | - Building the Go components is done via the Makefile, by default it will try to push the new binaries directly into lambda. 46 | - For the client, run `npm install` and `npm build` and put the generated files from `client/dist/` somewhere to be served. 47 | - copy the `example.env` file to `.env` and change the API endpoint to where the lambda functions are available. 48 | 49 | ## Infrastructure 50 | 51 | A huge thanks to fmalykh for contributing the terraform components that will deploy this. The ansible plays have been removed, 52 | but are easy to find at the commit tagged `ansible-removed`. 53 | -------------------------------------------------------------------------------- /terraform/iam.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "lambda_view_role" { 2 | name = "lambda_${var.app_name}_view_role" 3 | 4 | assume_role_policy = < 2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 |

ephemera

10 |

one-time secret sharing

11 |
12 | 13 | 14 | 15 |
16 |
17 | 18 |

19 | This is an application for sharing a secret, the secret can only be accessed once, and then it is gone 20 | forever.
21 |

22 |

Basic Usage:

23 |

24 | Type in the secret you want to share, and click "Get Link", provide this to the recipient and they can 25 | retrieve it.
26 |

27 |

28 | In the advanced settings you can control more of the behavior:
29 |

30 | 31 |
    How long the secret is valid, the default is 24 hours.
32 |
    Restrict access to the secret for a specific internet address.
33 |
    Add an additional layer of password protection that is neither stored in the database or as part of the link.
34 |
35 |

Is this safe?

36 |

37 | This application was designed to offer a moderate level of security, slightly better than pasting a password 38 | in a chat window. But, 39 | there is no provable way to know what is being done behind the scenes, and the only way to be entirely sure 40 | is to get the source from 41 | Github, audit the code, and deploy it under your own 42 | control. 43 |

44 |

45 | This application is designed to run as a serverless app in the AWS cloud, with the goal to be almost cost, 46 | and maintenance free. 47 | It uses KMS to encrypt the secrets in the database, which costs ~$1/month, specifically so that it could be 48 | easily deployed within 49 | an organization for exclusive use with little effort. 50 |

51 |

How does it work?

52 | 53 |
    When clicking on "Get Link", the browser uses javascript to create an encryption key, 54 | and encrypts the secret using AES-128 GCM with this key. Short secrets have additional plaintext added to further obfuscate the length of the message.
55 |
    The key is included as part of the URL that is displayed in the browser, but is *not* sent to the servers, a 56 | random IV, and the authentication tag are stored in the database.
57 |
    The storage is handled by an AWS Lambda function that: Uses KMS to encrypt the secret with AES-256 a second time, 58 | sets a timeout value for the secret to be deleted, and stores it in DynamoDB. This means by default 59 | the secret is encrypted twice, only Amazon has one key, and whoever has the link gets the second. 60 |
61 |
    When the recipient visits the site to retrieve the secret, the browser extracts the random ID for 62 | the stored secret and sends that to the Lambda function, which performs the first layer of decryption--immediately deleting the database record, 63 | and sends the result to the browser.
64 |
    The recipient's browser then decrypts the ciphertext sent back from the lambda function. 65 |
66 |
    If an optional passphrase was used, another key is created using a pbkdf2 algorithm, 67 | and will require the recipient to supply it for a third round of AES-128 GCM decryption. This could be something simple that is 68 | conveyed out of band to add a small additional layer of protection.
69 |
70 |

Why Bother?

71 |
    I appreciate that there are other similar services, such as viacry.pt and have used them, but ... 72 | if you want to ensure exclusive access for your organization for example, these rely on running a server full-time for the storage and back-end service. 73 | By using server-less technology in the cloud, anyone can deploy ephemera without committing to the cost of running, and maintaining a VPS.
74 |
    Most of the existing solutions immediately display the link when visited, this creates problems with tools 75 | that prefetch an image of a website (like a chat program.)
76 |
    Being able to limit by IP is crucial when users are on a known network.
77 |
    I wanted to be sure the tool I was using used a stronger cipher, with random IV, and message authentication.
78 |
    The ability to have an additional layer of encryption is nice. 79 | Not shared via a link, and unknown to the backend ... adds a distinct and important layer of security
80 | 83 |
84 | 85 |
86 | 87 | 88 | 90 | 91 | 93 | -------------------------------------------------------------------------------- /dynamo.go: -------------------------------------------------------------------------------- 1 | package sharedpw 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/dynamodb" 11 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 12 | "log" 13 | "net" 14 | "os" 15 | "regexp" 16 | "time" 17 | ) 18 | 19 | // Secret is the structure saved to dynamo. 20 | // Secret.Secret is the index, generated by GetRandomId 21 | // Expire is a unixtime value, for the Dynamo TTL 22 | type Secret struct { 23 | Secret string `json:"secret"` 24 | Expire int64 `json:"expire"` // calculated by server, dynamo db removal timestamp in unixtime 25 | Hours int `json:"hours"` // sent by client, should be < 72 26 | Message string `json:"message"` 27 | Ip string `json:"ip"` 28 | HasPass bool `json:"has_pass"` 29 | Hint string `json:"hint"` 30 | Err error `json:"error"` 31 | Tag string `json:"tag"` 32 | Iv string `json:"iv"` 33 | PwTag string `json:"pw_tag"` 34 | PwIv string `json:"pw_iv"` 35 | } 36 | 37 | // NewSecret initializes a Secret -- not sure this is useful outside of tests. 38 | func NewSecret() *Secret { 39 | s := &Secret{ 40 | Expire: time.Now().UTC().Add(time.Hour * 72).Unix(), // default 3 days lifetime 41 | } 42 | id, err := GetRandomId() 43 | if err != nil { 44 | s.Err = err 45 | return s 46 | } 47 | s.Secret = id 48 | return s 49 | } 50 | 51 | // NewId sets a new random ID for the secret 52 | func (s *Secret) NewId() (err error) { 53 | s.Secret, err = GetRandomId() 54 | return err 55 | } 56 | 57 | // SetTimeout sets the expiration timestamp in dynamo based on the hours requested. 58 | func (s *Secret) SetTimeout() (err error) { 59 | if s.Hours < 1 || s.Hours > 72 { 60 | return errors.New(`invalid expiration`) 61 | } 62 | s.Expire = time.Now().UTC().Add(time.Hour * time.Duration(s.Hours)).Unix() 63 | return 64 | } 65 | 66 | // ToJson returns a JSON string 67 | func (s *Secret) ToJson() (string, error) { 68 | j, err := json.MarshalIndent(s, "", " ") 69 | if err != nil { 70 | return "", err 71 | } 72 | return string(j), nil 73 | } 74 | 75 | // Save persistes a secret into the database 76 | func (s *Secret) Save(b64EncSecret string) error { 77 | if s.Expire == 0 { 78 | s.Expire = time.Now().UTC().Add(time.Hour * 24).Unix() 79 | } 80 | s.Message = b64EncSecret 81 | 82 | table, db, err := newClient() 83 | if err != nil { 84 | return err 85 | } 86 | j, err := dynamodbattribute.MarshalMap(s) 87 | if err != nil { 88 | return err 89 | } 90 | input := &dynamodb.PutItemInput{ 91 | Item: j, 92 | TableName: aws.String(table), 93 | } 94 | i, err := db.PutItem(input) 95 | fmt.Printf("%#v\n", i) 96 | if err != nil { 97 | log.Printf("| ERROR dynamo.go Save: %v", err) 98 | } 99 | fmt.Printf("%#v\n", s) 100 | return err 101 | } 102 | 103 | // Revealed holds the response from a secret lookup 104 | type Revealed struct { 105 | Secret string 106 | Exists bool 107 | HasPass bool 108 | Hint string 109 | Tag string 110 | Iv string 111 | PwTag string 112 | PwIv string 113 | } 114 | 115 | // Reveal returns a base64 encoded string of the secret stored in the db, and immediately deletes it. 116 | func Reveal(id string, ip net.IP, reveal bool) (revealed Revealed, err error) { 117 | dbIndex := `secret` 118 | notHex, _ := regexp.MatchString(`\W|[g-zA-Z]`, id) 119 | if len(id) != 16 || notHex { 120 | return revealed, errors.New("bad id") 121 | } 122 | table, db, err := newClient() 123 | 124 | // first get the secret: 125 | var queryInput = &dynamodb.QueryInput{ 126 | TableName: aws.String(table), 127 | KeyConditions: map[string]*dynamodb.Condition{ 128 | dbIndex: { 129 | ComparisonOperator: aws.String("EQ"), 130 | AttributeValueList: []*dynamodb.AttributeValue{ 131 | { 132 | S: aws.String(id), 133 | }, 134 | }, 135 | }, 136 | }, 137 | } 138 | result, err := db.Query(queryInput) 139 | if err != nil { 140 | return revealed, err 141 | } 142 | r := make([]interface{}, 0) 143 | err = dynamodbattribute.UnmarshalListOfMaps(result.Items, &r) 144 | if err != nil { 145 | return revealed, err 146 | } 147 | if len(r) < 1 { 148 | return revealed, errors.New("no items found") 149 | } 150 | // marshall and unmarshal so we can get the right struct type, 151 | j, err := json.Marshal(r[0]) 152 | if err != nil { 153 | return revealed, err 154 | } 155 | s := &Secret{} 156 | err = json.Unmarshal(j, s) 157 | 158 | // check the IP 159 | if len(s.Ip) > 0 && s.Ip != ip.String() { 160 | fmt.Printf("IP Mismatch, wanted %s got %s\n", s.Ip, ip.String()) 161 | return revealed, errors.New("not found") 162 | } 163 | 164 | // are we actually getting the secret, or just checking it exists? 165 | if !reveal { 166 | if s.Secret == id { 167 | revealed.Exists = true 168 | return revealed, nil 169 | } 170 | return revealed, nil 171 | } 172 | 173 | secret, err := base64.StdEncoding.DecodeString(string(s.Message)) 174 | if err != nil { 175 | return revealed, errors.New(fmt.Sprintf("could not decode secret %v", err)) 176 | } 177 | if err != nil || len(secret) == 0 { 178 | return revealed, errors.New(fmt.Sprintf("could not decode secret, got %d chars and error: %v", len(secret), err)) 179 | } 180 | 181 | // the secret looks okay, but make sure we can delete it before returning ... 182 | delInput := &dynamodb.DeleteItemInput{ 183 | Key: map[string]*dynamodb.AttributeValue{ 184 | dbIndex: { 185 | S: aws.String(id), 186 | }, 187 | }, 188 | TableName: aws.String(table), 189 | } 190 | _, err = db.DeleteItem(delInput) 191 | if err != nil { 192 | log.Printf("| ERROR db.go DbRemove: %v", err) 193 | return revealed, err 194 | } 195 | 196 | revealed.Secret = s.Message 197 | revealed.Exists = true 198 | revealed.Hint = s.Hint 199 | revealed.HasPass = s.HasPass 200 | revealed.Tag = s.Tag 201 | revealed.Iv = s.Iv 202 | revealed.PwTag = s.PwTag 203 | revealed.PwIv = s.PwIv 204 | return revealed, nil 205 | } 206 | 207 | // newClient returns a table name and dynamodb interface, references the 208 | // environ vars: TABLE and REGION, or uses sane defaults. Defaults to 209 | // "sharedpw" and "us-east-1" respectively. 210 | func newClient() (table string, db *dynamodb.DynamoDB, err error) { 211 | return func() string { 212 | if t, ok := os.LookupEnv("APPLICATION"); ok { 213 | return t 214 | } 215 | return "sharedpw" 216 | }(), 217 | func() *dynamodb.DynamoDB { 218 | region, ok := os.LookupEnv("REGION") 219 | if !ok { 220 | region = "us-east-1" 221 | } 222 | sess, err := session.NewSession(&aws.Config{ 223 | Region: aws.String(region)}, 224 | ) 225 | if err != nil { 226 | log.Println("| ERROR dynamo.go newClient: did not create session") 227 | } 228 | return dynamodb.New(sess) 229 | }(), err 230 | } 231 | -------------------------------------------------------------------------------- /terraform/api_gateway.tf: -------------------------------------------------------------------------------- 1 | # API Gateway object 2 | resource "aws_api_gateway_rest_api" "apigw" { 3 | name = "api_gw_${var.app_name}" 4 | description = "${var.app_name} API Gateway" 5 | } 6 | 7 | # POST method for /save endpoint 8 | resource "aws_api_gateway_resource" "save" { 9 | rest_api_id = aws_api_gateway_rest_api.apigw.id 10 | parent_id = aws_api_gateway_rest_api.apigw.root_resource_id 11 | path_part = "save" 12 | } 13 | 14 | resource "aws_api_gateway_method" "save" { 15 | rest_api_id = aws_api_gateway_rest_api.apigw.id 16 | resource_id = aws_api_gateway_resource.save.id 17 | http_method = "POST" 18 | authorization = "NONE" 19 | } 20 | 21 | resource "aws_api_gateway_integration" "lambda_save" { 22 | rest_api_id = aws_api_gateway_rest_api.apigw.id 23 | resource_id = aws_api_gateway_method.save.resource_id 24 | http_method = aws_api_gateway_method.save.http_method 25 | 26 | integration_http_method = "POST" 27 | type = "AWS_PROXY" 28 | uri = aws_lambda_function.lambda_save.invoke_arn 29 | } 30 | 31 | # POST method for /view endpoint 32 | resource "aws_api_gateway_resource" "view" { 33 | rest_api_id = aws_api_gateway_rest_api.apigw.id 34 | parent_id = aws_api_gateway_rest_api.apigw.root_resource_id 35 | path_part = "view" 36 | } 37 | 38 | resource "aws_api_gateway_method" "view" { 39 | rest_api_id = aws_api_gateway_rest_api.apigw.id 40 | resource_id = aws_api_gateway_resource.view.id 41 | http_method = "POST" 42 | authorization = "NONE" 43 | } 44 | 45 | resource "aws_api_gateway_integration" "lambda_view" { 46 | rest_api_id = aws_api_gateway_rest_api.apigw.id 47 | resource_id = aws_api_gateway_method.view.resource_id 48 | http_method = aws_api_gateway_method.view.http_method 49 | 50 | integration_http_method = "POST" 51 | type = "AWS_PROXY" 52 | uri = aws_lambda_function.lambda_view.invoke_arn 53 | } 54 | 55 | # Deployment. Add this later https://medium.com/coryodaniel/til-forcing-terraform-to-deploy-a-aws-api-gateway-deployment-ed36a9f60c1a 56 | resource "aws_api_gateway_deployment" "deployment" { 57 | depends_on = [ 58 | aws_api_gateway_integration.lambda_save, 59 | aws_api_gateway_integration.lambda_view, 60 | ] 61 | 62 | rest_api_id = aws_api_gateway_rest_api.apigw.id 63 | stage_name = "v1" 64 | } 65 | 66 | resource "aws_lambda_permission" "apigw_save" { 67 | statement_id = "AllowAPIGatewayInvoke" 68 | action = "lambda:InvokeFunction" 69 | function_name = aws_lambda_function.lambda_save.function_name 70 | principal = "apigateway.amazonaws.com" 71 | 72 | # The "/*/*" portion grants access from any method on any resource 73 | # within the API Gateway REST API. 74 | # source_arn = "${aws_api_gateway_rest_api.apigw.execution_arn}/*/*" 75 | source_arn = "${aws_api_gateway_rest_api.apigw.execution_arn}/*/${aws_api_gateway_method.save.http_method}${aws_api_gateway_resource.save.path}" 76 | } 77 | 78 | resource "aws_lambda_permission" "apigw_view" { 79 | statement_id = "AllowAPIGatewayInvoke" 80 | action = "lambda:InvokeFunction" 81 | function_name = aws_lambda_function.lambda_view.function_name 82 | principal = "apigateway.amazonaws.com" 83 | 84 | # The "/*/*" portion grants access from any method on any resource 85 | # within the API Gateway REST API. 86 | # source_arn = "${aws_api_gateway_rest_api.apigw.execution_arn}/*/*" 87 | source_arn = "${aws_api_gateway_rest_api.apigw.execution_arn}/*/${aws_api_gateway_method.view.http_method}${aws_api_gateway_resource.view.path}" 88 | } 89 | 90 | # OPTIONS method for /save endpoint 91 | resource "aws_api_gateway_method" "save_options_method" { 92 | rest_api_id = aws_api_gateway_rest_api.apigw.id 93 | resource_id = aws_api_gateway_resource.save.id 94 | http_method = "OPTIONS" 95 | authorization = "NONE" 96 | } 97 | resource "aws_api_gateway_method_response" "save_options" { 98 | rest_api_id = aws_api_gateway_rest_api.apigw.id 99 | resource_id = aws_api_gateway_resource.save.id 100 | http_method = aws_api_gateway_method.save_options_method.http_method 101 | status_code = "200" 102 | response_models = { 103 | "application/json" = "Empty" 104 | } 105 | response_parameters = { 106 | "method.response.header.Access-Control-Allow-Headers" = true, 107 | "method.response.header.Access-Control-Allow-Methods" = true, 108 | "method.response.header.Access-Control-Allow-Origin" = true 109 | } 110 | depends_on = [aws_api_gateway_method.save_options_method] 111 | } 112 | resource "aws_api_gateway_integration" "save_options_integration" { 113 | rest_api_id = aws_api_gateway_rest_api.apigw.id 114 | resource_id = aws_api_gateway_resource.save.id 115 | http_method = aws_api_gateway_method.save_options_method.http_method 116 | type = "MOCK" 117 | request_templates = { 118 | "application/json": "{\"statusCode\": 200}" 119 | } 120 | depends_on = [aws_api_gateway_method.save_options_method] 121 | } 122 | resource "aws_api_gateway_integration_response" "save_options_integration_response" { 123 | rest_api_id = aws_api_gateway_rest_api.apigw.id 124 | resource_id = aws_api_gateway_resource.save.id 125 | http_method = aws_api_gateway_method.save_options_method.http_method 126 | status_code = aws_api_gateway_method_response.save_options.status_code 127 | response_parameters = { 128 | "method.response.header.Access-Control-Allow-Headers" = "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", 129 | "method.response.header.Access-Control-Allow-Methods" = "'GET,OPTIONS,POST,PUT'", 130 | "method.response.header.Access-Control-Allow-Origin" = "'${var.scheme}://${var.bucket_name}'" 131 | } 132 | depends_on = [aws_api_gateway_method_response.save_options] 133 | } 134 | 135 | # OPTIONS method for /view endpoint 136 | resource "aws_api_gateway_method" "view_options_method" { 137 | rest_api_id = aws_api_gateway_rest_api.apigw.id 138 | resource_id = aws_api_gateway_resource.view.id 139 | http_method = "OPTIONS" 140 | authorization = "NONE" 141 | } 142 | resource "aws_api_gateway_method_response" "view_options" { 143 | rest_api_id = aws_api_gateway_rest_api.apigw.id 144 | resource_id = aws_api_gateway_resource.view.id 145 | http_method = aws_api_gateway_method.view_options_method.http_method 146 | status_code = "200" 147 | response_models = { 148 | "application/json" = "Empty" 149 | } 150 | response_parameters = { 151 | "method.response.header.Access-Control-Allow-Headers" = true, 152 | "method.response.header.Access-Control-Allow-Methods" = true, 153 | "method.response.header.Access-Control-Allow-Origin" = true 154 | } 155 | depends_on = [aws_api_gateway_method.view_options_method] 156 | } 157 | resource "aws_api_gateway_integration" "view_options_integration" { 158 | rest_api_id = aws_api_gateway_rest_api.apigw.id 159 | resource_id = aws_api_gateway_resource.view.id 160 | http_method = aws_api_gateway_method.view_options_method.http_method 161 | type = "MOCK" 162 | request_templates = { 163 | "application/json": "{\"statusCode\": 200}" 164 | } 165 | depends_on = [aws_api_gateway_method.view_options_method] 166 | } 167 | resource "aws_api_gateway_integration_response" "view_options_integration_response" { 168 | rest_api_id = aws_api_gateway_rest_api.apigw.id 169 | resource_id = aws_api_gateway_resource.view.id 170 | http_method = aws_api_gateway_method.view_options_method.http_method 171 | status_code = aws_api_gateway_method_response.view_options.status_code 172 | response_parameters = { 173 | "method.response.header.Access-Control-Allow-Headers" = "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", 174 | "method.response.header.Access-Control-Allow-Methods" = "'GET,OPTIONS,POST,PUT'", 175 | "method.response.header.Access-Control-Allow-Origin" = "'${var.scheme}://${var.bucket_name}'" 176 | } 177 | depends_on = [aws_api_gateway_method_response.view_options] 178 | } -------------------------------------------------------------------------------- /client/src/components/view.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 186 | 187 | 189 | -------------------------------------------------------------------------------- /client/src/components/save.vue: -------------------------------------------------------------------------------- 1 | 109 | 110 | 220 | 221 | 223 | --------------------------------------------------------------------------------