This note records how the recipient of a probe checks which Debuglet run sent it. The delivery order records the supported protocol and remaining limits. The tag algorithm itself is specified in the tag spec (tag spec v1).
Today. Each executor tags outgoing IPv4 packets with a 16-bit SipHash-2-4
tag in the IP ID, keyed by HKDF(k_t, run_id) over the first 64 bytes of the
canonical packet, where k_t is the key of epoch t in the executor's TESLA
chain. The executor discloses k_t on a heartbeat once epoch t + d has
started. The dispatcher records every chain an executor announces, every
disclosed key that verifies against its chain, and each run's interval and
source address, in its database for the configured retention (default 90
days, configuration).
Anyone can look them up without an account through
GET /attribution/candidates and GET /attribution/keys, so captures older
than the executor's current chain or the dispatcher's last restart can still
be verified. dbl verify and client.Verify check a capture offline against
these routes and write and check evidence bundles. A group
whose key is not disclosed yet is sent to POST /attribution/verify, whose
executor answers before disclosure (method server); with --offline, or
when no answer is possible, it is pending. The tag-algorithm reference
tools/verify_pcap.py uses the same dated lookup
routes and falls back to the deprecated
GET /executors/by-ip (account required, the caller's own runs among the
executor's last 20) and GET /executors/{id}/tesla (current chain only). With
a configured TESLA seed and intact generation history, an executor restart can
recover its immediately previous chain for disclosure only. Recovery requires a ready
clock and retirement of the previous signers; the heartbeat carries that
chain's due keys in extra_disclosures. See the recovery conditions and
retention limits,
including rapid restarts and the lack of a durable dispatcher acknowledgement.
A copied seed or a rolled-back generation database is not a recovery plan.
Before disclosure only the executor can check a tag, through the server
method.
Every executor has a TESLA chain with anchor k0, start t0, epoch length
I and disclosure delay d ≥ 2 epochs (default about 15 minutes). The key of
epoch t is disclosed once epoch t + d has started, at
t0 + (t + d)·I. A packet group is the packets from one source address in
one epoch. Each group is verified in one of two ways:
| Method | When | How | Proves to |
|---|---|---|---|
server |
Key not yet disclosed | The dispatcher relays the group to the executor, which still holds k_t and answers yes or no for the whole group. The dispatcher signs a receipt. The dispatcher never holds an undisclosed key. |
Anyone who trusts the dispatcher's receipt key |
offline |
Key disclosed | The verifier fetches the dated schedule and the disclosed keys and checks the tags itself. | Whoever independently trusts the schedule/run history and capture timestamps |
After disclosure anyone can compute valid tags, so an offline check only shows that the packets came from the run if they were captured before the disclosure time. That rests on the capture's own timestamps, which are the presenter's claim. A server receipt is the evidence to show a third party: it shows that the executor confirmed the tags while only it held the key.
dbl verify <capture.pcap|pcapng|evidence.json> [--at <time>] [--offline]
[--output text|json] [--evidence <file>] [--source <address|prefix>,…]- No account or login is needed, and no credential is sent. The dispatcher is
the selected connection (
--endpointor--dispatcher). - Each group is verified by
serverorofflineautomatically, and the output says which, and names the receipt key of aserververdict.--offlinenever uploads packets. Groups whose key is not disclosed are thenpending. Without it, the first 64 bytes of at most 256 packets of each such group are sent to the dispatcher. --atsets the capture time of every packet, overriding the capture's own timestamps (for example for a capture whose clock is known to be wrong).--evidencewrites the evidence bundle. Passing a bundle instead of a capture checks it again offline, together with its receipts.--sourcechecks only the packets from the given addresses or prefixes and skips the rest before any lookup. A lookup covers one address for one epoch of the runs it names, but an address without a run needs a lookup per second of its traffic (the shortest epoch), so unrelated traffic in a capture costs lookups at the dispatcher's rate limit. Lookups go round-robin over the addresses, busiest first, so every address gets its first lookup before any gets its second; over the cap the remaining packets areunsupported: work_cap, naming--source.- Requests are paced to the dispatcher's documented rate limit (a token
bucket of 10 per second, burst 40;
VerifyOptions.RequestRateandRequestBurstin the SDK). A429 rate_limitedanswer holds every request until itsRetry-Afterhas passed, and the check keeps retrying until the command's deadline (5 minutes by default,--timeout). It then fails with an error that says the dispatcher rate-limited it and how many groups remain unchecked (client.RateLimitedError). Text output has one line per group, grouped by run:
verified run 6f1c2b1d… executor exec-zrh-1 192.0.2.4 2026-09-29T09:02Z 40 packets via offline
invalid 192.0.2.7 2026-09-29T10:15Z 3 packets tag_mismatch
pending 198.51.100.4 2026-09-29T10:20Z 5 packets until 2026-09-29T10:35Z
missing 203.0.113.9 2025-11-02T08:00Z 7 packets no history retained for that time
unsupported 2001:db8::1 2 packets IPv6 is not tagged
A summary follows: the counts, each verified run in full with what an offline
verdict does and does not show, one line of explanation and next step per
reason (for example "Retry after 10:35 UTC" for pending), and where the
evidence bundle was written.
--output json prints the report instead. Exit codes:
| Code | Meaning |
|---|---|
| 0 | Every group is verified. |
| 1 | Usage, read or network error; nothing is concluded. Usage errors, including those in the global options, exit 1 here, not 2 as for other commands, so that 2 always means invalid. |
| 2 | At least one group is invalid. |
| 3 | No group is invalid, but at least one is pending, missing or unsupported. |
| 124, 130 | The command timed out or was interrupted, as for every command; nothing is concluded. |
The result and fixture contracts below are the integration contract for
companion browser implementations. Authenticated bundle and clock support in
a browser requires a separately identified companion change; this repository's
CLI and Go checks do not establish that browser support. A browser implementation
must ask the user before uploading packets for a server check.
pkg/client gains:
pkts, err := client.ReadCapture(r io.Reader) ([]client.CapturedPacket, error) // pcap or pcapng
rep, err := c.Verify(ctx, pkts, client.VerifyOptions{At: t, Offline: false})
// rep.Groups []VerifyGroup{Verdict, Reason, Method, RunID, ExecutorID, Epoch,
// Time, PendingUntil, Packets []int}
// rep.Evidence() Evidence; client.VerifyEvidence(ctx, ev) (Report, error)Verify works on a client without a credential. dbl verify is a thin
wrapper around it. tools/verify_pcap.py remains the tag-spec-v1 algorithm
and dated-lookup reference. It does not authenticate evidence bundles, signing
identities or receiver clock records; use dbl verify or pkg/client for those
checks. The Python tool, SDK and companion implementations must agree on the
shared tag-spec-v1 vectors: good, altered, truncated, expired-history and every
supported link type.
The routes below follow the existing conventions. They have no path prefix;
the version is negotiated with the Debuglet-API-Version header, and the
routes are additions of API 1.11 (candidates and keys) and API 1.18
(verify and receipt keys). They are public
(security: []), rate-limited per client address (10 requests per second,
burst 40, per TCP peer and per /64 for IPv6, or per X-Forwarded-For client
behind a proxy listed in [attribution] trusted_proxies; 429 rate_limited
with Retry-After), and answer the usual {code, message} errors. Their
field names follow GET /executors/{id}/tesla: an integer field carries its
unit in its name (_unix_ns, _ns, _seconds, _epochs), and other times
are RFC 3339.
| Route | Purpose |
|---|---|
GET /attribution/candidates?ip=&at= |
Dated lookup: the runs active from ip within one epoch of at (RFC 3339). Each candidate gives executor_id, run_id, active interval (active_from, active_to), ip_source (observed or advertised), the schedule {chain_id, k0, t0_unix_ns, epoch_seconds, disclosure_delay_epochs, chain_length, tag_spec}, disclosed_through (the latest disclosed epoch, 0 for none), disclosed_through_at_ns (when the dispatcher recorded that key, 0 for none) and next_disclosure_at_ns (the earliest time the key of disclosed_through + 1 may be disclosed, t0_unix_ns + (disclosed_through + 1 + d)·I). chain_id is the hex of the first 16 bytes of SHA-256(k0), and chain_length 0 when the executor did not report it. tag_spec is the version the executor reported with the chain when it registered: 1 is tag spec v1, 0 is legacy (an executor that did not report debuglet-tag-v1, including every executor that predates the report), and a v1 verifier reports a legacy chain's packets unsupported, not invalid. The answer includes retained_from, so that missing can be told apart from "no run", and truncated when more than 32 runs matched. |
GET /attribution/keys?executor_id=&chain_id=&from_epoch=&to_epoch= |
Disclosed keys of one chain, at most 1024 epochs per page, with next_epoch for the next page (null on the last). from_epoch defaults to 1 and to_epoch to no bound. Undisclosed epochs are absent. The client checks every key against k0. |
POST /attribution/verify |
Server-assisted check. The request lists up to 256 packets as {data: base64 of the first min(64, Total Length) bytes of the IPv4 packet, captured_at}. The response gives for each group {source, epoch, chain_id, executor_id, run_id, verdict, reason, method, packets, budget: {limit, remaining, resets_at}} (packets are indices into the request; budget only for a group with a chain) and a signed receipt {key_id, payload, signature}. |
GET /attribution/receipt-keys |
The dispatcher's current and past receipt-verification keys (Ed25519) with their validity periods. |
The dispatcher forms groups as the offline check does: the packets of one
source address from the first one up to the end of the current epoch of
every run a dated lookup names (a second without a run), and within that
window one group per candidate chain, in the epoch of the first packet's
captured_at under that chain. It checks one group at a time against the
candidates of its chain:
- A key on record for the epoch (or a later one it derives from) answers
from the key store with the tag functions of the offline check:
method: offline, no budget spent. - An unusable schedule (
tag_spec,disclosure_delay,no_signing_key) isunsupported; a key that is due but not on record ispending(not_disclosed) within two minutes of its due time andmissing(keys_missing) after. - Otherwise, when the executor is connected with that chain, the dispatcher
charges the group's candidate runs, one trial each, to the budget of that
executor, chain and epoch, in its own transaction, and then asks the
executor over the control session which of exactly those candidates, if
any, reproduces the tag of every packet. The executor never returns tags
or keys.
matchedisverifiedwith the run;unmatchedispending(unmatched) until the key's disclosure time: no single candidate reproduces every tag, which two legitimate runs from one address in one epoch answer as well as forged traffic, so the offline check after disclosure decides, per run.ambiguousand every executor refusal (unknown_chain,epoch_unavailable,attribution_unavailable,too_many,malformed) areunsupportedwith that reason. All of these havemethod: server. A group with more candidates than trials remain ispending(budget_exhausted) and charges nothing; an executor that is not connected or does not answer ispending(executor_unavailable). A pending group givespending_until, the key's disclosure time.
The budget counts candidate trials because a query with N candidates tests
the presented tags against N derived keys: a forger who submits fabricated
tags learns, per trial, whether one guess matches one run's key. Trials are
charged before the query is relayed and never refunded: an answer lost to a
crash, a timeout or a broken session still counts, so neither a restart nor
a failure resets the budget. A disconnected executor is not asked, so
nothing is charged for it. Only an answer from a disclosed key, which is
split per run, can be invalid (tag_mismatch), and only when none of the
group's packets matches. With a recorded key, the HTTP response and receipt
list verified run subsets, ambiguous packets, and unmatched packets separately.
Unmatched packets alongside valid ones are unsupported (unmatched). The
request and response each allow at most 16 groups, including these subsets;
split a request into smaller batches if it exceeds this limit. Before disclosure,
the executor answers for the whole group, without splitting or returning
per-packet matches. The HTTP route checks the epoch of the capture time only,
not the one before it.
The receipt is a detached Ed25519 signature over canonical JSON (keys
sorted, no whitespace) of {api_version, dispatcher, groups: [{chain_id, epoch, executor_id, method, packets, reason, run_id, source, verdict}], packets_digest, query_at}. dispatcher is the configured
[authentication] public_url, or the origin the request was addressed to;
packets_digest is the evidence digest of the request's
packets in request order; query_at is the dispatcher's clock when it
answered. The callers' captured_at enter only through the digest: a
receipt shows what was asked and what was answered when, not when a packet
was observed. key_id is the hex of the first 16 bytes of SHA-256 of the
public key, which GET /attribution/receipt-keys lists with its validity;
a receipt is valid when its query_at lies within that validity. The key is
read from [attribution] receipt_key_path, created when absent the first
time either route needs it, and recorded with its validity; a dispatcher
started with another key ends the validity of the earlier one.
GET /executors/by-ip and GET /executors/{id}/tesla remain unchanged
within API major 1. They are marked deprecated: true in OpenAPI and point to
the new routes. verify_pcap.py moves to the new routes. It falls back to the
old ones when the dispatcher does not offer them.
| Verdict | Meaning |
|---|---|
verified |
Every packet in the group carries a valid tag for the named run at the named epoch (±1 epoch of clock skew). The group also records whether this was established by server or offline. |
invalid |
History covers the time and no candidate run reproduces any packet of the group. reason is tag_mismatch or no_run (no run was active from that address). |
pending |
The key is not yet disclosed and no server answer decided the group. This happens with --offline, when the executor is offline, when the budget is exhausted, or when the executor found no single candidate that reproduces every tag (unmatched). pending_until is the disclosure time; retry then. |
missing |
The dispatcher no longer holds, or never held, the schedule or keys for that time (before retained_from, or lost). This is no evidence either way. |
unsupported |
The group cannot be checked. reason is one of IPv6, SCION, unknown link type, truncated packet, unknown or legacy (pre-v1) tag spec, too many candidates, or over a work cap. |
A group (one source address in one epoch) whose packets do not all
reproduce one run is split, since one executor running two measurements
toward the same recipient at once legitimately sends packets of both runs
from one address in one epoch. Each packet is attributed to the run whose
tag it reproduces (to the run every matched packet reproduces, when there
is one), and the group is reported as separate entries: one verified entry
per run, with that run's packets; one unsupported: ambiguous entry for the
packets that each reproduce more than one run; and one entry for the packets
no candidate reproduces. The unmatched entry is unsupported: unmatched
(or pending, missing and so on when a key it needs is not available): the
packets are attributed to no run, but since other packets of the same address
and epoch match, they are no evidence that Debuglet did not send the group
(they may have been altered on the way). A group is invalid only when none
of its packets matches. In the CLI and SDK report, every entry of a split
group carries split: {packets, matched, unmatched, runs} of the whole
group, so each run's match count can be read against the group's non-matches.
The HTTP response and signed receipt identify each subset by its request
packet indices; they do not include this report-only split summary.
Machine reasons (reason): invalid has tag_mismatch, no_run;
pending has not_disclosed, and from the server also budget_exhausted,
executor_unavailable and unmatched (no single candidate reproduces
every tag; the offline check after disclosure decides per run); missing has not_retained
(before retained_from) and keys_missing (the run is on record but no key
at or above the epoch is); unsupported has ipv6, not_ipv4,
too_short, malformed, fragment (the tag spec's own reasons),
link_type, tag_spec (a candidate chain reports tag spec 0, the legacy
pre-v1 tag, or another version), disclosure_delay (d < 2), schedule,
bad_key (a served key does not hash to k0), no_signing_key (epoch 0 or
past the chain's end), key_public (every candidate key may already have
been public at the capture time plus the clock tolerance), ambiguous (more
than one run reproduces every tag; the runs are listed),
too_many_candidates, work_cap and unmatched (packets of a split
group that no candidate reproduces). A group with an uncheckable candidate
and an unmatched packet is unsupported, not invalid, since that candidate
may be the sender.
Each group also reports matched and unmatched packet counts, since a
count of matches is meaningful only together with the non-matches (tag spec
section 7), the number of candidate runs, and for a verified group
false_match_bound, N·C(n, k)·(2·2⁻¹⁶)^k for N candidates tried and k of
the group's n packets, a union bound over the candidates and over which k
packets were picked (N·(2·2⁻¹⁶)^n for a group that is not split), and
disclosed_at, the earliest time the latest key it used could have been
public: the packets prove the run only if they were captured before it.
Offline checks follow tag spec section 6: candidate epochs are t and
t − 1 only, epoch 0 never signs, a schedule with d < 2 is refused, and an
epoch whose key could already have been public at the capture time plus the
clock tolerance (default 1 s) is skipped, allowing the dispatcher's 5 s for
an executor clock that leads.
A verdict attributes packets to a run. It says nothing about whether the measurement was consented to or whether its conclusions are sound.
The evidence bundle is a JSON file that lets a verifier repeat the check without a capture:
{
"format": "debuglet-verification-evidence", "format_version": 1,
"created_at": "…", "tool": {"name": "dbl", "version": "…"}, "tag_spec": 1,
"dispatcher": {"url": "…", "api_version": "1.11"}, "clock_tolerance_ms": 1000,
"packets": {"count": 17, "digest": "sha256:…",
"items": [{"data": "<base64 first 64 bytes>", "captured_at": "…"}]},
"groups": [{"verdict": "verified", "method": "server", "run_id": "…", "executor_id": "…", "epoch": 1234, "packets": [0, 1, 2],
"split": {"packets": 5, "matched": 5, "unmatched": 0, "runs": ["…", "…"]},
"schedule": {…}, "keys": [{"epoch": 1234, "key": "…"}]}],
"lookups": [{"ip": "…", "at": "…", "retained_from": "…", "candidates": […]}],
"chains": [{"executor_id": "…", "schedule": {…}, "keys": [{"epoch": 1234, "key": "…"}]}],
"receipts": [{"key_id": "…", "payload": "<base64 canonical JSON>", "signature": "…", "packets": [3, 4]}],
"receipt_keys": [{"key_id": "…", "public_key": "…", "valid_from": "…", "valid_to": null}]
}split is present only on the entries of a split group; it was added
within format 1, whose readers ignore unknown fields, but a bundle recomputes
only under the verifier's current grouping. The digest is SHA-256 over the concatenated big-endian uint16 length ‖ data ‖ int64 captured_at_ns of all packets in order. Offline groups carry their
schedule and keys, and server groups are covered by a receipt. Schedules and
candidates use the field names of GET /attribution/candidates. lookups
records the dispatcher's answer for every group and chains every key the
check used, so dbl verify evidence.json and client.VerifyEvidence repeat
the whole check without the capture or the dispatcher: they check the digest,
walk every chain's keys to its k0, recompute every group from the packets,
lookups and keys as of created_at, and fail when a recorded group differs.
Each receipt lists the bundle's packets its request sent (packets); its
signature is checked under the key of receipt_keys it names, the key's ID
against its public key, the receipt's query_at against the key's validity
and its packets_digest against those packets, and its server answers
then decide the groups that recompute as pending, exactly as in the live
check. A group whose packets exceed the 256 of one request is checked by
its first 256 packets; the rest stays a separate pending entry. The
embedded receipt keys are the dispatcher's claim like the rest of the
bundle: compare their IDs with GET /attribution/receipt-keys of the
dispatcher you trust.
GET /attribution/candidates?signed=true adds a statement under the existing
receipt key. It signs the complete dated lookup: source address, capture query
time, retention boundary, candidate run IDs and intervals, every schedule field,
and disclosure observations. Its payload uses the stable JSON encoding of
wire.AttributionHistoryPayload, with format debuglet-attribution-history-v1,
issuer dispatcher, signed_at, and lookup (without its statement).
client.Verify requests these statements and saves them with the signing keys.
Old dispatchers may return unsigned history; it remains usable as an explicitly
unauthenticated historical claim. A configured signer that cannot be read answers
503 to a signed request, rather than substituting unsigned data.
VerifyEvidence checks included history signatures and the exact metadata they
bind, as well as packet receipts. An embedded key alone does not establish who
signed the bundle. For authenticated offline use, save a trust file separately
while the dispatcher's HTTPS identity is known:
{"dispatcher":"https://dispatcher.example","keys":[{"key_id":"…","public_key":"<base64 Ed25519 key>","valid_from":"…","valid_to":null}]}The keys and validity intervals come from GET /attribution/receipt-keys.
The trust file's dispatcher pins the signed issuer, not the URL used to retrieve
it. Behind a TLS terminator or path-prefix proxy, those may differ: evidence
records the API address as dispatcher.url and the signed identity as
dispatcher.issuer. The issuer is [authentication] public_url when configured;
otherwise it is the backend request origin. Live verification obtains keys from
the configured API endpoint and verifies statements with those keys; it does not
infer an issuer from forwarding headers. Configure public_url for a stable
identity before exporting evidence across deployments.
Keep retired keys for older statements. Run dbl verify evidence.json --trust trusted-dispatcher.json, or call VerifyEvidenceWithTrust. These require every
lookup and packet receipt to verify under a separately supplied key and the
same dispatcher identity. Missing, replaced, or edited statements fail; replacing
a public key inside the evidence cannot replace a pinned key. The report sets
history_authenticated only when history has been verified against trusted keys.
This authenticates what the dispatcher said; it does not authenticate capture
timestamps or make packet attribution prove a measurement conclusion.
In API 1.21 each newly registered, TLS-authenticated executor also signs its
schedule using its existing enrolled certificate key. schedule.operator_proof
contains the leaf certificate DER and a signature over the stable JSON object
{"format":"debuglet-tesla-schedule-v1","executor_id":...,"schedule":...},
with operator_proof omitted from the schedule. The dispatcher accepts it only
when its certificate fingerprint matches the enrolled, authenticated control
peer. A dispatcher configured with optional client identity continues to accept
TLS connections without enrollment, but discards their unbound schedule proofs
and records only unsigned executor claims. It never treats an embedded
certificate as a trust root. Previously signed history cannot be downgraded by
an unenrolled reannouncement. Dispatcher schema 29 persists accepted proofs unchanged; reannouncement cannot replace
its parameters or remove the proof. Disclosures and a dispatcher restart keep it.
The signature covers the anchor, chain ID, origin, interval, delay, length and tag
specification. This is executor-origin evidence, not a separate account-key
signature or a certificate of clock quality.
For independent executor-origin verification, add executor_certificates to
the trust file: an object mapping each executor ID to an array of lowercase
SHA-256 certificate fingerprints (DER bytes), for example
{"executor_certificates":{"node-1":["<old fingerprint>","<current fingerprint>"]}}. Obtain those pins from the operator through
an authenticated channel, separately from the bundle. Every candidate must have
a valid proof under its pin; the report then sets schedules_authenticated.
Certificate validity is checked at the signed chain origin, so a retained proof
remains checkable after certificate expiry. A proof's embedded certificate alone
never establishes trust. Old histories without this proof remain usable as
unsigned executor claims, and strict executor trust rejects them. Preserve old
certificate pins together with the dispatcher keys and evidence before pruning
history. No private key is exported, and no additional signing key is provisioned.
api_version is the API version the client requires. With --at, at records
the override and the packets carry it as captured_at.
A valid packet tag and signed schedule do not establish when a packet arrived.
The default one-second tolerance is an assumption, not a measurement. Reports
set capture_time_trusted: false unless the caller supplies a receiver clock
record obtained independently from the evidence. The CLI states this condition
in text output as well. Dispatcher and executor signatures have their own
history_authenticated and schedules_authenticated flags.
The capture operator must measure or conservatively bound receiver time relative to the exact signed schedule origins used for verification. Include both machines' reference uncertainty, capture timestamping error and drift over the whole observation interval. A receiver-to-UTC observation alone does not bound an executor whose origin is unmeasured. A synchronized flag, the kernel's clock estimate, or the executor's local wall/monotonic drift threshold does not provide this cross-host bound. Debuglet validates the supplied record; it does not obtain or certify a time reference. Without that observation the result remains conditional, even when all signatures verify.
To produce a reviewable record:
- Save the capture and the reference-observation log on the receiver. Record the
measured interval and the combined worst-case error for every relevant
executor's chain origin. Keep the log at the
sourcereference named below. - Run
dbl verify probe.pcap --offline --evidence provisional.jsonto obtain the normalized packet digest and the executor/chain/origin identities. This step alone makes no clock claim. If using--source, use the same filter in every step; it changes the packet digest. - The trusted observer writes
receiver-clock.jsonusing those identities and the independent observation, then transfers it over an authenticated channel. Do not accept a clock record supplied by an untrusted evidence author.
{
"packets_digest": "sha256:<packets.digest from the receiver's own export>",
"source": "receiver observation log identifier and reference used",
"observed_at": "2026-10-07T10:00:00Z",
"valid_until": "2026-10-07T10:01:00Z",
"max_error_ns": 2000000,
"schedules": [{"executor_id":"...", "chain_id":"...", "origin_unix_ns":1791366000000000000}]
}The numbers illustrate the format; they are not a measured bound for a deployment.
The record is at most 128 KiB and names at most 128 schedule origins. Its positive
combined error bound is at most 24 hours; a bound too large for the disclosure
window produces an inconclusive result. Each packet's timestamp plus and minus
the bound must fit inside [observed_at, valid_until). The digest binds the
normalized packet bytes and timestamps, preventing a later timestamp edit from
reusing the observation.
Run dbl verify probe.pcap --offline --clock receiver-clock.json --evidence verified.json (on one command line). The SDK equivalent is
VerifyOptions{CaptureClock: &record}. Verification rounds the bound upward to
milliseconds and uses at least the existing tolerance, plus the protocol's
five-second early-disclosure allowance. A key is usable only while
captured_at + applied_bound < scheduled_disclosure - 5 seconds.
--at overrides cannot carry receiver clock trust. Tag-spec v1 still checks the
capture epoch and its predecessor; the observation does not expand that matching
window or its false-match bound. A deployment requiring a wider clock/delay
window needs a separately versioned matching policy, not a larger claimed error
bound alone.
For later offline checks, place that independently obtained record in the
capture_clock field of the existing trust file and run
dbl verify verified.json --trust trusted-dispatcher.json. Strict rechecks reject
an evidence bundle whose applied tolerance was smaller than the measured bound.
Keep the dispatcher keys and executor certificate pins in that trust file too.
A delayed check may run after valid_until: the observation must cover the
original packet arrival, not the time someone opens the bundle. Packets captured
after disclosure, expired observations at capture time, or unknown schedule
origins never gain authority by being checked later.
| Limit | Value | Why |
|---|---|---|
| Capture read by the CLI and SDK | 64 MiB, 1 000 000 packets | Bounded memory; larger captures are rejected, not truncated |
Tag computations per Verify |
1 000 000 (packets × candidates × 2 epochs) | Bounded CPU; groups beyond the cap are unsupported |
| Hash walk per chain | At most chain_length, done once per chain and cached; at most 2²⁴ SHA-256 steps per Verify |
Keys are checked against k0 without an unbounded walk |
Lookups per Verify |
1024 candidate lookups (one per group: an address and the shortest epoch of the runs the lookup names, or a second for an address without a run; round-robin over addresses, busiest first), 256 key pages | Bounded requests at the dispatcher's rate limit (paced to 10 per second, burst 40, so the cap takes under two minutes); groups beyond are unsupported: work_cap |
| Evidence bundle | 256 MiB | |
| Signed lookup payload | 2 MiB, 32 candidates | Includes bounded public certificate proofs |
| Executor schedule proof | 32 KiB on control registration; certificate 16 KiB, signature 1024 bytes | |
| Independent trust/clock record | 128 KiB | At most 128 keys, certificate pins and schedule origins each |
| Candidates per lookup | 32 | Larger answers are unsupported: too many candidates |
| Keys per page | 1024 epochs | |
Packets per POST /attribution/verify |
256, body ≤ 64 KiB, ≤ 16 source/epoch groups and ≤ 16 result subsets | |
Server budget R per (executor, chain, epoch) |
16 candidate trials (a query charges one per candidate run), shared by all requesters, durable across restarts, never refunded | Each trial tests at most one guess of a 16-bit tag against one run's key, so a forged tag for any candidate of that epoch succeeds with probability at most R/65536 ≈ 0.02 % |
| Clock skew | ±1 epoch | As today |
| History retention | Operator-configured, default 90 days | missing before retained_from |
An exhausted budget only delays verification until disclosure (pending). It
cannot make a valid group invalid, and neither can a server answer. A
group with more than 16 candidates cannot use the server method. Queries are also rate-limited per client
address, which makes exhausting a budget cost several addresses.
A verifier learns the run ID, the executor ID and its public schedule for an
address and time it names. It does not learn the account, the debuglet or the
results; a run ID grants no access to owner routes. The operator maps a run to
its account when handling an abuse report. A server query sends the
dispatcher the first 64 bytes of each submitted packet (the probe's own
headers and the recipient's address), which it relays to the executor of the
candidate runs. The dispatcher logs the packet digest, the number of packets
and groups and the receipt key, not the packets.
- Disclosure delay
d ≥ 2, configurable, default about 15 minutes (fix/tesla-disclosure-delay). Landed. - #71(a): durable disclosed-key history, a dated run-by-address record, and
GET /attribution/candidatesand/attribution/keys. The old routes are deprecated. Landed (dispatcher schema 14, API 1.11), with the dispatcher side of disclosing an old chain's tail after an executor restart. The executor also re-derives the immediately previous chain from its seed and generation record (executor schema 8) and sends due keys inextra_disclosures, subject to the recovery conditions above. - #73:
dbl verifyoffline,client.VerifyandReadCapture, result categories, work caps, the evidence bundle, and shared vectors withverify_pcap.py. Landed: the tag functions moved topkg/tagspec, shared by the taggers andpkg/client;pkg/clienttests run the shared vectors through every supported capture format and link type and compare the per-packet outcome withverify_pcap.py. The website'sverify.tsis a separate companion change. - #341:
POST /attribution/verify, the executor query over the control session, budgetR, receipts and/attribution/receipt-keys. The command selects the method automatically. Landed (dispatcher schema 25, API 1.18). - #71(b): executor-signed schedule parameters, carried in candidates and evidence with independently pinned certificate verification. Landed (dispatcher schema 29, API 1.21). The executor now renews automatically at a drained session boundary, after used keys have durable disclosure receipts. Admission pauses during this handover; seamless rollover is not supported. The previous-chain tail recovery and backup limits above remain unchanged. See key renewal.