Compare commits

...

12 Commits

Author SHA1 Message Date
Dustin fe19559964 server: Add /user/ca route
dustin/sshca/pipeline/head This commit looks good Details
The _GET /user/ca_ operation returns the public key of the user CA.
This can be used by hosts to "bootstrap" their trusted signing keys for
user authentication.
2024-11-09 13:40:03 -06:00
Dustin f8f8218537 ci: Auto deploy latest build of master
dustin/sshca/pipeline/head This commit looks good Details
After building and publishing a new "latest" container image, we can
automatically deploy it to production by restarting the *sshca*
Deployment.  We use `curl` here to patch the Deployment manually, since
`kubectl` is not available in the container image.  This is a simple
enough operation that it's not worth the effort of trying to install it.
2024-02-03 10:07:29 -06:00
Dustin 748f4dba9a user: Allow adding principals by group membership
dustin/sshca/pipeline/head This commit looks good Details
In some cases, users may need to authenticate as a different user on the
remote machine than their normal username. For example, the default user
*core* on a Fedora CoreOS machine, or the *root* user on machines that
have not been provisioned yet. In such cases, the default set of
principals on issued user certificates is not sufficient.

We don't want to allow users to specify arbitrary principals, so instead
we can use their membership in specific groups to add a preselected set
of principals.

Since the `groups` claim is not part of the core OpenID Connect
specification, we have to define it ourselves as part of the "additional
claims" of the token.  This is somewhat cumbersome and involves a lot of
copying from the core type aliases, but otherwise straightforward.
2024-02-02 20:00:32 -06:00
Dustin f9ebbbcce9 ca/user: Add certificate extensions
dustin/sshca/pipeline/head This commit looks good Details
According to the *sshd(8)* manual page:

> Certificates may encode access restrictions similar to these key
> options.  If both certificate restrictions and key options are
> present, the most restrictive union of the two is applied.

This would seem to apply that if a certificate has no restrictions, all
features are allowed unless restricted in the `authorized_keys` file.
Unfortunately, this is not actually the case.  A certificate with no
extensions apparently trumps all other configuration.  As such,
certificates need to explicitly list the features users will need.

The list of extensions to add to user certificates is configurable via
the `ca.user.extensions` array.  The default set should provide a good
user experience without being overly permissive.
2024-02-01 09:05:04 -06:00
Dustin f38f9d9f6e ci: Fix container image repo name
dustin/sshca/pipeline/head This commit looks good Details
The SSHCA container image is supposed to be published to the *packages*
organization, since it is an image for custom software, as opposed to a
customized OS.
2024-01-31 20:56:15 -06:00
Dustin 4b7c8c3d5a container: Install openssl-devel in build stage
dustin/sshca/pipeline/head This commit looks good Details
The OpenID Connect integration introduced a dependency on the system
OpenSSL library.  As such, the OpenSSL development package needs ot be
available when building the application.
2024-01-31 20:35:40 -06:00
Dustin be40c05b56 server/user: Add sign cert operation
dustin/sshca/pipeline/head There was a failure building this commit Details
The *POST /user/sign* operation issues SSH user certificates for the
public keys provided.  The request must include a valid OpenID Connect
Identity token in the `Authorization` request header, which will be used
to populate the valid principals in the signed certificate.

User certificates are typically issued for a very short duration (one
hour by default).  This precludes the need for revoking certificates
that are no longer trusted; users must reauthenticate frequently and
obtain a new certificate.
2023-11-21 22:03:02 -06:00
Dustin b945d0f142 server: Move SignKeyError to error module
The `SignKeyError` enum will also be used by the request handler for
signing SSH user certificates.  Thus, I am moving it to its own module
where it can be accessed from both places.
2023-11-21 22:03:02 -06:00
Dustin cd7a7272ef ca: Add sign_user_cert function
The `sshca::ca::sign_cert` function has been renamed to
`sign_host_cert`, reflecting that it creates SSH host certificates.  A
new `sign_user_cert` function is now available to sign SSH user
certificates.
2023-11-21 22:03:02 -06:00
Dustin 94ae6f727e server: user: Implement OIDC auth for users
The second major feature for SSHCA will be the ability to sign SSH
certificates for users.  Naturally, users will need to prove their
identity to the server in order for it to issue certificates for them.
To implement that, we will use OpenID Connect Identity Tokens.  Users
will obtain a token from an Identity Provider and include it in their
request to the SSHCA server.  If the token is valid and issued by a
trusted provider, the server will sign the user's keys.

The `openidconnect` crate provides everything we need to validate OIDC
ID tokens.  It supports fetching the OpenID Provider Configuration in
order to retrieve the signing keys.  These keys are then used to
verify the signature of a token; other token metadata are verified as
well, including issuer, audience, and expiration.

To avoid making an HTTP request to the OIDC IdP for every request, the
provider configuration is cached for an hour after each lookup.

Clients, such as the `sshca` CLI utility, can use the *GET
/user/oidc-config* HTTP path operation to fetch the SSHCA server's
OpenID Connect client configuration.  The can use the information
returned to initiate a login process with the IdP and obtain the
identity token to submit to the SSHCA server.
2023-11-21 22:03:02 -06:00
Dustin 839d756a28 server: Move auth logic to host module
In preparation for adding a separate authorization strategy for client
requests, I have moved the implementation of the authorization strategy
for host requests in to the `server::host` module.
2023-11-16 19:58:26 -06:00
Dustin 818cfc94c2 ci: Import ci pipeline from original repo
dustin/sshca/pipeline/head This commit looks good Details
When this repository was split from the original *dustin/sshca*
repository, the CI pipeline was not imported.  It wouldn't have mattered
if it had been, since it wouldn't have worked, anyway, given the path
changes.
2023-11-13 20:11:49 -06:00
20 changed files with 1464 additions and 149 deletions

View File

@ -8,3 +8,8 @@ trim_trailing_whitespace = true
[**.rs]
max_line_length = 79
[Jenkinsfile]
max_line_length = 79
indent_style = space
indent_size = 4

677
Cargo.lock generated
View File

@ -61,6 +61,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "argh"
version = "0.1.12"
@ -324,6 +339,21 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "chrono"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-targets",
]
[[package]]
name = "cipher"
version = "0.4.4"
@ -340,6 +370,22 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f"
[[package]]
name = "core-foundation"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
[[package]]
name = "cpufeatures"
version = "0.2.11"
@ -394,6 +440,7 @@ dependencies = [
"platforms",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
@ -407,6 +454,41 @@ dependencies = [
"syn",
]
[[package]]
name = "darling"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "dashmap"
version = "5.5.3"
@ -414,7 +496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown",
"hashbrown 0.14.2",
"lock_api",
"once_cell",
"parking_lot_core",
@ -427,9 +509,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -463,6 +556,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "dyn-clone"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d"
[[package]]
name = "ecdsa"
version = "0.16.8"
@ -483,6 +582,7 @@ version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
@ -494,9 +594,17 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"zeroize",
]
[[package]]
name = "either"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]]
name = "elliptic-curve"
version = "0.13.6"
@ -509,6 +617,8 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core",
"sec1",
@ -569,6 +679,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form-data-builder"
version = "1.0.1"
@ -683,8 +808,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -714,6 +841,31 @@ dependencies = [
"subtle",
]
[[package]]
name = "h2"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap 2.1.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.2"
@ -744,6 +896,27 @@ dependencies = [
"http",
]
[[package]]
name = "hermit-abi"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
@ -797,6 +970,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
@ -810,6 +984,69 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "iana-time-zone"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.1.0"
@ -817,7 +1054,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.14.2",
"serde",
]
[[package]]
@ -830,6 +1068,21 @@ dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.9"
@ -976,6 +1229,24 @@ dependencies = [
"version_check",
]
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -1034,6 +1305,36 @@ dependencies = [
"libm",
]
[[package]]
name = "num_cpus"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "oauth2"
version = "4.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f"
dependencies = [
"base64 0.13.1",
"chrono",
"getrandom",
"http",
"rand",
"reqwest",
"serde",
"serde_json",
"serde_path_to_error",
"sha2",
"thiserror",
"url",
]
[[package]]
name = "object"
version = "0.32.1"
@ -1055,12 +1356,97 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openidconnect"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62d6050f6a84b81f23c569f5607ad883293e57491036e318fafe6fc4895fadb1"
dependencies = [
"base64 0.13.1",
"chrono",
"dyn-clone",
"ed25519-dalek",
"hmac",
"http",
"itertools",
"log",
"oauth2",
"p256",
"p384",
"rand",
"rsa",
"serde",
"serde-value",
"serde_derive",
"serde_json",
"serde_path_to_error",
"serde_plain",
"serde_with",
"sha2",
"subtle",
"thiserror",
"url",
]
[[package]]
name = "openssl"
version = "0.10.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a257ad03cd8fb16ad4172fedf8094451e1af1c4b70097636ef2eac9a5f0cc33"
dependencies = [
"bitflags 2.4.1",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a4130519a360279579c2053038317e40eff64d13fd3f004f9e1b72b8a6aaf9"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "overload"
version = "0.1.1"
@ -1237,6 +1623,12 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@ -1364,6 +1756,44 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "reqwest"
version = "0.11.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b"
dependencies = [
"base64 0.21.5",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"hyper-tls",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"serde_urlencoded",
"system-configuration",
"tokio",
"tokio-native-tls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
@ -1450,6 +1880,15 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
[[package]]
name = "schannel"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88"
dependencies = [
"windows-sys",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -1470,6 +1909,29 @@ dependencies = [
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.20"
@ -1485,6 +1947,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_derive"
version = "1.0.190"
@ -1517,6 +1989,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_plain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "0.6.4"
@ -1538,6 +2019,35 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23"
dependencies = [
"base64 0.21.5",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.1.0",
"serde",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serial_test"
version = "2.0.0"
@ -1731,6 +2241,7 @@ dependencies = [
"form-data-builder",
"hyper",
"jsonwebtoken",
"openidconnect",
"rand_core",
"serde",
"serde_json",
@ -1746,6 +2257,12 @@ dependencies = [
"virt",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "subtle"
version = "2.5.0"
@ -1769,6 +2286,27 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "system-configuration"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tempfile"
version = "3.8.1"
@ -1812,6 +2350,50 @@ dependencies = [
"once_cell",
]
[[package]]
name = "time"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5"
dependencies = [
"deranged",
"itoa",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20"
dependencies = [
"time-core",
]
[[package]]
name = "tinyvec"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.33.0"
@ -1822,6 +2404,7 @@ dependencies = [
"bytes",
"libc",
"mio",
"num_cpus",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.5.5",
@ -1840,6 +2423,30 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
"tracing",
]
[[package]]
name = "toml"
version = "0.8.6"
@ -1867,7 +2474,7 @@ version = "0.20.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
dependencies = [
"indexmap",
"indexmap 2.1.0",
"serde",
"serde_spanned",
"toml_datetime",
@ -1976,12 +2583,27 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "unicode-bidi"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-normalization"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
dependencies = [
"tinyvec",
]
[[package]]
name = "universal-hash"
version = "0.5.1"
@ -1998,6 +2620,18 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "url"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "uuid"
version = "1.5.0"
@ -2010,6 +2644,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"
@ -2076,6 +2716,18 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.88"
@ -2137,6 +2789,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.51.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@ -2212,6 +2873,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if",
"windows-sys",
]
[[package]]
name = "zeroize"
version = "1.6.0"

View File

@ -9,6 +9,7 @@ argon2 = { version = "0.5.2", default-features = false, features = ["alloc"] }
axum = { version = "0.6.20", features = ["multipart", "headers", "json"] }
dirs = "5.0.1"
jsonwebtoken = { version = "8.3.0", default-features = false }
openidconnect = { version = "3.4.0", default-features = false, features = ["native-tls", "reqwest"] }
rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.190", features = ["derive"] }
serde_json = "1.0.108"

View File

@ -6,6 +6,7 @@ RUN --mount=type=cache,target=/var/cache \
cargo \
libvirt-devel \
rust \
openssl-devel \
&& :
COPY . /build

57
ci/Jenkinsfile vendored Normal file
View File

@ -0,0 +1,57 @@
pipeline {
agent none
stages {
stage('SSHCA') {
stages {
stage('Server') {
agent {
kubernetes {
yamlFile 'ci/podTemplate.yaml'
yamlMergeStrategy merge()
defaultContainer 'buildah'
}
}
stages {
stage('Build') {
steps {
sh '. ci/build.sh'
}
}
stage('Publish') {
steps {
withEnv([
"REGISTRY_AUTH_FILE=${env.WORKSPACE_TMP}/auth.json"
]) {
withCredentials([usernamePassword(
credentialsId: 'jenkins-packages',
usernameVariable: 'BUILDAH_USERNAME',
passwordVariable: 'BUILDAH_PASSWORD',
)]) {
sh """
buildah login \
--username \${BUILDAH_USERNAME} \
--password \${BUILDAH_PASSWORD} \
git.pyrocufflink.net
"""
}
sh '. ci/publish.sh'
}
}
}
stage('Deploy') {
when {
branch 'master'
}
steps {
sh '. ci/deploy.sh'
}
}
}
}
}
}
}
}

5
ci/build.sh Normal file
View File

@ -0,0 +1,5 @@
#!/bin/sh
. ci/common.sh
buildah build -t "${IMAGE_NAME}:${TAG}" .

13
ci/common.sh Normal file
View File

@ -0,0 +1,13 @@
escape_name() {
echo "$1" \
| tr A-Z a-z \
| sed -e 's/[^a-zA-Z0-9._-]/-/g' -e 's/^[.-]/_/'
}
REGISTRY_URL=git.pyrocufflink.net
NAMESPACE=packages
NAME="${JOB_NAME#*/}"
NAME=$(escape_name "${NAME%/*}")
TAG=$(escape_name "${BRANCH_NAME}")
IMAGE_NAME="${REGISTRY_URL}/${NAMESPACE}/${NAME}"

24
ci/deploy.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/sh
namespace=sshca
name=sshca
now=$(date +%Y-%m-%dT%H:%M:%S%:z)
curl https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}/apis/apps/v1/namespaces/${namespace}/deployments/${name} \
--cacert /run/secrets/kubernetes.io/serviceaccount/ca.crt \
-X PATCH \
-H "Authorization: Bearer $(cat /run/secrets/kubernetes.io/serviceaccount/token)" \
-H 'Content-Type: application/merge-patch+json' \
-H 'Accept: application/json' \
-d '{
"spec": {
"template": {
"metadata": {
"annotations": {
"kubectl.kubernetes.io/restartedAt": "'"${now}"'"
}
}
}
}
}'

19
ci/podTemplate.yaml Normal file
View File

@ -0,0 +1,19 @@
spec:
containers:
- name: buildah
image: quay.io/containers/buildah:v1
command:
- cat
stdin: true
tty: true
securityContext:
capabilities:
add:
- SYS_ADMIN
- MKNOD
- SYS_CHROOT
- SETFCAP
resources:
limits:
github.com/fuse: 1
hostUsers: false

11
ci/publish.sh Normal file
View File

@ -0,0 +1,11 @@
#!/bin/sh
. ci/common.sh
buildah push "${IMAGE_NAME}:${TAG}" "${IMAGE_NAME}:${TAG}-${BUILD_NUMBER}"
buildah push "${IMAGE_NAME}:${TAG}"
case "${BRANCH_NAME}" in
master|main)
buildah push "${IMAGE_NAME}:${TAG}" "${IMAGE_NAME}:latest"
;;
esac

View File

@ -19,7 +19,7 @@ use uuid::Uuid;
/// JWT Token Claims
#[derive(Debug, Deserialize)]
pub struct Claims {
pub struct HostClaims {
/// Token subject (machine hostname)
pub sub: String,
}
@ -34,7 +34,7 @@ pub fn get_token_subject(token: &str) -> Result<String> {
v.insecure_disable_signature_validation();
v.set_required_spec_claims(&["sub"]);
let k = DecodingKey::from_secret(b"");
let data: TokenData<Claims> = decode(token, &k, &v)?;
let data: TokenData<HostClaims> = decode(token, &k, &v)?;
Ok(data.claims.sub)
}
@ -46,12 +46,12 @@ pub fn get_token_subject(token: &str) -> Result<String> {
/// `service` argument, and is within its validity period (not before/expires).
/// The token must be signed with HMAC-SHA256 using the host's machine ID as
/// the secret key.
pub fn validate_token(
pub fn validate_host_token(
token: &str,
hostname: &str,
machine_id: &Uuid,
service: &str,
) -> Result<Claims> {
) -> Result<HostClaims> {
let mut v = Validation::new(Algorithm::HS256);
v.validate_nbf = true;
v.set_issuer(&[hostname]);
@ -66,7 +66,7 @@ pub fn validate_token(
OsRng.fill_bytes(&mut secret);
}
let k = DecodingKey::from_secret(&secret);
let data: TokenData<Claims> = decode(token, &k, &v)?;
let data: TokenData<HostClaims> = decode(token, &k, &v)?;
Ok(data.claims)
}
@ -130,7 +130,12 @@ pub(crate) mod test {
let machine_id = uuid!("9afd42e5-4ac3-4530-90c4-191869063ef9");
let token = make_token(hostname, machine_id);
validate_token(&token, hostname, &machine_id, "sshca.example.org")
.unwrap();
validate_host_token(
&token,
hostname,
&machine_id,
"sshca.example.org",
)
.unwrap();
}
}

View File

@ -137,7 +137,7 @@ pub fn parse_public_key(data: &[u8]) -> Result<PublicKey, LoadKeyError> {
/// This function creates a signed certificate for an SSH host public
/// key. The certificate will be valid for the specified hostname and
/// any alias names provided.
pub fn sign_cert(
pub fn sign_host_cert(
hostname: &str,
pubkey: &PublicKey,
duration: Duration,
@ -160,6 +160,38 @@ pub fn sign_cert(
Ok(builder.sign(privkey)?)
}
/// Create a signed SSH certificate for a user public key
///
/// This function creates a signed certificate for an SSH user public
/// key. The certificate will be valid for the specified username and
/// any alias names provided.
pub fn sign_user_cert(
username: &str,
pubkey: &PublicKey,
duration: Duration,
privkey: &PrivateKey,
alias: &[&str],
extensions: &[impl AsRef<str>],
) -> Result<Certificate, CertError> {
let now = SystemTime::now();
let not_before = now.duration_since(UNIX_EPOCH)?.as_secs();
let not_after = not_before + duration.as_secs();
let mut builder = Builder::new_with_random_nonce(
&mut OsRng, pubkey, not_before, not_after,
)?;
builder.cert_type(CertType::User)?;
builder.valid_principal(username)?;
for a in alias {
builder.valid_principal(*a)?;
}
for e in extensions {
builder.extension(e.as_ref(), "")?;
}
Ok(builder.sign(privkey)?)
}
#[cfg(test)]
mod test {
use ssh_key::Algorithm;
@ -175,9 +207,9 @@ mod test {
let host_pub_key = host_key.public_key();
let duration = Duration::from_secs(86400 * 30);
let hostname = "cloud0.example.org";
let cert = sign_cert(
let cert = sign_host_cert(
hostname,
&host_pub_key,
host_pub_key,
duration,
&ca_key,
&["nextcloud.example.org"],

View File

@ -1,4 +1,5 @@
//! Application configuration
use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
@ -65,11 +66,57 @@ impl Default for HostCaConfig {
}
}
#[derive(Debug, Deserialize)]
pub struct UserCaConfig {
/// Path to the User CA private key file
#[serde(default = "default_user_ca_key")]
pub private_key_file: PathBuf,
pub private_key_passphrase_file: Option<PathBuf>,
/// Duration of issued user certificates
#[serde(default = "default_user_cert_duration")]
pub cert_duration: u64,
/// Certificate extensions
#[serde(default = "default_user_cert_extensions")]
pub extensions: Vec<String>,
/// Additional principals to add based on user's group membership
#[serde(default)]
pub group_principals: HashMap<String, Vec<String>>
}
impl Default for UserCaConfig {
fn default() -> Self {
Self {
private_key_file: default_user_ca_key(),
private_key_passphrase_file: None,
cert_duration: default_user_cert_duration(),
extensions: default_user_cert_extensions(),
group_principals: Default::default(),
}
}
}
/// CA configuration
#[derive(Debug, Default, Deserialize)]
pub struct CaConfig {
/// Host CA configuration
pub host: HostCaConfig,
/// User CA configuration
pub user: UserCaConfig,
}
/// OpenID Connect configuration
#[derive(Debug, Deserialize)]
pub struct OidcConfig {
/// OIDC Discovery base URL (without /.well-known/...)
pub discovery_url: String,
/// OAuth2 client ID
pub client_id: String,
/// OAuth2 client secret
#[serde(default)]
pub client_secret: Option<String>,
}
/// Defines a connection to a libvirt VM host
@ -91,6 +138,9 @@ pub struct Configuration {
/// CA configuration
#[serde(default)]
pub ca: CaConfig,
/// OpenID Connect configuration for user authorization
#[serde(default)]
pub oidc: Option<OidcConfig>,
}
impl Default for Configuration {
@ -99,6 +149,7 @@ impl Default for Configuration {
libvirt: vec![],
machine_ids: default_machine_ids(),
ca: Default::default(),
oidc: Default::default(),
}
}
}
@ -123,6 +174,24 @@ fn default_host_cert_duration() -> u64 {
86400 * 30
}
fn default_user_ca_key() -> PathBuf {
default_config_path("user-ca.key")
}
fn default_user_cert_duration() -> u64 {
3600
}
fn default_user_cert_extensions() -> Vec<String> {
vec![
"permit-X11-forwarding".into(),
"permit-agent-forwarding".into(),
"permit-port-forwarding".into(),
"permit-pty".into(),
"permit-user-rc".into(),
]
}
/// Load configuration from a TOML file
///
/// If `path` is provided, the configuration will be loaded from the

75
src/server/error.rs Normal file
View File

@ -0,0 +1,75 @@
//! SSHCA HTTP server errors
use axum::extract::multipart::MultipartError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use tracing::{debug, error};
use crate::ca;
/// Error encountered while signing a key
pub enum SignKeyError {
/// Failed to parse HTTP multipart form request
Multipart(MultipartError),
/// Error handing SSH certificate
Cert(ca::CertError),
/// Error loading SSH private key file
LoadPrivateKey(ca::LoadKeyError),
/// Error parsing SSH public key
ParsePublicKey(ca::LoadKeyError),
/// Unsupported SSH key algorithm
UnsupportedAlgorithm(String),
/// No SSH public key included in request
NoKey,
}
impl From<MultipartError> for SignKeyError {
fn from(e: MultipartError) -> Self {
Self::Multipart(e)
}
}
impl From<ca::CertError> for SignKeyError {
fn from(e: ca::CertError) -> Self {
Self::Cert(e)
}
}
impl IntoResponse for SignKeyError {
fn into_response(self) -> Response {
match self {
Self::Multipart(e) => {
debug!("Error reading request: {}", e);
let body = e.to_string();
(StatusCode::BAD_REQUEST, body).into_response()
}
Self::Cert(e) => {
error!("Failed to sign certificate: {}", e);
let body = "Service Unavailable";
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
}
Self::LoadPrivateKey(e) => {
error!("Error loading CA private key: {}", e);
let body = "Service Unavailable";
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
}
Self::ParsePublicKey(e) => {
error!("Error parsing public keykey: {}", e);
let body = e.to_string();
(StatusCode::BAD_REQUEST, body).into_response()
}
Self::UnsupportedAlgorithm(a) => {
debug!("Requested certificate for unsupported key algorithm \"{}\"", a);
let body = format!("Unsupported key algorithm: {}", a);
(StatusCode::BAD_REQUEST, body).into_response()
}
Self::NoKey => {
debug!("No SSH public key provided in request");
(
StatusCode::BAD_REQUEST,
"No SSH public key provided in request",
)
.into_response()
}
}
}
}

View File

@ -1,16 +1,25 @@
use std::collections::HashMap;
use std::time::Duration;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::multipart::{Multipart, MultipartError};
use axum::async_trait;
use axum::extract::multipart::Multipart;
use axum::extract::FromRequestParts;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::headers::authorization::Bearer;
use axum::headers::{Authorization, Host};
use axum::http::request::Parts;
use axum::{RequestPartsExt, TypedHeader};
use serde::Serialize;
use ssh_key::Algorithm;
use tracing::{debug, error, info, warn};
use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::auth::Claims;
use super::error::SignKeyError;
use super::{AuthError, Context};
use crate::auth::{self, HostClaims};
use crate::ca;
use crate::machine_id;
#[derive(Serialize)]
pub struct SignKeyResponse {
@ -19,65 +28,68 @@ pub struct SignKeyResponse {
certificates: HashMap<String, String>,
}
pub enum SignKeyError {
Multipart(MultipartError),
Cert(ca::CertError),
LoadPrivateKey(ca::LoadKeyError),
ParsePublicKey(ca::LoadKeyError),
UnsupportedAlgorithm(String),
NoKey,
}
#[async_trait]
impl FromRequestParts<Arc<Context>> for HostClaims {
type Rejection = AuthError;
impl From<MultipartError> for SignKeyError {
fn from(e: MultipartError) -> Self {
Self::Multipart(e)
async fn from_request_parts(
parts: &mut Parts,
ctx: &super::State,
) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|e| {
debug!("Failed to extract token from HTTP request: {}", e);
AuthError
})?;
let host = parts.extract::<TypedHeader<Host>>().await.map_or_else(
|_| "localhost".to_owned(),
|v| v.hostname().to_owned(),
);
let hostname =
auth::get_token_subject(bearer.token()).map_err(|e| {
debug!("Could not get token subject: {}", e);
AuthError
})?;
let machine_id =
get_machine_id(&hostname, ctx).await.ok_or_else(|| {
debug!("No machine ID found for host {}", hostname);
AuthError
})?;
let claims = auth::validate_host_token(
bearer.token(),
&hostname,
&machine_id,
&host,
)
.map_err(|e| {
debug!("Invalid auth token: {}", e);
AuthError
})?;
debug!("Successfully authenticated request from host {}", hostname);
Ok(claims)
}
}
impl From<ca::CertError> for SignKeyError {
fn from(e: ca::CertError) -> Self {
Self::Cert(e)
}
}
impl IntoResponse for SignKeyError {
fn into_response(self) -> Response {
match self {
Self::Multipart(e) => {
debug!("Error reading request: {}", e);
let body = e.to_string();
(StatusCode::BAD_REQUEST, body).into_response()
}
Self::Cert(e) => {
error!("Failed to sign certificate: {}", e);
let body = "Service Unavailable";
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
}
Self::LoadPrivateKey(e) => {
error!("Error loading CA private key: {}", e);
let body = "Service Unavailable";
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
}
Self::ParsePublicKey(e) => {
error!("Error parsing public keykey: {}", e);
let body = e.to_string();
(StatusCode::BAD_REQUEST, body).into_response()
}
Self::UnsupportedAlgorithm(a) => {
debug!("Requested certificate for unsupported key algorithm \"{}\"", a);
let body = format!("Unsupported key algorithm: {}", a);
(StatusCode::BAD_REQUEST, body).into_response()
}
Self::NoKey => {
debug!("No SSH public key provided in request");
(
StatusCode::BAD_REQUEST,
"No SSH public key provided in request",
)
.into_response()
}
async fn get_machine_id(hostname: &str, ctx: &super::State) -> Option<Uuid> {
let cache = ctx.cache.read().await;
if let Some((ts, m)) = cache.get(hostname) {
if ts.elapsed() < Duration::from_secs(60) {
debug!("Found cached machine ID for {}", hostname);
return Some(*m);
} else {
debug!("Cached machine ID for {} has expired", hostname);
}
}
drop(cache);
let machine_id =
machine_id::get_machine_id(hostname, ctx.config.clone()).await?;
let mut cache = ctx.cache.write().await;
debug!("Caching machine ID for {}", hostname);
cache.insert(hostname.into(), (Instant::now(), machine_id));
Some(machine_id)
}
#[derive(Default)]
@ -87,7 +99,7 @@ struct SignKeyRequest {
}
pub(super) async fn sign_host_cert(
claims: Claims,
claims: HostClaims,
State(ctx): State<super::State>,
mut form: Multipart,
) -> Result<String, SignKeyError> {
@ -137,7 +149,7 @@ pub(super) async fn sign_host_cert(
hostname
);
let cert =
ca::sign_cert(&hostname, &pubkey, duration, &privkey, &[])?;
ca::sign_host_cert(&hostname, &pubkey, duration, &privkey, &[])?;
info!(
"Signed {} key for {}",
pubkey.algorithm().as_str(),

View File

@ -1,29 +1,26 @@
mod error;
mod host;
mod oidc;
mod user;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Instant;
use axum::async_trait;
use axum::extract::FromRequestParts;
use axum::headers::authorization::Bearer;
use axum::headers::{Authorization, Host};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{RequestPartsExt, Router, TypedHeader};
use axum::Router;
use openidconnect::core::CoreProviderMetadata;
use tokio::sync::RwLock;
use tracing::debug;
use uuid::Uuid;
use crate::auth::{self, Claims};
use crate::config::Configuration;
use crate::machine_id;
struct Context {
config: Arc<Configuration>,
cache: RwLock<HashMap<String, (Instant, Uuid)>>,
oidc: RwLock<Option<(Instant, CoreProviderMetadata)>>,
}
type State = Arc<Context>;
@ -36,81 +33,21 @@ impl IntoResponse for AuthError {
}
}
#[async_trait]
impl FromRequestParts<Arc<Context>> for Claims {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
ctx: &State,
) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|e| {
debug!("Failed to extract token from HTTP request: {}", e);
AuthError
})?;
let host = parts.extract::<TypedHeader<Host>>().await.map_or_else(
|_| "localhost".to_owned(),
|v| v.hostname().to_owned(),
);
let hostname =
auth::get_token_subject(bearer.token()).map_err(|e| {
debug!("Could not get token subject: {}", e);
AuthError
})?;
let machine_id =
get_machine_id(&hostname, ctx).await.ok_or_else(|| {
debug!("No machine ID found for host {}", hostname);
AuthError
})?;
let claims = auth::validate_token(
bearer.token(),
&hostname,
&machine_id,
&host,
)
.map_err(|e| {
debug!("Invalid auth token: {}", e);
AuthError
})?;
debug!("Successfully authenticated request from host {}", hostname);
Ok(claims)
}
}
pub fn make_app(config: Configuration) -> Router {
let ctx = Arc::new(Context {
config: config.into(),
cache: RwLock::new(Default::default()),
oidc: Default::default(),
});
Router::new()
.route("/", get(|| async { "UP" }))
.route("/host/sign", post(host::sign_host_cert))
.route("/user/oidc-config", get(user::get_oidc_config))
.route("/user/sign", post(user::sign_user_cert))
.route("/user/ca", get(user::get_ca_pubkey))
.with_state(ctx)
}
async fn get_machine_id(hostname: &str, ctx: &State) -> Option<Uuid> {
let cache = ctx.cache.read().await;
if let Some((ts, m)) = cache.get(hostname) {
if ts.elapsed() < Duration::from_secs(60) {
debug!("Found cached machine ID for {}", hostname);
return Some(*m);
} else {
debug!("Cached machine ID for {} has expired", hostname);
}
}
drop(cache);
let machine_id =
machine_id::get_machine_id(hostname, ctx.config.clone()).await?;
let mut cache = ctx.cache.write().await;
debug!("Caching machine ID for {}", hostname);
cache.insert(hostname.into(), (Instant::now(), machine_id));
Some(machine_id)
}
#[cfg(test)]
mod test {
use axum::body::Body;

55
src/server/oidc.rs Normal file
View File

@ -0,0 +1,55 @@
use openidconnect::core::*;
use openidconnect::*;
use serde::{Deserialize, Serialize};
pub type IdTokenFields = openidconnect::IdTokenFields<
AdditionalClaims,
EmptyExtraTokenFields,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
CoreJsonWebKeyType,
>;
pub type IdToken = openidconnect::IdToken<
AdditionalClaims,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
CoreJsonWebKeyType,
>;
pub type IdTokenClaims =
openidconnect::IdTokenClaims<AdditionalClaims, CoreGenderClaim>;
pub type TokenResponse = StandardTokenResponse<IdTokenFields, CoreTokenType>;
pub type Client = openidconnect::Client<
AdditionalClaims,
CoreAuthDisplay,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
CoreJsonWebKeyType,
CoreJsonWebKeyUse,
CoreJsonWebKey,
CoreAuthPrompt,
StandardErrorResponse<CoreErrorResponseType>,
TokenResponse,
CoreTokenType,
CoreTokenIntrospectionResponse,
CoreRevocableToken,
CoreRevocationErrorResponse,
>;
#[derive(Serialize, Deserialize, Debug)]
pub struct AdditionalClaims {
groups: Vec<String>,
}
impl AdditionalClaims {
pub fn groups(&self) -> &Vec<String> {
&self.groups
}
}
impl openidconnect::AdditionalClaims for AdditionalClaims {}

284
src/server/user.rs Normal file
View File

@ -0,0 +1,284 @@
//! User CA operations
use std::str::FromStr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::async_trait;
use axum::extract::multipart::Multipart;
use axum::extract::FromRequestParts;
use axum::extract::State;
use axum::headers::authorization::Bearer;
use axum::headers::Authorization;
use axum::http::request::Parts;
use axum::Json;
use axum::{RequestPartsExt, TypedHeader};
use openidconnect::core::CoreProviderMetadata;
use openidconnect::reqwest::async_http_client;
use openidconnect::IssuerUrl;
use openidconnect::Nonce;
use openidconnect::{ClientId, ClientSecret};
use serde::Serialize;
use ssh_key::Algorithm;
use tracing::{debug, error, info, trace, warn};
use super::error::SignKeyError;
use super::oidc;
use super::{AuthError, Context};
use crate::ca;
/// Response type for GET /user/openid-config
///
/// This structure contains OpenID configuration information for
/// client utilities.
#[derive(Debug, Default, Serialize)]
pub struct OidcConfigResponse {
url: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
}
/// OpenID Connect ID token claims
pub struct Claims(oidc::IdTokenClaims);
/// Axum request extractor for OIDC ID tokens in Authorization headers
///
/// This extractor parses an OpenID Connect identity token (as a JWT)
/// from the `Authorization` HTTP request header. If the token is
/// valid, a [`Claims`] structure is returned. If the token is not
/// valid (e.g. signed by an untrusted key, expired, for a different
/// audience, etc), [`AuthError`] is returned.
#[async_trait]
impl FromRequestParts<Arc<Context>> for Claims {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
ctx: &super::State,
) -> Result<Self, Self::Rejection> {
let config = &ctx.config;
let oidc_config = &config.oidc.as_ref().ok_or_else(|| {
warn!("OpenID Connect not configured");
AuthError
})?;
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|e| {
debug!("Failed to extract token from HTTP request: {}", e);
AuthError
})?;
let token = oidc::IdToken::from_str(bearer.token()).map_err(|e| {
debug!("Failed to parse OIDC ID token: {}", e);
AuthError
})?;
let client_id = &oidc_config.client_id;
let client_secret = &oidc_config.client_secret;
let provider_metadata = get_metadata(ctx).await.ok_or(AuthError)?;
let client = oidc::Client::from_provider_metadata(
provider_metadata,
ClientId::new(client_id.into()),
client_secret.as_ref().map(|s| ClientSecret::new(s.into())),
);
let verifier = client.id_token_verifier();
let claims = token
// Ignore the token nonce, as we have no way of validating it.
.into_claims(&verifier, |_: Option<&Nonce>| Ok(()))
.map_err(|e| {
debug!("Invalid ID token: {}", e);
AuthError
})?;
trace!("Token Claims: {:?}", claims);
debug!(
"Successfully authorized user {} (issuer {})",
claims.subject().as_str(),
claims.issuer().as_str()
);
Ok(Claims(claims))
}
}
/// Retrieve OpenID Connect client configuration
///
/// Clients can access this resource to determine the appropriate
/// configuration for the OpenID Connect identity provider where they
/// can obtain identity tokens.
pub(super) async fn get_oidc_config(
State(ctx): State<super::State>,
) -> Json<OidcConfigResponse> {
let config = &ctx.config;
let res = if let Some(oidc) = &config.oidc {
OidcConfigResponse {
url: Some(oidc.discovery_url.clone()),
client_id: Some(oidc.client_id.clone()),
client_secret: oidc.client_secret.clone(),
}
} else {
Default::default()
};
Json(res)
}
/// User SSH key signing request payload
#[derive(Default)]
struct SignKeyRequest {
/// Public keys to sign
pubkey: Vec<u8>,
}
/// Handler for user certificate signing requests
///
/// An SSH user certificate will be signed for each submitted public
/// key. The valid principals on the certificates will be taken from
/// the OpenID Connect Identity Token in the Authorization header, via
/// the `sub`, `perferred_username`, and `email` claims (if present).
pub(super) async fn sign_user_cert(
Claims(claims): Claims,
State(ctx): State<super::State>,
mut form: Multipart,
) -> Result<String, SignKeyError> {
let username = claims.subject().as_str();
let mut body = SignKeyRequest::default();
while let Some(field) = form.next_field().await? {
match field.name() {
Some("pubkey") => {
body.pubkey = field.bytes().await?.into();
}
Some(n) => {
warn!("Client request included unsupported field {:?}", n);
}
None => {}
}
}
if body.pubkey.is_empty() {
return Err(SignKeyError::NoKey);
}
let mut alias = vec![];
if let Some(username) = claims.preferred_username() {
alias.push(username.as_str());
}
if let Some(email) = claims.email() {
if claims.email_verified() == Some(true) {
alias.push(email.as_str());
}
}
let config = &ctx.config;
let duration = Duration::from_secs(config.ca.user.cert_duration);
let extensions = &config.ca.user.extensions;
for group in claims.additional_claims().groups() {
if let Some(principals) = config.ca.user.group_principals.get(group) {
debug!("Adding principals from group {}", group);
for p in principals {
alias.push(p.as_str())
}
}
}
let privkey = ca::load_private_key(
&config.ca.user.private_key_file,
config.ca.user.private_key_passphrase_file.as_ref(),
)
.await
.map_err(SignKeyError::LoadPrivateKey)?;
let pubkey = ca::parse_public_key(&body.pubkey)
.map_err(SignKeyError::ParsePublicKey)?;
match pubkey.algorithm() {
Algorithm::Ecdsa { .. } => (),
Algorithm::Ed25519 => (),
Algorithm::Rsa { .. } => (),
_ => {
return Err(SignKeyError::UnsupportedAlgorithm(
pubkey.algorithm().as_str().into(),
));
}
};
debug!(
"Signing {} key for {}",
pubkey.algorithm().as_str(),
username
);
let cert = ca::sign_user_cert(
username,
&pubkey,
duration,
&privkey,
&alias,
&extensions[..],
)?;
info!(
"Signed {} key for {}",
pubkey.algorithm().as_str(),
username
);
Ok(cert.to_openssh().map_err(ca::CertError::from)?)
}
/// Get the public key of the user CA
///
/// Returns a string representation of the CA public key. This can be
/// used by hosts to find the current public key to trust for
/// authenticating users.
pub(super) async fn get_ca_pubkey(
State(ctx): State<super::State>,
) -> Result<String, SignKeyError> {
let config = &ctx.config;
let privkey = ca::load_private_key(
&config.ca.user.private_key_file,
config.ca.user.private_key_passphrase_file.as_ref(),
)
.await
.map_err(SignKeyError::LoadPrivateKey)?;
let pubkey = privkey.public_key()
.to_openssh()
.map_err(ca::LoadKeyError::SshKey)
.map_err(SignKeyError::LoadPrivateKey)?;
Ok(format!("{}\n", pubkey))
}
/// Get OIDC provider metadata (possibly from cache)
///
/// This function will return metadata for the configured OIDC identity
/// provider. When called for the first time, it will initiate an
/// HTTP request to the provider's OpenID Provider Configuration
/// Document (i.e. `/.well-known/openid-configuration`). The result is
/// cached for 1 hour, so subsequent calls to this function will not
/// initiate another HTTP request, unless more than 1 hour has passed
/// since the first request.
///
/// If an error occurs while attempting to fetch the metadata, `None`
/// is returned.
async fn get_metadata(ctx: &super::State) -> Option<CoreProviderMetadata> {
let cache = ctx.oidc.read().await;
if let Some((ts, m)) = &*cache {
if ts.elapsed() < Duration::from_secs(3600) {
debug!("Using cached OIDC provider metadata");
return Some(m.clone());
}
};
let oidc_url = &ctx.config.oidc.as_ref()?.discovery_url;
debug!("Fetching OIDC provider metadata");
let metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(oidc_url.into()).unwrap(),
async_http_client,
)
.await;
match metadata {
Ok(m) => {
drop(cache);
debug!("Caching OIDC provider metadata");
let mut cache = ctx.oidc.write().await;
(*cache).replace((Instant::now(), m.clone()));
Some(m)
}
Err(e) => {
error!("Failed to get OIDC provider metadata: {}", e);
None
}
}
}

View File

@ -8,7 +8,7 @@ use ssh_key::{Algorithm, Fingerprint, PrivateKey, PublicKey};
use tempfile::NamedTempFile;
use tracing_subscriber::EnvFilter;
use sshca::config::Configuration;
use sshca::config::{Configuration, OidcConfig};
static INIT: Once = Once::new();
@ -24,6 +24,11 @@ fn gen_machine_ids() -> Result<NamedTempFile, Box<dyn Error>> {
fn gen_config(machine_ids: &Path, host_key: &Path) -> Configuration {
let mut config = Configuration {
machine_ids: machine_ids.to_str().unwrap().into(),
oidc: Some(OidcConfig {
discovery_url: "https://auth.example.org".into(),
client_id: "sshca".into(),
client_secret: None,
}),
..Default::default()
};
config.ca.host.private_key_file = host_key.to_str().unwrap().into();

34
tests/test_user.rs Normal file
View File

@ -0,0 +1,34 @@
mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use sshca::server::make_app;
use common::setup;
#[tokio::test]
async fn test_oidc_config() {
let (_ctx, config) = setup::setup().await.unwrap();
let app = make_app(config);
let res = app
.oneshot(
Request::builder()
.uri("/user/oidc-config")
.method("GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
let result: serde_json::Value =
serde_json::from_str(std::str::from_utf8(&body).unwrap()).unwrap();
assert_eq!(result["url"].as_str(), Some("https://auth.example.org"));
assert_eq!(result["client_id"].as_str(), Some("sshca"));
assert_eq!(result["client_secret"].as_str(), None);
}