Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ All notable changes to this project are tracked in this file.
- Added Vault / OpenBao secret resolver backend (`OpenClaw.Security.Vault`): KV v2 read with token auth, TTL cache with single-flight and refresh-ahead, startup pre-warm via `IHostedService`, and `vault:<mount>/data/<path>#<key>` reference syntax. See `docs/security/vault.md`.
- Added `Security.Vault.Tls.CaCertPath` support: load a custom CA bundle (PEM file or directory) for Vault TLS validation; mutually exclusive with `Tls.SkipVerify`.
- Made `vault:` references fail closed: resolving a `vault:` ref while the Vault backend is disabled now throws `VaultNotConfiguredException` instead of silently falling back to the literal string.
- Account tokens now run PBKDF2 only on their first use in each gateway process, cutting a repeat verification from about 18 ms under the account store's global lock to microseconds. Later uses match the token by SHA-256 digest and still re-check revocation, expiry, the account's enabled state, and its role. Token use updates `lastLoginAtUtc` at most once a minute instead of rewriting the accounts file on every request.

### Memory Retention and Hardening

Expand Down
100 changes: 80 additions & 20 deletions src/OpenClaw.Gateway/OperatorAccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,30 @@ internal sealed class StoredToken
private const string DirectoryName = "admin";
private const string FileName = "operator-accounts.json";

// Token use is recorded as a login at most this often, so authenticated requests don't each rewrite the accounts file.
private static readonly TimeSpan TokenLastLoginInterval = TimeSpan.FromMinutes(1);

private readonly string _path;
private readonly Lock _gate = new();
private readonly ILogger<OperatorAccountService> _logger;
private readonly TimeProvider _clock;
private readonly Func<string, string, string> _hashSecret;
private readonly Dictionary<string, VerifiedToken> _verifiedTokens = new(StringComparer.Ordinal);
private StoreState? _cached;

public OperatorAccountService(string storagePath, ILogger<OperatorAccountService> logger)
public OperatorAccountService(
string storagePath,
ILogger<OperatorAccountService> logger,
TimeProvider? clock = null,
Func<string, string, string>? hashSecret = null)
{
var rootedStoragePath = Path.IsPathRooted(storagePath)
? storagePath
: Path.GetFullPath(storagePath);
_path = Path.Combine(rootedStoragePath, DirectoryName, FileName);
_logger = logger;
_clock = clock ?? TimeProvider.System;
_hashSecret = hashSecret ?? Pbkdf2Hash;
}

public IReadOnlyList<OperatorAccountSummary> List()
Expand Down Expand Up @@ -108,8 +120,8 @@ public OperatorAccountSummary Create(OperatorAccountCreateRequest request)
DisplayName = request.DisplayName?.Trim() ?? "",
Role = OperatorRoleNames.Normalize(request.Role),
Enabled = request.Enabled,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
CreatedAtUtc = _clock.GetUtcNow(),
UpdatedAtUtc = _clock.GetUtcNow(),
PasswordSalt = salt,
PasswordHash = HashSecret(password, salt)
};
Expand Down Expand Up @@ -142,7 +154,7 @@ public OperatorAccountSummary Create(OperatorAccountCreateRequest request)
account.PasswordHash = HashSecret(NormalizePassword(request.Password), salt);
}

account.UpdatedAtUtc = DateTimeOffset.UtcNow;
account.UpdatedAtUtc = _clock.GetUtcNow();
SaveUnsafe(state);
return MapSummary(account);
}
Expand Down Expand Up @@ -176,8 +188,8 @@ public bool Delete(string id)
return null;

var created = CreateTokenUnsafe(account, request.Label, request.ExpiresAtUtc);
account.LastLoginAtUtc = DateTimeOffset.UtcNow;
account.UpdatedAtUtc = DateTimeOffset.UtcNow;
account.LastLoginAtUtc = _clock.GetUtcNow();
account.UpdatedAtUtc = _clock.GetUtcNow();
SaveUnsafe(state);

return new OperatorTokenExchangeResponse
Expand All @@ -199,7 +211,7 @@ public bool Delete(string id)
return null;

var created = CreateTokenUnsafe(account, request.Label, request.ExpiresAtUtc);
account.UpdatedAtUtc = DateTimeOffset.UtcNow;
account.UpdatedAtUtc = _clock.GetUtcNow();
SaveUnsafe(state);

return new OperatorAccountTokenCreateResponse
Expand Down Expand Up @@ -246,9 +258,9 @@ public bool RevokeToken(string accountId, string tokenId)

if (token.RevokedAtUtc is null)
{
token.RevokedAtUtc = DateTimeOffset.UtcNow;
token.RevokedAtUtc = _clock.GetUtcNow();
if (account is not null)
account.UpdatedAtUtc = DateTimeOffset.UtcNow;
account.UpdatedAtUtc = _clock.GetUtcNow();
SaveUnsafe(state);
}

Expand All @@ -271,7 +283,7 @@ public bool TryAuthenticatePassword(string username, string password, out Operat
if (!SecretMatches(NormalizePassword(password), account.PasswordSalt, account.PasswordHash))
return false;

account.LastLoginAtUtc = DateTimeOffset.UtcNow;
account.LastLoginAtUtc = _clock.GetUtcNow();
SaveUnsafe(state);
identity = MapIdentity(account, OrganizationAuthModeNames.BrowserSession);
return true;
Expand All @@ -285,28 +297,43 @@ public bool TryAuthenticateToken(string token, out OperatorIdentitySnapshot? ide
return false;

var tokenPrefix = GetTokenPrefix(token);
var tokenDigest = DigestToken(token);
lock (_gate)
{
var state = LoadUnsafe();
var now = _clock.GetUtcNow();

// A token verified earlier skips PBKDF2, but its account and record are re-checked on every use.
if (_verifiedTokens.TryGetValue(tokenDigest, out var verified))
{
var verifiedAccount = state.Accounts.FirstOrDefault(item => string.Equals(item.Id, verified.AccountId, StringComparison.Ordinal));
var verifiedRecord = verifiedAccount?.Tokens.FirstOrDefault(item => string.Equals(item.Id, verified.TokenId, StringComparison.Ordinal));
if (verifiedAccount is not { Enabled: true } || verifiedRecord is null || !IsUsable(verifiedRecord, now))
{
_verifiedTokens.Remove(tokenDigest);
return false;
}

identity = AcceptTokenUnsafe(state, verifiedAccount, now);
return true;
}

foreach (var account in state.Accounts)
{
if (!account.Enabled)
continue;

foreach (var tokenRecord in account.Tokens)
{
if (tokenRecord.RevokedAtUtc is not null)
continue;
if (tokenRecord.ExpiresAtUtc is not null && tokenRecord.ExpiresAtUtc <= DateTimeOffset.UtcNow)
if (!IsUsable(tokenRecord, now))
continue;
if (!string.Equals(tokenRecord.TokenPrefix, tokenPrefix, StringComparison.Ordinal))
continue;
if (!SecretMatches(token, tokenRecord.SecretSalt, tokenRecord.SecretHash))
continue;

account.LastLoginAtUtc = DateTimeOffset.UtcNow;
SaveUnsafe(state);
identity = MapIdentity(account, OrganizationAuthModeNames.AccountToken);
_verifiedTokens[tokenDigest] = new VerifiedToken(account.Id, tokenRecord.Id);
identity = AcceptTokenUnsafe(state, account, now);
return true;
}
}
Expand All @@ -315,6 +342,34 @@ public bool TryAuthenticateToken(string token, out OperatorIdentitySnapshot? ide
return false;
}

private static bool IsUsable(StoredToken token, DateTimeOffset now)
=> token.RevokedAtUtc is null && (token.ExpiresAtUtc is null || token.ExpiresAtUtc > now);

private OperatorIdentitySnapshot AcceptTokenUnsafe(StoreState state, StoredAccount account, DateTimeOffset now)
{
if (account.LastLoginAtUtc is not { } lastLogin || now - lastLogin >= TokenLastLoginInterval)
{
var previous = account.LastLoginAtUtc;
account.LastLoginAtUtc = now;
try
{
SaveUnsafe(state);
}
catch
{
// Only a saved login starts the interval; otherwise the next minute of requests would skip the write.
account.LastLoginAtUtc = previous;
throw;
}
}

return MapIdentity(account, OrganizationAuthModeNames.AccountToken);
}

// Account tokens are 192-bit random secrets, so a fast digest identifies one safely; PBKDF2 adds nothing on a repeat use.
private static string DigestToken(string token)
=> Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));

private StoreState LoadUnsafe()
{
if (_cached is not null)
Expand Down Expand Up @@ -374,7 +429,7 @@ private static OperatorIdentitySnapshot MapIdentity(StoredAccount account, strin
IsBootstrapAdmin = false
};

private static CreatedToken CreateTokenUnsafe(StoredAccount account, string? label, DateTimeOffset? expiresAtUtc)
private CreatedToken CreateTokenUnsafe(StoredAccount account, string? label, DateTimeOffset? expiresAtUtc)
{
var token = $"oca_{Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant()}";
var salt = GenerateSalt();
Expand All @@ -385,7 +440,7 @@ private static CreatedToken CreateTokenUnsafe(StoredAccount account, string? lab
TokenPrefix = GetTokenPrefix(token),
SecretSalt = salt,
SecretHash = HashSecret(token, salt),
CreatedAtUtc = DateTimeOffset.UtcNow,
CreatedAtUtc = _clock.GetUtcNow(),
ExpiresAtUtc = expiresAtUtc
};

Expand Down Expand Up @@ -417,19 +472,24 @@ private static string GenerateSalt()
private static string GetTokenPrefix(string token)
=> token[..Math.Min(token.Length, TokenPrefixLength)];

private static string HashSecret(string secret, string saltHex)
private string HashSecret(string secret, string saltHex)
=> _hashSecret(secret, saltHex);

private static string Pbkdf2Hash(string secret, string saltHex)
{
var secretBytes = System.Text.Encoding.UTF8.GetBytes(secret);
var saltBytes = Convert.FromHexString(saltHex);
return Convert.ToHexString(Rfc2898DeriveBytes.Pbkdf2(secretBytes, saltBytes, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes));
}

private static bool SecretMatches(string candidate, string saltHex, string expectedHashHex)
private bool SecretMatches(string candidate, string saltHex, string expectedHashHex)
{
var actual = Convert.FromHexString(HashSecret(candidate, saltHex));
var expected = Convert.FromHexString(expectedHashHex);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}

private sealed record CreatedToken(string Secret, StoredToken Record);

private sealed record VerifiedToken(string AccountId, string TokenId);
}
Loading
Loading