diff --git a/Cargo.lock b/Cargo.lock index e44e6b2c..b0bf57a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -111,6 +117,15 @@ dependencies = [ "backtrace", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -242,12 +257,24 @@ dependencies = [ "rustc-demangle", ] +[[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 = "bindgen" version = "0.69.5" @@ -277,6 +304,15 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -295,6 +331,32 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +[[package]] +name = "cat-token" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3cc24bcf74f42890dbe32674e817fcea8501665a8b3099a57e533c24cb2222" +dependencies = [ + "base64", + "chrono", + "ciborium", + "geohash", + "hex", + "hmac", + "lru", + "p256", + "regex", + "ring", + "rsa", + "serde", + "serde_cbor", + "serde_json", + "sha2", + "thiserror 1.0.61", + "uuid", + "zeroize", +] + [[package]] name = "cc" version = "1.2.40" @@ -349,6 +411,33 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half 2.7.1", +] + [[package]] name = "clang-sys" version = "1.7.0" @@ -429,6 +518,12 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -455,6 +550,15 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -470,6 +574,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.20.11" @@ -505,6 +637,17 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.4" @@ -515,6 +658,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + [[package]] name = "dunce" version = "1.0.4" @@ -527,12 +682,46 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" +[[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", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -567,6 +756,16 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[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 = "find-msvc-tools" version = "0.1.3" @@ -705,6 +904,38 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "serde", +] + +[[package]] +name = "geohash" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f58890382f70caccc5fa388981f7ac80c913795042afce9f3e065695d8f7464" +dependencies = [ + "geo-types", + "libm", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -744,6 +975,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[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.5" @@ -763,6 +1005,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -775,6 +1034,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -796,6 +1057,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.9" @@ -1085,6 +1355,9 @@ name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin", +] [[package]] name = "lazycell" @@ -1136,6 +1409,15 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1260,6 +1542,33 @@ dependencies = [ "url", ] +[[package]] +name = "moq-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "moq-transport", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "moq-auth-cat" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "cat-token", + "moq-auth", + "moq-transport", + "tokio", + "tracing", +] + [[package]] name = "moq-catalog" version = "0.2.3" @@ -1332,6 +1641,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "bytes", "clap", "fs2", "futures", @@ -1340,8 +1650,11 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "moq-api", + "moq-auth", + "moq-auth-cat", "moq-native-ietf", "moq-transport", + "rand 0.8.5", "serde", "serde_json", "thiserror 2.0.17", @@ -1462,6 +1775,22 @@ dependencies = [ "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.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1477,6 +1806,17 @@ 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-rational" version = "0.4.2" @@ -1496,6 +1836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1525,6 +1866,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[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", +] + [[package]] name = "parking_lot" version = "0.12.2" @@ -1554,6 +1907,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[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.1" @@ -1592,6 +1954,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1620,6 +2003,15 @@ dependencies = [ "syn", ] +[[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.101" @@ -1959,6 +2351,16 @@ dependencies = [ "mpeg4-audio-const", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1973,6 +2375,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2189,6 +2612,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.0" @@ -2235,6 +2672,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half 1.8.3", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2340,6 +2787,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2364,6 +2822,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -2414,6 +2882,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2720,6 +3204,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -2773,6 +3263,7 @@ checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", + "serde", "wasm-bindgen", ] @@ -3342,3 +3833,17 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +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", +] diff --git a/Cargo.toml b/Cargo.toml index 61a90c1d..dc8c4819 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ members = [ "moq-native-ietf", "moq-catalog", "moq-test-client", + "moq-auth", + "moq-auth-cat", ] resolver = "2" diff --git a/moq-auth-cat/Cargo.toml b/moq-auth-cat/Cargo.toml new file mode 100644 index 00000000..eb801690 --- /dev/null +++ b/moq-auth-cat/Cargo.toml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +[package] +name = "moq-auth-cat" +description = "C4M (CAT for MoQ) authentication hook for MoQ relay" +authors = ["moq-rs contributors"] +repository = "https://github.com/cloudflare/moq-rs" +license = "MIT OR Apache-2.0" +version = "0.1.0" +edition = "2021" + +keywords = ["quic", "moq", "auth", "cat", "c4m"] +categories = ["authentication", "network-programming"] + +[dependencies] +moq-auth = { path = "../moq-auth", version = "0.1" } +moq-transport = { path = "../moq-transport", version = "0.14" } +cat-token = "0.1.3" +async-trait = "0.1" +bytes = "1" +anyhow = "1" +tracing = { workspace = true } + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros"] } diff --git a/moq-auth-cat/src/config.rs b/moq-auth-cat/src/config.rs new file mode 100644 index 00000000..c431df0b --- /dev/null +++ b/moq-auth-cat/src/config.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::sync::Arc; + +use cat_token::{CatTokenValidator, CryptographicAlgorithm, MoqtValidator}; + +/// Configuration for building a C4MAuthHook. +pub struct C4MConfig { + pub(crate) algorithm: Arc, + pub(crate) token_validator: CatTokenValidator, + pub(crate) moqt_validator: MoqtValidator, +} + +impl C4MConfig { + pub fn new(algorithm: impl CryptographicAlgorithm + Send + Sync + 'static) -> Self { + Self { + algorithm: Arc::new(algorithm), + token_validator: CatTokenValidator::new(), + moqt_validator: MoqtValidator::new(), + } + } + + pub fn with_expected_issuers(mut self, issuers: Vec) -> Self { + self.token_validator = self.token_validator.with_expected_issuers(issuers); + self + } + + pub fn with_expected_audiences(mut self, audiences: Vec) -> Self { + self.token_validator = self.token_validator.with_expected_audiences(audiences); + self + } + + pub fn with_clock_skew_tolerance(mut self, seconds: i64) -> Self { + self.token_validator = self.token_validator.with_clock_skew_tolerance(seconds); + self + } + + pub fn with_min_revalidation_interval(mut self, seconds: f64) -> Self { + self.moqt_validator = self.moqt_validator.with_min_revalidation_interval(seconds); + self + } +} diff --git a/moq-auth-cat/src/error.rs b/moq-auth-cat/src/error.rs new file mode 100644 index 00000000..f46ebee1 --- /dev/null +++ b/moq-auth-cat/src/error.rs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use cat_token::CatError; +use moq_auth::DenyReason; + +pub(crate) fn map_cat_error(err: CatError) -> DenyReason { + match err { + CatError::TokenExpired => DenyReason::TokenExpired, + CatError::TokenNotYetValid => DenyReason::TokenExpired, + CatError::InvalidIssuer => DenyReason::IssuerUnknown, + CatError::InvalidAudience => DenyReason::TokenInvalid, + CatError::SignatureVerificationFailed => DenyReason::TokenInvalid, + CatError::MoqtActionNotAuthorized(_) => DenyReason::ScopeMismatch, + CatError::InvalidTokenFormat => DenyReason::TokenMalformed, + CatError::InvalidCbor(_) => DenyReason::TokenMalformed, + CatError::InvalidBase64(_) => DenyReason::TokenMalformed, + CatError::MissingRequiredClaim(_) => DenyReason::TokenMalformed, + CatError::ReplayAttackDetected => DenyReason::TokenReplayed, + CatError::UnsupportedAlgorithm(_) => DenyReason::TokenInvalid, + CatError::AlgorithmMismatch { .. } => DenyReason::TokenInvalid, + _ => DenyReason::Other { + message: format!("{err}"), + }, + } +} diff --git a/moq-auth-cat/src/lib.rs b/moq-auth-cat/src/lib.rs new file mode 100644 index 00000000..06c64684 --- /dev/null +++ b/moq-auth-cat/src/lib.rs @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! C4M (CAT for MoQ) authentication hook for the MoQ relay. +//! +//! Implements [draft-ietf-moq-c4m](https://datatracker.ietf.org/doc/draft-ietf-moq-c4m/) +//! using the [`cat-token`](https://crates.io/crates/cat-token) crate. + +mod config; +mod error; +mod mapping; + +#[cfg(test)] +mod tests; + +pub use config::C4MConfig; +pub use cat_token::Es256Algorithm; + +use std::sync::Arc; + +use async_trait::async_trait; +use cat_token::{ + CatTokenValidator, CryptographicAlgorithm, MoqtAction, MoqtAuthRequest, MoqtValidator, + decode_token_bytes, +}; +use moq_auth::{AuthBlob, AuthDecision, AuthHook, DenyReason, RequestContext, SessionContext}; + +use crate::error::map_cat_error; +use crate::mapping::map_operation; + +pub use cat_token::C4M_TOKEN_TYPE; + +/// C4M authentication hook implementing the CAT for MoQ auth scheme. +/// +/// Validates CWT tokens carrying MOQT-specific claims (namespace/track +/// scope matching) using the `cat-token` library. +pub struct C4MAuthHook { + algorithm: Arc, + token_validator: CatTokenValidator, + moqt_validator: MoqtValidator, +} + +impl C4MAuthHook { + pub fn new(config: C4MConfig) -> Self { + Self { + algorithm: config.algorithm, + token_validator: config.token_validator, + moqt_validator: config.moqt_validator, + } + } + + fn find_c4m_blob<'a>(&self, tokens: &'a [AuthBlob]) -> Option<&'a AuthBlob> { + tokens.iter().find(|t| t.token_type == C4M_TOKEN_TYPE) + } + + fn validate_and_authorize( + &self, + blob: &AuthBlob, + action: MoqtAction, + namespace: Vec>, + track: Vec, + ) -> Result { + let token = decode_token_bytes(&blob.token_value, self.algorithm.as_ref()).map_err( + |e| { + tracing::debug!(error = %e, "C4M token decode/signature failed"); + map_cat_error(e) + }, + )?; + + self.token_validator.validate(&token).map_err(|e| { + tracing::debug!(error = %e, sub = ?token.informational.sub, "C4M claims validation failed"); + map_cat_error(e) + })?; + + self.moqt_validator.validate_moqt_claims(&token).map_err(|e| { + tracing::debug!(error = %e, sub = ?token.informational.sub, "C4M MOQT claims invalid"); + map_cat_error(e) + })?; + + let request = MoqtAuthRequest::new(action.clone(), namespace.clone(), track.clone()); + let result = self.moqt_validator.authorize(&token, &request); + + if result.authorized { + let principal = token.informational.sub.clone(); + tracing::debug!( + sub = ?principal, + action = ?action, + "C4M auth: allowed" + ); + Ok(AuthDecision::allow().with_principal(principal)) + } else { + tracing::debug!( + sub = ?token.informational.sub, + action = ?action, + namespace = ?namespace.iter().map(|n| String::from_utf8_lossy(n).to_string()).collect::>(), + "C4M auth: denied (scope mismatch)" + ); + Err(DenyReason::ScopeMismatch) + } + } +} + +#[async_trait] +impl AuthHook for C4MAuthHook { + async fn on_setup( + &self, + _ctx: &SessionContext, + tokens: &[AuthBlob], + ) -> anyhow::Result { + let Some(blob) = self.find_c4m_blob(tokens) else { + return Ok(AuthDecision::deny(DenyReason::TokenMissing)); + }; + + match self.validate_and_authorize(blob, MoqtAction::ClientSetup, vec![], vec![]) { + Ok(decision) => Ok(decision), + Err(reason) => Ok(AuthDecision::deny(reason)), + } + } + + async fn on_request( + &self, + ctx: &RequestContext<'_>, + tokens: &[AuthBlob], + ) -> anyhow::Result { + let Some(blob) = self.find_c4m_blob(tokens) else { + return Ok(AuthDecision::deny(DenyReason::TokenMissing)); + }; + + let Some((action, namespace, track)) = map_operation(&ctx.operation) else { + tracing::debug!(operation = ?ctx.operation, "C4M auth: unknown operation, denying"); + return Ok(AuthDecision::deny(DenyReason::ScopeMismatch)); + }; + + match self.validate_and_authorize(blob, action, namespace, track) { + Ok(decision) => Ok(decision), + Err(reason) => Ok(AuthDecision::deny(reason)), + } + } +} diff --git a/moq-auth-cat/src/mapping.rs b/moq-auth-cat/src/mapping.rs new file mode 100644 index 00000000..97244f82 --- /dev/null +++ b/moq-auth-cat/src/mapping.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use cat_token::MoqtAction; +use moq_auth::AuthzOperation; +use moq_transport::coding::TrackNamespace; + +/// Maps an AuthzOperation to the (MoqtAction, namespace_tuple, track) triple +/// expected by cat-token's MoqtAuthRequest. +/// +/// Returns `None` for unknown operations, which the caller must treat as deny. +pub(crate) fn map_operation(op: &AuthzOperation<'_>) -> Option<(MoqtAction, Vec>, Vec)> { + match op { + AuthzOperation::Publish { namespace, track } => { + Some((MoqtAction::Publish, ns_to_tuple(namespace), track.to_vec())) + } + AuthzOperation::PublishNamespace { namespace } => { + Some((MoqtAction::PublishNamespace, ns_to_tuple(namespace), vec![])) + } + AuthzOperation::PublishNamespaceDone { namespace } => { + Some((MoqtAction::PublishNamespace, ns_to_tuple(namespace), vec![])) + } + AuthzOperation::Subscribe { namespace, track } => { + Some((MoqtAction::Subscribe, ns_to_tuple(namespace), track.to_vec())) + } + AuthzOperation::SubscribeNamespace { prefix } => { + Some((MoqtAction::SubscribeNamespace, ns_to_tuple(prefix), vec![])) + } + AuthzOperation::Fetch { namespace, track } => { + Some((MoqtAction::Fetch, ns_to_tuple(namespace), track.to_vec())) + } + AuthzOperation::TrackStatus { namespace, track } => { + Some((MoqtAction::TrackStatus, ns_to_tuple(namespace), track.to_vec())) + } + AuthzOperation::RequestUpdate { .. } => Some((MoqtAction::RequestUpdate, vec![], vec![])), + _ => None, + } +} + +fn ns_to_tuple(ns: &TrackNamespace) -> Vec> { + ns.fields.iter().map(|f| f.value.clone()).collect() +} diff --git a/moq-auth-cat/src/tests.rs b/moq-auth-cat/src/tests.rs new file mode 100644 index 00000000..0967722c --- /dev/null +++ b/moq-auth-cat/src/tests.rs @@ -0,0 +1,357 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::net::SocketAddr; + +use bytes::Bytes; +use cat_token::{ + CatTokenBuilder, Es256Algorithm, MoqtAction, MoqtScopeBuilder, encode_token, +}; +use moq_auth::{ + AuthBlob, AuthHook, AuthzOperation, RequestContext, SessionContext, +}; +use moq_transport::coding::TrackNamespace; + +use crate::{C4MAuthHook, C4MConfig, C4M_TOKEN_TYPE}; + +fn test_session_ctx() -> SessionContext { + SessionContext { + session_id: 42, + connection_path: Some("/test/scope".to_string()), + peer: "127.0.0.1:4443".parse::().unwrap(), + } +} + +fn make_hook_and_key() -> (C4MAuthHook, Es256Algorithm) { + let signing_key = Es256Algorithm::new_with_key_pair().unwrap(); + let verifying_key = Es256Algorithm::new_verifier( + signing_key.verifying_key().clone(), + ); + + let config = C4MConfig::new(verifying_key) + .with_expected_issuers(vec!["test-issuer".to_string()]) + .with_expected_audiences(vec!["test-relay".to_string()]) + .with_clock_skew_tolerance(60); + + (C4MAuthHook::new(config), signing_key) +} + +fn make_publisher_token(signing_key: &Es256Algorithm, namespace_parts: &[&[u8]]) -> String { + let mut builder = MoqtScopeBuilder::new().publisher(); + for part in namespace_parts { + builder = builder.namespace_prefix(part); + } + let scope = builder.track_prefix(b"").build(); + + let setup_scope = MoqtScopeBuilder::new() + .action(MoqtAction::ClientSetup) + .build(); + + let token = CatTokenBuilder::new() + .issuer("test-issuer") + .single_audience("test-relay") + .subject("publisher-1") + .expires_in(3600) + .moqt_scope(scope) + .moqt_scope(setup_scope) + .build(); + + encode_token(&token, signing_key).unwrap() +} + +fn make_subscriber_token(signing_key: &Es256Algorithm, namespace_parts: &[&[u8]]) -> String { + let mut builder = MoqtScopeBuilder::new().subscriber(); + for part in namespace_parts { + builder = builder.namespace_prefix(part); + } + let scope = builder.track_prefix(b"").build(); + + let setup_scope = MoqtScopeBuilder::new() + .action(MoqtAction::ClientSetup) + .build(); + + let token = CatTokenBuilder::new() + .issuer("test-issuer") + .single_audience("test-relay") + .subject("subscriber-1") + .expires_in(3600) + .moqt_scope(scope) + .moqt_scope(setup_scope) + .build(); + + encode_token(&token, signing_key).unwrap() +} + +fn token_to_blobs(token_str: &str) -> Vec { + vec![AuthBlob { + token_type: C4M_TOKEN_TYPE, + token_value: Bytes::from(token_str.to_string()), + }] +} + +#[tokio::test] +async fn valid_publisher_token_allows_setup() { + let (hook, key) = make_hook_and_key(); + let token = make_publisher_token(&key, &[b"sports", b"football"]); + let blobs = token_to_blobs(&token); + let ctx = test_session_ctx(); + + let decision = hook.on_setup(&ctx, &blobs).await.unwrap(); + assert!(decision.is_allowed()); + assert_eq!(decision.principal.as_deref(), Some("publisher-1")); +} + +#[tokio::test] +async fn valid_publisher_token_allows_publish_namespace() { + let (hook, key) = make_hook_and_key(); + let token = make_publisher_token(&key, &[b"sports", b"football"]); + let blobs = token_to_blobs(&token); + let ctx = test_session_ctx(); + let ns = TrackNamespace::from_utf8_path("sports/football/match-42"); + + let req_ctx = RequestContext { + session: &ctx, + operation: AuthzOperation::PublishNamespace { namespace: &ns }, + request_id: None, + }; + let decision = hook.on_request(&req_ctx, &blobs).await.unwrap(); + assert!(decision.is_allowed()); +} + +#[tokio::test] +async fn valid_publisher_token_allows_publish() { + let (hook, key) = make_hook_and_key(); + let token = make_publisher_token(&key, &[b"sports", b"football"]); + let blobs = token_to_blobs(&token); + let ctx = test_session_ctx(); + let ns = TrackNamespace::from_utf8_path("sports/football/match-42"); + + let req_ctx = RequestContext { + session: &ctx, + operation: AuthzOperation::Publish { + namespace: &ns, + track: b"video-1080p", + }, + request_id: None, + }; + let decision = hook.on_request(&req_ctx, &blobs).await.unwrap(); + assert!(decision.is_allowed()); +} + +#[tokio::test] +async fn subscriber_token_denies_publish() { + let (hook, key) = make_hook_and_key(); + let token = make_subscriber_token(&key, &[b"sports", b"football"]); + let blobs = token_to_blobs(&token); + let ctx = test_session_ctx(); + let ns = TrackNamespace::from_utf8_path("sports/football/match-42"); + + let req_ctx = RequestContext { + session: &ctx, + operation: AuthzOperation::PublishNamespace { namespace: &ns }, + request_id: None, + }; + let decision = hook.on_request(&req_ctx, &blobs).await.unwrap(); + assert!(!decision.is_allowed()); +} + +#[tokio::test] +async fn valid_subscriber_token_allows_subscribe() { + let (hook, key) = make_hook_and_key(); + let token = make_subscriber_token(&key, &[b"sports", b"football"]); + let blobs = token_to_blobs(&token); + let ctx = test_session_ctx(); + let ns = TrackNamespace::from_utf8_path("sports/football/match-42"); + + let req_ctx = RequestContext { + session: &ctx, + operation: AuthzOperation::Subscribe { + namespace: &ns, + track: b"video-1080p", + }, + request_id: None, + }; + let decision = hook.on_request(&req_ctx, &blobs).await.unwrap(); + assert!(decision.is_allowed()); +} + +#[tokio::test] +async fn valid_subscriber_token_allows_fetch() { + let (hook, key) = make_hook_and_key(); + let token = make_subscriber_token(&key, &[b"sports", b"football"]); + let blobs = token_to_blobs(&token); + let ctx = test_session_ctx(); + let ns = TrackNamespace::from_utf8_path("sports/football/match-42"); + + let req_ctx = RequestContext { + session: &ctx, + operation: AuthzOperation::Fetch { + namespace: &ns, + track: b"video-1080p", + }, + request_id: None, + }; + let decision = hook.on_request(&req_ctx, &blobs).await.unwrap(); + assert!(decision.is_allowed()); +} + +#[tokio::test] +async fn wrong_namespace_denies() { + let (hook, key) = make_hook_and_key(); + let token = make_publisher_token(&key, &[b"sports", b"football"]); + let blobs = token_to_blobs(&token); + let ctx = test_session_ctx(); + let ns = TrackNamespace::from_utf8_path("music/concert/live"); + + let req_ctx = RequestContext { + session: &ctx, + operation: AuthzOperation::PublishNamespace { namespace: &ns }, + request_id: None, + }; + let decision = hook.on_request(&req_ctx, &blobs).await.unwrap(); + assert!(!decision.is_allowed()); +} + +#[tokio::test] +async fn missing_token_denies() { + let (hook, _) = make_hook_and_key(); + let ctx = test_session_ctx(); + + let decision = hook.on_setup(&ctx, &[]).await.unwrap(); + assert!(!decision.is_allowed()); +} + +#[tokio::test] +async fn malformed_token_denies() { + let (hook, _) = make_hook_and_key(); + let ctx = test_session_ctx(); + let blobs = vec![AuthBlob { + token_type: C4M_TOKEN_TYPE, + token_value: Bytes::from_static(b"not.a.valid.token"), + }]; + + let decision = hook.on_setup(&ctx, &blobs).await.unwrap(); + assert!(!decision.is_allowed()); +} + +#[tokio::test] +async fn wrong_issuer_denies() { + let signing_key = Es256Algorithm::new_with_key_pair().unwrap(); + let verifying_key = Es256Algorithm::new_verifier( + signing_key.verifying_key().clone(), + ); + + let config = C4MConfig::new(verifying_key) + .with_expected_issuers(vec!["expected-issuer".to_string()]) + .with_expected_audiences(vec!["test-relay".to_string()]); + + let hook = C4MAuthHook::new(config); + + let scope = MoqtScopeBuilder::new() + .action(MoqtAction::ClientSetup) + .build(); + + let token = CatTokenBuilder::new() + .issuer("wrong-issuer") + .single_audience("test-relay") + .expires_in(3600) + .moqt_scope(scope) + .build(); + + let token_str = encode_token(&token, &signing_key).unwrap(); + let blobs = token_to_blobs(&token_str); + let ctx = test_session_ctx(); + + let decision = hook.on_setup(&ctx, &blobs).await.unwrap(); + assert!(!decision.is_allowed()); +} + +#[tokio::test] +async fn wrong_audience_denies() { + let signing_key = Es256Algorithm::new_with_key_pair().unwrap(); + let verifying_key = Es256Algorithm::new_verifier( + signing_key.verifying_key().clone(), + ); + + let config = C4MConfig::new(verifying_key) + .with_expected_issuers(vec!["test-issuer".to_string()]) + .with_expected_audiences(vec!["expected-relay".to_string()]); + + let hook = C4MAuthHook::new(config); + + let scope = MoqtScopeBuilder::new() + .action(MoqtAction::ClientSetup) + .build(); + + let token = CatTokenBuilder::new() + .issuer("test-issuer") + .single_audience("wrong-relay") + .expires_in(3600) + .moqt_scope(scope) + .build(); + + let token_str = encode_token(&token, &signing_key).unwrap(); + let blobs = token_to_blobs(&token_str); + let ctx = test_session_ctx(); + + let decision = hook.on_setup(&ctx, &blobs).await.unwrap(); + assert!(!decision.is_allowed()); +} + +#[tokio::test] +async fn token_without_client_setup_scope_denies_setup() { + let (hook, key) = make_hook_and_key(); + + let scope = MoqtScopeBuilder::new() + .publisher() + .namespace_prefix(b"sports") + .track_prefix(b"/") + .build(); + + let token = CatTokenBuilder::new() + .issuer("test-issuer") + .single_audience("test-relay") + .expires_in(3600) + .moqt_scope(scope) + .build(); + + let token_str = encode_token(&token, &key).unwrap(); + let blobs = token_to_blobs(&token_str); + let ctx = test_session_ctx(); + + let decision = hook.on_setup(&ctx, &blobs).await.unwrap(); + assert!(!decision.is_allowed()); +} + +#[tokio::test] +async fn invalid_signature_denies() { + let signing_key = Es256Algorithm::new_with_key_pair().unwrap(); + let different_key = Es256Algorithm::new_with_key_pair().unwrap(); + let verifying_key = Es256Algorithm::new_verifier( + different_key.verifying_key().clone(), + ); + + let config = C4MConfig::new(verifying_key) + .with_expected_issuers(vec!["test-issuer".to_string()]) + .with_expected_audiences(vec!["test-relay".to_string()]); + + let hook = C4MAuthHook::new(config); + + let scope = MoqtScopeBuilder::new() + .action(MoqtAction::ClientSetup) + .build(); + + let token = CatTokenBuilder::new() + .issuer("test-issuer") + .single_audience("test-relay") + .expires_in(3600) + .moqt_scope(scope) + .build(); + + let token_str = encode_token(&token, &signing_key).unwrap(); + let blobs = token_to_blobs(&token_str); + let ctx = test_session_ctx(); + + let decision = hook.on_setup(&ctx, &blobs).await.unwrap(); + assert!(!decision.is_allowed()); +} diff --git a/moq-auth/Cargo.toml b/moq-auth/Cargo.toml new file mode 100644 index 00000000..1a2471d7 --- /dev/null +++ b/moq-auth/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +[package] +name = "moq-auth" +description = "Pluggable authorization hook trait for MoQ relay" +authors = ["moq-rs contributors"] +repository = "https://github.com/cloudflare/moq-rs" +license = "MIT OR Apache-2.0" +version = "0.1.0" +edition = "2021" + +keywords = ["quic", "moq", "auth", "authorization"] +categories = ["authentication", "network-programming"] + +[dependencies] +async-trait = "0.1" +anyhow = "1" +bytes = "1" +thiserror = "2" +tracing = { workspace = true } +moq-transport = { path = "../moq-transport", version = "0.14" } + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros"] } diff --git a/moq-auth/src/hook.rs b/moq-auth/src/hook.rs new file mode 100644 index 00000000..376f0214 --- /dev/null +++ b/moq-auth/src/hook.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use async_trait::async_trait; + +use crate::{AuthBlob, AuthDecision, RequestContext, SessionContext}; + +/// Pluggable authorization hook for the MoQ relay. +/// +/// Called at session establishment and before each authorization-relevant +/// request. Implementations validate tokens and return allow/deny verdicts. +/// +/// Default implementations return `Ok(AuthDecision::allow())` — a relay +/// without an explicit hook behaves identically to one with no auth. +#[async_trait] +pub trait AuthHook: Send + Sync { + /// Called once per session at SETUP time, after AUTHORIZATION TOKEN + /// parameters have been decoded and any aliases resolved. + /// + /// Returning a deny verdict causes the relay to terminate the session. + async fn on_setup( + &self, + _ctx: &SessionContext, + _tokens: &[AuthBlob], + ) -> anyhow::Result { + Ok(AuthDecision::allow()) + } + + /// Called once per request before the relay performs any + /// authorization-relevant action. + async fn on_request( + &self, + _ctx: &RequestContext<'_>, + _tokens: &[AuthBlob], + ) -> anyhow::Result { + Ok(AuthDecision::allow()) + } + + /// Graceful shutdown hook. Implementations may use this to flush state. + async fn shutdown(&self) -> anyhow::Result<()> { + Ok(()) + } +} diff --git a/moq-auth/src/hooks/allow_all.rs b/moq-auth/src/hooks/allow_all.rs new file mode 100644 index 00000000..d1fdf618 --- /dev/null +++ b/moq-auth/src/hooks/allow_all.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use async_trait::async_trait; + +use crate::AuthHook; + +/// Default hook that allows all operations unconditionally. +/// +/// A relay built without an explicit `AuthHook` gets this behavior. +pub struct AllowAllAuthHook; + +#[async_trait] +impl AuthHook for AllowAllAuthHook {} diff --git a/moq-auth/src/hooks/key_value.rs b/moq-auth/src/hooks/key_value.rs new file mode 100644 index 00000000..9af72b95 --- /dev/null +++ b/moq-auth/src/hooks/key_value.rs @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use async_trait::async_trait; +use bytes::Bytes; + +use crate::{AuthBlob, AuthDecision, AuthHook, DenyReason, RequestContext, SessionContext}; + +/// Accepts any token whose Token Type is 0 (negotiated out-of-band) and +/// whose Token Value matches a configured shared secret. +/// +/// Uses constant-time comparison to prevent timing attacks. +/// Useful for early end-to-end integration testing without standing up +/// issuer infrastructure. +pub struct KeyValueAuthHook { + expected_secret: Bytes, +} + +impl KeyValueAuthHook { + pub fn new(secret: impl Into) -> Self { + Self { + expected_secret: secret.into(), + } + } + + fn validate(&self, tokens: &[AuthBlob]) -> AuthDecision { + for token in tokens { + if token.token_type == 0 && constant_time_eq(&token.token_value, &self.expected_secret) + { + return AuthDecision::allow(); + } + } + AuthDecision::deny(DenyReason::TokenInvalid) + } +} + +#[async_trait] +impl AuthHook for KeyValueAuthHook { + async fn on_setup( + &self, + _ctx: &SessionContext, + tokens: &[AuthBlob], + ) -> anyhow::Result { + Ok(self.validate(tokens)) + } + + async fn on_request( + &self, + _ctx: &RequestContext<'_>, + tokens: &[AuthBlob], + ) -> anyhow::Result { + Ok(self.validate(tokens)) + } +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + // XOR lengths and accumulate into diff to avoid leaking secret length + // via early return timing. + let len_diff = a.len() ^ b.len(); + let mut diff = len_diff as u8; + for i in 0..a.len() { + let bval = if i < b.len() { b[i] } else { 0 }; + diff |= a[i] ^ bval; + } + for i in 0..b.len() { + let aval = if i < a.len() { a[i] } else { 0 }; + diff |= aval ^ b[i]; + } + diff == 0 && len_diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_time_eq_works() { + assert!(constant_time_eq(b"hello", b"hello")); + assert!(!constant_time_eq(b"hello", b"world")); + assert!(!constant_time_eq(b"hello", b"hell")); + assert!(constant_time_eq(b"", b"")); + } +} diff --git a/moq-auth/src/hooks/logging.rs b/moq-auth/src/hooks/logging.rs new file mode 100644 index 00000000..16567cc7 --- /dev/null +++ b/moq-auth/src/hooks/logging.rs @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use async_trait::async_trait; + +use crate::{AuthBlob, AuthDecision, AuthHook, RequestContext, SessionContext}; + +/// Composable observability wrapper that logs every hook invocation +/// while delegating the actual decision to the inner hook. +pub struct LoggingAuthHook { + inner: H, +} + +impl LoggingAuthHook { + pub fn new(inner: H) -> Self { + Self { inner } + } +} + +#[async_trait] +impl AuthHook for LoggingAuthHook { + async fn on_setup( + &self, + ctx: &SessionContext, + tokens: &[AuthBlob], + ) -> anyhow::Result { + let result: anyhow::Result = self.inner.on_setup(ctx, tokens).await; + match &result { + Ok(decision) => { + tracing::debug!( + session_id = ctx.session_id, + token_count = tokens.len(), + allowed = decision.is_allowed(), + principal = decision.principal.as_deref(), + "auth on_setup" + ); + } + Err(e) => { + tracing::error!( + session_id = ctx.session_id, + error = %e, + "auth on_setup error" + ); + } + } + result + } + + async fn on_request( + &self, + ctx: &RequestContext<'_>, + tokens: &[AuthBlob], + ) -> anyhow::Result { + let result: anyhow::Result = self.inner.on_request(ctx, tokens).await; + match &result { + Ok(decision) => { + tracing::debug!( + session_id = ctx.session.session_id, + operation = ?ctx.operation, + allowed = decision.is_allowed(), + "auth on_request" + ); + } + Err(e) => { + tracing::error!( + session_id = ctx.session.session_id, + operation = ?ctx.operation, + error = %e, + "auth on_request error" + ); + } + } + result + } + + async fn shutdown(&self) -> anyhow::Result<()> { + self.inner.shutdown().await + } +} diff --git a/moq-auth/src/hooks/mod.rs b/moq-auth/src/hooks/mod.rs new file mode 100644 index 00000000..cbee3b0d --- /dev/null +++ b/moq-auth/src/hooks/mod.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +mod allow_all; +mod key_value; +mod logging; + +pub use allow_all::AllowAllAuthHook; +pub use key_value::KeyValueAuthHook; +pub use logging::LoggingAuthHook; diff --git a/moq-auth/src/lib.rs b/moq-auth/src/lib.rs new file mode 100644 index 00000000..dc4dd45e --- /dev/null +++ b/moq-auth/src/lib.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Pluggable authorization hook for the MoQ relay. +//! +//! This crate defines the [`AuthHook`] trait and supporting types for +//! intra-scope authorization in `moq-relay-ietf`. Concrete implementations +//! (C4M, PrivacyPass) live in separate crates. + +mod hook; +pub mod hooks; +mod types; + +#[cfg(test)] +mod tests; + +pub use hook::AuthHook; +pub use hooks::*; +pub use types::*; diff --git a/moq-auth/src/tests.rs b/moq-auth/src/tests.rs new file mode 100644 index 00000000..ad3bce0f --- /dev/null +++ b/moq-auth/src/tests.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::net::SocketAddr; +use std::sync::Arc; + +use bytes::Bytes; + +use crate::*; + +fn test_session_ctx() -> SessionContext { + SessionContext { + session_id: 1, + connection_path: Some("/test/scope".to_string()), + peer: "127.0.0.1:4443".parse::().unwrap(), + } +} + +fn test_tokens(secret: &[u8]) -> Vec { + vec![AuthBlob { + token_type: 0, + token_value: Bytes::copy_from_slice(secret), + }] +} + +#[tokio::test] +async fn allow_all_hook_allows_setup() { + let hook = AllowAllAuthHook; + let ctx = test_session_ctx(); + let decision = hook.on_setup(&ctx, &[]).await.unwrap(); + assert!(decision.is_allowed()); +} + +#[tokio::test] +async fn allow_all_hook_allows_request() { + let hook = AllowAllAuthHook; + let ctx = test_session_ctx(); + let ns = moq_transport::coding::TrackNamespace::from_utf8_path("test/ns"); + let req_ctx = RequestContext { + session: &ctx, + operation: AuthzOperation::Subscribe { + namespace: &ns, + track: b"video", + }, + request_id: Some(1), + }; + let decision = hook.on_request(&req_ctx, &[]).await.unwrap(); + assert!(decision.is_allowed()); +} + + +#[tokio::test] +async fn key_value_hook_accepts_matching_secret() { + let hook = KeyValueAuthHook::new(b"my-secret".as_slice()); + let ctx = test_session_ctx(); + let tokens = test_tokens(b"my-secret"); + let decision = hook.on_setup(&ctx, &tokens).await.unwrap(); + assert!(decision.is_allowed()); +} + +#[tokio::test] +async fn key_value_hook_rejects_wrong_secret() { + let hook = KeyValueAuthHook::new(b"my-secret".as_slice()); + let ctx = test_session_ctx(); + let tokens = test_tokens(b"wrong-secret"); + let decision = hook.on_setup(&ctx, &tokens).await.unwrap(); + assert!(!decision.is_allowed()); + assert!(matches!(decision.verdict, Verdict::Deny(DenyReason::TokenInvalid))); +} + +#[tokio::test] +async fn key_value_hook_rejects_empty_tokens() { + let hook = KeyValueAuthHook::new(b"my-secret".as_slice()); + let ctx = test_session_ctx(); + let decision = hook.on_setup(&ctx, &[]).await.unwrap(); + assert!(!decision.is_allowed()); +} + +#[tokio::test] +async fn key_value_hook_rejects_wrong_token_type() { + let hook = KeyValueAuthHook::new(b"my-secret".as_slice()); + let ctx = test_session_ctx(); + let tokens = vec![AuthBlob { + token_type: 1, // wrong type, expects 0 + token_value: Bytes::from_static(b"my-secret"), + }]; + let decision = hook.on_setup(&ctx, &tokens).await.unwrap(); + assert!(!decision.is_allowed()); +} + + +#[tokio::test] +async fn auth_decision_builder() { + let d = AuthDecision::allow().with_principal(Some("user@example.com".to_string())); + assert!(d.is_allowed()); + assert_eq!(d.principal.as_deref(), Some("user@example.com")); + + let d = AuthDecision::deny(DenyReason::TokenExpired); + assert!(!d.is_allowed()); + assert!(d.principal.is_none()); +} + +#[tokio::test] +async fn logging_hook_delegates_to_inner() { + let inner = KeyValueAuthHook::new(b"secret".as_slice()); + let hook = LoggingAuthHook::new(inner); + let ctx = test_session_ctx(); + + let tokens = test_tokens(b"secret"); + let decision = hook.on_setup(&ctx, &tokens).await.unwrap(); + assert!(decision.is_allowed()); + + let tokens = test_tokens(b"wrong"); + let decision = hook.on_setup(&ctx, &tokens).await.unwrap(); + assert!(!decision.is_allowed()); +} diff --git a/moq-auth/src/types.rs b/moq-auth/src/types.rs new file mode 100644 index 00000000..ef1816e8 --- /dev/null +++ b/moq-auth/src/types.rs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::net::SocketAddr; + +use bytes::Bytes; +use moq_transport::coding::TrackNamespace; + +/// Information about the QUIC/MoQT session. Stable for the session's lifetime. +#[derive(Debug, Clone)] +pub struct SessionContext { + /// Relay-assigned session handle. Stable across the session lifetime. + pub session_id: u64, + + /// The connection path from the WebTransport URL or the PATH setup parameter. + pub connection_path: Option, + + /// Peer socket address. + pub peer: SocketAddr, +} + +/// Information about a specific request. Built fresh per call. +#[derive(Debug)] +pub struct RequestContext<'a> { + pub session: &'a SessionContext, + pub operation: AuthzOperation<'a>, + pub request_id: Option, +} + +/// The operation being authorized. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum AuthzOperation<'a> { + PublishNamespace { namespace: &'a TrackNamespace }, + PublishNamespaceDone { namespace: &'a TrackNamespace }, + Publish { namespace: &'a TrackNamespace, track: &'a [u8] }, + Subscribe { namespace: &'a TrackNamespace, track: &'a [u8] }, + SubscribeNamespace { prefix: &'a TrackNamespace }, + Fetch { namespace: &'a TrackNamespace, track: &'a [u8] }, + TrackStatus { namespace: &'a TrackNamespace, track: &'a [u8] }, + RequestUpdate { request_id: u64 }, +} + +/// A fully-resolved AUTHORIZATION TOKEN parameter. +/// +/// Alias bookkeeping is handled by the relay's transport layer; the hook +/// always sees Type+Value pairs, never raw alias directives. +#[derive(Debug, Clone)] +pub struct AuthBlob { + /// Token type from the IANA "MOQT Auth Token Type" registry. + pub token_type: u64, + /// Raw token value bytes. + pub token_value: Bytes, +} + +/// The hook's decision. +#[derive(Debug, Clone)] +pub struct AuthDecision { + pub verdict: Verdict, + /// Optional opaque principal identifier for logging and metrics. + pub principal: Option, +} + +impl AuthDecision { + pub fn allow() -> Self { + Self { + verdict: Verdict::Allow, + principal: None, + } + } + + pub fn deny(reason: DenyReason) -> Self { + Self { + verdict: Verdict::Deny(reason), + principal: None, + } + } + + pub fn with_principal(mut self, principal: impl Into>) -> Self { + self.principal = principal.into(); + self + } + + pub fn is_allowed(&self) -> bool { + matches!(self.verdict, Verdict::Allow) + } +} + +#[derive(Debug, Clone)] +pub enum Verdict { + Allow, + Deny(DenyReason), +} + +/// Abstract deny reasons. The relay maps these to wire codes appropriate +/// for the auth scheme in use. +#[derive(Debug, Clone, thiserror::Error)] +pub enum DenyReason { + #[error("token missing")] + TokenMissing, + #[error("token invalid")] + TokenInvalid, + #[error("token expired")] + TokenExpired, + #[error("token replayed")] + TokenReplayed, + #[error("token malformed")] + TokenMalformed, + #[error("scope mismatch")] + ScopeMismatch, + #[error("issuer unknown")] + IssuerUnknown, + #[error("{message}")] + Other { message: String }, +} diff --git a/moq-clock-ietf/src/cli.rs b/moq-clock-ietf/src/cli.rs index fd08590b..250bd06b 100644 --- a/moq-clock-ietf/src/cli.rs +++ b/moq-clock-ietf/src/cli.rs @@ -39,4 +39,12 @@ pub struct Cli { /// Use datagrams instead of streams for the clock publisher. #[arg(long)] pub datagrams: bool, + + /// Auth token string to include in CLIENT_SETUP AUTHORIZATION TOKEN parameter. + #[arg(long)] + pub auth_token: Option, + + /// Token type identifier for the auth token (e.g. C4M=6501485, PrivacyPass=0, shared-secret). + #[arg(long, default_value = "6501485")] + pub auth_token_type: u64, } diff --git a/moq-clock-ietf/src/main.rs b/moq-clock-ietf/src/main.rs index ac8a1ef6..552209b2 100644 --- a/moq-clock-ietf/src/main.rs +++ b/moq-clock-ietf/src/main.rs @@ -14,7 +14,7 @@ use cli::Cli; use moq_transport::{ coding::TrackNamespace, serve, - session::{Publisher, Subscriber}, + session::{Publisher, Subscriber, encode_auth_token}, }; /// The main entry point for the MoQ Clock IETF example. @@ -45,10 +45,15 @@ async fn main() -> anyhow::Result<()> { connection_id ); + let auth_raw = match &config.auth_token { + Some(token) => encode_auth_token(config.auth_token_type, token.as_bytes()), + None => vec![], + }; + // Depending on whether we are publishing or subscribing, create the appropriate session if config.publish { // Create the publisher session - let (session, mut publisher) = Publisher::connect(session, transport) + let (session, mut publisher) = Publisher::connect_with_auth(session, transport, auth_raw) .await .context("failed to create MoQ Transport session")?; @@ -87,7 +92,7 @@ async fn main() -> anyhow::Result<()> { } } else { // Create the subscriber session - let (session, mut subscriber) = Subscriber::connect(session, transport) + let (session, mut subscriber) = Subscriber::connect_with_auth(session, transport, auth_raw) .await .context("failed to create MoQ Transport session")?; diff --git a/moq-pub/src/main.rs b/moq-pub/src/main.rs index 17d6403b..ef479b45 100644 --- a/moq-pub/src/main.rs +++ b/moq-pub/src/main.rs @@ -12,7 +12,7 @@ use tokio::io::AsyncReadExt; use moq_native_ietf::quic; use moq_pub::Media; -use moq_transport::{coding::TrackNamespace, serve, session::Publisher}; +use moq_transport::{coding::TrackNamespace, serve, session::{Publisher, encode_auth_token}}; #[derive(Parser, Clone)] pub struct Cli { @@ -41,6 +41,14 @@ pub struct Cli { /// The TLS configuration. #[command(flatten)] pub tls: moq_native_ietf::tls::Args, + + /// Auth token string to include in CLIENT_SETUP AUTHORIZATION TOKEN parameter. + #[arg(long)] + pub auth_token: Option, + + /// Token type identifier for the auth token (e.g. C4M=6501485, PrivacyPass=0, shared-secret). + #[arg(long, default_value = "6501485")] + pub auth_token_type: u64, } #[tokio::main] @@ -76,7 +84,12 @@ async fn main() -> anyhow::Result<()> { connection_id ); - let (session, mut publisher) = Publisher::connect(session, transport) + let auth_raw = match &cli.auth_token { + Some(token) => encode_auth_token(cli.auth_token_type, token.as_bytes()), + None => vec![], + }; + + let (session, mut publisher) = Publisher::connect_with_auth(session, transport, auth_raw) .await .context("failed to create MoQ Transport publisher")?; diff --git a/moq-relay-ietf/Cargo.toml b/moq-relay-ietf/Cargo.toml index 2821e634..1782f1f8 100644 --- a/moq-relay-ietf/Cargo.toml +++ b/moq-relay-ietf/Cargo.toml @@ -25,8 +25,11 @@ path = "src/bin/moq-relay-ietf/main.rs" [dependencies] moq-transport = { path = "../moq-transport", version = "0.14" } moq-native-ietf = { path = "../moq-native-ietf", version = "0.8" } +moq-auth = { path = "../moq-auth", version = "0.1" } +moq-auth-cat = { path = "../moq-auth-cat", version = "0.1", optional = true } moq-api = { path = "../moq-api", version = "0.2" } web-transport = { workspace = true } +bytes = "1" # QUIC url = "2" @@ -55,6 +58,9 @@ fs2 = "0.4" # Error handling anyhow = { version = "1", features = ["backtrace"] } +# Random +rand = "0.8" + # CLI clap = { version = "4", features = ["derive"] } @@ -73,3 +79,4 @@ metrics-exporter-prometheus = { version = "0.16", optional = true } [features] default = [] metrics-prometheus = ["dep:metrics-exporter-prometheus"] +auth-cat = ["dep:moq-auth-cat"] diff --git a/moq-relay-ietf/src/bin/moq-relay-ietf/main.rs b/moq-relay-ietf/src/bin/moq-relay-ietf/main.rs index 9ccfe462..d19c4c7b 100644 --- a/moq-relay-ietf/src/bin/moq-relay-ietf/main.rs +++ b/moq-relay-ietf/src/bin/moq-relay-ietf/main.rs @@ -14,6 +14,12 @@ use api_coordinator::{ApiCoordinator, ApiCoordinatorConfig}; use file_coordinator::FileCoordinator; use moq_relay_ietf::{Coordinator, Relay, RelayConfig, Web, WebConfig}; +#[cfg(feature = "auth-cat")] +use { + anyhow::Context, + moq_auth_cat::{C4MAuthHook, C4MConfig, Es256Algorithm}, +}; + #[derive(Parser, Clone)] pub struct Cli { /// Listen on this address @@ -87,6 +93,28 @@ pub struct Cli { /// When set, serves metrics at http:///metrics #[arg(long)] pub metrics_addr: Option, + + /// Shared secret for token-type-0 auth (simple pre-shared key). + /// Clients must pass --auth-token-type 0 --auth-token . + #[arg(long, env = "MOQ_AUTH_SHARED_SECRET")] + pub auth_shared_secret: Option, + + /// Path to PEM-encoded ES256 public key for C4M token verification. + /// Requires the `auth-cat` feature. + #[arg(long)] + pub auth_cat_public_key: Option, + + /// Expected token issuer for C4M auth (repeatable). + #[arg(long)] + pub auth_cat_issuer: Vec, + + /// Expected token audience for C4M auth (repeatable). + #[arg(long)] + pub auth_cat_audience: Vec, + + /// Clock skew tolerance in seconds for C4M token validation. + #[arg(long, default_value = "60")] + pub auth_cat_clock_skew: i64, } #[tokio::main] @@ -180,6 +208,9 @@ async fn main() -> anyhow::Result<()> { Arc::new(FileCoordinator::new(&cli.coordinator_file, relay_url)) }; + // Build the auth hook if C4M is configured + let auth_hook = build_auth_hook(&cli)?; + // Create a QUIC server for media. let relay = Relay::new(RelayConfig { tls: tls.clone(), @@ -190,6 +221,7 @@ async fn main() -> anyhow::Result<()> { node: cli.node, announce: cli.announce, coordinator, + auth_hook, })?; if cli.dev { @@ -209,3 +241,64 @@ async fn main() -> anyhow::Result<()> { relay.run().await } + +#[cfg(feature = "auth-cat")] +fn build_auth_hook(cli: &Cli) -> anyhow::Result>> { + if cli.auth_shared_secret.is_some() && cli.auth_cat_public_key.is_some() { + anyhow::bail!( + "--auth-shared-secret and --auth-cat-public-key are mutually exclusive" + ); + } + + if let Some(ref secret) = cli.auth_shared_secret { + tracing::info!("shared-secret auth enabled (token type 0)"); + return Ok(Some(Arc::new(moq_auth::KeyValueAuthHook::new( + secret.as_bytes().to_vec(), + )))); + } + + let Some(key_path) = &cli.auth_cat_public_key else { + return Ok(None); + }; + + let pem_data = std::fs::read_to_string(key_path) + .with_context(|| format!("reading C4M public key from {}", key_path.display()))?; + + let algorithm = Es256Algorithm::from_public_key_pem(&pem_data) + .map_err(|e| anyhow::anyhow!("invalid ES256 public key: {e}"))?; + + let mut config = C4MConfig::new(algorithm) + .with_clock_skew_tolerance(cli.auth_cat_clock_skew); + + if !cli.auth_cat_issuer.is_empty() { + config = config.with_expected_issuers(cli.auth_cat_issuer.clone()); + } + if !cli.auth_cat_audience.is_empty() { + config = config.with_expected_audiences(cli.auth_cat_audience.clone()); + } + + tracing::info!( + "C4M auth enabled with key {}", + key_path.display() + ); + + Ok(Some(Arc::new(C4MAuthHook::new(config)))) +} + +#[cfg(not(feature = "auth-cat"))] +fn build_auth_hook(cli: &Cli) -> anyhow::Result>> { + if let Some(ref secret) = cli.auth_shared_secret { + tracing::info!("shared-secret auth enabled (token type 0)"); + return Ok(Some(Arc::new(moq_auth::KeyValueAuthHook::new( + secret.as_bytes().to_vec(), + )))); + } + + if cli.auth_cat_public_key.is_some() { + anyhow::bail!( + "--auth-cat-public-key requires the `auth-cat` feature. \ + Rebuild with --features auth-cat" + ); + } + Ok(None) +} diff --git a/moq-relay-ietf/src/consumer.rs b/moq-relay-ietf/src/consumer.rs index 7b25ae78..793f2fd3 100644 --- a/moq-relay-ietf/src/consumer.rs +++ b/moq-relay-ietf/src/consumer.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use anyhow::Context; use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; +use moq_auth::{AuthBlob, AuthHook, AuthzOperation, RequestContext, SessionContext}; use moq_transport::{ serve::Tracks, session::{Announced, SessionError, Subscriber}, @@ -23,6 +24,9 @@ pub struct Consumer { /// Produced by `Coordinator::resolve_scope()` from the connection path. /// Passed to coordinator register/lookup calls to isolate namespaces. scope: Option, + auth_hook: Arc, + session_ctx: SessionContext, + auth_tokens: Vec, } impl Consumer { @@ -32,6 +36,9 @@ impl Consumer { coordinator: Arc, forward: Option, scope: Option, + auth_hook: Arc, + session_ctx: SessionContext, + auth_tokens: Vec, ) -> Self { Self { subscriber, @@ -39,6 +46,9 @@ impl Consumer { coordinator, forward, scope, + auth_hook, + session_ctx, + auth_tokens, } } @@ -77,6 +87,26 @@ impl Consumer { // Track active publishers - decrements when this function returns let _publisher_guard = GaugeGuard::new("moq_relay_active_publishers"); + // Auth check: on_request for PublishNamespace + let req_ctx = RequestContext { + session: &self.session_ctx, + operation: AuthzOperation::PublishNamespace { + namespace: &announce.namespace, + }, + request_id: None, + }; + match self.auth_hook.on_request(&req_ctx, &self.auth_tokens).await { + Ok(decision) if !decision.is_allowed() => { + metrics::counter!("moq_relay_announce_errors_total", "phase" => "auth").increment(1); + return Err(anyhow::anyhow!("unauthorized publish_namespace")); + } + Err(e) => { + metrics::counter!("moq_relay_announce_errors_total", "phase" => "auth").increment(1); + return Err(anyhow::anyhow!("auth error: {e}")); + } + _ => {} + } + let mut tasks = FuturesUnordered::new(); // Produce the tracks for this announce and return the reader diff --git a/moq-relay-ietf/src/lib.rs b/moq-relay-ietf/src/lib.rs index c469a730..400f157e 100644 --- a/moq-relay-ietf/src/lib.rs +++ b/moq-relay-ietf/src/lib.rs @@ -46,6 +46,7 @@ pub use api::*; pub use consumer::*; pub use coordinator::*; pub use local::*; +pub use moq_auth; pub use producer::*; pub use relay::*; pub use remote::RemoteManager; diff --git a/moq-relay-ietf/src/producer.rs b/moq-relay-ietf/src/producer.rs index 9387b6a1..8616d74e 100644 --- a/moq-relay-ietf/src/producer.rs +++ b/moq-relay-ietf/src/producer.rs @@ -1,7 +1,10 @@ // SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc., Luke Curley, Mike English and contributors // SPDX-License-Identifier: MIT OR Apache-2.0 +use std::sync::Arc; + use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; +use moq_auth::{AuthBlob, AuthHook, AuthzOperation, RequestContext, SessionContext}; use moq_transport::{ serve::{ServeError, TracksReader}, session::{Publisher, SessionError, Subscribed, TrackStatusRequested}, @@ -22,6 +25,9 @@ pub struct Producer { /// Produced by `Coordinator::resolve_scope()` from the connection path. /// Passed to locals/remotes to isolate namespace lookups. scope: Option, + auth_hook: Arc, + session_ctx: SessionContext, + auth_tokens: Vec, } impl Producer { @@ -30,12 +36,18 @@ impl Producer { locals: Locals, remotes: RemoteManager, scope: Option, + auth_hook: Arc, + session_ctx: SessionContext, + auth_tokens: Vec, ) -> Self { Self { publisher, locals, remotes, scope, + auth_hook, + session_ctx, + auth_tokens, } } @@ -107,6 +119,29 @@ impl Producer { let namespace = subscribed.track_namespace.clone(); let track_name = subscribed.track_name.clone(); + // Auth check: on_request for Subscribe + let req_ctx = RequestContext { + session: &self.session_ctx, + operation: AuthzOperation::Subscribe { + namespace: &namespace, + track: track_name.as_bytes(), + }, + request_id: None, + }; + match self.auth_hook.on_request(&req_ctx, &self.auth_tokens).await { + Ok(decision) if !decision.is_allowed() => { + let err = ServeError::not_found_ctx("unauthorized"); + subscribed.close(err.clone())?; + return Err(err.into()); + } + Err(e) => { + let err = ServeError::internal_ctx(format!("auth error: {e}")); + subscribed.close(err.clone())?; + return Err(err.into()); + } + _ => {} + } + // Check local tracks first, and serve from local if possible if let Some(mut local) = self.locals.retrieve(self.scope.as_deref(), &namespace) { // Pass the full requested namespace, not the announced prefix @@ -174,6 +209,27 @@ impl Producer { self, mut track_status_requested: TrackStatusRequested, ) -> Result<(), anyhow::Error> { + // Auth check: on_request for TrackStatus + let req_ctx = RequestContext { + session: &self.session_ctx, + operation: AuthzOperation::TrackStatus { + namespace: &track_status_requested.request_msg.track_namespace, + track: track_status_requested.request_msg.track_name.as_bytes(), + }, + request_id: None, + }; + match self.auth_hook.on_request(&req_ctx, &self.auth_tokens).await { + Ok(decision) if !decision.is_allowed() => { + track_status_requested.respond_error(4, "unauthorized")?; + return Err(anyhow::anyhow!("unauthorized track_status")); + } + Err(e) => { + track_status_requested.respond_error(4, "authorization error")?; + return Err(anyhow::anyhow!("auth hook error on track_status: {e}")); + } + _ => {} + } + // Check local tracks first, and serve from local if possible if let Some(mut local_tracks) = self.locals.retrieve( self.scope.as_deref(), diff --git a/moq-relay-ietf/src/relay.rs b/moq-relay-ietf/src/relay.rs index 06a43c9e..a8066204 100644 --- a/moq-relay-ietf/src/relay.rs +++ b/moq-relay-ietf/src/relay.rs @@ -4,11 +4,14 @@ use std::{future::Future, net, path::PathBuf, pin::Pin, sync::Arc}; use anyhow::Context; - +use bytes::Buf; use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; use moq_native_ietf::quic::{self, Endpoint}; use url::Url; +use moq_auth::{AllowAllAuthHook, AuthBlob, AuthHook, SessionContext}; +use moq_transport::coding::{Decode, VarInt}; + use crate::{metrics::GaugeGuard, Consumer, Coordinator, Locals, Producer, RemoteManager, Session}; // A type alias for boxed future @@ -53,6 +56,9 @@ pub struct RelayConfig { /// The coordinator for namespace/track registration and discovery. pub coordinator: Arc, + + /// Authorization hook for validating tokens. Defaults to AllowAllAuthHook. + pub auth_hook: Option>, } /// MoQ Relay server. @@ -63,6 +69,7 @@ pub struct Relay { locals: Locals, remotes: RemoteManager, coordinator: Arc, + auth_hook: Arc, } impl Relay { @@ -108,6 +115,10 @@ impl Relay { // Create remote manager - uses coordinator for namespace lookups let remotes = RemoteManager::new(config.coordinator.clone(), remote_clients); + let auth_hook: Arc = config + .auth_hook + .unwrap_or_else(|| Arc::new(AllowAllAuthHook)); + Ok(Self { quic_endpoints: endpoints, announce_url: config.announce, @@ -115,6 +126,7 @@ impl Relay { locals, remotes, coordinator: config.coordinator, + auth_hook, }) } @@ -127,6 +139,7 @@ impl Relay { locals, remotes, coordinator, + auth_hook, } = self; let run_result = async { @@ -168,6 +181,15 @@ impl Relay { let forward_scope = session.connection_path().map(|s| s.to_string()); let forward_coordinator = coordinator.clone(); + // TODO: MoQT auth is hop-by-hop. Forward sessions are relay-to-relay + // (operator-configured via --announce) so they bypass client auth. + // Future work: mutual relay authentication for inter-relay links. + let forward_auth: Arc = Arc::new(AllowAllAuthHook); + let forward_ctx = SessionContext { + session_id: rand_session_id(), + connection_path: forward_scope.clone(), + peer: "0.0.0.0:0".parse().unwrap(), + }; let session = Session { session, producer: Some(Producer::new( @@ -175,6 +197,9 @@ impl Relay { locals.clone(), remote_manager.clone(), forward_scope.clone(), + forward_auth.clone(), + forward_ctx.clone(), + vec![], )), consumer: Some(Consumer::new( subscriber, @@ -182,6 +207,9 @@ impl Relay { forward_coordinator, None, forward_scope, + forward_auth, + forward_ctx, + vec![], )), // Forward connections are always full read-write relay peers, // so no reject loops needed. @@ -244,6 +272,7 @@ impl Relay { let remotes = remote_manager.clone(); let forward = forward_producer.clone(); let coordinator = coordinator.clone(); + let auth_hook = auth_hook.clone(); // Spawn a new task to handle the connection tasks.push(async move { @@ -269,6 +298,43 @@ impl Relay { // Create our MoQ relay session let moq_session = session; + // Parse auth tokens from the raw AUTHORIZATION TOKEN parameter. + let auth_tokens = parse_auth_tokens(moq_session.auth_token_raw()); + + // Build session context for the auth hook. + let session_ctx = SessionContext { + session_id: rand_session_id(), + connection_path: moq_session.connection_path().map(|s| s.to_string()), + peer: "0.0.0.0:0".parse().unwrap(), + }; + + // Invoke auth hook at SETUP time. + match auth_hook.on_setup(&session_ctx, &auth_tokens).await { + Ok(decision) if decision.is_allowed() => { + tracing::debug!( + principal = ?decision.principal, + "auth on_setup: allowed" + ); + } + Ok(decision) => { + tracing::info!( + verdict = ?decision.verdict, + "auth on_setup: denied, closing session" + ); + raw_conn.close(0x2, "unauthorized"); + metrics::counter!("moq_relay_connection_errors_total", "stage" => "auth_setup").increment(1); + metrics::counter!("moq_relay_connections_closed_total").increment(1); + return Ok(()); + } + Err(err) => { + tracing::error!(error = %err, "auth hook on_setup failed"); + raw_conn.close(0x2, "authorization error"); + metrics::counter!("moq_relay_connection_errors_total", "stage" => "auth_setup").increment(1); + metrics::counter!("moq_relay_connections_closed_total").increment(1); + return Ok(()); + } + } + // Resolve the connection path to a scope (identity + permissions). // This translates the raw transport-level path into an application-level // scope_id and determines what the connection is allowed to do. @@ -316,13 +382,19 @@ impl Relay { // to the Session's reject fields so unauthorized messages get // an explicit error response instead of being silently ignored. let (producer, reject_subscribes) = if can_subscribe { - (publisher.map(|publisher| Producer::new(publisher, locals.clone(), remotes, scope_id.clone())), None) + (publisher.map(|publisher| Producer::new( + publisher, locals.clone(), remotes, scope_id.clone(), + auth_hook.clone(), session_ctx.clone(), auth_tokens.clone(), + )), None) } else { (None, publisher) }; let (consumer, reject_publishes) = if can_publish { - (subscriber.map(|subscriber| Consumer::new(subscriber, locals, coordinator, forward, scope_id)), None) + (subscriber.map(|subscriber| Consumer::new( + subscriber, locals, coordinator, forward, scope_id, + auth_hook.clone(), session_ctx.clone(), auth_tokens.clone(), + )), None) } else { (None, subscriber) }; @@ -366,3 +438,62 @@ impl Relay { run_result } } + +/// Parse the raw AUTHORIZATION TOKEN parameter into AuthBlobs. +/// +/// For the initial implementation, we handle inline tokens (USE_VALUE, alias type 0x2) +/// which carry Token Type + Token Value directly. Alias-based token operations +/// (REGISTER, USE_ALIAS, DELETE) are not yet supported. +/// +/// Wire format per token entry: +/// Alias Type (vi64) = 0x2 (USE_VALUE) +/// Token Type (vi64) +/// Token Value (bytes: length-prefixed) +fn parse_auth_tokens(raw: &[u8]) -> Vec { + if raw.is_empty() { + return vec![]; + } + + let mut tokens = Vec::new(); + let mut buf = bytes::Bytes::copy_from_slice(raw); + + while buf.has_remaining() { + let Ok(alias_type) = VarInt::decode(&mut buf) else { + tracing::warn!(remaining = buf.remaining(), "malformed auth token parameter, truncating"); + break; + }; + + match alias_type.into_inner() { + 0x2 => { + let Ok(token_type) = VarInt::decode(&mut buf) else { + tracing::warn!("malformed auth token: missing token type"); + break; + }; + let Ok(token_len) = VarInt::decode(&mut buf) else { + tracing::warn!("malformed auth token: missing token length"); + break; + }; + let token_len: usize = token_len.into(); + if buf.remaining() < token_len { + tracing::warn!(expected = token_len, actual = buf.remaining(), "malformed auth token: truncated value"); + break; + } + let token_value = buf.copy_to_bytes(token_len); + tokens.push(AuthBlob { + token_type: token_type.into_inner(), + token_value, + }); + } + other => { + tracing::debug!(alias_type = other, "skipping unsupported auth token alias type"); + break; + } + } + } + + tokens +} + +fn rand_session_id() -> u64 { + rand::random() +} diff --git a/moq-sub/src/main.rs b/moq-sub/src/main.rs index 5fbd6e30..05131d89 100644 --- a/moq-sub/src/main.rs +++ b/moq-sub/src/main.rs @@ -10,7 +10,7 @@ use url::Url; use moq_native_ietf::quic; use moq_sub::media::Media; -use moq_transport::{coding::TrackNamespace, serve::Tracks}; +use moq_transport::{coding::TrackNamespace, serve::Tracks, session::encode_auth_token}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -36,9 +36,15 @@ async fn main() -> anyhow::Result<()> { connection_id ); - let (session, subscriber) = moq_transport::session::Subscriber::connect(session, transport) - .await - .context("failed to create MoQ Transport session")?; + let auth_raw = match &config.auth_token { + Some(token) => encode_auth_token(config.auth_token_type, token.as_bytes()), + None => vec![], + }; + + let (session, subscriber) = + moq_transport::session::Subscriber::connect_with_auth(session, transport, auth_raw) + .await + .context("failed to create MoQ Transport session")?; // Associate empty set of Tracks with provided namespace let tracks = Tracks::new(TrackNamespace::from_utf8_path(&config.name)); @@ -79,6 +85,14 @@ pub struct Config { /// "0.mp4" for the init track, "{track_id}.m4s" for the rest. #[arg(long)] pub catalog: bool, + + /// Auth token string to include in CLIENT_SETUP AUTHORIZATION TOKEN parameter. + #[arg(long)] + pub auth_token: Option, + + /// Token type identifier for the auth token (e.g. C4M=6501485, PrivacyPass=0, shared-secret). + #[arg(long, default_value = "6501485")] + pub auth_token_type: u64, } fn moq_url(s: &str) -> Result { diff --git a/moq-transport/src/session/mod.rs b/moq-transport/src/session/mod.rs index a2313636..15fc0e76 100644 --- a/moq-transport/src/session/mod.rs +++ b/moq-transport/src/session/mod.rs @@ -35,6 +35,19 @@ use crate::watch::Queue; use crate::{message, setup}; use std::path::PathBuf; +/// Encode a single auth token into AUTHORIZATION TOKEN wire format. +/// Uses USE_VALUE (alias type 0x2) with the given token_type and value. +pub fn encode_auth_token(token_type: u64, token_value: &[u8]) -> Vec { + use crate::coding::{Encode, VarInt}; + + let mut buf = Vec::new(); + VarInt::from_u32(0x2).encode(&mut buf).unwrap(); // USE_VALUE + VarInt::try_from(token_type).unwrap().encode(&mut buf).unwrap(); + VarInt::try_from(token_value.len() as u64).unwrap().encode(&mut buf).unwrap(); + buf.extend_from_slice(token_value); + buf +} + /// The transport protocol negotiated for this MoQT connection. /// /// MoQT can run over either WebTransport (HTTP/3 + QUIC) or raw QUIC. @@ -81,6 +94,11 @@ pub struct Session { /// (takes precedence) or the CLIENT_SETUP PATH parameter (key 0x1). /// For outgoing connections: auto-extracted from the session URL in connect(). connection_path: Option, + + /// Raw AUTHORIZATION TOKEN parameter value from CLIENT_SETUP (key 0x3). + /// Contains the uninterpreted bytes of the token parameter for the relay's + /// auth hook to parse. Empty if no token was present. + auth_token_raw: Vec, } impl Session { @@ -183,6 +201,14 @@ impl Session { self.connection_path.as_deref() } + /// Returns the raw AUTHORIZATION TOKEN parameter value from CLIENT_SETUP. + /// + /// This is the uninterpreted bytes from setup parameter key 0x3. + /// Returns an empty slice if no token was present. + pub fn auth_token_raw(&self) -> &[u8] { + &self.auth_token_raw + } + // Helper for determining the largest supported version fn largest_common(a: &[T], b: &[T]) -> Option { a.iter() @@ -492,6 +518,7 @@ impl Session { mlog: Option, transport: Transport, connection_path: Option, + auth_token_raw: Vec, ) -> (Self, Option, Option) { let next_requestid = Arc::new(atomic::AtomicU64::new(first_requestid)); let outgoing = Queue::default().split(); @@ -521,6 +548,7 @@ impl Session { mlog: mlog_shared, transport, connection_path, + auth_token_raw, }; (session, publisher, subscriber) @@ -538,6 +566,15 @@ impl Session { session: web_transport::Session, mlog_path: Option, transport: Transport, + ) -> Result<(Session, Publisher, Subscriber), SessionError> { + Self::connect_with_auth(session, mlog_path, transport, vec![]).await + } + + pub async fn connect_with_auth( + session: web_transport::Session, + mlog_path: Option, + transport: Transport, + auth_token_raw: Vec, ) -> Result<(Session, Publisher, Subscriber), SessionError> { // Auto-extract path from the session URL. // This aligns with the unified moqt:// URI scheme direction (IETF PR #1486) @@ -567,6 +604,13 @@ impl Session { } } + if !auth_token_raw.is_empty() { + params.set_bytesvalue( + setup::ParameterType::AuthorizationToken.into(), + auth_token_raw, + ); + } + let client = setup::Client { versions: versions.clone(), params, @@ -597,7 +641,7 @@ impl Session { // TODO: emit server_setup_parsed event // We are the client, so the first request id is 0 - let session = Session::new(session, sender, recver, 0, mlog, transport, path); + let session = Session::new(session, sender, recver, 0, mlog, transport, path, vec![]); Ok((session.0, session.1.unwrap(), session.2.unwrap())) } @@ -645,6 +689,16 @@ impl Session { // Raw QUIC connections only have CLIENT_SETUP PATH. let connection_path = wt_path.or(client_setup_path); + // Extract AUTHORIZATION TOKEN parameter (key 0x3, BytesValue). + let auth_token_raw = client + .params + .get(setup::ParameterType::AuthorizationToken.into()) + .and_then(|kvp| match &kvp.value { + crate::coding::Value::BytesValue(bytes) => Some(bytes.clone()), + _ => None, + }) + .unwrap_or_default(); + if connection_path.is_some() { tracing::debug!( connection_path = connection_path.as_deref(), @@ -697,6 +751,7 @@ impl Session { mlog, transport, connection_path, + auth_token_raw, )) } else { Err(SessionError::Version(client.versions, server_versions)) diff --git a/moq-transport/src/session/publisher.rs b/moq-transport/src/session/publisher.rs index 8b8c5085..06aec8d1 100644 --- a/moq-transport/src/session/publisher.rs +++ b/moq-transport/src/session/publisher.rs @@ -88,7 +88,16 @@ impl Publisher { session: web_transport::Session, transport: super::Transport, ) -> Result<(Session, Publisher), SessionError> { - let (session, publisher, _) = Session::connect(session, None, transport).await?; + Self::connect_with_auth(session, transport, vec![]).await + } + + pub async fn connect_with_auth( + session: web_transport::Session, + transport: super::Transport, + auth_token_raw: Vec, + ) -> Result<(Session, Publisher), SessionError> { + let (session, publisher, _) = + Session::connect_with_auth(session, None, transport, auth_token_raw).await?; Ok((session, publisher)) } diff --git a/moq-transport/src/session/subscriber.rs b/moq-transport/src/session/subscriber.rs index 2653abee..84a407f1 100644 --- a/moq-transport/src/session/subscriber.rs +++ b/moq-transport/src/session/subscriber.rs @@ -91,7 +91,16 @@ impl Subscriber { session: web_transport::Session, transport: super::Transport, ) -> Result<(Session, Self), SessionError> { - let (session, _, subscriber) = Session::connect(session, None, transport).await?; + Self::connect_with_auth(session, transport, vec![]).await + } + + pub async fn connect_with_auth( + session: web_transport::Session, + transport: super::Transport, + auth_token_raw: Vec, + ) -> Result<(Session, Self), SessionError> { + let (session, _, subscriber) = + Session::connect_with_auth(session, None, transport, auth_token_raw).await?; Ok((session, subscriber)) }