github.com/grailbio/base@v0.0.11/cmd/ticket-server/ec2blesser.go (about) 1 // Copyright 2018 GRAIL, Inc. All rights reserved. 2 // Use of this source code is governed by the Apache-2.0 3 // license that can be found in the LICENSE file. 4 5 package main 6 7 import ( 8 "fmt" 9 "net" 10 "os" 11 "strings" 12 "time" 13 14 "github.com/aws/aws-sdk-go/aws" 15 "github.com/aws/aws-sdk-go/aws/awserr" 16 "github.com/aws/aws-sdk-go/aws/client" 17 "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 18 "github.com/aws/aws-sdk-go/aws/session" 19 "github.com/aws/aws-sdk-go/service/dynamodb" 20 "github.com/aws/aws-sdk-go/service/ec2" 21 "github.com/grailbio/base/cloud/ec2util" 22 "github.com/grailbio/base/common/log" 23 "v.io/v23/context" 24 "v.io/v23/rpc" 25 "v.io/v23/security" 26 ) 27 28 const pendingTimeWindow = time.Hour 29 30 // setupEc2Blesser creates the DynamoDB table used for enforcing the uniqueness 31 // of the EC2-based blessing requests. For each VM we only want to handle 32 // blessings only to the first request. This prevents replay attacks in the case 33 // when the EC2 instance document was leaked to an adversary. 34 // 35 // The schema of the table is the following: 36 // 37 // ID: (string, hash key) '/'-separated of (account, region, instance, IP) 38 // IdentityDocument: (string) JSON of the IdentityDocument from the request 39 // DescribeInstance: (string) JSON response for the DescribeInstance call 40 // Timestamp: (string) Timestamp in RFC3339Nano when the record was created 41 func setupEc2Blesser(ctx *context.T, s *session.Session, table string) { 42 if table == "" { 43 return 44 } 45 46 client := dynamodb.New(s) 47 out, err := client.DescribeTable(&dynamodb.DescribeTableInput{ 48 TableName: aws.String(table), 49 }) 50 51 if err == nil { 52 log.Error(ctx, "DynamoDB table already exists", "table", out) 53 return 54 } 55 56 want := dynamodb.ErrCodeResourceNotFoundException 57 if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != want { 58 log.Error(ctx, "unexpected DynamoDB error", "got", err, "want", want) 59 os.Exit(255) 60 } 61 62 _, err = client.CreateTable(&dynamodb.CreateTableInput{ 63 TableName: aws.String(table), 64 AttributeDefinitions: []*dynamodb.AttributeDefinition{ 65 { 66 AttributeName: aws.String("ID"), 67 AttributeType: aws.String("S"), 68 }, 69 }, 70 KeySchema: []*dynamodb.KeySchemaElement{ 71 { 72 AttributeName: aws.String("ID"), 73 KeyType: aws.String("HASH"), 74 }, 75 }, 76 ProvisionedThroughput: &dynamodb.ProvisionedThroughput{ 77 ReadCapacityUnits: aws.Int64(int64(1)), 78 WriteCapacityUnits: aws.Int64(int64(1)), 79 }, 80 }) 81 if err != nil { 82 log.Error(ctx, err.Error()) 83 os.Exit(255) 84 } 85 log.Debug(ctx, "created DynamoDB table", "table", table) 86 // TODO(razvanm): wait for the table to reach ACTIVE state? 87 // TODO(razvanm): enable the auto scaling? 88 } 89 90 type ec2Blesser struct { 91 ctx context.T 92 expirationInterval time.Duration 93 role string 94 table string 95 session *session.Session 96 } 97 98 func newEc2Blesser(ctx *context.T, s *session.Session, expiration time.Duration, role string, table string) *ec2Blesser { 99 setupEc2Blesser(ctx, s, ec2DynamoDBTableFlag) 100 return &ec2Blesser{ 101 ctx: *ctx, 102 expirationInterval: expiration, 103 role: role, 104 table: table, 105 session: s, 106 } 107 } 108 109 func (blesser *ec2Blesser) checkUniqueness(ctx *context.T, doc *ec2util.IdentityDocument, remoteAddr string, jsonDoc string, jsonInstance string) error { 110 if blesser.table == "" { 111 return nil 112 } 113 ipAddr, _, err := net.SplitHostPort(remoteAddr) 114 if err != nil { 115 return err 116 } 117 key := strings.Join([]string{doc.AccountID, doc.Region, doc.InstanceID, ipAddr}, "/") 118 log.Debug(ctx, "DynamoDB info", "key", key, "remoteAddr", remoteAddr) 119 cond := aws.String("attribute_not_exists(ID)") 120 if ec2DisableUniquenessCheckFlag { 121 cond = nil 122 } 123 _, err = dynamodb.New(blesser.session).PutItem(&dynamodb.PutItemInput{ 124 TableName: aws.String(blesser.table), 125 ConditionExpression: cond, 126 Item: map[string]*dynamodb.AttributeValue{ 127 "ID": {S: aws.String(key)}, 128 "IdentityDocument": {S: aws.String(jsonDoc)}, 129 "DescribeInstance": {S: aws.String(jsonInstance)}, 130 "Timestamp": {S: aws.String(time.Now().UTC().Format(time.RFC3339Nano))}, 131 }, 132 }) 133 return err 134 } 135 136 func checkPendingTime(doc *ec2util.IdentityDocument) error { 137 pendingTime := doc.PendingTime 138 if time.Since(doc.PendingTime) > pendingTimeWindow { 139 return fmt.Errorf("launch time is too old: %s should be within %s", pendingTime, pendingTimeWindow) 140 } 141 return nil 142 } 143 144 func (blesser *ec2Blesser) BlessEc2(ctx *context.T, call rpc.ServerCall, pkcs7b64 string) (security.Blessings, error) { 145 var empty security.Blessings 146 147 remoteAddress := call.RemoteAddr().String() 148 doc, jsonDoc, err := ec2util.ParseAndVerifyIdentityDocument(pkcs7b64) 149 log.Info(ctx, "bless EC2 request", "remoteAddr", remoteAddress, "remoteEndpoint", call.RemoteEndpoint().Addr(), 150 "pkcs7b64Bytes", len(pkcs7b64), "doc", doc) 151 if err != nil { 152 log.Error(ctx, "Error parsing and verifying identity document.", "err", err) 153 return empty, err 154 } 155 156 if !ec2DisablePendingTimeCheckFlag { 157 if err := checkPendingTime(doc); err != nil { 158 log.Error(ctx, err.Error()) 159 return empty, err 160 } 161 } 162 163 config := aws.Config{ 164 Credentials: stscreds.NewCredentials(blesser.session, fmt.Sprintf("arn:aws:iam::%s:role/%s", doc.AccountID, blesser.role)), 165 Retryer: client.DefaultRetryer{ 166 NumMaxRetries: 100, 167 }, 168 Region: aws.String(doc.Region), 169 } 170 validateRemoteAddr := remoteAddress 171 if ec2DisableAddrCheckFlag { 172 validateRemoteAddr = "" 173 } 174 175 output, err := ec2.New(blesser.session, &config).DescribeInstances(&ec2.DescribeInstancesInput{ 176 InstanceIds: []*string{aws.String(doc.InstanceID)}, 177 }) 178 179 if err != nil { 180 log.Error(ctx, err.Error()) 181 return empty, err 182 } 183 184 role, err := ec2util.ValidateInstance(output, *doc, validateRemoteAddr) 185 if err != nil { 186 log.Error(ctx, err.Error()) 187 return empty, err 188 } 189 190 if err = blesser.checkUniqueness(ctx, doc, remoteAddress, jsonDoc, output.String()); err != nil { 191 log.Error(ctx, err.Error()) 192 return empty, err 193 } 194 195 ext := fmt.Sprintf("ec2:%s:%s:%s", doc.AccountID, role, doc.InstanceID) 196 197 securityCall := call.Security() 198 if securityCall.LocalPrincipal() == nil { 199 return empty, fmt.Errorf("server misconfiguration: no authentication happened") 200 } 201 202 pubKey := securityCall.RemoteBlessings().PublicKey() 203 caveat, err := security.NewExpiryCaveat(time.Now().Add(blesser.expirationInterval)) 204 // TODO(razvanm): using a PublicKeyThirdPartyCaveat we could also invalidate 205 // the older blessings. This will force the clients to talk to the 206 // ticket-server more frequently though. 207 if err != nil { 208 return empty, err 209 } 210 return securityCall.LocalPrincipal().Bless(pubKey, securityCall.LocalBlessings(), ext, caveat) 211 }