diff options
| author | Stefan Boberg <[email protected]> | 2026-04-21 13:48:41 +0200 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-04-21 13:48:41 +0200 |
| commit | 245d2e562165d048e5ee2ab97f1260975a8142d3 (patch) | |
| tree | c01a36119ccacf830c8486634d78d0af137ec47f /src/zen/authutils.cpp | |
| parent | async consul register/deregister (#992) (diff) | |
| download | archived-zen-245d2e562165d048e5ee2ab97f1260975a8142d3.tar.xz archived-zen-245d2e562165d048e5ee2ab97f1260975a8142d3.zip | |
zen CLI security review fixes (#974)
Security review follow-ups to the `zen` CLI. Each fix stands on its own commit. Grouped by category below.
## Credentials and secrets
- **Per-install random auth encryption key instead of a hardcoded literal.** The default AES key and IV used to encrypt persisted OIDC refresh tokens / OAuth client secrets were ASCII literals compiled into the public source. Replaced with 32+16 random bytes persisted to `<system-root>/auth/machinekey.dat`. `SecureRandomBytes` added in zencore/crypto wrapping BCryptGenRandom / OpenSSL / mbedTLS CTR_DRBG. Partial override (only one of `--encryption-aes-key`/`--encryption-aes-iv`) is now rejected instead of silently using the hardcoded half.
- **Wrap the machine key with OS-protected storage.** `machinekey.dat` is now a tagged format (4-byte magic + flags + wrapped-or-raw payload). Windows wraps via DPAPI (`CryptProtectData` at per-user scope) so a stolen disk copy cannot decrypt without the OS master key. macOS uses Keychain Services (GenericPassword under `org.unrealengine.zen.auth`, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`). Linux uses libsecret (opt-in via `--zenlibsecret=yes`, off by default because headless servers typically have no Secret Service daemon). All platforms fall back to raw persistence with `0600` perms on POSIX when wrapping is unavailable. Legacy files from the prior commit are detected by size and still read.
> Note: argv-redaction before Sentry on crash was previously part of this PR but was superseded by `ScrubSensitiveValues()` from #989; this PR now just calls that helper instead of walking argv itself.
## Path traversal
- **Reject unsafe filenames from the remote oplog in `oplog-mirror`.** The filename from each oplog entry was joined to the mirror root without normalisation; a compromised remote could use drive letters, UNC shares, device path prefixes, absolute paths, or `..` components to write anywhere the zen user could write. An `UnsafeFileNameReason` check runs immediately after extraction, logs the offending filename, and aborts the mirror.
- **Use the resolved absolute download-spec path in `builds download`.** `--download-spec-path` was computed into a sanitised absolute path, then the original unresolved path was passed to `ParseBuildManifest`, bypassing the `MakeSafeAbsolutePath` mitigations and reading from the process cwd rather than `--local-path`.
## Input validation
- **Stop asserting on malformed `--build-id` / `--build-part-id`.** `Oid::FromHexString` asserts on bad input and `ZEN_ASSERT` is active in release, so a too-short or non-hex user value aborted the process instead of surfacing an `OptionParseException`. Routed all callers through `TryFromHexString`. Also fixes `ParseBuildPartId` reporting errors under the wrong option name.
- **Check the JSON parse error in `oplog-export --builds-metadata-path`.** The single-arg `LoadCompactBinaryFromJson` overload discarded the parser error; malformed JSON shipped a truncated compact-binary `metadata` field to the server with no indication. Switched to the two-arg overload and throws a descriptive error naming the file and reason.
- **Format the actual value in the malformed `--url` error.** The message was constructed with a literal `{}` placeholder and no `fmt::format` call, so users saw the placeholder instead of the offending URL.
- **Require `--output-path` in `cache get` unless `--as-text` is set.** Previously an empty path auto-filled from the value key / attachment hash and wrote into the process cwd; the `--as-text && empty path` stdout branch was unreachable because the auto-fill ran first.
- **Clear the cxxopts `allow_unrecognised_options` flag after permissive parse.** `ParseOptionsPermissive` set the flag on the Options it received and never cleared it, priming that Options for silent typo acceptance on any later reuse. Added `disallow_unrecognised_options()` to the vendored cxxopts (local patch — flagged at the declaration) and wrapped the toggle in RAII.
## Resource lifecycle
- **Restore signal handlers via RAII.** `wipe`, `builds`, and `oplog-mirror` installed SIGINT/SIGBREAK handlers with raw `signal()` and never restored them; an option-parse throw left the handler targeting an abort flag nothing reads. Added `zen::ScopedSignalHandler` in zen.h and applied at all three sites (builds uses `std::optional` members so the guards survive past `OnParentOptionsParsed` into the subcommand's Run).
- **Route SIGINT in `oplog-mirror` to the worker-pool abort flag.** The command declared a local `std::atomic<bool> AbortFlag` but no handler targeted it — Ctrl-C killed the process instead of cleanly aborting. Added a `MirrorAbortFlag` / `MirrorSignalCallbackHandler` pair in projectstore_impl and bound the local as a reference; existing `.store`/`.load`/capture sites unchanged.
- **Clean up the `cache get` temp download on every exit path.** `Http.Download` parks the payload in the system temp dir; a failed `MoveToFile` (cross-volume, denied target) or an exception could leave the temp file behind. The downloaded buffer is already flagged delete-on-close by `HttpClient`, so the fix is just to clear that flag after a successful `MoveToFile` so the renamed-out file isn't reaped.
## Other
- **Fix wrong URL fields in `oplog-export` / `oplog-import` builds-branch descriptions.** Two operator-facing "[builds] URL/namespace/bucket/buildsid" messages formatted `m_CloudUrl` instead of `m_BuildsUrl` / `m_BuildsHost` (copy-paste from neighbouring `[cloud]` branches), shown as empty or stale at the start of an export/import.
- **Fix "Can't find oplog in project '{}'" formatting and a "Failed top mirror" typo in projectstore_cmd.**
- **Fix a misleading `oplog-export` comment on the `--zen` scheme default** ("Assume https" vs. the `http://` the code writes).
- **Fail `ScrambleDir` when `RemoveFile` doesn't delete.** The `zen builds test` scramble phase used `(void)RemoveFile(FilePath)`, discarding both the bool return and the error. A quiet delete failure let verification run against stale state; switched to the two-arg overload and throw on false return or non-empty `error_code`.
Diffstat (limited to 'src/zen/authutils.cpp')
| -rw-r--r-- | src/zen/authutils.cpp | 237 |
1 files changed, 183 insertions, 54 deletions
diff --git a/src/zen/authutils.cpp b/src/zen/authutils.cpp index 2696cdcc3..a2af2b63e 100644 --- a/src/zen/authutils.cpp +++ b/src/zen/authutils.cpp @@ -2,8 +2,10 @@ #include "authutils.h" +#include <zencore/crypto.h> #include <zencore/filesystem.h> #include <zencore/fmtutils.h> +#include <zencore/iobuffer.h> #include <zencore/logging.h> #include <zenhttp/auth/authmgr.h> @@ -113,77 +115,204 @@ AuthCommandLineOptions::AddOptions(cxxopts::Options& Ops) ""); }; +// Load or generate a per-install machine AES key+IV under AuthDir/machinekey.dat +// so the auth-state file is encrypted with bytes unique to this machine rather +// than a hardcoded constant. +// +// When per-user OS-protected storage is available (DPAPI on Windows) the key +// material is wrapped before it lands on disk, so a copy of the file off-machine +// or out of a backup cannot be unwrapped without also stealing the user's OS +// master key. On platforms without OS-level wrapping we fall back to persisting +// the raw bytes with restrictive file permissions (0600 on POSIX; user-only on +// Windows via inheritance from the profile dir). +// +// File format: +// [4-byte magic 'Z','E','N','\x01'] [1-byte flags] [payload] +// flags bit 0 set -> payload is OS-protected (DPAPI blob) +// flags bit 0 clear -> payload is raw KeyBytes+IvBytes bytes +// Legacy files without the magic are interpreted as raw bytes. void -AuthCommandLineOptions::ParseOptions(cxxopts::Options& Ops, - const std::filesystem::path& SystemRootDir, - HttpClientSettings& ClientSettings, - std::string_view HostUrl, - std::unique_ptr<AuthMgr>& Auth, - bool Quiet, - bool Hidden, - bool Verbose) +AuthCommandLineOptions::LoadOrCreateMachineKey(const std::filesystem::path& AuthDir, bool Quiet) { - auto CreateAuthMgr = [&]() { - ZEN_ASSERT(!SystemRootDir.empty()); - if (!Auth) + constexpr size_t KeyBytes = AesKey256Bit::ByteCount; + constexpr size_t IvBytes = AesIV128Bit::ByteCount; + static constexpr std::array<uint8_t, 4> FileMagic = {'Z', 'E', 'N', 0x01}; + static constexpr uint8_t FlagProtected = 0x01; + const std::filesystem::path KeyFile = AuthDir / "machinekey.dat"; + std::array<uint8_t, KeyBytes + IvBytes> KeyMaterial{}; + bool Loaded = false; + + auto ParseFile = [&](MemoryView FileBytes) -> bool { + // Legacy: raw KeyBytes+IvBytes payload. + if (FileBytes.GetSize() == KeyMaterial.size()) + { + memcpy(KeyMaterial.data(), FileBytes.GetData(), KeyMaterial.size()); + return true; + } + if (FileBytes.GetSize() < FileMagic.size() + 1) + { + return false; + } + if (memcmp(FileBytes.GetData(), FileMagic.data(), FileMagic.size()) != 0) + { + return false; + } + const uint8_t Flags = static_cast<const uint8_t*>(FileBytes.GetData())[FileMagic.size()]; + const MemoryView Payload = FileBytes.Mid(FileMagic.size() + 1); + if (Flags & FlagProtected) { - static const std::string_view DefaultEncryptionKey("abcdefghijklmnopqrstuvxyz0123456"); - static const std::string_view DefaultEncryptionIV("0123456789abcdef"); - if (m_EncryptionKey.empty() && m_EncryptionIV.empty()) + std::vector<uint8_t> Plaintext; + if (!TryUnprotectData(Payload, Plaintext)) { - m_EncryptionKey = DefaultEncryptionKey; - m_EncryptionIV = DefaultEncryptionIV; if (!Quiet) { - ZEN_CONSOLE_WARN("Auth: Using default encryption key and initialization vector for auth storage"); + ZEN_CONSOLE_WARN("Auth: failed to unwrap OS-protected machine key at '{}', regenerating", KeyFile); } + return false; } - else + if (Plaintext.size() != KeyMaterial.size()) { - if (m_EncryptionKey.empty()) - { - m_EncryptionKey = DefaultEncryptionKey; - if (!Quiet) - { - ZEN_CONSOLE_WARN("Auth: Using default encryption key for auth storage"); - } - } - if (m_EncryptionIV.empty()) - { - m_EncryptionIV = DefaultEncryptionIV; - if (!Quiet) - { - ZEN_CONSOLE_WARN("Auth: Using default encryption initialization vector for auth storage"); - } - } + return false; } + memcpy(KeyMaterial.data(), Plaintext.data(), KeyMaterial.size()); + return true; + } + if (Payload.GetSize() != KeyMaterial.size()) + { + return false; + } + memcpy(KeyMaterial.data(), Payload.GetData(), KeyMaterial.size()); + return true; + }; - AuthConfig AuthMgrConfig = {.RootDirectory = SystemRootDir / "auth", - .EncryptionKey = AesKey256Bit::FromString(m_EncryptionKey), - .EncryptionIV = AesIV128Bit::FromString(m_EncryptionIV)}; - if (!AuthMgrConfig.EncryptionKey.IsValid()) - { - throw OptionParseException(fmt::format("'--encryption-aes-key' ('{}') is malformed", m_EncryptionKey), Ops.help()); - } - if (!AuthMgrConfig.EncryptionIV.IsValid()) + std::error_code Ec; + if (std::filesystem::exists(KeyFile, Ec)) + { + IoBuffer Data = ReadFile(KeyFile).Flatten(); + if (ParseFile(Data.GetView())) + { + Loaded = true; + } + else if (!Quiet) + { + ZEN_CONSOLE_WARN("Auth: machine key file '{}' is unreadable (size {}), regenerating", KeyFile, Data.GetSize()); + } + } + + if (!Loaded) + { + CreateDirectories(AuthDir); + if (!SecureRandomBytes(MutableMemoryView(KeyMaterial.data(), KeyMaterial.size()))) + { + throw std::runtime_error("failed to obtain secure random bytes for auth machine key"); + } + + std::vector<uint8_t> FileBytes; + FileBytes.reserve(FileMagic.size() + 1 + KeyMaterial.size()); + FileBytes.insert(FileBytes.end(), FileMagic.begin(), FileMagic.end()); + + std::vector<uint8_t> Wrapped; + if (TryProtectData(MemoryView(KeyMaterial.data(), KeyMaterial.size()), Wrapped)) + { + FileBytes.push_back(FlagProtected); + FileBytes.insert(FileBytes.end(), Wrapped.begin(), Wrapped.end()); + if (!Quiet) { - throw OptionParseException(fmt::format("'--encryption-aes-iv' ('{}') is malformed", m_EncryptionIV), Ops.help()); + ZEN_CONSOLE_WARN("Auth: generated OS-protected machine-specific auth encryption key at '{}'", KeyFile); } - if (Verbose) + } + else + { + FileBytes.push_back(0); + FileBytes.insert(FileBytes.end(), KeyMaterial.begin(), KeyMaterial.end()); + if (!Quiet) { - ExtendableStringBuilder<128> SB; - SB << "\n RootDirectory: " << AuthMgrConfig.RootDirectory.string(); - SB << "\n EncryptionKey: " << HideSensitiveString(m_EncryptionKey); - SB << "\n EncryptionIV: " << HideSensitiveString(m_EncryptionIV); - ZEN_CONSOLE("Auth: Creating auth manager with:{}", SB.ToString()); + ZEN_CONSOLE_WARN("Auth: generated machine-specific auth encryption key at '{}' (no OS wrapping available)", KeyFile); } - Auth = AuthMgr::Create(AuthMgrConfig); } - }; + WriteFile(KeyFile, IoBufferBuilder::MakeCloneFromMemory(FileBytes.data(), FileBytes.size())); + + // Belt and suspenders: restrict access on POSIX. On Windows the + // default DACL inherited from a per-user profile dir is already + // user-only in the common case; an explicit tighten there would + // require touching the DACL which is more code than it's worth + // while DPAPI wrapping is the primary defense. +#if !ZEN_PLATFORM_WINDOWS + std::error_code PermEc; + std::filesystem::permissions(KeyFile, + std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, + std::filesystem::perm_options::replace, + PermEc); +#endif + } + + m_EncryptionKey.assign(reinterpret_cast<const char*>(KeyMaterial.data()), KeyBytes); + m_EncryptionIV.assign(reinterpret_cast<const char*>(KeyMaterial.data() + KeyBytes), IvBytes); +} + +void +AuthCommandLineOptions::CreateAuthMgr(cxxopts::Options& Ops, + const std::filesystem::path& SystemRootDir, + std::unique_ptr<AuthMgr>& InOutAuth, + bool Quiet, + bool Verbose) +{ + ZEN_ASSERT(!SystemRootDir.empty()); + if (InOutAuth) + { + return; + } + + const std::filesystem::path AuthDir = SystemRootDir / "auth"; + + if (m_EncryptionKey.empty() != m_EncryptionIV.empty()) + { + throw OptionParseException( + std::string("'--encryption-aes-key' and '--encryption-aes-iv' must be supplied together or both omitted"), + Ops.help()); + } + + if (m_EncryptionKey.empty() && m_EncryptionIV.empty()) + { + LoadOrCreateMachineKey(AuthDir, Quiet); + } + + AuthConfig AuthMgrConfig = {.RootDirectory = AuthDir, + .EncryptionKey = AesKey256Bit::FromString(m_EncryptionKey), + .EncryptionIV = AesIV128Bit::FromString(m_EncryptionIV)}; + if (!AuthMgrConfig.EncryptionKey.IsValid()) + { + throw OptionParseException(fmt::format("'--encryption-aes-key' ('{}') is malformed", m_EncryptionKey), Ops.help()); + } + if (!AuthMgrConfig.EncryptionIV.IsValid()) + { + throw OptionParseException(fmt::format("'--encryption-aes-iv' ('{}') is malformed", m_EncryptionIV), Ops.help()); + } + if (Verbose) + { + ExtendableStringBuilder<128> SB; + SB << "\n RootDirectory: " << AuthMgrConfig.RootDirectory.string(); + SB << "\n EncryptionKey: " << HideSensitiveString(m_EncryptionKey); + SB << "\n EncryptionIV: " << HideSensitiveString(m_EncryptionIV); + ZEN_CONSOLE("Auth: Creating auth manager with:{}", SB.ToString()); + } + InOutAuth = AuthMgr::Create(AuthMgrConfig); +} + +void +AuthCommandLineOptions::ParseOptions(cxxopts::Options& Ops, + const std::filesystem::path& SystemRootDir, + HttpClientSettings& ClientSettings, + std::string_view HostUrl, + std::unique_ptr<AuthMgr>& Auth, + bool Quiet, + bool Hidden, + bool Verbose) +{ if (!m_OpenIdProviderUrl.empty() && !m_OpenIdClientId.empty()) { - CreateAuthMgr(); + CreateAuthMgr(Ops, SystemRootDir, Auth, Quiet, Verbose); std::string ProviderName = m_OpenIdProviderName.empty() ? "Default" : m_OpenIdProviderName; if (Verbose) { @@ -250,7 +379,7 @@ AuthCommandLineOptions::ParseOptions(cxxopts::Options& Ops, } else if (!m_OpenIdProviderName.empty()) { - CreateAuthMgr(); + CreateAuthMgr(Ops, SystemRootDir, Auth, Quiet, Verbose); if (!Quiet) { ZEN_CONSOLE("Auth: Using OpenId provider: {}", m_OpenIdProviderName); @@ -283,7 +412,7 @@ AuthCommandLineOptions::ParseOptions(cxxopts::Options& Ops, if (!ClientSettings.AccessTokenProvider) { - CreateAuthMgr(); + CreateAuthMgr(Ops, SystemRootDir, Auth, Quiet, Verbose); if (!Quiet) { ZEN_CONSOLE("Auth: Using default Open ID provider"); |