diff --git a/Cargo.lock b/Cargo.lock index 5b94f83..348b5d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,50 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +61,15 @@ dependencies = [ "memchr", ] +[[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 = "anstream" version = "1.0.0" @@ -73,6 +126,45 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -164,12 +256,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bcrypt" version = "0.15.1" @@ -183,6 +287,222 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bergshamra" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d7fa82e5a4437e4766511843d661355af8005b30576db52b96b595865353dc" +dependencies = [ + "base64", + "bergshamra-c14n", + "bergshamra-core", + "bergshamra-crypto", + "bergshamra-dsig", + "bergshamra-enc", + "bergshamra-keys", + "bergshamra-transforms", + "bergshamra-xml", + "clap", + "rand 0.8.6", + "uppsala", +] + +[[package]] +name = "bergshamra-c14n" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5802757aa34889959a88721cf22044fb13ab3b3fae556bfc94d3679f74d2b6" +dependencies = [ + "bergshamra-core", + "bergshamra-xml", + "uppsala", +] + +[[package]] +name = "bergshamra-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de25efa9a8386aad3d17cfd029277ae8ccb917475848eb66d6bf72c6a70ad75" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "bergshamra-crypto" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7486bf4a51a6277de8b8f3979c43151ffed8c6636381b84b4825e19ca803884d" +dependencies = [ + "aes", + "aes-gcm", + "aes-kw", + "bergshamra-core", + "cbc", + "des", + "digest 0.10.7", + "dsa", + "ecdsa", + "ed25519-dalek", + "hkdf", + "hmac 0.12.1", + "kryptering", + "md-5", + "ml-dsa", + "num-bigint-dig", + "num-traits", + "p256", + "p384", + "p521", + "pbkdf2", + "pkcs1", + "pkcs8 0.10.2", + "pkcs8 0.11.0-rc.11", + "rand 0.8.6", + "ripemd", + "rsa", + "sha1", + "sha2 0.10.9", + "sha3 0.10.9", + "signature 2.2.0", + "slh-dsa", + "x25519-dalek", +] + +[[package]] +name = "bergshamra-dsig" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79a4f0a9ec25c1a804dbb717ecf47da4579d41e35a0db127d83c29396fc79290" +dependencies = [ + "base64", + "bergshamra-c14n", + "bergshamra-core", + "bergshamra-crypto", + "bergshamra-keys", + "bergshamra-transforms", + "bergshamra-xml", + "der 0.7.10", + "dsa", + "ed25519-dalek", + "kryptering", + "p256", + "p384", + "p521", + "rsa", + "uppsala", + "x509-cert", +] + +[[package]] +name = "bergshamra-enc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41620927810588fc9155932f1020fd501d8b658a2a4831e56333748a49ee647e" +dependencies = [ + "base64", + "bergshamra-c14n", + "bergshamra-core", + "bergshamra-crypto", + "bergshamra-keys", + "bergshamra-transforms", + "bergshamra-xml", + "kryptering", + "p256", + "p384", + "p521", + "rand 0.8.6", + "uppsala", +] + +[[package]] +name = "bergshamra-keys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4b1a745bef9d6ea50a8ed8145d364a85892a8b2e5a499b9435089b14df167b" +dependencies = [ + "base64", + "bergshamra-core", + "bergshamra-crypto", + "bergshamra-pkcs12", + "const-oid 0.10.2", + "der 0.7.10", + "digest 0.10.7", + "dsa", + "ecdsa", + "ed25519-dalek", + "md-5", + "ml-dsa", + "num-bigint-dig", + "num-traits", + "p256", + "p384", + "p521", + "pem-rfc7468", + "pkcs1", + "pkcs5", + "pkcs8 0.10.2", + "pkcs8 0.11.0-rc.11", + "rsa", + "sha1", + "sha2 0.10.9", + "signature 2.2.0", + "slh-dsa", + "spki 0.7.3", + "tsp-ltv", + "uppsala", + "x25519-dalek", + "x509-cert", +] + +[[package]] +name = "bergshamra-pkcs12" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d46759b4868e86830544904e1d15ae061bc387384c7a806703096fb51e380c5" +dependencies = [ + "aes", + "bergshamra-core", + "cbc", + "cipher", + "des", + "hmac 0.12.1", + "pbkdf2", + "sha1", + "sha2 0.10.9", + "yasna", +] + +[[package]] +name = "bergshamra-transforms" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e467a1622d7fe18fc784f19d2ba0355dc82d2d425c90b2269c10361dc9b8e88a" +dependencies = [ + "base64", + "bergshamra-c14n", + "bergshamra-core", + "bergshamra-crypto", + "bergshamra-keys", + "bergshamra-xml", + "uppsala", +] + +[[package]] +name = "bergshamra-xml" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644aeb454e2791fd16ce90db48a9175a2a464991dc76022e1ca7503815c79772" +dependencies = [ + "bergshamra-core", + "uppsala", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -198,6 +518,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -248,6 +586,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.63" @@ -270,13 +617,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -320,6 +678,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "x509-cert", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -332,6 +708,24 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -341,6 +735,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -350,6 +753,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -357,15 +772,141 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cryptoki" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d645cc2c5faf466571c0c752d39d8fbc2746773b2f043ac8f9cd73bec55db9" +dependencies = [ + "bitflags 1.3.2", + "cryptoki-sys", + "libloading", + "log", + "paste", + "secrecy", +] + +[[package]] +name = "cryptoki-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750380200f47d4ff677be725b6e0d78b590e1d0343573dcd4b62147f25dc6efa" +dependencies = [ + "libloading", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deranged" version = "0.5.8" @@ -376,6 +917,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "dhcproto" version = "0.12.0" @@ -403,8 +953,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -418,6 +981,82 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8 0.10.2", + "rfc6979", + "sha2 0.10.9", + "signature 2.2.0", + "zeroize", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.1" @@ -486,12 +1125,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -619,6 +1280,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -661,6 +1323,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.2" @@ -684,6 +1356,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -736,6 +1419,33 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hostname" version = "0.4.2" @@ -798,6 +1508,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -859,6 +1578,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "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 = "icu_collections" version = "2.2.0" @@ -1025,6 +1768,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1068,11 +1812,72 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kryptering" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec2448476edac64a0fed5f3dc93a8ce0298711e6e3e856908c3b5ef2fe26464c" +dependencies = [ + "aes", + "aes-gcm", + "aes-kw", + "cbc", + "cryptoki", + "des", + "digest 0.10.7", + "dsa", + "ecdsa", + "ed25519-dalek", + "hkdf", + "hmac 0.12.1", + "md-5", + "ml-dsa", + "num-bigint-dig", + "num-traits", + "p256", + "p384", + "p521", + "pbkdf2", + "pkcs8 0.11.0-rc.11", + "rand 0.8.6", + "ripemd", + "rsa", + "sha1", + "sha2 0.10.9", + "sha3 0.10.9", + "signature 2.2.0", + "slh-dsa", + "thiserror 2.0.18", + "x25519-dalek", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1097,7 +1902,7 @@ dependencies = [ "httpdate", "idna 1.1.0", "mime", - "nom", + "nom 8.0.0", "percent-encoding", "quoted_printable", "rustls", @@ -1114,6 +1919,22 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1168,6 +1989,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1190,6 +2021,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1211,6 +2048,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af6e554a2affc86740759dbe568a92abd58b47fea4e28ebe1b7bb4da99e490d4" +dependencies = [ + "const-oid 0.10.2", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0-rc.11", + "rand_core 0.10.1", + "sha3 0.11.0", + "signature 3.0.0", +] + +[[package]] +name = "module-lattice" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfecc750073acc09af2f8899b2342d520d570392ba1c3aed53eeb0d84ca4103" +dependencies = [ + "hybrid-array", + "num-traits", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -1269,6 +2131,16 @@ dependencies = [ "nfs3_macros", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -1287,12 +2159,59 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "serde", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1300,6 +2219,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", ] [[package]] @@ -1314,9 +2243,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openpxe" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "axum", @@ -1338,11 +2273,17 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", + "base64", "bcrypt", + "bergshamra", + "flate2", "parking_lot", + "quick-xml", + "rcgen", + "roxmltree", "serde", "serde_json", "tempfile", @@ -1353,11 +2294,12 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "x509-parser", ] [[package]] name = "openpxe-dhcp-proxy" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "bytes", @@ -1371,10 +2313,12 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "axum", + "base64", + "bergshamra", "bytes", "futures", "hyper", @@ -1387,6 +2331,7 @@ dependencies = [ "openpxe-iso-store", "openpxe-webui", "parking_lot", + "rcgen", "reqwest", "serde", "serde_json", @@ -1404,7 +2349,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.5.0" +version = "0.5.1" dependencies = [ "openpxe-core", "rust-embed", @@ -1414,7 +2359,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "bcrypt", @@ -1429,7 +2374,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "time", @@ -1441,7 +2386,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "bytes", @@ -1455,7 +2400,45 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.5.0" +version = "0.5.1" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] [[package]] name = "parking_lot" @@ -1480,6 +2463,41 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1492,19 +2510,81 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der 0.7.10", + "des", + "pbkdf2", + "scrypt", + "sha1", + "sha2 0.10.9", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "pkcs5", + "rand_core 0.6.4", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1539,6 +2619,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1560,6 +2649,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1701,13 +2799,32 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -1768,6 +2885,16 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1782,6 +2909,45 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -1813,7 +2979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -1823,13 +2989,31 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1884,6 +3068,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1899,6 +3092,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1980,6 +3207,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1987,8 +3225,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", ] [[package]] @@ -2016,6 +3285,26 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -2028,6 +3317,25 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slh-dsa" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f6f9b5317f06189671584c283b3f26339b89c97f21b5c50ae24aec397304a7" +dependencies = [ + "const-oid 0.10.2", + "digest 0.11.3", + "hmac 0.13.0", + "hybrid-array", + "pkcs8 0.11.0-rc.11", + "rand_core 0.10.1", + "sha2 0.11.0", + "sha3 0.11.0", + "signature 3.0.0", + "typenum", + "zerocopy", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2060,6 +3368,26 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2238,6 +3566,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio" version = "1.52.1" @@ -2364,7 +3713,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -2503,6 +3852,37 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tsp-ltv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "072e18abb3fbc096b1c607cbb3eefffda07430f226c1a4cf8a9411185bc004d9" +dependencies = [ + "base64", + "chrono", + "cms", + "const-oid 0.9.6", + "der 0.7.10", + "digest 0.10.7", + "ecdsa", + "ed25519-dalek", + "hex", + "log", + "md-5", + "p256", + "p384", + "p521", + "pem-rfc7468", + "rsa", + "sha1", + "sha2 0.10.9", + "sha3 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "thiserror 2.0.18", + "x509-cert", +] + [[package]] name = "typenum" version = "1.20.0" @@ -2542,12 +3922,28 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uppsala" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1600dc6ec465bbba4056042fc73041b82081849ab3117f5d2e233eb96e3e1725" + [[package]] name = "url" version = "2.5.8" @@ -2735,7 +4131,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -2776,6 +4172,22 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2785,12 +4197,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2946,7 +4417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -2982,6 +4453,56 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "tls_codec", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" @@ -3051,6 +4572,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index d226f2c..f6c833f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.5.0" +version = "0.5.1" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" @@ -71,6 +71,20 @@ nfs3_types = "0.5" # to match reqwest and stay musl-static-friendly — no OpenSSL. lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] } +# v0.5.1: pure-Rust SAML 2.0 Service Provider. bergshamra does XML-DSig +# verification + exclusive c14n with RustCrypto (no OpenSSL/xmlsec/libxml2 +# C deps), so the static musl binary stays OpenSSL-free — samael was +# rejected precisely because it hard-requires OpenSSL. We build the thin +# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top. +bergshamra = "0.4" +roxmltree = "0.21" +quick-xml = "0.40" +x509-parser = "0.18" +# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the +# zlib/zlib-ng C backends, which would break the musl-static build. +flate2 = "1.1" +base64 = "0.22" + openpxe-core = { path = "crates/core" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-tftp = { path = "crates/tftp" } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 3994901..8872a33 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -25,5 +25,19 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } # for per-ISO boot passwords; just re-exported here. bcrypt.workspace = true +# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive +# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML; +# x509-parser pulls the IdP signing cert out of metadata; flate2+base64 +# encode the HTTP-Redirect binding's SAMLRequest. +bergshamra.workspace = true +roxmltree.workspace = true +quick-xml.workspace = true +x509-parser.workspace = true +flate2.workspace = true +base64.workspace = true + [dev-dependencies] tempfile = "3.12" +# v0.5.1: generate a throwaway self-signed signing cert/key so SAML +# verification tests can produce genuinely signed SAMLResponses. +rcgen = "0.13" diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a3813b7..2e39e0a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -14,6 +14,7 @@ pub mod log_bus; pub mod metrics; pub mod notify; pub mod queue; +pub mod saml; pub mod settings; pub mod sso; pub mod wol; @@ -23,7 +24,6 @@ pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use boot_log::{BootEvent, BootLog}; pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; -pub use sso::{SsoConfig, SsoStore}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use error::{Error, Result}; pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; @@ -31,4 +31,6 @@ pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use metrics::{HttpRoute, Metrics}; pub use notify::{NotifyConfig, NotifyKind, NotifyStore}; pub use queue::{DeploymentQueue, QueueEntry}; +pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse}; pub use settings::{Settings, SettingsStore, TimeoutAction}; +pub use sso::{SsoConfig, SsoStore}; diff --git a/crates/core/src/saml/authn_request.rs b/crates/core/src/saml/authn_request.rs new file mode 100644 index 0000000..c9be5d4 --- /dev/null +++ b/crates/core/src/saml/authn_request.rs @@ -0,0 +1,188 @@ +//! AuthnRequest construction + HTTP-Redirect binding encoding. +//! +//! For SP-initiated login we build an ``, then encode it for the +//! HTTP-Redirect binding: raw DEFLATE (RFC 1951) → base64 → percent-encode, +//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent +//! unsigned in this release (the IdP must not require client signatures). + +use std::fmt::Write as _; +use std::io::Write as _; + +use base64::Engine; +use flate2::write::DeflateEncoder; +use flate2::Compression; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +use super::{SamlError, SpParams}; + +const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol"; +const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion"; +const NAMEID_EMAIL: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; +const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; + +/// A built AuthnRequest, ready to redirect the browser to the IdP. +#[derive(Debug, Clone)] +pub struct AuthnRequest { + /// The request `ID` — the caller records this so the matching response's + /// `InResponseTo` can be correlated (replay/CSRF protection). + pub id: String, + /// The full IdP URL to 302 the browser to (includes `SAMLRequest` and, + /// when supplied, `RelayState`). + pub location: String, +} + +/// Build an AuthnRequest targeting `idp_sso_url` and encode it for the +/// HTTP-Redirect binding. `relay_state`, if given, round-trips back to us via +/// the response (we use it to send the operator to their intended page). +pub fn build( + sp: &SpParams, + idp_sso_url: &str, + relay_state: Option<&str>, +) -> Result { + let id = format!("_{}", uuid::Uuid::new_v4().simple()); + let issue_instant = OffsetDateTime::now_utc() + .replace_nanosecond(0) + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .format(&Rfc3339) + .map_err(|e| SamlError::Timestamp(e.to_string()))?; + + let xml = format!( + r#"{issuer}"#, + instant = issue_instant, + dest = xml_escape(idp_sso_url), + acs = xml_escape(&sp.acs_url), + issuer = xml_escape(&sp.entity_id), + ); + + let encoded = deflate_base64(&xml)?; + + let sep = if idp_sso_url.contains('?') { '&' } else { '?' }; + let mut location = format!("{idp_sso_url}{sep}SAMLRequest={}", pct_encode(&encoded)); + if let Some(rs) = relay_state { + location.push_str("&RelayState="); + location.push_str(&pct_encode(rs)); + } + + Ok(AuthnRequest { id, location }) +} + +/// Raw-DEFLATE then base64 — the HTTP-Redirect binding's `SAMLRequest` payload. +fn deflate_base64(xml: &str) -> Result { + let mut enc = DeflateEncoder::new(Vec::new(), Compression::default()); + enc.write_all(xml.as_bytes()) + .and_then(|()| enc.try_finish()) + .map_err(|e| SamlError::Xml(format!("deflate: {e}")))?; + let compressed = enc + .finish() + .map_err(|e| SamlError::Xml(format!("deflate: {e}")))?; + Ok(base64::engine::general_purpose::STANDARD.encode(compressed)) +} + +/// Percent-encode a query-string component (RFC 3986 unreserved set passes +/// through; everything else is `%XX`). +fn pct_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 3); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + let _ = write!(out, "%{b:02X}"); + } + } + } + out +} + +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::read::DeflateDecoder; + use std::io::Read; + + fn sp() -> SpParams { + SpParams { + entity_id: "https://pxe.example.com".into(), + acs_url: "https://pxe.example.com/api/sso/acs".into(), + } + } + + fn pct_decode(s: &str) -> Vec { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hi = (bytes[i + 1] as char).to_digit(16).unwrap(); + let lo = (bytes[i + 2] as char).to_digit(16).unwrap(); + out.push((hi * 16 + lo) as u8); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + out + } + + #[test] + fn id_is_ncname_and_location_has_request() { + let req = build(&sp(), "https://idp.example.com/sso", Some("/dashboard")).unwrap(); + assert!(req.id.starts_with('_')); + assert!(req + .location + .starts_with("https://idp.example.com/sso?SAMLRequest=")); + assert!(req.location.contains("&RelayState=%2Fdashboard")); + } + + #[test] + fn redirect_payload_round_trips_to_our_authn_request() { + let req = build(&sp(), "https://idp.example.com/sso", None).unwrap(); + // Pull SAMLRequest value out of the query string. + let q = req.location.split("SAMLRequest=").nth(1).unwrap(); + let val = q.split('&').next().unwrap(); + let compressed = base64::engine::general_purpose::STANDARD + .decode(pct_decode(val)) + .unwrap(); + let mut inflate = DeflateDecoder::new(&compressed[..]); + let mut xml = String::new(); + inflate.read_to_string(&mut xml).unwrap(); + + let doc = roxmltree::Document::parse(&xml).unwrap(); + let root = doc.root_element(); + assert_eq!(root.tag_name().name(), "AuthnRequest"); + assert_eq!(root.attribute("ID").unwrap(), req.id); + assert_eq!( + root.attribute("AssertionConsumerServiceURL").unwrap(), + "https://pxe.example.com/api/sso/acs" + ); + let issuer = root + .descendants() + .find(|n| n.tag_name().name() == "Issuer") + .unwrap(); + assert_eq!(issuer.text().unwrap(), "https://pxe.example.com"); + } + + #[test] + fn existing_query_uses_ampersand_separator() { + let req = build(&sp(), "https://idp.example.com/sso?foo=bar", None).unwrap(); + assert!(req.location.contains("?foo=bar&SAMLRequest=")); + } +} diff --git a/crates/core/src/saml/metadata.rs b/crates/core/src/saml/metadata.rs new file mode 100644 index 0000000..3972f43 --- /dev/null +++ b/crates/core/src/saml/metadata.rs @@ -0,0 +1,241 @@ +//! IdP metadata parsing + SP metadata generation. +//! +//! We parse only what the SP flow needs: the IdP Entity ID, its +//! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the +//! X.509 signing certificate(s). Everything else in the document is ignored. + +use base64::Engine; + +use super::{SamlError, SpParams}; + +/// SAML 2.0 binding URIs. +pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; +pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; + +/// The subset of an IdP's `EntityDescriptor` the SP flow consumes. +#[derive(Debug, Clone)] +pub struct IdpMetadata { + /// The IdP's Entity ID — we require incoming assertions to be issued by it. + pub entity_id: String, + /// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests). + pub sso_redirect_url: Option, + /// SSO endpoint for the HTTP-POST binding (fallback target). + pub sso_post_url: Option, + /// DER-encoded X.509 signing certificate(s). More than one appears during + /// key rotation; verification tries each. + pub signing_certs_der: Vec>, +} + +impl IdpMetadata { + /// Parse an IdP `EntityDescriptor` document. + /// + /// Robust to namespace-prefix variation (matches on local element names), + /// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …). + pub fn parse(xml: &str) -> Result { + let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?; + let root = doc.root_element(); + + // The signing IDP descriptor. Some metadata wraps multiple + // descriptors (AA, SP) in one document; we want IDPSSODescriptor. + let idp_desc = root + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor") + .ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?; + + // Entity ID lives on the EntityDescriptor (root, or an ancestor of the + // IDPSSODescriptor when several are nested). + let entity_id = idp_desc + .ancestors() + .find_map(|n| { + if n.tag_name().name() == "EntityDescriptor" { + n.attribute("entityID") + } else { + None + } + }) + .or_else(|| root.attribute("entityID")) + .map(str::to_owned) + .ok_or_else(|| SamlError::Metadata("entityID".into()))?; + + let mut sso_redirect_url = None; + let mut sso_post_url = None; + for sso in idp_desc + .children() + .filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService") + { + let binding = sso.attribute("Binding").unwrap_or(""); + let location = sso.attribute("Location").map(str::to_owned); + match binding { + BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location, + BINDING_POST if sso_post_url.is_none() => sso_post_url = location, + _ => {} + } + } + + // Signing certs: KeyDescriptor with use="signing" or no use attribute + // (a bare KeyDescriptor is valid for both signing and encryption). + let mut signing_certs_der = Vec::new(); + for kd in idp_desc + .children() + .filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor") + { + match kd.attribute("use") { + Some("signing") | None => {} + Some(_) => continue, // encryption-only key — skip + } + for cert_node in kd + .descendants() + .filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate") + { + let b64: String = node_text(&cert_node) + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + if b64.is_empty() { + continue; + } + let der = base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .map_err(|e| SamlError::Base64(e.to_string()))?; + signing_certs_der.push(der); + } + } + + if signing_certs_der.is_empty() { + return Err(SamlError::NoSigningCert); + } + + Ok(Self { + entity_id, + sso_redirect_url, + sso_post_url, + signing_certs_der, + }) + } + + /// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect + /// if advertised, otherwise HTTP-POST. + pub fn sso_destination(&self) -> Option<&str> { + self.sso_redirect_url + .as_deref() + .or(self.sso_post_url.as_deref()) + } +} + +/// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a +/// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress +/// NameID format — matching what the response path expects. +pub fn build_sp_metadata(sp: &SpParams) -> String { + let entity = xml_escape(&sp.entity_id); + let acs = xml_escape(&sp.acs_url); + format!( + r#" + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + +"# + ) +} + +/// Collect the concatenated text of an element's direct text children. +fn node_text(n: &roxmltree::Node<'_, '_>) -> String { + n.children() + .filter(roxmltree::Node::is_text) + .filter_map(|c| c.text()) + .collect() +} + +/// Minimal XML attribute/text escaping for the values we interpolate. +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + // A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in; + // signing tests build real certs in the parent module's tests). + const SAMPLE: &str = r#" + + + + QUJDREVG + + + + WlpaWg== + + + + + "#; + + #[test] + fn parses_entity_sso_and_signing_cert() { + let m = IdpMetadata::parse(SAMPLE).unwrap(); + assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet"); + assert_eq!( + m.sso_redirect_url.as_deref(), + Some("https://idp.example.com/realms/fleet/protocol/saml") + ); + assert!(m.sso_post_url.is_some()); + // Only the signing KeyDescriptor's cert is collected (ABCDEF), not the + // encryption one (ZZZZ). + assert_eq!(m.signing_certs_der.len(), 1); + assert_eq!(m.signing_certs_der[0], b"ABCDEF"); + } + + #[test] + fn missing_signing_cert_is_rejected() { + let xml = r#" + + + "#; + assert!(matches!( + IdpMetadata::parse(xml), + Err(SamlError::NoSigningCert) + )); + } + + #[test] + fn missing_idp_descriptor_is_rejected() { + let xml = r#""#; + assert!(matches!( + IdpMetadata::parse(xml), + Err(SamlError::Metadata(_)) + )); + } + + #[test] + fn sp_metadata_contains_entity_and_acs() { + let sp = SpParams { + entity_id: "https://pxe.example.com".into(), + acs_url: "https://pxe.example.com/api/sso/acs".into(), + }; + let xml = build_sp_metadata(&sp); + assert!(xml.contains(r#"entityID="https://pxe.example.com""#)); + assert!(xml.contains("https://pxe.example.com/api/sso/acs")); + assert!(xml.contains(BINDING_POST)); + // Must be well-formed. + roxmltree::Document::parse(&xml).unwrap(); + } +} diff --git a/crates/core/src/saml/mod.rs b/crates/core/src/saml/mod.rs new file mode 100644 index 0000000..2ba1df5 --- /dev/null +++ b/crates/core/src/saml/mod.rs @@ -0,0 +1,92 @@ +//! Pure-Rust SAML 2.0 Service Provider (v0.5.1). +//! +//! This module implements the SP half of a SAML Web-Browser-SSO profile: +//! +//! * [`metadata`] — parse the IdP's `EntityDescriptor` (SSO URLs + signing +//! certificates) and build *our* SP metadata for the IdP admin to import. +//! * [`authn_request`] — build an `AuthnRequest` and encode it for the +//! HTTP-Redirect binding. +//! * [`response`] — decode a `SAMLResponse`, **verify its XML signature** +//! against the IdP's pinned certificate (via the pure-Rust `bergshamra` +//! crate — no OpenSSL/libxml2/xmlsec, so the static musl binary stays +//! C-free), then enforce the SP-side semantic checks (Status, Destination, +//! Audience, time bounds) that are where SAML SPs actually get attacked. +//! +//! Stateful checks (replay of assertion IDs, correlating `InResponseTo` +//! against requests *we* issued, gating IdP-initiated login) live in the +//! HTTP layer — [`response::consume`] is deliberately stateless and returns +//! the IDs the caller needs to perform them. +//! +//! Access model: any assertion the IdP authenticates and we cryptographically +//! verify yields an operator [`VerifiedPrincipal`]. OpenPXE is single-tier — +//! there is no per-user role table — and the local admin account remains a +//! guaranteed fallback owner regardless of SSO state. + +pub mod authn_request; +pub mod metadata; +pub mod response; + +pub use authn_request::AuthnRequest; +pub use metadata::IdpMetadata; +pub use response::{VerifiedPrincipal, VerifiedResponse}; + +use thiserror::Error; + +/// Default clock-skew tolerance applied to assertion time bounds. SAML IdPs +/// and SPs rarely have perfectly synced clocks; 60s matches common practice +/// (Shibboleth/FleetDM defaults are in this ballpark). +pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 60; + +/// Runtime SP parameters, derived from [`crate::SsoConfig`] + the advertised +/// public base URL by the HTTP layer. +#[derive(Debug, Clone)] +pub struct SpParams { + /// Our SP Entity ID (the `` we send and the `Audience` we require + /// in responses). Defaults to the public base URL when the operator left + /// the Entity ID field blank. + pub entity_id: String, + /// The Assertion Consumer Service URL the IdP POSTs the response to — + /// `/api/sso/acs`. + pub acs_url: String, +} + +/// Everything that can go wrong consuming a SAML response. Kept coarse on +/// purpose: the HTTP layer logs the detail and shows the operator a generic +/// "SSO sign-in failed" — we never leak which specific check tripped to the +/// browser, since that aids an attacker probing the SP. +#[derive(Debug, Error)] +pub enum SamlError { + #[error("SAML XML parse error: {0}")] + Xml(String), + #[error("IdP metadata is missing a required element: {0}")] + Metadata(String), + #[error("no usable IdP signing certificate in metadata")] + NoSigningCert, + #[error("signature verification failed: {0}")] + Signature(String), + #[error("the signature does not cover the assertion we read")] + SignatureScope, + #[error("SAML response status was not Success: {0}")] + Status(String), + #[error("response is missing a required element: {0}")] + MissingElement(String), + #[error("encrypted assertions are not supported in this release")] + EncryptedAssertionUnsupported, + #[error("expected exactly one assertion, found {0}")] + AssertionCount(usize), + #[error("issuer mismatch: response was not issued by the configured IdP")] + IssuerMismatch, + #[error("audience mismatch: assertion is not addressed to this service provider")] + AudienceMismatch, + #[error("response destination does not match our ACS URL")] + DestinationMismatch, + #[error("assertion is expired or not yet valid")] + TimeBounds, + #[error("invalid SAML timestamp: {0}")] + Timestamp(String), + #[error("base64 decode failed: {0}")] + Base64(String), +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/saml/response.rs b/crates/core/src/saml/response.rs new file mode 100644 index 0000000..65150d1 --- /dev/null +++ b/crates/core/src/saml/response.rs @@ -0,0 +1,294 @@ +//! SAMLResponse consumption: signature verification + SP-side validation. +//! +//! [`consume`] is intentionally **stateless** — it verifies the XML signature +//! against the IdP's pinned certificate(s) and enforces every check that can +//! be made from the response alone (Status, Destination, Issuer, Audience, +//! time bounds, signature scope). It then returns the `assertion_id` and +//! `in_response_to` so the HTTP layer can perform the *stateful* checks it +//! owns: replay rejection, correlating the request we issued, and gating +//! IdP-initiated login. + +use roxmltree::{Document, Node}; +use time::format_description::well_known::Rfc3339; +use time::{Duration, OffsetDateTime}; + +use super::metadata::IdpMetadata; +use super::{SamlError, SpParams}; + +const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success"; + +/// The verified subject of a SAML assertion. OpenPXE is single-tier, so this +/// is all an operator session needs. +#[derive(Debug, Clone)] +pub struct VerifiedPrincipal { + /// The `` value (an email, per our requested NameID format). + pub name_id: String, + /// Email used as the session identity. Equals `name_id` for the + /// emailAddress NameID format. + pub email: String, + /// Human-readable display name, if the IdP sent one as an attribute. + pub display_name: Option, +} + +/// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's +/// stateful checks. +#[derive(Debug, Clone)] +pub struct VerifiedResponse { + pub principal: VerifiedPrincipal, + /// `InResponseTo` from the response, if present. `None` = unsolicited + /// (IdP-initiated) — the HTTP layer only accepts that when the operator + /// enabled it. + pub in_response_to: Option, + /// The assertion's `ID` — used by the caller as the replay-guard key. + pub assertion_id: String, + /// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay + /// guard can drop the consumed ID after this instant. + pub assertion_expiry: OffsetDateTime, + /// `AuthnStatement/@SessionIndex`, if present (useful for future SLO). + pub session_index: Option, +} + +/// Verify and validate a decoded `SAMLResponse` XML document. +pub fn consume( + xml: &str, + sp: &SpParams, + idp: &IdpMetadata, + now: OffsetDateTime, + clock_skew: Duration, +) -> Result { + // 1. Cryptographically verify the signature against the pinned IdP cert(s). + // `trusted_keys_only` ignores any cert embedded in the document's + // KeyInfo, so an attacker can't substitute their own key. + let verified_uris = verify_signature(xml, &idp.signing_certs_der)?; + + // 2. Parse for semantic validation. + let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?; + let root = doc.root_element(); + if root.tag_name().name() != "Response" { + return Err(SamlError::MissingElement("Response".into())); + } + let response_id = root.attribute("ID").map(str::to_owned); + let in_response_to = root.attribute("InResponseTo").map(str::to_owned); + + // 3. Status must be Success. + let status_value = root + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "StatusCode") + .and_then(|sc| sc.attribute("Value")) + .unwrap_or(""); + if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") { + return Err(SamlError::Status(status_value.to_owned())); + } + + // 4. Destination (if the IdP set one) must be our ACS. + if let Some(dest) = root.attribute("Destination") { + if !urls_equal(dest, &sp.acs_url) { + return Err(SamlError::DestinationMismatch); + } + } + + // 5. Exactly one (unencrypted) Assertion. + if root + .descendants() + .any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion") + { + return Err(SamlError::EncryptedAssertionUnsupported); + } + let assertions: Vec> = root + .children() + .filter(|c| c.is_element() && c.tag_name().name() == "Assertion") + .collect(); + if assertions.len() != 1 { + return Err(SamlError::AssertionCount(assertions.len())); + } + let assertion = assertions[0]; + let assertion_id = assertion + .attribute("ID") + .map(str::to_owned) + .ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?; + + // 6. The signature must actually cover the assertion we're about to trust: + // either the assertion itself, the enclosing response, or the whole + // document. (bergshamra's strict_verification already constrains where + // the signed element may sit; this ties it to *our* assertion.) + let covers_assertion = verified_uris.iter().any(|u| { + u.is_empty() + || u == &format!("#{assertion_id}") + || response_id + .as_ref() + .is_some_and(|rid| u == &format!("#{rid}")) + }); + if !covers_assertion { + return Err(SamlError::SignatureScope); + } + + // 7. Issuer must be the configured IdP. + let issuer = first_child(assertion, "Issuer") + .map(text_of) + .unwrap_or_default(); + if !idp.entity_id.is_empty() && issuer != idp.entity_id { + return Err(SamlError::IssuerMismatch); + } + + // 8. Subject → NameID + SubjectConfirmationData time/recipient checks. + let subject = first_child(assertion, "Subject") + .ok_or_else(|| SamlError::MissingElement("Subject".into()))?; + let name_id = first_child(subject, "NameID") + .map(text_of) + .filter(|s| !s.is_empty()) + .ok_or_else(|| SamlError::MissingElement("NameID".into()))?; + if let Some(scd) = subject + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData") + { + if let Some(recipient) = scd.attribute("Recipient") { + if !urls_equal(recipient, &sp.acs_url) { + return Err(SamlError::DestinationMismatch); + } + } + if let Some(noa) = scd.attribute("NotOnOrAfter") { + let noa = parse_instant(noa)?; + if now >= noa + clock_skew { + return Err(SamlError::TimeBounds); + } + } + } + + // 9. Conditions: time window + audience. + let conditions = first_child(assertion, "Conditions"); + if let Some(cond) = conditions { + if let Some(nb) = cond.attribute("NotBefore") { + let nb = parse_instant(nb)?; + if now < nb - clock_skew { + return Err(SamlError::TimeBounds); + } + } + } + let assertion_expiry = conditions + .and_then(|c| c.attribute("NotOnOrAfter")) + .map(parse_instant) + .transpose()? + .ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?; + if now >= assertion_expiry + clock_skew { + return Err(SamlError::TimeBounds); + } + + let audience_ok = conditions.is_some_and(|c| { + c.descendants() + .filter(|n| n.is_element() && n.tag_name().name() == "Audience") + .any(|a| text_of(a) == sp.entity_id) + }); + if !audience_ok { + return Err(SamlError::AudienceMismatch); + } + + // 10. Optional: SessionIndex + display-name attribute. + let session_index = assertion + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement") + .and_then(|a| a.attribute("SessionIndex")) + .map(str::to_owned); + + let display_name = extract_display_name(assertion); + + Ok(VerifiedResponse { + principal: VerifiedPrincipal { + email: name_id.clone(), + name_id, + display_name, + }, + in_response_to, + assertion_id, + assertion_expiry, + session_index, + }) +} + +/// Verify the document's XML-DSig against each pinned IdP cert in turn +/// (handles key rotation), returning the verified `` URIs. +fn verify_signature(xml: &str, certs_der: &[Vec]) -> Result, SamlError> { + let mut last_err = String::from("no signing certificate matched"); + for der in certs_der { + let key = match bergshamra::keys::loader::load_x509_cert_der(der) { + Ok(k) => k, + Err(e) => { + last_err = e.to_string(); + continue; + } + }; + let mut km = bergshamra::keys::KeysManager::new(); + km.add_key(key); + // trusted_keys_only: only ever trust the pinned IdP key, never an + // inline KeyInfo cert. strict_verification: XSW positional defense. + let ctx = bergshamra::DsigContext::new(km) + .with_trusted_keys_only(true) + .with_strict_verification(true); + match bergshamra::verify(&ctx, xml) { + Ok(bergshamra::VerifyResult::Valid { references, .. }) => { + return Ok(references.into_iter().map(|r| r.uri).collect()); + } + Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason, + Err(e) => last_err = e.to_string(), + } + } + Err(SamlError::Signature(last_err)) +} + +/// Pull a display name from the assertion's attribute statement, trying the +/// common attribute names IdPs use (FleetDM checks the same set). +fn extract_display_name(assertion: Node<'_, '_>) -> Option { + const WANTED: &[&str] = &[ + "name", + "displayname", + "cn", + "urn:oid:2.5.4.3", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + ]; + for attr in assertion + .descendants() + .filter(|n| n.is_element() && n.tag_name().name() == "Attribute") + { + let key = attr + .attribute("Name") + .or_else(|| attr.attribute("FriendlyName")) + .unwrap_or("") + .to_ascii_lowercase(); + if WANTED.contains(&key.as_str()) { + if let Some(val) = attr + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "AttributeValue") + { + let v = text_of(val); + if !v.is_empty() { + return Some(v); + } + } + } + } + None +} + +fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option> { + n.children() + .find(|c| c.is_element() && c.tag_name().name() == local) +} + +fn text_of(n: Node<'_, '_>) -> String { + n.children() + .filter(Node::is_text) + .filter_map(|c| c.text()) + .collect::() + .trim() + .to_owned() +} + +/// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`). +fn parse_instant(s: &str) -> Result { + OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}"))) +} + +/// Compare two URLs for SAML endpoint-matching purposes: exact, or differing +/// only by a single trailing slash. +fn urls_equal(a: &str, b: &str) -> bool { + a == b || a.trim_end_matches('/') == b.trim_end_matches('/') +} diff --git a/crates/core/src/saml/tests.rs b/crates/core/src/saml/tests.rs new file mode 100644 index 0000000..cd9ad0e --- /dev/null +++ b/crates/core/src/saml/tests.rs @@ -0,0 +1,287 @@ +//! End-to-end SAML SP tests. +//! +//! We mint a throwaway self-signed cert/key with `rcgen`, sign a SAML Response +//! template with `bergshamra::sign` (the same engine that verifies it), and +//! drive [`response::consume`] through the accept path and every reject path. +//! This proves both the signature wiring and the SP-semantic checks. + +use time::format_description::well_known::Rfc3339; +use time::{Duration, OffsetDateTime}; + +use super::metadata::IdpMetadata; +use super::{response, SamlError, SpParams}; + +const SP_ENTITY: &str = "https://pxe.example.com"; +const ACS: &str = "https://pxe.example.com/api/sso/acs"; +const IDP_ENTITY: &str = "https://idp.example.com/realms/fleet"; +const EMAIL: &str = "user@example.com"; + +struct TestIdp { + cert_der: Vec, + key_pem: String, +} + +fn test_idp() -> TestIdp { + let ck = rcgen::generate_simple_self_signed(vec!["idp.example.com".to_string()]).unwrap(); + TestIdp { + cert_der: ck.cert.der().as_ref().to_vec(), + key_pem: ck.key_pair.serialize_pem(), + } +} + +fn fmt(t: OffsetDateTime) -> String { + t.replace_nanosecond(0).unwrap().format(&Rfc3339).unwrap() +} + +/// Knobs for building a response template — defaults are a valid response. +struct Resp { + issuer: String, + audience: String, + status: String, + not_before: OffsetDateTime, + not_on_or_after: OffsetDateTime, + in_response_to: Option, + recipient: String, +} + +impl Default for Resp { + fn default() -> Self { + let now = OffsetDateTime::now_utc(); + Self { + issuer: IDP_ENTITY.into(), + audience: SP_ENTITY.into(), + status: "urn:oasis:names:tc:SAML:2.0:status:Success".into(), + not_before: now - Duration::minutes(5), + not_on_or_after: now + Duration::hours(1), + in_response_to: Some("_req-abc".into()), + recipient: ACS.into(), + } + } +} + +impl Resp { + /// The unsigned template (a `` with empty values). + fn template(&self) -> String { + let now = fmt(OffsetDateTime::now_utc()); + let irt = self + .in_response_to + .as_ref() + .map(|v| format!(r#" InResponseTo="{v}""#)) + .unwrap_or_default(); + format!( + r##" + {issuer} + + + {issuer} + + + + + + + + + + + + + + + + + {EMAIL} + + + + + + {audience} + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + Miles Ward + + +"##, + issuer = self.issuer, + status = self.status, + audience = self.audience, + recipient = self.recipient, + nb = fmt(self.not_before), + noa = fmt(self.not_on_or_after), + ) + } +} + +fn sign(template: &str, key_pem: &str) -> String { + let key = bergshamra::keys::loader::load_pem_auto(key_pem.as_bytes(), None) + .expect("load test signing key"); + let mut km = bergshamra::keys::KeysManager::new(); + km.add_key(key); + let ctx = bergshamra::DsigContext::new(km); + bergshamra::sign(&ctx, template).expect("sign test response") +} + +fn sp() -> SpParams { + SpParams { + entity_id: SP_ENTITY.into(), + acs_url: ACS.into(), + } +} + +fn idp(cert_der: Vec) -> IdpMetadata { + IdpMetadata { + entity_id: IDP_ENTITY.into(), + sso_redirect_url: None, + sso_post_url: None, + signing_certs_der: vec![cert_der], + } +} + +fn consume(xml: &str, cert_der: Vec) -> Result { + response::consume( + xml, + &sp(), + &idp(cert_der), + OffsetDateTime::now_utc(), + Duration::seconds(60), + ) +} + +#[test] +fn good_response_yields_principal() { + let t = test_idp(); + let signed = sign(&Resp::default().template(), &t.key_pem); + let out = consume(&signed, t.cert_der).expect("valid response should verify"); + assert_eq!(out.principal.email, EMAIL); + assert_eq!(out.principal.name_id, EMAIL); + assert_eq!(out.principal.display_name.as_deref(), Some("Miles Ward")); + assert_eq!(out.in_response_to.as_deref(), Some("_req-abc")); + assert_eq!(out.assertion_id, "_assertion1"); + assert_eq!(out.session_index.as_deref(), Some("sess-123")); +} + +#[test] +fn tampered_assertion_is_rejected() { + let t = test_idp(); + let signed = sign(&Resp::default().template(), &t.key_pem); + // Flip the subject email after signing — breaks the digest. + let tampered = signed.replace(EMAIL, "attacker@evil.example"); + assert_ne!(signed, tampered); + assert!(matches!( + consume(&tampered, t.cert_der), + Err(SamlError::Signature(_) | SamlError::SignatureScope) + )); +} + +#[test] +fn unsigned_response_is_rejected() { + let t = test_idp(); + // Feed the *unsigned* template (empty SignatureValue) straight in. + let unsigned = Resp::default().template(); + assert!(matches!( + consume(&unsigned, t.cert_der), + Err(SamlError::Signature(_)) + )); +} + +#[test] +fn wrong_signing_key_is_rejected() { + let signer = test_idp(); + let other = test_idp(); // different keypair pinned as the "IdP" cert + let signed = sign(&Resp::default().template(), &signer.key_pem); + assert!(matches!( + consume(&signed, other.cert_der), + Err(SamlError::Signature(_)) + )); +} + +#[test] +fn wrong_audience_is_rejected() { + let t = test_idp(); + let r = Resp { + audience: "https://someone-else.example".into(), + ..Resp::default() + }; + let signed = sign(&r.template(), &t.key_pem); + assert!(matches!( + consume(&signed, t.cert_der), + Err(SamlError::AudienceMismatch) + )); +} + +#[test] +fn expired_assertion_is_rejected() { + let t = test_idp(); + let now = OffsetDateTime::now_utc(); + let r = Resp { + not_before: now - Duration::hours(2), + not_on_or_after: now - Duration::hours(1), + ..Resp::default() + }; + let signed = sign(&r.template(), &t.key_pem); + assert!(matches!( + consume(&signed, t.cert_der), + Err(SamlError::TimeBounds) + )); +} + +#[test] +fn future_assertion_is_rejected() { + let t = test_idp(); + let now = OffsetDateTime::now_utc(); + let r = Resp { + not_before: now + Duration::hours(1), + not_on_or_after: now + Duration::hours(2), + ..Resp::default() + }; + let signed = sign(&r.template(), &t.key_pem); + assert!(matches!( + consume(&signed, t.cert_der), + Err(SamlError::TimeBounds) + )); +} + +#[test] +fn wrong_issuer_is_rejected() { + let t = test_idp(); + let r = Resp { + issuer: "https://evil-idp.example".into(), + ..Resp::default() + }; + let signed = sign(&r.template(), &t.key_pem); + assert!(matches!( + consume(&signed, t.cert_der), + Err(SamlError::IssuerMismatch) + )); +} + +#[test] +fn non_success_status_is_rejected() { + let t = test_idp(); + let r = Resp { + status: "urn:oasis:names:tc:SAML:2.0:status:Requester".into(), + ..Resp::default() + }; + let signed = sign(&r.template(), &t.key_pem); + assert!(matches!( + consume(&signed, t.cert_der), + Err(SamlError::Status(_)) + )); +} + +#[test] +fn idp_initiated_has_no_in_response_to() { + // No InResponseTo => the HTTP layer must gate it behind allow_idp_initiated. + let t = test_idp(); + let r = Resp { + in_response_to: None, + ..Resp::default() + }; + let signed = sign(&r.template(), &t.key_pem); + let out = consume(&signed, t.cert_der).expect("unsolicited but otherwise valid"); + assert!(out.in_response_to.is_none()); +} diff --git a/crates/core/src/sso.rs b/crates/core/src/sso.rs index a32fa1d..da47b9e 100644 --- a/crates/core/src/sso.rs +++ b/crates/core/src/sso.rs @@ -1,16 +1,17 @@ -//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5. +//! SAML SSO configuration — FleetDM-shaped. //! //! The operator pastes their IdP's metadata XML (or its URL) and a -//! human-readable label; v0.4.5 just persists it. The actual SAML -//! response-validation / JIT-provisioning flow lands in a later release -//! — for now we cover the "configurable" half so an operator can teach -//! OpenPXE about their IdP today and flip the switch on next upgrade. +//! human-readable label. As of v0.5.1 the SAML login flow is wired +//! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS +//! endpoint, pure-Rust signature verification, and operator-session +//! minting. This module owns only the persisted *configuration*. //! //! Shape borrowed from 's app-config -//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you -//! have access or you don't). Entity ID is omitted from the operator -//! UI per the v0.4.5 brief — it defaults to the advertised public base -//! URL when SAML wiring lands, which is what most IdPs expect anyway. +//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any +//! IdP-authenticated user the SP cryptographically verifies gets an +//! operator session; there is no per-user role table). Entity ID is +//! exposed (FleetDM-style) but defaults to the advertised public base +//! URL when blank, which is what most IdPs expect anyway. use parking_lot::RwLock; use serde::{Deserialize, Serialize}; @@ -47,6 +48,18 @@ pub struct SsoConfig { /// future SAML flow; not validated here beyond a basic length cap. #[serde(default)] pub metadata_url: String, + /// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID". + /// Must exactly match the SP/Relying-Party entry configured on the IdP. + /// Empty falls back to the advertised public base URL at runtime, which + /// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`]. + #[serde(default)] + pub entity_id: String, + /// Allow IdP-initiated login — an unsolicited `` POSTed to the + /// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login + /// initiated by identity provider". Default off; SP-initiated (the + /// "Sign in with X" button) is always allowed regardless. + #[serde(default)] + pub allow_idp_initiated: bool, } impl SsoConfig { @@ -56,8 +69,7 @@ impl SsoConfig { /// surface a yellow "configured but not live yet" hint. #[must_use] pub fn is_usable(&self) -> bool { - self.enabled - && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty()) + self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty()) } } @@ -108,6 +120,12 @@ impl SsoStore { cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string(); cfg.metadata = cfg.metadata.trim().to_string(); cfg.metadata_url = cfg.metadata_url.trim().to_string(); + cfg.entity_id = cfg.entity_id.trim().to_string(); + if cfg.entity_id.len() > MAX_URL_LEN { + return Err(Error::Invalid(format!( + "entity_id exceeds {MAX_URL_LEN}-char cap" + ))); + } if cfg.metadata.len() > MAX_METADATA_BYTES { return Err(Error::Invalid(format!( "metadata XML exceeds {MAX_METADATA_BYTES}-byte cap" @@ -217,6 +235,8 @@ mod tests { metadata: String::new(), metadata_url: "https://idp.example.com/metadata".into(), idp_logo_url: String::new(), + entity_id: String::new(), + allow_idp_initiated: false, }) .unwrap(); drop(s); @@ -239,6 +259,8 @@ mod tests { metadata: xml.into(), metadata_url: String::new(), idp_logo_url: String::new(), + entity_id: String::new(), + allow_idp_initiated: false, }) .unwrap(); assert!(s.snapshot().is_usable()); @@ -254,6 +276,8 @@ mod tests { metadata: String::new(), metadata_url: String::new(), idp_logo_url: String::new(), + entity_id: String::new(), + allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); // …and a disabled blank config is fine. @@ -270,6 +294,8 @@ mod tests { metadata: String::new(), metadata_url: "ftp://idp.example.com/metadata".into(), idp_logo_url: String::new(), + entity_id: String::new(), + allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); } @@ -287,6 +313,8 @@ mod tests { metadata: String::new(), metadata_url: String::new(), idp_logo_url: "data:image/png;base64,...".into(), + entity_id: String::new(), + allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); // Real HTTPS URL is fine. @@ -296,9 +324,51 @@ mod tests { metadata: String::new(), metadata_url: String::new(), idp_logo_url: "https://idp.example.com/logo.png".into(), + entity_id: String::new(), + allow_idp_initiated: false, }) .unwrap(); - assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png"); + assert_eq!( + s.snapshot().idp_logo_url, + "https://idp.example.com/logo.png" + ); + } + + #[test] + fn entity_id_and_idp_initiated_round_trip() { + // v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload. + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + s.replace(SsoConfig { + enabled: true, + idp_name: "Keycloak".into(), + metadata: String::new(), + metadata_url: "https://idp.example.com/metadata".into(), + idp_logo_url: String::new(), + entity_id: "https://pxe.example.com".into(), + allow_idp_initiated: true, + }) + .unwrap(); + drop(s); + let cfg = SsoStore::load_or_default(dir.path()).snapshot(); + assert_eq!(cfg.entity_id, "https://pxe.example.com"); + assert!(cfg.allow_idp_initiated); + } + + #[test] + fn entity_id_cap_enforced() { + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + let r = s.replace(SsoConfig { + enabled: false, + idp_name: String::new(), + metadata: String::new(), + metadata_url: String::new(), + idp_logo_url: String::new(), + entity_id: "x".repeat(MAX_URL_LEN + 1), + allow_idp_initiated: false, + }); + assert!(matches!(r, Err(Error::Invalid(_)))); } #[test] @@ -312,6 +382,8 @@ mod tests { metadata: oversize, metadata_url: String::new(), idp_logo_url: String::new(), + entity_id: String::new(), + allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); } diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml index 86c87d0..ffb108a 100644 --- a/crates/http-api/Cargo.toml +++ b/crates/http-api/Cargo.toml @@ -44,6 +44,8 @@ parking_lot.workspace = true # OpenSSL-free. reqwest.workspace = true lettre.workspace = true +# v0.5.1: decode the base64 SAMLResponse at the ACS endpoint. +base64.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } @@ -54,3 +56,8 @@ time = { workspace = true } # v0.4.61: integration tests need to generate real PNG bytes for the # `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile. image = { version = "0.25", default-features = false, features = ["png"] } +# v0.5.1: the SAML ACS integration tests mint a throwaway IdP keypair +# (rcgen) and sign a SAMLResponse with bergshamra so the happy-path, +# replay, and IdP-initiated-gating flows exercise real signatures. +rcgen = "0.13" +bergshamra = { workspace = true } diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 81a4a53..ab6eddf 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -116,14 +116,16 @@ pub fn build_router(state: AppState) -> Router { .route("/api/login", post(auth_api::api_login)) .route("/api/logout", post(auth_api::api_logout)) .route("/api/me", get(auth_api::api_me)) - .route( - "/api/me/credentials", - put(auth_api::api_update_credentials), - ) - // v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only). - // The actual sign-in flow lands in a later release; this just - // gives operators a place to paste their IdP metadata today. + .route("/api/me/credentials", put(auth_api::api_update_credentials)) + // SAML SSO configuration (FleetDM-shaped). Gated behind auth — the + // operator pastes their IdP metadata, Entity ID, and toggles here. .route("/api/sso", get(api_sso_get).put(api_sso_put)) + // v0.5.1: SAML SP login flow (pre-auth — see the require_auth + // allowlist). /login redirects to the IdP, /acs consumes the signed + // response + mints a session, /metadata serves our SP descriptor. + .route("/api/sso/login", get(crate::saml_routes::sso_login)) + .route("/api/sso/acs", post(crate::saml_routes::sso_acs)) + .route("/api/sso/metadata", get(crate::saml_routes::sso_metadata)) .route("/api/clients", get(api_list_clients)) .route("/api/status", get(api_status)) .route("/api/settings", get(api_get_settings).put(api_put_settings)) @@ -138,13 +140,19 @@ pub fn build_router(state: AppState) -> Router { // modules (Unraid), and no container-side configuration could // load a host kernel module. `smbclient` speaks SMB over a // plain TCP socket in userspace, works in every container. - .route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add)) + .route( + "/api/smb-shares", + get(api_smb_shares_list).post(api_smb_shares_add), + ) .route("/api/smb-shares/:id", delete(api_smb_shares_remove)) .route("/api/smb-shares/:id/scan", post(api_smb_shares_scan)) // v0.4.67: NFSv3 share manager (pure-Rust in-process client). // Ships alongside SMB. Routes are parallel so the UI can // reuse the same form/error/hint rendering for both. - .route("/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add)) + .route( + "/api/nfs-shares", + get(api_nfs_shares_list).post(api_nfs_shares_add), + ) .route("/api/nfs-shares/:id", delete(api_nfs_shares_remove)) .route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan)) // Phase 4: Network info (read-only) + DNS edit. @@ -204,9 +212,7 @@ async fn api_sso_get(State(state): State) -> Json { async fn api_sso_put(State(state): State, Json(body): Json) -> Response { match state.sso.replace(body) { Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(), - Err(Error::Invalid(msg)) => { - (StatusCode::BAD_REQUEST, msg).into_response() - } + Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } @@ -251,8 +257,7 @@ async fn index(State(state): State) -> Response { /// with the `?v=` query string in index.html, the practical /// upper bound on caching across an upgrade is "until the operator /// reloads". -const ASSET_CACHE_CONTROL: HeaderValue = - HeaderValue::from_static("no-cache, must-revalidate"); +const ASSET_CACHE_CONTROL: HeaderValue = HeaderValue::from_static("no-cache, must-revalidate"); async fn ui_js() -> Response { ( @@ -347,9 +352,7 @@ async fn ui_pxe_logo(State(state): State) -> Response { // iPXE/our compositor can consume. SVG (or a missing/unreadable // file) yields `None`, which composes the default background. let raster: Option> = match (state.branding.logo_path(), state.branding.logo_mime()) { - (Some(path), Some(mime)) if mime != "image/svg+xml" => { - tokio::fs::read(&path).await.ok() - } + (Some(path), Some(mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(), _ => None, }; @@ -693,9 +696,7 @@ async fn iso_raw( }; match stream_file_range(&path, headers.get(header::RANGE)).await { Ok(r) => r, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response() - } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } IsoSource::Smb { @@ -710,7 +711,10 @@ async fn iso_raw( if headers.get(header::RANGE).is_some() { return Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) - .header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes)) + .header( + header::CONTENT_RANGE, + format!("bytes */{}", meta.size_bytes), + ) .body(Body::empty()) .unwrap(); } @@ -1048,10 +1052,7 @@ async fn api_storage_disk(State(state): State) -> Json, - mut multipart: Multipart, -) -> Response { +async fn api_branding_upload(State(state): State, mut multipart: Multipart) -> Response { while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name != "file" && name != "logo" { @@ -1072,9 +1073,7 @@ async fn api_branding_upload( // hitting disk. Logos are tiny by definition. let bytes = match field.bytes().await { Ok(b) => b, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response() - } + Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(), }; if bytes.len() > MAX_LOGO_BYTES { return ( @@ -1102,9 +1101,7 @@ async fn api_branding_upload( ) .into_response() } - Err(e) => { - return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response() - } + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } (StatusCode::BAD_REQUEST, "no 'file' part").into_response() @@ -2064,10 +2061,7 @@ async fn api_hosts_remove( /// common "same VLAN as OpenPXE" case with zero network config. We only /// wake MACs that are actually bound — keeps this from being an open /// "spray packets at any MAC" endpoint. -async fn api_hosts_wol( - State(state): State, - AxumPath(mac): AxumPath, -) -> Response { +async fn api_hosts_wol(State(state): State, AxumPath(mac): AxumPath) -> Response { if state.hosts.lookup(&mac).is_none() { return ( StatusCode::NOT_FOUND, @@ -2097,8 +2091,7 @@ async fn api_hosts_wol( // The send is a blocking std UDP call; push it off the async // executor. let mac_owned = mac.clone(); - let result = - tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await; + let result = tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await; match result { Ok(Ok(n)) => { // Fire-and-forget notification — nice "someone woke a box" @@ -2111,7 +2104,11 @@ async fn api_hosts_wol( Json(json!({ "ok": true, "broadcasts": n })).into_response() } Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("wol task failed: {e}")).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("wol task failed: {e}"), + ) + .into_response(), } } @@ -2122,10 +2119,7 @@ async fn api_notify_get(State(state): State) -> Json { Json(state.notify.snapshot().redacted()) } -async fn api_notify_put( - State(state): State, - Json(cfg): Json, -) -> Response { +async fn api_notify_put(State(state): State, Json(cfg): Json) -> Response { match state.notify.replace(cfg) { Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(), Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), @@ -2191,10 +2185,7 @@ async fn api_updates_check() -> Response { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let update_available = version_is_newer( - latest_tag.trim_start_matches('v'), - current, - ); + let update_available = version_is_newer(latest_tag.trim_start_matches('v'), current); Json(json!({ "current": current, "latest": latest_tag, @@ -2241,7 +2232,11 @@ fn gitea_releases_api_url() -> Option { fn version_is_newer(latest: &str, current: &str) -> bool { fn parts(v: &str) -> Vec { v.split('.') - .map(|p| p.chars().take_while(char::is_ascii_digit).collect::()) + .map(|p| { + p.chars() + .take_while(char::is_ascii_digit) + .collect::() + }) .map(|s| s.parse::().unwrap_or(0)) .collect() } diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs index 5879b36..9ee83e7 100644 --- a/crates/http-api/src/auth.rs +++ b/crates/http-api/src/auth.rs @@ -140,9 +140,16 @@ fn cookie_attrs(value: &str, max_age: Option) -> String { // never overflow i64, but clippy's `cast_possible_wrap` lint wants // us to be explicit. `cast_signed` is the documented form. let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed()); - format!( - "{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}" - ) + format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}") +} + +/// Build the `Set-Cookie` header value that establishes a fresh operator +/// session with the default 24h TTL. Exposed so the SAML ACS handler can +/// attach an operator session to its post-login redirect, exactly as the +/// Forms-login path does via [`login_response`]. +#[must_use] +pub fn session_cookie(session: &str) -> String { + cookie_attrs(session, None) } fn parse_cookie(headers: &axum::http::HeaderMap) -> Option { @@ -169,9 +176,18 @@ fn is_public_path(path: &str) -> bool { return true; } // Auth surface and iPXE long-poll endpoints (no cookie available). + // The SAML SP endpoints are pre-auth by nature — the operator hasn't a + // session yet when they start (or arrive from) the IdP. `/api/sso` + // (the config GET/PUT, no trailing slash) stays gated. matches!( path, - "/api/setup" | "/api/login" | "/api/logout" | "/api/me" + "/api/setup" + | "/api/login" + | "/api/logout" + | "/api/me" + | "/api/sso/login" + | "/api/sso/acs" + | "/api/sso/metadata" ) || path.starts_with("/api/queue/join") || path.starts_with("/api/queue/poll/") } @@ -222,10 +238,7 @@ pub struct SetupBody { /// guards against a leaked WebUI being re-bootstrapped by an attacker /// who's seen the deployment URL. After bootstrap, the new session /// cookie is set so the operator goes straight to the dashboard. -pub async fn api_setup( - State(state): State, - Json(body): Json, -) -> Response { +pub async fn api_setup(State(state): State, Json(body): Json) -> Response { if state.admin.is_configured() { return ( StatusCode::CONFLICT, @@ -280,10 +293,7 @@ pub async fn api_login(State(state): State, Json(body): Json, - headers: axum::http::HeaderMap, -) -> Response { +pub async fn api_logout(State(state): State, headers: axum::http::HeaderMap) -> Response { if let Some(t) = parse_cookie(&headers) { state.sessions.revoke(&t); } @@ -449,8 +459,14 @@ mod tests { fn public_path_allowlist() { // PXE + chrome paths bypass auth. for p in [ - "/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe", - "/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz", + "/", + "/assets/app.js", + "/boot.ipxe", + "/boot/fake.ipxe", + "/iso/fake.iso", + "/ipxe/snponly.efi", + "/healthz", + "/readyz", "/metrics", // v0.4.6: iPXE fetches this for `console --picture` before // it can possibly have a session cookie. @@ -462,6 +478,10 @@ mod tests { for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] { assert!(is_public_path(p), "expected {p} to be public"); } + // v0.5.1: SAML SP endpoints are pre-auth (no session yet). + for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] { + assert!(is_public_path(p), "expected {p} to be public"); + } // iPXE long-poll endpoints are public (no cookie available). assert!(is_public_path("/api/queue/join")); assert!(is_public_path("/api/queue/poll/abc")); @@ -483,8 +503,7 @@ mod tests { let mut h = axum::http::HeaderMap::new(); h.insert( header::COOKIE, - HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")) - .unwrap(), + HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(), ); assert_eq!(parse_cookie(&h).as_deref(), Some("abc123")); // Different name → None. diff --git a/crates/http-api/src/lib.rs b/crates/http-api/src/lib.rs index c3a80c2..dc631c6 100644 --- a/crates/http-api/src/lib.rs +++ b/crates/http-api/src/lib.rs @@ -19,6 +19,7 @@ pub mod ipxe_script; pub mod iso_fs; pub mod log_stream; pub mod notify; +pub mod saml_routes; pub mod state; pub mod terminal; pub mod uploads; diff --git a/crates/http-api/src/saml_routes.rs b/crates/http-api/src/saml_routes.rs new file mode 100644 index 0000000..db8be85 --- /dev/null +++ b/crates/http-api/src/saml_routes.rs @@ -0,0 +1,338 @@ +//! SAML 2.0 Service Provider HTTP endpoints (v0.5.1). +//! +//! * `GET /api/sso/login` — SP-initiated: build an AuthnRequest, record its +//! ID, and 302 the browser to the IdP. +//! * `POST /api/sso/acs` — Assertion Consumer Service: verify + validate +//! the IdP's `SAMLResponse`, perform the stateful checks (InResponseTo +//! correlation, IdP-initiated gating, assertion replay), mint an operator +//! session, and 302 to the dashboard. (Mirrors FleetDM's `/sso/callback`.) +//! * `GET /api/sso/metadata` — serve our SP metadata XML for IdP import. +//! +//! Stateless crypto + semantic validation live in `openpxe_core::saml`; this +//! module owns only the HTTP glue and the in-memory state the SP needs. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration as StdDuration, Instant}; + +use axum::{ + body::Body, + extract::{Form, Query, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; +use base64::Engine; +use parking_lot::Mutex; +use serde::Deserialize; +use time::{Duration, OffsetDateTime}; + +use openpxe_core::saml::{self, metadata::IdpMetadata, SamlError, SpParams}; +use openpxe_core::SsoConfig; + +use crate::auth; +use crate::state::AppState; + +/// Outstanding AuthnRequest IDs live at most this long before a matching +/// response is considered stale (covers a slow human at the IdP login form). +const REQUEST_TTL: StdDuration = StdDuration::from_mins(10); +/// How long we fetch-cache IdP metadata loaded from a URL. +const METADATA_FETCH_TIMEOUT: StdDuration = StdDuration::from_secs(10); + +/// In-memory SAML runtime state. Cheap to clone (Arc-shared). +#[derive(Clone, Default)] +pub struct SamlRuntime { + /// request_id → issued_at. Correlates a response's `InResponseTo` to a + /// request *we* actually sent (replay / CSRF defense for SP-initiated). + outstanding: Arc>>, + /// assertion_id → expiry. A consumed assertion may not be replayed. + consumed: Arc>>, + /// Cache of IdP metadata fetched from a URL: (url, parsed). + metadata_cache: Arc>>, +} + +impl SamlRuntime { + /// Record an AuthnRequest we just sent. + pub fn register_request(&self, id: &str) { + let mut g = self.outstanding.lock(); + prune(&mut g); + g.insert(id.to_owned(), Instant::now()); + } + + /// Consume an outstanding request ID, returning `true` if it was present + /// and still fresh. A miss means the response doesn't correlate to any + /// live request we issued. + pub fn take_request(&self, id: &str) -> bool { + let mut g = self.outstanding.lock(); + prune(&mut g); + g.remove(id).is_some() + } + + /// Record a consumed assertion. Returns `false` if it was already + /// consumed (a replay) — in which case the caller must reject. + pub fn record_assertion(&self, id: &str, expiry: OffsetDateTime) -> bool { + let mut g = self.consumed.lock(); + prune(&mut g); + if g.contains_key(id) { + return false; + } + let ttl = (expiry - OffsetDateTime::now_utc()) + .max(Duration::ZERO) + .unsigned_abs(); + g.insert(id.to_owned(), Instant::now() + ttl); + true + } + + fn cached_metadata(&self, url: &str) -> Option { + let g = self.metadata_cache.lock(); + match &*g { + Some((cached_url, md)) if cached_url == url => Some(md.clone()), + _ => None, + } + } + + fn cache_metadata(&self, url: String, md: IdpMetadata) { + *self.metadata_cache.lock() = Some((url, md)); + } +} + +/// Drop expired entries so neither map grows unbounded. +fn prune(map: &mut HashMap) { + let now = Instant::now(); + // For the request map this over-prunes (entries store issued_at, not + // expiry), so cap by REQUEST_TTL; the consumed map stores absolute + // expiry instants. Using saturating logic keeps both correct: request + // entries older than REQUEST_TTL go, consumed entries past expiry go. + map.retain(|_, &mut t| now.saturating_duration_since(t) < REQUEST_TTL || t > now); +} + +// ─── GET /api/sso/login ─────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct LoginQuery { + /// Optional local path to return to after login (becomes RelayState). + #[serde(default)] + pub next: Option, +} + +pub async fn sso_login(State(state): State, Query(q): Query) -> Response { + let cfg = state.sso.snapshot(); + if !cfg.is_usable() { + return redirect("/?sso_error=unavailable"); + } + let idp = match resolve_idp_metadata(&state, &cfg).await { + Ok(m) => m, + Err(e) => { + tracing::warn!(target: "openpxe::saml", "sso_login: metadata unavailable: {e}"); + return redirect("/?sso_error=metadata"); + } + }; + let Some(dest) = idp.sso_destination().map(str::to_owned) else { + tracing::warn!(target: "openpxe::saml", "sso_login: IdP metadata has no SSO endpoint"); + return redirect("/?sso_error=metadata"); + }; + let sp = sp_params(&state, &cfg); + let relay = safe_local_path(q.next.as_deref()); + match saml::authn_request::build(&sp, &dest, Some(&relay)) { + Ok(req) => { + state.saml.register_request(&req.id); + redirect(&req.location) + } + Err(e) => { + tracing::warn!(target: "openpxe::saml", "sso_login: build AuthnRequest failed: {e}"); + redirect("/?sso_error=request") + } + } +} + +// ─── POST /api/sso/acs ────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct AcsForm { + #[serde(rename = "SAMLResponse")] + pub saml_response: String, + #[serde(rename = "RelayState", default)] + pub relay_state: Option, +} + +pub async fn sso_acs(State(state): State, Form(form): Form) -> Response { + let cfg = state.sso.snapshot(); + if !cfg.is_usable() { + return redirect("/?sso_error=unavailable"); + } + let xml = match base64::engine::general_purpose::STANDARD.decode(form.saml_response.as_bytes()) + { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(e) => { + tracing::warn!(target: "openpxe::saml", "acs: base64 decode failed: {e}"); + return redirect("/?sso_error=1"); + } + }; + let idp = match resolve_idp_metadata(&state, &cfg).await { + Ok(m) => m, + Err(e) => { + tracing::warn!(target: "openpxe::saml", "acs: metadata unavailable: {e}"); + return redirect("/?sso_error=metadata"); + } + }; + let sp = sp_params(&state, &cfg); + + // Signature verification + semantic checks are CPU-bound — keep them off + // the async executor. + let now = OffsetDateTime::now_utc(); + let skew = Duration::seconds(saml::DEFAULT_CLOCK_SKEW_SECS); + let verify = { + let xml = xml.clone(); + let sp = sp.clone(); + tokio::task::spawn_blocking(move || saml::response::consume(&xml, &sp, &idp, now, skew)) + .await + }; + let verified = match verify { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + // Never leak which specific check failed to the browser. + tracing::warn!(target: "openpxe::saml", "acs: response rejected: {e}"); + return redirect("/?sso_error=1"); + } + Err(join) => { + tracing::error!(target: "openpxe::saml", "acs: verify task panicked: {join}"); + return redirect("/?sso_error=1"); + } + }; + + // Stateful checks the core deliberately left to us. + match &verified.in_response_to { + Some(id) => { + if !state.saml.take_request(id) { + tracing::warn!(target: "openpxe::saml", "acs: InResponseTo matches no live request"); + return redirect("/?sso_error=1"); + } + } + None => { + if !cfg.allow_idp_initiated { + tracing::warn!(target: "openpxe::saml", "acs: IdP-initiated login is disabled"); + return redirect("/?sso_error=idp_initiated"); + } + } + } + if !state + .saml + .record_assertion(&verified.assertion_id, verified.assertion_expiry) + { + tracing::warn!(target: "openpxe::saml", "acs: assertion replay rejected"); + return redirect("/?sso_error=1"); + } + + // Success → mint an operator session keyed to the verified email. + let session = state.sessions.create(&verified.principal.email); + tracing::info!( + target: "openpxe::saml", + email = %verified.principal.email, + idp_initiated = verified.in_response_to.is_none(), + "SAML SSO sign-in" + ); + // safe_local_path already maps None / unsafe values to "/". + let relay = safe_local_path(form.relay_state.as_deref()); + redirect_with_session(&relay, &session) +} + +// ─── GET /api/sso/metadata ────────────────────────────────────────────────── + +pub async fn sso_metadata(State(state): State) -> Response { + let cfg = state.sso.snapshot(); + let sp = sp_params(&state, &cfg); + let xml = saml::metadata::build_sp_metadata(&sp); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/samlmetadata+xml")], + xml, + ) + .into_response() +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +/// Derive runtime SP parameters from config + the advertised public base URL. +fn sp_params(state: &AppState, cfg: &SsoConfig) -> SpParams { + let base = state.public_base_url.trim_end_matches('/'); + let entity_id = if cfg.entity_id.trim().is_empty() { + base.to_owned() + } else { + cfg.entity_id.trim().to_owned() + }; + SpParams { + entity_id, + acs_url: format!("{base}/api/sso/acs"), + } +} + +/// Resolve the IdP metadata: prefer the metadata URL (fetched + cached) per +/// the "URL wins" rule, else parse the pasted XML. +async fn resolve_idp_metadata(state: &AppState, cfg: &SsoConfig) -> Result { + let url = cfg.metadata_url.trim(); + if !url.is_empty() { + if let Some(md) = state.saml.cached_metadata(url) { + return Ok(md); + } + let body = fetch_metadata(url).await?; + let md = IdpMetadata::parse(&body)?; + state.saml.cache_metadata(url.to_owned(), md.clone()); + return Ok(md); + } + if !cfg.metadata.trim().is_empty() { + return IdpMetadata::parse(&cfg.metadata); + } + Err(SamlError::Metadata("no metadata source configured".into())) +} + +async fn fetch_metadata(url: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(METADATA_FETCH_TIMEOUT) + .build() + .map_err(|e| SamlError::Metadata(format!("http client: {e}")))?; + let resp = client + .get(url) + .send() + .await + .map_err(|e| SamlError::Metadata(format!("fetch {url}: {e}")))?; + if !resp.status().is_success() { + return Err(SamlError::Metadata(format!( + "fetch {url}: HTTP {}", + resp.status() + ))); + } + resp.text() + .await + .map_err(|e| SamlError::Metadata(format!("read {url}: {e}"))) +} + +/// Only permit a same-site path (single leading slash) as a redirect target — +/// blocks open-redirect / protocol-relative (`//evil.com`) abuse of RelayState. +fn safe_local_path(p: Option<&str>) -> String { + match p { + Some(p) if p.starts_with('/') && !p.starts_with("//") => p.to_owned(), + _ => "/".to_owned(), + } +} + +fn redirect(location: &str) -> Response { + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, location) + .body(Body::empty()) + .map_or_else( + |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), + IntoResponse::into_response, + ) +} + +fn redirect_with_session(location: &str, session: &str) -> Response { + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, location) + .header(header::SET_COOKIE, auth::session_cookie(session)) + .body(Body::empty()) + .map_or_else( + |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), + IntoResponse::into_response, + ) +} diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index b61e327..0713faf 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -1,5 +1,6 @@ -use crate::uploads::UploadSessions; use crate::auth::SessionStore; +use crate::saml_routes::SamlRuntime; +use crate::uploads::UploadSessions; use openpxe_core::{ AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore, @@ -34,9 +35,13 @@ pub struct AppState { /// process restart (sessions are tied to UI state, not persisted — /// matches Sonarr/Radarr behaviour). pub sessions: SessionStore, - /// SAML SSO configuration. v0.4.5 stores it; the actual SSO login - /// flow ships in a later release. + /// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles). pub sso: SsoStore, + /// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs + /// (for InResponseTo correlation), consumed-assertion replay guard, and + /// a cache of fetched IdP metadata. Tied to process lifetime, like + /// `sessions`; a restart simply invalidates any in-flight SSO login. + pub saml: SamlRuntime, /// v0.5.0: webhook / email notification config (Slack/Teams/Discord/ /// SMTP). Drives the fire-and-forget pings on boot events and powers /// the Advanced tab's config + "Send test" button. diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 6a1c9fa..05e84a2 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -116,6 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { admin, sessions, sso, + saml: openpxe_http_api::saml_routes::SamlRuntime::default(), notify, metrics, smb: None, @@ -559,7 +560,10 @@ async fn notify_config_round_trips_and_redacts_smtp_password() { assert_eq!(v["kind"], "smtp"); let pw = v["smtp_password"].as_str().unwrap_or(""); assert_ne!(pw, "s3cret", "raw password must never be returned"); - assert!(!pw.is_empty(), "a set password should surface as a sentinel"); + assert!( + !pw.is_empty(), + "a set password should surface as a sentinel" + ); } #[tokio::test] @@ -1459,7 +1463,7 @@ async fn storage_disk_endpoint_reports_volume_stats() { let avail = v["available_bytes"].as_u64().unwrap(); let used = v["used_bytes"].as_u64().unwrap(); assert!(total >= avail, "{v}"); - assert!(total >= used, "{v}"); + assert!(total >= used, "{v}"); assert!(v["path"].as_str().unwrap().contains("isos"), "got {v}"); } @@ -1551,7 +1555,11 @@ async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, // ─── v0.4.5: Forms auth + SSO ───────────────────────────────────────────── -async fn post_collect(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec, Vec) { +async fn post_collect( + router: &axum::Router, + path: &str, + body: &str, +) -> (StatusCode, Vec, Vec) { let res = router .clone() .oneshot( @@ -1962,7 +1970,10 @@ async fn pxe_background_falls_back_to_default_for_svg_upload() { let body = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap(); - assert!(body.starts_with(b"\x89PNG"), "should serve default PNG for SVG"); + assert!( + body.starts_with(b"\x89PNG"), + "should serve default PNG for SVG" + ); let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]); assert_eq!(width, 1024); } @@ -1974,10 +1985,7 @@ async fn pxe_logo_composes_to_1024x768_png() { // the iPXE menu always paints at consistent dimensions. let (state, _dir) = build_state().await; let png = tiny_png(); - state - .branding - .set_logo("image/png", "png", &png) - .unwrap(); + state.branding.set_logo("image/png", "png", &png).unwrap(); let app = build_router(state); let res = app .clone() @@ -2017,10 +2025,7 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() { // auth allowlist gates `/api/*` only. let (state, _dir) = build_state().await; let png = tiny_png(); - state - .branding - .set_logo("image/png", "png", &png) - .unwrap(); + state.branding.set_logo("image/png", "png", &png).unwrap(); let app = build_router(state); // Configure an admin so the middleware kicks in. let (s, _, _) = post_collect( @@ -2034,3 +2039,305 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() { let (s, _) = get(&app, "/branding/pxe-logo").await; assert_eq!(s, StatusCode::OK); } + +// ─── v0.5.1: SAML SSO flow ────────────────────────────────────────────────── +// +// The core crate exhaustively tests signature verification + semantic +// validation (crates/core/src/saml/tests.rs). These integration tests cover +// the HTTP wiring the core can't: routing, base64 decode, session minting, +// the InResponseTo / IdP-initiated gating, and assertion-replay rejection. + +use base64::Engine as _; +use openpxe_core::SsoConfig; +use time::format_description::well_known::Rfc3339; +use time::{Duration as TimeDuration, OffsetDateTime}; + +const SP_BASE: &str = "http://127.0.0.1"; // build_state's public_base_url +const SP_ACS: &str = "http://127.0.0.1/api/sso/acs"; +const IDP_ENTITY: &str = "https://idp.test/realms/fleet"; +const IDP_SSO: &str = "https://idp.test/realms/fleet/protocol/saml"; + +struct TestIdp { + cert_b64: String, + key_pem: String, +} + +fn make_idp() -> TestIdp { + let ck = rcgen::generate_simple_self_signed(vec!["idp.test".to_string()]).unwrap(); + let der = ck.cert.der().as_ref().to_vec(); + TestIdp { + cert_b64: base64::engine::general_purpose::STANDARD.encode(der), + key_pem: ck.key_pair.serialize_pem(), + } +} + +fn idp_metadata_xml(cert_b64: &str) -> String { + format!( + r#" + + {cert_b64} + + +"# + ) +} + +/// Build + sign a SAMLResponse with the test IdP key. `in_response_to: None` +/// makes it an unsolicited (IdP-initiated) response. +fn signed_response(idp: &TestIdp, in_response_to: Option<&str>) -> String { + let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap(); + let fmt = |t: OffsetDateTime| t.format(&Rfc3339).unwrap(); + let irt = in_response_to + .map(|v| format!(r#" InResponseTo="{v}""#)) + .unwrap_or_default(); + let template = format!( + r##" + {IDP_ENTITY} + + + {IDP_ENTITY} + + + + + + + + + + + + + + + + + tech@example.com + + + + + + {SP_BASE} + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + +"##, + now = fmt(now), + nb = fmt(now - TimeDuration::minutes(5)), + noa = fmt(now + TimeDuration::hours(1)), + ); + let key = bergshamra::keys::loader::load_pem_auto(idp.key_pem.as_bytes(), None).unwrap(); + let mut km = bergshamra::keys::KeysManager::new(); + km.add_key(key); + let ctx = bergshamra::DsigContext::new(km); + bergshamra::sign(&ctx, &template).unwrap() +} + +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 3); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase()); + out.push(char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase()); + } + } + } + out +} + +fn configure_sso(state: &AppState, metadata: String, allow_idp_initiated: bool) { + state + .sso + .replace(SsoConfig { + enabled: true, + idp_name: "Test IdP".into(), + idp_logo_url: String::new(), + metadata, + metadata_url: String::new(), + entity_id: String::new(), + allow_idp_initiated, + }) + .unwrap(); +} + +async fn post_acs(router: &axum::Router, signed_xml: &str) -> axum::response::Response { + let b64 = base64::engine::general_purpose::STANDARD.encode(signed_xml.as_bytes()); + let body = format!("SAMLResponse={}", urlencode(&b64)); + router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/sso/acs") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap() +} + +fn has_session_cookie(resp: &axum::response::Response) -> bool { + resp.headers().get_all(header::SET_COOKIE).iter().any(|v| { + let s = v.to_str().unwrap_or(""); + s.starts_with("openpxe_session=") + && !s.contains("openpxe_session=;") + && !s.contains("Max-Age=0") + }) +} + +fn location(resp: &axum::response::Response) -> String { + resp.headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned() +} + +#[tokio::test] +async fn sso_login_redirects_to_idp() { + let (state, _dir) = build_state().await; + let idp = make_idp(); + configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false); + let app = build_router(state); + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/api/sso/login") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FOUND); + let loc = location(&resp); + assert!(loc.starts_with(IDP_SSO), "redirect to IdP, got {loc}"); + assert!( + loc.contains("SAMLRequest="), + "carries SAMLRequest, got {loc}" + ); +} + +#[tokio::test] +async fn sso_login_unavailable_when_disabled() { + let (state, _dir) = build_state().await; + let app = build_router(state); // SSO never configured + let resp = app + .oneshot( + Request::builder() + .uri("/api/sso/login") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FOUND); + assert!(location(&resp).contains("sso_error")); +} + +#[tokio::test] +async fn sso_metadata_is_served() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (status, body) = get(&app, "/api/sso/metadata").await; + assert_eq!(status, StatusCode::OK); + let xml = String::from_utf8(body).unwrap(); + assert!(xml.contains("SPSSODescriptor")); + assert!(xml.contains(SP_ACS)); + assert!(xml.contains(SP_BASE)); +} + +#[tokio::test] +async fn acs_idp_initiated_mints_session() { + let (state, _dir) = build_state().await; + let idp = make_idp(); + configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true); + let app = build_router(state); + let signed = signed_response(&idp, None); + let resp = post_acs(&app, &signed).await; + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(location(&resp), "/"); + assert!( + has_session_cookie(&resp), + "ACS must set an operator session cookie" + ); +} + +#[tokio::test] +async fn acs_idp_initiated_blocked_when_disabled() { + let (state, _dir) = build_state().await; + let idp = make_idp(); + configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false); // gate OFF + let app = build_router(state); + let signed = signed_response(&idp, None); + let resp = post_acs(&app, &signed).await; + assert_eq!(resp.status(), StatusCode::FOUND); + assert!(location(&resp).contains("sso_error")); + assert!( + !has_session_cookie(&resp), + "no session when IdP-initiated is disabled" + ); +} + +#[tokio::test] +async fn acs_sp_initiated_without_known_request_is_rejected() { + let (state, _dir) = build_state().await; + let idp = make_idp(); + configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true); + let app = build_router(state); + // A valid signature but an InResponseTo we never issued => reject. + let signed = signed_response(&idp, Some("_never-issued")); + let resp = post_acs(&app, &signed).await; + assert_eq!(resp.status(), StatusCode::FOUND); + assert!(location(&resp).contains("sso_error")); + assert!(!has_session_cookie(&resp)); +} + +#[tokio::test] +async fn acs_replayed_assertion_is_rejected() { + let (state, _dir) = build_state().await; + let idp = make_idp(); + configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true); + let app = build_router(state); + let signed = signed_response(&idp, None); + // First use succeeds… + let first = post_acs(&app, &signed).await; + assert!(has_session_cookie(&first)); + // …replaying the identical assertion is rejected. + let second = post_acs(&app, &signed).await; + assert_eq!(second.status(), StatusCode::FOUND); + assert!(location(&second).contains("sso_error")); + assert!(!has_session_cookie(&second)); +} + +#[tokio::test] +async fn acs_garbage_is_rejected_without_500() { + let (state, _dir) = build_state().await; + let idp = make_idp(); + configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true); + let app = build_router(state); + let body = "SAMLResponse=not%20valid%20base64%21%21"; + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/sso/acs") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FOUND); + assert!(location(&resp).contains("sso_error")); + assert!(!has_session_cookie(&resp)); +} diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index 76c3df5..b0ed347 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -168,6 +168,7 @@ async fn main() -> anyhow::Result<()> { admin: admin.clone(), sessions: sessions.clone(), sso: sso.clone(), + saml: openpxe_http_api::saml_routes::SamlRuntime::default(), notify: notify.clone(), metrics: metrics.clone(), smb: Some(smb.clone()), diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index a5294e2..3a49939 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -797,3 +797,46 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); } .logo-preview .info { flex: 1; min-width: 0; } .logo-preview .info .name { color: var(--fg); font-weight: 600; } .logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; } + +/* v0.5.1: collapsible "Advanced" disclosure at the bottom of Settings + (the former Advanced sidebar tab). A quiet, full-width toggle that + expands to reveal the notification + API-reference cards. */ +.advanced-disclosure { width: 100%; } +.advanced-summary { + list-style: none; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + color: var(--fg-dim); + font-size: 13px; + font-weight: 600; + background: var(--bg-panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.advanced-summary:hover { color: var(--fg); } +.advanced-summary::-webkit-details-marker { display: none; } +.advanced-summary::before { + content: "▸"; + font-size: 11px; + transition: transform 0.15s ease; +} +.advanced-disclosure[open] .advanced-summary::before { transform: rotate(90deg); } + +/* v0.5.1: protocol tag on a unified Remote-shares row (SMB / NFS). */ +.proto-badge { + display: inline-block; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 1px 6px; + margin-right: 8px; + border-radius: 4px; + vertical-align: middle; + background: var(--bg-panel-2); + border: 1px solid var(--border); + color: var(--fg-dim); +} diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index b83cbe1..46c4c2d 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -684,19 +684,46 @@ ]) : el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.'); - // ── SMB shares section (v0.4.65) ── - // Replaces the kernel-mount NFS card. SMB shares are consumed - // in userspace via Samba's `smbclient` CLI — no kernel modules, - // no CAP_SYS_ADMIN, works in any container. This is the same - // approach Bootimus uses. - const smbMsg = el('div', {class:'msg'}); + // ── Remote shares section (v0.5.1) ── + // SMB + NFS unified into one "Remote shares" card with a protocol + // dropdown. The two protocols keep their own backend endpoints + // (/api/smb-shares, /api/nfs-shares) and the same add/scan/remove + // UX; the form just swaps the relevant fields. This declutters the + // Storage tab and leaves room for a future "Config files" card. + const shareMsg = el('div', {class:'msg'}); + + // Shared structured-error renderer ({error, stderr, hint}) for both + // protocols' add calls. + const showShareError = async (r) => { + let bodyJson = null; + let raw = null; + try { bodyJson = await r.clone().json(); } + catch (_) { raw = await r.text().catch(() => 'connect failed'); } + const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed'); + const hint = bodyJson && bodyJson.hint; + const parts = [el('div', {}, [ + el('strong', {}, 'Connect failed: '), + document.createTextNode(msg), + ])]; + if (hint) { + parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint)); + } + shareMsg.replaceChildren(...parts); + shareMsg.className = 'msg err'; + }; + + // Protocol picker — swaps which field block is visible. + const protoSelect = el('select', {}, [ + el('option', {value:'smb'}, 'SMB / CIFS'), + el('option', {value:'nfs'}, 'NFS (NFSv3)'), + ]); + + // SMB inputs. const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'}); const smbShare = el('input', {type:'text', placeholder:'isos'}); const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true; const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'}); const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'}); - // Toggle username/password fields based on the Guest checkbox so - // operators don't get confused about which fields matter. const syncAuthDisabled = () => { smbUser.disabled = smbGuest.checked; smbPass.disabled = smbGuest.checked; @@ -705,58 +732,23 @@ }; smbGuest.addEventListener('change', syncAuthDisabled); syncAuthDisabled(); + const smbFields = el('div', {}, [ + el('div', {class:'form-row cols-2'}, [ + el('label', {class:'field'}, [el('span', {class:'name'}, 'SMB server'), smbServer]), + el('label', {class:'field'}, [el('span', {class:'name'}, 'Share name'), smbShare]), + ]), + el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [ + el('label', {class:'check'}, [smbGuest, el('span', {}, 'Guest (anonymous read)')]), + el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), smbUser]), + el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), smbPass]), + ]), + ]); - const addSmb = el('button', {onclick: async () => { - if (!smbServer.value || !smbShare.value) { - smbMsg.replaceChildren(document.createTextNode('Server and share name are required.')); - smbMsg.className='msg err'; return; - } - if (!smbGuest.checked && !smbUser.value) { - smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.')); - smbMsg.className='msg err'; return; - } - smbMsg.replaceChildren(document.createTextNode('Connecting…')); - smbMsg.className = 'msg'; - const body = { - server: smbServer.value, - share: smbShare.value, - guest: smbGuest.checked, - }; - if (!smbGuest.checked) { - body.username = smbUser.value; - body.password = smbPass.value; - } - const r = await postJSON('/api/smb-shares', body); - if (r.ok) { - smbMsg.replaceChildren(document.createTextNode('Connected.')); - smbMsg.className = 'msg ok'; - render('storage'); - } else { - // The API returns a structured {error, stderr, hint} JSON - // body on failure so the raw smbclient error and the - // actionable hint render as two distinct lines. - let bodyJson = null; - let raw = null; - try { bodyJson = await r.clone().json(); } - catch (_) { raw = await r.text().catch(()=> 'connect failed'); } - const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed'); - const hint = bodyJson && bodyJson.hint; - const parts = [el('div', {}, [ - el('strong', {}, 'Connect failed: '), - document.createTextNode(msg), - ])]; - if (hint) { - parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint)); - } - smbMsg.replaceChildren(...parts); - smbMsg.className = 'msg err'; - } - }}, 'Add share'); - - const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [ + const smbRowEls = shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [ el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}), el('div', {}, [ - el('div', {class:'id'}, '//' + m.server + '/' + m.share), + el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SMB'), + document.createTextNode('//' + m.server + '/' + m.share)]), el('div', {class:'meta'}, (m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')), @@ -773,59 +765,75 @@ render('storage'); }}, 'Remove'), el('span'), - ])) : [el('div', {class:'empty'}, 'No SMB shares configured.')]; + ])); - // ── NFS shares section (v0.4.67) ── - // Parallel to SMB shares above. The NFSv3 client is in-process - // (nfs3_client crate) so NFS-sourced ISOs support HTTP Range - // requests — SMB-sourced ones don't (smbclient CLI can't seek - // mid-stream). Otherwise the UX is identical: server + export, - // submit, scan, remove. - const nfsMsg = el('div', {class:'msg'}); + // NFS inputs. The NFSv3 client is in-process (nfs3_client crate) so + // NFS-sourced ISOs support HTTP Range — SMB-sourced ones can't seek + // mid-stream. No auth fields: NFSv3 access is gated by client IP on + // the server's export list, not client-supplied credentials. const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'}); const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'}); - const addNfs = el('button', {style:'margin-top:14px', onclick: async () => { - if (!nfsServerIn.value || !nfsExportIn.value) { - nfsMsg.replaceChildren(document.createTextNode('Server and export are required.')); - nfsMsg.className = 'msg err'; return; - } - nfsMsg.replaceChildren(document.createTextNode('Connecting…')); - nfsMsg.className = 'msg'; - const r = await postJSON('/api/nfs-shares', { - server: nfsServerIn.value, - export: nfsExportIn.value, - }); - if (r.ok) { - nfsMsg.replaceChildren(document.createTextNode('Connected.')); - nfsMsg.className = 'msg ok'; - render('storage'); - } else { - // Structured {error, stderr, hint} same as SMB. - let bodyJson = null; - let raw = null; - try { bodyJson = await r.clone().json(); } - catch (_) { raw = await r.text().catch(()=> 'connect failed'); } - const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed'); - const hint = bodyJson && bodyJson.hint; - const parts = [el('div', {}, [ - el('strong', {}, 'Connect failed: '), - document.createTextNode(msg), - ])]; - if (hint) { - parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint)); + const nfsFields = el('div', {}, [ + el('div', {class:'form-row cols-2'}, [ + el('label', {class:'field'}, [el('span', {class:'name'}, 'NFS server'), nfsServerIn]), + el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), nfsExportIn]), + ]), + ]); + + // Swap the visible field block + clear any stale message. + const syncProto = () => { + const nfs = protoSelect.value === 'nfs'; + smbFields.style.display = nfs ? 'none' : ''; + nfsFields.style.display = nfs ? '' : 'none'; + shareMsg.replaceChildren(); + shareMsg.className = 'msg'; + }; + protoSelect.addEventListener('change', syncProto); + + // One add button; dispatches to the selected protocol's endpoint. + const addShare = el('button', {style:'margin-top:14px', onclick: async () => { + if (protoSelect.value === 'smb') { + if (!smbServer.value || !smbShare.value) { + shareMsg.replaceChildren(document.createTextNode('Server and share name are required.')); + shareMsg.className = 'msg err'; return; } - nfsMsg.replaceChildren(...parts); - nfsMsg.className = 'msg err'; + if (!smbGuest.checked && !smbUser.value) { + shareMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.')); + shareMsg.className = 'msg err'; return; + } + shareMsg.replaceChildren(document.createTextNode('Connecting…')); + shareMsg.className = 'msg'; + const body = { server: smbServer.value, share: smbShare.value, guest: smbGuest.checked }; + if (!smbGuest.checked) { body.username = smbUser.value; body.password = smbPass.value; } + const r = await postJSON('/api/smb-shares', body); + if (r.ok) { + shareMsg.replaceChildren(document.createTextNode('Connected.')); + shareMsg.className = 'msg ok'; + render('storage'); + } else { await showShareError(r); } + } else { + if (!nfsServerIn.value || !nfsExportIn.value) { + shareMsg.replaceChildren(document.createTextNode('Server and export are required.')); + shareMsg.className = 'msg err'; return; + } + shareMsg.replaceChildren(document.createTextNode('Connecting…')); + shareMsg.className = 'msg'; + const r = await postJSON('/api/nfs-shares', { server: nfsServerIn.value, export: nfsExportIn.value }); + if (r.ok) { + shareMsg.replaceChildren(document.createTextNode('Connected.')); + shareMsg.className = 'msg ok'; + render('storage'); + } else { await showShareError(r); } } }}, 'Add share'); - const nfsRows = nfsShares.length ? nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [ + const nfsRowEls = nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [ el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}), el('div', {}, [ - el('div', {class:'id'}, m.server + ':' + m.export), + el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'NFS'), + document.createTextNode(m.server + ':' + m.export)]), el('div', {class:'meta'}, - 'NFSv3 · ' + - (m.reachable ? m.iso_count + ' isos' : 'not reachable')), + 'NFSv3 · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')), m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null, m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null, ]), @@ -839,7 +847,13 @@ render('storage'); }}, 'Remove'), el('span'), - ])) : [el('div', {class:'empty'}, 'No NFS shares configured.')]; + ])); + + const totalShares = shares.length + nfsShares.length; + const remoteRows = totalShares + ? [...smbRowEls, ...nfsRowEls] + : [el('div', {class:'empty'}, 'No remote shares configured.')]; + syncProto(); const diskCard = diskSpaceCard(disk); @@ -849,77 +863,36 @@ el('header', {}, el('h2', {}, 'Upload ISO')), el('div', {class:'body'}, [drop, file, prog, upMsg]), ]), + // v0.5.1: SMB + NFS unified into one "Remote shares" card with a + // protocol dropdown. Backend endpoints are unchanged; this is a + // pure UI consolidation that declutters the Storage tab. el('div', {class:'card'}, [ el('header', {}, [ - el('h2', {}, 'SMB shares'), - el('span', {class:'sub'}, shares.length + ' configured'), + el('h2', {}, 'Remote shares'), + el('span', {class:'sub'}, totalShares + ' configured'), ]), el('div', {class:'body'}, [ el('div', {class:'form-row cols-2'}, [ el('label', {class:'field'}, [ - el('span', {class:'name'}, 'SMB server'), - smbServer, - ]), - el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Share name'), - smbShare, + el('span', {class:'name'}, 'Protocol'), + protoSelect, ]), + el('span'), ]), - el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [ - el('label', {class:'check'}, [ - smbGuest, el('span', {}, 'Guest (anonymous read)'), - ]), - el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Username'), - smbUser, - ]), - el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Password'), - smbPass, - ]), - ]), - addSmb, smbMsg, - el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows), + el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]), + addShare, shareMsg, + el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows), el('p', {class:'msg', style:'margin-top:14px'}, - 'SMB shares are read in userspace via Samba’s smbclient — ' + - 'no kernel modules, no CAP_SYS_ADMIN, works in any container ' + - '(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' + - 'appliances expose ISO libraries as guest-readable; check the box ' + - 'above when that’s the case. ISOs are streamed on demand at PXE ' + - 'boot time — no local cache, no double disk usage.'), - ]), - ]), - // v0.4.67: NFS shares card sits right below SMB so operators - // can see both protocols at a glance. The form is simpler - // (no auth) because NFSv3 access control is by client IP on - // the server side, not by client-supplied credentials. - el('div', {class:'card'}, [ - el('header', {}, [ - el('h2', {}, 'NFS shares'), - el('span', {class:'sub'}, nfsShares.length + ' configured'), - ]), - el('div', {class:'body'}, [ - el('div', {class:'form-row cols-2'}, [ - el('label', {class:'field'}, [ - el('span', {class:'name'}, 'NFS server'), - nfsServerIn, - ]), - el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Export path'), - nfsExportIn, - ]), - ]), - addNfs, nfsMsg, - el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows), - el('p', {class:'msg', style:'margin-top:14px'}, - 'NFSv3 shares are read in-process via a pure-Rust client — ' + - 'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' + - 'every container the SMB path works in (Unraid included). ' + - 'NFSv3 auth is AUTH_SYS only; gate access on the server side ' + - 'by allowing this OpenPXE host’s IP in the export list. ' + - 'ISOs are streamed on demand and HTTP Range requests work — ' + - 'NFSv3 READ3 takes an explicit offset, so clients can seek ' + - 'into a 5 GB ISO without reading what comes before.'), + 'Remote ISO libraries are read on demand — no local cache, no ' + + 'double disk usage. SMB/CIFS is read in userspace via Samba’s ' + + 'smbclient; NFSv3 via a pure-Rust in-process client. Both work in ' + + 'any container (Unraid, OpenShift restricted SCC, plain Docker) with ' + + 'no kernel modules and no CAP_SYS_ADMIN. SMB supports guest or ' + + 'user/password; most NAS appliances expose ISO libraries as ' + + 'guest-readable. NFSv3 auth is AUTH_SYS only — gate access by ' + + 'allowing this OpenPXE host’s IP in the server’s export list. ' + + 'NFS-sourced ISOs also support HTTP Range (seek into a 5 GB ISO ' + + 'without reading what precedes the offset); SMB streams sequentially.'), ]), ]), el('div', {class:'card'}, [ @@ -1204,12 +1177,16 @@ }, settings: async () => { - const [status, me, sso] = await Promise.all([ + const [status, me, sso, notify, docs] = await Promise.all([ getJSON('/api/status'), getJSON('/api/me').catch(() => ({})), getJSON('/api/sso').catch(() => ({ enabled:false, idp_name:'', metadata:'', metadata_url:'', })), + // v0.5.1: the former Advanced tab folds in here, so Settings + // fetches the notify config + API docs it needs too. + getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })), + getJSON('/api/docs').catch(() => ({ groups: [] })), ]); const hasLogo = !!status.custom_logo; @@ -1507,18 +1484,26 @@ ]), ]); - // v0.5.0: the API reference moved to the Advanced tab; Settings - // now holds just account / SSO / branding. - return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]); + // v0.5.1: the former "Advanced" sidebar tab now lives here, folded + // into a collapsible disclosure beneath the core settings cards — + // webhook/email notifications + the API reference. Keeps Settings + // clean by default while leaving the knobs one click away. + const [notifyCard, apiCard] = views._advancedCards(notify, docs); + const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [ + el('summary', {class:'advanced-summary'}, 'Advanced'), + el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]), + ]); + return el('div', {}, [ + el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]), + advanced, + ]); }, - // v0.5.0: Advanced settings — webhook/email notifications, and the - // API reference (relocated from the bottom of Settings). - advanced: async () => { - const [notify, docs] = await Promise.all([ - getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })), - getJSON('/api/docs').catch(() => ({ groups: [] })), - ]); + // v0.5.1: builds the two "Advanced" cards — webhook/email notifications + // and the API reference. There is no longer an Advanced sidebar tab; + // the Settings view folds these into a collapsible disclosure and + // passes in the pre-fetched `notify` + `docs` payloads. + _advancedCards: (notify, docs) => { // ── Notification config ── const nMsg = el('div', {class:'msg', style:'margin-top:12px'}); @@ -1600,7 +1585,7 @@ const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => { nMsg.textContent = 'Saving…'; nMsg.className = 'msg'; const r = await putJSON('/api/notify', collectNotify()); - if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('advanced'); } + if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); } else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; } }}, 'Save notification settings'); const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px', @@ -1656,7 +1641,7 @@ 'No API documentation returned by /api/docs.')), ]); - return el('div', {class:'grid'}, [notifyCard, apiCard]); + return [notifyCard, apiCard]; }, about: async () => { @@ -1770,7 +1755,6 @@ hosts: 'Hosts', terminal: 'Terminal', settings: 'Settings', - advanced: 'Advanced', about: 'About', }; @@ -1910,16 +1894,28 @@ const err = el('div', {class:'auth-err', style:'display:none'}); const submit = el('button', {class:'submit', type:'submit'}, 'Sign in'); + // v0.5.1: surface a failed/blocked SSO round-trip. The ACS handler + // redirects back to "/?sso_error=..." on any failure; we show a + // generic, non-leaky message and scrub the query so a refresh is clean. + const ssoErr = new URLSearchParams(window.location.search).get('sso_error'); + if (ssoErr) { + err.textContent = ssoErr === 'idp_initiated' + ? 'IdP-initiated SSO is disabled. Use the “Sign in with …” button, or enable it under Settings → SSO.' + : (ssoErr === 'unavailable' || ssoErr === 'metadata') + ? 'Single sign-on is unavailable right now. Sign in with the local admin, or check the SSO settings.' + : 'SSO sign-in failed. Please try again, or sign in with the local admin.'; + err.style.display = ''; + window.history.replaceState({}, '', window.location.pathname); + } + const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata) ? el('button', {type:'button', class:'sso-btn', onclick: () => { - // SSO login flow lands in a later release — for now we - // surface a friendly note so the operator knows the config - // landed but the runtime hookup is pending. - err.textContent = 'SSO sign-in is configured but the runtime flow ships in a future release. Sign in with the local admin for now.'; - err.style.display = ''; + // SP-initiated SAML login (v0.5.1): hand off to the IdP. The + // /api/sso/acs endpoint verifies the response, mints the + // operator session, and redirects back to the dashboard. + window.location.assign('/api/sso/login'); }}, [ el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')), - el('div', {class:'meta'}, 'configured · runtime flow pending'), ]) : null; diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index 6fa32b9..6b91256 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -53,7 +53,6 @@ Terminal Settings - Advanced About