aboutsummaryrefslogtreecommitdiff
path: root/src/zen/authutils.cpp
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-04-21 13:48:41 +0200
committerGitHub Enterprise <[email protected]>2026-04-21 13:48:41 +0200
commit245d2e562165d048e5ee2ab97f1260975a8142d3 (patch)
treec01a36119ccacf830c8486634d78d0af137ec47f /src/zen/authutils.cpp
parentasync consul register/deregister (#992) (diff)
downloadarchived-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.cpp237
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");