Jakapil.Capture.Contracts
2.0.0
dotnet add package Jakapil.Capture.Contracts --version 2.0.0
NuGet\Install-Package Jakapil.Capture.Contracts -Version 2.0.0
<PackageReference Include="Jakapil.Capture.Contracts" Version="2.0.0" />
<PackageVersion Include="Jakapil.Capture.Contracts" Version="2.0.0" />
<PackageReference Include="Jakapil.Capture.Contracts" />
paket add Jakapil.Capture.Contracts --version 2.0.0
#r "nuget: Jakapil.Capture.Contracts, 2.0.0"
#:package Jakapil.Capture.Contracts@2.0.0
#addin nuget:?package=Jakapil.Capture.Contracts&version=2.0.0
#tool nuget:?package=Jakapil.Capture.Contracts&version=2.0.0
Jakapil.Capture
Jakapil Capture is an ASP.NET Core middleware SDK that captures HTTP traffic from running .NET APIs. You add it to your application as a single line of middleware; it captures incoming requests, outgoing responses, and correlation signals in the background — without blocking the request path — and exports them to a Jakapil collector.
The captured traffic is turned into self-verifying test scenarios on the Jakapil side. The middleware is designed to be safe under production load: the response always streams to the client without buffering, the capture copy is bounded, and the queue drops the oldest entries under backpressure (it never blocks the request pipeline).
Installation
dotnet add package Jakapil.Capture
Usage
Register the service and add the middleware to the pipeline in Program.cs:
builder.Services.AddJakapilCapture(
ingestKey: builder.Configuration["Jakapil:Capture:IngestKey"]!,
collectorUri: "https://collector.jakapil.example");
// ...
app.UseJakapilCapture();
ingestKey is your ingest key and is sent to the collector verbatim in the X-Jakapil-Key
header — so it must be supplied only through an environment variable / secret and never
hard-coded. collectorUri is the collector address where captured interactions are sent.
If you need finer control, you can use the overload that takes an Action<JakapilCaptureOptions>:
builder.Services.AddJakapilCapture(options =>
{
options.IngestKey = builder.Configuration["Jakapil:Capture:IngestKey"]!;
options.CollectorUri = "https://collector.jakapil.example";
options.SampleRate = 0.25;
});
The ingest key
An ingest key is issued per environment from the Jakapil UI and has a fixed shape:
jk_<16 uppercase-hex scope ref>_<64 uppercase-hex secret>
The key is always used whole: it goes into the X-Jakapil-Key header verbatim, exactly as
before. The SDK never splits it on the wire. What it does do is read the two halves apart locally,
because they mean different things:
<secret>— the half the collector authenticates you with. Treat the key as a secret: supply it through an environment variable or a secret store, never hard-coded in source orappsettings.json.<scope ref>— a stable, non-secret label of the environment the key was issued for. The collector already knows it (it stores it next to the environment) and this SDK uses it as the domain-separation scope under which every anonymization digest is derived, then reports it back inCapturedInteraction.Anon.ScopeRefso the collector can tell which scope a payload belongs to.
A key that does not match this shape is rejected at startup (see "Anonymization is required by default" below for what a startup failure looks like) rather than being silently accepted:
The ingest key (IngestKey) is malformed: it must have the shape
jk_<16 uppercase-hex scope ref>_<64 uppercase-hex secret>. Keys issued before this format are no
longer valid — regenerate the key for this environment in the Jakapil UI and update the
configuration.
Rotating the ingest key vs. rotating the anonymization key
These two rotations have opposite consequences, and the difference is worth internalizing before you rotate either one:
- Rotating the ingest key does not break correlation. The scope ref is a property of the environment, not of the key material, so a freshly generated key for the same environment carries the same scope ref. Every digest keeps coming out the same, and the corpus you already captured stays correlatable against everything you capture afterwards. Rotate an ingest key as often as your secret-handling policy demands.
- Rotating the anonymization key (
JAKAPIL_ANON_KEY) does break correlation, by design. That key is the HMAC key itself, so every fingerprint and every synthetic value changes. Nothing captured under the new key correlates with anything captured under the old one, and scenarios generated from the older corpus have to be regenerated. BumpAnonymization.KeyVersionwhen you do it: the version travels in every envelope and is what marks the rotation boundary, so the two key spaces are never mistaken for one.
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
Enabled |
bool |
true |
Main on/off switch. When false, the middleware is a pure passthrough. |
SampleRate |
double |
1.0 |
The fraction of requests to capture, [0, 1]. 1.0 = capture everything. |
CollectorUri |
string? |
null |
The root address of the collector where captured interactions are sent. If left empty, the export worker stays idle. |
IngestKey |
string? |
null |
The raw ingest key, sent to the collector whole in the X-Jakapil-Key header. Must have the jk_<scope ref>_<secret> shape above; a malformed key fails startup. |
MaxInlineBodyBytes |
int |
1048576 (1 MiB) |
Bodies at or below this size are captured inline; larger ones are truncated and flagged as Truncated. |
Anonymization.RequireAnonymization |
bool |
true |
When true, starting the host without the anonymization key configured is a startup failure instead of a silent downgrade to plaintext capture. See "Anonymization is required by default" below. |
Anonymization.KeyEnvironmentVariable |
string |
"JAKAPIL_ANON_KEY" |
The name of the environment variable the HMAC anonymization key is read from. Only the name is configured here — the key value itself never belongs in this options object, so it can never be logged, serialized, or committed to a config file. |
Anonymization.KeyVersion |
int |
1 |
The key version tag written into every fingerprint envelope. A rotation boundary: two versions are separate, non-correlating digest spaces. Must be non-negative. |
Anonymization.SyntheticEmailDomain |
string |
"example.com" |
The domain used for synthesized email addresses. The real domain is never preserved; point this at your own staging/test domain if you prefer. |
Anonymization.FieldPolicy |
IDictionary<string, FieldClass> |
empty | Per-field classification overrides, matched case-insensitively. Always wins over every built-in rule. |
Other settings (MaxCapturedResponseBytes, StreamingContentTypes, QueueCapacity,
SensitiveHeaderNames, CorrelationHeaderNames, ExportBatchMaxItems,
ExportFlushIntervalSeconds) ship with sensible defaults; see the JakapilCaptureOptions and
AnonymizationOptions XML documentation comments for details.
The anonymization scope is not configurable: it comes from the ingest key's scope ref (see above), so it is always the real, server-known identity of the target environment and cannot be left blank by accident.
Identity capture from multi-identity principals
HttpContext.User commonly carries more than one ClaimsIdentity — for example an ASP.NET Core
Identity cookie identity alongside a JWT bearer identity added by a second authentication handler on
the same request. CaptureBuilder selects exactly one identity as authoritative for
IdentityInfo.AuthenticationScheme, IdentityInfo.UserName, and IdentityInfo.SubjectId: the first
identity for which ClaimsIdentity.IsAuthenticated is true, falling back to the principal's primary
identity (ClaimsPrincipal.Identity) when none is authenticated.
This is a documented limitation, not a claim that the first authenticated identity is the one that
actually authorised the request. HttpContext alone does not reveal which of several authenticated
identities did, and inferring it from the raw Authorization header would couple identity capture to
transport details this SDK deliberately avoids. What the selection guarantees is that the three
fields above always describe the same identity, instead of being silently mixed across two.
Behavior change. SubjectId used to be resolved with principal.FindFirst(...), which searches
every identity on the principal. It is now resolved only against the selected identity. If a
principal's selected identity carries no NameIdentifier/sub claim while a different identity on
the same principal does, SubjectId goes from populated to null compared to earlier versions. This
is intentional — a subject id that belongs to a different identity than the reported scheme and user
name is worse than no subject id at all — but it is an observable change for consumers upgrading the
package.
IdentityInfo.Claims is unaffected by the selection: it stays merged across every identity on the
principal, so a role claim relevant to matching is never lost even when it lives on an identity other
than the one selected as authoritative.
Multi-valued claims. IdentityInfo.Claims holds one value per claim type, so a principal with
several claims of the same type — the normal shape for a multi-role user, e.g. two separate role
claims — only keeps the last one there. IdentityInfo.MultiValuedClaims carries the full set of
values for every claim type that has more than one, in encounter order; it is null when no claim
type is duplicated, so the common single-valued case reads Claims as before and pays no extra cost.
Field anonymization
With an anonymization key configured (read from JAKAPIL_ANON_KEY by default), every captured
request/response leaf is classified and transformed before it leaves your process — no raw production
value (name, email, password, flow identifier, ...) is ever sent to the collector. See
Jakapil.Capture.Anonymization.FieldClass for the four possible outcomes (SafeLiteral,
FlowFingerprint, SyntheticPii, SecretTombstone) and AnonymizationOptions.FieldPolicy for the
customer override that always wins over the built-in rules below.
Fingerprints and synthetic values are derived from the anonymization key and the scope ref taken from your ingest key:
digest = HMAC-SHA256(key, scopeRef \0 semanticKind \0 value)[0..16]
The scope ref is what keeps two environments — or two customers — from ever producing the same digest for the same raw value. Because it is issued by the server rather than configured locally, it cannot be left blank, and it cannot accidentally differ between two deployments of the same environment.
Anonymization is required by default (fail-closed)
Anonymization.RequireAnonymization defaults to true. With capture enabled (Enabled = true) and
that default in place, the host does not start unless the anonymization key environment variable
is set. Options validation fails, ValidateOnStart turns the failure into an
OptionsValidationException, and you get this message:
Anonymization is required (Anonymization.RequireAnonymization=true) but the environment variable
'JAKAPIL_ANON_KEY' is not set. Without that key, capture sends PLAINTEXT production request and
response data to the collector. Set 'JAKAPIL_ANON_KEY' to the anonymization key, or opt out
deliberately with Anonymization.RequireAnonymization = false.
Why the default is true. Without a key, the SDK falls back to pass-through mode: raw production
request and response bodies, identity fields and all, go to the collector in the clear. Earlier
versions did exactly that on a missing key, announcing it with nothing louder than a startup log
warning — which, on a busy service, is scrolled past in seconds and then lives on undetected for
months. A misconfiguration whose worst case is "production data has been leaving the building" is not
one a warning is allowed to carry; it has to stop the process.
Opting out is deliberate and explicit. A local sandbox with no real data in it is a legitimate reason to run without a key — you just have to say so:
options.Anonymization.RequireAnonymization = false; // no real data in this environment
With it set to false, the previous behavior returns unchanged: a missing key logs a pass-through
warning and capture continues in plaintext. The check is also skipped entirely when capture itself is
off (Enabled = false), since nothing is being sent.
Identity, correlation, and auth-binding fields are anonymized too
Earlier releases of this SDK only ran the classifier above over the Request/Response body,
route/query parameters, and the allowlisted headers. The identity and correlation data every
captured interaction also carries — Identity (SubjectId, UserName, every claim), Correlation
(SubjectId, SessionCookieId, ClientConnectionId, CustomCorrelationHeader), and
AuthBinding.SubjectId — passed through untouched. With a key configured, a JWT sub, a username,
and every claim your identity provider issues went to the collector as plaintext even though the
request/response body was fully anonymized alongside it.
Anonymizer.Anonymize now covers those three blocks too, using the exact same one-way HMAC
fingerprint as the rest of this SDK — same key, same scope ref. The three copies of a subject id that
appear across Identity, Correlation, and AuthBinding are guaranteed to fingerprint to the
identical value, so correlating a user across all three survives anonymization. The scheme
identifier reported in CapturedInteraction.Anon changed to reflect the wider coverage — this is a
purely declarative marker (the collector never compares it against anything) whose only purpose is
telling the cloud side "this payload's identity fields are covered too".
Role claims are the one deliberate exception — a claim named role, or the ASP.NET Core role
claim URI, is still sent as plaintext. A role (Administrators, Support) is an authorization
category, not something that identifies a person, and the set of roles in a typical application is
small enough that a fingerprint over it would be reversible by dictionary attack anyway — hiding it
buys little privacy while destroying a signal a role-aware test-user matching feature would need
down the line. Every other claim your identity provider issues — including ones this SDK doesn't
recognize — is fingerprinted; an unrecognized claim type is treated as potentially identifying, never
assumed safe. IdentityInfo.MultiValuedClaims (a multi-role user's extra role values, or any other
claim type repeated more than once) goes through the exact same rule: role values stay plaintext,
everything else is fingerprinted with the same key and scope as its Claims counterpart.
With no anonymization key configured, none of this runs: the whole interaction — body, route/query,
and these identity/correlation fields too — passes through unchanged, with a startup warning about
pass-through mode. Reaching that state now takes a deliberate
Anonymization.RequireAnonymization = false; with the default in place the host refuses to start
instead (see "Anonymization is required by default" above). Pass-through is also what you get if no
well-formed ingest key could be parsed, since the scope ref every digest is derived under comes from
it — but that, too, is caught at startup before any traffic is captured.
Generic name is context-sensitive (since v1.2.0)
Strong-signal person-name fields (fullName, firstName, lastName, surname, ...) are always
synthesized, in every context. The bare, generic field name, however, is ambiguous on its own — a
GET /api/catalog-types response with { "catalogTypes": [ { "name": "Mug" }, { "name": "T-Shirt" } ] }
is not personal data, but earlier versions treated it exactly like fullName and rewrote every category
label to a synthetic person name (e.g. "Sage Blake"). Scenarios built from that capture then asserted
the wrong literal on every replay against the live API — a guaranteed, reproducible false regression, not
a real product bug.
As of v1.2.0, a bare name field is only classified as PII (SyntheticPii) when the JSON object that
directly contains it has a field name that itself suggests a person or contact record — e.g.
$.customer.name, $.billingAddress.name. With no enclosing object (a root-level name) or a
non-person parent ($.catalogTypes[].name, $.products[].name), the value passes through unchanged as
SafeLiteral. Plural collection endpoints are recognized too — $.users[].name,
$.customers[].name, $.employees[].name all still resolve to SyntheticPii (a simple, deterministic
singularization is tried against the person-context word list — no inflection library, no IO) — this
matters because a plural collection is the single most common REST shape a person record appears in.
Privacy trade-off. This intentionally loosens the default: false positives (real, non-personal labels
rewritten as fake names) become rarer, but so does the safety margin — a field literally called name
that does hold a person's name, under a parent object this heuristic does not recognize (an unusual or
customer-specific schema), will now be sent as plaintext instead of being synthesized. The singularization
step is deliberately biased toward over-matching, not under-matching: a made-up plural that happens to
singularize into a listed word gets anonymized unnecessarily (recoverable via FieldPolicy), which is
preferred over the alternative of missing a real one (unrecoverable — the value is already off your
machine). If your API uses bare name for personal data in a context not covered by the built-in list
(user, customer, person, contact, member, employee, account, buyer, seller, recipient,
sender, owner, author, patient, client, guest, subscriber, profile, billing, shipping,
address — singular or plural), set it explicitly via FieldPolicy:
options.Anonymization.FieldPolicy["name"] = FieldClass.SyntheticPii;
FieldPolicy always takes priority over every built-in rule, including this one.
Type-preserving replacement values (since v1.2.0)
Whatever a leaf's JSON type was, its replacement keeps that type — a JSON number is replaced by a
synthetic number (never a quoted string), a JSON boolean is never replaced at all (booleans are
never ambiguous), and a JSON string is replaced by a synthetic string. Classification is unaffected
by this guarantee — an unrecognized field still fails safe to SyntheticPii exactly as documented above;
only the shape of the replacement value changes to match what it is standing in for. Two concrete
consequences:
- A numeric field with no recognized name (e.g. a custom
batchCountquery parameter) used to synthesize to alphanumeric text (synthetic-77598c5712fd), which many APIs' model binders reject outright (400 Bad Request) — breaking replay while still hiding nothing useful. It now synthesizes to numeric text instead (still non-reversible and non-correlatable outside its key/scope), so the request stays valid. Route, query, and header values are always text on the wire (there is no JSON container type at those positions), so "type" there means the replacement must still parse as the original kind — a numeric query parameter synthesizes to digits, atrue/false-shaped one synthesizes totrue/false. - A JSON body number classified
SyntheticPii(via an explicitFieldPolicyoverride — the built-in fail-safe never routes a bare number there on its own) is written back as an unquoted JSON number, not a quoted string, and stays integral when the original had no fractional part. The synthetic magnitude is always bounded well within a 32-bit integer's range, regardless of how large the original value was.
This does not change how FlowFingerprint (fp:...) and SecretTombstone (jkp:tomb:...)
envelopes are written for a JSON body leaf — those remain textual by design, and the envelope grammar
itself already records the original JSON type as an embedded tag (fp:n:..., jkp:tomb:n:...) for the
server side to use. The one passthrough exception is described next.
Pagination/counter query, route, and header values pass through unchanged (since v1.3.1)
page and limit have always been SafeLiteral (see the built-in allowlist above), but a request like
GET /api/catalog-items?PageSize=10&PageIndex=0 used to have its PageSize/PageIndex values
anonymized into a different number (e.g. ?PageSize=35&PageIndex=1) — neither name was recognized,
so both fell to the unknown-field fail-safe (SyntheticPii). Type-preservation (above) kept the
replacement numeric so the target's model binder still accepted it, but the value itself changed,
which silently corrupts the request's meaning: a scenario built from that capture keeps asking for page
35 instead of page 0/10, and a size-dependent assertion (e.g. "response has 10 items") fails forever
against a live re-run, even though nothing about the target actually broke.
As of v1.3.1, these English pagination/counter names are recognized as SafeLiteral only when they
appear as a route, query-string, or header value — never in a JSON body — and only when the raw text
still looks like the number it claims to be:
pageSize, pageIndex, pageNumber, perPage, offset, skip, take, top (plus the
pre-existing page/limit) — matched case-insensitively and independent of separators, so
page_size, page[size], $top, and _limit all resolve to the same recognized name.
Two things deliberately still apply on top of a name match:
- Shape gate. The value must actually look numeric (
SyntheticPiiGenerator.DetectTransportShape). A name/value mismatch — e.g.?pageSize=ahmet@x.com— does not take this exception and is anonymized exactly as before, so a field merely named like a counter can never leak free text this way. There is no upper bound on digit count — a legitimateoffset=1000000still passes unchanged. FieldPolicyalways wins. An explicitAnonymizationOptions.FieldPolicy["pageSize"] = ...entry overrides this default exactly like every other built-in rule.
Non-English pagination names are not covered. A parameter named sayfaBoyutu or kayitSayisi
normalizes to a token this list does not contain, so it still falls to the unknown-field fail-safe
(shape-preserved synthesis, not plaintext passthrough). If your API uses non-English (or otherwise
unlisted) pagination/counter names and you want them to pass through unchanged too, the escape hatch is
the same FieldPolicy override:
options.Anonymization.FieldPolicy["sayfaBoyutu"] = FieldClass.SafeLiteral;
Also deliberately not included: sort, order, orderBy, direction, asc, desc. No concrete
failure has been reported for these, and order in particular can hold a business reference (e.g.
"ORD-2024-000123") rather than a sort direction in some APIs — widen this list only for a documented
case, via FieldPolicy, not speculatively.
Signed replay-request verification (since v1.2.0)
When the Jakapil Runner re-runs a scenario against your API (a test run), it can attach a cryptographically
signed X-Jakapil-Replay header to each request. When this SDK verifies that signature, two things happen to
that request that never happen for ordinary traffic:
- It is never captured/exported. Replay traffic would otherwise pollute your captured corpus with its own
test data, so a verified replay request is unconditionally excluded from capture — independent of
SampleRateor evenEnabled. - The response body is masked on the way out, using the exact same anonymization key, scope ref, and field
classification as ordinary capture-side anonymization (see "Field anonymization" above) — so your API's
live production-shaped response ("Mug") comes back anonymized ("Sage Blake") exactly like the captured
corpus value it is being compared against, letting equality-style assertions built from anonymized capture
data pass against a live re-run without ever exposing what your API actually returned. One exception: fields
correlating one response to the next request in the same scenario (order id, created resource id, ...) are
left as their live value, because the Runner needs the real identifier your API produced to keep chaining
requests correctly — masking those would break the scenario, and they carry no personal content of their own.
This live value is written back with its original JSON type intact (since v1.2.0) — a numeric id stays
a JSON number, never a quoted string — so a scenario's schema assertion (
{ id: Number }) still passes against the masked replay response exactly as it would against the real one.
Run-issued credentials pass through live too (since v1.2.0)
The same problem that justifies the FlowFingerprint exception above also applies to a login/session response: a
token field used to come back as a jkp:tomb:s:token marker, which the Runner cannot inject into the next
step's Authorization header — no authenticated scenario could ever be replayed. As of v1.2.0, a field whose
name's semantic kind is token, session, cookie, csrf, or cursor — matched on the TRAILING
camelCase/snake_case word, e.g. token, authToken, sessionToken, csrfToken, nextCursor all match
(sessionId does not — its trailing word is id, already covered by the pre-existing FlowFingerprint
passthrough) — also passes through live, for the identical reason FlowFingerprint does: the target produced
it during this run, and the Runner needs the real value to keep the scenario going. This pre-empts whatever
classification the field would otherwise have received — most commonly SecretTombstone, since token/cookie
are also secret field names.
Two response headers get the same treatment: Set-Cookie and Location — the two places a run-issued
session identifier or a newly created resource's address are most likely to appear outside the body.
password never passes through, by construction — it is not, and never will be, one of the five allowlisted
words, so an echoed password field stays tombstoned exactly as before. A compound name like
passwordResetToken does pass through (its trailing word is token) — that field holds a token the target
issued during the run, not the password value itself, which is the correct call under the same rule.
FieldPolicy is still the escape hatch and always wins: an explicit override for a field or header name (e.g.
options.Anonymization.FieldPolicy["token"] = FieldClass.SecretTombstone;) is honored instead of the
RunCredential default, for both JSON leaves and the two headers.
Idempotent replay masking (since v1.2.0)
Masking a replay response used to be a one-way street: if the Jakapil Runner sent a synthetic value from its corpus (say, a synthesized name) and your API validated and echoed it straight back, this SDK — seeing that echoed text as if it were fresh production data — synthesized it again, seeded off the synthetic text itself rather than the original raw value, producing a second, different synthetic value. Every echo-equality assertion built from the corpus then failed structurally, even though your API's behavior never changed.
As of v1.2.0, replay masking recognizes two shapes of "already anonymized" data and passes them through byte-identical instead of re-masking them:
- A well-formed
fp:/jkp:tomb:envelope, regardless of which field it currently sits under (an envelope echoed back under a different field name than the one that produced it is still recognized by its own shape). - A value that already matches the exact deterministic output format of this SDK's own synthetic generators (email/phone/name/free-text/generic) — checked only against the ONE generator the field's own name would select, never a blanket "looks synthetic" scan, to keep false positives bounded.
This recognition only ever runs in the replay masking path — capture-side anonymization is completely
unaffected, and a genuine production value is still synthesized exactly as before. It is deliberately
conservative: numeric and boolean synthetic values are never recognized this way (there is no reliable way
to distinguish a synthetic number from a real one by format alone), so an echoed numeric/boolean SyntheticPii
field can still re-synthesize to a different value on replay — a narrower, documented gap rather than a silent
one, and the rarer fallback-only case in practice.
The masking-confirmation header, in full (since v1.2.0)
X-Jakapil-Masked: v1;scheme=<scheme>;keyVersion=<n>[;body=unmasked-nonjson][;live=<path>(,<path>)*][;liveHeaders=<name>(,<name>)*]
scheme/keyVersion are unchanged from before v1.2.0. live= and liveHeaders= arrived in v1.2.0; body=
in v1.2.1. All three are appended only when there is something to declare — an ordinary JSON response with no
RunCredential leaves produces the exact same header as before:
body=(since v1.2.1) — declares that the response body could not be masked and was forwarded unchanged. The only value this SDK version ever emits isunmasked-nonjson: the response'sContent-Typewas not JSON, so there is no safe, general way to anonymize it — exactly the same reasoning capture-time anonymization already applies to a non-JSON request/response body (ADR-0002's field classification is defined over named JSON leaves, not arbitrary text/binary blobs; capture keeps the body as-is for the same reason). This field is omitted on the ordinary JSON-masked path rather than always being emitted with an explicitbody=maskedcounterpart — that keeps the header byte-for-byte identical, for every case that already worked, to what it was before this field existed, and matches the receiver's documented default: absentbody=means masked. A malformed JSON body (Content-Typesays JSON, but the bytes fail to parse) and a truncated body (the response exceededReplay.MaxMaskedResponseBytes) are both different from this case and do not get this field, or any header at all — see below.live=(since v1.2.0) — a comma-separated list of JSONPaths (root-relative,$) naming every response-body leaf that passed through live via the RunCredential mechanism above. Object property access is.name; an array contributes a single[*]wildcard covering every element, not one entry per index. A property name that isn't a simple[A-Za-z_][A-Za-z0-9_]*identifier is percent-encoded (Uri.EscapeDataString, plus an explicit.→%2Esubstitution for the one delimiter that function leaves unescaped) so it can never collide with the./,/;grammar delimiters — decode withUri.UnescapeDataString. FlowFingerprint passthrough leaves (the pre-existing ADR-0003 §5 behavior) are not included here; this field is scoped to the new RunCredential category only.liveHeaders=(since v1.2.0) — a comma-separated list of header names (never encoded — an HTTP header name cannot contain,/;) that passed through live; only everSet-Cookieand/orLocation.
Examples: v1;scheme=hmac-sha256-v2;keyVersion=1 (nothing to declare) ·
v1;scheme=hmac-sha256-v2;keyVersion=1;body=unmasked-nonjson (non-JSON body, forwarded unchanged) ·
v1;scheme=hmac-sha256-v2;keyVersion=1;live=$.token;liveHeaders=Set-Cookie ·
v1;scheme=hmac-sha256-v2;keyVersion=1;live=$.auth.sessionToken,$.items[*].cursor.
The header is absent — fail-closed, exactly like before v1.2.0 — for every case where the response body
could not even be classified, as opposed to being deliberately non-JSON: a malformed JSON body (Content-Type
claims JSON, parsing fails) and a body that was truncated for exceeding Replay.MaxMaskedResponseBytes (see
"Configuring it" below) both fall into this bucket. In both cases the live, unmodified bytes are still
forwarded to the caller — only the confirmation header is withheld, so the Jakapil cloud side never mistakes an
unclassifiable body for one it can safely persist.
When the header is absent, malformed, expired, replayed, or fails to verify for any reason, behavior is byte-for-byte identical to today: no masking, no capture suppression, no error response. The signature is purely a behavior switch — it never grants any extra authority over the request, which still goes through your application's own authentication/authorization exactly as if the header were not there.
Configuring it
Signed replay verification is off by default in the sense that matters: with no public key configured, there is nothing to verify against, so it costs nothing and does nothing. To turn it on, add the public key(s) the Jakapil panel gives you for this project/environment:
builder.Services.AddJakapilCapture(options =>
{
options.IngestKey = builder.Configuration["Jakapil:Capture:IngestKey"]!;
options.CollectorUri = "https://collector.jakapil.example";
options.Replay.PublicKeys = [builder.Configuration["Jakapil:Capture:ReplayPublicKey"]!];
options.Replay.ExpectedTenantId = "..."; // optional — see below
options.Replay.ExpectedEnvironmentId = "..."; // optional — see below
});
Replay.PublicKeysaccepts each key as either PEM (-----BEGIN PUBLIC KEY-----...) or a single-line base64(DER) string — both areSubjectPublicKeyInfo-encoded ECDSA P-256 keys, never a secret (only Jakapil holds the matching private key, so configuring this does not require any secret handling on your side). Multiple keys can be listed at once — this is how key rotation works without downtime: Jakapil starts signing with a new key while the old one is still listed here, and you remove the old entry once the rotation window has closed.Replay.ExpectedTenantId/ExpectedEnvironmentIdare optional extra binding checks — when set, a signature for a different tenant/environment id than configured here is treated as invalid. They are independent of the anonymization scope ref taken from your ingest key (that value feeds HMAC domain separation, a different concern with a different correctness bar — it decides every synthetic value you have ever seen, so replay identity binding deliberately gets its own, separately-optional fields instead of reusing it). Leaving them unset is not wide open (the key ring is still per-project), but it does mean a run signed for one environment could be replayed against a different instance of the same project sharing the same public key. The real protection for production is the switch below, not these fields — a production deployment should keep replay verification disabled outright rather than relying onExpectedEnvironmentIdalone.Replay.ClockSkewTolerance(default 300s),Replay.NonceCacheSize(default 10,000), andReplay.MaxMaskedResponseBytes(default 8 MiB — the hard cap on how large a live response this SDK is willing to buffer in order to mask it) all have sensible defaults; seeReplayVerificationOptions' XML documentation for details on when you would want to change them.- Set
Replay.Enabled = false(or simply never configureReplay.PublicKeys) to keep verification off in a given environment — production deployments should do this, since replay verification is designed for the staging/test targets the Jakapil Runner actually replays scenarios against (see the "privacy guarantee" section below for why a clean, non-production target matters here regardless).
Cross-implementation test vectors
The Jakapil Runner builds the ADR-0003 §6.2 canonical signature string INDEPENDENTLY of this SDK — the two
implementations never share code, only the spec. A one-character format drift between them (field order, an
escaped vs. raw route character, a missing byte in the body hash, ...) makes every signature invalid in the
field, silently, with no error surfaced anywhere except "replay behavior never activates". To catch that class
of bug before it ships, this repo publishes
tests/vectors/replay-signature-vectors.json: a golden fixture
of several representative requests (empty body, a JSON body, a body with multi-byte UTF-8 characters, a query
string with percent-encoding, an unusual HTTP method) together with their exact canonical strings, body hashes,
and — signed with a fixed, clearly-marked TEST-ONLY ECDSA key pair committed in the same file — valid
signatures over them. Any independent implementation (the Jakapil Runner's, or anyone else's) can recompute the
canonical string for each case from its recorded inputs and compare it byte-for-byte against the recorded value,
and/or verify the recorded signature against the recorded public key, to prove interoperability without needing
a live end-to-end run. ReplaySignatureVectorsTests.cs in this repo's test suite does exactly that against this
SDK's own implementation, so the fixture can never silently drift out of sync with the code that generates it.
The privacy guarantee, precisely
A signed replay response never carries your API's actual production-shaped values (personal data, free text, secrets) back to Jakapil — those are anonymized in exactly the same way, and to the same degree, as your already-anonymized captured corpus. What it does carry back are the correlation identifiers your API itself produced during that run (e.g. a newly created order's id), so Jakapil can keep chaining a multi-step scenario correctly; this is only meaningful if the target you are running replay scenarios against is a clean, isolated staging environment that never holds a copy of production data — see the "Field anonymization" section and the ADR this feature implements for the full reasoning.
Version history
Full notes for each release: https://github.com/jakapildev/jakapil-capture/releases
2.0.0
Breaking changes.
AnonymizationScopeandAnonymizationOptions.Scopeare removed. The HMAC domain-separation scope is no longer configured in your application; it is parsed out of the ingest key's scope-ref half (jk_<scope ref>_<secret>). Any code that setoptions.Anonymization.Scopeno longer compiles and should simply drop those lines. The reason is blunt: the scope was a set of free-text strings a deployment could — and routinely did — leave empty, which put every such deployment into a single shared digest domain, i.e. no separation at all. A server-issued scope ref cannot be left blank and cannot silently disagree between two deployments of the same environment.- Every digest changes. The digest input went from
tenantId \0 projectId \0 environment \0 semanticKind \0 valuetoscopeRef \0 semanticKind \0 value, so every fingerprint and every synthetic value this SDK produces is different from what earlier versions produced. Correlation with any previously captured corpus is lost, and scenarios generated from older captures must be regenerated. This is not a defect; it is the cost of the scope actually being applied where it previously was not. - A malformed ingest key now fails startup. The key must parse as
jk_<16 uppercase-hex scope ref>_<64 uppercase-hex secret>. Keys issued before this format are no longer valid — regenerate the key for the environment in the Jakapil UI. The key still goes to the collector whole and unchanged on the wire. - Anonymization is required by default (
Anonymization.RequireAnonymization, new, defaults totrue). With capture enabled and the anonymization key environment variable unset, options validation fails and the host does not start. Earlier versions shipped plaintext production traffic in that situation, announced only by a startup log warning. Opt out deliberately withAnonymization.RequireAnonymization = false— appropriate for a local sandbox holding no real data, and nowhere else.
Additions. AnonymizationInfo.ScopeRef carries the scope ref every digest in the payload was
derived under. It is optional and nullable, so the wire contract stays append-only: a collector reading
payloads from older SDKs still deserializes them, and null there means "an older SDK that derived its
digests under a locally configured scope", not an error.
Note on rotation. Rotating the ingest key does not invalidate your corpus — the scope ref belongs to the environment, not to the key material. Rotating the anonymization key does. See "Rotating the ingest key vs. rotating the anonymization key".
1.4.0
Identity fields are read from one identity, consistently. HttpContext.User often carries several
ClaimsIdentity instances (an ASP.NET Core Identity cookie identity next to a JWT bearer identity is a
common shape). AuthenticationScheme, UserName, and IsAuthenticated were read from the principal's
primary identity while SubjectId was resolved with principal.FindFirst, which searches every
identity — so the four fields could silently describe different identities. A live measurement found 876
of 905 captured interactions reporting the cookie scheme for requests the client had authenticated with
a bearer token. One selection rule now backs all of them: the first authenticated identity, falling back
to the primary identity. Observable change: SubjectId is no longer resolved across all identities,
so a principal whose selected identity carries no NameIdentifier/sub claim now reports null where
an earlier version reported another identity's subject. Claims stay merged across every identity.
Tokens are attributed to the subject the response names. When a login response mints a token, it belongs to the subject that response identifies, not to whoever happened to be signed in on the connection — a service account performing the login, or a stale session, previously took the credit. The caller's identity remains the fallback for responses carrying no recognisable subject field.
Repeated claim types are preserved. IdentityInfo.Claims maps a claim type to a single value, so a
multi-role user collapsed to one role, last writer wins — and the server matches replay test users by
the captured role set, so the identity was mis-modelled and matching picked the wrong account or skipped
the scenario. The new optional IdentityInfo.MultiValuedClaims carries every value of any claim type
that appears more than once; it stays null otherwise, so the common single-valued case is unaffected
and servers that do not know the field keep working. It goes through the same anonymization transform as
Claims — role types plaintext, everything else fingerprinted with the same semantic kind — so it is
not a second door around anonymization.
1.3.1
Identity, correlation, and auth-binding fields were never anonymized. Anonymize rewrote only the
request and response; Identity (SubjectId, UserName, every claim), CorrelationSignals
(SubjectId, SessionCookieId, ClientConnectionId, CustomCorrelationHeader) and
AuthBinding.SubjectId passed through untouched. With a key configured and the body fully anonymized
alongside it, the JWT sub, the user name, and every claim — email and full name included — still
reached the collector in plaintext and were persisted there. All three blocks now go through the same
keyed fingerprint, with role claims the one deliberate exception (an authorization category identifies
nobody, the set is small enough to be dictionary-attacked anyway, and it is the signal role-aware test
user matching depends on). The three copies of a subject id fingerprint identically, so correlating a
user across them survives. SessionCookieId, previously an unkeyed plain SHA-256 with no domain
separation, joined the keyed path. The scheme identifier moved from hmac-sha256-v1 to
hmac-sha256-v2 — the honest way to tell the collector this payload covers identity fields too; the
wire contract's shape is unchanged.
Pagination and counter parameters were corrupted by anonymization.
?PageSize=10&PageIndex=0 came back as ?PageSize=35&PageIndex=1: neither name was recognized, so both
fell to the unknown-field fail-safe. Against a 12-item catalog, page 35 of 35 is empty, so the generated
scenario failed its assertion and stayed permanently red — anonymization was changing the meaning of
the request. A transport-only exception list (pageSize, pageIndex, pageNumber, perPage, offset,
skip, take, top) now passes these through unchanged when they appear as a route, query, or header
value — never in a JSON body — and only when the value actually looks numeric, so ?pageSize=a@b.com
can never leak. Non-English names are not covered; FieldPolicy is the escape hatch. See
"Pagination/counter query, route, and header values pass through unchanged".
Also: this Version history section itself, and a correction attributing the body= confirmation field
to 1.2.1 rather than 1.2.0. There is no 1.3.0 release — it was packed to a local feed before the second
fix above landed, so the fixed content shipped under a new number rather than reusing one.
1.2.1
Masking confirmation for non-JSON response bodies. A signed-replay response whose Content-Type was not
JSON previously got no confirmation header at all, because masking never ran. The cloud side treats a
missing confirmation as "do not persist the body" — correct for privacy, but it meant such a step could never
be verified. A 409 Conflict carrying a plain-text message is one of the most common error shapes in ASP.NET
(Response.WriteAsync("…") defaults to text/plain when no content type is set), so scenarios expecting a
4xx were silently unverifiable.
The confirmation header gained an optional body= field (see "The masking-confirmation header, in full"). A
non-JSON body is now forwarded unchanged and that fact is declared honestly, rather than passing through
in silence. The field is optional and absent means masked, so 1.2.0 and earlier receivers are unaffected.
Malformed JSON and truncated bodies deliberately still withhold the header — those are unclassifiable, not merely non-JSON.
1.2.0
Signed replay-request verification with in-process response masking; context-sensitive classification of the
generic name field; type-preserving replacement values; run-issued credentials (token, session, cookie,
csrf, cursor) passing through live so multi-step scenarios keep chaining; idempotent replay masking; and
the live=/liveHeaders= declarations on the confirmation header.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Jakapil.Capture.Contracts:
| Package | Downloads |
|---|---|
|
Jakapil.Capture
ASP.NET Core middleware SDK that captures HTTP traffic from running .NET APIs. |
GitHub repositories
This package is not used by any popular GitHub repositories.