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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user