feat(google-calendar): encrypt chat tokens and give watchers a short-lived access token

token.json is sealed with AES-256-GCM (key derived from the OAuth client secret,
bound to the chat), so the agent's processes can read the file but not the
refresh token. Plain files, including VolcanicSecretary's refresh_token format,
are rewritten sealed on first read.

The scheduler gains IScriptEnvironment: extra per-run variables set after the
process environment filter and redacted from the run's output. The Telegram
host uses it to pass GOOGLE_ACCESS_TOKEN, refreshed to cover the job's timeout;
the refresh token and client secret never reach the script.
This commit is contained in:
EugeneTes
2026-10-02 08:31:24 +00:00
parent 28bbfbc759
commit d2fd7ffa02
16 changed files with 436 additions and 31 deletions

View File

@@ -28,7 +28,29 @@ public sealed class GoogleCalendarConnections
var directory = Path.GetFullPath(settings.TokenRoot is { } root
? Path.Combine(root, ChatKeys.For(scope))
: Path.Combine(workspace, WorkspaceTokenDirectory));
return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings, new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName))));
return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings,
new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName), new TokenCipher(settings.ClientSecret, ChatKeys.For(scope)))));
}
/// <summary>
/// For the host (e.g. a scheduled script's environment): the chat's access token, valid for at least
/// <paramref name="validFor"/> as far as Google's token lifetime allows, or null when the calendar isn't configured or
/// the chat isn't connected (or Google rejected its token). The refresh token and the client secret never leave this
/// process. Other failures (network, Google errors) throw.
/// </summary>
public async Task<string?> TryGetAccessTokenAsync(GoogleCalendarOptions options, string scope, string workspace, TimeSpan validFor,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
if (options.Resolve() is not { } settings) return null;
try
{
return await For(settings, scope, workspace).GetAccessTokenAsync(cancellationToken, validFor).ConfigureAwait(false);
}
catch (GoogleCalendarException ex) when (ex.NotConnected)
{
return null;
}
}
}
@@ -81,18 +103,20 @@ internal sealed class GoogleCalendarConnection
public void CancelPending() => Volatile.Read(ref _pending)?.Cancel();
/// <summary>
/// A valid access token: the cached one while it has more than 60 s left and belongs to the stored refresh
/// token, else one refresh shared by all concurrent callers.
/// A valid access token: the cached one while it has more than 60 s (or <paramref name="validFor"/>, if longer) left
/// and belongs to the stored refresh token, else one refresh shared by all concurrent callers. A fresh token is
/// returned even when Google's lifetime is shorter than <paramref name="validFor"/>.
/// </summary>
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken, TimeSpan? validFor = null)
{
var margin = validFor is { } wanted && wanted > RefreshMargin ? wanted : RefreshMargin;
var stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
if (Fresh(stored) is { } token) return token;
if (Fresh(stored, margin) is { } token) return token;
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
if (Fresh(stored) is { } again) return again;
if (Fresh(stored, margin) is { } again) return again;
var access = await OAuth.RefreshAsync(stored.RefreshToken, cancellationToken).ConfigureAwait(false);
Volatile.Write(ref _cached, new CachedToken(stored.RefreshToken, access.Token, access.ExpiresAt));
return access.Token;
@@ -119,9 +143,9 @@ internal sealed class GoogleCalendarConnection
return chatZone;
}
private string? Fresh(StoredToken stored) =>
private string? Fresh(StoredToken stored, TimeSpan margin) =>
Volatile.Read(ref _cached) is { } cached && cached.RefreshToken == stored.RefreshToken
&& cached.ExpiresAt - Settings.Time.GetUtcNow() > RefreshMargin
&& cached.ExpiresAt - Settings.Time.GetUtcNow() > margin
? cached.AccessToken
: null;

View File

@@ -1,4 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
@@ -12,10 +15,17 @@ internal sealed record StoredToken(string RefreshToken, string? Scope = null, st
/// <summary>
/// One chat's <c>token.json</c>. Reads never throw (unreadable = not connected); writes are atomic
/// (<c>token.json.tmp</c> then a move) and owner-only on Unix.
/// <para>
/// With a <see cref="TokenCipher"/> the file is <c>{ "version": 2, "sealed": "..." }</c>: the agent's processes run as
/// the bot's OS user (and owner-only modes mean nothing on some file systems), so they can read the file, but not the
/// token. A plain file (written before encryption, or VolcanicSecretary's <c>{ "refresh_token": ... }</c>) is read once
/// and rewritten sealed.
/// </para>
/// </summary>
internal sealed class GoogleTokenStore(string path)
internal sealed class GoogleTokenStore(string path, TokenCipher? cipher = null)
{
public const string FileName = "token.json";
private const int SealedVersion = 2;
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
@@ -28,10 +38,20 @@ internal sealed class GoogleTokenStore(string path)
if (!File.Exists(Path)) return null;
var text = File.ReadAllText(Path);
if (string.IsNullOrWhiteSpace(text)) return null;
var token = VolcanicJson.Deserialize<StoredToken>(text);
return string.IsNullOrWhiteSpace(token?.RefreshToken) ? null : token;
if (JsonNode.Parse(text) is not JsonObject file) return null;
if (file["sealed"] is JsonValue sealedValue)
{
var plain = sealedValue.TryGetValue<string>(out var data) ? cipher?.Open(data) : null;
return plain is null ? null : Valid(VolcanicJson.Deserialize<StoredToken>(Encoding.UTF8.GetString(plain)));
}
var token = Valid(VolcanicJson.Deserialize<StoredToken>(text));
if (token is null && file["refresh_token"] is JsonValue legacy && legacy.TryGetValue<string>(out var refresh)) // VolcanicSecretary
token = Valid(new StoredToken(refresh, GoogleOAuthClient.CalendarScope));
if (token is not null && cipher is not null) Reseal(token);
return token;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException)
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException or InvalidOperationException)
{
return null;
}
@@ -47,7 +67,15 @@ internal sealed class GoogleTokenStore(string path)
var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
using (var stream = new FileStream(temp, options))
JsonSerializer.Serialize(stream, token, VolcanicJson.Indented);
{
if (cipher is null) JsonSerializer.Serialize(stream, token, VolcanicJson.Indented);
else
{
var plain = JsonSerializer.SerializeToUtf8Bytes(token, VolcanicJson.Options);
JsonSerializer.Serialize(stream, new SealedFile(SealedVersion, cipher.Seal(plain)), VolcanicJson.Indented);
CryptographicOperations.ZeroMemory(plain);
}
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PrivateFile); // a leftover .tmp keeps its old mode otherwise
File.Move(temp, Path, overwrite: true);
}
@@ -57,6 +85,20 @@ internal sealed class GoogleTokenStore(string path)
if (File.Exists(Path)) File.Delete(Path);
}
private static StoredToken? Valid(StoredToken? token) => string.IsNullOrWhiteSpace(token?.RefreshToken) ? null : token;
/// <summary>Best effort: a plain file that can't be rewritten still works, and is sealed on a later read.</summary>
private void Reseal(StoredToken token)
{
try
{
Write(token);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
}
}
private static void CreatePrivateDirectory(string directory)
{
if (Directory.Exists(directory)) return;
@@ -67,4 +109,57 @@ internal sealed class GoogleTokenStore(string path)
}
Directory.CreateDirectory(directory, PrivateDirectory);
}
private sealed record SealedFile(int Version, string Sealed);
}
/// <summary>
/// AES-256-GCM for <c>token.json</c>. The key is derived (HKDF-SHA256) from the OAuth client secret, which the agent's
/// processes never see; the chat key is the associated data, so a file copied into another chat's folder doesn't open.
/// A new client secret makes every stored token unreadable: each chat runs <c>/calendar_connect</c> again.
/// Sealed form: base64 of nonce (12 bytes), tag (16 bytes), ciphertext.
/// </summary>
internal sealed class TokenCipher
{
private const int NonceSize = 12;
private const int TagSize = 16;
private static readonly byte[] KeyInfo = Encoding.UTF8.GetBytes("volcanic google-calendar token.json v2");
private readonly byte[] _key;
private readonly byte[] _chat;
public TokenCipher(string clientSecret, string chatKey)
{
ArgumentException.ThrowIfNullOrEmpty(clientSecret);
ArgumentException.ThrowIfNullOrEmpty(chatKey);
_key = HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(clientSecret), 32, info: KeyInfo);
_chat = Encoding.UTF8.GetBytes(chatKey);
}
public string Seal(byte[] plain)
{
var output = new byte[NonceSize + TagSize + plain.Length];
var nonce = output.AsSpan(0, NonceSize);
RandomNumberGenerator.Fill(nonce);
using var aes = new AesGcm(_key, TagSize);
aes.Encrypt(nonce, plain, output.AsSpan(NonceSize + TagSize), output.AsSpan(NonceSize, TagSize), _chat);
return Convert.ToBase64String(output);
}
/// <summary>Null when the data is damaged, sealed with another client secret, or sealed for another chat.</summary>
public byte[]? Open(string data)
{
try
{
var input = Convert.FromBase64String(data);
if (input.Length < NonceSize + TagSize) return null;
var plain = new byte[input.Length - NonceSize - TagSize];
using var aes = new AesGcm(_key, TagSize);
aes.Decrypt(input.AsSpan(0, NonceSize), input.AsSpan(NonceSize + TagSize), input.AsSpan(NonceSize, TagSize), plain, _chat);
return plain;
}
catch (Exception ex) when (ex is FormatException or CryptographicException)
{
return null;
}
}
}

View File

@@ -22,6 +22,7 @@ public sealed class JobScheduler : IAsyncDisposable
private readonly SchedulerOptions _options;
private readonly IScriptRunner _runner;
private readonly ISecretRedactor _redactor;
private readonly IScriptEnvironment? _scriptEnvironment;
private readonly TimeProvider _time;
private readonly ILogger _logger;
private readonly Dictionary<string, JobStore> _stores = new(StringComparer.Ordinal);
@@ -37,12 +38,14 @@ public sealed class JobScheduler : IAsyncDisposable
private Task _pendingTick = Task.CompletedTask;
private int _firesStarted;
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger,
IScriptEnvironment? scriptEnvironment = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_runner = runner;
_redactor = redactor;
_scriptEnvironment = scriptEnvironment;
_time = time;
_logger = logger;
}
@@ -448,16 +451,19 @@ public sealed class JobScheduler : IAsyncDisposable
private async Task<JobResult?> RunWatcherAsync(ScheduledJob job, IJobDelivery delivery, TimeZoneInfo zone, CancellationToken scriptToken)
{
var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout;
var request = new ScriptRequest(job.Command!, delivery.WorkspaceFor(job.Target), timeout, job.Target, job.Id, ZoneNames.Iana(zone));
IReadOnlyDictionary<string, string>? extra;
ScriptOutcome outcome;
try
{
extra = await ScriptEnvironmentAsync(job, timeout, scriptToken).ConfigureAwait(false);
var request = new ScriptRequest(job.Command!, delivery.WorkspaceFor(job.Target), timeout, job.Target, job.Id, ZoneNames.Iana(zone), extra);
outcome = await _runner.RunAsync(request, scriptToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (scriptToken.IsCancellationRequested)
{
return null;
}
string Redact(string text) => RedactRun(text, extra);
if (scriptToken.IsCancellationRequested || !Exists(job.Id)) return null;
var at = _time.GetUtcNow();
@@ -465,11 +471,11 @@ public sealed class JobScheduler : IAsyncDisposable
{
case ScriptOutcome.Output output:
// The condition is met: exactly one agent prompt. Redacted: watcher stdout isn't a tool result.
return await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false)
return await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, Redact(output.Text)), ct)).ConfigureAwait(false)
? new JobResult(true, 0, false, null, at)
: null;
case ScriptOutcome.Failed failed:
var result = new JobResult(false, failed.ExitCode, failed.TimedOut, _redactor.Redact(failed.StderrTail), at);
var result = new JobResult(false, failed.ExitCode, failed.TimedOut, Redact(failed.StderrTail), at);
if (!IsRepeat(LastResult(job.Id), result)
&& !await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false))
return null; // the alert was cut off by shutdown: not recorded, so the next failure alerts again
@@ -479,6 +485,32 @@ public sealed class JobScheduler : IAsyncDisposable
}
}
/// <summary>The host's extra variables for this run; null when there are none or the host failed (logged).</summary>
private async Task<IReadOnlyDictionary<string, string>?> ScriptEnvironmentAsync(ScheduledJob job, TimeSpan timeout, CancellationToken scriptToken)
{
if (_scriptEnvironment is null) return null;
try
{
var extra = await _scriptEnvironment.ForRunAsync(job.Target, timeout, scriptToken).ConfigureAwait(false);
return extra.Count == 0 ? null : extra;
}
catch (Exception ex) when (ex is not OperationCanceledException || !scriptToken.IsCancellationRequested)
{
_logger.LogWarning(ex, "Job {Id} ({Name}): the script environment failed; the watcher runs without it", job.Id, job.Name);
return null;
}
}
/// <summary>The host's secrets, then this run's extra values (8 characters or more, like the policy's secret values).</summary>
private string RedactRun(string text, IReadOnlyDictionary<string, string>? extra)
{
text = _redactor.Redact(text);
if (extra is null) return text;
foreach (var value in extra.Values.Where(v => v.Length >= 8).OrderByDescending(v => v.Length))
text = text.Replace(value, "[REDACTED]", StringComparison.Ordinal);
return text;
}
/// <summary>The previous run failed the same way (exit code and timeout): the alert isn't sent again.</summary>
private static bool IsRepeat(JobResult? previous, JobResult current) =>
previous is { Ok: false } p && p.ExitCode == current.ExitCode && p.TimedOut == current.TimedOut;

View File

@@ -7,8 +7,22 @@ using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>One watcher run. <see cref="TimeZoneId"/> becomes the script's <c>TZ</c> (the target's zone).</summary>
public sealed record ScriptRequest(string Command, string Cwd, TimeSpan Timeout, string Target, string JobId, string? TimeZoneId);
/// <summary>
/// One watcher run. <see cref="TimeZoneId"/> becomes the script's <c>TZ</c> (the target's zone); <see cref="Environment"/>
/// (from the host's <see cref="IScriptEnvironment"/>) is set after the process environment filter.
/// </summary>
public sealed record ScriptRequest(string Command, string Cwd, TimeSpan Timeout, string Target, string JobId, string? TimeZoneId,
IReadOnlyDictionary<string, string>? Environment = null);
/// <summary>
/// Extra variables for one watcher run, e.g. a short-lived access token. They are set after the process environment
/// filter (so a deny rule doesn't remove them), and their values are redacted from the run's output and failure alerts.
/// </summary>
public interface IScriptEnvironment
{
/// <summary>Variables for a run of <paramref name="target"/>'s watcher that may last <paramref name="timeout"/>; empty adds nothing. A throw is logged and the run goes ahead without them.</summary>
Task<IReadOnlyDictionary<string, string>> ForRunAsync(string target, TimeSpan timeout, CancellationToken cancellationToken);
}
/// <summary>What a watcher run means for the job.</summary>
public abstract record ScriptOutcome
@@ -60,6 +74,7 @@ public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy p
Scope = request.Target,
Origin = request.JobId,
TimeZoneId = request.TimeZoneId,
Environment = request.Environment?.ToDictionary(e => e.Key, e => (string?)e.Value),
});
}
catch (Exception ex)