From ef67749ca4d5de37249e8201b103ab7749aa55ed Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sat, 11 Jul 2026 15:37:50 -0700 Subject: [PATCH] Add Epic link + PsyNet training-pack publish endpoints Lets the browser stats player publish a custom training pack to Psyonix's PsyNet and get back the public share code, using the published psynet/rlru crates. - POST /api/v1/me/epic-link/start returns Epic's login URL; POST /me/epic-link/complete exchanges the pasted authorization code for Epic tokens (EGS -> exchange code -> EOS, mirroring rlru::auth::AuthManager) and links the Epic account to the current user; GET/DELETE /me/epic-link report and remove the link. - POST /api/v1/training-packs/publish validates the pack payload (ordinals, non-empty name/map/rounds), mints the TM_Guid and derives NumRounds server-side, refreshes an EOS session from the stored tokens (EGS-first with EOS fallback, persisting rotations), then auth_player + Training/AddTrainingData over the PsyNet socket and returns { code, tm_guid }. - New epic_account_links table (migration 0091): one link per user, refresh tokens stored as XChaCha20-Poly1305 ciphertext keyed by the new ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY server secret with row-specific AAD binding (user, epic account, token kind). - Unit tests cover DTO lowering/validation, the token cipher (roundtrip, AAD/key/tamper rejection, random nonces), key parsing, and migration presence. Security follow-ups (JWT expiry, global CORS) recorded in docs/training-publish-followups.md rather than changed here. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 610 ++++++++++++++- Cargo.toml | 8 + crates/rocket-sense-server/Cargo.toml | 6 + crates/rocket-sense-server/src/api/mod.rs | 2 + crates/rocket-sense-server/src/api/openapi.rs | 17 +- .../src/api/training_packs.rs | 729 ++++++++++++++++++ .../src/api/training_packs_tests.rs | 239 ++++++ crates/rocket-sense-server/src/app.rs | 4 + .../src/processing_tests.rs | 1 + crates/rocket-sense-server/src/settings.rs | 33 + .../rocket-sense-server/src/settings_tests.rs | 29 + docs/training-publish-followups.md | 34 + migrations/0091_epic_account_links.sql | 22 + 13 files changed, 1731 insertions(+), 3 deletions(-) create mode 100644 crates/rocket-sense-server/src/api/training_packs.rs create mode 100644 crates/rocket-sense-server/src/api/training_packs_tests.rs create mode 100644 docs/training-publish-followups.md create mode 100644 migrations/0091_epic_account_links.sql diff --git a/Cargo.lock b/Cargo.lock index 638f0c79..4c3169f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +27,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -114,6 +139,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -248,6 +285,27 @@ dependencies = [ "serde", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -290,6 +348,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.43" @@ -304,6 +386,35 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -319,6 +430,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -380,9 +501,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -426,6 +554,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -478,6 +627,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -551,6 +710,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -831,6 +1005,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "hyper" version = "1.9.0" @@ -868,6 +1058,22 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1036,6 +1242,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1154,6 +1369,12 @@ dependencies = [ "syn", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1228,6 +1449,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1266,6 +1497,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndarray" version = "0.17.2" @@ -1367,6 +1615,61 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking" version = "2.2.1" @@ -1529,6 +1832,17 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1587,6 +1901,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psynet" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abfda4100afccd3bb47fadf0b8e6d2b23ea4640cc30f59af14d59aeb9628c07" +dependencies = [ + "anyhow", + "base64", + "futures-util", + "getrandom 0.3.4", + "hmac", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tokio-tungstenite", + "tracing", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1746,6 +2080,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.3" @@ -1784,14 +2129,18 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -1802,13 +2151,16 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.7", ] @@ -1827,6 +2179,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rlru" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac16e15aeec7359bc1c82fffd06fc1a074ab66607bf25fd3f27ddad4427e7fd" +dependencies = [ + "anyhow", + "base64", + "chrono", + "dirs", + "humantime-serde", + "psynet", + "reqwest", + "secrecy", + "serde", + "serde_json", + "thiserror", + "tokio", + "toml", + "tracing", + "url", +] + [[package]] name = "rocket-sense-db" version = "0.1.0" @@ -1853,15 +2228,21 @@ dependencies = [ "apalis", "apalis-postgres", "axum", + "base64", "boxcars", "bytes", + "chacha20poly1305", "chrono", "futures", + "hex", "jsonwebtoken", + "psynet", "reqwest", + "rlru", "rocket-sense-db", "rocket-sense-egress", "rocket-sense-storage", + "secrecy", "serde", "serde_json", "sqlx", @@ -1918,6 +2299,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1965,12 +2359,54 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2031,6 +2467,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2086,6 +2531,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -2436,6 +2891,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -2540,6 +3008,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -2556,6 +3025,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2577,6 +3056,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "native-tls", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2590,6 +3087,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -2613,12 +3151,17 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -2742,6 +3285,26 @@ dependencies = [ "termcolor", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -2765,6 +3328,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2798,6 +3367,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2814,8 +3393,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2994,6 +3580,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -3060,7 +3659,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -3270,6 +3869,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 1d7ca31e..bf2672e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,15 +22,23 @@ apalis = "1.0.0-rc.9" apalis-postgres = "1.0.0-rc.8" async-trait = "0.1" axum = { version = "0.8", features = ["multipart"] } +base64 = "0.22" boxcars = ">=0.11.3, <1.0.0" bytes = "1" +chacha20poly1305 = "0.10" chrono = { version = "0.4", features = ["serde"] } flate2 = "1" futures = "0.3" hex = "0.4" jsonwebtoken = "9" mime = "0.3" +psynet = "0.1.34" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# The rlru library's Epic auth client (EGS login-code flow + EOS token +# exchange) backs the training-pack publish Epic link. `default-features = +# false` drops the CLI-only clap/webbrowser/tracing-subscriber deps. +rlru = { version = "0.1.34", default-features = false } +secrecy = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/crates/rocket-sense-server/Cargo.toml b/crates/rocket-sense-server/Cargo.toml index c5ba9068..67017016 100644 --- a/crates/rocket-sense-server/Cargo.toml +++ b/crates/rocket-sense-server/Cargo.toml @@ -12,15 +12,21 @@ anyhow.workspace = true apalis.workspace = true apalis-postgres.workspace = true axum.workspace = true +base64.workspace = true boxcars.workspace = true bytes.workspace = true +chacha20poly1305.workspace = true chrono.workspace = true futures.workspace = true +hex.workspace = true jsonwebtoken.workspace = true +psynet.workspace = true reqwest.workspace = true +rlru.workspace = true rocket-sense-db.workspace = true rocket-sense-egress.workspace = true rocket-sense-storage.workspace = true +secrecy.workspace = true serde.workspace = true serde_json.workspace = true sqlx.workspace = true diff --git a/crates/rocket-sense-server/src/api/mod.rs b/crates/rocket-sense-server/src/api/mod.rs index e3668b2c..06930d3e 100644 --- a/crates/rocket-sense-server/src/api/mod.rs +++ b/crates/rocket-sense-server/src/api/mod.rs @@ -22,6 +22,7 @@ mod replay_set; mod replays; mod spa; mod stats; +mod training_packs; mod users; /// Reused by `crate::processing` to key the rank-median benchmark population on @@ -86,6 +87,7 @@ fn api_v1_router(state: AppState) -> Router { .merge(rank_trends::router()) .merge(replays::router()) .merge(stats::router()) + .merge(training_packs::router()) .merge(users::router()) .with_state(state) } diff --git a/crates/rocket-sense-server/src/api/openapi.rs b/crates/rocket-sense-server/src/api/openapi.rs index 0f65fd9b..2e2b9fb8 100644 --- a/crates/rocket-sense-server/src/api/openapi.rs +++ b/crates/rocket-sense-server/src/api/openapi.rs @@ -1,7 +1,7 @@ use super::{ admin, auth, ballchasing, favorites, game_outcomes, health, leaderboards, meta, movement_stats, player_overview, player_timeline, players, positioning_stats, possession_stats, - rank_benchmark_cohorts, rank_trends, replays, stats, users, + rank_benchmark_cohorts, rank_trends, replays, stats, training_packs, users, }; use utoipa::{ openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, @@ -92,6 +92,11 @@ use utoipa::{ replays::reprocess_replay, replays::reprocess_replay_client, replays::set_replay_ranks, + training_packs::get_epic_link, + training_packs::start_epic_link, + training_packs::complete_epic_link, + training_packs::unlink_epic_account, + training_packs::publish_training_pack, users::get_user_profile, ), components( @@ -228,6 +233,13 @@ use utoipa::{ crate::ranks::RankSubmission, crate::ranks::SubmittedRank, crate::ranks::SkillSnapshot, + training_packs::EpicLinkStatusResponse, + training_packs::EpicLinkStartResponse, + training_packs::CompleteEpicLinkRequest, + training_packs::EpicLinkResponse, + training_packs::PublishTrainingPackRequest, + training_packs::PublishTrainingRoundRequest, + training_packs::PublishTrainingPackResponse, users::UserProfileResponse, users::UserGameIdentity, ) @@ -243,7 +255,8 @@ use utoipa::{ (name = "users", description = "Rocket Sense user (uploader) profile endpoints"), (name = "stats", description = "Aggregate replay statistics endpoints"), (name = "replays", description = "Replay upload and metadata endpoints"), - (name = "replay-groups", description = "Replay group endpoints") + (name = "replay-groups", description = "Replay group endpoints"), + (name = "training-packs", description = "Epic account linking and PsyNet training pack publishing") ), modifiers(&SecurityAddon) )] diff --git a/crates/rocket-sense-server/src/api/training_packs.rs b/crates/rocket-sense-server/src/api/training_packs.rs new file mode 100644 index 00000000..416a8530 --- /dev/null +++ b/crates/rocket-sense-server/src/api/training_packs.rs @@ -0,0 +1,729 @@ +//! Epic account linking + PsyNet training-pack publishing. +//! +//! The browser stats player builds a custom training pack (rounds of +//! serialized UE archetype strings) and publishes it to Psyonix's PsyNet to +//! get back a public share code. Publishing requires authenticating against +//! PsyNet as a real Epic account, so a rocket-sense user first *links* an +//! Epic account: +//! +//! 1. `POST /me/epic-link/start` returns Epic's login URL. The user signs in +//! there and copies the authorization code Epic displays. +//! 2. `POST /me/epic-link/complete` exchanges that code for Epic tokens +//! (EGS token -> exchange code -> EOS token, mirroring +//! `rlru::auth::AuthManager`) and stores the refresh tokens encrypted. +//! 3. `POST /training-packs/publish` refreshes an EOS session from the stored +//! tokens, authenticates a PsyNet websocket session, and calls +//! `Training/AddTrainingData` to obtain the share code. +//! +//! Refresh tokens are sealed with XChaCha20-Poly1305 under the +//! `ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY` server secret, with row-specific +//! associated data binding each ciphertext to (user, Epic account, token +//! kind). The long-lived EGS refresh token is the durable credential; the +//! short-lived EOS refresh token is kept as a fallback only. + +use crate::{app::AppState, auth::AuthUser}; +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use chacha20poly1305::{ + aead::{Aead, AeadCore, KeyInit, OsRng, Payload}, + XChaCha20Poly1305, XNonce, +}; +use chrono::{DateTime, Utc}; +use psynet::{PsyNetClient, SaveTrainingData, SaveTrainingRound}; +use rlru::auth::{EosTokenResponse, EpicClient}; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; +use sqlx::{PgPool, Row}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::replays::{require_db, ApiError}; + +pub fn router() -> Router { + Router::new() + .route( + "/me/epic-link", + get(get_epic_link).delete(unlink_epic_account), + ) + .route("/me/epic-link/start", post(start_epic_link)) + .route("/me/epic-link/complete", post(complete_epic_link)) + .route("/training-packs/publish", post(publish_training_pack)) +} + +// --------------------------------------------------------------------------- +// Encrypted token storage +// --------------------------------------------------------------------------- + +/// Format version byte prefixed to every stored ciphertext so the sealing +/// scheme can be rotated without guessing at blob layouts. +const TOKEN_CIPHERTEXT_VERSION: u8 = 1; +const XNONCE_LEN: usize = 24; + +/// Which refresh token a ciphertext holds; part of the associated data so an +/// EGS blob can never be replayed into the EOS column or vice versa. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TokenKind { + EgsRefresh, + EosRefresh, +} + +impl TokenKind { + fn as_str(self) -> &'static str { + match self { + Self::EgsRefresh => "egs-refresh", + Self::EosRefresh => "eos-refresh", + } + } +} + +/// Row-specific associated data: binds a ciphertext to the exact (user, Epic +/// account, token kind) cell it was written for, so a blob copied between +/// rows or columns fails authentication instead of decrypting. +fn token_aad(user_id: Uuid, epic_account_id: &str, kind: TokenKind) -> String { + format!( + "rocket-sense/epic-link/v{TOKEN_CIPHERTEXT_VERSION}/{user_id}/{epic_account_id}/{}", + kind.as_str() + ) +} + +/// Authenticated encryption for stored Epic refresh tokens. Blob layout: +/// `version(1) || nonce(24) || ciphertext+tag`. +struct TokenCipher { + cipher: XChaCha20Poly1305, +} + +impl TokenCipher { + fn new(key: &[u8; 32]) -> Self { + Self { + cipher: XChaCha20Poly1305::new(key.into()), + } + } + + fn encrypt(&self, plaintext: &str, aad: &str) -> Result, String> { + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let ciphertext = self + .cipher + .encrypt( + &nonce, + Payload { + msg: plaintext.as_bytes(), + aad: aad.as_bytes(), + }, + ) + .map_err(|error| format!("token encryption failed: {error}"))?; + + let mut blob = Vec::with_capacity(1 + XNONCE_LEN + ciphertext.len()); + blob.push(TOKEN_CIPHERTEXT_VERSION); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ciphertext); + Ok(blob) + } + + fn decrypt(&self, blob: &[u8], aad: &str) -> Result { + let (&version, rest) = blob + .split_first() + .ok_or_else(|| "token ciphertext is empty".to_owned())?; + if version != TOKEN_CIPHERTEXT_VERSION { + return Err(format!("unsupported token ciphertext version {version}")); + } + if rest.len() <= XNONCE_LEN { + return Err("token ciphertext is truncated".to_owned()); + } + let (nonce, ciphertext) = rest.split_at(XNONCE_LEN); + let plaintext = self + .cipher + .decrypt( + XNonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad: aad.as_bytes(), + }, + ) + .map_err(|error| format!("token decryption failed: {error}"))?; + String::from_utf8(plaintext).map_err(|_| "decrypted token is not utf-8".to_owned()) + } +} + +fn require_token_cipher(state: &AppState) -> Result { + let key = state.epic_token_encryption_key.as_deref().ok_or_else(|| { + ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "Epic account linking is not configured on this server \ + (ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY is unset)", + ) + })?; + Ok(TokenCipher::new(key)) +} + +// --------------------------------------------------------------------------- +// Publish request DTO -> psynet::SaveTrainingData +// --------------------------------------------------------------------------- + +/// `ETrainingType` ordinals accepted from the browser: None=0, Aerial=1, +/// Goalie=2, Striker=3. (4 is the UE enum-end sentinel, never a real pack +/// type.) +const MAX_TRAINING_TYPE: i32 = 3; +/// `EDifficulty` ordinal upper bound; kept permissive since PsyNet enforces +/// the authoritative range. +const MAX_DIFFICULTY: i32 = 4; + +/// A training pack to publish, as sent by the browser stats player. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct PublishTrainingPackRequest { + /// Pack name shown in-game. + pub name: String, + /// Optional free-text description. + #[serde(default)] + pub description: Option, + /// `ETrainingType` ordinal: None=0, Aerial=1, Goalie=2, Striker=3. + pub training_type: i32, + /// `EDifficulty` ordinal. + pub difficulty: i32, + /// In-game map the pack was authored on (e.g. `EuroStadium_P`). + pub map_name: String, + /// Free-form tag strings. + #[serde(default)] + pub tags: Vec, + /// The pack's rounds (shots), in order. + pub rounds: Vec, +} + +/// One round (shot) of a training pack. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct PublishTrainingRoundRequest { + /// Round time limit in seconds. + pub time_limit: f32, + /// Raw UE serialized archetype strings (ball spawn, dynamic spawn point, + /// player car), preserved verbatim. + pub serialized_archetypes: Vec, +} + +/// Validate a publish request and lower it to the PsyNet wire struct: mints a +/// fresh pack GUID, derives `NumRounds`, and normalizes name/description/tags. +fn build_save_training_data( + request: &PublishTrainingPackRequest, +) -> Result { + let name = request.name.trim(); + if name.is_empty() { + return Err("training pack name must not be empty".to_owned()); + } + let map_name = request.map_name.trim(); + if map_name.is_empty() { + return Err("training pack map_name must not be empty".to_owned()); + } + if !(0..=MAX_TRAINING_TYPE).contains(&request.training_type) { + return Err(format!( + "training_type must be between 0 and {MAX_TRAINING_TYPE} (None/Aerial/Goalie/Striker), got {}", + request.training_type + )); + } + if !(0..=MAX_DIFFICULTY).contains(&request.difficulty) { + return Err(format!( + "difficulty must be between 0 and {MAX_DIFFICULTY}, got {}", + request.difficulty + )); + } + if request.rounds.is_empty() { + return Err("training pack must contain at least one round".to_owned()); + } + + let mut rounds = Vec::with_capacity(request.rounds.len()); + for (index, round) in request.rounds.iter().enumerate() { + if !round.time_limit.is_finite() || round.time_limit < 0.0 { + return Err(format!( + "round {index} time_limit must be a non-negative number" + )); + } + if round.serialized_archetypes.is_empty() + || round + .serialized_archetypes + .iter() + .any(|archetype| archetype.trim().is_empty()) + { + return Err(format!( + "round {index} must contain non-empty serialized archetypes" + )); + } + rounds.push(SaveTrainingRound { + time_limit: round.time_limit, + serialized_archetypes: round.serialized_archetypes.clone(), + }); + } + + let tags: Vec = request + .tags + .iter() + .map(|tag| tag.trim().to_owned()) + .filter(|tag| !tag.is_empty()) + .collect(); + let description = request + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_owned); + + Ok(SaveTrainingData { + tm_guid: SaveTrainingData::new_guid(), + tm_name: name.to_owned(), + training_type: request.training_type, + difficulty: request.difficulty, + map_name: map_name.to_owned(), + tags, + num_rounds: rounds.len() as i32, + rounds, + description, + }) +} + +// --------------------------------------------------------------------------- +// Epic account link storage +// --------------------------------------------------------------------------- + +struct EpicLinkRow { + epic_account_id: String, + epic_display_name: Option, + egs_refresh_token_ciphertext: Vec, + eos_refresh_token_ciphertext: Option>, + created_at: DateTime, +} + +async fn load_epic_link(db: &PgPool, user_id: Uuid) -> Result, ApiError> { + let row = sqlx::query( + "SELECT epic_account_id, epic_display_name, egs_refresh_token_ciphertext, \ + eos_refresh_token_ciphertext, created_at \ + FROM epic_account_links WHERE user_id = $1", + ) + .bind(user_id) + .fetch_optional(db) + .await + .map_err(ApiError::internal)?; + + row.map(|row| { + Ok(EpicLinkRow { + epic_account_id: row.try_get("epic_account_id").map_err(ApiError::internal)?, + epic_display_name: row + .try_get("epic_display_name") + .map_err(ApiError::internal)?, + egs_refresh_token_ciphertext: row + .try_get("egs_refresh_token_ciphertext") + .map_err(ApiError::internal)?, + eos_refresh_token_ciphertext: row + .try_get("eos_refresh_token_ciphertext") + .map_err(ApiError::internal)?, + created_at: row.try_get("created_at").map_err(ApiError::internal)?, + }) + }) + .transpose() +} + +/// Persist rotated refresh tokens after a successful Epic token refresh. +/// Best-effort callers may ignore failures: the previous tokens usually stay +/// valid, so losing one rotation only risks an extra re-link later. +async fn store_rotated_tokens( + db: &PgPool, + user_id: Uuid, + egs_ciphertext: Option<&[u8]>, + eos_ciphertext: &[u8], +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE epic_account_links \ + SET egs_refresh_token_ciphertext = COALESCE($2, egs_refresh_token_ciphertext), \ + eos_refresh_token_ciphertext = $3, \ + updated_at = now() \ + WHERE user_id = $1", + ) + .bind(user_id) + .bind(egs_ciphertext) + .bind(eos_ciphertext) + .execute(db) + .await?; + Ok(()) +} + +/// Rebuild a live EOS session from the stored refresh tokens, mirroring +/// `rlru::auth::AuthManager::restore_or_refresh`: prefer the long-lived EGS +/// refresh token (refresh -> exchange code -> EOS token), fall back to the +/// short-lived EOS refresh token, and persist whichever tokens were rotated. +async fn refresh_eos_session( + client: &EpicClient, + cipher: &TokenCipher, + db: &PgPool, + user_id: Uuid, + link: &EpicLinkRow, +) -> Result { + let egs_aad = token_aad(user_id, &link.epic_account_id, TokenKind::EgsRefresh); + let eos_aad = token_aad(user_id, &link.epic_account_id, TokenKind::EosRefresh); + + let egs_refresh = cipher + .decrypt(&link.egs_refresh_token_ciphertext, &egs_aad) + .map_err(ApiError::internal)?; + + match refresh_via_egs(client, &egs_refresh).await { + Ok((egs_refresh_token, eos)) => { + let egs_ciphertext = cipher + .encrypt(&egs_refresh_token, &egs_aad) + .map_err(ApiError::internal)?; + let eos_ciphertext = cipher + .encrypt(eos.refresh_token.expose_secret(), &eos_aad) + .map_err(ApiError::internal)?; + if let Err(error) = + store_rotated_tokens(db, user_id, Some(&egs_ciphertext), &eos_ciphertext).await + { + tracing::warn!(error = %error, "failed to persist rotated Epic tokens"); + } + return Ok(eos); + } + Err(error) => { + tracing::warn!(error = %error, "EGS refresh failed; falling back to EOS refresh token"); + } + } + + let Some(eos_ciphertext) = link.eos_refresh_token_ciphertext.as_deref() else { + return Err(epic_session_expired()); + }; + let eos_refresh = cipher + .decrypt(eos_ciphertext, &eos_aad) + .map_err(ApiError::internal)?; + let eos = match client.refresh_eos_token(&eos_refresh).await { + Ok(eos) => eos, + Err(error) => { + tracing::warn!(error = %error, "EOS refresh failed; Epic link must be recreated"); + return Err(epic_session_expired()); + } + }; + let eos_ciphertext = cipher + .encrypt(eos.refresh_token.expose_secret(), &eos_aad) + .map_err(ApiError::internal)?; + if let Err(error) = store_rotated_tokens(db, user_id, None, &eos_ciphertext).await { + tracing::warn!(error = %error, "failed to persist rotated Epic tokens"); + } + Ok(eos) +} + +/// EGS-refresh-token path to a fresh EOS session. Returns the rotated EGS +/// refresh token alongside the EOS token so both can be re-sealed. +async fn refresh_via_egs( + client: &EpicClient, + egs_refresh_token: &str, +) -> anyhow::Result<(String, EosTokenResponse)> { + let egs = client + .authenticate_with_refresh_token(egs_refresh_token) + .await?; + let exchange_code = client + .exchange_code(egs.access_token.expose_secret()) + .await?; + let eos = client.exchange_eos_token(&exchange_code).await?; + Ok((egs.refresh_token.expose_secret().to_owned(), eos)) +} + +fn epic_session_expired() -> ApiError { + ApiError::new( + StatusCode::CONFLICT, + "the linked Epic session has expired; re-link your Epic account", + ) +} + +fn epic_link_missing() -> ApiError { + ApiError::new( + StatusCode::CONFLICT, + "no Epic account is linked; link one before publishing training packs", + ) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// Whether the current user has a linked Epic account. +#[derive(Debug, Serialize, ToSchema)] +pub struct EpicLinkStatusResponse { + pub linked: bool, + pub epic_account_id: Option, + pub epic_display_name: Option, + pub linked_at: Option>, +} + +#[utoipa::path( + get, + path = "/api/v1/me/epic-link", + tag = "training-packs", + responses( + (status = 200, description = "The current user's Epic account link status", body = EpicLinkStatusResponse), + (status = 401, description = "Authentication required"), + (status = 503, description = "Postgres connection is not configured") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn get_epic_link( + auth_user: AuthUser, + State(state): State, +) -> Result, ApiError> { + let db = require_db(&state)?; + let link = load_epic_link(db, auth_user.id).await?; + + Ok(Json(match link { + Some(link) => EpicLinkStatusResponse { + linked: true, + epic_account_id: Some(link.epic_account_id), + epic_display_name: link.epic_display_name, + linked_at: Some(link.created_at), + }, + None => EpicLinkStatusResponse { + linked: false, + epic_account_id: None, + epic_display_name: None, + linked_at: None, + }, + })) +} + +/// Where the user should sign in to obtain an Epic authorization code. +#[derive(Debug, Serialize, ToSchema)] +pub struct EpicLinkStartResponse { + /// Epic login URL; after signing in, Epic displays a JSON document whose + /// `authorizationCode` field is what `complete` expects. + pub login_url: String, +} + +#[utoipa::path( + post, + path = "/api/v1/me/epic-link/start", + tag = "training-packs", + responses( + (status = 200, description = "Epic login URL to obtain an authorization code", body = EpicLinkStartResponse), + (status = 401, description = "Authentication required"), + (status = 503, description = "Epic linking is not configured on this server") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn start_epic_link( + _auth_user: AuthUser, + State(state): State, +) -> Result, ApiError> { + // Fail before sending the user through Epic's login if the server cannot + // store the resulting tokens anyway. + require_token_cipher(&state)?; + + Ok(Json(EpicLinkStartResponse { + login_url: EpicClient::new().login_url(), + })) +} + +/// The Epic authorization code produced by the login URL from `start`. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CompleteEpicLinkRequest { + pub code: String, +} + +/// The Epic account that was linked. +#[derive(Debug, Serialize, ToSchema)] +pub struct EpicLinkResponse { + pub epic_account_id: String, + pub epic_display_name: Option, +} + +#[utoipa::path( + post, + path = "/api/v1/me/epic-link/complete", + tag = "training-packs", + request_body = CompleteEpicLinkRequest, + responses( + (status = 200, description = "Epic account linked", body = EpicLinkResponse), + (status = 400, description = "The authorization code was rejected by Epic"), + (status = 401, description = "Authentication required"), + (status = 503, description = "Postgres or Epic linking is not configured") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn complete_epic_link( + auth_user: AuthUser, + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let db = require_db(&state)?; + let cipher = require_token_cipher(&state)?; + let code = request.code.trim(); + if code.is_empty() { + return Err(ApiError::bad_request( + "authorization code must not be empty", + )); + } + + // EGS authorization code -> EGS tokens -> exchange code -> EOS tokens, + // mirroring rlru::auth::AuthManager::authenticate_with_code. + let client = EpicClient::new(); + let (egs, eos) = async { + let egs = client.authenticate_with_code(code).await?; + let exchange_code = client + .exchange_code(egs.access_token.expose_secret()) + .await?; + let eos = client.exchange_eos_token(&exchange_code).await?; + anyhow::Ok((egs, eos)) + } + .await + .map_err(|error| { + tracing::warn!(error = %error, "Epic authorization code exchange failed"); + ApiError::bad_request( + "failed to authenticate with Epic; the authorization code may be invalid or expired", + ) + })?; + + let epic_account_id = eos.account_id.clone(); + let epic_display_name = + Some(egs.display_name.trim().to_owned()).filter(|display_name| !display_name.is_empty()); + + let egs_ciphertext = cipher + .encrypt( + egs.refresh_token.expose_secret(), + &token_aad(auth_user.id, &epic_account_id, TokenKind::EgsRefresh), + ) + .map_err(ApiError::internal)?; + let eos_ciphertext = cipher + .encrypt( + eos.refresh_token.expose_secret(), + &token_aad(auth_user.id, &epic_account_id, TokenKind::EosRefresh), + ) + .map_err(ApiError::internal)?; + + sqlx::query( + "INSERT INTO epic_account_links \ + (user_id, epic_account_id, epic_display_name, \ + egs_refresh_token_ciphertext, eos_refresh_token_ciphertext) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT (user_id) DO UPDATE \ + SET epic_account_id = EXCLUDED.epic_account_id, \ + epic_display_name = EXCLUDED.epic_display_name, \ + egs_refresh_token_ciphertext = EXCLUDED.egs_refresh_token_ciphertext, \ + eos_refresh_token_ciphertext = EXCLUDED.eos_refresh_token_ciphertext, \ + updated_at = now()", + ) + .bind(auth_user.id) + .bind(&epic_account_id) + .bind(&epic_display_name) + .bind(&egs_ciphertext) + .bind(&eos_ciphertext) + .execute(db) + .await + .map_err(ApiError::internal)?; + + Ok(Json(EpicLinkResponse { + epic_account_id, + epic_display_name, + })) +} + +#[utoipa::path( + delete, + path = "/api/v1/me/epic-link", + tag = "training-packs", + responses( + (status = 204, description = "Epic account unlinked (idempotent)"), + (status = 401, description = "Authentication required"), + (status = 503, description = "Postgres connection is not configured") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn unlink_epic_account( + auth_user: AuthUser, + State(state): State, +) -> Result { + let db = require_db(&state)?; + sqlx::query("DELETE FROM epic_account_links WHERE user_id = $1") + .bind(auth_user.id) + .execute(db) + .await + .map_err(ApiError::internal)?; + + Ok(StatusCode::NO_CONTENT) +} + +/// The published pack's public share code. +#[derive(Debug, Serialize, ToSchema)] +pub struct PublishTrainingPackResponse { + /// Share code, e.g. `9F94-DF9F-0460-7481`. + pub code: String, + /// The pack GUID minted for this publish. + pub tm_guid: String, +} + +#[utoipa::path( + post, + path = "/api/v1/training-packs/publish", + tag = "training-packs", + request_body = PublishTrainingPackRequest, + responses( + (status = 200, description = "Training pack published to PsyNet", body = PublishTrainingPackResponse), + (status = 400, description = "The training pack payload is invalid"), + (status = 401, description = "Authentication required"), + (status = 409, description = "No linked Epic account, or the linked session expired"), + (status = 502, description = "PsyNet rejected the publish"), + (status = 503, description = "Postgres or Epic linking is not configured") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn publish_training_pack( + auth_user: AuthUser, + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let db = require_db(&state)?; + let cipher = require_token_cipher(&state)?; + + let pack = build_save_training_data(&request).map_err(ApiError::bad_request)?; + let tm_guid = pack.tm_guid.clone(); + + let link = load_epic_link(db, auth_user.id) + .await? + .ok_or_else(epic_link_missing)?; + + let client = EpicClient::new(); + let eos = refresh_eos_session(&client, &cipher, db, auth_user.id, &link).await?; + + let rpc = PsyNetClient::new() + .auth_player(&eos.account_id, eos.access_token.expose_secret()) + .await + .map_err(|error| { + tracing::warn!(error = %error, "PsyNet player authentication failed"); + ApiError::new( + StatusCode::BAD_GATEWAY, + "failed to authenticate the linked Epic account with PsyNet", + ) + })?; + + let publish_result = rpc.save_training_data(pack).await; + if let Err(error) = rpc.close().await { + tracing::debug!(error = %error, "failed to close PsyNet socket after publish"); + } + + let response = publish_result.map_err(|error| { + tracing::warn!(error = %error, "PsyNet training pack publish failed"); + ApiError::new( + StatusCode::BAD_GATEWAY, + format!("PsyNet rejected the training pack: {error}"), + ) + })?; + + Ok(Json(PublishTrainingPackResponse { + code: response.code, + tm_guid, + })) +} + +#[cfg(test)] +#[path = "training_packs_tests.rs"] +mod tests; diff --git a/crates/rocket-sense-server/src/api/training_packs_tests.rs b/crates/rocket-sense-server/src/api/training_packs_tests.rs new file mode 100644 index 00000000..9c7e4d71 --- /dev/null +++ b/crates/rocket-sense-server/src/api/training_packs_tests.rs @@ -0,0 +1,239 @@ +use super::*; +use base64::Engine as _; + +fn valid_request() -> PublishTrainingPackRequest { + PublishTrainingPackRequest { + name: "Backboard Reads".to_owned(), + description: Some("Double taps off the backboard".to_owned()), + training_type: 3, + difficulty: 2, + map_name: "EuroStadium_P".to_owned(), + tags: vec!["Aerials".to_owned(), "Backboard".to_owned()], + rounds: vec![ + PublishTrainingRoundRequest { + time_limit: 8.0, + serialized_archetypes: vec![ + "Archetypes.Ball.Ball_Default".to_owned(), + "Archetypes.SpawnPoints.PlayerSpawn".to_owned(), + ], + }, + PublishTrainingRoundRequest { + time_limit: 10.0, + serialized_archetypes: vec!["Archetypes.Ball.Ball_Default".to_owned()], + }, + ], + } +} + +#[test] +fn publish_request_lowers_to_save_training_data() { + let pack = build_save_training_data(&valid_request()).expect("valid request should convert"); + + // A fresh GUID is minted server-side: base64 of exactly 16 random bytes. + let guid_bytes = base64::engine::general_purpose::STANDARD + .decode(&pack.tm_guid) + .expect("tm_guid should be base64"); + assert_eq!(guid_bytes.len(), 16); + + assert_eq!(pack.tm_name, "Backboard Reads"); + assert_eq!(pack.training_type, 3); + assert_eq!(pack.difficulty, 2); + assert_eq!(pack.map_name, "EuroStadium_P"); + assert_eq!(pack.num_rounds, 2); + assert_eq!(pack.rounds.len(), 2); + assert_eq!(pack.rounds[0].serialized_archetypes.len(), 2); + assert_eq!( + pack.description.as_deref(), + Some("Double taps off the backboard") + ); + + // Tags stay strings on the wire (PsyNet's `Tags` field is `Vec`). + assert_eq!(pack.tags, ["Aerials", "Backboard"]); + let wire = serde_json::to_value(&pack).expect("pack should serialize"); + assert_eq!( + wire["Tags"], + serde_json::json!(["Aerials", "Backboard"]), + "tags must serialize as an array of strings under the PascalCase key" + ); + assert_eq!(wire["NumRounds"], serde_json::json!(2)); + assert!(wire["TM_Guid"].is_string()); +} + +#[test] +fn publish_requests_mint_distinct_guids() { + let request = valid_request(); + let first = build_save_training_data(&request).unwrap(); + let second = build_save_training_data(&request).unwrap(); + + assert_ne!(first.tm_guid, second.tm_guid); +} + +#[test] +fn publish_request_normalizes_tags_and_description() { + let mut request = valid_request(); + request.tags = vec![" Shooting ".to_owned(), " ".to_owned()]; + request.description = Some(" ".to_owned()); + + let pack = build_save_training_data(&request).unwrap(); + + assert_eq!(pack.tags, ["Shooting"]); + assert_eq!(pack.description, None); +} + +#[test] +fn publish_request_rejects_empty_rounds() { + let mut request = valid_request(); + request.rounds.clear(); + + let error = build_save_training_data(&request).unwrap_err(); + assert!(error.contains("at least one round")); +} + +#[test] +fn publish_request_rejects_blank_name_and_map() { + let mut request = valid_request(); + request.name = " ".to_owned(); + assert!(build_save_training_data(&request) + .unwrap_err() + .contains("name")); + + let mut request = valid_request(); + request.map_name = String::new(); + assert!(build_save_training_data(&request) + .unwrap_err() + .contains("map_name")); +} + +#[test] +fn publish_request_rejects_invalid_ordinals() { + // 4 is ETrainingType's enum-end sentinel, not a real pack type. + let mut request = valid_request(); + request.training_type = 4; + assert!(build_save_training_data(&request) + .unwrap_err() + .contains("training_type")); + + let mut request = valid_request(); + request.training_type = -1; + assert!(build_save_training_data(&request) + .unwrap_err() + .contains("training_type")); + + let mut request = valid_request(); + request.difficulty = MAX_DIFFICULTY + 1; + assert!(build_save_training_data(&request) + .unwrap_err() + .contains("difficulty")); +} + +#[test] +fn publish_request_rejects_bad_rounds() { + let mut request = valid_request(); + request.rounds[1].time_limit = -1.0; + assert!(build_save_training_data(&request) + .unwrap_err() + .contains("time_limit")); + + let mut request = valid_request(); + request.rounds[0].serialized_archetypes.clear(); + assert!(build_save_training_data(&request) + .unwrap_err() + .contains("serialized archetypes")); + + let mut request = valid_request(); + request.rounds[0] + .serialized_archetypes + .push(" ".to_owned()); + assert!(build_save_training_data(&request) + .unwrap_err() + .contains("serialized archetypes")); +} + +fn test_key() -> [u8; 32] { + [7u8; 32] +} + +fn test_aad() -> String { + token_aad( + Uuid::from_u128(0x1234), + "abcdef0123456789", + TokenKind::EgsRefresh, + ) +} + +#[test] +fn token_cipher_roundtrips() { + let cipher = TokenCipher::new(&test_key()); + let blob = cipher.encrypt("refresh-token-value", &test_aad()).unwrap(); + + assert_eq!(blob[0], TOKEN_CIPHERTEXT_VERSION); + assert_eq!( + cipher.decrypt(&blob, &test_aad()).unwrap(), + "refresh-token-value" + ); +} + +#[test] +fn token_cipher_uses_random_nonces() { + let cipher = TokenCipher::new(&test_key()); + let first = cipher.encrypt("refresh-token-value", &test_aad()).unwrap(); + let second = cipher.encrypt("refresh-token-value", &test_aad()).unwrap(); + + assert_ne!(first, second, "encrypting twice must never reuse a nonce"); +} + +#[test] +fn token_cipher_rejects_wrong_aad() { + let cipher = TokenCipher::new(&test_key()); + let blob = cipher.encrypt("refresh-token-value", &test_aad()).unwrap(); + + // Same row, different token kind: the ciphertext must not be replayable + // into the other column. + let other_aad = token_aad( + Uuid::from_u128(0x1234), + "abcdef0123456789", + TokenKind::EosRefresh, + ); + assert!(cipher.decrypt(&blob, &other_aad).is_err()); + + // Different user entirely. + let other_user_aad = token_aad( + Uuid::from_u128(0x9999), + "abcdef0123456789", + TokenKind::EgsRefresh, + ); + assert!(cipher.decrypt(&blob, &other_user_aad).is_err()); +} + +#[test] +fn token_cipher_rejects_wrong_key_and_tampering() { + let cipher = TokenCipher::new(&test_key()); + let blob = cipher.encrypt("refresh-token-value", &test_aad()).unwrap(); + + let other_cipher = TokenCipher::new(&[8u8; 32]); + assert!(other_cipher.decrypt(&blob, &test_aad()).is_err()); + + let mut tampered = blob.clone(); + *tampered.last_mut().unwrap() ^= 1; + assert!(cipher.decrypt(&tampered, &test_aad()).is_err()); + + let mut wrong_version = blob; + wrong_version[0] = 2; + assert!(cipher.decrypt(&wrong_version, &test_aad()).is_err()); + + assert!(cipher.decrypt(&[], &test_aad()).is_err()); + assert!(cipher + .decrypt(&[TOKEN_CIPHERTEXT_VERSION, 1, 2, 3], &test_aad()) + .is_err()); +} + +#[test] +fn epic_link_migration_creates_encrypted_link_table() { + let migration = include_str!("../../../../migrations/0091_epic_account_links.sql"); + + assert!(migration.contains("CREATE TABLE epic_account_links")); + assert!(migration.contains("user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE")); + assert!(migration.contains("egs_refresh_token_ciphertext bytea NOT NULL")); + assert!(migration.contains("eos_refresh_token_ciphertext bytea")); + assert!(migration.contains("epic_account_id text NOT NULL")); +} diff --git a/crates/rocket-sense-server/src/app.rs b/crates/rocket-sense-server/src/app.rs index 1ee21824..612f5138 100644 --- a/crates/rocket-sense-server/src/app.rs +++ b/crates/rocket-sense-server/src/app.rs @@ -41,6 +41,9 @@ pub struct AppState { /// Ballchasing.com API key for mirroring ballchasing groups. `None` disables /// the ballchasing mirror endpoints. pub ballchasing_api_key: Option>, + /// 32-byte key for authenticated encryption of stored Epic refresh tokens. + /// `None` disables the Epic link / training-pack publish endpoints. + pub epic_token_encryption_key: Option>, /// Round-robin SOCKS5 egress pool for rate-limited upstreams (ballchasing /// replay-file proxying). A single direct exit unless proxies are configured. pub egress: Arc, @@ -82,6 +85,7 @@ pub async fn build(settings: settings::Settings) -> Result { rank_benchmark_calc: settings.rank_benchmark_calc, admin_emails: Arc::from(settings.admin_emails), ballchasing_api_key: settings.ballchasing_api_key.map(Arc::from), + epic_token_encryption_key: settings.epic_token_encryption_key.map(Arc::new), egress, }; diff --git a/crates/rocket-sense-server/src/processing_tests.rs b/crates/rocket-sense-server/src/processing_tests.rs index 8be18af9..9088ab3f 100644 --- a/crates/rocket-sense-server/src/processing_tests.rs +++ b/crates/rocket-sense-server/src/processing_tests.rs @@ -3031,6 +3031,7 @@ async fn stats_read_paths_decode_after_reprocess() { rank_benchmark_calc: crate::rank_benchmark::CalcStyle::PerAppearance, admin_emails: Arc::from(Vec::new()), ballchasing_api_key: None, + epic_token_encryption_key: None, egress: Arc::new( rocket_sense_egress::EgressPool::new( rocket_sense_egress::PoolConfig::default(), diff --git a/crates/rocket-sense-server/src/settings.rs b/crates/rocket-sense-server/src/settings.rs index e01abb09..e23d49aa 100644 --- a/crates/rocket-sense-server/src/settings.rs +++ b/crates/rocket-sense-server/src/settings.rs @@ -150,6 +150,11 @@ pub struct Settings { /// Ballchasing.com API key used to mirror/sync ballchasing groups. When /// absent, ballchasing mirror endpoints are disabled. pub ballchasing_api_key: Option, + /// 32-byte key for authenticated encryption of stored Epic refresh tokens + /// (`ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY`, hex- or base64-encoded). + /// When absent, the Epic account link / training-pack publish endpoints + /// are disabled. + pub epic_token_encryption_key: Option<[u8; 32]>, /// Outbound egress pool used for rate-limited / ban-prone upstreams (e.g. /// proxying replay files from ballchasing.com). Defaults to a single direct /// exit, preserving today's behavior until SOCKS5 proxies are configured. @@ -249,6 +254,12 @@ impl Settings { .ok() .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()); + let epic_token_encryption_key = env::var("ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY") + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .map(|value| parse_epic_token_encryption_key(&value)) + .transpose()?; let egress = parse_egress_settings(); Ok(Self { @@ -272,11 +283,33 @@ impl Settings { rank_benchmark_calc, admin_emails, ballchasing_api_key, + epic_token_encryption_key, egress, }) } } +/// Parse `ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY`: exactly 32 bytes, encoded +/// as either 64 hex characters or standard base64. Fails fast on anything +/// else so a mis-pasted key can never silently encrypt tokens with the wrong +/// material. +fn parse_epic_token_encryption_key(raw: &str) -> Result<[u8; 32]> { + use base64::Engine as _; + + let bytes = hex::decode(raw) + .ok() + .or_else(|| base64::engine::general_purpose::STANDARD.decode(raw).ok()) + .context( + "ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY must be hex or base64 (32 bytes decoded)", + )?; + bytes.as_slice().try_into().map_err(|_| { + anyhow::anyhow!( + "ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY must decode to exactly 32 bytes, got {}", + bytes.len() + ) + }) +} + /// Build the egress pool config from the environment. /// /// Exit selection (first match wins): diff --git a/crates/rocket-sense-server/src/settings_tests.rs b/crates/rocket-sense-server/src/settings_tests.rs index 6b9ca087..5a493464 100644 --- a/crates/rocket-sense-server/src/settings_tests.rs +++ b/crates/rocket-sense-server/src/settings_tests.rs @@ -91,3 +91,32 @@ fn egress_proxies_parse_bare_and_named_entries() { assert_eq!(exits[1].name, "nl-ams"); assert_eq!(exits[1].proxy.as_deref(), Some("socks5h://127.0.0.1:9002")); } + +#[test] +fn epic_token_key_parses_hex_and_base64() { + let key_bytes = [0xabu8; 32]; + + let hex_encoded = hex::encode(key_bytes); + assert_eq!( + parse_epic_token_encryption_key(&hex_encoded).unwrap(), + key_bytes + ); + + let base64_encoded = { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(key_bytes) + }; + assert_eq!( + parse_epic_token_encryption_key(&base64_encoded).unwrap(), + key_bytes + ); +} + +#[test] +fn epic_token_key_rejects_wrong_length_and_garbage() { + // 16 bytes instead of 32. + let error = parse_epic_token_encryption_key(&hex::encode([1u8; 16])).unwrap_err(); + assert!(error.to_string().contains("32 bytes")); + + assert!(parse_epic_token_encryption_key("not hex, not base64!!").is_err()); +} diff --git a/docs/training-publish-followups.md b/docs/training-publish-followups.md new file mode 100644 index 00000000..57b9c5a0 --- /dev/null +++ b/docs/training-publish-followups.md @@ -0,0 +1,34 @@ +# Training-pack publish: security follow-ups + +The Epic-link / training-pack publish feature (`api/training_packs.rs`, +migration `0091_epic_account_links.sql`) deliberately did **not** change two +pre-existing server-wide behaviors that its design review flagged as +hardening concerns. They deserve a separate decision because tightening them +affects every endpoint, not just this feature: + +1. **Non-expiring app JWTs.** Access tokens minted by `crate::auth` set no + `exp` claim and the validation only requires `iss`. A leaked bearer token + is therefore valid forever (until the shared `ROCKET_SENSE_APP_JWT_SECRET` + rotates). Now that a bearer token can transitively publish to PsyNet as a + user's linked Epic account (and rotate their stored refresh tokens), the + blast radius of a stolen token is larger than before. Consider adding + expiry + refresh, or at least per-user token revocation. + +2. **Permissive global CORS.** `app.rs` applies `CorsLayer::permissive()` to + the whole router. Combined with bearer-token auth this is mostly safe + against classic CSRF, but it allows any origin to call the API with a + token it has obtained by other means, and it applies equally to the new + state-changing Epic-link endpoints. Consider an allowlist of known web + origins. + +Additional feature-scoped items worth revisiting later: + +- **Key rotation.** Stored token ciphertexts carry a format version byte, but + there is no re-encryption path for rotating + `ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY`. Rotation currently means users + must re-link. +- **Rate limiting.** `POST /training-packs/publish` performs outbound Epic + + PsyNet calls on behalf of the user; there is no per-user rate limit yet. +- **Audit trail.** Publishes are only visible in tracing logs; a + `training_pack_publishes` table would let users see (and support debug) + what was published with their link. diff --git a/migrations/0091_epic_account_links.sql b/migrations/0091_epic_account_links.sql new file mode 100644 index 00000000..f88ff824 --- /dev/null +++ b/migrations/0091_epic_account_links.sql @@ -0,0 +1,22 @@ +-- Per-user Epic Games account link used to publish custom training packs to +-- Psyonix's PsyNet on the user's behalf. One link per rocket-sense user. +-- +-- Refresh tokens are stored as authenticated-encryption ciphertext +-- (XChaCha20-Poly1305 keyed by ROCKET_SENSE_EPIC_TOKEN_ENCRYPTION_KEY) with +-- row-specific associated data binding each blob to (user_id, +-- epic_account_id, token kind), so a ciphertext copied between rows or +-- columns fails to decrypt. Plaintext tokens never touch the database. +-- +-- Two token columns because Epic's auth is two-tier: the EGS (launcher) +-- refresh token is long-lived and is the durable credential; the EOS refresh +-- token expires quickly and is kept only as a fallback so a recently-used +-- link can survive a transient EGS refresh failure. +CREATE TABLE epic_account_links ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + epic_account_id text NOT NULL, + epic_display_name text, + egs_refresh_token_ciphertext bytea NOT NULL, + eos_refresh_token_ciphertext bytea, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +);