diff --git a/.amplifyignore b/.amplifyignore new file mode 100644 index 0000000..ca24bed --- /dev/null +++ b/.amplifyignore @@ -0,0 +1 @@ +ecdsa-p521-local.private.pem diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..5662bb4 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,3 @@ +[advisories] +ignore = ["RUSTSEC-2023-0071"] +# RUSTSEC-2023-0071: rust_crypto pulls in rsa as a dependency, but we don't use those code paths. diff --git a/Cargo.lock b/Cargo.lock index d17c491..19d96ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,10 +44,12 @@ dependencies = [ "const-hex", "enum_dispatch", "hex-literal", + "jsonwebtoken", "reqwest", "reqwest-middleware", "reqwest-retry", "serde", + "serde_json", "sha2", "thiserror 1.0.69", "tokei", @@ -133,12 +135,24 @@ dependencies = [ "windows-link", ] +[[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 = "bitflags" version = "1.3.2" @@ -298,6 +312,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -347,6 +367,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[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,6 +389,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "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", +] + [[package]] name = "dashmap" version = "4.0.2" @@ -368,6 +427,26 @@ dependencies = [ "serde", ] +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -381,7 +460,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -415,12 +496,71 @@ dependencies = [ "syn", ] +[[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 = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[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", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -484,6 +624,22 @@ dependencies = [ "once_cell", ] +[[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.8" @@ -596,6 +752,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -679,6 +836,17 @@ dependencies = [ "memmap2", ] +[[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 = "hermit-abi" version = "0.1.19" @@ -700,6 +868,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -1008,11 +1194,37 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "git+https://github.com/arsenin-kitsoft/jsonwebtoken?rev=fd96c1c#fd96c1c1897854f12db17a4b7c8407b43ab1f2aa" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "p521", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -1098,6 +1310,38 @@ 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.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-format" version = "0.4.4" @@ -1108,6 +1352,26 @@ dependencies = [ "itoa", ] +[[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" @@ -1115,6 +1379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1148,6 +1413,44 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[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 = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[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", +] + [[package]] name = "parking_lot" version = "0.11.2" @@ -1205,6 +1508,25 @@ dependencies = [ "regex", ] +[[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" @@ -1304,6 +1626,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 = "potential_utf" version = "0.1.4" @@ -1313,6 +1656,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1322,6 +1671,15 @@ dependencies = [ "zerocopy", ] +[[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" @@ -1646,6 +2004,16 @@ dependencies = [ "rand 0.8.5", ] +[[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" @@ -1660,6 +2028,26 @@ 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", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -1672,6 +2060,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.36" @@ -1734,6 +2131,26 @@ 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 = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1825,6 +2242,28 @@ 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 = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -1863,6 +2302,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +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", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2011,6 +2466,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index cf9755d..e49a459 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ color-eyre = { version = "0.6.5", features = ["track-caller", "capture-spantrace const-hex = "1.17.0" enum_dispatch = "0.3.13" hex-literal = "1.1.0" +jsonwebtoken = { git = "https://github.com/arsenin-kitsoft/jsonwebtoken", rev = "fd96c1c", features = ["use_pem", "rust_crypto"] } reqwest = { version = "0.12.7", features = ["json", "rustls-tls"], default-features = false } reqwest-middleware = { version = "0.4.2", default-features = false, features = ["json", "rustls-tls"] } reqwest-retry = "0.7.0" @@ -23,3 +24,6 @@ sha2 = "0.10.8" thiserror = "1.0.63" tokei = "12.1.2" tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread", "signal", "process", "sync", "time", "io-util", "io-std", "parking_lot"] } + +[dev-dependencies] +serde_json = "1" diff --git a/ecdsa-p521-local.private.pem b/ecdsa-p521-local.private.pem new file mode 100644 index 0000000..6ebb5ef --- /dev/null +++ b/ecdsa-p521-local.private.pem @@ -0,0 +1,8 @@ +-----BEGIN PRIVATE KEY----- +MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIBkVnED1X34y7oiRp4 +CDB4iOts9mlR5Ht24onc1ZyLnWwD6MwTW2Lyt28Kq1lKTHDFSgox+gzxsiHwqpg1 +UDMMsn+hgYkDgYYABAHVd8RNe9CbyN1qxY0SYlxKXLLe+hWhhK03du+1MCpxBYm7 +1Z8A3C+4RejytDJZs2bGlrnCKjDGB2dYgqxj/mN4yQB+Wad94wCElPaqpN8qRnt8 +FtFHmSkYOdBwfSyxYd+jC5c0HaxiVeIC0jONtC5SC75FXh1OhjhjuRTqmX6Qvox8 +3g== +-----END PRIVATE KEY----- diff --git a/ecdsa-p521-local.public.pem b/ecdsa-p521-local.public.pem new file mode 100644 index 0000000..ff0e161 --- /dev/null +++ b/ecdsa-p521-local.public.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQB1XfETXvQm8jdasWNEmJcSlyy3voV +oYStN3bvtTAqcQWJu9WfANwvuEXo8rQyWbNmxpa5wiowxgdnWIKsY/5jeMkAflmn +feMAhJT2qqTfKkZ7fBbRR5kpGDnQcH0ssWHfowuXNB2sYlXiAtIzjbQuUgu+RV4d +ToY4Y7kU6pl+kL6MfN4= +-----END PUBLIC KEY----- diff --git a/src/auth/gitlab/mod.rs b/src/auth/gitlab/mod.rs index 12591e5..6adaa72 100644 --- a/src/auth/gitlab/mod.rs +++ b/src/auth/gitlab/mod.rs @@ -1,6 +1,21 @@ -//! Authentication for Gitlab Pipelines +//! Authentication for GitLab CI pipelines. +//! +//! Token resolution order: +//! +//! 1. **`AMPLIFY_ID_TOKEN`** – an OIDC ID token issued by GitLab itself. +//! This token is automatically configured in Amplify's runner component +//! and should be preferred when in use. +//! 2. **`TRUSTED_PRIVATE_KEY`** – a PEM-encoded private key supplied by the +//! user that is configured in Amplify. The runner signs its own JWT and +//! includes a set of GitLab predefined CI/CD variables as claims so that +//! the Amplify API can identify the pipeline and project. -use color_eyre::eyre::{eyre, Result}; +use color_eyre::eyre::{Result, WrapErr}; +use serde::{Deserialize, Serialize}; + +use crate::auth::tpk::{TpkJwt, DEFAULT_TOKEN_TTL_SECS}; + +// ─── auth provider ─────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub(crate) struct GitlabAuth { @@ -12,15 +27,253 @@ impl GitlabAuth { Ok(GitlabAuth { jwt: None }) } + /// Return a bearer token that identifies this pipeline run to the Amplify API. pub async fn get_token(&mut self) -> Result { - // GitLab generates JWTs for specific audiences within the pipeline - // configuration itself, so all we have to do is pull the env variable - // https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html if let Ok(token) = std::env::var("AMPLIFY_ID_TOKEN") { - self.jwt = Some(token); - return Ok(self.jwt.clone().unwrap()); + self.jwt = Some(token.clone()); + return Ok(token); + } + + if let Some(signer) = + TpkJwt::from_env().wrap_err("Failed to load TRUSTED_PRIVATE_KEY for GitLab TPK JWT")? + { + let claims = GitlabTpkClaims::from_env() + .wrap_err("Failed to read required GitLab CI variables for TPK JWT")?; + let token = signer + .create_token(claims, DEFAULT_TOKEN_TTL_SECS) + .wrap_err("Failed to sign GitLab TPK JWT")?; + self.jwt = Some(token.clone()); + return Ok(token); } - Err(eyre!("Failed to locate an ID Token from Gitlab.")) + Err(color_eyre::eyre::eyre!( + "No GitLab ID token found. \ + Either use the amplify-security/components/runner component in \ + your `.gitlab-ci.yml`, or create a keypair in Amplify and \ + configure `TRUSTED_PRIVATE_KEY` to the private key in your CI \ + environment variables." + )) + } +} + +/// JWT payload for the Trusted Public Key fallback path. +/// +/// The field names match the claim names used in real GitLab OIDC ID tokens so +/// that the Amplify API can handle both token kinds uniformly. +#[derive(Debug, Serialize, Deserialize)] +struct GitlabTpkClaims { + /// URL of the GitLab instance (`CI_SERVER_URL`). + /// e.g. `"https://gitlab.com"` or `"https://gitlab.example.com:8080"`. + ci_server_url: String, + + /// Instance-level pipeline ID (`CI_PIPELINE_ID`). + pipeline_id: String, + + /// Instance-level project ID (`CI_PROJECT_ID`). + project_id: String, + + /// Namespace + project path (`CI_PROJECT_PATH`), e.g. `"my-group/my-project"`. + project_path: String, + + /// The branch or tag name (`CI_COMMIT_REF_NAME`). + /// Renamed to `ref` to match the GitLab OIDC token schema. + #[serde(rename = "ref")] + git_ref: String, + + /// Instance-level job ID (`CI_JOB_ID`). + job_id: String, + + /// Full commit SHA (`CI_COMMIT_SHA`). + sha: String, +} + +impl GitlabTpkClaims { + /// Populate claims from the current process environment. + /// + /// All fields are required. Every variable listed here is a predefined + /// GitLab CI/CD variable that is always present in any GitLab CI job. + fn from_env() -> Result { + Ok(Self { + ci_server_url: std::env::var("CI_SERVER_URL") + .wrap_err("Expected CI_SERVER_URL to be set, but it wasn't!")?, + pipeline_id: std::env::var("CI_PIPELINE_ID") + .wrap_err("Expected CI_PIPELINE_ID to be set, but it wasn't!")?, + project_id: std::env::var("CI_PROJECT_ID") + .wrap_err("Expected CI_PROJECT_ID to be set, but it wasn't!")?, + project_path: std::env::var("CI_PROJECT_PATH") + .wrap_err("Expected CI_PROJECT_PATH to be set, but it wasn't!")?, + git_ref: std::env::var("CI_COMMIT_REF_NAME") + .wrap_err("Expected CI_COMMIT_REF_NAME to be set, but it wasn't!")?, + job_id: std::env::var("CI_JOB_ID") + .wrap_err("Expected CI_JOB_ID to be set, but it wasn't!")?, + sha: std::env::var("CI_COMMIT_SHA") + .wrap_err("Expected CI_COMMIT_SHA to be set, but it wasn't!")?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; + + const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../ecdsa-p521-local.private.pem"); + const TEST_PUBLIC_KEY_PEM: &str = include_str!("../../../ecdsa-p521-local.public.pem"); + + fn set_all_gitlab_vars() { + std::env::set_var("CI_SERVER_URL", "https://gitlab.example.com"); + std::env::set_var("CI_PIPELINE_ID", "1001"); + std::env::set_var("CI_PROJECT_ID", "42"); + std::env::set_var("CI_PROJECT_PATH", "my-group/my-project"); + std::env::set_var("CI_COMMIT_REF_NAME", "main"); + std::env::set_var("CI_JOB_ID", "9999"); + std::env::set_var("CI_COMMIT_SHA", "abc123def456"); + } + + fn clear_all_vars() { + std::env::remove_var("AMPLIFY_ID_TOKEN"); + std::env::remove_var("TRUSTED_PRIVATE_KEY"); + std::env::remove_var("CI_SERVER_URL"); + std::env::remove_var("CI_PIPELINE_ID"); + std::env::remove_var("CI_PROJECT_ID"); + std::env::remove_var("CI_PROJECT_PATH"); + std::env::remove_var("CI_COMMIT_REF_NAME"); + std::env::remove_var("CI_JOB_ID"); + std::env::remove_var("CI_COMMIT_SHA"); + } + + fn make_validation() -> Validation { + let mut v = Validation::new(Algorithm::ES512); + v.set_audience(&[crate::auth::tpk::DEFAULT_AUDIENCE]); + v + } + + // AMPLIFY_ID_TOKEN usage (from runner component) + + #[tokio::test] + async fn test_amplify_id_token_is_returned_directly() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + clear_all_vars(); + std::env::set_var("AMPLIFY_ID_TOKEN", "gitlab.issued.token"); + + let mut auth = GitlabAuth::new().unwrap(); + let token = auth.get_token().await.unwrap(); + + assert_eq!(token, "gitlab.issued.token"); + } + + #[tokio::test] + async fn test_amplify_id_token_is_cached_on_auth_struct() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + clear_all_vars(); + std::env::set_var("AMPLIFY_ID_TOKEN", "cached.token"); + + let mut auth = GitlabAuth::new().unwrap(); + auth.get_token().await.unwrap(); + + assert_eq!(auth.jwt.as_deref(), Some("cached.token")); + } + + // TPK Fallback + + #[tokio::test] + async fn test_tpk_fallback_produces_valid_jwt() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + clear_all_vars(); + set_all_gitlab_vars(); + std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM); + + let mut auth = GitlabAuth::new().unwrap(); + let token = auth.get_token().await.unwrap(); + + let decoding_key = DecodingKey::from_ec_pem(TEST_PUBLIC_KEY_PEM.as_bytes()).unwrap(); + let decoded = + decode::(&token, &decoding_key, &make_validation()).unwrap(); + let claims = decoded.claims; + + assert_eq!(claims["ci_server_url"], "https://gitlab.example.com"); + assert_eq!(claims["pipeline_id"], "1001"); + assert_eq!(claims["project_id"], "42"); + assert_eq!(claims["project_path"], "my-group/my-project"); + assert_eq!(claims["ref"], "main"); + assert_eq!(claims["job_id"], "9999"); + assert_eq!(claims["sha"], "abc123def456"); + } + + #[tokio::test] + async fn test_tpk_fallback_uses_correct_issuer_and_audience() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + clear_all_vars(); + set_all_gitlab_vars(); + std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM); + + let mut auth = GitlabAuth::new().unwrap(); + let token = auth.get_token().await.unwrap(); + + let decoding_key = DecodingKey::from_ec_pem(TEST_PUBLIC_KEY_PEM.as_bytes()).unwrap(); + let decoded = + decode::(&token, &decoding_key, &make_validation()).unwrap(); + let claims = decoded.claims; + + assert_eq!(claims["iss"], crate::auth::tpk::DEFAULT_ISSUER); + assert_eq!(claims["aud"], crate::auth::tpk::DEFAULT_AUDIENCE); + } + + #[tokio::test] + async fn test_tpk_fallback_token_is_cached_on_auth_struct() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + clear_all_vars(); + set_all_gitlab_vars(); + std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM); + + let mut auth = GitlabAuth::new().unwrap(); + let token = auth.get_token().await.unwrap(); + + assert_eq!(auth.jwt.as_deref(), Some(token.as_str())); + } + + // Error Cases + + #[tokio::test] + async fn test_error_when_neither_token_source_is_available() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + clear_all_vars(); + + let mut auth = GitlabAuth::new().unwrap(); + let result = auth.get_token().await; + + assert!( + result.is_err(), + "should fail when no token source is configured" + ); + } + + #[tokio::test] + async fn test_error_when_tpk_present_but_ci_var_missing() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + + // Each predefined GitLab CI variable is required; verify that omitting + // any one of them produces an error. + let all_vars = [ + "CI_SERVER_URL", + "CI_PIPELINE_ID", + "CI_PROJECT_ID", + "CI_PROJECT_PATH", + "CI_COMMIT_REF_NAME", + "CI_JOB_ID", + "CI_COMMIT_SHA", + ]; + + for omit in all_vars { + clear_all_vars(); + set_all_gitlab_vars(); + std::env::remove_var(omit); + std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM); + + let mut auth = GitlabAuth::new().unwrap(); + let result = auth.get_token().await; + + assert!(result.is_err(), "should fail when {omit} is missing"); + } } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 30622d6..095a53e 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod amplify; pub(crate) mod github; pub(crate) mod gitlab; +pub(crate) mod tpk; use color_eyre::eyre::Result; diff --git a/src/auth/tpk/mod.rs b/src/auth/tpk/mod.rs new file mode 100644 index 0000000..a325204 --- /dev/null +++ b/src/auth/tpk/mod.rs @@ -0,0 +1,370 @@ +//! Platform-independent JWT creation using a user's Trusted Private Key +//! +//! When a platform-native OIDC token (e.g. `AMPLIFY_ID_TOKEN` from GitLab) is +//! not available, the runner can fall back to signing its own JWT, pulling +//! the necessary information from the runner's CI environment to associate a +//! CI pipeline (and artifacts) with a customer account, using the private key +//! from a keypair that the user creates in their Amplify dashboard by setting +//! a `TRUSTED_PRIVATE_KEY` environment variable in their CI system. +//! +//! # Defaults +//! +//! | Setting | Value | +//! |-----------|----------------------------------| +//! | Issuer | `https://tpk.amplify.security` | +//! | Audience | `https://api.amplify.security` | +//! | Algorithm | ES512 (ECDSA P-521 + SHA-512) | +//! | Token TTL | 3 600 seconds (1 hour) | + +use color_eyre::eyre::{Result, WrapErr}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use serde::Serialize; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Default `iss` claim for trusted-private-key JWTs. +pub const DEFAULT_ISSUER: &str = "https://tpk.amplify.security"; +/// Default `aud` claim for trusted-private-key JWTs. +pub const DEFAULT_AUDIENCE: &str = "https://api.amplify.security"; +/// Default token lifetime in seconds (1 hour). +pub const DEFAULT_TOKEN_TTL_SECS: u64 = 3_600; + +// ─── signer ────────────────────────────────────────────────────────────────── + +/// JWT signer backed by a customer-provided EC private key. +/// +/// # Example +/// +/// ```rust,ignore +/// use serde::Serialize; +/// +/// #[derive(Serialize)] +/// struct MyClaims { pipeline_id: String } +/// +/// let token = TpkJwt::from_ec_pem(pem_bytes)? +/// .with_issuer("https://ci.example.com") +/// .create_token(MyClaims { pipeline_id: "42".into() }, DEFAULT_TOKEN_TTL_SECS)?; +/// ``` +pub(crate) struct TpkJwt { + /// Value placed in the `iss` claim. + pub issuer: String, + /// Value placed in the `aud` claim. + pub audience: String, + encoding_key: EncodingKey, + algorithm: Algorithm, +} + +impl TpkJwt { + /// Build a signer from a PEM-encoded EC private key. + /// + /// Defaults to [`Algorithm::ES512`] (ECDSA P-521), issuer + /// [`DEFAULT_ISSUER`], and audience [`DEFAULT_AUDIENCE`]. + pub fn from_ec_pem(pem: &[u8]) -> Result { + let encoding_key = EncodingKey::from_ec_pem(pem) + .wrap_err("Failed to parse EC private key PEM for TPK JWT")?; + Ok(Self { + issuer: DEFAULT_ISSUER.to_string(), + audience: DEFAULT_AUDIENCE.to_string(), + encoding_key, + algorithm: Algorithm::ES512, + }) + } + + /// Attempt to build a signer from the `TRUSTED_PRIVATE_KEY` environment + /// variable, which must contain a PEM-encoded EC private key. + /// + /// Returns `Ok(None)` when the variable is not set. + pub fn from_env() -> Result> { + match std::env::var("TRUSTED_PRIVATE_KEY") { + Ok(pem) => Ok(Some(Self::from_ec_pem(pem.as_bytes())?)), + Err(_) => Ok(None), + } + } + + /// Override the `iss` (issuer) claim. Returns `self` for chaining. + #[allow(dead_code)] + pub fn with_issuer(mut self, issuer: impl Into) -> Self { + self.issuer = issuer.into(); + self + } + + /// Override the `aud` (audience) claim. Returns `self` for chaining. + #[allow(dead_code)] + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.audience = audience.into(); + self + } + + /// Override the signing algorithm (default: [`Algorithm::ES512`]). + /// Returns `self` for chaining. + #[allow(dead_code)] + pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self { + self.algorithm = algorithm; + self + } + + /// Sign and return a JWT containing the standard claims (`iss`, `aud`, + /// `iat`, `nbf`, `exp`) merged with the caller-supplied `custom_claims`. + /// + /// `ttl_secs` controls how long the token is valid for. + pub fn create_token(&self, custom_claims: C, ttl_secs: u64) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() as usize; + + let claims = TpkClaims { + iss: self.issuer.clone(), + aud: self.audience.clone(), + iat: now, + nbf: now, + exp: now + ttl_secs as usize, + extra: custom_claims, + }; + + let header = Header::new(self.algorithm); + encode(&header, &claims, &self.encoding_key).wrap_err("Failed to encode TPK JWT") + } +} + +// ─── internal claims envelope ──────────────────────────────────────────────── + +/// Standard JWT fields with a generic extra payload flattened alongside them. +#[derive(Debug, Serialize)] +struct TpkClaims { + iss: String, + aud: String, + iat: usize, + nbf: usize, + exp: usize, + #[serde(flatten)] + extra: C, +} + +// ─── tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{decode, DecodingKey, Validation}; + use serde::{Deserialize, Serialize}; + // Paths are relative to this source file: + // runner/src/auth/tpk/mod.rs → runner/ + const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../ecdsa-p521-local.private.pem"); + const TEST_PUBLIC_KEY_PEM: &str = include_str!("../../../ecdsa-p521-local.public.pem"); + + // ── helpers ─────────────────────────────────────────────────────────── + + fn make_signer() -> TpkJwt { + TpkJwt::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()) + .expect("test private key should be valid") + } + + fn make_decoding_key() -> DecodingKey { + DecodingKey::from_ec_pem(TEST_PUBLIC_KEY_PEM.as_bytes()) + .expect("test public key should be valid") + } + + fn make_validation(audience: &str) -> Validation { + let mut v = Validation::new(Algorithm::ES512); + v.set_audience(&[audience]); + v + } + + #[derive(Debug, Serialize, Deserialize)] + struct SampleClaims { + sub: String, + custom_field: String, + } + + // ── construction ────────────────────────────────────────────────────── + + #[test] + fn test_defaults_are_set() { + let signer = make_signer(); + assert_eq!(signer.issuer, DEFAULT_ISSUER); + assert_eq!(signer.audience, DEFAULT_AUDIENCE); + } + + #[test] + fn test_builder_overrides_issuer_and_audience() { + let signer = make_signer() + .with_issuer("https://ci.issuer.example.com") + .with_audience("https://ci.audience.example.com"); + assert_eq!(signer.issuer, "https://ci.issuer.example.com"); + assert_eq!(signer.audience, "https://ci.audience.example.com"); + } + + // ── token shape ─────────────────────────────────────────────────────── + + #[test] + fn test_create_token_produces_three_part_jwt() { + let token = make_signer() + .create_token( + SampleClaims { + sub: "test-subject".into(), + custom_field: "hello".into(), + }, + 3600, + ) + .expect("token creation should succeed"); + + assert!(!token.is_empty()); + // A compact-serialised JWT has exactly two '.' separators. + assert_eq!( + token.chars().filter(|&c| c == '.').count(), + 2, + "token should be a three-part compact JWT" + ); + } + + // ── round-trip verification ─────────────────────────────────────────── + + #[test] + fn test_token_verifies_with_matching_public_key() { + let token = make_signer() + .create_token( + SampleClaims { + sub: "verified-subject".into(), + custom_field: "check-me".into(), + }, + 3600, + ) + .unwrap(); + + let decoded = decode::( + &token, + &make_decoding_key(), + &make_validation(DEFAULT_AUDIENCE), + ) + .expect("token should verify with the matching public key"); + + let claims = decoded.claims; + assert_eq!(claims["iss"], DEFAULT_ISSUER); + assert_eq!(claims["aud"], DEFAULT_AUDIENCE); + assert_eq!(claims["sub"], "verified-subject"); + assert_eq!(claims["custom_field"], "check-me"); + } + + #[test] + fn test_custom_issuer_and_audience_appear_in_decoded_token() { + let signer = make_signer() + .with_issuer("https://my.issuer.test") + .with_audience("https://my.audience.test"); + + let token = signer + .create_token( + SampleClaims { + sub: "s".into(), + custom_field: "v".into(), + }, + 3600, + ) + .unwrap(); + + let decoded = decode::( + &token, + &make_decoding_key(), + &make_validation("https://my.audience.test"), + ) + .unwrap(); + + assert_eq!(decoded.claims["iss"], "https://my.issuer.test"); + assert_eq!(decoded.claims["aud"], "https://my.audience.test"); + } + + #[test] + fn test_standard_time_claims_are_present_and_consistent() { + let ttl: u64 = 1_800; + let token = make_signer() + .create_token( + SampleClaims { + sub: "t".into(), + custom_field: "v".into(), + }, + ttl, + ) + .unwrap(); + + let decoded = decode::( + &token, + &make_decoding_key(), + &make_validation(DEFAULT_AUDIENCE), + ) + .unwrap(); + let claims = decoded.claims; + + let iat = claims["iat"].as_u64().expect("iat should be a number"); + let exp = claims["exp"].as_u64().expect("exp should be a number"); + let nbf = claims["nbf"].as_u64().expect("nbf should be a number"); + + assert_eq!(exp - iat, ttl, "exp should equal iat + ttl"); + assert_eq!(iat, nbf, "iat and nbf should be equal at issuance"); + } + + #[test] + fn test_wrong_public_key_fails_verification() { + // Forge a second signer from the same key; in practice this tests + // that the validation machinery rejects a structurally valid but + // wrongly-signed token when the key pair does not match. + let token = make_signer() + .create_token( + SampleClaims { + sub: "x".into(), + custom_field: "y".into(), + }, + 3600, + ) + .unwrap(); + + // Corrupt the signature segment (last third of the JWT). + let mut parts: Vec<&str> = token.splitn(3, '.').collect(); + let mut bad_sig = parts[2].to_string(); + // Flip a character to invalidate the signature. + if bad_sig.starts_with('A') { + bad_sig.replace_range(0..1, "B"); + } else { + bad_sig.replace_range(0..1, "A"); + } + parts[2] = &bad_sig; + let tampered = parts.join("."); + + let result = decode::( + &tampered, + &make_decoding_key(), + &make_validation(DEFAULT_AUDIENCE), + ); + assert!(result.is_err(), "tampered token should fail verification"); + } + + // ── from_env ────────────────────────────────────────────────────────── + // + // These tests are async so they can share the crate-wide ENV_MUTEX + // (tokio::sync::Mutex) with the gitlab tests, preventing races when both + // suites run in parallel and manipulate TRUSTED_PRIVATE_KEY. + + #[tokio::test] + async fn test_from_env_returns_none_when_var_absent() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + std::env::remove_var("TRUSTED_PRIVATE_KEY"); + let result = TpkJwt::from_env().unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_from_env_returns_signer_when_var_is_valid_key() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM); + let result = TpkJwt::from_env().unwrap(); + assert!(result.is_some()); + std::env::remove_var("TRUSTED_PRIVATE_KEY"); + } + + #[tokio::test] + async fn test_from_env_errors_on_invalid_pem() { + let _lock = crate::common::test_support::ENV_MUTEX.lock().await; + std::env::set_var("TRUSTED_PRIVATE_KEY", "not-a-valid-pem"); + let result = TpkJwt::from_env(); + assert!(result.is_err(), "an invalid PEM should produce an error"); + std::env::remove_var("TRUSTED_PRIVATE_KEY"); + } +} diff --git a/src/common.rs b/src/common.rs index aa4ad9c..75d425d 100644 --- a/src/common.rs +++ b/src/common.rs @@ -27,3 +27,10 @@ impl RetryableStrategy for DefaultRetryStrategyWith401 { } } } + +/// Shared utilities for tests across the crate. +#[cfg(test)] +pub(crate) mod test_support { + /// Async mutex used by every test that reads or writes env vars. + pub(crate) static ENV_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +}