├── .gitignore ├── .travis.yml ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── ci ├── cfn.yml ├── e2e.bash ├── ec2-ssh-host-key ├── ec2-ssh-key.enc ├── ec2-ssh-key.pub ├── expected-output-host.txt ├── expected-output-jumpbox-sshconf.txt ├── expected-output-jumpbox.txt ├── expected-output-vouched.txt ├── expected-output.txt ├── setup_env.bash └── travis-user.yml ├── cmd ├── lambda │ └── main.go └── lkp │ ├── adv.go │ ├── host.go │ ├── main.go │ ├── mfa.go │ ├── root.go │ ├── setup.go │ ├── ssh.go │ ├── sshExec.go │ ├── sshMatch.go │ ├── sshProxy.go │ ├── sshSign.go │ ├── tokenCreate.go │ ├── tokenValidate.go │ ├── version.go │ └── vouch.go ├── docs ├── README.md ├── access-policy.md ├── sequence-diagram.png └── sequence-diagram.txt ├── examples └── bastion.yml └── pkg └── lastkeypair ├── authorizer.go ├── cli ├── utils.go └── utils_test.go ├── common.go ├── keypair.go ├── lambda.go ├── lambda_req_resp.go ├── netcat └── netcat.go ├── ssh.go ├── token.go └── voucher.go /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | handler.zip 3 | /lkp_* 4 | /lambda_* 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | dist: trusty 4 | sudo: false 5 | 6 | cache: 7 | directories: 8 | - dl 9 | - $HOME/.local 10 | - vendor 11 | 12 | go: 13 | - 1.8.3 14 | 15 | install: 16 | - ci/setup_env.bash 17 | - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 18 | - dep ensure 19 | 20 | script: 21 | - PATH=$(pwd)/upx-3.94-amd64_linux:$PATH make linux zip 22 | - ci/e2e.bash 23 | - ./lkp_linux_amd64 version 24 | 25 | before_deploy: 26 | - PATH=$(pwd)/upx-3.94-amd64_linux:$PATH make otherplats 27 | 28 | deploy: 29 | provider: releases 30 | api_key: 31 | secure: pU8Mgcdz3A0cCYto8GKRMKPDp4eu/koO/o2Eu9Xbv3sTIa0Of9ZOFvPlYKrvdMH5+KN2uj3ucQPDIqaNSvepuWvtCgx4TgLbtY027Xopt0Pr6ON31JDnd4EFXszAqqc/7kxFX/8jgqsVWN/bX63XtB9qSHqpdnm4fuH7ck9XpTJyKzfkWWb7U8noFrc+fZ+TlCxl/hDPf93eoPfNW/aeYK/r+6YVvb7SuFpyZcwVv7Ry+xMpYwD/587xv5+FgdN6NxqHnj6B5nJlrh8/qjxZGNGFthr7mBpaY4zIzdQrhP8IWk6WzlmtedgujQ3czQHglO46DNrSlUyy0WZgSN9hsPBbL63KKL/9Yb5aVBCebt4b3IqspFtBuuxAk1K+wCaBu9d02C31FQp9lhlwj5T3CDW0XZmlPgrbajerzWHOrCXKVpLUJUK211JpTlSwvTaMHdTGhxMJldHj+g8OsdkDbu/+AaMUS+3aPG7mRmhnKPG+WTokyN8MUBjxE2dRG8LlxsnwRLdUN8GQ6R7OfL3LjwqcGVAVHgVpFxvOgkr9HT3rhhF9bVPke2Qms4dgIkqYtTZmVjFr+wUcSnpE8g/eAZgGRE7DrFMtJvJm/naLXy5+ZPsdnwBRtLZFvCkwF6pISTu74gqziFqE6LmelA6Ua4Cmtnh7kf5ru+r3ckVaHFI= 32 | skip_cleanup: true 33 | file: 34 | - handler.zip 35 | - lkp_linux_amd64 36 | - lkp_darwin_amd64 37 | - lkp_windows_amd64.exe 38 | on: 39 | tags: true 40 | repo: glassechidna/lastkeypair 41 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:65b2ccf790a3a41c2c4654a7546b882c932e11c5d2d7af05d5aa1dcea6fda297" 6 | name = "github.com/AlecAivazis/survey" 7 | packages = ["."] 8 | pruneopts = "UT" 9 | revision = "0aa8b6a162b391fe2d95648b7677d1d6ac2090a6" 10 | version = "v1.4.1" 11 | 12 | [[projects]] 13 | digest = "1:7637de64e2bad97a7b9d1665e9cb0f9b709b1ef28d09232da908ead542bf89f0" 14 | name = "github.com/aws/aws-lambda-go" 15 | packages = [ 16 | "lambda", 17 | "lambda/messages", 18 | "lambdacontext", 19 | ] 20 | pruneopts = "UT" 21 | revision = "4d30d0ff60440c2d0480a15747c96ee71c3c53d4" 22 | version = "v1.2.0" 23 | 24 | [[projects]] 25 | digest = "1:ce3d9b6bd789ee6ffb6394a0c431cb7f54ec8de1a3f36c2b909a333a71b5860e" 26 | name = "github.com/aws/aws-sdk-go" 27 | packages = [ 28 | "aws", 29 | "aws/awserr", 30 | "aws/awsutil", 31 | "aws/client", 32 | "aws/client/metadata", 33 | "aws/corehandlers", 34 | "aws/credentials", 35 | "aws/credentials/ec2rolecreds", 36 | "aws/credentials/endpointcreds", 37 | "aws/credentials/stscreds", 38 | "aws/defaults", 39 | "aws/ec2metadata", 40 | "aws/endpoints", 41 | "aws/request", 42 | "aws/session", 43 | "aws/signer/v4", 44 | "internal/sdkio", 45 | "internal/sdkrand", 46 | "internal/shareddefaults", 47 | "private/protocol", 48 | "private/protocol/json/jsonutil", 49 | "private/protocol/jsonrpc", 50 | "private/protocol/query", 51 | "private/protocol/query/queryutil", 52 | "private/protocol/rest", 53 | "private/protocol/restjson", 54 | "private/protocol/xml/xmlutil", 55 | "service/kms", 56 | "service/lambda", 57 | "service/ssm", 58 | "service/sts", 59 | ] 60 | pruneopts = "UT" 61 | revision = "c8c4e0c0514d87f930c1342fef390db5329928b7" 62 | version = "v1.13.20" 63 | 64 | [[projects]] 65 | digest = "1:7b94d37d65c0445053c6f3e73090e3966c1c29127035492c349e14f25c440359" 66 | name = "github.com/boombuler/barcode" 67 | packages = [ 68 | ".", 69 | "qr", 70 | "utils", 71 | ] 72 | pruneopts = "UT" 73 | revision = "3cfea5ab600ae37946be2b763b8ec2c1cf2d272d" 74 | version = "v1.0.0" 75 | 76 | [[projects]] 77 | digest = "1:511f7e1ca840c3b8c5761870d8606aa8bdf95f5d422a74fbe0ef3cf210cfe2c9" 78 | name = "github.com/davecgh/go-spew" 79 | packages = ["spew"] 80 | pruneopts = "UT" 81 | revision = "04cdfd42973bb9c8589fd6a731800cf222fde1a9" 82 | 83 | [[projects]] 84 | digest = "1:38c783cf85b9454cc02a1a8319239800ed0af6c1c864adf19cea0539e134adad" 85 | name = "github.com/fsnotify/fsnotify" 86 | packages = ["."] 87 | pruneopts = "UT" 88 | revision = "4da3e2cfbabc9f751898f250b49f2439785783a1" 89 | 90 | [[projects]] 91 | branch = "master" 92 | digest = "1:73a66350f4361b414e7b0922d0a430476338ee749598fd0500bac48f952db849" 93 | name = "github.com/glassechidna/awscredcache" 94 | packages = [ 95 | ".", 96 | "sneakyvendor/aws-shared-defaults", 97 | ] 98 | pruneopts = "UT" 99 | revision = "8def02b6f71b890414c54f9cb7ded5f26904743b" 100 | 101 | [[projects]] 102 | digest = "1:50772cf1f376b08cb57b4e8e9225775d9f9f4aad8487313afcb66846ceaa5d4c" 103 | name = "github.com/go-ini/ini" 104 | packages = ["."] 105 | pruneopts = "UT" 106 | revision = "32e4c1e6bc4e7d0d8451aa6b75200d19e37a536a" 107 | version = "v1.32.0" 108 | 109 | [[projects]] 110 | digest = "1:b5090146529377f77830ae60f254e37bbf9bb89aac519829405c1ac010d1ec97" 111 | name = "github.com/hashicorp/hcl" 112 | packages = [ 113 | ".", 114 | "hcl/ast", 115 | "hcl/parser", 116 | "hcl/scanner", 117 | "hcl/strconv", 118 | "hcl/token", 119 | "json/parser", 120 | "json/scanner", 121 | "json/token", 122 | ] 123 | pruneopts = "UT" 124 | revision = "392dba7d905ed5d04a5794ba89f558b27e2ba1ca" 125 | 126 | [[projects]] 127 | digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" 128 | name = "github.com/inconshreveable/mousetrap" 129 | packages = ["."] 130 | pruneopts = "UT" 131 | revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" 132 | version = "v1.0" 133 | 134 | [[projects]] 135 | digest = "1:e22af8c7518e1eab6f2eab2b7d7558927f816262586cd6ed9f349c97a6c285c4" 136 | name = "github.com/jmespath/go-jmespath" 137 | packages = ["."] 138 | pruneopts = "UT" 139 | revision = "0b12d6b5" 140 | 141 | [[projects]] 142 | digest = "1:79c55505b38424ce7dc479e8e0813db40245434db95a5d2e3390bfa24047b43c" 143 | name = "github.com/magiconair/properties" 144 | packages = ["."] 145 | pruneopts = "UT" 146 | revision = "51463bfca2576e06c62a8504b5c0f06d61312647" 147 | 148 | [[projects]] 149 | digest = "1:31fcf8f5f8f6cc36ef11182c0a0f63ba464e1a7ad9f0e92a7cf46f38bfc0adfd" 150 | name = "github.com/mattn/go-colorable" 151 | packages = ["."] 152 | pruneopts = "UT" 153 | revision = "5411d3eea5978e6cdc258b30de592b60df6aba96" 154 | 155 | [[projects]] 156 | digest = "1:fa610f9fe6a93f4a75e64c83673dfff9bf1a34bbb21e6102021b6bc7850834a3" 157 | name = "github.com/mattn/go-isatty" 158 | packages = ["."] 159 | pruneopts = "UT" 160 | revision = "57fdcb988a5c543893cc61bce354a6e24ab70022" 161 | 162 | [[projects]] 163 | branch = "master" 164 | digest = "1:2b32af4d2a529083275afc192d1067d8126b578c7a9613b26600e4df9c735155" 165 | name = "github.com/mgutz/ansi" 166 | packages = ["."] 167 | pruneopts = "UT" 168 | revision = "9520e82c474b0a04dd04f8a40959027271bab992" 169 | 170 | [[projects]] 171 | digest = "1:12ae6210bdbdad658a9a67fd95cd9c99f7fdbf12f6d36eaf0af704e69dacf4f5" 172 | name = "github.com/mitchellh/go-homedir" 173 | packages = ["."] 174 | pruneopts = "UT" 175 | revision = "b8bc1bf767474819792c23f32d8286a45736f1c6" 176 | 177 | [[projects]] 178 | digest = "1:3b1bd950e1602fa2ae92650110628444bf205cca908d7491794b09e045a66ac0" 179 | name = "github.com/mitchellh/mapstructure" 180 | packages = ["."] 181 | pruneopts = "UT" 182 | revision = "cc8532a8e9a55ea36402aa21efdf403a60d34096" 183 | 184 | [[projects]] 185 | digest = "1:b2ee62e09bec113cf086d2ce0769efcc7bf79481aba8373fd8f7884e94df3462" 186 | name = "github.com/pelletier/go-buffruneio" 187 | packages = ["."] 188 | pruneopts = "UT" 189 | revision = "c37440a7cf42ac63b919c752ca73a85067e05992" 190 | version = "v0.2.0" 191 | 192 | [[projects]] 193 | digest = "1:ffc2c3199837b02191e80b27cec0c8a32599f93b97277e21ff739b518a34120c" 194 | name = "github.com/pelletier/go-toml" 195 | packages = ["."] 196 | pruneopts = "UT" 197 | revision = "23f644976aa7c724adf4aec911dadf4af17840ab" 198 | 199 | [[projects]] 200 | digest = "1:40e195917a951a8bf867cd05de2a46aaf1806c50cf92eebf4c16f78cd196f747" 201 | name = "github.com/pkg/errors" 202 | packages = ["."] 203 | pruneopts = "UT" 204 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 205 | version = "v0.8.0" 206 | 207 | [[projects]] 208 | digest = "1:08413c4235cad94a96c39e1e2f697789733c4a87d1fdf06b412d2cf2ba49826a" 209 | name = "github.com/pmezard/go-difflib" 210 | packages = ["difflib"] 211 | pruneopts = "UT" 212 | revision = "d8ed2627bdf02c080bf22230dbb337003b7aba2d" 213 | 214 | [[projects]] 215 | digest = "1:6c7a3f738e37a1c7ad3d56122a34932140654d51a57e01f8613cdf3eaf050911" 216 | name = "github.com/pquerna/otp" 217 | packages = [ 218 | ".", 219 | "hotp", 220 | "totp", 221 | ] 222 | pruneopts = "UT" 223 | revision = "b7b89250c468c06871d3837bee02e2d5c155ae19" 224 | version = "v1.0.0" 225 | 226 | [[projects]] 227 | digest = "1:612c049404353b151a3d9fd779b72a0905df1f799056c29202ad84d4fcb1b748" 228 | name = "github.com/spf13/afero" 229 | packages = [ 230 | ".", 231 | "mem", 232 | ] 233 | pruneopts = "UT" 234 | revision = "9be650865eab0c12963d8753212f4f9c66cdcf12" 235 | 236 | [[projects]] 237 | digest = "1:9b28ee2984c69d78afe2ce52b1650ba91a6381f355ff08c1d0e53d9e66bd62fe" 238 | name = "github.com/spf13/cast" 239 | packages = ["."] 240 | pruneopts = "UT" 241 | revision = "acbeb36b902d72a7a4c18e8f3241075e7ab763e4" 242 | version = "v1.1.0" 243 | 244 | [[projects]] 245 | digest = "1:7ffc0983035bc7e297da3688d9fe19d60a420e9c38bef23f845c53788ed6a05e" 246 | name = "github.com/spf13/cobra" 247 | packages = ["."] 248 | pruneopts = "UT" 249 | revision = "7b2c5ac9fc04fc5efafb60700713d4fa609b777b" 250 | version = "v0.0.1" 251 | 252 | [[projects]] 253 | digest = "1:a57e9df4ec9730e40dd6bac6f88c5dfa62159de9e7c3273fb7d829c09d4b20d2" 254 | name = "github.com/spf13/jwalterweatherman" 255 | packages = ["."] 256 | pruneopts = "UT" 257 | revision = "8f07c835e5cc1450c082fe3a439cf87b0cbb2d99" 258 | 259 | [[projects]] 260 | digest = "1:1b21a2b4058a779f290c7341cd93267492e0ecea6c8b54f64a4a5fd7ff131034" 261 | name = "github.com/spf13/pflag" 262 | packages = ["."] 263 | pruneopts = "UT" 264 | revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" 265 | version = "v1.0.0" 266 | 267 | [[projects]] 268 | digest = "1:f8e1a678a2571e265f4bf91a3e5e32aa6b1474a55cb0ea849750cc177b664d96" 269 | name = "github.com/spf13/viper" 270 | packages = ["."] 271 | pruneopts = "UT" 272 | revision = "25b30aa063fc18e48662b86996252eabdcf2f0c7" 273 | version = "v1.0.0" 274 | 275 | [[projects]] 276 | digest = "1:1f37c313333ab92ceaa17b1f7f4427e316f58147d0a5dce1665e2d3091851e9a" 277 | name = "github.com/stretchr/testify" 278 | packages = ["assert"] 279 | pruneopts = "UT" 280 | revision = "2aa2c176b9dab406a6970f6a55f513e8a8c8b18f" 281 | 282 | [[projects]] 283 | digest = "1:8c24dfb1cad68042141e74d61eea5bd9cafc3436dc0f4f579a41fb6e9d60be9c" 284 | name = "golang.org/x/crypto" 285 | packages = [ 286 | "curve25519", 287 | "ed25519", 288 | "ed25519/internal/edwards25519", 289 | "ssh", 290 | ] 291 | pruneopts = "UT" 292 | revision = "84f24dfdf3c414ed893ca1b318d0045ef5a1f607" 293 | 294 | [[projects]] 295 | digest = "1:7217a703ed82a3c04939fdaf0768bf651406a9d2e41e061582f0809ab5459e00" 296 | name = "golang.org/x/sys" 297 | packages = ["unix"] 298 | pruneopts = "UT" 299 | revision = "9ccfe848b9db8435a24c424abbc07a921adf1df5" 300 | 301 | [[projects]] 302 | digest = "1:c2e479b85643a71b8397b324f1d50dc9e7cb0db009dea2efc36ab67172a38bf3" 303 | name = "golang.org/x/text" 304 | packages = [ 305 | "internal/gen", 306 | "internal/triegen", 307 | "internal/ucd", 308 | "transform", 309 | "unicode/cldr", 310 | "unicode/norm", 311 | ] 312 | pruneopts = "UT" 313 | revision = "470f45bf29f4147d6fbd7dfd0a02a848e49f5bf4" 314 | 315 | [[projects]] 316 | digest = "1:43cd1b8531831307906059a16b940bf7151f97f83114ed0b5bd95f63258db99a" 317 | name = "gopkg.in/AlecAivazis/survey.v1" 318 | packages = [ 319 | "core", 320 | "terminal", 321 | ] 322 | pruneopts = "UT" 323 | revision = "9d3458c219bae9653d267883634bdb5375281fd8" 324 | version = "v1.5.1" 325 | 326 | [[projects]] 327 | digest = "1:2a81c6e126d36ad027328cffaa4888fc3be40f09dc48028d1f93705b718130b9" 328 | name = "gopkg.in/yaml.v2" 329 | packages = ["."] 330 | pruneopts = "UT" 331 | revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5" 332 | version = "v2.1.1" 333 | 334 | [solve-meta] 335 | analyzer-name = "dep" 336 | analyzer-version = 1 337 | input-imports = [ 338 | "github.com/AlecAivazis/survey", 339 | "github.com/aws/aws-lambda-go/lambda", 340 | "github.com/aws/aws-sdk-go/aws", 341 | "github.com/aws/aws-sdk-go/aws/credentials", 342 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds", 343 | "github.com/aws/aws-sdk-go/aws/ec2metadata", 344 | "github.com/aws/aws-sdk-go/aws/request", 345 | "github.com/aws/aws-sdk-go/aws/session", 346 | "github.com/aws/aws-sdk-go/service/kms", 347 | "github.com/aws/aws-sdk-go/service/lambda", 348 | "github.com/aws/aws-sdk-go/service/ssm", 349 | "github.com/aws/aws-sdk-go/service/sts", 350 | "github.com/glassechidna/awscredcache", 351 | "github.com/glassechidna/awscredcache/sneakyvendor/aws-shared-defaults", 352 | "github.com/go-ini/ini", 353 | "github.com/inconshreveable/mousetrap", 354 | "github.com/mitchellh/go-homedir", 355 | "github.com/pkg/errors", 356 | "github.com/pquerna/otp/totp", 357 | "github.com/spf13/cobra", 358 | "github.com/spf13/viper", 359 | "github.com/stretchr/testify/assert", 360 | "golang.org/x/crypto/ssh", 361 | ] 362 | solver-name = "gps-cdcl" 363 | solver-version = 1 364 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | name = "github.com/AlecAivazis/survey" 30 | version = "1.4.1" 31 | 32 | [[constraint]] 33 | name = "github.com/aws/aws-sdk-go" 34 | version = "1.10.3" 35 | 36 | [[constraint]] 37 | branch = "master" 38 | name = "github.com/glassechidna/awscredcache" 39 | 40 | [[constraint]] 41 | name = "github.com/go-ini/ini" 42 | version = "1.32.0" 43 | 44 | [[constraint]] 45 | name = "github.com/inconshreveable/mousetrap" 46 | version = "1.0.0" 47 | 48 | [[constraint]] 49 | name = "github.com/pkg/errors" 50 | version = "0.8.0" 51 | 52 | [[constraint]] 53 | name = "github.com/pquerna/otp" 54 | version = "1.0.0" 55 | 56 | [[constraint]] 57 | name = "github.com/spf13/cobra" 58 | version = "0.0.1" 59 | 60 | [[constraint]] 61 | name = "github.com/spf13/viper" 62 | version = "1.0.0" 63 | 64 | [prune] 65 | go-tests = true 66 | unused-packages = true 67 | 68 | [[constraint]] 69 | name = "github.com/aws/aws-lambda-go" 70 | version = "1.2.0" 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | HANDLER ?= handler 2 | PACKAGE ?= $(HANDLER) 3 | GOPATH ?= $(HOME)/go 4 | 5 | VERSION = $(shell git describe --tags) 6 | DATE = $(shell date +%FT%T%z) 7 | GO_LDFLAGS := "-X github.com/glassechidna/lastkeypair/pkg/lastkeypair.ApplicationVersion=$(VERSION) -X github.com/glassechidna/lastkeypair/pkg/lastkeypair.ApplicationBuildDate=$(DATE)" 8 | 9 | all: linux otherplats zip 10 | 11 | .PHONY: all 12 | 13 | linux: 14 | @gox -arch="amd64" -os="linux" -ldflags=$(GO_LDFLAGS) ./cmd/... 15 | @upx lkp_linux_amd64 16 | 17 | otherplats: 18 | @gox -arch="amd64" -os="windows darwin" -ldflags=$(GO_LDFLAGS) ./cmd/lkp 19 | @upx lkp_darwin_amd64 lkp_windows_amd64.exe 20 | 21 | zip: 22 | @zip handler.zip lambda_linux_amd64 23 | -------------------------------------------------------------------------------- /ci/cfn.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Parameters: 3 | FunctionName: 4 | Type: String 5 | Default: LastKeypair 6 | FunctionKmsAuthName: 7 | Type: String 8 | Default: LastKeypair 9 | KeyAliasName: 10 | Type: String 11 | Default: alias/LastKeypair 12 | PstoreCAKeyBytesName: 13 | Type: String 14 | S3Bucket: 15 | Type: String 16 | S3Key: 17 | Type: String 18 | S3ObjectVersion: 19 | Type: String 20 | MFARequired: 21 | Type: String 22 | Default: false 23 | AllowedAwsAccounts: 24 | Type: String 25 | JumpboxDns: 26 | Type: String 27 | Conditions: 28 | MFARequired: !Equals [!Ref MFARequired, "true"] 29 | Resources: 30 | Key: 31 | Type: AWS::KMS::Key 32 | Properties: 33 | KeyPolicy: 34 | Version: "2012-10-17" 35 | Id: key-policy 36 | Statement: 37 | - Sid: Default 38 | Effect: Allow 39 | Principal: 40 | AWS: !Sub arn:aws:iam::${AWS::AccountId}:role/KMSAdminRole 41 | # AWS: !Sub arn:aws:iam::${AWS::AccountId}:root 42 | Action: kms:* 43 | Resource: "*" 44 | - Sid: KmsDescribe 45 | Effect: Allow 46 | Principal: 47 | AWS: "*" # we use kms:CallerAccount condition key instead 48 | Action: kms:DescribeKey 49 | Resource: "*" 50 | Condition: 51 | StringEquals: 52 | kms:CallerAccount: !Split [",", !Ref AllowedAwsAccounts] 53 | - Sid: AllowIAMUserEncrypt 54 | Effect: Allow 55 | Principal: 56 | AWS: "*" 57 | Action: kms:Encrypt 58 | Resource: "*" # we use kms:CallerAccount condition key instead 59 | Condition: 60 | StringEquals: 61 | kms:EncryptionContext:to: !Ref FunctionKmsAuthName 62 | kms:EncryptionContext:type: "${aws:principaltype}" 63 | kms:EncryptionContext:fromId: "${aws:userid}" 64 | kms:EncryptionContext:fromAccount: "${kms:CallerAccount}" 65 | kms:CallerAccount: !Split [",", !Ref AllowedAwsAccounts] 66 | StringEqualsIfExists: 67 | aws:username: "${kms:EncryptionContext:fromName}" 68 | kms:EncryptionContext:fromName: "${aws:username}" 69 | ec2:SourceInstanceARN: "${kms:EncryptionContext:hostInstanceArn}" 70 | kms:EncryptionContext:hostInstanceArn: "${ec2:SourceInstanceARN}" 71 | # Bool: 72 | # aws:MultiFactorAuthPresent: true 73 | - Sid: KmsLambdaAuth 74 | Effect: Allow 75 | Principal: 76 | AWS: !GetAtt LambdaRole.Arn 77 | Action: kms:Decrypt 78 | Resource: "*" 79 | Condition: 80 | StringEquals: 81 | kms:EncryptionContext:to: 82 | - !Ref FunctionKmsAuthName 83 | KeyAlias: 84 | Type: AWS::KMS::Alias 85 | Properties: 86 | TargetKeyId: !Ref Key 87 | AliasName: !Ref KeyAliasName 88 | Function: 89 | Type: AWS::Lambda::Function 90 | Properties: 91 | Handler: lambda_linux_amd64 92 | FunctionName: !Ref FunctionName 93 | Role: !GetAtt LambdaRole.Arn 94 | Runtime: go1.x 95 | Timeout: 60 96 | Code: 97 | S3Bucket: !Ref S3Bucket 98 | S3Key: !Ref S3Key 99 | S3ObjectVersion: !Ref S3ObjectVersion 100 | Environment: 101 | Variables: 102 | KMS_KEY_ID: !GetAtt Key.Arn 103 | KMS_TOKEN_IDENTITY: !Ref FunctionKmsAuthName 104 | VALIDITY_DURATION: 900 105 | PSTORE_CA_KEY_BYTES: !Ref PstoreCAKeyBytesName 106 | AUTHORIZATION_LAMBDA: !Ref AuthorizationFunction 107 | LambdaRole: 108 | Type: AWS::IAM::Role 109 | Properties: 110 | AssumeRolePolicyDocument: 111 | Version: '2012-10-17' 112 | Statement: 113 | - Effect: Allow 114 | Principal: 115 | Service: 116 | - lambda.amazonaws.com 117 | Action: 118 | - sts:AssumeRole 119 | Path: "/" 120 | Policies: [] 121 | Policy: # needed to avoid circular dependency between function, role and key 122 | Type: AWS::IAM::Policy 123 | Properties: 124 | Roles: [!Ref LambdaRole] 125 | PolicyName: root 126 | PolicyDocument: 127 | Version: '2012-10-17' 128 | Statement: 129 | - Effect: Allow 130 | Action: 131 | - logs:CreateLogGroup 132 | - logs:CreateLogStream 133 | - logs:PutLogEvents 134 | Resource: arn:aws:logs:*:*:* 135 | - Effect: Allow 136 | Action: ssm:GetParameters 137 | Resource: "*" 138 | - Effect: Allow 139 | Action: kms:Decrypt 140 | Resource: "*" 141 | - Effect: Allow 142 | Action: lambda:InvokeFunction 143 | Resource: !GetAtt AuthorizationFunction.Arn 144 | - Effect: Allow 145 | Action: kms:Describe* 146 | Resource: !GetAtt Key.Arn 147 | AuthorizationFunction: 148 | Type: AWS::Lambda::Function 149 | Properties: 150 | Handler: index.handler 151 | FunctionName: !Sub ${FunctionName}-Authorizer 152 | Role: !GetAtt LambdaRole.Arn 153 | Runtime: nodejs6.10 154 | Timeout: 60 155 | Environment: 156 | Variables: 157 | JUMPBOX_DNS: !Ref JumpboxDns 158 | Code: 159 | ZipFile: | 160 | exports.handler = function(event, context, callback) { 161 | console.log(JSON.stringify(event)); 162 | var cb = function(err, resp) { 163 | console.log(JSON.stringify(resp)); 164 | callback(err, resp); 165 | } 166 | if (event.Kind === "LkpHostCertAuthorizationRequest") { 167 | cb(null, { 168 | authorized: true, 169 | principals: event.Principals 170 | }); 171 | return; 172 | } 173 | if (event.RemoteInstanceArn == "abcdef") { 174 | cb(null, { 175 | authorized: true, 176 | principals: [event.RemoteInstanceArn] 177 | }); 178 | } else if (event.Vouchers && event.Vouchers.length > 0) { 179 | cb(null, { 180 | authorized: true, 181 | principals: [event.RemoteInstanceArn, process.env.JUMPBOX_DNS], 182 | jumpboxes: [{ 183 | address: process.env.JUMPBOX_DNS, 184 | user: event.Vouchers[0].Name // really just for testing purposes 185 | }] 186 | }); 187 | } else { 188 | cb(null, { 189 | authorized: true, 190 | principals: [event.RemoteInstanceArn, process.env.JUMPBOX_DNS], 191 | jumpboxes: [{ 192 | address: process.env.JUMPBOX_DNS, 193 | user: "ec2-user" 194 | }], 195 | TargetAddress: "78.65.43.21" 196 | }); 197 | } 198 | }; 199 | Outputs: 200 | FunctionArn: 201 | Value: !GetAtt Function.Arn 202 | KeyArn: 203 | Value: !GetAtt Key.Arn 204 | -------------------------------------------------------------------------------- /ci/e2e.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export PATH=$PATH:$HOME/.local/bin # for awscli 4 | 5 | set -euxo pipefail 6 | 7 | S3_BUCKET=lkp-lambda-test 8 | S3_KEY=handler.zip 9 | 10 | aws s3 cp handler.zip s3://$S3_BUCKET/$S3_KEY 11 | S3_OBJVER=$(aws s3api head-object --bucket $S3_BUCKET --key $S3_KEY --query VersionId --output text) 12 | 13 | ./dl/stackit up \ 14 | --stack-name lkp-lambda-test \ 15 | --template ci/cfn.yml \ 16 | --previous-param-value PstoreCAKeyBytesName \ 17 | --previous-param-value S3Bucket \ 18 | --previous-param-value S3Key \ 19 | --previous-param-value AllowedAwsAccounts \ 20 | --previous-param-value JumpboxDns \ 21 | --param-value S3ObjectVersion=$S3_OBJVER 22 | 23 | ./dl/sshello & 24 | sleep 1 25 | ./lkp_linux_amd64 ssh exec --kms-key $AWS_ACCOUNT_ID:alias/LastKeypair --instance-arn abcdef -- -o StrictHostKeyChecking=no -o LogLevel=QUIET -p 2222 -o HostName=localhost travis@target | tee out.log 26 | diff -w out.log ci/expected-output.txt 27 | 28 | ./lkp_linux_amd64 ssh exec --instance-arn defghi --dry-run -- -o StrictHostKeyChecking=no -o LogLevel=QUIET -p 2222 | tee out.log 29 | diff -w out.log ci/expected-output-jumpbox.txt 30 | diff -w ~/.lkp/tmp/defghi/sshconf ci/expected-output-jumpbox-sshconf.txt 31 | 32 | VOUCHER=$(./lkp_linux_amd64 adv vouch --vouchee aidan --context moo) 33 | ./lkp_linux_amd64 ssh exec --instance-arn defghi --voucher $VOUCHER --dry-run -- -o StrictHostKeyChecking=no -o LogLevel=QUIET -p 2222 travis@localhost | tee out.log 34 | diff -w out.log ci/expected-output-vouched.txt 35 | 36 | # openssl aes-256-cbc -pass "pass:$CI_SSH_PASSPHRASE" -in ci/ec2-ssh-key.enc -out ci/ec2-ssh-key -d -a 37 | # chmod 0600 ci/ec2-ssh-key 38 | # cat ci/ec2-ssh-host-key >> ~/.ssh/known_hosts 39 | 40 | # scp -i ci/ec2-ssh-key lkp_linux_amd64 ec2-user@$GE_CI_HOST:~/ 41 | 42 | # ssh -T -i ci/ec2-ssh-key ec2-user@$GE_CI_HOST << 'ENDSSH' | tee out.log 43 | # rm -rf lkp-ci 44 | # mkdir lkp-ci 45 | 46 | # cp /etc/ssh/ssh_host_rsa_key.pub lkp-ci/ssh_host_rsa_key.pub 47 | 48 | # touch \ 49 | # lkp-ci/authorized_principals \ 50 | # lkp-ci/cert_authority.pub \ 51 | # lkp-ci/ssh_host_rsa_key-cert.pub \ 52 | # lkp-ci/sshd_config 53 | 54 | # ./lkp_linux_amd64 \ 55 | # host \ 56 | # --authorized-principals-path lkp-ci/authorized_principals \ 57 | # --cert-authority-path lkp-ci/cert_authority.pub \ 58 | # --host-key-path lkp-ci/ssh_host_rsa_key.pub \ 59 | # --signed-host-key-path lkp-ci/ssh_host_rsa_key-cert.pub \ 60 | # --sshd-config-path lkp-ci/sshd_config 61 | 62 | # cat lkp-ci/authorized_principals 63 | # cat lkp-ci/sshd_config 64 | # ssh-keygen -Lf lkp-ci/ssh_host_rsa_key-cert.pub 65 | # ENDSSH 66 | # diff -I 'Valid: after' out.log ci/expected-output-host.txt 67 | -------------------------------------------------------------------------------- /ci/ec2-ssh-host-key: -------------------------------------------------------------------------------- 1 | 13.54.10.178 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDu0tPDGIF6ZVCIzRpSpQEfV7heE8FoC8Ge71xEWDa+Z3x154Xhm5IFr9EoO8St8ExVhw3WkY92N0E2o1ePG4gA= 2 | -------------------------------------------------------------------------------- /ci/ec2-ssh-key.enc: -------------------------------------------------------------------------------- 1 | U2FsdGVkX18DuBla6PD2jU84bbLl7sBplrqUn1MokiPGWBiYr6yu4ghYTcOIq8g1 2 | JHrtD/AVmFaSgStVvhhBYvfH+LyLvixiFpBBSoh+klonnr1Tq0pTAgMmDIVku5My 3 | kvJh8K1WN08AhrQCivnsD64Nz1lS6M8qzLW7FjgWltg1eCiW6MwTKRUyC549oo73 4 | yVSj8ak7Aa+PM22affqhob9/mmvQXz31yc6GUKerIDnAvx8uC8FesVgxa+NQxico 5 | 3pmGKLlXGO6EOU1BEIF+cwaaS0Hx7sOKS4LCifnFmQLcRTzASFa0Ec+ZuPnyIC6I 6 | 6zr7cmTKWc68CzjyJ8B4uM+6pZP2/BtInHiLu69wOyyOQuV8AHwyZBR8/KXOJT92 7 | fGL59cRocy7debMoInnTicjotNO6yvTsgo8GO/rSoiPJJQCroKNONDBLQzFKFrwY 8 | dTaUsvxZchGxkIF/T1AVcuvhGL5cRSeTeFhPE3duBSLoJR5+0lhHJo2RU6uGkiRZ 9 | o+dqMB066yF24FmJCGouMsCUm7EGJE0FM2uwWlpzTFa/fTwqF+aNgqaPDLc+VbX8 10 | 3tesbwMGERw0GBlLCkoh2PzkTcp3khKJJjOvjSr1hI4ZbNztCTga6/61Q+P8Oux3 11 | j/4Z+olIuAmHJKI78cbcSfCqCAy3s66JE56UUAV9uQZm7Xrlk3bpLp1ILxxIwVU8 12 | R2ndghh2NK4tfXAzZqVngOLIgnjeDUYPjYezj1sh8aPwV7Uh2Tgi6wFA7FR3KdbS 13 | 4t91UdYBN2Is/UKmFWqSXucFf00Lgb6+Q3F++lcDgPlWfmyWv2bldQHi4epjYEde 14 | Jxw2lqAhhJJLq6LumZ4CDaQHOphJ/i649qn81lEvu7MA8Z3Gy5flSkqAzELgPlnj 15 | TekMYr6oIbf8kY9CWM7GqSngeIJk/8viSL91CQMR3jnTP6Bw9LLQWbnxKNxR8o0l 16 | iNQEk0tUsfZfIyxxidfyb0APf+aXUduRPrpJUCeL4PeknRYMcwoHYTHl13xjVuIc 17 | Uj/2VVp6vwPKSo5NYa699gap/k+hSdK7L5XB3UJiLB3z9cO35kFCOKLsP21XFns/ 18 | NlGQGOKwAV9BWbA+ZrmEhvVA2ikjUkYTvXKT5b3MgXRucz78sj91KIthwiaQgtrn 19 | xEuiB3SUBJzf2npiZs26lrQTQwRlkVeYvOzP8BHabKglFCUZzdB7/zbchjF1jNZA 20 | 5S30thOosipldHENH+edx4KpZAS+R1RBYl3Jp73sglDFGwyS9AqTacsSEzfxBahJ 21 | GIOzmlmDecDXLnDlJKl+QV1RagH0kJwR2NCbgAF4mWORWSC51lVlq6sfYsgjai2s 22 | 99SFAc9S4rKLB3C4iCEkUuIZ+ommpCPVycZVm1kKRMmMFFUQq32i1mAhN9dyJ9SD 23 | fUzx0bvVXIrm6v2l6723Wi/5yzkIcflD3TirlVdMgplHM5NSRMrl86tF/x506TaJ 24 | 0vXUL/vFQKi9ZIm51HDrgwpqVOQk8SqnjOJHaPiDc8n/MvB7zEquTDy9GahdWYpk 25 | GBrgRidv209VzRd7DPs+62tx0y6ZawilxrO3WrtC+K4HlaoKw0JR6V6niMWPzV60 26 | f/iMvTA6VNomdii6b/K4SMhS4pCxUtb/aTL6BVR6epWPMabfLsxjO27UDnR8w9k5 27 | A7a0bk4RijJDgRyngWWVICcukX0yT8KmSwe78cgpumkFkve5mBxdubJLZR0/Txli 28 | EiH/PE/aUz/mssYF1qxI7v9lfE8JsTdut8KruuNG/16l9MEFQVxGni1jYaY3q2AY 29 | 3D+JDlkxbW0E7mJvrdiWnFMlYAekPfUpQXBPD/2HOUHYwO/Xa3xOuGDzfLUBtjEx 30 | W3in2NwqrbmUyfykCpEodkYILQTjNDw0zDTTKZC6ms2xnFldCCKJ3QgjqxDj7p/z 31 | 6PsclUSGUzAfXxYxaWTXGd2ruEfZPew+5rbxsJ/t2aabsEZaS3VQ1KdbVw+ywD6F 32 | cGQ7ak/s3IrDIpkf1vzzCXPpxkl22/qcCmFrmT2BOfMvDQ4DSKgm2+vdvM51/OLP 33 | PthBxINGnOLMPV0m4RXmqnpR1oW0v77vJ2RsL7uxd2hGrtfS8SM+aP+KIFnx+Cha 34 | jAmnndP1avAQKJ793rzC7CFe5Icy6Ec5eZLLsVXCBmMCD/g0Lh1boyyUzMh6djQB 35 | EipriqvvC6El2iM240b8iR6KW81RSg4aWsX2Oh2HuTu8gmo0av1AmxKd7nUAARBu 36 | tsF72bu1/WPgWKo9KB7U6Q== 37 | -------------------------------------------------------------------------------- /ci/ec2-ssh-key.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDB2JPTpisivAo/ZtU93pmXeRBCqPYfQsVq1Feqvkjvc/O3zPaM9zpK4sBo66qZ88E3wWR3Vq+DpuHP6Uql8QTWx6wOi89zB1fYxizqXd0AIplq3kCbs7mQZaJVUmosbbtYAK2Ooc6DczThGK1F8Wee/mPAQFbYwcD6Qktd/Vj+0Zd+W+4+hDwl078JUbD0g9oiCo96H1Qr9AWhW6OvHKsE3loUxoacG8LHtVD17NstB7AmnHXuRw8e6upUfqs91TsVtWZ/klP6o87hnX9k0IERqlWDkXKBf1dcnHh4opVzJhNSAWWcoiUNV4Vf1DRiEUP/7Q+OhPpsvzFRftG+O6rl aidan@MacBookPro2017.local 2 | -------------------------------------------------------------------------------- /ci/expected-output-host.txt: -------------------------------------------------------------------------------- 1 | arn:aws:ec2:ap-southeast-2:607481581596:instance/i-04ff400e21aab5391 2 | 3 | HostCertificate /home/ec2-user/lkp-ci/ssh_host_rsa_key-cert.pub 4 | TrustedUserCAKeys /home/ec2-user/lkp-ci/cert_authority.pub 5 | AuthorizedPrincipalsFile /home/ec2-user/lkp-ci/authorized_principals 6 | lkp-ci/ssh_host_rsa_key-cert.pub: 7 | Type: ssh-rsa-cert-v01@openssh.com host certificate 8 | Public key: RSA-CERT a1:16:43:c3:4e:58:cd:72:b8:31:9d:d4:48:24:d3:7d 9 | Signing CA: RSA de:d2:a2:2b:2d:e1:25:ce:17:0b:6b:de:28:91:99:cb 10 | Key ID: "arn:aws:ec2:ap-southeast-2:607481581596:instance/i-04ff400e21aab5391" 11 | Serial: 0 12 | Valid: after 2017-12-16T22:39:28 13 | Principals: 14 | arn:aws:ec2:ap-southeast-2:607481581596:instance/i-04ff400e21aab5391 15 | Critical Options: (none) 16 | Extensions: (none) 17 | -------------------------------------------------------------------------------- /ci/expected-output-jumpbox-sshconf.txt: -------------------------------------------------------------------------------- 1 | IgnoreUnknown CertificateFile 2 | 3 | Host jump0 4 | HostName 12.34.56.78 5 | HostKeyAlias 12.34.56.78 6 | IdentityFile /home/travis/.lkp/id_rsa 7 | CertificateFile /home/travis/.lkp/tmp/12.34.56.78/id_rsa-cert.pub 8 | User ec2-user 9 | 10 | Host target 11 | HostKeyAlias defghi 12 | IdentityFile /home/travis/.lkp/id_rsa 13 | CertificateFile /home/travis/.lkp/id_rsa-cert.pub 14 | User ec2-user 15 | HostName 78.65.43.21 16 | ProxyJump jump0 17 | 18 | -------------------------------------------------------------------------------- /ci/expected-output-jumpbox.txt: -------------------------------------------------------------------------------- 1 | ssh -F /home/travis/.lkp/tmp/defghi/sshconf -o StrictHostKeyChecking=no -o LogLevel=QUIET -p 2222 target 2 | -------------------------------------------------------------------------------- /ci/expected-output-vouched.txt: -------------------------------------------------------------------------------- 1 | ssh -F /home/travis/.lkp/tmp/defghi/sshconf -o StrictHostKeyChecking=no -o LogLevel=QUIET -p 2222 travis@localhost 2 | -------------------------------------------------------------------------------- /ci/expected-output.txt: -------------------------------------------------------------------------------- 1 | (main.PrintableCertFields) { 2 | Serial: (uint64) 0, 3 | CertType: (uint32) 1, 4 | KeyId: (string) (len=62) "lkp-travis-user-TravisUser-1ILBFVUOPN36N-AIDAJZF7RF5JNJR5CBDIE", 5 | ValidPrincipals: ([]string) (len=1 cap=1) { 6 | (string) (len=6) "abcdef" 7 | }, 8 | Permissions: (ssh.Permissions) { 9 | CriticalOptions: (map[string]string) { 10 | }, 11 | Extensions: (map[string]string) (len=5) { 12 | (string) (len=21) "permit-X11-forwarding": (string) "", 13 | (string) (len=23) "permit-agent-forwarding": (string) "", 14 | (string) (len=22) "permit-port-forwarding": (string) "", 15 | (string) (len=10) "permit-pty": (string) "", 16 | (string) (len=14) "permit-user-rc": (string) "" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ci/setup_env.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | go get github.com/mitchellh/gox 5 | 6 | curl -z dl/upx.txz -o dl/upx.txz -L https://github.com/upx/upx/releases/download/v3.94/upx-3.94-amd64_linux.tar.xz 7 | tar -xvf dl/upx.txz 8 | 9 | curl -z dl/stackit -o dl/stackit -L https://github.com/glassechidna/stackit/releases/download/0.0.9/stackit_linux_amd64 10 | chmod +x dl/stackit 11 | 12 | curl -z dl/sshello -o dl/sshello -L https://github.com/glassechidna/sshello/releases/download/0.0.1/sshello_linux_amd64 13 | chmod +x dl/sshello 14 | 15 | pip install --user awscli 16 | -------------------------------------------------------------------------------- /ci/travis-user.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Parameters: 3 | S3BucketName: 4 | Type: String 5 | Default: lkp-lambda-test 6 | S3KeyName: 7 | Type: String 8 | Default: handler.zip 9 | LambdaStackArn: 10 | Type: String 11 | Default: arn:aws:cloudformation:ap-southeast-2:607481581596:stack/lkp-lambda-test 12 | FunctionArn: 13 | Type: String 14 | Default: arn:aws:lambda:ap-southeast-2:607481581596:function:LastKeypair 15 | LambdaRoleArn: 16 | Type: String 17 | Default: arn:aws:iam::607481581596:role/lkp-lambda-test-LambdaRole-19I38JO49J7JM 18 | KeyArn: 19 | Type: String 20 | Default: arn:aws:kms:ap-southeast-2:607481581596:key/d2157914-fb4d-4c39-ae3e-f5b35def0d21 21 | Resources: 22 | TravisUser: 23 | Type: AWS::IAM::User 24 | Properties: 25 | Policies: 26 | - PolicyName: user 27 | PolicyDocument: 28 | Version: "2012-10-17" 29 | Statement: 30 | - Effect: Allow 31 | Action: 32 | - s3:PutObject 33 | - s3:GetObject 34 | - s3:GetObjectVersion 35 | Resource: !Sub arn:aws:s3:::${S3BucketName}/${S3KeyName} 36 | - Effect: Allow 37 | Action: 38 | - cloudformation:UpdateStack 39 | - cloudformation:CreateStack 40 | - cloudformation:DescribeStackEvents 41 | - cloudformation:DescribeStacks 42 | Resource: !Sub ${LambdaStackArn}/* 43 | - Effect: Allow 44 | Action: iam:GetRole 45 | Resource: !Ref LambdaRoleArn 46 | - Effect: Allow 47 | Action: 48 | - lambda:UpdateFunctionCode 49 | - lambda:GetFunctionConfiguration 50 | - lambda:InvokeFunction 51 | Resource: !Ref FunctionArn 52 | - Effect: Allow 53 | Action: 54 | - kms:DescribeKey # for cfn.yml stack updates 55 | Resource: !Ref KeyArn 56 | - Effect: Allow 57 | Action: sts:GetCallerIdentity 58 | Resource: "*" 59 | Outputs: 60 | UserName: 61 | Value: !Ref TravisUser 62 | -------------------------------------------------------------------------------- /cmd/lambda/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/lambda" 5 | "encoding/json" 6 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 7 | ) 8 | 9 | func HandleLambdaEvent(event json.RawMessage) (interface{}, error) { 10 | return lastkeypair.LambdaHandle(event) 11 | } 12 | 13 | func main() { 14 | lambda.Start(HandleLambdaEvent) 15 | } 16 | -------------------------------------------------------------------------------- /cmd/lkp/adv.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var advCmd = &cobra.Command{ 8 | Use: "adv", 9 | Short: "A grab bag of commands useful to the lkp developer :)", 10 | Run: func(cmd *cobra.Command, args []string) { 11 | }, 12 | } 13 | 14 | func init() { 15 | RootCmd.AddCommand(advCmd) 16 | } 17 | -------------------------------------------------------------------------------- /cmd/lkp/host.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "io/ioutil" 8 | "github.com/pkg/errors" 9 | "github.com/aws/aws-sdk-go/aws/ec2metadata" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 12 | "log" 13 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 14 | "github.com/aws/aws-sdk-go/aws" 15 | "os" 16 | "strings" 17 | "path/filepath" 18 | ) 19 | 20 | var hostCmd = &cobra.Command{ 21 | Use: "host", 22 | Short: "Create signed SSH host certificates", 23 | Long: ` 24 | A signed SSH host certificate means that users are able to log into a machine 25 | without seeing a host key validation prompt if their SSH client trusts the 26 | certificate authority that signed the host cert. 27 | 28 | This command can be invoked from an EC2 instance userdata script to request 29 | a signed SSH host cert and install it in the appropriate sshd config. 30 | `, 31 | Run: func(cmd *cobra.Command, args []string) { 32 | hostKeyPath, _ := cmd.PersistentFlags().GetString("host-key-path") 33 | signedHostKeyPath, _ := cmd.PersistentFlags().GetString("signed-host-key-path") 34 | caPubkeyPath, _ := cmd.PersistentFlags().GetString("cert-authority-path") 35 | sshdConfigPath, _ := cmd.PersistentFlags().GetString("sshd-config-path") 36 | authorizedPrincipalsPath, _ := cmd.PersistentFlags().GetString("authorized-principals-path") 37 | functionName, _ := cmd.PersistentFlags().GetString("lambda-func") 38 | kmsKeyId, _ := cmd.PersistentFlags().GetString("kms-key") 39 | principals, _ := cmd.PersistentFlags().GetStringSlice("principal") 40 | 41 | err := doit(hostKeyPath, signedHostKeyPath, caPubkeyPath, sshdConfigPath, authorizedPrincipalsPath, functionName, kmsKeyId, principals) 42 | if err != nil { 43 | log.Panicf("err: %s\n", err.Error()) 44 | } 45 | }, 46 | } 47 | 48 | func hostSession() (*session.Session, error) { 49 | sessOpts := session.Options{ 50 | SharedConfigState: session.SharedConfigEnable, 51 | AssumeRoleTokenProvider: stscreds.StdinTokenProvider, 52 | } 53 | 54 | sess, err := session.NewSessionWithOptions(sessOpts) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "creating aws session") 57 | } 58 | 59 | client := ec2metadata.New(sess) 60 | if client.Available() { 61 | region, err := client.Region() 62 | if err != nil { 63 | return nil, errors.Wrap(err, "getting region from ec2 metadata") 64 | } 65 | sess = sess.Copy(aws.NewConfig().WithRegion(region)) 66 | } 67 | 68 | return sess, nil 69 | } 70 | 71 | func doit(hostKeyPath, signedHostKeyPath, caPubkeyPath, sshdConfigPath, authorizedPrincipalsPath, functionName, kmsKeyId string, principals []string) error { 72 | // we absolute-ize these paths because ssh requires paths in sshd_config to be absolute 73 | authorizedPrincipalsPath, _ = filepath.Abs(authorizedPrincipalsPath) 74 | caPubkeyPath, _ = filepath.Abs(caPubkeyPath) 75 | signedHostKeyPath, _ = filepath.Abs(signedHostKeyPath) 76 | 77 | 78 | hostKeyBytes, err := ioutil.ReadFile(hostKeyPath) 79 | if err != nil { 80 | return errors.Wrap(err, "reading ssh host key") 81 | } 82 | hostKey := string(hostKeyBytes) 83 | 84 | sess, err := hostSession() 85 | client := ec2metadata.New(sess) 86 | 87 | ident, err := lastkeypair.CallerIdentityUser(sess) 88 | instanceArn, err := getInstanceArn(client) 89 | if err != nil { 90 | return errors.Wrap(err, "fetching instance arn from metadata service") 91 | } 92 | 93 | principals = append(principals, *instanceArn) 94 | token, err := hostCertToken(sess, *ident, kmsKeyId, *instanceArn, principals) 95 | 96 | caPubkey, err := client.GetMetadata("public-keys/0/openssh-key") 97 | if err != nil { 98 | return errors.Wrap(err, "fetching ssh CA key") 99 | } 100 | 101 | response := lastkeypair.HostCertRespJson{} 102 | err = lastkeypair.RequestSignedPayload(sess, functionName, lastkeypair.HostCertReqJson{ 103 | EventType: "HostCertReq", 104 | Token: *token, 105 | PublicKey: hostKey, 106 | }, &response) 107 | if err != nil { 108 | return errors.Wrap(err, "requesting signed host key") 109 | } 110 | 111 | err = ioutil.WriteFile(signedHostKeyPath, []byte(response.SignedHostPublicKey), 0600) 112 | if err != nil { 113 | return errors.Wrap(err, "writing signed host key to filesystem") 114 | } 115 | 116 | err = ioutil.WriteFile(caPubkeyPath, []byte(caPubkey), 0600) 117 | if err != nil { 118 | return errors.Wrap(err, "writing ca pubkey to filesystem") 119 | } 120 | 121 | authorizedPrincipalsBytes := []byte(fmt.Sprintf("%s\n", strings.Join(principals, "\n"))) 122 | 123 | err = ioutil.WriteFile(authorizedPrincipalsPath, authorizedPrincipalsBytes, 0444) 124 | if err != nil { 125 | return errors.Wrap(err, "writing authorized principals to filesystem") 126 | } 127 | 128 | err = appendToFile(sshdConfigPath, fmt.Sprintf(` 129 | HostCertificate %s 130 | TrustedUserCAKeys %s 131 | AuthorizedPrincipalsFile %s 132 | `, signedHostKeyPath, caPubkeyPath, authorizedPrincipalsPath)) 133 | if err != nil { 134 | return errors.Wrap(err, "appending to sshd config") 135 | } 136 | 137 | return nil 138 | } 139 | 140 | func getInstanceArn(client *ec2metadata.EC2Metadata) (*string, error) { 141 | region, err := client.Region() 142 | if err != nil { 143 | return nil, errors.Wrap(err, "getting region") 144 | } 145 | 146 | ident, err := client.GetInstanceIdentityDocument() 147 | if err != nil { 148 | return nil, errors.Wrap(err, "getting identity doc for account id and instance id") 149 | } 150 | 151 | ret := fmt.Sprintf("arn:aws:ec2:%s:%s:instance/%s", region, ident.AccountID, ident.InstanceID) 152 | return &ret, nil 153 | } 154 | 155 | func hostCertToken(sess *session.Session, ident lastkeypair.StsIdentity, kmsKeyId, instanceArn string, principals []string) (*lastkeypair.Token, error) { 156 | params := lastkeypair.TokenParams{ 157 | FromId: ident.UserId, 158 | FromAccount: ident.AccountId, 159 | To: "LastKeypair", 160 | Type: "AssumedRole", 161 | HostInstanceArn: instanceArn, 162 | Principals: principals, 163 | } 164 | 165 | ret := lastkeypair.CreateToken(sess, params, kmsKeyId) 166 | return &ret, nil 167 | } 168 | 169 | func appendToFile(path, text string) error { 170 | f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, os.ModeAppend) 171 | if err != nil { 172 | return err 173 | } 174 | defer f.Close() 175 | 176 | _, err = f.WriteString(text) 177 | if err != nil { 178 | return err 179 | } 180 | return nil 181 | } 182 | 183 | func init() { 184 | RootCmd.AddCommand(hostCmd) 185 | 186 | hostCmd.PersistentFlags().String("host-key-path", "/etc/ssh/ssh_host_rsa_key.pub", "") 187 | hostCmd.PersistentFlags().String("signed-host-key-path", "/etc/ssh/ssh_host_rsa_key-cert.pub", "") 188 | hostCmd.PersistentFlags().String("cert-authority-path", "/etc/ssh/cert_authority.pub", "") 189 | hostCmd.PersistentFlags().String("authorized-principals-path", "/etc/ssh/authorized_principals", "") 190 | hostCmd.PersistentFlags().String("sshd-config-path", "/etc/ssh/sshd_config", "") 191 | hostCmd.PersistentFlags().String("lambda-func", "LastKeypair", "") 192 | hostCmd.PersistentFlags().StringSlice("principal", []string{""}, "Additional principals to request from CA") 193 | hostCmd.PersistentFlags().String("kms-key", "alias/LastKeypair", "ID, ARN or alias of KMS key for auth to CA") 194 | } 195 | -------------------------------------------------------------------------------- /cmd/lkp/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | Execute() 5 | } 6 | -------------------------------------------------------------------------------- /cmd/lkp/mfa.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | "github.com/glassechidna/awscredcache" 9 | "github.com/aws/aws-sdk-go/aws/credentials" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/aws" 12 | "time" 13 | "github.com/aws/aws-sdk-go/service/sts" 14 | "github.com/pquerna/otp/totp" 15 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 16 | ) 17 | 18 | var mfaCmd = &cobra.Command{ 19 | Use: "mfa", 20 | Short: "MFA-authenticate your AWS credentials before SSHing", 21 | Long: ` 22 | Some AWS account administrators may require users to authenticate using MFA 23 | in order to SSH into an instance. SSH doesn't support interactive helper tools, 24 | so you have to type in your code using this command before you can SSH like normal. 25 | `, 26 | Run: func(cmd *cobra.Command, args []string) { 27 | code, _ := cmd.Flags().GetString("code") 28 | duration, _ := cmd.Flags().GetInt("duration") 29 | profile := viper.GetString("profile") 30 | mfa(profile, code, duration) 31 | }, 32 | } 33 | 34 | func mfa(profile, code string, duration int) { 35 | provider := awscredcache.NewAwsCacheCredProvider(profile) 36 | provider.Duration = time.Duration(duration) * time.Second 37 | 38 | provider.MfaCodeProvider = func(mfaSecret string) (string, error) { 39 | if len(mfaSecret) > 0 { 40 | return totp.GenerateCode(mfaSecret, time.Now()) 41 | } else if len(code) > 0 { 42 | return code, nil 43 | } else { 44 | return stscreds.StdinTokenProvider() 45 | } 46 | } 47 | 48 | creds := credentials.NewCredentials(provider) 49 | 50 | sess, err := session.NewSession(&aws.Config{Credentials: creds}) 51 | if err != nil { panic(err) } 52 | 53 | api := sts.New(sess) 54 | resp, err := api.GetCallerIdentity(&sts.GetCallerIdentityInput{}) 55 | if err != nil { panic(err) } 56 | 57 | fmt.Printf("Successfully authenticated as %s\n", *resp.Arn) 58 | } 59 | 60 | func init() { 61 | RootCmd.AddCommand(mfaCmd) 62 | mfaCmd.Flags().StringP("code", "c", "", "6 digit MFA code") 63 | mfaCmd.Flags().IntP("duration", "d", 43200, "Validity of cached credentials (in seconds)") 64 | viper.BindPFlags(mfaCmd.PersistentFlags()) 65 | } 66 | -------------------------------------------------------------------------------- /cmd/lkp/root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/viper" 6 | "github.com/inconshreveable/mousetrap" 7 | "os" 8 | "github.com/mitchellh/go-homedir" 9 | "fmt" 10 | ) 11 | 12 | var cfgFile string 13 | 14 | var RootCmd = &cobra.Command{ 15 | Use: "lastkeypair", 16 | Short: "A serverless SSH certificate authority to control access to machines using IAM and Lambda", 17 | Long: ` 18 | lastkeypair is a CLI tool and a Lambda function to control remote access to 19 | instances through SSH certificates. User certificates are created on-demand and 20 | are specific to a user and target server. Host certificates are also created 21 | at instance launch time and allow for host validation without needing host key 22 | validation prompts. 23 | `, 24 | } 25 | 26 | func fileExists(path string) bool { 27 | if _, err := os.Stat(path); os.IsNotExist(err) { 28 | return false 29 | } 30 | return true 31 | } 32 | 33 | func Execute() { 34 | if mousetrap.StartedByExplorer() { 35 | configPath, _ := homedir.Expand("~/.lkp/config.yml") 36 | defer keepTerminalVisible() 37 | 38 | if !fileExists(configPath) { 39 | setup() 40 | } else { 41 | fmt.Println("LastKeypair is a command-line tool, you should invoke it from the command prompt or Powershell.") 42 | } 43 | } else { 44 | if err := RootCmd.Execute(); err != nil { 45 | 46 | } 47 | } 48 | } 49 | 50 | func init() { 51 | cobra.MousetrapHelpText = "" // we want to use mousetrap ourselves in setup 52 | cobra.OnInitialize(initConfig) 53 | 54 | RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.lkp/config.yml)") 55 | RootCmd.PersistentFlags().StringP("profile", "p", "", "Name of profile in ~/.aws/config to use") 56 | } 57 | 58 | func initConfig() { 59 | if cfgFile != "" { 60 | viper.SetConfigFile(cfgFile) 61 | } 62 | 63 | viper.SetConfigName("config") 64 | viper.AddConfigPath("$HOME/.lkp") 65 | viper.AutomaticEnv() 66 | 67 | if err := viper.ReadInConfig(); err == nil { 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /cmd/lkp/setup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "path" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "sort" 10 | "path/filepath" 11 | "github.com/spf13/cobra" 12 | "github.com/go-ini/ini" 13 | "github.com/mitchellh/go-homedir" 14 | "github.com/inconshreveable/mousetrap" 15 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 16 | "github.com/glassechidna/awscredcache/sneakyvendor/aws-shared-defaults" 17 | "github.com/AlecAivazis/survey" 18 | ) 19 | 20 | var setupCmd = &cobra.Command{ 21 | Use: "setup", 22 | Short: "First-time installation and setup", 23 | Run: func(cmd *cobra.Command, args []string) { 24 | setup() 25 | }, 26 | } 27 | 28 | func setup() { 29 | profile := selectAwsProfile() 30 | lambda := inputLambdaFunc() 31 | kms := inputKmsKey() 32 | writeLkpConfig(profile, lambda, kms) 33 | askUserAboutMfa(profile) 34 | writeSshConfig() 35 | addIncludeToSshConfig("~/.lkp/ssh_config") // openssh on windows doesn't like a non-relative path 36 | promptToAddToPath() 37 | informNextSteps() 38 | } 39 | 40 | func askUserAboutMfa(profile string) { 41 | prompt := &survey.Confirm{ 42 | Message: "Does your administrator require MFA to use LKP?", 43 | } 44 | 45 | mfaRequired := false 46 | survey.AskOne(prompt, &mfaRequired, nil) 47 | 48 | if mfaRequired { 49 | cfgPath := shareddefaults.SharedConfigFilename() 50 | cfg, _ := ini.Load(cfgPath) 51 | sect, err := cfg.GetSection(profile) 52 | if err != nil { 53 | sect, _ = cfg.GetSection("profile " + profile) 54 | } 55 | 56 | _, err = sect.GetKey("mfa_serial") 57 | if err == nil { 58 | fmt.Printf(` 59 | Before SSHing into instances, you should first execute 'lastkeypair mfa' 60 | and follow the prompts. 61 | `) 62 | } else { 63 | fmt.Printf(` 64 | You should add an mfa_serial = arn:aws:iam::XXXXXX:mfa/SERIAL line to your 65 | ~/.aws/config in the [%s] section in order to make MFA work. Before SSHing 66 | into instances, you should first execute 'lastkeypair mfa' and follow the 67 | prompts. 68 | `, sect.Name()) 69 | } 70 | } 71 | } 72 | 73 | func selectAwsProfile() string { 74 | profiles := awsProfileNames() 75 | 76 | prompt := &survey.Select{ 77 | Message: "Which AWS profile do you want to use with LKP by default?", 78 | Options: profiles, 79 | PageSize: 15, 80 | } 81 | 82 | result := "" 83 | err := survey.AskOne(prompt, &result, nil) 84 | if err != nil { 85 | panic(err) 86 | } 87 | 88 | return result 89 | } 90 | 91 | func inputLambdaFunc() string { 92 | prompt := &survey.Input{ 93 | Message: "What is the name/ARN of the LKP Lambda function?", 94 | Default: "LastKeypair", 95 | } 96 | 97 | result := "" 98 | err := survey.AskOne(prompt, &result, nil) 99 | if err != nil { 100 | panic(err) 101 | } 102 | 103 | return result 104 | } 105 | 106 | func inputKmsKey() string { 107 | prompt := &survey.Input{ 108 | Message: "What is the alias/key ID/ARN of the KMS key ID?", 109 | Default: "alias/LastKeypair", 110 | } 111 | 112 | result := "" 113 | err := survey.AskOne(prompt, &result, nil) 114 | if err != nil { 115 | panic(err) 116 | } 117 | 118 | return result 119 | } 120 | 121 | func writeLkpConfig(profile, lambda, kms string) { 122 | str := fmt.Sprintf(` 123 | profile: %s 124 | lambda-func: %s 125 | kms-key: %s 126 | `, profile, lambda, kms) 127 | 128 | ioutil.WriteFile(path.Join(lastkeypair.AppDir(), "config.yml"), []byte(str), 0644) 129 | } 130 | 131 | func writeSshConfig() string { 132 | str := fmt.Sprintf(` 133 | Match exec "lkp ssh match --instance-arn %%n --ssh-username %%r" 134 | IdentityFile %s/id_rsa 135 | CertificateFile %s/id_rsa-cert.pub 136 | ProxyCommand lkp ssh proxy --instance-arn %%h 137 | `, lastkeypair.AppDir(), lastkeypair.AppDir()) 138 | 139 | lkpSshConfigPath := path.Join(lastkeypair.AppDir(), "ssh_config") 140 | ioutil.WriteFile(lkpSshConfigPath, []byte(str), 0644) 141 | return lkpSshConfigPath 142 | } 143 | 144 | func addIncludeToSshConfig(path string) { 145 | sshConfigPath, _ := homedir.Expand("~/.ssh/config") 146 | sshConfigBytes, _ := ioutil.ReadFile(sshConfigPath) 147 | 148 | sshConfig := string(sshConfigBytes) 149 | sshConfig = fmt.Sprintf("Include %s\n\n%s", path, sshConfig) 150 | 151 | os.MkdirAll(filepath.Dir(sshConfigPath), 0644) 152 | ioutil.WriteFile(sshConfigPath, []byte(sshConfig), 0644) 153 | } 154 | 155 | func promptToAddToPath() { 156 | pathenv := os.Getenv("PATH") 157 | paths := filepath.SplitList(pathenv) 158 | sort.Strings(paths) 159 | joined := strings.Join(paths, "\n") 160 | 161 | fmt.Printf(` 162 | The last step is to now add LastKeypair to somewhere on your PATH. Consider 163 | one of the following directories already on your PATH: 164 | 165 | %s 166 | `, joined) 167 | 168 | } 169 | 170 | func keepTerminalVisible() { 171 | // terminal on macos stays open after exe ends. TODO check linux behaviour 172 | if mousetrap.StartedByExplorer() { 173 | var input string 174 | fmt.Scanln(&input) 175 | } 176 | } 177 | 178 | func informNextSteps() { 179 | fmt.Println(` 180 | Great work, you should be all set up now. Configuration files have been written 181 | to ~/.ssh/config and ~/.lkp/config.yml. You can now run 'ssh ec2-user@instance-arn' 182 | and hit the ground running.`) 183 | } 184 | 185 | func init() { 186 | RootCmd.AddCommand(setupCmd) 187 | } 188 | 189 | func awsProfileNames() []string { 190 | cfgPath := shareddefaults.SharedConfigFilename() 191 | cfg, err := ini.Load(cfgPath) 192 | 193 | if err != nil { 194 | fmt.Fprintf(os.Stderr, ` 195 | LastKeypair requires that you have a valid configuration file stored at %s. 196 | This file will look something like: 197 | 198 | [default] 199 | region = ap-southeast-2 200 | mfa_serial = arn:aws:iam::0987654321:mfa/aidan.steele@example.com 201 | 202 | You will also need a corresponding credentials file stored at %s with 203 | contents that look like: 204 | 205 | [default] 206 | aws_access_key_id = AKIA... 207 | aws_secret_access_key = qGrg.... 208 | 209 | LastKeypair will also work with named profiles if they are defined in your 210 | configuration file. 211 | 212 | Hit Enter now to close this prompt. After the above files are created, you 213 | can open LastKeypair again. 214 | `, cfgPath, shareddefaults.SharedCredentialsFilename()) 215 | } 216 | 217 | rawProfiles := cfg.SectionStrings() 218 | profiles := []string{} 219 | 220 | for _, profile := range rawProfiles { 221 | if strings.HasPrefix(profile, "profile ") { 222 | profiles = append(profiles, profile[8:]) 223 | } else if profile != "DEFAULT" { 224 | profiles = append(profiles, profile) 225 | } 226 | } 227 | 228 | sort.Strings(profiles) 229 | return profiles 230 | } 231 | -------------------------------------------------------------------------------- /cmd/lkp/ssh.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var sshCmd = &cobra.Command{ 8 | Use: "ssh", 9 | Short: "Integration with the client ssh program", 10 | } 11 | 12 | func init() { 13 | RootCmd.AddCommand(sshCmd) 14 | } 15 | -------------------------------------------------------------------------------- /cmd/lkp/sshExec.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 6 | "os/exec" 7 | "syscall" 8 | "os" 9 | "fmt" 10 | "strings" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | var sshExecCmd = &cobra.Command{ 15 | Use: "exec", 16 | Short: "A brief description of your command", 17 | Long: `A longer description that spans multiple lines and likely contains examples 18 | and usage of using your command. For example: 19 | 20 | Cobra is a CLI library for Go that empowers applications. 21 | This application is a tool to generate the needed files 22 | to quickly create a Cobra application.`, 23 | Run: func(cmd *cobra.Command, args []string) { 24 | rei := lastkeypair.NewReifiedLoginWithCmd(cmd, args) 25 | rei.PopulateByInvoke() 26 | 27 | sshconfPath := rei.WriteSshConfig() 28 | sshcmd := []string{"ssh", "-F", sshconfPath} 29 | sshcmd = append(sshcmd, args...) 30 | if len(rei.Response.TargetAddress) > 0 { 31 | sshcmd = append(sshcmd, "target") 32 | } 33 | 34 | dryRun, _ := cmd.PersistentFlags().GetBool("dry-run") 35 | if dryRun { 36 | fmt.Println(strings.Join(sshcmd, " ")) 37 | } else { 38 | sshPath, _ := exec.LookPath("ssh") 39 | syscall.Exec(sshPath, sshcmd, os.Environ()) 40 | } 41 | }, 42 | } 43 | 44 | func init() { 45 | sshCmd.AddCommand(sshExecCmd) 46 | 47 | sshExecCmd.PersistentFlags().String("lambda-func", "LastKeypair", "Function name or ARN") 48 | sshExecCmd.PersistentFlags().String("kms-key", "alias/LastKeypair", "ID, ARN or alias of KMS key for auth to CA") 49 | sshExecCmd.PersistentFlags().String("instance-arn", "", "") 50 | sshExecCmd.PersistentFlags().String("ssh-username", "ec2-user", "Username that you wish to SSH in with") 51 | sshExecCmd.PersistentFlags().StringSlice("voucher", []string{}, "Optional voucher(s) from other people") 52 | sshExecCmd.PersistentFlags().Bool("dry-run", false, "Do everything _except_ the SSH login") 53 | 54 | viper.BindPFlags(sshExecCmd.PersistentFlags()) 55 | } 56 | -------------------------------------------------------------------------------- /cmd/lkp/sshMatch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "os" 6 | "strings" 7 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 8 | ) 9 | 10 | var sshMatchCmd = &cobra.Command{ 11 | Use: "match", 12 | Short: "Internal command invoked by SSH client", 13 | Long: "`ssh` invokes this to determine if LKP should be used to login to a host", 14 | Run: func(cmd *cobra.Command, args []string) { 15 | rei := lastkeypair.NewReifiedLoginWithCmd(cmd, args) 16 | 17 | if !isLkpHost(rei.InstanceArn) { 18 | os.Exit(1) 19 | } else { 20 | rei.PopulateByInvoke() 21 | } 22 | }, 23 | } 24 | 25 | func isLkpHost(hostname string) bool { 26 | return strings.HasPrefix(hostname, "arn:aws:ec2") 27 | } 28 | 29 | func init() { 30 | sshCmd.AddCommand(sshMatchCmd) 31 | sshMatchCmd.PersistentFlags().String("instance-arn", "", "") 32 | sshMatchCmd.PersistentFlags().String("ssh-username", "ec2-user", "") 33 | } 34 | -------------------------------------------------------------------------------- /cmd/lkp/sshProxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "net" 6 | "github.com/spf13/cobra" 7 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 8 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair/netcat" 9 | "syscall" 10 | "os/exec" 11 | "fmt" 12 | ) 13 | 14 | var sshProxyCmd = &cobra.Command{ 15 | Use: "proxy", 16 | Short: "Internal command invoked by SSH client", 17 | Long: ` 18 | This is used by ssh as the SSH ProxyCommand in order to connect to EC2 instances 19 | by their instance ARN rather than IP address. 20 | `, 21 | Run: proxy, 22 | } 23 | 24 | func proxy(cmd *cobra.Command, args []string) { 25 | port, _ := cmd.PersistentFlags().GetString("port") 26 | 27 | rei := lastkeypair.NewReifiedLoginWithCmd(cmd, args) 28 | rei.PopulateByRestoreCache() 29 | 30 | jump := rei.Response.Jumpboxes 31 | if len(jump) == 0 { 32 | conn, _ := net.Dial("tcp", rei.Response.TargetAddress + ":" + port) 33 | netcat.TcpToPipes(conn, os.Stdin, os.Stdout) 34 | } else { 35 | sshconfPath := rei.WriteSshConfig() 36 | lastJumphost := fmt.Sprintf("jump%d", len(jump) - 1) 37 | sshcmd := []string{"ssh", "-F", sshconfPath, "-W", rei.Response.TargetAddress + ":22", lastJumphost} 38 | sshPath, _ := exec.LookPath("ssh") 39 | syscall.Exec(sshPath, sshcmd, os.Environ()) 40 | } 41 | } 42 | 43 | func init() { 44 | sshCmd.AddCommand(sshProxyCmd) 45 | sshProxyCmd.PersistentFlags().String("instance-arn", "", "Fully-specified EC2 instance ARN") 46 | sshProxyCmd.PersistentFlags().String("port", "22", "Remote SSH server port (default 22)") 47 | } 48 | -------------------------------------------------------------------------------- /cmd/lkp/sshSign.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "io/ioutil" 8 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 9 | "log" 10 | "golang.org/x/crypto/ssh" 11 | "time" 12 | ) 13 | 14 | var sshSignCmd = &cobra.Command{ 15 | Use: "ssh-sign", 16 | Short: "A brief description of your command", 17 | Long: `A longer description that spans multiple lines and likely contains examples 18 | and usage of using your command. For example: 19 | 20 | Cobra is a CLI library for Go that empowers applications. 21 | This application is a tool to generate the needed files 22 | to quickly create a Cobra application.`, 23 | Run: func(cmd *cobra.Command, args []string) { 24 | caKeyPath, _ := cmd.PersistentFlags().GetString("ca-key-path") 25 | caKeyPassphrase, _ := cmd.PersistentFlags().GetString("ca-key-passphrase") 26 | userKeyPath, _ := cmd.PersistentFlags().GetString("user-key-path") 27 | keyId, _ := cmd.PersistentFlags().GetString("key-id") 28 | duration, _ := cmd.PersistentFlags().GetInt64("duration") 29 | principals, _ := cmd.PersistentFlags().GetStringSlice("principals") 30 | 31 | keyBytes, _ := ioutil.ReadFile(caKeyPath) 32 | userPubkeyBytes, _ := ioutil.ReadFile(userKeyPath) 33 | 34 | formatted, err := lastkeypair.SignSsh( 35 | keyBytes, 36 | []byte(caKeyPassphrase), 37 | userPubkeyBytes, 38 | ssh.UserCert, 39 | uint64(time.Now().Unix() + duration), 40 | lastkeypair.DefaultSshPermissions, 41 | keyId, 42 | principals, 43 | ) 44 | 45 | if err != nil { 46 | log.Panicf("err signing ssh key: %s", err.Error()) 47 | } 48 | 49 | fmt.Println(*formatted) 50 | }, 51 | } 52 | 53 | func init() { 54 | advCmd.AddCommand(sshSignCmd) 55 | 56 | sshSignCmd.PersistentFlags().String("ca-key-path", "", "") 57 | sshSignCmd.PersistentFlags().String("ca-key-passphrase", "", "") 58 | sshSignCmd.PersistentFlags().String("user-key-path", "", "") 59 | sshSignCmd.PersistentFlags().String("key-id", "", "") 60 | sshSignCmd.PersistentFlags().Int64("duration", 3600, "") 61 | sshSignCmd.PersistentFlags().StringSlice("principals", []string{}, "") 62 | } 63 | -------------------------------------------------------------------------------- /cmd/lkp/tokenCreate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/viper" 6 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 7 | "log" 8 | "fmt" 9 | "encoding/json" 10 | ) 11 | 12 | var tokenCreateCmd = &cobra.Command{ 13 | Use: "token-create", 14 | Short: "A brief description of your command", 15 | Long: `A longer description that spans multiple lines and likely contains examples 16 | and usage of using your command. For example: 17 | 18 | Cobra is a CLI library for Go that empowers applications. 19 | This application is a tool to generate the needed files 20 | to quickly create a Cobra application.`, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | profile := viper.GetString("profile") 23 | region := viper.GetString("region") 24 | sess := lastkeypair.ClientAwsSession(profile, region) 25 | 26 | key := viper.GetString("kms-key") 27 | fromName := viper.GetString("from-name") 28 | fromId := viper.GetString("from-id") 29 | fromAcct := viper.GetString("from-account") 30 | to := viper.GetString("to") 31 | typ := viper.GetString("principal") 32 | 33 | params := lastkeypair.TokenParams{ 34 | FromId: fromId, 35 | FromName: fromName, 36 | FromAccount: fromAcct, 37 | To: to, 38 | Type: typ, 39 | } 40 | 41 | ident, err := lastkeypair.CallerIdentityUser(sess) 42 | if err != nil { 43 | log.Panicf("No 'from' specified and could not determine caller identity: %s", err.Error()) 44 | } 45 | 46 | if len(params.FromName) == 0 { 47 | params.FromName = ident.Username 48 | } 49 | if len(params.FromAccount) == 0 { 50 | params.FromAccount = ident.AccountId 51 | } 52 | if len(params.FromId) == 0 { 53 | params.FromId = ident.UserId 54 | } 55 | 56 | token := lastkeypair.CreateToken(sess, params, key) 57 | jsonToken, _ := json.Marshal(token) 58 | fmt.Println(string(jsonToken)) 59 | }, 60 | } 61 | 62 | func init() { 63 | advCmd.AddCommand(tokenCreateCmd) 64 | 65 | // profile is at root level 66 | tokenCreateCmd.PersistentFlags().String("region", "", "") 67 | 68 | tokenCreateCmd.PersistentFlags().String("kms-key", "alias/LastKeypair", "ID, ARN or alias of KMS key for auth to CA") 69 | tokenCreateCmd.PersistentFlags().String("from-account", "", "AWS account of 'from' user") 70 | tokenCreateCmd.PersistentFlags().String("from-name", "", "(defaults to IAM username)") 71 | tokenCreateCmd.PersistentFlags().String("from-id", "", "(defaults to IAM userid)") 72 | tokenCreateCmd.PersistentFlags().String("to", "", "") 73 | tokenCreateCmd.PersistentFlags().String("principal", "user", "") 74 | 75 | viper.BindPFlags(tokenCreateCmd.PersistentFlags()) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/lkp/tokenValidate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/viper" 6 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 7 | "fmt" 8 | "encoding/base64" 9 | ) 10 | 11 | var tokenValidateCmd = &cobra.Command{ 12 | Use: "token-validate", 13 | Short: "A brief description of your command", 14 | Long: `A longer description that spans multiple lines and likely contains examples 15 | and usage of using your command. For example: 16 | 17 | Cobra is a CLI library for Go that empowers applications. 18 | This application is a tool to generate the needed files 19 | to quickly create a Cobra application.`, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | profile := viper.GetString("profile") 22 | region := viper.GetString("region") 23 | sess := lastkeypair.ClientAwsSession(profile, region) 24 | 25 | key := viper.GetString("key-id") 26 | fromName := viper.GetString("from-name") 27 | fromId := viper.GetString("from-id") 28 | fromAcct := viper.GetString("from-account") 29 | to := viper.GetString("to") 30 | typ := viper.GetString("type") 31 | signature := viper.GetString("signature") 32 | 33 | rawSig, _ := base64.StdEncoding.DecodeString(signature) 34 | 35 | token := lastkeypair.Token{ 36 | Params: lastkeypair.TokenParams{ 37 | FromId: fromId, 38 | FromName: fromName, 39 | FromAccount: fromAcct, 40 | To: to, 41 | Type: typ, 42 | }, 43 | Signature: rawSig, 44 | } 45 | 46 | valid := lastkeypair.ValidateToken(sess, token, key) 47 | fmt.Printf("token valid: %+v\n", valid) 48 | }, 49 | } 50 | 51 | func init() { 52 | advCmd.AddCommand(tokenValidateCmd) 53 | 54 | // profile is at root level 55 | tokenValidateCmd.PersistentFlags().String("region", "", "") 56 | 57 | tokenValidateCmd.PersistentFlags().String("key-id", "", "") 58 | tokenValidateCmd.PersistentFlags().String("from-id", "", "") 59 | tokenValidateCmd.PersistentFlags().String("from-name", "", "") 60 | tokenValidateCmd.PersistentFlags().String("from-account", "", "") 61 | tokenValidateCmd.PersistentFlags().String("to", "", "") 62 | tokenValidateCmd.PersistentFlags().String("type", "user", "") 63 | tokenValidateCmd.PersistentFlags().String("signature", "", "") 64 | 65 | //viper.BindPFlags(tokenValidateCmd.PersistentFlags()) 66 | } 67 | -------------------------------------------------------------------------------- /cmd/lkp/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 8 | ) 9 | 10 | var versionCmd = &cobra.Command{ 11 | Use: "version", 12 | Short: "Output lastkeypair version information", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | fmt.Printf("Version: %s\nBuild Date: %s\n", lastkeypair.ApplicationVersion, lastkeypair.ApplicationBuildDate) 15 | }, 16 | } 17 | 18 | func init() { 19 | RootCmd.AddCommand(versionCmd) 20 | } 21 | -------------------------------------------------------------------------------- /cmd/lkp/vouch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | var vouchCmd = &cobra.Command{ 12 | Use: "vouch", 13 | Short: "A brief description of your command", 14 | Long: `A longer description that spans multiple lines and likely contains examples 15 | and usage of using your command. For example: 16 | 17 | Cobra is a CLI library for Go that empowers applications. 18 | This application is a tool to generate the needed files 19 | to quickly create a Cobra application.`, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | profile := viper.GetString("profile") 22 | region, _ := cmd.PersistentFlags().GetString("region") 23 | sess := lastkeypair.ClientAwsSession(profile, region) 24 | 25 | keyId, _ := cmd.PersistentFlags().GetString("kms-key") 26 | to, _ := cmd.PersistentFlags().GetString("to") 27 | vouchee, _ := cmd.PersistentFlags().GetString("vouchee") 28 | context, _ := cmd.PersistentFlags().GetString("context") 29 | 30 | token := lastkeypair.Vouch(sess, keyId, to, vouchee, context) 31 | encoded := token.Encode() 32 | fmt.Println(encoded) 33 | }, 34 | } 35 | 36 | func init() { 37 | advCmd.AddCommand(vouchCmd) 38 | 39 | // profile is at root level 40 | vouchCmd.PersistentFlags().String("region", "", "") 41 | 42 | vouchCmd.PersistentFlags().String("kms-key", "alias/LastKeypair", "ID, ARN or alias of KMS key for auth to CA") 43 | vouchCmd.PersistentFlags().String("to", "LastKeypair", "") 44 | 45 | vouchCmd.PersistentFlags().String("vouchee", "", "") 46 | vouchCmd.PersistentFlags().String("context", "", "") 47 | } 48 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # `lastkeypair` 2 | 3 | [![Build Status](https://travis-ci.org/glassechidna/lastkeypair.svg?branch=master)](https://travis-ci.org/glassechidna/lastkeypair) 4 | 5 | **NOTE: README is a work-in-progress** 6 | 7 | ## Preamble 8 | 9 | `lastkeypair` was borne out of a frustration with the proliferation of SSH 10 | key-pairs that is all too common across large AWS deployments. These are the 11 | normal sort of pains: 12 | 13 | * Developers freely create new keys because they don't have access to existing 14 | keys. These are only stored on their dev laptops and maybe shared with others 15 | on an ad-hoc basis. 16 | * Golden keys are centrally administered by a benevolent ops teams that does a 17 | decent job of key-rotation, key-sharing and so on. Keys need to be immediately 18 | replaced when an employee leaves. 19 | * Every developer has their own key and all keys are distributed to all 20 | instances - maybe as a boot job and on an hourly basis thereafter. 21 | * People decide all the above options are awful and resort to some kind of LDAP 22 | module wherein `sshd` on instances makes a call back to a central login 23 | server - better hope that server is up and there is connectivity at 2AM when 24 | a system is on fire. 25 | 26 | OpenSSH has a long-supported but woefully under-utilised certificate 27 | functionality. This is conceptually _similar_ to X509 (i.e. "SSL certs"), but 28 | SSH-specific. `lastkeypair` aims to be a plug-and-play solution based on SSH 29 | certificates, AWS Lambda and AWS KMS. 30 | 31 | ## Setup 32 | 33 | **NOTE: Add first-time admin setup** 34 | 35 | Once your administrator has setup `lastkeypair` (LKP) there are a couple of 36 | things you do differently. Your instance initialisation has an addition step 37 | and you SSH into machines with a new command. 38 | 39 | ### EC2 instance setup 40 | 41 | On the EC2 side of things, you set up your instances to trust the LKP SSH 42 | certificate authority. You do this by choosing the LKP SSH keypair when starting 43 | your instance. Secondly, in your userdata script (or whichever instance 44 | initialisation system you use) you run the LKP binary. This binary retrieves the 45 | instance ID and its SSH host key and sends both of these to the LKP Lambda 46 | function. The Lambda returns a _signed_ host certificate and the LKP binary 47 | configures the SSH server to: 48 | 49 | * Present the signed host certificate to users. This prevents the "unknown 50 | host key" prompt when connecting to a server for the first time. 51 | * Trust the LKP SSH CA for when users log in. 52 | * Create a list of "authorised principals" that ensures can only log in 53 | when they've explicitly told the LKP Lambda the exact instance they want 54 | to SSH into. This prevents one user cert from being valid for _any_ one 55 | of your instances (i.e. authentication without authorisation). 56 | 57 | To do this you add something like this to your userdata: 58 | 59 | curl -L -o lkp https://github.com/glassechidna/lastkeypair/releases/download/${VERSION}/lkp_${OS}_${ARCH} 60 | chmod +x lkp 61 | ./lkp host # there are several flags you can pass in here if your Lambda or KMS key alias aren't the default 62 | service sshd restart # This is correct for Amazon Linux, can be different on other distros 63 | rm lkp 64 | 65 | ### User laptop setup 66 | 67 | The setup on your laptop is simpler than configuring your EC2 instances. 68 | 69 | Firstly download the correct LKP binary for your operating system from the 70 | [GitHub Releases](https://github.com/glassechidna/lastkeypair/releases) page 71 | and place it somewhere on your `PATH`. 72 | 73 | If your administrator selected the default LKP Lambda and KMS key alias, you can 74 | now simply type: 75 | 76 | $ lkp ssh exec --instance-arn arn:aws:ec2:us-east-1:9876543210:instance/i-0123abcd 77 | 78 | This fetches a time-limited SSH certificate valid for the selected instance and 79 | initiates an SSH connection to it. Any flags passed after `--` are passed directly 80 | to the underlying `ssh` invocation. 81 | 82 | ## How it works 83 | 84 | At a very high level, LKP works as follows: 85 | 86 | * User specifies instance to log into 87 | * CLI creates a KMS token to prove its identity 88 | * CLI sends token and to Lambda and asks for a cert 89 | * Lambda validates token 90 | * (Optional) Lambda does authorisation 91 | * Lambda returns cert 92 | * CLI uses cert to SSH into instance 93 | 94 | This interaction is illustrated in this sequence diagram. 95 | 96 | ![lastkeypair-sequence-diagram](sequence-diagram.png) 97 | 98 | ## Authorisation 99 | 100 | Out of the box LKP provides only *authentication*. This means that the identity 101 | of users and hosts is guaranteed, but _all_ users have access to _all_ instances 102 | at all times. This is often fine for smaller setups, but you might operate in 103 | an environment where finer granularity is desired. 104 | 105 | LKP supports authorisation by means of factoring it out into a separate 106 | Lambda function that you author. Essentially, on each SSH request LKP will 107 | call your authoriser and your function will respond "authorised" or "not authorised". 108 | 109 | More details are available in the [access control policy](access-policy.md) 110 | docs. 111 | 112 | ## Alternatives 113 | 114 | LKP is unlikely to meet everyone's needs. Here are a few other open-source 115 | solutions I found scattered across the Internet - they might be more what you need. 116 | Failing that, feel free to open an issue on GitHub and let's see if we can meet 117 | your needs! 118 | 119 | ### [BLESS](https://github.com/netflix/bless) 120 | 121 | It's by Netflix, so it's probably pretty solid. BLESS is what first introduced 122 | me to the idea of using Lambda for SSH certificate generation. 123 | 124 | BLESS is more designed to be invoked from a jump box, so it lacks finer-grained 125 | authentication or authorisation. 126 | 127 | ### [python-blessclient](https://github.com/lyft/python-blessclient) 128 | 129 | python-blessclient by Lyft builds upon BLESS significantly. It allows usage 130 | directly from developers' laptops and uses identifies individual users. This 131 | project is what first introduced me to the idea of using KMS-encrypted blobs' 132 | encryption contexts with clever use of KMS key policies to authenticate 133 | users. 134 | 135 | python-blessclient is a bit cumbersome to install (especially for people without 136 | Python setup on their machine) and doesn't support any authorisation - only 137 | authentication. 138 | 139 | ### [sshephalopod](https://github.com/realestate-com-au/sshephalopod/) 140 | 141 | sshephalopod by REA Group solves a similar problem, albeit has a much stronger 142 | focus on SAML assertions and DNS. 143 | 144 | ### [ssh-cert-authority](https://github.com/cloudtools/ssh-cert-authority) 145 | 146 | ssh-cert-authority is a super interesting approach to an SSH CA, albeit one 147 | that is not built on top of Lambda - it requires running a daemon somewhere. 148 | 149 | This project is what reminded me that sometimes an SSH CA shouldn't trust just 150 | a single user - sometimes organisation rules mandate that a second person 151 | "vouch" for the person requesting SSH access to a system. 152 | 153 | ### [pam-ussh](https://github.com/uber/pam-ussh) 154 | 155 | Uber have an interesting approach wherein they not only use an SSH CA, but also 156 | a custom PAM module to actually boot users out of the SSH session once the cert 157 | expires. The associated [blog post](https://medium.com/uber-security-privacy/introducing-the-uber-ssh-certificate-authority-4f840839c5cc) 158 | is a good read. 159 | 160 | ### [facebook-doc](https://code.facebook.com/posts/365787980419535/scalable-and-secure-access-with-ssh/) 161 | 162 | Not actually software that you can download, but engineers at Facebook have 163 | blogged about how they use an SSH CA. 164 | -------------------------------------------------------------------------------- /docs/access-policy.md: -------------------------------------------------------------------------------- 1 | # LastKeypair Access Control Policies 2 | 3 | Sometimes you don't want to grant all authenticated users access to all instances 4 | in your AWS account. You might work on isolated teams, you might grant third 5 | parties access to selected machines or your company might require at least two 6 | people to sign off on access to particularly sensitive machines. Maybe you just 7 | want to mandate a work-life balance and disable SSH access for people not 8 | on-call after 5PM. 9 | 10 | LastKeypair supports these use-cases with access policies. Rather than try to 11 | shoehorn this into some IAM policy monstrosity - or worse yet, make you learn 12 | another cryptic rules-engine JSON schema - we have opted to factor out authorisation 13 | into a separate Lambda function. 14 | 15 | If you specify an `AUTHORIZATION_LAMBDA` environment variable, LKP will execute 16 | that Lambda function in order to determine if a user is authorised to SSH into 17 | their requested instance. You are free to structure your Lambda function however 18 | you please. 19 | 20 | The format of the Lambda's request parameters and the expected response are 21 | documented in Typescript notation. 22 | 23 | ```typescript 24 | interface LkpIdentity { 25 | Name?: string; // IAM username - not set for assumed roles (e.g. SAML users) 26 | Id: string; // IAM (role/user) unique ID, equal to ${aws:userid} IAM policy variable 27 | Account: string; // AWS numeric account ID containing user 28 | Type: "User" | "AssumedRole" | "FederatedUser"; // type of user 29 | } 30 | 31 | type LkpVoucher = LkpIdentity & { 32 | Vouchee: string; // free-form identifier of vouched user 33 | Context: string; // free-form identifier, could be e.g. instance arn 34 | }; 35 | 36 | interface LkpUserCertAuthorizationRequest { 37 | Kind: "LkpUserCertAuthorizationRequest"; 38 | From: LkpIdentity; 39 | RemoteInstanceArn: string; // instance ARN that user is requesting access to 40 | SshUsername: string; 41 | Vouchers?: LkpVoucher[]; 42 | } 43 | 44 | interface LkpUserCertAuthorizationResponse { 45 | Authorized: boolean; 46 | Principals: string[]; // LKP uses instance ARNs as principals for trusted hosts 47 | // if this key is absent, it will default to permitting 48 | // the requested RemoteInstanceArn 49 | Jumpboxes?: { 50 | Address: string; // ip/domain that user should use as bastion host 51 | User: string; // linux user on jumpbox 52 | HostKeyAlias?: string; // you might return an IP in the Address field, but the jumpbox has a different has a different principal in its host cert. defaults to Address 53 | CertificateOptions?: { // as per https://man.openbsd.org/ssh-keygen#O 54 | ForceCommand?: string; 55 | SourceAddress?: string; 56 | }; 57 | }[]; 58 | TargetAddress?: string; // the IP address of the instance to connect to. this is 59 | // necessary to enable transparent ssh client operation 60 | CertificateOptions?: { // as per https://man.openbsd.org/ssh-keygen#O 61 | ForceCommand?: string; 62 | SourceAddress?: string; 63 | }; 64 | } 65 | 66 | interface LkpHostCertAuthorizationRequest { 67 | Kind: "LkpHostCertAuthorizationRequest"; 68 | From: LkpIdentity; 69 | HostInstanceArn: string; 70 | Principals: string[]; 71 | } 72 | 73 | interface LkpHostCertAuthorizationResponse { 74 | Authorized: boolean; 75 | KeyId?: string; // defaults to HostInstanceArn if not provided 76 | Principals: string[]; // LKP uses instance ARNs as principals for trusted hosts. 77 | // additional principals are useful for bastion box domain 78 | // names, etc 79 | } 80 | 81 | type LkpAuthorizationRequest = LkpHostCertAuthorizationRequest | LkpUserCertAuthorizationRequest; 82 | type LkpAuthorizationResponse = LkpUserCertAuthorizationResponse | LkpHostCertAuthorizationResponse; 83 | ``` 84 | 85 | ## Example 86 | 87 | This is a somewhat exhaustive example of the sorts of policies you might enact. 88 | 89 | ```javascript 90 | exports.handler = function(event, context, callback) { 91 | // we allow multiple a third-party account ssh access and we don't want them to be 92 | // sneaky and create IAM users with the same name as us. we _could_ use IAM unique IDs 93 | // but in this case we'd prefer to check (acctID, username) tuples. 94 | var isMainAccount = event.From.Account === '9876543210'; 95 | 96 | var now = new Date(); 97 | var hour = now.getUTCHours(); 98 | var authorized = function() { callback({ authorized: true }) }; 99 | 100 | if (isMainAccount && event.Kind === "LkpHostCertAuthorizationRequest") authorized(); 101 | 102 | if (isMainAccount && event.From.Name === 'aidan.steele@glassechidna.com.au') authorized(); // aidan is all powerful 103 | 104 | if (isMainAccount && event.From.Name === 'benjamin.dobell@glassechidna.com.au') { 105 | if (hour >= 9 && hour < 17) authorized(); // ben usually only has access during work hours 106 | } 107 | 108 | if (event.From.Account === '01234567890') { // aws account id of 3rd-party support provider 109 | if (hour < 9 || hour >= 17) authorized(); // our trusted partner is allowed in outside of work hours 110 | } 111 | 112 | var rolePrefix = "AROAIIWP2XR7EN6EXAMPLE:"; 113 | if (isMainAccount && event.From.Id.indexOf(rolePrefix) === 0) { 114 | var roleSessionName = event.From.Id.substr(rolePrefix.length); 115 | // dan isn't an IAM user (he uses SAML to log into AWS) so we check the role session 116 | // name from his sts:AssumeRole call 117 | if (roleSessionName === 'daniel.whyte@glassechidna.com') authorized(); 118 | } 119 | 120 | var partyHost = 'arn:aws:ec2:ap-southeast-2:9876543210:instance/i-0123abcd'; 121 | if (event.RemoteInstanceArn === partyHost) authorized(); // we'll let anyone on our party box 122 | 123 | var devRegion = 'arn:aws:ec2:us-east-1:9876543210'; 124 | if (event.RemoteInstanceArn.indexOf(devRegion) === 0) authorized(); // the dev region is a free-for-all 125 | 126 | callback({ authorized: false }); 127 | } 128 | ``` 129 | -------------------------------------------------------------------------------- /docs/sequence-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glassechidna/lastkeypair/0325025415bc58a95e9262e4afd1e1e2cf23da8a/docs/sequence-diagram.png -------------------------------------------------------------------------------- /docs/sequence-diagram.txt: -------------------------------------------------------------------------------- 1 | title Lastkeypair Sequence Diagram 2 | autonumber 3 | 4 | participant Sam 5 | participant "LKP CLI" as lkp_cli 6 | participant 10.0.0.1 7 | participant "LKP Lambda" as lkp_lambda 8 | participant "Authorizer" as lkp_auth 9 | participant "AWS KMS" as KMS 10 | participant CloudTrail 11 | 12 | Sam->lkp_cli: lkp ssh ec2-user@10.0.0.1 13 | lkp_cli->KMS: Encrypt a timestamp token with context:\n""from=sam"" \n""to=lkp_lambda"" \n""user=ec2-user"" \n""host=10.0.0.1"" 14 | note right of KMS: KMS will reject encryption API call if **from** \n context does not match user's IAM identity \n (due to key policy) 15 | KMS->CloudTrail: kms:Encrypt with full context 16 | KMS->lkp_cli: Returns ciphertext decryptable\nonly by LKP Lambda 17 | lkp_cli->lkp_lambda: Ciphertext along with context \n and user SSH pubkey 18 | lkp_lambda->KMS: Decrypt token with given context 19 | note right of KMS: KMS will reject decryption API call if context not \n identical to context provided during encryption \n or **to** context does not match LKP Lambda's \n IAM execution role (due to key policy) 20 | KMS->CloudTrail: kms:Decrypt with full context 21 | KMS->lkp_lambda: Plaintext of validity window timestamp 22 | lkp_lambda->lkp_auth: Authorization check using context 23 | lkp_auth->lkp_lambda: Authorized=true/false 24 | note right of lkp_lambda: Will only sign pubkey if a) authorizer \nisn't configured or b) authorizer \nreturns true 25 | lkp_lambda->lkp_cli: Certificate of user pubkey signed by SSH CA 26 | note left of lkp_cli: Certificate is valid for limited time. \n Time limit chosen by LKP Lambda 27 | lkp_cli->10.0.0.1: ssh -o IdentityFile=... ec2-user@10.0.0.1 28 | note right of 10.0.0.1: SSH server validates that cert is signed\n by trusted CA and isn't expired 29 | -------------------------------------------------------------------------------- /examples/bastion.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Parameters: 3 | VpcId: 4 | Type: AWS::EC2::VPC::Id 5 | LoadBalancerSubnetIds: 6 | Type: List 7 | BastionSubnetIds: 8 | Type: List 9 | InstanceCount: 10 | Type: Number 11 | AmiId: # any amazon linux should work with the userdata in this template 12 | Type: AWS::EC2::Image::Id 13 | InstanceType: 14 | Type: String 15 | Default: t2.nano 16 | InstanceProfile: # either specify an existing instance profile or leave as default "create" 17 | Type: String # for cfn to create one on your behalf. created role/profile will have permission 18 | Default: create # to call kms key and lambda func specified elsewhere in parameters. note that this 19 | # isn't necessary in case key policy and lambda func policy grant access 20 | KeypairName: # keypair of LKP-managed cert authority pubkey 21 | Type: AWS::EC2::KeyPair::KeyName 22 | LkpKeyArn: # needs to be ARN as it's used in IAM role policy 23 | Type: String 24 | AllowedPattern: ^arn:aws:kms:[a-z0-9]+:[0-9]+:key/.+$ 25 | LkpLambdaArn: # needs to be ARN as it's used in IAM role policy 26 | Type: String 27 | AllowedPattern: ^arn:aws:lambda:[a-z0-9]+:[0-9]+:function:.+$ 28 | Scheme: 29 | Type: String 30 | Default: internet-facing 31 | AllowedValues: 32 | - internet-facing 33 | - internal 34 | LastKeypairVersion: 35 | Type: String 36 | Default: "0.0.4" 37 | Subdomain: 38 | Type: String 39 | HostedZoneName: # should _NOT_ have trailing dot 40 | Type: String 41 | SshAllowedCidr: 42 | Type: String 43 | Default: 0.0.0.0/0 44 | Conditions: 45 | CreateProfile: !Equals [!Ref InstanceProfile, create] 46 | Resources: 47 | LoadBalancer: 48 | Type: AWS::ElasticLoadBalancingV2::LoadBalancer 49 | Properties: 50 | Scheme: !Ref Scheme 51 | Subnets: !Ref LoadBalancerSubnetIds 52 | Type: network 53 | Tags: 54 | - Key: Name 55 | Value: LastKeypair-managed SSH bastion 56 | Listener: 57 | Type: AWS::ElasticLoadBalancingV2::Listener 58 | Properties: 59 | Protocol: TCP 60 | Port: 22 61 | LoadBalancerArn: !Ref LoadBalancer 62 | DefaultActions: 63 | - Type: forward 64 | TargetGroupArn: !Ref TargetGroup 65 | RecordSet: 66 | Type: AWS::Route53::RecordSet 67 | Properties: 68 | HostedZoneName: !Sub ${HostedZoneName}. 69 | Name: !Sub ${Subdomain}.${HostedZoneName} 70 | Type: A 71 | AliasTarget: 72 | HostedZoneId: !GetAtt LoadBalancer.CanonicalHostedZoneID 73 | DNSName: !GetAtt LoadBalancer.DNSName 74 | TargetGroup: 75 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 76 | Properties: 77 | Port: 22 78 | Protocol: TCP 79 | VpcId: !Ref VpcId 80 | Tags: 81 | - Key: Name 82 | Value: LastKeypair-managed SSH bastion 83 | LaunchConfiguration: 84 | Type: AWS::AutoScaling::LaunchConfiguration 85 | Properties: 86 | AssociatePublicIpAddress: true 87 | IamInstanceProfile: !If [CreateProfile, !Ref CreatedInstanceProfile, !Ref InstanceProfile] 88 | ImageId: !Ref AmiId 89 | InstanceType: !Ref InstanceType 90 | KeyName: !Ref KeypairName 91 | SecurityGroups: [!Ref InstanceSecurityGroup] 92 | UserData: 93 | Fn::Base64: !Sub | 94 | #!/bin/bash 95 | set -euxo pipefail 96 | 97 | curl -L -o lkp https://github.com/glassechidna/lastkeypair/releases/download/${LastKeypairVersion}/lkp_linux_amd64 98 | chmod +x lkp 99 | ./lkp host \ # there are several more flags you can pass in here 100 | --lambda-func ${LkpLambdaArn} \ 101 | --kms-key ${LkpKeyArn} \ 102 | -p ${LoadBalancer.DNSName} \ 103 | -p ${RecordSet} # we add the domain names as additional principals as otherwise the ssh client will reject the host cert 104 | service sshd restart # This is correct for Amazon Linux, can be different on other distros 105 | rm lkp 106 | AutoScalingGroup: 107 | Type: AWS::AutoScaling::AutoScalingGroup 108 | Properties: 109 | VPCZoneIdentifier: !Ref BastionSubnetIds 110 | DesiredCapacity: !Ref InstanceCount 111 | MaxSize: !Ref InstanceCount 112 | MinSize: !Ref InstanceCount 113 | LaunchConfigurationName: !Ref LaunchConfiguration 114 | TargetGroupARNs: [!Ref TargetGroup] 115 | Tags: 116 | - Key: Name 117 | Value: LastKeypair-managed SSH bastion 118 | PropagateAtLaunch: true 119 | UpdatePolicy: 120 | AutoScalingRollingUpdate: 121 | MinInstancesInService: 1 122 | MaxBatchSize: 3 123 | InstanceSecurityGroup: 124 | Type: AWS::EC2::SecurityGroup 125 | Properties: 126 | GroupDescription: LastKeypair-managed SSH bastion 127 | VpcId: !Ref VpcId 128 | SecurityGroupIngress: 129 | - IpProtocol: tcp 130 | FromPort: 22 131 | ToPort: 22 132 | CidrIp: !Ref SshAllowedCidr 133 | Tags: 134 | - Key: Name 135 | Value: LastKeypair-managed SSH bastion 136 | Role: 137 | Type: AWS::IAM::Role 138 | Condition: CreateProfile 139 | Properties: 140 | AssumeRolePolicyDocument: 141 | Statement: 142 | - Effect: Allow 143 | Principal: 144 | Service: ec2.amazonaws.com 145 | Action: sts:AssumeRole 146 | ManagedPolicyArns: 147 | - arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM 148 | Policies: 149 | - PolicyName: AllowLkpHostKeyCreation 150 | PolicyDocument: 151 | Version: "2012-10-17" 152 | Statement: 153 | - Action: lambda:InvokeFunction 154 | Effect: Allow 155 | Resource: !Ref LkpLambdaArn 156 | CreatedInstanceProfile: 157 | Type: AWS::IAM::InstanceProfile 158 | Condition: CreateProfile 159 | Properties: 160 | Roles: [!Ref Role] 161 | Outputs: 162 | LoadBalancerDNSName: 163 | Description: DNS for load balancer 164 | Value: !GetAtt LoadBalancer.DNSName 165 | LoadBalancerCanonicalHostedZoneID: 166 | Description: Route 53 HostedZoneId for NLB 167 | Value: !GetAtt LoadBalancer.CanonicalHostedZoneID 168 | AutoScalingGroup: 169 | Value: !Ref AutoScalingGroup 170 | BastionDomain: 171 | Value: !Ref RecordSet 172 | -------------------------------------------------------------------------------- /pkg/lastkeypair/authorizer.go: -------------------------------------------------------------------------------- 1 | package lastkeypair 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/service/lambda" 5 | "encoding/json" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | type authorizationLambdaIdentity struct { 10 | Name *string `json:",omitempty"` 11 | Id string 12 | Account string 13 | Type string 14 | } 15 | 16 | type authorizationLambdaVoucher struct { 17 | Name *string `json:",omitempty"` 18 | Id string 19 | Account string 20 | Type string 21 | Vouchee string 22 | Context string 23 | } 24 | 25 | type LkpUserCertAuthorizationRequest struct { 26 | Kind string 27 | From authorizationLambdaIdentity 28 | RemoteInstanceArn string 29 | SshUsername string 30 | Vouchers []authorizationLambdaVoucher `json:",omitempty"` 31 | } 32 | 33 | type LkpUserCertAuthorizationResponse struct { 34 | Authorized bool 35 | Message string 36 | Principals []string 37 | Jumpboxes []Jumpbox `json:",omitempty"` 38 | TargetAddress string `json:",omitempty"` 39 | CertificateOptions *CertificateOptions 40 | } 41 | 42 | type LkpHostCertAuthorizationRequest struct { 43 | Kind string 44 | From authorizationLambdaIdentity 45 | HostInstanceArn string 46 | Principals []string 47 | } 48 | 49 | type LkpHostCertAuthorizationResponse struct { 50 | Authorized bool 51 | KeyId string 52 | Principals []string 53 | } 54 | 55 | type AuthorizationLambda struct { 56 | config LambdaConfig 57 | } 58 | 59 | func NewAuthorizationLambda(config LambdaConfig) *AuthorizationLambda { 60 | return &AuthorizationLambda{config: config} 61 | } 62 | 63 | func (a *AuthorizationLambda) doLambda(req interface{}, resp interface{}) error { 64 | client := lambda.New(LambdaAwsSession()) 65 | 66 | encoded, err := json.Marshal(&req) 67 | if err != nil { 68 | return errors.Wrap(err, "encoding authorisation lambda request") 69 | } 70 | 71 | input := &lambda.InvokeInput{ 72 | FunctionName: &a.config.AuthorizationLambda, 73 | Payload: encoded, 74 | } 75 | 76 | lambdaResp, err := client.Invoke(input) 77 | if err != nil { 78 | return errors.Wrap(err, "executing authorisation lambda") 79 | } 80 | 81 | err = json.Unmarshal(lambdaResp.Payload, resp) 82 | if err != nil { 83 | return errors.Wrap(err, "decoding auth lambda response") 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func tokenParamsToAuthLambdaIdentity(p TokenParams) authorizationLambdaIdentity { 90 | return authorizationLambdaIdentity{ 91 | Name: &p.FromName, 92 | Id: p.FromId, 93 | Account: p.FromAccount, 94 | Type: p.Type, 95 | } 96 | } 97 | 98 | func (a *AuthorizationLambda) DoUserReq(userReq UserCertReqJson) (*LkpUserCertAuthorizationResponse, error) { 99 | if len(a.config.AuthorizationLambda) == 0 { 100 | return &LkpUserCertAuthorizationResponse{ 101 | Authorized: true, 102 | Principals: []string{userReq.Token.Params.RemoteInstanceArn}, 103 | }, nil 104 | } 105 | 106 | p := userReq.Token.Params 107 | req := LkpUserCertAuthorizationRequest{ 108 | Kind: "LkpUserCertAuthorizationRequest", 109 | From: tokenParamsToAuthLambdaIdentity(p), 110 | RemoteInstanceArn: p.RemoteInstanceArn, 111 | SshUsername: userReq.Token.Params.SshUsername, 112 | } 113 | 114 | for _, v := range p.Vouchers { 115 | vp := v.Params 116 | voucher := authorizationLambdaVoucher{ 117 | Name: &vp.FromName, 118 | Id: vp.FromId, 119 | Account: vp.FromAccount, 120 | Type: vp.Type, 121 | Vouchee: vp.Vouchee, 122 | Context: vp.Context, 123 | } 124 | req.Vouchers = append(req.Vouchers, voucher) 125 | } 126 | 127 | authResp := LkpUserCertAuthorizationResponse{} 128 | err := a.doLambda(req, &authResp) 129 | if err != nil { 130 | return nil, errors.Wrap(err, "invoking user cert authorisation lambda") 131 | } 132 | 133 | // if the lambda's response is missing the "Principals" key, default to the requested instance 134 | if authResp.Principals == nil { 135 | authResp.Principals = []string{p.RemoteInstanceArn} 136 | } 137 | 138 | return &authResp, nil 139 | } 140 | 141 | func (a *AuthorizationLambda) DoHostReq(hostReq HostCertReqJson) (*LkpHostCertAuthorizationResponse, error) { 142 | hostArn := hostReq.Token.Params.HostInstanceArn 143 | 144 | if len(a.config.AuthorizationLambda) == 0 { 145 | return &LkpHostCertAuthorizationResponse{ 146 | Authorized: true, 147 | KeyId: hostArn, 148 | Principals: []string{hostArn}, 149 | }, nil 150 | } 151 | 152 | p := hostReq.Token.Params 153 | req := LkpHostCertAuthorizationRequest{ 154 | Kind: "LkpHostCertAuthorizationRequest", 155 | From: tokenParamsToAuthLambdaIdentity(p), 156 | HostInstanceArn: hostArn, 157 | Principals: p.Principals, 158 | } 159 | 160 | authResp := LkpHostCertAuthorizationResponse{} 161 | err := a.doLambda(req, &authResp) 162 | if err != nil { 163 | return nil, errors.Wrap(err, "invoking host cert authorisation lambda") 164 | } 165 | 166 | if len(authResp.KeyId) == 0 { 167 | authResp.KeyId = hostArn 168 | } 169 | 170 | return &authResp, nil 171 | } 172 | -------------------------------------------------------------------------------- /pkg/lastkeypair/cli/utils.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "strings" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "regexp" 7 | "fmt" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | func FullKmsKey(sess *session.Session, input string) (string, error) { 12 | myRegion := *sess.Config.Region 13 | 14 | if strings.HasPrefix(input, "arn:aws:kms") { return input, nil } 15 | 16 | regex, _ := regexp.Compile("(\\d+):(.+)") 17 | matches := regex.FindStringSubmatch(input) 18 | if len(matches) == 3 { 19 | if len(myRegion) == 0 { 20 | return "", errors.New("can't deduce key arn without a valid region set") 21 | } 22 | accountId := matches[1] 23 | keyOrAlias := matches[2] 24 | return fmt.Sprintf("arn:aws:kms:%s:%s:%s", myRegion, accountId, keyOrAlias), nil 25 | } else { 26 | return input, nil 27 | } 28 | 29 | return "", nil 30 | } 31 | 32 | -------------------------------------------------------------------------------- /pkg/lastkeypair/cli/utils_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "fmt" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/aws/aws-sdk-go/aws" 9 | ) 10 | 11 | func TestFullKmsKey(t *testing.T) { 12 | sess := session.Must(session.NewSession(&aws.Config{Region: aws.String("us-east-1")})) 13 | full := "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" 14 | 15 | a := ass{t: t} 16 | str := a.assertNoErr(FullKmsKey(sess, full)) 17 | assertEqual(t, str, full, "") 18 | 19 | str = a.assertNoErr(FullKmsKey(sess, "123456789012:key/12345678-1234-1234-1234-123456789012")) 20 | assertEqual(t, str, full, "") 21 | 22 | str = a.assertNoErr(FullKmsKey(sess, "123456789012:alias/myalias")) 23 | assertEqual(t, str, full, "") 24 | } 25 | 26 | func assertEqual(t *testing.T, a interface{}, b interface{}, message string) { 27 | if a == b { 28 | return 29 | } 30 | if len(message) == 0 { 31 | message = fmt.Sprintf("%v != %v", a, b) 32 | } 33 | t.Fatal(message) 34 | } 35 | 36 | type ass struct{ 37 | t *testing.T 38 | } 39 | 40 | func (a *ass) assertNoErr(str string, err error) string { 41 | assert.Nil(a.t, err) 42 | return str 43 | } 44 | -------------------------------------------------------------------------------- /pkg/lastkeypair/common.go: -------------------------------------------------------------------------------- 1 | package lastkeypair 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/service/kms" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 7 | "github.com/aws/aws-sdk-go/aws" 8 | "time" 9 | "encoding/json" 10 | "log" 11 | "encoding/base64" 12 | "github.com/aws/aws-sdk-go/service/sts" 13 | "strings" 14 | "golang.org/x/crypto/ssh" 15 | "fmt" 16 | "github.com/pkg/errors" 17 | "crypto/rand" 18 | "github.com/aws/aws-sdk-go/aws/request" 19 | "github.com/glassechidna/awscredcache" 20 | "github.com/aws/aws-sdk-go/aws/credentials" 21 | "github.com/pquerna/otp/totp" 22 | "os" 23 | "github.com/glassechidna/lastkeypair/pkg/lastkeypair/cli" 24 | ) 25 | 26 | var ApplicationVersion string 27 | var ApplicationBuildDate string 28 | 29 | var DefaultSshPermissions = ssh.Permissions{ 30 | CriticalOptions: map[string]string{}, 31 | Extensions: map[string]string{ 32 | "permit-X11-forwarding": "", 33 | "permit-agent-forwarding": "", 34 | "permit-port-forwarding": "", 35 | "permit-pty": "", 36 | "permit-user-rc": "", 37 | }, 38 | } 39 | 40 | func SignSsh(caKeyBytes, sshKeyPassphrase, pubkeyBytes []byte, certType uint32, expiry uint64, permissions ssh.Permissions, keyId string, principals []string) (*string, error) { 41 | var signer ssh.Signer 42 | var err error 43 | 44 | if len(sshKeyPassphrase) > 0 { 45 | signer, err = ssh.ParsePrivateKeyWithPassphrase(caKeyBytes, sshKeyPassphrase) 46 | } else { 47 | signer, err = ssh.ParsePrivateKey(caKeyBytes) 48 | } 49 | 50 | if err != nil { 51 | return nil, errors.Wrap(err, "err parsing ca priv key") 52 | } 53 | 54 | userPubkey, _, _, _, err := ssh.ParseAuthorizedKey(pubkeyBytes) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "err parsing user pub key") 57 | } 58 | 59 | now := time.Now() 60 | after := now.Add(-300 * time.Second) 61 | 62 | cert := &ssh.Certificate{ 63 | //Nonce: is generated by cert.SignCert 64 | Key: userPubkey, 65 | Serial: 0, 66 | CertType: certType, 67 | KeyId: keyId, 68 | ValidPrincipals: principals, 69 | ValidAfter: uint64(after.Unix()), 70 | ValidBefore: expiry, 71 | Permissions: permissions, 72 | Reserved: []byte{}, 73 | } 74 | 75 | randSource := rand.Reader 76 | err = cert.SignCert(randSource, signer) 77 | if err != nil { 78 | return nil, errors.Wrap(err, "err signing cert") 79 | } 80 | 81 | signed := cert.Marshal() 82 | 83 | b64 := base64.StdEncoding.EncodeToString(signed) 84 | formatted := fmt.Sprintf("%s %s", cert.Type(), b64) 85 | return &formatted, nil 86 | } 87 | 88 | func ClientAwsSession(profile, region string) *session.Session { 89 | provider := awscredcache.NewAwsCacheCredProvider(profile) 90 | provider.MfaCodeProvider = func(mfaSecret string) (string, error) { 91 | if len(mfaSecret) > 0 { 92 | return totp.GenerateCode(mfaSecret, time.Now()) 93 | } else { 94 | return stscreds.StdinTokenProvider() 95 | } 96 | } 97 | 98 | creds := credentials.NewCredentials(provider.WrapInChain()) 99 | 100 | sessOpts := session.Options{ 101 | SharedConfigState: session.SharedConfigEnable, 102 | AssumeRoleTokenProvider: stscreds.StdinTokenProvider, 103 | Config: aws.Config{Credentials: creds}, 104 | } 105 | 106 | if len(os.Getenv("LKP_AWS_VERBOSE")) > 0 { 107 | sessOpts.Config.LogLevel = aws.LogLevel(aws.LogDebugWithHTTPBody) 108 | } 109 | 110 | if len(profile) > 0 { 111 | sessOpts.Profile = profile 112 | } 113 | 114 | sess, _ := session.NewSessionWithOptions(sessOpts) 115 | 116 | userAgentHandler := request.NamedHandler{ 117 | Name: "LastKeypair.UserAgentHandler", 118 | Fn: request.MakeAddToUserAgentHandler("LastKeypair", ApplicationVersion), 119 | } 120 | sess.Handlers.Build.PushBackNamed(userAgentHandler) 121 | 122 | if len(region) > 0 { 123 | sess.Config.Region = aws.String(region) 124 | } 125 | 126 | return sess 127 | } 128 | 129 | type PlaintextPayload struct { 130 | NotBefore int64 // this is what json.unmarshal wants 131 | NotAfter int64 132 | } 133 | 134 | func kmsClientForKeyId(sess *session.Session, keyId string) *kms.KMS { 135 | if strings.HasPrefix(keyId, "arn:aws:kms") { 136 | parts := strings.Split(keyId, ":") 137 | region := parts[3] 138 | sess = sess.Copy(aws.NewConfig().WithRegion(region)) 139 | } 140 | 141 | return kms.New(sess) 142 | } 143 | 144 | func CreateToken(sess *session.Session, params TokenParams, keyId string) Token { 145 | context := params.ToKmsContext() 146 | 147 | now := int64(time.Now().Unix()) 148 | end := now + 3600 // 1 hour 149 | 150 | payload := PlaintextPayload{ 151 | NotBefore: now, 152 | NotAfter: end, 153 | } 154 | 155 | plaintext, err := json.Marshal(&payload) 156 | if err != nil { 157 | log.Panicf("Payload json encoding error: %s", err.Error()) 158 | } 159 | 160 | keyArn, err := cli.FullKmsKey(sess, keyId) 161 | if err != nil { 162 | log.Panicf("Determining KMS key ARN from key id/alias: %s", err.Error()) 163 | } 164 | 165 | input := &kms.EncryptInput{ 166 | Plaintext: plaintext, 167 | KeyId: &keyArn, 168 | EncryptionContext: context, 169 | } 170 | 171 | client := kmsClientForKeyId(sess, keyArn) 172 | response, err := client.Encrypt(input) 173 | if err != nil { 174 | log.Panicf("Encryption error: %s", err.Error()) 175 | } 176 | 177 | blob := response.CiphertextBlob 178 | return Token{Params: params, Signature: blob} 179 | } 180 | 181 | func ValidateToken(sess *session.Session, token Token, expectedKeyId string) bool { 182 | context := token.Params.ToKmsContext() 183 | 184 | input := &kms.DecryptInput{ 185 | CiphertextBlob: token.Signature, 186 | EncryptionContext: context, 187 | } 188 | 189 | client := kms.New(sess) 190 | response, err := client.Decrypt(input) 191 | if err != nil { 192 | log.Panicf("Decryption error: %s", err.Error()) 193 | } 194 | 195 | /* We verify that the encryption key used is the one that we expected it to be. 196 | This is very important, as an attacker could submit ciphertext encrypted with 197 | a key they control that grants our Lambda permission to decrypt. Perhaps it 198 | would be worth implementing some kind of alert here? 199 | */ 200 | if expectedKeyId != *response.KeyId { 201 | log.Panicf("Mismatching KMS key ids: %s and %s", expectedKeyId, *response.KeyId) 202 | } 203 | 204 | payload := PlaintextPayload{} 205 | err = json.Unmarshal([]byte(response.Plaintext), &payload) 206 | if err != nil { 207 | log.Printf("decoding token json") 208 | return false 209 | } 210 | 211 | now := int64(time.Now().Unix()) 212 | sway := int64(150) 213 | if now < payload.NotBefore - sway { 214 | log.Printf("token yet to be valid") 215 | return false 216 | } 217 | 218 | if now > payload.NotAfter + sway { 219 | log.Printf("expired token") 220 | return false 221 | } 222 | 223 | return true 224 | } 225 | 226 | type StsIdentity struct { 227 | AccountId string 228 | UserId string 229 | Username string 230 | Type string 231 | } 232 | 233 | func CallerIdentityUser(sess *session.Session) (*StsIdentity, error) { 234 | client := sts.New(sess) 235 | response, err := client.GetCallerIdentity(&sts.GetCallerIdentityInput{}) 236 | 237 | if err == nil { 238 | arn := *response.Arn 239 | parts := strings.SplitN(arn, ":", 6) 240 | 241 | if strings.HasPrefix(parts[5], "user/") { 242 | name := parts[5][5:] 243 | return &StsIdentity{ 244 | AccountId: *response.Account, 245 | UserId: *response.UserId, 246 | Username: name, 247 | Type: "User", 248 | }, nil 249 | } else if strings.HasPrefix(parts[5], "assumed-role/") { 250 | return &StsIdentity{ 251 | AccountId: *response.Account, 252 | UserId: *response.UserId, 253 | Username: "", 254 | Type: "AssumedRole", 255 | }, nil 256 | } else { 257 | return nil, errors.New("unsupported IAM identity type") 258 | } 259 | } else { 260 | return nil, err 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /pkg/lastkeypair/keypair.go: -------------------------------------------------------------------------------- 1 | package lastkeypair 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "golang.org/x/crypto/ssh" 8 | "github.com/mitchellh/go-homedir" 9 | "path" 10 | "os" 11 | "io/ioutil" 12 | "crypto/rand" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | type Keypair struct { 17 | PrivateKey []byte 18 | PublicKey []byte 19 | } 20 | 21 | func GenerateKeyPair() (*Keypair, error) { 22 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 23 | if err != nil { 24 | return nil, errors.Wrap(err, "generating privkey") 25 | } 26 | 27 | privateKeyDer := x509.MarshalPKCS1PrivateKey(privateKey) 28 | privateKeyBlock := pem.Block{ 29 | Type: "RSA PRIVATE KEY", 30 | Headers: nil, 31 | Bytes: privateKeyDer, 32 | } 33 | privateKeyPem := pem.EncodeToMemory(&privateKeyBlock) 34 | 35 | pub, err := ssh.NewPublicKey(&privateKey.PublicKey) 36 | if err != nil { 37 | return nil, errors.Wrap(err, "deriving pubkey from privkey") 38 | } 39 | 40 | pubkey := ssh.MarshalAuthorizedKey(pub) 41 | 42 | return &Keypair{ 43 | PrivateKey: privateKeyPem, 44 | PublicKey: pubkey, 45 | }, nil 46 | } 47 | 48 | func AppDir() string { 49 | home, _ := homedir.Dir() 50 | appDir := path.Join(home, ".lkp") 51 | os.MkdirAll(appDir, 0755) 52 | return appDir 53 | } 54 | 55 | func TmpDir() string { 56 | tmpDir := path.Join(AppDir(), "tmp") 57 | os.MkdirAll(tmpDir, 0755) 58 | return tmpDir 59 | } 60 | 61 | func MyKeyPair() (*Keypair, error) { 62 | privkeyPath := path.Join(AppDir(), "id_rsa") 63 | pubkeyPath := path.Join(AppDir(), "id_rsa.pub") 64 | 65 | if _, err := os.Stat(privkeyPath); os.IsNotExist(err) { 66 | keypair, _ := GenerateKeyPair() 67 | ioutil.WriteFile(privkeyPath, keypair.PrivateKey, 0600) 68 | ioutil.WriteFile(pubkeyPath, keypair.PublicKey, 0644) 69 | return keypair, nil 70 | } else { 71 | keypair := Keypair{} 72 | keypair.PrivateKey, _ = ioutil.ReadFile(privkeyPath) 73 | keypair.PublicKey, _ = ioutil.ReadFile(pubkeyPath) 74 | return &keypair, nil 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/lastkeypair/lambda.go: -------------------------------------------------------------------------------- 1 | package lastkeypair 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws/session" 5 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 6 | "github.com/aws/aws-sdk-go/service/kms" 7 | "encoding/json" 8 | "time" 9 | "github.com/pkg/errors" 10 | "os" 11 | "strconv" 12 | "github.com/aws/aws-sdk-go/service/ssm" 13 | "github.com/aws/aws-sdk-go/aws" 14 | "encoding/base64" 15 | "fmt" 16 | "log" 17 | "golang.org/x/crypto/ssh" 18 | ) 19 | 20 | type LambdaConfig struct { 21 | KeyId string 22 | KmsTokenIdentity string 23 | CaKeyBytes []byte 24 | CaKeyPassphraseBytes []byte 25 | ValidityDuration int64 26 | AuthorizationLambda string 27 | } 28 | 29 | func getPstoreOrKmsOrRawBytes(name string) ([]byte, error) { 30 | var bytes []byte 31 | 32 | if pstoreName, found := os.LookupEnv(fmt.Sprintf("PSTORE_%s", name)); found { 33 | ssmClient := ssm.New(session.New()) 34 | ssmInput := &ssm.GetParametersInput{ 35 | Names: aws.StringSlice([]string{pstoreName}), 36 | WithDecryption: aws.Bool(true), 37 | } 38 | 39 | ssmResp, err := ssmClient.GetParameters(ssmInput) 40 | if err != nil { 41 | return nil, errors.Wrap(err, "decrypting key bytes from pstore") 42 | } 43 | 44 | valstr := ssmResp.Parameters[0].Value 45 | bytes = []byte(*valstr) 46 | } else if kmsEncrypted, found := os.LookupEnv(fmt.Sprintf("KMS_B64_%s", name)); found { 47 | kmsClient := kms.New(session.New()) 48 | 49 | b64dec, err := base64.StdEncoding.DecodeString(kmsEncrypted) 50 | if err != nil { 51 | return nil, errors.Wrap(err, "base64 decoding kms-encrypted ca key bytes") 52 | } 53 | 54 | kmsInput := &kms.DecryptInput{CiphertextBlob: b64dec} 55 | kmsResp, err := kmsClient.Decrypt(kmsInput) 56 | if err != nil { 57 | return nil, errors.Wrap(err, "decrypting kms-encrypted ca key bytes") 58 | } 59 | 60 | bytes = kmsResp.Plaintext 61 | } else if raw, found := os.LookupEnv(name); found { 62 | bytes = []byte(raw) 63 | } else { 64 | return nil, nil 65 | } 66 | 67 | return bytes, nil 68 | } 69 | 70 | func LambdaHandle(evt json.RawMessage) (interface{}, error) { 71 | caKeyBytes, err := getPstoreOrKmsOrRawBytes("CA_KEY_BYTES") 72 | if err != nil { 73 | return nil, err 74 | } else if caKeyBytes == nil { 75 | return nil, errors.New("no ca key bytes provided") 76 | } 77 | 78 | caKeyPassphraseBytes, err := getPstoreOrKmsOrRawBytes("CA_KEY_PASSPHRASE_BYTES") 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | validity, err := strconv.ParseInt(os.Getenv("VALIDITY_DURATION"), 10, 64) 84 | 85 | kmsTokenIdentity := os.Getenv("KMS_TOKEN_IDENTITY") 86 | if len(kmsTokenIdentity) == 0 { 87 | kmsTokenIdentity = "LastKeypair" 88 | } 89 | 90 | config := LambdaConfig{ 91 | KeyId: os.Getenv("KMS_KEY_ID"), 92 | KmsTokenIdentity: kmsTokenIdentity, 93 | CaKeyBytes: caKeyBytes, 94 | CaKeyPassphraseBytes: caKeyPassphraseBytes, 95 | ValidityDuration: validity, 96 | AuthorizationLambda: os.Getenv("AUTHORIZATION_LAMBDA"), 97 | } 98 | 99 | raw := make(map[string]string) 100 | json.Unmarshal(evt, &raw) 101 | 102 | switch raw["EventType"] { 103 | case "UserCertReq": 104 | req := UserCertReqJson{} 105 | err := json.Unmarshal(evt, &req) 106 | if err != nil { 107 | return nil, errors.Wrap(err, "unmarshalling input") 108 | } 109 | return DoUserCertReq(req, config) 110 | case "HostCertReq": 111 | req := HostCertReqJson{} 112 | err := json.Unmarshal(evt, &req) 113 | if err != nil { 114 | return nil, errors.Wrap(err, "unmarshalling input") 115 | } 116 | return DoHostCertReq(req, config) 117 | default: 118 | return nil, errors.New("unexpected event type") 119 | } 120 | } 121 | 122 | func LambdaAwsSession() *session.Session { 123 | sessOpts := session.Options{ 124 | SharedConfigState: session.SharedConfigEnable, 125 | AssumeRoleTokenProvider: stscreds.StdinTokenProvider, 126 | } 127 | 128 | sess, err := session.NewSessionWithOptions(sessOpts) 129 | if err != nil { 130 | log.Panicf("couldn't create aws session") 131 | } 132 | 133 | return sess 134 | } 135 | 136 | func DoHostCertReq(req HostCertReqJson, config LambdaConfig) (*HostCertRespJson, error) { 137 | sess := LambdaAwsSession() 138 | 139 | if !ValidateToken(sess, req.Token, config.KeyId) { 140 | return nil, errors.New("invalid token") 141 | } 142 | 143 | permissions := ssh.Permissions{ 144 | CriticalOptions: map[string]string{}, 145 | Extensions: map[string]string{}, 146 | } 147 | 148 | authLambda := NewAuthorizationLambda(config) 149 | auth, err := authLambda.DoHostReq(req) 150 | 151 | signed, err := SignSsh( 152 | config.CaKeyBytes, 153 | config.CaKeyPassphraseBytes, 154 | []byte(req.PublicKey), 155 | ssh.HostCert, 156 | ssh.CertTimeInfinity, 157 | permissions, 158 | auth.KeyId, 159 | auth.Principals, 160 | ) 161 | 162 | if err != nil { 163 | return nil, errors.Wrap(err, "signing ssh key") 164 | } 165 | 166 | resp := HostCertRespJson{ 167 | SignedHostPublicKey: *signed, 168 | } 169 | 170 | return &resp, nil 171 | } 172 | 173 | func GenerateSshPermissions(options *CertificateOptions) ssh.Permissions { 174 | var SshPermissions = ssh.Permissions{ 175 | CriticalOptions: map[string]string{}, 176 | Extensions: map[string]string{ 177 | "permit-X11-forwarding": "", 178 | "permit-agent-forwarding": "", 179 | "permit-port-forwarding": "", 180 | "permit-pty": "", 181 | "permit-user-rc": "", 182 | }, 183 | } 184 | if options != nil { 185 | if options.ForceCommand != nil { 186 | SshPermissions.Extensions["force-command"] = *options.ForceCommand 187 | } 188 | if options.SourceAddress != nil { 189 | SshPermissions.Extensions["source-address"] = *options.SourceAddress 190 | } 191 | if options.PermitX11Forwarding == false { 192 | delete(SshPermissions.Extensions, "permit-X11-forwarding") 193 | } 194 | if options.PermitAgentForwarding == false { 195 | delete(SshPermissions.Extensions, "permit-agent-forwarding") 196 | } 197 | if options.PermitPortForwarding == false { 198 | delete(SshPermissions.Extensions, "permit-port-forwarding") 199 | } 200 | } 201 | 202 | return SshPermissions 203 | } 204 | 205 | func DoUserCertReq(req UserCertReqJson, config LambdaConfig) (*UserCertRespJson, error) { 206 | sess := LambdaAwsSession() 207 | 208 | if !ValidateToken(sess, req.Token, config.KeyId) { 209 | return nil, errors.New("invalid token") 210 | } 211 | 212 | identity := req.Token.Params.FromId 213 | if name := req.Token.Params.FromName; len(name) > 0 { 214 | identity = fmt.Sprintf("%s-%s", name, identity) 215 | } 216 | 217 | instanceArn := req.Token.Params.RemoteInstanceArn 218 | if len(instanceArn) == 0 { 219 | return nil, errors.New("target instance arn must be specified") 220 | } 221 | 222 | authLambda := NewAuthorizationLambda(config) 223 | auth, err := authLambda.DoUserReq(req) 224 | if err != nil { 225 | return nil, errors.Wrap(err, "authorising user cert") 226 | } 227 | 228 | if !auth.Authorized { 229 | errorMessage := "authorisation denied by auth lambda" 230 | if len(auth.Message) > 0 { 231 | errorMessage = auth.Message 232 | } 233 | return nil, errors.New(errorMessage) 234 | } 235 | 236 | SshPermissions := GenerateSshPermissions(auth.CertificateOptions) 237 | 238 | signed, err := SignSsh( 239 | config.CaKeyBytes, 240 | config.CaKeyPassphraseBytes, 241 | []byte(req.PublicKey), 242 | ssh.UserCert, 243 | uint64(time.Now().Unix() + config.ValidityDuration), 244 | SshPermissions, 245 | identity, 246 | auth.Principals, 247 | ) 248 | 249 | for idx := range auth.Jumpboxes { 250 | j := &auth.Jumpboxes[idx] 251 | if len(j.HostKeyAlias) == 0 { 252 | j.HostKeyAlias = j.Address 253 | } 254 | if len(j.Principals) == 0 { 255 | j.Principals = append(j.Principals, j.Address) 256 | } 257 | jSshPermissions := GenerateSshPermissions(j.CertificateOptions) 258 | jSigned, jErr := SignSsh( 259 | config.CaKeyBytes, 260 | config.CaKeyPassphraseBytes, 261 | []byte(req.PublicKey), 262 | ssh.UserCert, 263 | uint64(time.Now().Unix() + config.ValidityDuration), 264 | jSshPermissions, 265 | identity, 266 | j.Principals, 267 | ) 268 | j.SignedPublicKey = *jSigned 269 | if jErr != nil { 270 | return nil, errors.Wrap(err, "error signing ssh key for jumphost") 271 | } 272 | } 273 | 274 | if err != nil { 275 | return nil, errors.Wrap(err, "error signing ssh key") 276 | } 277 | 278 | expiry := time.Now().Add(time.Duration(config.ValidityDuration) * time.Second) 279 | 280 | resp := UserCertRespJson{ 281 | SignedPublicKey: *signed, 282 | Jumpboxes: auth.Jumpboxes, 283 | TargetAddress: auth.TargetAddress, 284 | Expiry: expiry.Unix(), 285 | } 286 | 287 | return &resp, nil 288 | } 289 | -------------------------------------------------------------------------------- /pkg/lastkeypair/lambda_req_resp.go: -------------------------------------------------------------------------------- 1 | package lastkeypair 2 | 3 | type UserCertReqJson struct { 4 | // NOTE: be very careful of adding new fields to this struct. only fields 5 | // inside Token.TokenParams are part of the encryption context (and hence 6 | // logged in cloudtrail) 7 | EventType string 8 | Token Token 9 | PublicKey string 10 | } 11 | 12 | type HostCertReqJson struct { 13 | EventType string 14 | Token Token 15 | PublicKey string 16 | } 17 | 18 | type UserCertRespJson struct { 19 | SignedPublicKey string 20 | Jumpboxes []Jumpbox `json:",omitempty"` 21 | TargetAddress string `json:",omitempty"` 22 | Expiry int64 23 | } 24 | 25 | type Jumpbox struct { 26 | Address string 27 | User string 28 | HostKeyAlias string 29 | Principals []string 30 | SignedPublicKey string 31 | CertificateOptions *CertificateOptions 32 | } 33 | 34 | type CertificateOptions struct { 35 | ForceCommand *string `json:",omitempty"` 36 | SourceAddress *string `json:",omitempty"` 37 | PermitX11Forwarding bool 38 | PermitAgentForwarding bool 39 | PermitPortForwarding bool 40 | } 41 | 42 | type HostCertRespJson struct { 43 | SignedHostPublicKey string 44 | } 45 | -------------------------------------------------------------------------------- /pkg/lastkeypair/netcat/netcat.go: -------------------------------------------------------------------------------- 1 | package netcat 2 | // adapted from https://github.com/vfedoroff/go-netcat/blob/master/main.go 3 | 4 | import ( 5 | "log" 6 | "io" 7 | "net" 8 | ) 9 | 10 | func TcpToPipes(conn net.Conn, src io.Reader, dst io.Writer) { 11 | chanToStdout := streamCopy(conn, dst) 12 | chanToRemote := streamCopy(src, conn) 13 | select { 14 | case <-chanToStdout: 15 | log.Println("Remote connection is closed") 16 | case <-chanToRemote: 17 | log.Println("Local program is terminated") 18 | } 19 | } 20 | 21 | // Performs copy operation between streams: os and tcp streams 22 | func streamCopy(src io.Reader, dst io.Writer) <-chan int { 23 | buf := make([]byte, 1024) 24 | syncChannel := make(chan int) 25 | go func() { 26 | defer func() { 27 | if con, ok := dst.(net.Conn); ok { 28 | con.Close() 29 | log.Printf("Connection from %v is closed\n", con.RemoteAddr()) 30 | } 31 | syncChannel <- 0 // Notify that processing is finished 32 | }() 33 | for { 34 | var nBytes int 35 | var err error 36 | nBytes, err = src.Read(buf) 37 | if err != nil { 38 | if err != io.EOF { 39 | log.Printf("Read error: %s\n", err) 40 | } 41 | break 42 | } 43 | _, err = dst.Write(buf[0:nBytes]) 44 | if err != nil { 45 | log.Fatalf("Write error: %s\n", err) 46 | } 47 | } 48 | }() 49 | return syncChannel 50 | } 51 | -------------------------------------------------------------------------------- /pkg/lastkeypair/ssh.go: -------------------------------------------------------------------------------- 1 | package lastkeypair 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws/session" 5 | "log" 6 | "io/ioutil" 7 | "github.com/aws/aws-sdk-go/service/lambda" 8 | "encoding/json" 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/pkg/errors" 11 | "fmt" 12 | "strings" 13 | "github.com/spf13/cobra" 14 | "github.com/spf13/viper" 15 | "path/filepath" 16 | "os" 17 | ) 18 | 19 | func sshReqResp(sess *session.Session, lambdaFunc, kmsKeyId, instanceArn, username string, encodedVouchers []string) (UserCertReqJson, UserCertRespJson) { 20 | kp, _ := MyKeyPair() 21 | 22 | ident, err := CallerIdentityUser(sess) 23 | if err != nil { 24 | log.Panicf("error getting aws user identity: %+v\n", err) 25 | } 26 | 27 | vouchers := []VoucherToken{} 28 | for _, encVoucher := range encodedVouchers { 29 | voucher, err := DecodeVoucherToken(encVoucher) 30 | if err != nil { 31 | log.Panicf("couldn't decode voucher: %+v\n", err) 32 | } 33 | vouchers = append(vouchers, *voucher) 34 | } 35 | 36 | token := CreateToken(sess, TokenParams{ 37 | FromId: ident.UserId, 38 | FromAccount: ident.AccountId, 39 | FromName: ident.Username, 40 | To: "LastKeypair", 41 | Type: ident.Type, 42 | RemoteInstanceArn: instanceArn, 43 | Vouchers: vouchers, 44 | SshUsername: username, 45 | }, kmsKeyId) 46 | 47 | req := UserCertReqJson{ 48 | EventType: "UserCertReq", 49 | Token: token, 50 | PublicKey: string(kp.PublicKey), 51 | } 52 | 53 | resp := UserCertRespJson{} 54 | err = RequestSignedPayload(sess, lambdaFunc, req, &resp) 55 | if err != nil { 56 | log.Panicf("err: %s", err.Error()) 57 | } 58 | 59 | return req, resp 60 | } 61 | 62 | //func SshCommand(sess *session.Session, lambdaFunc, kmsKeyId, InstanceArn, username string, encodedVouchers, args []string) []string { 63 | // req, resp := sshReqResp(sess, lambdaFunc, kmsKeyId, InstanceArn, username, encodedVouchers) 64 | // return append(sshCommandFromResponse(req, resp), args...) 65 | //} 66 | 67 | type ReifiedLogin struct { 68 | sess *session.Session 69 | lambdaFunc string 70 | kmsKeyId string 71 | InstanceArn string 72 | username string 73 | encodedVouchers []string 74 | args []string 75 | 76 | Request *UserCertReqJson 77 | Response *UserCertRespJson 78 | } 79 | 80 | func NewReifiedLoginWithCmd(cmd *cobra.Command, args []string) *ReifiedLogin { 81 | profile := viper.GetString("profile") 82 | 83 | lambdaFunc := viper.GetString("lambda-func") 84 | kmsKeyId := viper.GetString("kms-key") 85 | instanceArn, _ := cmd.PersistentFlags().GetString("instance-arn") 86 | username, _ := cmd.PersistentFlags().GetString("ssh-username") 87 | region, _ := cmd.PersistentFlags().GetString("region") 88 | vouchers, _ := cmd.PersistentFlags().GetStringSlice("voucher") 89 | 90 | instanceArnParts := strings.Split(instanceArn, ":") 91 | if len(instanceArnParts) > 3 { 92 | region = instanceArnParts[3] 93 | } 94 | sess := ClientAwsSession(profile, region) 95 | 96 | return &ReifiedLogin{ 97 | sess: sess, 98 | lambdaFunc: lambdaFunc, 99 | kmsKeyId: kmsKeyId, 100 | InstanceArn: instanceArn, 101 | username: username, 102 | encodedVouchers: vouchers, 103 | args: args, 104 | } 105 | } 106 | 107 | func (r *ReifiedLogin) PopulateByInvoke() { 108 | req, resp := sshReqResp(r.sess, r.lambdaFunc, r.kmsKeyId, r.InstanceArn, r.username, r.encodedVouchers) 109 | 110 | r.Request = &req 111 | r.Response = &resp 112 | 113 | certPath := r.CertificatePath() 114 | ioutil.WriteFile(certPath, []byte(resp.SignedPublicKey), 0644) 115 | for _, j := range r.Response.Jumpboxes { 116 | ioutil.WriteFile(j.JumpCertificatePath(), []byte(j.SignedPublicKey), 0644) 117 | } 118 | 119 | serialized, _ := json.MarshalIndent(r, "", " ") 120 | ioutil.WriteFile(r.Filepath("conn.json"), serialized, 0644) 121 | } 122 | 123 | func (r* ReifiedLogin) Filepath(name string) string { 124 | arn := r.InstanceArn 125 | arn = strings.Replace(arn, ":", "-", -1) 126 | arn = strings.Replace(arn, "/", "-", -1) 127 | arnDir := filepath.Join(TmpDir(), arn) 128 | os.MkdirAll(arnDir, 0755) 129 | return filepath.Join(arnDir, name) 130 | } 131 | 132 | func (j* Jumpbox) JumpboxFilepath() string { 133 | arn := j.HostKeyAlias 134 | arn = strings.Replace(arn, ":", "-", -1) 135 | arn = strings.Replace(arn, "/", "-", -1) 136 | arnDir := filepath.Join(TmpDir(), arn) 137 | os.MkdirAll(arnDir, 0755) 138 | return filepath.Join(arnDir) 139 | } 140 | 141 | func (r *ReifiedLogin) PopulateByRestoreCache() { 142 | serialized, _ := ioutil.ReadFile(r.Filepath("conn.json")) 143 | json.Unmarshal(serialized, r) 144 | } 145 | 146 | func (r *ReifiedLogin) WriteSshConfig() string { 147 | jump := r.Response.Jumpboxes 148 | 149 | filebuf := "IgnoreUnknown CertificateFile\n" // CertificateFile was introduced in 7.1 150 | 151 | for idx, j := range jump { 152 | filebuf = filebuf + fmt.Sprintf(` 153 | Host jump%d 154 | HostName %s 155 | HostKeyAlias %s 156 | IdentityFile %s 157 | CertificateFile %s 158 | User %s 159 | `, idx, j.Address, j.HostKeyAlias, r.PrivateKeyPath(), j.JumpCertificatePath(), j.User) 160 | if idx > 0 { 161 | filebuf = filebuf + fmt.Sprintf(" ProxyJump jump%d\n\n", idx-1) 162 | } 163 | } 164 | 165 | filebuf = filebuf + fmt.Sprintf(` 166 | Host target 167 | HostKeyAlias %s 168 | IdentityFile %s 169 | CertificateFile %s 170 | User %s 171 | `, r.Request.Token.Params.RemoteInstanceArn, r.PrivateKeyPath(), r.CertificatePath(), r.Request.Token.Params.SshUsername) 172 | 173 | if len(r.Response.TargetAddress) > 0 { 174 | filebuf = filebuf + fmt.Sprintf(" HostName %s\n", r.Response.TargetAddress) 175 | } 176 | 177 | if len(jump) > 0 { 178 | filebuf = filebuf + fmt.Sprintf(" ProxyJump jump%d\n\n", len(jump) - 1) 179 | } 180 | 181 | sshconfPath := r.Filepath("sshconf") 182 | ioutil.WriteFile(sshconfPath, []byte(filebuf), 0700) 183 | 184 | return sshconfPath 185 | } 186 | 187 | func (r *ReifiedLogin) PrivateKeyPath() string { 188 | return filepath.Join(AppDir(), "id_rsa") 189 | } 190 | 191 | func (r *ReifiedLogin) CertificatePath() string { 192 | return filepath.Join(AppDir(), "id_rsa-cert.pub") 193 | } 194 | 195 | func (j* Jumpbox) JumpCertificatePath() string { 196 | return filepath.Join(j.JumpboxFilepath(), "id_rsa-cert.pub") 197 | } 198 | 199 | func lambdaClientForKeyId(sess *session.Session, lambdaArn string) *lambda.Lambda { 200 | if strings.HasPrefix(lambdaArn, "arn:aws:lambda") { 201 | parts := strings.Split(lambdaArn, ":") 202 | region := parts[3] 203 | sess = sess.Copy(aws.NewConfig().WithRegion(region)) 204 | } 205 | 206 | return lambda.New(sess) 207 | } 208 | 209 | func RequestSignedPayload(sess *session.Session, lambdaArn string, req interface{}, resp interface{}) error { 210 | ca := lambdaClientForKeyId(sess, lambdaArn) 211 | 212 | reqPayload, err := json.Marshal(&req) 213 | if err != nil { 214 | return errors.Wrap(err, "marshalling lambda req payload") 215 | } 216 | 217 | input := lambda.InvokeInput{ 218 | FunctionName: aws.String(lambdaArn), 219 | Payload: reqPayload, 220 | } 221 | 222 | lambdaResp, err := ca.Invoke(&input) 223 | if err != nil { 224 | return errors.Wrap(err, "invoking CA lambda") 225 | } 226 | if lambdaResp.FunctionError != nil { 227 | return errors.New(fmt.Sprintf("%s: %s", *lambdaResp.FunctionError, string(lambdaResp.Payload))) 228 | } 229 | 230 | err = json.Unmarshal(lambdaResp.Payload, resp) 231 | if err != nil { 232 | return errors.Wrap(err, "unmarshalling lambda resp payload") 233 | } 234 | 235 | return nil 236 | } 237 | -------------------------------------------------------------------------------- /pkg/lastkeypair/token.go: -------------------------------------------------------------------------------- 1 | package lastkeypair 2 | 3 | import "fmt" 4 | 5 | type Token struct { 6 | Params TokenParams 7 | Signature []byte 8 | } 9 | 10 | type TokenParams struct { 11 | FromId string 12 | FromAccount string 13 | To string 14 | Type string 15 | 16 | // optional fields below this comment 17 | FromName string `json:",omitempty"` 18 | Vouchee string `json:",omitempty"` 19 | Context string `json:",omitempty"` 20 | Vouchers []VoucherToken `json:",omitempty"` 21 | 22 | // the reason we have both these fields (rather than overloading one "InstanceArn" field) 23 | // is because we want to specify a KMS key policy that HostInstanceArn _MUST_ match 24 | // the ec2:SourceInstanceARN if it exists. if we didn't do this, then anyone _not_ on 25 | // an instance could request a host cert. 26 | HostInstanceArn string `json:",omitempty"` // this field is for when an instance is requesting a host cert 27 | RemoteInstanceArn string `json:",omitempty"` // this field is for when a user is requesting a user cert for a specific host 28 | 29 | SshUsername string `json:",omitempty"` // username on remote instance that user wants to access 30 | Principals []string `json:",omitempty"` // additional principals to include in cert 31 | } 32 | 33 | func (params *TokenParams) ToKmsContext() map[string]*string { 34 | // TODO: i think this is a recipe for problems. see issue #24 35 | iterateParams := func(p *TokenParams, cb func(string, *string)) { 36 | cb("fromId", &p.FromId) 37 | cb("fromAccount", &p.FromAccount) 38 | cb("to", &p.To) 39 | cb("type", &p.Type) 40 | 41 | if len(p.FromName) > 0 { 42 | cb("fromName", &p.FromName) 43 | } 44 | 45 | if len(p.HostInstanceArn) > 0 { 46 | cb("hostInstanceArn", &p.HostInstanceArn) 47 | } 48 | 49 | if len(p.RemoteInstanceArn) > 0 { 50 | cb("remoteInstanceArn", &p.RemoteInstanceArn) 51 | } 52 | 53 | if len(p.SshUsername) > 0 { 54 | cb("sshUsername", &p.SshUsername) 55 | } 56 | 57 | if len(p.Vouchee) > 0 { 58 | cb("vouchee", &p.Vouchee) 59 | } 60 | 61 | if len(p.Context) > 0 { 62 | cb("context", &p.Context) 63 | } 64 | } 65 | 66 | context := make(map[string]*string) 67 | iterateParams(params, func(key string, val *string) { 68 | context[key] = val 69 | }) 70 | 71 | if len(params.Vouchers) > 0 { 72 | for i, v := range params.Vouchers { 73 | keyPrefix := fmt.Sprintf("voucher-%d-", i) 74 | 75 | iterateParams(&v.Params, func(key string, val *string) { 76 | context[keyPrefix + key] = val 77 | }) 78 | } 79 | } 80 | 81 | if len(params.Principals) > 0 { 82 | for i, principal := range params.Principals { 83 | principal := principal 84 | key := fmt.Sprintf("principal-%d", i) 85 | context[key] = &principal 86 | } 87 | } 88 | 89 | return context 90 | } 91 | -------------------------------------------------------------------------------- /pkg/lastkeypair/voucher.go: -------------------------------------------------------------------------------- 1 | package lastkeypair 2 | 3 | import ( 4 | "encoding/json" 5 | "bytes" 6 | "compress/gzip" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "log" 9 | "encoding/base32" 10 | "github.com/pkg/errors" 11 | "io/ioutil" 12 | ) 13 | 14 | type VoucherToken Token 15 | 16 | func (vt *VoucherToken) Encode() string { 17 | jsonToken, _ := json.Marshal(vt) 18 | buf := bytes.Buffer{} 19 | gz := gzip.NewWriter(&buf) 20 | gz.Write(jsonToken) 21 | gz.Flush() 22 | gz.Close() 23 | encoded := base32.StdEncoding.EncodeToString(buf.Bytes()) 24 | return encoded 25 | } 26 | 27 | func DecodeVoucherToken(encoded string) (*VoucherToken, error) { 28 | compressed, err := base32.StdEncoding.DecodeString(encoded) 29 | if err != nil { 30 | return nil, errors.Wrap(err, "decoding base32 voucher") 31 | } 32 | 33 | bytesReader := bytes.NewReader(compressed) 34 | gz, err := gzip.NewReader(bytesReader) 35 | if err != nil { 36 | return nil, errors.Wrap(err, "creating gzip reader for voucher") 37 | } 38 | 39 | jsonToken, err := ioutil.ReadAll(gz) 40 | if err != nil { 41 | return nil, errors.Wrap(err, "reading gzipped voucher") 42 | } 43 | 44 | token := VoucherToken{} 45 | err = json.Unmarshal(jsonToken, &token) 46 | if err != nil { 47 | return nil, errors.Wrap(err, "reading json voucher") 48 | } 49 | 50 | return &token, nil 51 | } 52 | 53 | func Vouch(sess *session.Session, kmsKeyId, to, vouchee, context string) VoucherToken { 54 | ident, err := CallerIdentityUser(sess) 55 | if err != nil { 56 | log.Panicf("error getting aws user identity: %+v\n", err) 57 | } 58 | 59 | token := CreateToken(sess, TokenParams{ 60 | FromId: ident.UserId, 61 | FromAccount: ident.AccountId, 62 | FromName: ident.Username, 63 | To: to, 64 | Type: ident.Type, 65 | Vouchee: vouchee, 66 | Context: context, 67 | }, kmsKeyId) 68 | 69 | return VoucherToken(token) 70 | } 71 | 72 | --------------------------------------------------------------------------------