Skip to content

Commit 2cea8fa

Browse files
ANcpLuaclaude
andcommitted
Workflow content: durable key, run-scoped references, validated digests
Three defects in the captured-content path, each with a test that fails without the fix. The content encryption key could be derived from QYL_OTLP_PRIMARY_API_KEY. That bound the lifetime of stored content to a rotatable ingest credential: rotating the OTLP key left every captured payload undecryptable, surfacing later as an AES-GCM tag mismatch that reads like corruption rather than key management. Production now requires QYL_WORKFLOW_CONTENT_KEY and fails closed without it — a collector that will not start is recoverable; ciphertext without its key is not. workflow_content is deduplicated per project by digest, so the reference existence check passed for content captured by any run in the project. Since GetWorkflowContentAsync authorises on the reference row existing for the asking run, minting that row was the whole exploit: reference another run's digest, then read its payload back. A run may now reference only content it captured in the same batch or has already referenced. The ^sha256:[a-f0-9]{64}$ pattern on WorkflowContentRef is an OpenAPI constraint with no runtime enforcement in the generated contract. A reference shorter than the prefix threw ArgumentOutOfRangeException out of the digest slice — a 500 on attacker-controlled input instead of a rejected request. QYL_WORKFLOW_CONTENT_KEY is set on the production collector (skipDeploys, so it lands on the next deploy). No content is stored yet, so the key can still be rotated freely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c9f25ef commit 2cea8fa

3 files changed

Lines changed: 216 additions & 12 deletions

File tree

services/qyl.collector/Storage/DuckDbStore.Workflow.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ public Task<WorkflowAppendResult> AppendWorkflowEventsAsync(
115115
foreach (var item in content)
116116
InsertWorkflowContent(con, transaction, projectId, _workflowContentProtector.Protect(item));
117117

118+
var capturedInThisBatch = content
119+
.Select(static item => item.ContentRef)
120+
.ToHashSet(StringComparer.Ordinal);
121+
118122
var accepted = 0;
119123
var duplicates = 0;
120124
ulong? firstJournalSequence = null;
@@ -168,6 +172,8 @@ public Task<WorkflowAppendResult> AppendWorkflowEventsAsync(
168172
con,
169173
transaction,
170174
projectId,
175+
runId,
176+
capturedInThisBatch,
171177
workflowEvent.ContentRefs);
172178
latest++;
173179
InsertWorkflowEvent(
@@ -757,25 +763,38 @@ FROM workflow_events
757763
return reader.Read() ? ReadWorkflowEvent(reader) : null;
758764
}
759765

766+
/// <summary>
767+
/// A run may reference content it captured in this batch, or content it has already
768+
/// referenced. It may NOT reference content merely because some other run in the project
769+
/// captured it: <c>workflow_content</c> is deduplicated per project by digest, so a
770+
/// project-scoped existence check let run A mint a reference row for run B's payload, and
771+
/// <see cref="GetWorkflowContentAsync"/> then served it — it gates on the reference row
772+
/// existing for the asking run, which A had just created.
773+
/// </summary>
760774
private static void EnsureContentReferencesExist(
761775
DuckDBConnection con,
762776
DbTransaction transaction,
763777
string projectId,
778+
string runId,
779+
IReadOnlySet<string> capturedInThisBatch,
764780
IReadOnlyList<string> contentRefs)
765781
{
766782
foreach (var contentRef in contentRefs)
767783
{
784+
if (capturedInThisBatch.Contains(contentRef))
785+
continue;
786+
768787
using var command = con.CreateCommand();
769788
command.Transaction = transaction;
770789
command.CommandText = """
771790
SELECT count(*)
772-
FROM workflow_content
773-
WHERE project_id = $1 AND content_ref = $2
791+
FROM workflow_content_refs
792+
WHERE project_id = $1 AND run_id = $2 AND content_ref = $3
774793
""";
775-
AddParameters(command, projectId, contentRef);
794+
AddParameters(command, projectId, runId, contentRef);
776795
if (Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture) is 0)
777796
throw new WorkflowEventConflictException(
778-
$"Workflow event references content '{contentRef}' that has not been captured.");
797+
$"Workflow event references content '{contentRef}' that this run has not captured.");
779798
}
780799
}
781800

services/qyl.collector/Workflow/WorkflowContentProtector.cs

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,58 @@ public static WorkflowContentProtector FromConfiguration(
4040
return new WorkflowContentProtector(key);
4141
}
4242

43-
if (configuration["QYL_OTLP_PRIMARY_API_KEY"] is { Length: > 0 } apiKey)
44-
{
45-
return new WorkflowContentProtector(
46-
SHA256.HashData(Encoding.UTF8.GetBytes($"qyl.workflow.content.v1\0{apiKey}")));
47-
}
48-
4943
if (environment.IsDevelopment() || environment.IsEnvironment("Testing"))
5044
{
5145
return new WorkflowContentProtector(
5246
SHA256.HashData(Encoding.UTF8.GetBytes("qyl-development-workflow-content-key")));
5347
}
5448

49+
// Deriving this key from QYL_OTLP_PRIMARY_API_KEY used to be the production fallback.
50+
// That silently bound the lifetime of stored content to a ROTATABLE ingest credential:
51+
// rotating the OTLP key left every previously captured payload undecryptable, as an
52+
// AES-GCM tag mismatch at read time rather than anything that looks like a key problem.
53+
// Content encryption needs a key with its own rotation story, so refuse to start rather
54+
// than accept one that is guaranteed to be rotated out from under the data.
5555
throw new InvalidOperationException(
56-
"Workflow content capture requires QYL_WORKFLOW_CONTENT_KEY or QYL_OTLP_PRIMARY_API_KEY.");
56+
"Workflow content capture requires QYL_WORKFLOW_CONTENT_KEY (base64-encoded 32 bytes). " +
57+
"It must not be derived from the OTLP ingest key: that key rotates, and rotating it " +
58+
"would permanently destroy access to all previously captured workflow content.");
59+
}
60+
61+
private const string ContentRefPrefix = "sha256:";
62+
private const int ContentRefLength = 71; // "sha256:" + 64 lowercase hex characters.
63+
64+
/// <summary>
65+
/// The <c>^sha256:[a-f0-9]{64}$</c> pattern on WorkflowContentRef is an OpenAPI constraint;
66+
/// the generated contract carries no runtime validation attribute, so an untrusted observer
67+
/// can post any string. Without this guard a ref shorter than the prefix threw
68+
/// ArgumentOutOfRangeException out of the slice below and surfaced as a 500 from the append
69+
/// endpoint instead of a rejected request.
70+
/// </summary>
71+
internal static void RequireWellFormedContentRef(string contentRef)
72+
{
73+
if (contentRef.Length != ContentRefLength ||
74+
!contentRef.StartsWith(ContentRefPrefix, StringComparison.Ordinal))
75+
{
76+
throw new InvalidDataException(
77+
$"Captured content reference '{contentRef}' is not a well-formed 'sha256:' digest.");
78+
}
79+
80+
foreach (var character in contentRef.AsSpan(ContentRefPrefix.Length))
81+
{
82+
if (character is not (>= '0' and <= '9') and not (>= 'a' and <= 'f'))
83+
{
84+
throw new InvalidDataException(
85+
$"Captured content reference '{contentRef}' is not lowercase hexadecimal.");
86+
}
87+
}
5788
}
5889

5990
public WorkflowContentStorageRow Protect(WorkflowContentWrite content)
6091
{
92+
RequireWellFormedContentRef(content.ContentRef);
6193
var plaintext = Decode(content);
62-
var expected = content.ContentRef["sha256:".Length..];
94+
var expected = content.ContentRef[ContentRefPrefix.Length..];
6395
var actual = Convert.ToHexStringLower(SHA256.HashData(plaintext));
6496
if (!CryptographicOperations.FixedTimeEquals(
6597
Encoding.ASCII.GetBytes(expected),
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using Microsoft.Extensions.Configuration;
4+
using Microsoft.Extensions.FileProviders;
5+
using Microsoft.Extensions.Hosting;
6+
using Qyl.Api.Contracts.Workflow;
7+
using Qyl.Collector.Storage;
8+
using Qyl.Collector.Workflow;
9+
10+
namespace Qyl.Collector.Tests;
11+
12+
/// <summary>
13+
/// Captured workflow content is agent output — tool results, file contents, messages. These
14+
/// assert the three properties that decide whether storing it is safe: the key outlives the
15+
/// data, a run cannot read another run's payload, and a malformed reference is rejected rather
16+
/// than crashing the ingest path.
17+
/// </summary>
18+
public sealed class WorkflowContentSecurityTests
19+
{
20+
private static readonly DateTimeOffset s_startedAt =
21+
new(2026, 7, 28, 8, 0, 0, TimeSpan.Zero);
22+
23+
/// <summary>
24+
/// The production fallback used to derive the content key from QYL_OTLP_PRIMARY_API_KEY.
25+
/// That key rotates; the data it encrypts does not get re-encrypted, so a routine ingest-key
26+
/// rotation silently destroyed every previously captured payload — surfacing later as an
27+
/// AES-GCM tag mismatch, which reads like corruption rather than a key-management mistake.
28+
/// Refusing to boot is the correct trade: a collector that will not start is recoverable.
29+
/// </summary>
30+
[Fact]
31+
public void Content_key_is_never_derived_from_the_rotatable_ingest_key()
32+
{
33+
var configuration = new ConfigurationBuilder()
34+
.AddInMemoryCollection(new Dictionary<string, string?>
35+
{
36+
["QYL_OTLP_PRIMARY_API_KEY"] = "an-ingest-key-that-will-be-rotated",
37+
})
38+
.Build();
39+
40+
var failure = Assert.Throws<InvalidOperationException>(
41+
() => WorkflowContentProtector.FromConfiguration(configuration, new ProductionEnvironment()));
42+
43+
Assert.Contains("QYL_WORKFLOW_CONTENT_KEY", failure.Message, StringComparison.Ordinal);
44+
}
45+
46+
/// <summary>An explicit, independently rotatable key is still accepted.</summary>
47+
[Fact]
48+
public void An_explicit_content_key_is_accepted_in_production()
49+
{
50+
var configuration = new ConfigurationBuilder()
51+
.AddInMemoryCollection(new Dictionary<string, string?>
52+
{
53+
["QYL_WORKFLOW_CONTENT_KEY"] = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
54+
})
55+
.Build();
56+
57+
Assert.NotNull(WorkflowContentProtector.FromConfiguration(configuration, new ProductionEnvironment()));
58+
}
59+
60+
/// <summary>
61+
/// workflow_content is deduplicated per project by digest, so the existence check used to
62+
/// pass for any content captured by ANY run in the project. Because GetWorkflowContentAsync
63+
/// authorises on the reference row existing for the asking run, minting that row was the
64+
/// whole exploit: reference another run's digest, then read its payload back as your own.
65+
/// </summary>
66+
[Fact]
67+
public async Task A_run_cannot_reference_content_captured_by_another_run()
68+
{
69+
await using var store = new DuckDbStore(":memory:");
70+
await CreateRunAsync(store, "run-victim");
71+
await CreateRunAsync(store, "run-attacker");
72+
73+
const string secret = "AWS_SECRET_ACCESS_KEY=not-actually-a-real-key";
74+
var secretRef = ContentRef(secret);
75+
76+
// The victim run captures the payload and references it legitimately.
77+
await store.AppendWorkflowEventsAsync(
78+
"project-a", "run-victim", "observer-1",
79+
[Event("victim-1", 1, WorkflowJournalEventKind.AttemptStarted, "attempt-1", [secretRef])],
80+
[new WorkflowContentWrite(secretRef, "text/plain", WorkflowContentEncoding.Utf8, secret)],
81+
TestContext.Current.CancellationToken);
82+
83+
// The attacker run knows only the digest and captures nothing.
84+
var reach = await Assert.ThrowsAsync<WorkflowEventConflictException>(() =>
85+
store.AppendWorkflowEventsAsync(
86+
"project-a", "run-attacker", "observer-2",
87+
[Event("attacker-1", 1, WorkflowJournalEventKind.AttemptStarted, "attempt-1", [secretRef])],
88+
[],
89+
TestContext.Current.CancellationToken));
90+
Assert.Contains("has not captured", reach.Message, StringComparison.Ordinal);
91+
92+
// And the payload stays unreadable through the attacker's run scope.
93+
Assert.Null(await store.GetWorkflowContentAsync(
94+
"project-a", "run-attacker", secretRef, TestContext.Current.CancellationToken));
95+
Assert.NotNull(await store.GetWorkflowContentAsync(
96+
"project-a", "run-victim", secretRef, TestContext.Current.CancellationToken));
97+
}
98+
99+
/// <summary>
100+
/// The ^sha256:[a-f0-9]{64}$ pattern is an OpenAPI constraint with no runtime enforcement in
101+
/// the generated contract, so an observer can post anything. A ref shorter than the prefix
102+
/// threw ArgumentOutOfRangeException out of the digest slice — a 500 on attacker-controlled
103+
/// input rather than a rejected request.
104+
/// </summary>
105+
[Theory]
106+
[InlineData("sha")]
107+
[InlineData("")]
108+
[InlineData("sha256:")]
109+
[InlineData("sha256:tooshort")]
110+
[InlineData("md5:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")]
111+
[InlineData("sha256:0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef")]
112+
public async Task A_malformed_content_reference_is_rejected_not_crashed_on(string contentRef)
113+
{
114+
await using var store = new DuckDbStore(":memory:");
115+
await CreateRunAsync(store, "run-1");
116+
117+
await Assert.ThrowsAsync<InvalidDataException>(() =>
118+
store.AppendWorkflowEventsAsync(
119+
"project-a", "run-1", "observer-1",
120+
[Event("e1", 1, WorkflowJournalEventKind.AttemptStarted, "attempt-1", [])],
121+
[new WorkflowContentWrite(contentRef, "text/plain", WorkflowContentEncoding.Utf8, "payload")],
122+
TestContext.Current.CancellationToken));
123+
}
124+
125+
private sealed class ProductionEnvironment : IHostEnvironment
126+
{
127+
public string EnvironmentName { get; set; } = Environments.Production;
128+
public string ApplicationName { get; set; } = "qyl.collector";
129+
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
130+
public IFileProvider ContentRootFileProvider { get; set; } =
131+
new NullFileProvider();
132+
}
133+
134+
private static string ContentRef(string plaintext) =>
135+
$"sha256:{Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(plaintext)))}";
136+
137+
private static Task<WorkflowRunStorageRow> CreateRunAsync(DuckDbStore store, string runId) =>
138+
store.CreateWorkflowRunAsync(
139+
new WorkflowRunStorageRow(
140+
"project-a", runId, "thread-1", "Content security fixture",
141+
WorkflowRunStatus.Active, s_startedAt, null, 0, null, null),
142+
TestContext.Current.CancellationToken);
143+
144+
private static WorkflowEventWrite Event(
145+
string eventId,
146+
ulong sourceSequence,
147+
WorkflowJournalEventKind kind,
148+
string attemptId,
149+
IReadOnlyList<string> contentRefs) =>
150+
new(
151+
eventId, sourceSequence, s_startedAt, kind, "thread-1", null, attemptId,
152+
null, null, null, null, contentRefs, null);
153+
}

0 commit comments

Comments
 (0)