feat(labels): label signing and verification #170
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!170
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-135-labels"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #135.
Label signing and verification per the label spec. The SDK already modelled
verandsigonLabel, but it could neither produce nor check them.Signing:
LabelSigner(ATProtoNet.Labeling)sig, using the labeler's#atproto_labelkey. The signature is low-S (AtProtoKey.Sign) andveris set to 1.@atproto/ozone'ssignLabel:neg: falseis dropped before signing, and fields the Lexicon does not declare (ExtensionData) are not carried over, because the signature would not cover them.srcmust be the signer's DID,vermust be absent or 1,uriandvalmust be non-empty,valis at most 128 UTF-8 bytes, andctsandexpmust be valid datetimes.ECDsais not documented as thread-safe, so signing runs under a lock.Sign(subject, value, cid?, negate?, expiresAt?)stampsctsfrom aTimeProvider.SigningKeyexposes the publicdid:key, which the labeler publishes and stores alongside its signatures.LabelSigning.GetSigningBytes(label)exposes the exact bytes a signature covers.Verification:
LabelVerifiersrcthroughIDidResolverand checks the signature against#atproto_labelonly. It never falls back to the#atprotoaccount key.ServiceAuthVerifier/FirehoseVerifierpattern: when verification fails against the cached document, the document is refetched once. A refreshed key identical to the first is not re-checked.LabelVerificationStatus:Valid,Unsigned,UnsupportedVersion,Malformed,NoLabelKey,IssuerUnresolved(with theDidResolutionException) orInvalidSignature.LabelVerificationResultcarries the label, its status and the key it was checked against.AtProtoCrypto.VerifySignature→AtProtoKey.Verify), not the lenient JWT path.VerifyAllAsync(labels)verifies aqueryLabelspage and keeps the order.LabelSigning.Verify(label, didKey)checks against a key the caller already has.Optional verification on
subscribeLabelsandqueryLabelsLabelStreamConsumerOptions.Verifier(new,init): when set, everyLabelsEventcarries 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: callverifier.VerifyAllAsync(page.Labels).LabelClientis unchanged.Labeler-service helpers
queryLabelsserving (ATProtoNet.Server.Labeling):QueryLabelsEndpointreads from an app-providedILabelSource.*only at the end of a pattern, an emptyuriPatternsrejected,limitfrom 1 to 250 with a default of 50 and larger values capped. Invalid requests answerInvalidRequest.LabelSigneris registered, the endpoint signs the labeler's own unsigned labels on the way out, like ozone'sformatLabelAndEnsureSig. Labels from other sources pass through untouched.LabelQuery.Matches(label)helps sources that filter in memory.subscribeLabelsframes:LabelStreamFrames.Encode(LabelsEvent | LabelInfoEvent)andEncodeError(error, message?)produce complete binary WebSocket messages (header followed by body).Encoderefuses unsigned labels.DidDocument.LabelKeyId=#atproto_label(additive).Breaking changes
None. Everything is additive:
LabelStreamConsumerOptionsgains a body with oneinitproperty.LabelsEventgains one[JsonIgnore]property.DidDocumentgains one constant.A test also changed.
AddXrpcEndpointsFromAssembly(serverAssembly)now also discoversQueryLabelsEndpoint, 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
Labeling/*andServer/Labeling/*.LabelSigningInteropTests:atproto/labeling/label_test.go,TestVerifyLabel: the label, its K-256 key and signature verify. The signed bytes equal@atproto/commoncborEncodebyte for byte, and a mutatedvalfails.mod.bsky.appqueryLabels: a!takedownlabel and aneg: truenegation. Both verify against the#atproto_labelkey in the realdid:plc:ar7c4by46qjdydhdevvrndacdocument, pinned as JSON and resolved through the verifier end to end. Both fail against that labeler's#atprotokey.@atproto/ozonesignLabel(@atproto/common+@atproto/crypto) with fixed K-256 and P-256 keys and every optional field (cid,exp,neg), plus aneg: falsevariant:LabelSigning.Verify.neg: falseexactly as the reference does.#labels(seq 42 plus the indigo label),#infoandFutureCursorerror frames equalcborEncodeof the same header and body. Frames fromLabelStreamFramesare read back throughLabelStreamConsumerwith a verifier, which reports oneValidlabel and oneInvalidSignature(forged) label, both delivered.FakeDidDocumentResolver):Valid.Valid.InvalidSignatureafter one refetch.NoLabelKey.#atproto_label:NoLabelKey, with no fallback to#atproto.IssuerUnresolved.Deferred
subscribeLabelshosting.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.mdshows a minimalUseWebSocketshandler 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 passbatch.LabelstoVerifyAllAsync.StandardLabelValueshasHide/Warn/Bot,Goreobsolete andNotAvailableobsolete. Nothing to do.Docs:
docs/labeler.mdgains Signing Labels, Verifying Labels and Serving Labels sections. The page already existed, so it was extended rather than created. It is already linked fromdocs/index.md, whose entry now mentions the new sections. This PR also:CreatedAt = DateTime.UtcNow.ToString("o")in the page's record sampledocs/api-reference.mddocs/firehose.md🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ 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.
@claude /review — signature code with pinned interop vectors (indigo,
@atproto/ozoneK-256 and P-256, and livemod.bsky.applabels). Please read in full from the branch:src/ATProtoNet/Labeling/LabelSigning.cs(DRISL encoding withoutsig, and whenneg/verare omitted),LabelSigner.cs(low-S,ver = 1),LabelVerifier.cs(#atproto_labelonly with no#atprotofallback, refetch-once, statuses instead of exceptions, high-S refused),LabelStreamFrames.cs, theLabelStreamConsumer.cschange (failed labels are still delivered, with a result per label), andsrc/ATProtoNet.Server/Labeling/QueryLabelsEndpoint.cs+ILabelSource.cs(query parsing and limits, pattern matching onuriPatterns, signing on the way out). Compare with indigoatproto/labelingand the label spec. Focus on signature bypasses (encoding differences between signer and verifier, a field outside the signed bytes, key-id confusion), resource limits onqueryLabels, and wildcard pattern semantics.🤖 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.
✅ Verdict: approve — no blocking issues found.
Solid, carefully-built implementation of label signing and verification. Highlights I checked closely:
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.LabelSigner.Signrejects an invalidcts/expup front (IsValidcheck), whileLabelSigning.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.LabelVerifierrefetch-once logic: exceptions from either the cached-document resolve or the refetch are caught by the samecatch (DidResolutionException), correctly surfacingIssuerUnresolved; a malformed or absent#atproto_labelkey is handled without throwing (DidDocument.TryGetVerificationKeycatches format/crypto exceptions itself), soLabelVerifiernever 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 rejectinga*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 throughLabelStreamConsumerwith a verifier in the added tests.CreatedAt = AtDatetime.Now()) matches the actualLabelerServiceRecord.CreatedAttype (AtDatetime, notstring).CHANGELOG.mdwas 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