Compare commits

..

5 Commits

Author SHA1 Message Date
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
11 changed files with 1205 additions and 149 deletions

677
Cargo.lock generated
View File

@ -61,6 +61,21 @@ dependencies = [
"memchr", "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]] [[package]]
name = "argh" name = "argh"
version = "0.1.12" version = "0.1.12"
@ -324,6 +339,21 @@ dependencies = [
"cpufeatures", "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]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@ -340,6 +370,22 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" 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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.11" version = "0.2.11"
@ -394,6 +440,7 @@ dependencies = [
"platforms", "platforms",
"rustc_version", "rustc_version",
"subtle", "subtle",
"zeroize",
] ]
[[package]] [[package]]
@ -407,6 +454,41 @@ dependencies = [
"syn", "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]] [[package]]
name = "dashmap" name = "dashmap"
version = "5.5.3" version = "5.5.3"
@ -414,7 +496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"hashbrown", "hashbrown 0.14.2",
"lock_api", "lock_api",
"once_cell", "once_cell",
"parking_lot_core", "parking_lot_core",
@ -427,9 +509,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"pem-rfc7468",
"zeroize", "zeroize",
] ]
[[package]]
name = "deranged"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3"
dependencies = [
"powerfmt",
"serde",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -463,6 +556,12 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "dyn-clone"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d"
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.16.8" version = "0.16.8"
@ -483,6 +582,7 @@ version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [ dependencies = [
"pkcs8",
"signature", "signature",
] ]
@ -494,9 +594,17 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519", "ed25519",
"serde",
"sha2", "sha2",
"zeroize",
] ]
[[package]]
name = "either"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]] [[package]]
name = "elliptic-curve" name = "elliptic-curve"
version = "0.13.6" version = "0.13.6"
@ -509,6 +617,8 @@ dependencies = [
"ff", "ff",
"generic-array", "generic-array",
"group", "group",
"hkdf",
"pem-rfc7468",
"pkcs8", "pkcs8",
"rand_core", "rand_core",
"sec1", "sec1",
@ -569,6 +679,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 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]] [[package]]
name = "form-data-builder" name = "form-data-builder"
version = "1.0.1" version = "1.0.1"
@ -683,8 +808,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -714,6 +841,31 @@ dependencies = [
"subtle", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.2" version = "0.14.2"
@ -744,6 +896,27 @@ dependencies = [
"http", "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]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.12.1"
@ -797,6 +970,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@ -810,6 +984,69 @@ dependencies = [
"want", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.1.0" version = "2.1.0"
@ -817,7 +1054,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.14.2",
"serde",
] ]
[[package]] [[package]]
@ -830,6 +1068,21 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.9" version = "1.0.9"
@ -976,6 +1229,24 @@ dependencies = [
"version_check", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@ -1034,6 +1305,36 @@ dependencies = [
"libm", "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]] [[package]]
name = "object" name = "object"
version = "0.32.1" version = "0.32.1"
@ -1055,12 +1356,97 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" 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]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "overload" name = "overload"
version = "0.1.1" version = "0.1.1"
@ -1237,6 +1623,12 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.17" version = "0.2.17"
@ -1364,6 +1756,44 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" 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]] [[package]]
name = "rfc6979" name = "rfc6979"
version = "0.4.0" version = "0.4.0"
@ -1450,6 +1880,15 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" 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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -1470,6 +1909,29 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "semver" name = "semver"
version = "1.0.20" version = "1.0.20"
@ -1485,6 +1947,16 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.190" version = "1.0.190"
@ -1517,6 +1989,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_plain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "0.6.4" version = "0.6.4"
@ -1538,6 +2019,35 @@ dependencies = [
"serde", "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]] [[package]]
name = "serial_test" name = "serial_test"
version = "2.0.0" version = "2.0.0"
@ -1731,6 +2241,7 @@ dependencies = [
"form-data-builder", "form-data-builder",
"hyper", "hyper",
"jsonwebtoken", "jsonwebtoken",
"openidconnect",
"rand_core", "rand_core",
"serde", "serde",
"serde_json", "serde_json",
@ -1746,6 +2257,12 @@ dependencies = [
"virt", "virt",
] ]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.5.0" version = "2.5.0"
@ -1769,6 +2286,27 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" 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]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.8.1" version = "3.8.1"
@ -1812,6 +2350,50 @@ dependencies = [
"once_cell", "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]] [[package]]
name = "tokio" name = "tokio"
version = "1.33.0" version = "1.33.0"
@ -1822,6 +2404,7 @@ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio",
"num_cpus",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2 0.5.5", "socket2 0.5.5",
@ -1840,6 +2423,30 @@ dependencies = [
"syn", "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]] [[package]]
name = "toml" name = "toml"
version = "0.8.6" version = "0.8.6"
@ -1867,7 +2474,7 @@ version = "0.20.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.1.0",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
@ -1976,12 +2583,27 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "unicode-bidi"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.12" version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 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]] [[package]]
name = "universal-hash" name = "universal-hash"
version = "0.5.1" version = "0.5.1"
@ -1998,6 +2620,18 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" 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]] [[package]]
name = "uuid" name = "uuid"
version = "1.5.0" version = "1.5.0"
@ -2010,6 +2644,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.4" version = "0.9.4"
@ -2076,6 +2716,18 @@ dependencies = [
"wasm-bindgen-shared", "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]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.88" version = "0.2.88"
@ -2137,6 +2789,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"
@ -2212,6 +2873,16 @@ dependencies = [
"memchr", "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]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.6.0" 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"] } axum = { version = "0.6.20", features = ["multipart", "headers", "json"] }
dirs = "5.0.1" dirs = "5.0.1"
jsonwebtoken = { version = "8.3.0", default-features = false } 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"] } rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.190", features = ["derive"] } serde = { version = "1.0.190", features = ["derive"] }
serde_json = "1.0.108" serde_json = "1.0.108"

View File

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

View File

@ -137,12 +137,38 @@ pub fn parse_public_key(data: &[u8]) -> Result<PublicKey, LoadKeyError> {
/// This function creates a signed certificate for an SSH host public /// This function creates a signed certificate for an SSH host public
/// key. The certificate will be valid for the specified hostname and /// key. The certificate will be valid for the specified hostname and
/// any alias names provided. /// any alias names provided.
pub fn sign_cert( pub fn sign_host_cert(
hostname: &str, hostname: &str,
pubkey: &PublicKey, pubkey: &PublicKey,
duration: Duration, duration: Duration,
privkey: &PrivateKey, privkey: &PrivateKey,
alias: &[&str], alias: &[&str],
) -> Result<Certificate, CertError> {
sign_cert(hostname, pubkey, duration, privkey, alias, CertType::Host)
}
/// 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],
) -> Result<Certificate, CertError> {
sign_cert(username, pubkey, duration, privkey, alias, CertType::User)
}
fn sign_cert(
principal: &str,
pubkey: &PublicKey,
duration: Duration,
privkey: &PrivateKey,
alias: &[&str],
cert_type: CertType,
) -> Result<Certificate, CertError> { ) -> Result<Certificate, CertError> {
let now = SystemTime::now(); let now = SystemTime::now();
let not_before = now.duration_since(UNIX_EPOCH)?.as_secs(); let not_before = now.duration_since(UNIX_EPOCH)?.as_secs();
@ -151,8 +177,8 @@ pub fn sign_cert(
let mut builder = Builder::new_with_random_nonce( let mut builder = Builder::new_with_random_nonce(
&mut OsRng, pubkey, not_before, not_after, &mut OsRng, pubkey, not_before, not_after,
)?; )?;
builder.cert_type(CertType::Host)?; builder.cert_type(cert_type)?;
builder.valid_principal(hostname)?; builder.valid_principal(principal)?;
for a in alias { for a in alias {
builder.valid_principal(*a)?; builder.valid_principal(*a)?;
} }

View File

@ -65,11 +65,47 @@ 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,
}
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(),
}
}
}
/// CA configuration /// CA configuration
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct CaConfig { pub struct CaConfig {
/// Host CA configuration /// Host CA configuration
pub host: HostCaConfig, 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 /// Defines a connection to a libvirt VM host
@ -91,6 +127,9 @@ pub struct Configuration {
/// CA configuration /// CA configuration
#[serde(default)] #[serde(default)]
pub ca: CaConfig, pub ca: CaConfig,
/// OpenID Connect configuration for user authorization
#[serde(default)]
pub oidc: Option<OidcConfig>,
} }
impl Default for Configuration { impl Default for Configuration {
@ -99,6 +138,7 @@ impl Default for Configuration {
libvirt: vec![], libvirt: vec![],
machine_ids: default_machine_ids(), machine_ids: default_machine_ids(),
ca: Default::default(), ca: Default::default(),
oidc: Default::default(),
} }
} }
} }
@ -123,6 +163,14 @@ fn default_host_cert_duration() -> u64 {
86400 * 30 86400 * 30
} }
fn default_user_ca_key() -> PathBuf {
default_config_path("user-ca.key")
}
fn default_user_cert_duration() -> u64 {
3600
}
/// Load configuration from a TOML file /// Load configuration from a TOML file
/// ///
/// If `path` is provided, the configuration will be loaded from the /// 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::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::extract::State;
use axum::http::StatusCode; use axum::headers::authorization::Bearer;
use axum::response::{IntoResponse, Response}; use axum::headers::{Authorization, Host};
use axum::http::request::Parts;
use axum::{RequestPartsExt, TypedHeader};
use serde::Serialize; use serde::Serialize;
use ssh_key::Algorithm; 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::ca;
use crate::machine_id;
#[derive(Serialize)] #[derive(Serialize)]
pub struct SignKeyResponse { pub struct SignKeyResponse {
@ -19,65 +28,68 @@ pub struct SignKeyResponse {
certificates: HashMap<String, String>, certificates: HashMap<String, String>,
} }
pub enum SignKeyError { #[async_trait]
Multipart(MultipartError), impl FromRequestParts<Arc<Context>> for HostClaims {
Cert(ca::CertError), type Rejection = AuthError;
LoadPrivateKey(ca::LoadKeyError),
ParsePublicKey(ca::LoadKeyError),
UnsupportedAlgorithm(String),
NoKey,
}
impl From<MultipartError> for SignKeyError { async fn from_request_parts(
fn from(e: MultipartError) -> Self { parts: &mut Parts,
Self::Multipart(e) 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(),
);
impl From<ca::CertError> for SignKeyError { let hostname =
fn from(e: ca::CertError) -> Self { auth::get_token_subject(bearer.token()).map_err(|e| {
Self::Cert(e) debug!("Could not get token subject: {}", e);
} AuthError
} })?;
let machine_id =
impl IntoResponse for SignKeyError { get_machine_id(&hostname, ctx).await.ok_or_else(|| {
fn into_response(self) -> Response { debug!("No machine ID found for host {}", hostname);
match self { AuthError
Self::Multipart(e) => { })?;
debug!("Error reading request: {}", e); let claims = auth::validate_host_token(
let body = e.to_string(); bearer.token(),
(StatusCode::BAD_REQUEST, body).into_response() &hostname,
} &machine_id,
Self::Cert(e) => { &host,
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() .map_err(|e| {
} debug!("Invalid auth token: {}", e);
AuthError
})?;
debug!("Successfully authenticated request from host {}", hostname);
Ok(claims)
}
}
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)] #[derive(Default)]
@ -87,7 +99,7 @@ struct SignKeyRequest {
} }
pub(super) async fn sign_host_cert( pub(super) async fn sign_host_cert(
claims: Claims, claims: HostClaims,
State(ctx): State<super::State>, State(ctx): State<super::State>,
mut form: Multipart, mut form: Multipart,
) -> Result<String, SignKeyError> { ) -> Result<String, SignKeyError> {
@ -137,7 +149,7 @@ pub(super) async fn sign_host_cert(
hostname hostname
); );
let cert = let cert =
ca::sign_cert(&hostname, &pubkey, duration, &privkey, &[])?; ca::sign_host_cert(&hostname, &pubkey, duration, &privkey, &[])?;
info!( info!(
"Signed {} key for {}", "Signed {} key for {}",
pubkey.algorithm().as_str(), pubkey.algorithm().as_str(),

View File

@ -1,29 +1,25 @@
mod error;
mod host; mod host;
mod user;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; 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::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{RequestPartsExt, Router, TypedHeader}; use axum::Router;
use openidconnect::core::CoreProviderMetadata;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::debug;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::{self, Claims};
use crate::config::Configuration; use crate::config::Configuration;
use crate::machine_id;
struct Context { struct Context {
config: Arc<Configuration>, config: Arc<Configuration>,
cache: RwLock<HashMap<String, (Instant, Uuid)>>, cache: RwLock<HashMap<String, (Instant, Uuid)>>,
oidc: RwLock<Option<(Instant, CoreProviderMetadata)>>,
} }
type State = Arc<Context>; type State = Arc<Context>;
@ -36,81 +32,20 @@ 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 { pub fn make_app(config: Configuration) -> Router {
let ctx = Arc::new(Context { let ctx = Arc::new(Context {
config: config.into(), config: config.into(),
cache: RwLock::new(Default::default()), cache: RwLock::new(Default::default()),
oidc: Default::default(),
}); });
Router::new() Router::new()
.route("/", get(|| async { "UP" })) .route("/", get(|| async { "UP" }))
.route("/host/sign", post(host::sign_host_cert)) .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))
.with_state(ctx) .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)] #[cfg(test)]
mod test { mod test {
use axum::body::Body; use axum::body::Body;

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

@ -0,0 +1,244 @@
//! 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::{CoreClient, CoreProviderMetadata};
use openidconnect::core::{CoreIdToken, CoreIdTokenClaims};
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::{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(CoreIdTokenClaims);
/// 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 = CoreIdToken::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 = CoreClient::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 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)?;
info!(
"Signed {} key for {}",
pubkey.algorithm().as_str(),
username
);
Ok(cert.to_openssh().map_err(ca::CertError::from)?)
}
/// 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 tempfile::NamedTempFile;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use sshca::config::Configuration; use sshca::config::{Configuration, OidcConfig};
static INIT: Once = Once::new(); 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 { fn gen_config(machine_ids: &Path, host_key: &Path) -> Configuration {
let mut config = Configuration { let mut config = Configuration {
machine_ids: machine_ids.to_str().unwrap().into(), 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() ..Default::default()
}; };
config.ca.host.private_key_file = host_key.to_str().unwrap().into(); 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);
}