feat(labels): label signing and verification #170

Merged
Grandiras merged 1 commit from issue-135-labels into main 2026-09-26 16:12:37 +00:00
Owner

Closes #135.

Label signing and verification per the label spec. The SDK already modelled ver and sig on Label, but it could neither produce nor check them.

Signing: LabelSigner (ATProtoNet.Labeling)

  • Signs over the DRISL encoding of the label without sig, using the labeler's #atproto_label key. The signature is low-S (AtProtoKey.Sign) and ver is set to 1.
  • Matches @atproto/ozone's signLabel: neg: false is dropped before signing, and fields the Lexicon does not declare (ExtensionData) are not carried over, because the signature would not cover them.
  • Validates before signing: src must be the signer's DID, ver must be absent or 1, uri and val must be non-empty, val is at most 128 UTF-8 bytes, and cts and exp must be valid datetimes.
  • Safe for concurrent use: ECDsa is not documented as thread-safe, so signing runs under a lock.
  • Sign(subject, value, cid?, negate?, expiresAt?) stamps cts from a TimeProvider.
  • SigningKey exposes the public did:key, which the labeler publishes and stores alongside its signatures.
  • LabelSigning.GetSigningBytes(label) exposes the exact bytes a signature covers.

Verification: LabelVerifier

  • Resolves the label's src through IDidResolver and checks the signature against #atproto_label only. It never falls back to the #atproto account key.
  • Follows the ServiceAuthVerifier / FirehoseVerifier pattern: when verification fails against the cached document, the document is refetched once. A refreshed key identical to the first is not re-checked.
  • Never throws for a bad label; only cancellation escapes. Each outcome is a LabelVerificationStatus: Valid, Unsigned, UnsupportedVersion, Malformed, NoLabelKey, IssuerUnresolved (with the DidResolutionException) or InvalidSignature.
  • Each LabelVerificationResult carries the label, its status and the key it was checked against.
  • Verification uses the strict low-S path (AtProtoCrypto.VerifySignature → AtProtoKey.Verify), not the lenient JWT path.
  • VerifyAllAsync(labels) verifies a queryLabels page and keeps the order.
  • LabelSigning.Verify(label, didKey) checks against a key the caller already has.

Optional verification on subscribeLabels and queryLabels

  • LabelStreamConsumerOptions.Verifier (new, init): when set, every LabelsEvent carries a new [JsonIgnore] property, Verification, with one result per label in the same order. Labels that fail verification are still delivered, so the caller decides.
  • queryLabels: call verifier.VerifyAllAsync(page.Labels). LabelClient is unchanged.

Labeler-service helpers

  • queryLabels serving (ATProtoNet.Server.Labeling): QueryLabelsEndpoint reads from an app-provided ILabelSource.
    • Validates the request like ozone: * only at the end of a pattern, an empty uriPatterns rejected, limit from 1 to 250 with a default of 50 and larger values capped. Invalid requests answer InvalidRequest.
    • When a LabelSigner is registered, the endpoint signs the labeler's own unsigned labels on the way out, like ozone's formatLabelAndEnsureSig. Labels from other sources pass through untouched.
    • LabelQuery.Matches(label) helps sources that filter in memory.
  • subscribeLabels frames: LabelStreamFrames.Encode(LabelsEvent | LabelInfoEvent) and EncodeError(error, message?) produce complete binary WebSocket messages (header followed by body). Encode refuses unsigned labels.
  • DidDocument.LabelKeyId = #atproto_label (additive).

Breaking changes

None. Everything is additive:

  • LabelStreamConsumerOptions gains a body with one init property.
  • LabelsEvent gains one [JsonIgnore] property.
  • DidDocument gains one constant.

A test also changed. AddXrpcEndpointsFromAssembly(serverAssembly) now also discovers QueryLabelsEndpoint, so the test comparing the server assembly scan against the space-server registrations now registers it explicitly and expects 20 endpoints instead of 19.

Verification

  • Full solution build: 0 warnings, 0 errors.
  • Unit tests: 4589 passed, 0 failed, 5 skipped. 71 of these are new: Labeling/* and Server/Labeling/*.
  • Interop vectors, pinned in LabelSigningInteropTests:
    • indigo atproto/labeling/label_test.go, TestVerifyLabel: the label, its K-256 key and signature verify. The signed bytes equal @atproto/common cborEncode byte for byte, and a mutated val fails.
    • Live Bluesky moderation labels from mod.bsky.app queryLabels: a !takedown label and a neg: true negation. Both verify against the #atproto_label key in the real did:plc:ar7c4by46qjdydhdevvrndac document, pinned as JSON and resolved through the verifier end to end. Both fail against that labeler's #atproto key.
    • @atproto/ozone signLabel (@atproto/common + @atproto/crypto) with fixed K-256 and P-256 keys and every optional field (cid, exp, neg), plus a neg: false variant:
      • The reference signatures verify with LabelSigning.Verify.
      • Their high-S forms are rejected.
      • Our signer produces the identical signed bytes, a low-S signature that verifies, and drops neg: false exactly as the reference does.
    • ECDSA here is randomized, while the references use RFC 6979, so our own signatures cannot be pinned byte for byte. Pinning covers what both sides must agree on instead.
  • Frames: the #labels (seq 42 plus the indigo label), #info and FutureCursor error frames equal cborEncode of the same header and body. Frames from LabelStreamFrames are read back through LabelStreamConsumer with a verifier, which reports one Valid label and one InvalidSignature (forged) label, both delivered.
  • Verifier behaviour (with FakeDidDocumentResolver):
    • A key rotated since the document was cached: one refetch, then Valid.
    • A label key published since the document was cached: one refetch, then Valid.
    • A label signed before a rotation: InvalidSignature after one refetch.
    • A malformed key: NoLabelKey.
    • No #atproto_label: NoLabelKey, with no fallback to #atproto.
    • An unresolvable DID: IssuerUnresolved.
    • Unsigned or unversioned labels are reported without any resolution.
    • Cancellation propagates.

Deferred

  • subscribeLabels hosting. ATProtoNet.Server's XRPC hosting (#121) has no WebSocket subscription support. Following the issue's "where cheap" guidance, only the frame encoder ships. docs/labeler.md shows a minimal UseWebSockets handler built on it. A hosted subscription endpoint (sequencing, backfill from a cursor, OutdatedCursor, ConsumerTooSlow) would be its own piece of work.
  • FirehoseClient.SubscribeLabelsAsync, the single-connection client, takes no verifier: adding a parameter would be a binary break. Callers can pass batch.Labels to VerifyAllAsync.
  • Row B2 (well-known label values) was already done on main, by #123/#159: StandardLabelValues has Hide/Warn/Bot, Gore obsolete and NotAvailable obsolete. Nothing to do.

Docs: docs/labeler.md gains Signing Labels, Verifying Labels and Serving Labels sections. The page already existed, so it was extended rather than created. It is already linked from docs/index.md, whose entry now mentions the new sections. This PR also:

  • fixes a stale CreatedAt = DateTime.UtcNow.ToString("o") in the page's record sample
  • adds a Labels table to docs/api-reference.md
  • adds a verification note to the label-stream section of docs/firehose.md

🤖 Generated with Claude Code

Closes #135. Label signing and verification per the [label spec](https://atproto.com/specs/label). The SDK already modelled `ver` and `sig` on `Label`, but it could neither produce nor check them. ## Signing: `LabelSigner` (`ATProtoNet.Labeling`) - Signs over the DRISL encoding of the label without `sig`, using the labeler's `#atproto_label` key. The signature is low-S (`AtProtoKey.Sign`) and `ver` is set to 1. - Matches `@atproto/ozone`'s `signLabel`: `neg: false` is dropped before signing, and fields the Lexicon does not declare (`ExtensionData`) are not carried over, because the signature would not cover them. - Validates before signing: `src` must be the signer's DID, `ver` must be absent or 1, `uri` and `val` must be non-empty, `val` is at most 128 UTF-8 bytes, and `cts` and `exp` must be valid datetimes. - Safe for concurrent use: `ECDsa` is not documented as thread-safe, so signing runs under a lock. - `Sign(subject, value, cid?, negate?, expiresAt?)` stamps `cts` from a `TimeProvider`. - `SigningKey` exposes the public `did:key`, which the labeler publishes and stores alongside its signatures. - `LabelSigning.GetSigningBytes(label)` exposes the exact bytes a signature covers. ## Verification: `LabelVerifier` - Resolves the label's `src` through `IDidResolver` and checks the signature against `#atproto_label` only. It never falls back to the `#atproto` account key. - Follows the `ServiceAuthVerifier` / `FirehoseVerifier` pattern: when verification fails against the cached document, the document is refetched once. A refreshed key identical to the first is not re-checked. - Never throws for a bad label; only cancellation escapes. Each outcome is a `LabelVerificationStatus`: `Valid`, `Unsigned`, `UnsupportedVersion`, `Malformed`, `NoLabelKey`, `IssuerUnresolved` (with the `DidResolutionException`) or `InvalidSignature`. - Each `LabelVerificationResult` carries the label, its status and the key it was checked against. - Verification uses the strict low-S path (`AtProtoCrypto.VerifySignature` → `AtProtoKey.Verify`), not the lenient JWT path. - `VerifyAllAsync(labels)` verifies a `queryLabels` page and keeps the order. - `LabelSigning.Verify(label, didKey)` checks against a key the caller already has. ## Optional verification on `subscribeLabels` and `queryLabels` - `LabelStreamConsumerOptions.Verifier` (new, `init`): when set, every `LabelsEvent` carries a new `[JsonIgnore]` property, `Verification`, with one result per label in the same order. Labels that fail verification are still delivered, so the caller decides. - `queryLabels`: call `verifier.VerifyAllAsync(page.Labels)`. `LabelClient` is unchanged. ## Labeler-service helpers - **`queryLabels` serving** (`ATProtoNet.Server.Labeling`): `QueryLabelsEndpoint` reads from an app-provided `ILabelSource`. - Validates the request like ozone: `*` only at the end of a pattern, an empty `uriPatterns` rejected, `limit` from 1 to 250 with a default of 50 and larger values capped. Invalid requests answer `InvalidRequest`. - When a `LabelSigner` is registered, the endpoint signs the labeler's own unsigned labels on the way out, like ozone's `formatLabelAndEnsureSig`. Labels from other sources pass through untouched. - `LabelQuery.Matches(label)` helps sources that filter in memory. - **`subscribeLabels` frames**: `LabelStreamFrames.Encode(LabelsEvent | LabelInfoEvent)` and `EncodeError(error, message?)` produce complete binary WebSocket messages (header followed by body). `Encode` refuses unsigned labels. - **`DidDocument.LabelKeyId`** = `#atproto_label` (additive). ## Breaking changes None. Everything is additive: - `LabelStreamConsumerOptions` gains a body with one `init` property. - `LabelsEvent` gains one `[JsonIgnore]` property. - `DidDocument` gains one constant. A test also changed. `AddXrpcEndpointsFromAssembly(serverAssembly)` now also discovers `QueryLabelsEndpoint`, so the test comparing the server assembly scan against the space-server registrations now registers it explicitly and expects 20 endpoints instead of 19. ## Verification - Full solution build: 0 warnings, 0 errors. - Unit tests: 4589 passed, 0 failed, 5 skipped. 71 of these are new: `Labeling/*` and `Server/Labeling/*`. - **Interop vectors, pinned in `LabelSigningInteropTests`:** - indigo `atproto/labeling/label_test.go`, `TestVerifyLabel`: the label, its K-256 key and signature verify. The signed bytes equal `@atproto/common` `cborEncode` byte for byte, and a mutated `val` fails. - **Live Bluesky moderation labels** from `mod.bsky.app` `queryLabels`: a `!takedown` label and a `neg: true` negation. Both verify against the `#atproto_label` key in the real `did:plc:ar7c4by46qjdydhdevvrndac` document, pinned as JSON and resolved through the verifier end to end. Both fail against that labeler's `#atproto` key. - **`@atproto/ozone` `signLabel`** (`@atproto/common` + `@atproto/crypto`) with fixed K-256 and P-256 keys and every optional field (`cid`, `exp`, `neg`), plus a `neg: false` variant: - The reference signatures verify with `LabelSigning.Verify`. - Their high-S forms are rejected. - Our signer produces the identical signed bytes, a low-S signature that verifies, and drops `neg: false` exactly as the reference does. - ECDSA here is randomized, while the references use RFC 6979, so our own signatures cannot be pinned byte for byte. Pinning covers what both sides must agree on instead. - **Frames**: the `#labels` (seq 42 plus the indigo label), `#info` and `FutureCursor` error frames equal `cborEncode` of the same header and body. Frames from `LabelStreamFrames` are read back through `LabelStreamConsumer` with a verifier, which reports one `Valid` label and one `InvalidSignature` (forged) label, both delivered. - **Verifier behaviour** (with `FakeDidDocumentResolver`): - A key rotated since the document was cached: one refetch, then `Valid`. - A label key published since the document was cached: one refetch, then `Valid`. - A label signed before a rotation: `InvalidSignature` after one refetch. - A malformed key: `NoLabelKey`. - No `#atproto_label`: `NoLabelKey`, with no fallback to `#atproto`. - An unresolvable DID: `IssuerUnresolved`. - Unsigned or unversioned labels are reported without any resolution. - Cancellation propagates. ## Deferred - **`subscribeLabels` hosting.** `ATProtoNet.Server`'s XRPC hosting (#121) has no WebSocket subscription support. Following the issue's "where cheap" guidance, only the frame encoder ships. `docs/labeler.md` shows a minimal `UseWebSockets` handler built on it. A hosted subscription endpoint (sequencing, backfill from a cursor, `OutdatedCursor`, `ConsumerTooSlow`) would be its own piece of work. - `FirehoseClient.SubscribeLabelsAsync`, the single-connection client, takes no verifier: adding a parameter would be a binary break. Callers can pass `batch.Labels` to `VerifyAllAsync`. - Row B2 (well-known label values) was already done on main, by #123/#159: `StandardLabelValues` has `Hide`/`Warn`/`Bot`, `Gore` obsolete and `NotAvailable` obsolete. Nothing to do. Docs: `docs/labeler.md` gains Signing Labels, Verifying Labels and Serving Labels sections. The page already existed, so it was extended rather than created. It is already linked from `docs/index.md`, whose entry now mentions the new sections. This PR also: - fixes a stale `CreatedAt = DateTime.UtcNow.ToString("o")` in the page's record sample - adds a Labels table to `docs/api-reference.md` - adds a verification note to the label-stream section of `docs/firehose.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(labels): label signing and verification (closes #135)
All checks were successful
CI / pds-integration (pull_request) Successful in 28s
CI / build-and-test (pull_request) Successful in 56s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 8s
31b2fa17d9
Labels a service hands to another must be signed with the labeler's
SDK modelled ver and sig but could neither produce nor check them.

LabelSigner signs over the DRISL encoding without sig (low-S, ver = 1,
neg: false dropped as @atproto/ozone does). LabelVerifier checks a label
against its issuer's #atproto_label key through the caching resolver and
refetches the DID document once on failure, reporting every outcome as a
LabelVerificationStatus rather than throwing. LabelStreamConsumer verifies
optionally and exposes one result per label in LabelsEvent.Verification.
ATProtoNet.Server serves queryLabels over an ILabelSource, and
LabelStreamFrames encodes subscribeLabels frames.

The signed bytes and frames are pinned against @atproto/common's
cborEncode, and signatures from indigo's test vector, Bluesky's moderation
service and @atproto/ozone's signLabel (K-256 and P-256) verify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-170/run-2026-09-26T16-10-08-092Z.md) — updating every ~5 minutes while the review runs.
Collaborator

⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T21:10:08.583Z) — nothing to re-trigger.

⏳ **Held on the OpenCode Go window** — the 5-hour window is at 0% (the bot holds at 95%). This continues automatically after the window resets (2026-09-26T21:10:08.583Z) — nothing to re-trigger. <!-- opencode-run:paused --> <!-- opencode-session:ses_f2184cef0ffeUghQSpkfjua9GJ -->
Author
Owner

@claude /review — signature code with pinned interop vectors (indigo, @atproto/ozone K-256 and P-256, and live mod.bsky.app labels). Please read in full from the branch: src/ATProtoNet/Labeling/LabelSigning.cs (DRISL encoding without sig, and when neg/ver are omitted), LabelSigner.cs (low-S, ver = 1), LabelVerifier.cs (#atproto_label only with no #atproto fallback, refetch-once, statuses instead of exceptions, high-S refused), LabelStreamFrames.cs, the LabelStreamConsumer.cs change (failed labels are still delivered, with a result per label), and src/ATProtoNet.Server/Labeling/QueryLabelsEndpoint.cs + ILabelSource.cs (query parsing and limits, pattern matching on uriPatterns, signing on the way out). Compare with indigo atproto/labeling and the label spec. Focus on signature bypasses (encoding differences between signer and verifier, a field outside the signed bytes, key-id confusion), resource limits on queryLabels, and wildcard pattern semantics.

@claude /review — signature code with pinned interop vectors (indigo, `@atproto/ozone` K-256 and P-256, and live `mod.bsky.app` labels). Please read in full from the branch: `src/ATProtoNet/Labeling/LabelSigning.cs` (DRISL encoding without `sig`, and when `neg`/`ver` are omitted), `LabelSigner.cs` (low-S, `ver = 1`), `LabelVerifier.cs` (`#atproto_label` only with no `#atproto` fallback, refetch-once, statuses instead of exceptions, high-S refused), `LabelStreamFrames.cs`, the `LabelStreamConsumer.cs` change (failed labels are still delivered, with a result per label), and `src/ATProtoNet.Server/Labeling/QueryLabelsEndpoint.cs` + `ILabelSource.cs` (query parsing and limits, pattern matching on `uriPatterns`, signing on the way out). Compare with indigo `atproto/labeling` and the label spec. Focus on signature bypasses (encoding differences between signer and verifier, a field outside the signed bytes, key-id confusion), resource limits on `queryLabels`, and wildcard pattern semantics.
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/617); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-170/run-617.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Solid, carefully-built implementation of label signing and verification. Highlights I checked closely:

  • DRISL encoding (LabelSigning.Write): field set and key ordering (cid, cts, exp, neg, sig, src, uri, val, ver) is correct DAG-CBOR canonical order (all keys happen to be 3 bytes, so it collapses to alphabetical) — matches the pinned interop hex vectors from indigo and @atproto/ozone.
  • Sign/Verify asymmetry is intentional: LabelSigner.Sign rejects an invalid cts/exp up front (IsValid check), while LabelSigning.Write's own guard only checks for an empty string. That means the verifier will still encode and check a signature over a label carrying a non-canonical-but-non-empty timestamp string rather than reject it outright — correct, since verification must encode whatever bytes were actually signed, not enforce format policy after the fact.
  • LabelVerifier refetch-once logic: exceptions from either the cached-document resolve or the refetch are caught by the same catch (DidResolutionException), correctly surfacing IssuerUnresolved; a malformed or absent #atproto_label key is handled without throwing (DidDocument.TryGetVerificationKey catches format/crypto exceptions itself), so LabelVerifier never leaks an unexpected exception type.
  • QueryLabelsEndpoint.ValidatePatterns: checking only the first *'s position is sufficient to reject any non-trailing wildcard (a middle * is never last unless it's the only one), correctly handling *, prefix*, exact URIs, and rejecting a*b*/**.
  • LabelStreamFrames: header/body key ordering, the #labels/#info/error frame shapes, and the refusal to encode an unsigned label all check out; round-tripped through LabelStreamConsumer with a verifier in the added tests.
  • Docs fix (CreatedAt = AtDatetime.Now()) matches the actual LabelerServiceRecord.CreatedAt type (AtDatetime, not string).
  • CHANGELOG.md was updated in the same commit set, consistent with the repo's contribution rule.

No correctness, security, or omission issues found. The interop test suite (indigo, live Bluesky moderation labels, @atproto/ozone) is a strong signal that the wire format is right.


⏱ 1m 45s · 9 turns · tokens: 76.6k in / 10.3k out (+776.8k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Solid, carefully-built implementation of label signing and verification. Highlights I checked closely: - **DRISL encoding** (`LabelSigning.Write`): field set and key ordering (cid, cts, exp, neg, sig, src, uri, val, ver) is correct DAG-CBOR canonical order (all keys happen to be 3 bytes, so it collapses to alphabetical) — matches the pinned interop hex vectors from indigo and `@atproto/ozone`. - **Sign/Verify asymmetry is intentional**: `LabelSigner.Sign` rejects an invalid `cts`/`exp` up front (`IsValid` check), while `LabelSigning.Write`'s own guard only checks for an empty string. That means the verifier will still encode and check a signature over a label carrying a non-canonical-but-non-empty timestamp string rather than reject it outright — correct, since verification must encode whatever bytes were actually signed, not enforce format policy after the fact. - **`LabelVerifier` refetch-once logic**: exceptions from either the cached-document resolve or the refetch are caught by the same `catch (DidResolutionException)`, correctly surfacing `IssuerUnresolved`; a malformed or absent `#atproto_label` key is handled without throwing (`DidDocument.TryGetVerificationKey` catches format/crypto exceptions itself), so `LabelVerifier` never leaks an unexpected exception type. - **`QueryLabelsEndpoint.ValidatePatterns`**: checking only the first `*`'s position is sufficient to reject any non-trailing wildcard (a middle `*` is never last unless it's the only one), correctly handling `*`, `prefix*`, exact URIs, and rejecting `a*b*`/`**`. - **`LabelStreamFrames`**: header/body key ordering, the `#labels`/`#info`/error frame shapes, and the refusal to encode an unsigned label all check out; round-tripped through `LabelStreamConsumer` with a verifier in the added tests. - Docs fix (`CreatedAt = AtDatetime.Now()`) matches the actual `LabelerServiceRecord.CreatedAt` type (`AtDatetime`, not `string`). - `CHANGELOG.md` was updated in the same commit set, consistent with the repo's contribution rule. No correctness, security, or omission issues found. The interop test suite (indigo, live Bluesky moderation labels, `@atproto/ozone`) is a strong signal that the wire format is right. --- ⏱ 1m 45s · 9 turns · tokens: 76.6k in / 10.3k out (+776.8k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-170/run-617.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:31b2fa17d9102a07035dee1782ef5a49febfb38d -->
Grandiras deleted branch issue-135-labels 2026-09-26 16:12:37 +00:00
Sign in to join this conversation.
No description provided.