From 97981bd1d321cb6147c01039103984eb297ebb08 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 7 Apr 2026 20:58:37 +0000 Subject: [PATCH 01/11] feat: add cryptographic signature support to record lexicons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional cryptographic signature support to record lexicons, enabling platform attestation and verification that records were created through trusted services. Inspired by: - ATProtocol Attestation Specification by Nick Gerakines - The platformAttestation pattern from app.bumicerts.link.evm New lexicons: - app.certified.signature.inline: Inline signature with JOSE algorithm identifiers (ES256K, ES256, Ed25519) per ATProto convention - app.certified.signature.defs: Shared type definitions providing the `#list` array def (an open union of inline signatures and strongRefs to remote attestation proofs). Follows the atproto convention of putting reusable defs in a dedicated `*.defs` lexicon; see the lexicon spec section on "Lexicon Files" and the style guide's recommendation for `.defs` schemas. - app.certified.signature.proof: Remote attestation proof record holding the CID of attested content, hosted in the attestor's repository 19 record lexicons gain an optional `signatures` property (a flat array referencing app.certified.signature.defs#list) so callers access them as record.signatures[] with no wrapper nesting. This matches the ATProtocol Attestation Specification while keeping the array definition DRY via a single shared lexicon def. Lexicons updated: - org.hypercerts.claim.* (activity, contribution, contributorInformation, rights) - org.hypercerts.collection - org.hypercerts.context.* (acknowledgement, attachment, evaluation, measurement) - org.hypercerts.funding.receipt - org.hypercerts.workscope.tag - org.hyperboards.* (board, displayProfile) - app.certified.badge.* (award, definition, response) - app.certified.actor.* (profile, organization) - app.certified.location app.certified.link.evm is deliberately excluded: it already carries its own EIP-712 wallet-ownership proof in the `proof` field, which is the integrity mechanism for its semantic DID ↔ wallet claim. The EIP-712 signature is incompatible with the ATProto attestation spec (different preimage, hash function, signature format, and binding model), and adding a second signing mechanism on the same record would provide no additional trust while inviting confusion about which signature is authoritative. Includes validation tests for inline signatures, proof records, and signatures attached to records, plus regenerated SCHEMAS.md, ERD.puml, README.md, and a changeset. --- .changeset/add-signature-support.md | 52 +++++++++++ ERD.puml | 38 ++++++++ README.md | 80 +++++++++++++++++ .../app/certified/actor/organization.json | 5 ++ lexicons/app/certified/actor/profile.json | 5 ++ lexicons/app/certified/badge/award.json | 5 ++ lexicons/app/certified/badge/definition.json | 5 ++ lexicons/app/certified/badge/response.json | 5 ++ lexicons/app/certified/location.json | 5 ++ lexicons/app/certified/signature/defs.json | 16 ++++ lexicons/app/certified/signature/inline.json | 29 ++++++ lexicons/app/certified/signature/proof.json | 33 +++++++ lexicons/org/hyperboards/board.json | 5 ++ lexicons/org/hyperboards/displayProfile.json | 5 ++ lexicons/org/hypercerts/claim/activity.json | 5 ++ .../org/hypercerts/claim/contribution.json | 5 ++ .../claim/contributorInformation.json | 5 ++ lexicons/org/hypercerts/claim/rights.json | 5 ++ lexicons/org/hypercerts/collection.json | 5 ++ .../hypercerts/context/acknowledgement.json | 5 ++ .../org/hypercerts/context/attachment.json | 5 ++ .../org/hypercerts/context/evaluation.json | 5 ++ .../org/hypercerts/context/measurement.json | 5 ++ lexicons/org/hypercerts/funding/receipt.json | 5 ++ lexicons/org/hypercerts/workscope/tag.json | 5 ++ package-lock.json | 16 ++-- package.json | 2 +- tests/validate-signature-inline.test.ts | 74 +++++++++++++++ tests/validate-signature-proof.test.ts | 69 ++++++++++++++ tests/validate-signatures-on-record.test.ts | 90 +++++++++++++++++++ 30 files changed, 585 insertions(+), 9 deletions(-) create mode 100644 .changeset/add-signature-support.md create mode 100644 lexicons/app/certified/signature/defs.json create mode 100644 lexicons/app/certified/signature/inline.json create mode 100644 lexicons/app/certified/signature/proof.json create mode 100644 tests/validate-signature-inline.test.ts create mode 100644 tests/validate-signature-proof.test.ts create mode 100644 tests/validate-signatures-on-record.test.ts diff --git a/.changeset/add-signature-support.md b/.changeset/add-signature-support.md new file mode 100644 index 00000000..6706c90e --- /dev/null +++ b/.changeset/add-signature-support.md @@ -0,0 +1,52 @@ +--- +"@hypercerts-org/lexicon": minor +--- + +Add cryptographic signature support to all lexicons + +Adds optional cryptographic signature support to all record lexicons, enabling platform attestation and verification that records were created through trusted services. This is inspired by the [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r) by Nick Gerakines. + +**New lexicons:** + +- `app.certified.signature.inline` - Inline cryptographic signature object with JOSE algorithm identifiers (ES256, ES256K, Ed25519) +- `app.certified.signature.defs` - Shared type definitions for signatures, providing the `#list` array def (a union of inline signatures and strongRefs to remote proofs) that is referenced from record lexicons +- `app.certified.signature.proof` - Remote attestation proof record containing the CID of attested content + +**Changes to existing lexicons:** + +19 record lexicons now include an optional `signatures` property (a ref to `app.certified.signature.defs#list`) placed directly on the record with no wrapper object: + +- `org.hypercerts.claim.activity` +- `org.hypercerts.claim.contribution` +- `org.hypercerts.claim.contributorInformation` +- `org.hypercerts.claim.rights` +- `org.hypercerts.collection` +- `org.hypercerts.context.acknowledgement` +- `org.hypercerts.context.attachment` +- `org.hypercerts.context.evaluation` +- `org.hypercerts.context.measurement` +- `org.hypercerts.funding.receipt` +- `org.hypercerts.workscope.tag` +- `org.hyperboards.board` +- `org.hyperboards.displayProfile` +- `app.certified.badge.award` +- `app.certified.badge.definition` +- `app.certified.badge.response` +- `app.certified.actor.profile` +- `app.certified.actor.organization` +- `app.certified.location` + +`app.certified.link.evm` is deliberately excluded: it already carries its own EIP-712 wallet-ownership proof in the `proof` field, which is the integrity mechanism for its semantic DID ↔ wallet claim. Adding a second, incompatible signing mechanism on the same record would provide no additional trust and invite confusion. + +**Design notes:** + +This is a non-breaking extension - signatures are optional on all records. Two signature patterns are supported: + +1. **Inline signatures** - Embedded directly in the record via `app.certified.signature.inline` +2. **Remote attestations** - References to proof records in other repositories via `com.atproto.repo.strongRef` + +Signature algorithm identifiers use [JOSE format](https://www.iana.org/assignments/jose/jose.xhtml) per ATProto convention: + +- `ES256K` - secp256k1 (Ethereum/Bitcoin curve, ATProto default) +- `ES256` - P-256 (NIST curve, WebCrypto compatible) +- `Ed25519` - EdDSA diff --git a/ERD.puml b/ERD.puml index 130db6cc..8ed494c6 100644 --- a/ERD.puml +++ b/ERD.puml @@ -240,6 +240,33 @@ dataclass rights { !endif } +' app.certified.signature.inline +dataclass signatureInline { + !if (SHOW_FIELDS == "true") + signatureType? + signature + key + !endif +} + +' app.certified.signature.proof +dataclass signatureProof { + !if (SHOW_FIELDS == "true") + cid + note? + createdAt? + !endif +} + +' app.certified.link.evm +dataclass linkEvm { + !if (SHOW_FIELDS == "true") + address + proof + createdAt + !endif +} + ' org.hypercerts.collection dataclass collection { !if (SHOW_FIELDS == "true") @@ -405,4 +432,15 @@ follow::subject --> contributorEntity : follows ' This screws up the layout 'badgeAward::subject --[norank]-> collection +' Signature relationships +' Nearly all record lexicons have an optional `signatures` array (ref +' to app.certified.signature.defs#list) containing a union of +' signatureInline objects and strongRefs to signatureProof records. +' Only activity is shown here to avoid cluttering the diagram; the +' pattern is identical for all record lexicons except +' app.certified.link.evm, which carries its own EIP-712 wallet +' ownership proof and deliberately omits the signatures property. +activity ..> signatureInline : signatures +activity ..> signatureProof : "signatures (strongRef)" + @enduml diff --git a/README.md b/README.md index 831dd343..3f07c060 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ CERTIFIED ─ shared lexicons (certified.app) actor/organization (org metadata) badge/response ──► badge/award ──► badge/definition graph/follow ────────────► account DID (social follow) + signature/inline (embedded cryptographic signature) + signature/defs (shared signatures array def) + signature/proof (remote attestation proof record) ``` Every arrow (`►`) is a `strongRef` or union reference stored on the @@ -278,6 +281,18 @@ await agent.api.com.atproto.repo.createRecord({ | **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID ↔ EVM wallet link via EIP-712 signature. Extensible for future proof methods (e.g. ERC-1271, ERC-6492). | | **Follow** | `app.certified.graph.follow` | Social-graph follow relationship — declares that the author follows another account by DID. Schema-compatible with `app.bsky.graph.follow`. | +### Signatures (`app.certified.signature.*`) + +| Lexicon | NSID | Description | +| -------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Inline Signature** | `app.certified.signature.inline` | An inline cryptographic signature for attesting to record content. Uses JOSE algorithm identifiers (ES256, ES256K, Ed25519). | +| **Shared Defs** | `app.certified.signature.defs` | Shared type definitions for signatures. Provides the `#list` array def (a union of inline signatures and strongRefs) referenced by the `signatures` property on records. | +| **Proof** | `app.certified.signature.proof` | Remote attestation proof record containing the CID of attested content. Lives in the attestor's repository and can be referenced via strongRef. | + +Nearly all record lexicons include an optional `signatures` property (a ref to `app.certified.signature.defs#list`) enabling cryptographic attestations. See [Cryptographic Signatures](#cryptographic-signatures) for usage details. + +`app.certified.link.evm` is the one exception: it already carries its own EIP-712 wallet-ownership proof in the `proof` field, which is the integrity mechanism for its semantic claim (DID ↔ wallet). Adding an `app.certified.signature.defs#list` on top would introduce a second, incompatible signing mechanism on the same record and no additional trust, so it is deliberately omitted. + > **Full property tables** → [SCHEMAS.md](SCHEMAS.md) ## Schema Conventions @@ -564,6 +579,71 @@ const attachment = { }; ``` +### Cryptographic Signatures + +Nearly all record lexicons support optional cryptographic signatures via the `signatures` property. This enables platform attestation and verification that records were created through trusted services. (`app.certified.link.evm` is the sole exception — see the [Signatures table](#signatures-appcertifiedsignature) above for why.) + +Signatures support two patterns: + +1. **Inline signatures**: Embedded directly in the record +2. **Remote attestations**: References to proof records in other repositories (via `strongRef`) + +```typescript +import { ACTIVITY_NSID } from "@hypercerts-org/lexicon"; + +// Activity with inline signature +const signedActivity = { + $type: ACTIVITY_NSID, + title: "Verified Reforestation Project", + shortDescription: "Planted 1,000 trees in partnership with local community", + createdAt: new Date().toISOString(), + signatures: [ + // Inline signature (embedded) + { + $type: "app.certified.signature.inline", + signatureType: "ES256K", // JOSE algorithm: secp256k1 + signature: new Uint8Array([ + /* signature bytes */ + ]), + key: "did:plc:platform123#signing", // DID verification method + }, + // Remote attestation (reference to proof in another repo) + { + $type: "com.atproto.repo.strongRef", + uri: "at://did:plc:verifier/app.certified.signature.proof/abc123", + cid: "bafy...", + }, + ], +}; +``` + +#### Signature Algorithm Identifiers + +The `signatureType` property uses [JOSE algorithm identifiers](https://www.iana.org/assignments/jose/jose.xhtml): + +| Value | Curve | Description | +| --------- | --------- | --------------------------------------- | +| `ES256` | P-256 | NIST curve, WebCrypto compatible | +| `ES256K` | secp256k1 | Ethereum/Bitcoin curve, ATProto default | +| `Ed25519` | Ed25519 | EdDSA, increasingly popular | + +#### Remote Attestation Proofs + +For remote attestations, create a proof record in the attestor's repository: + +```typescript +import { SIGNATURE_PROOF_NSID } from "@hypercerts-org/lexicon"; + +const proof = { + $type: SIGNATURE_PROOF_NSID, + cid: "bafy...", // CID of the attested content + note: "Verified by platform quality assurance process", + createdAt: new Date().toISOString(), +}; +``` + +For the full specification, see Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r). + ## Development ### Commands diff --git a/lexicons/app/certified/actor/organization.json b/lexicons/app/certified/actor/organization.json index fbea68f3..c736416a 100644 --- a/lexicons/app/certified/actor/organization.json +++ b/lexicons/app/certified/actor/organization.json @@ -56,6 +56,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/app/certified/actor/profile.json b/lexicons/app/certified/actor/profile.json index c5a98947..e9252f1f 100644 --- a/lexicons/app/certified/actor/profile.json +++ b/lexicons/app/certified/actor/profile.json @@ -53,6 +53,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/app/certified/badge/award.json b/lexicons/app/certified/badge/award.json index 20854eb7..ed6c08dd 100644 --- a/lexicons/app/certified/badge/award.json +++ b/lexicons/app/certified/badge/award.json @@ -35,6 +35,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/app/certified/badge/definition.json b/lexicons/app/certified/badge/definition.json index 809f82a7..86592429 100644 --- a/lexicons/app/certified/badge/definition.json +++ b/lexicons/app/certified/badge/definition.json @@ -58,6 +58,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/app/certified/badge/response.json b/lexicons/app/certified/badge/response.json index 168ab7b9..1ae27ac8 100644 --- a/lexicons/app/certified/badge/response.json +++ b/lexicons/app/certified/badge/response.json @@ -29,6 +29,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/app/certified/location.json b/lexicons/app/certified/location.json index 853a6b35..644def3d 100644 --- a/lexicons/app/certified/location.json +++ b/lexicons/app/certified/location.json @@ -67,6 +67,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/app/certified/signature/defs.json b/lexicons/app/certified/signature/defs.json new file mode 100644 index 00000000..450a64a6 --- /dev/null +++ b/lexicons/app/certified/signature/defs.json @@ -0,0 +1,16 @@ +{ + "lexicon": 1, + "id": "app.certified.signature.defs", + "description": "Common type definitions for cryptographic signatures attached to records.", + "defs": { + "list": { + "type": "array", + "description": "Reusable array of cryptographic signatures attesting to a record's content. Uses an open union to support inline signatures and strong references to remote attestation proof records.", + "items": { + "type": "union", + "refs": ["app.certified.signature.inline", "com.atproto.repo.strongRef"] + }, + "maxLength": 100 + } + } +} diff --git a/lexicons/app/certified/signature/inline.json b/lexicons/app/certified/signature/inline.json new file mode 100644 index 00000000..c4947517 --- /dev/null +++ b/lexicons/app/certified/signature/inline.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "app.certified.signature.inline", + "description": "An inline cryptographic signature for attesting to record content. The signature is generated by signing the CID of the record content (with repository binding via $sig metadata during CID generation).", + "defs": { + "main": { + "type": "object", + "description": "Inline attestation signature embedded directly in a record.", + "required": ["signature", "key"], + "properties": { + "signatureType": { + "type": "string", + "description": "The signature algorithm identifier (JOSE format). ES256K for secp256k1 (Ethereum/Bitcoin), ES256 for P-256 (NIST/WebCrypto), Ed25519 for EdDSA. Optional but recommended for explicit validation.", + "knownValues": ["ES256", "ES256K", "Ed25519"], + "maxLength": 32 + }, + "signature": { + "type": "bytes", + "description": "The cryptographic signature bytes over the CID. ECDSA signatures must use the low-S variant per BIP-0062." + }, + "key": { + "type": "string", + "description": "Full DID verification method reference (format: did:{method}:{identifier}#{fragment}). Identifies the signer and specific key used.", + "maxLength": 512 + } + } + } + } +} diff --git a/lexicons/app/certified/signature/proof.json b/lexicons/app/certified/signature/proof.json new file mode 100644 index 00000000..9c2cc726 --- /dev/null +++ b/lexicons/app/certified/signature/proof.json @@ -0,0 +1,33 @@ +{ + "lexicon": 1, + "id": "app.certified.signature.proof", + "description": "A remote attestation proof record containing the CID of attested content. This record lives in the attestor's repository and can be referenced via strongRef from the attested record's signatures array.", + "defs": { + "main": { + "type": "record", + "description": "Remote attestation proof containing the CID of the content being attested.", + "key": "tid", + "record": { + "type": "object", + "required": ["cid"], + "properties": { + "cid": { + "type": "string", + "format": "cid", + "description": "CID of the attested content (record without signatures field, with $sig metadata including repository DID)." + }, + "note": { + "type": "string", + "description": "Optional note explaining the attestation purpose or context.", + "maxLength": 500 + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this proof was created." + } + } + } + } + } +} diff --git a/lexicons/org/hyperboards/board.json b/lexicons/org/hyperboards/board.json index 6eaac83c..c4fba9f2 100644 --- a/lexicons/org/hyperboards/board.json +++ b/lexicons/org/hyperboards/board.json @@ -33,6 +33,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hyperboards/displayProfile.json b/lexicons/org/hyperboards/displayProfile.json index d0af7f80..3c4f3239 100644 --- a/lexicons/org/hyperboards/displayProfile.json +++ b/lexicons/org/hyperboards/displayProfile.json @@ -56,6 +56,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/claim/activity.json b/lexicons/org/hypercerts/claim/activity.json index 86c41829..7991aaee 100644 --- a/lexicons/org/hypercerts/claim/activity.json +++ b/lexicons/org/hypercerts/claim/activity.json @@ -87,6 +87,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/claim/contribution.json b/lexicons/org/hypercerts/claim/contribution.json index 791a367c..edafe6e8 100644 --- a/lexicons/org/hypercerts/claim/contribution.json +++ b/lexicons/org/hypercerts/claim/contribution.json @@ -35,6 +35,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/claim/contributorInformation.json b/lexicons/org/hypercerts/claim/contributorInformation.json index b3bd7751..e6dd1c64 100644 --- a/lexicons/org/hypercerts/claim/contributorInformation.json +++ b/lexicons/org/hypercerts/claim/contributorInformation.json @@ -32,6 +32,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/claim/rights.json b/lexicons/org/hypercerts/claim/rights.json index f242f465..30f3c8f8 100644 --- a/lexicons/org/hypercerts/claim/rights.json +++ b/lexicons/org/hypercerts/claim/rights.json @@ -43,6 +43,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/collection.json b/lexicons/org/hypercerts/collection.json index ba974b98..bde6c5ae 100644 --- a/lexicons/org/hypercerts/collection.json +++ b/lexicons/org/hypercerts/collection.json @@ -79,6 +79,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/context/acknowledgement.json b/lexicons/org/hypercerts/context/acknowledgement.json index b4122540..8fde4b8f 100644 --- a/lexicons/org/hypercerts/context/acknowledgement.json +++ b/lexicons/org/hypercerts/context/acknowledgement.json @@ -34,6 +34,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/context/attachment.json b/lexicons/org/hypercerts/context/attachment.json index ee188193..b10c141c 100644 --- a/lexicons/org/hypercerts/context/attachment.json +++ b/lexicons/org/hypercerts/context/attachment.json @@ -80,6 +80,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/context/evaluation.json b/lexicons/org/hypercerts/context/evaluation.json index 3f18463a..17aef905 100644 --- a/lexicons/org/hypercerts/context/evaluation.json +++ b/lexicons/org/hypercerts/context/evaluation.json @@ -65,6 +65,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/context/measurement.json b/lexicons/org/hypercerts/context/measurement.json index 08d8d495..3b3fb191 100644 --- a/lexicons/org/hypercerts/context/measurement.json +++ b/lexicons/org/hypercerts/context/measurement.json @@ -99,6 +99,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created" + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/funding/receipt.json b/lexicons/org/hypercerts/funding/receipt.json index fb2806c1..e0522e7c 100644 --- a/lexicons/org/hypercerts/funding/receipt.json +++ b/lexicons/org/hypercerts/funding/receipt.json @@ -72,6 +72,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this receipt record was created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/org/hypercerts/workscope/tag.json b/lexicons/org/hypercerts/workscope/tag.json index c1260caa..346d99fe 100644 --- a/lexicons/org/hypercerts/workscope/tag.json +++ b/lexicons/org/hypercerts/workscope/tag.json @@ -79,6 +79,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/package-lock.json b/package-lock.json index f0079887..cdb0e747 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "tslib": "^2.8.1", "typescript": "^5.7.2", "typescript-eslint": "^8.51.0", - "viem": "^2.46.3", + "viem": "^2.47.5", "vitest": "^4.0.16" }, "peerDependencies": { @@ -3740,9 +3740,9 @@ "license": "MIT" }, "node_modules/ox": { - "version": "0.12.4", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.12.4.tgz", - "integrity": "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==", + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.5.tgz", + "integrity": "sha512-HgmHmBveYO40H/R3K6TMrwYtHsx/u6TAB+GpZlgJCoW0Sq5Ttpjih0IZZiwGQw7T6vdW4IAyobYrE2mdAvyF8Q==", "dev": true, "funding": [ { @@ -4617,9 +4617,9 @@ } }, "node_modules/viem": { - "version": "2.46.3", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.46.3.tgz", - "integrity": "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg==", + "version": "2.47.5", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.5.tgz", + "integrity": "sha512-nVrJEQ8GL4JoVIrMBF3wwpTUZun0cpojfnOZ+96GtDWhqxZkVdy6vOEgu+jwfXqfTA/+wrR+YsN9TBQmhDUk0g==", "dev": true, "funding": [ { @@ -4635,7 +4635,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.12.4", + "ox": "0.14.5", "ws": "8.18.3" }, "peerDependencies": { diff --git a/package.json b/package.json index 3460ea83..a35e9657 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "tslib": "^2.8.1", "typescript": "^5.7.2", "typescript-eslint": "^8.51.0", - "viem": "^2.46.3", + "viem": "^2.47.5", "vitest": "^4.0.16" }, "peerDependencies": { diff --git a/tests/validate-signature-inline.test.ts b/tests/validate-signature-inline.test.ts new file mode 100644 index 00000000..f6061e3e --- /dev/null +++ b/tests/validate-signature-inline.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { validate, ids } from "../generated/lexicons.js"; +import * as SignatureInline from "../generated/types/app/certified/signature/inline.js"; + +describe("app.certified.signature.inline", () => { + it("should accept a valid inline signature with all fields", () => { + const result = SignatureInline.validateMain({ + $type: "app.certified.signature.inline", + signatureType: "ES256K", + signature: new Uint8Array([1, 2, 3, 4]), + key: "did:plc:abc123#signing", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.key).toBe("did:plc:abc123#signing"); + expect(result.value.signatureType).toBe("ES256K"); + } + }); + + it("should accept a signature without optional signatureType", () => { + const result = SignatureInline.validateMain({ + $type: "app.certified.signature.inline", + signature: new Uint8Array([1, 2, 3, 4]), + key: "did:plc:abc123#signing", + }); + expect(result.success).toBe(true); + }); + + it("should accept all known algorithm identifiers", () => { + for (const alg of ["ES256", "ES256K", "Ed25519"]) { + const result = SignatureInline.validateMain({ + $type: "app.certified.signature.inline", + signatureType: alg, + signature: new Uint8Array([0xde, 0xad]), + key: "did:plc:test#key-1", + }); + expect(result.success).toBe(true); + } + }); + + it("should accept unknown algorithm identifiers (knownValues is open)", () => { + const result = SignatureInline.validateMain({ + $type: "app.certified.signature.inline", + signatureType: "PS256", + signature: new Uint8Array([0xca, 0xfe]), + key: "did:web:example.com#key-1", + }); + expect(result.success).toBe(true); + }); + + it("should reject when required field 'signature' is missing", () => { + const result = validate( + { + key: "did:plc:abc123#signing", + }, + ids.AppCertifiedSignatureInline, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject when required field 'key' is missing", () => { + const result = validate( + { + signature: new Uint8Array([1, 2, 3]), + }, + ids.AppCertifiedSignatureInline, + "main", + false, + ); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/validate-signature-proof.test.ts b/tests/validate-signature-proof.test.ts new file mode 100644 index 00000000..ff75623e --- /dev/null +++ b/tests/validate-signature-proof.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { validate, ids } from "../generated/lexicons.js"; +import * as SignatureProof from "../generated/types/app/certified/signature/proof.js"; + +describe("app.certified.signature.proof", () => { + it("should accept a valid proof with all fields", () => { + const result = SignatureProof.validateMain({ + $type: "app.certified.signature.proof", + cid: "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae", + note: "Verified by platform quality assurance process", + createdAt: "2024-06-15T12:00:00.000Z", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.cid).toBe( + "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae", + ); + expect(result.value.note).toBe( + "Verified by platform quality assurance process", + ); + } + }); + + it("should accept a proof with only the required cid field", () => { + const result = SignatureProof.validateMain({ + $type: "app.certified.signature.proof", + cid: "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae", + }); + expect(result.success).toBe(true); + }); + + it("should reject when required field 'cid' is missing", () => { + const result = validate( + { + note: "Some note", + createdAt: "2024-06-15T12:00:00.000Z", + }, + ids.AppCertifiedSignatureProof, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should require $type since proof is a record type", () => { + const result = validate( + { + cid: "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae", + }, + ids.AppCertifiedSignatureProof, + "main", + true, + ); + expect(result.success).toBe(false); + }); + + it("should reject an invalid datetime format", () => { + const result = validate( + { + cid: "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae", + createdAt: "not-a-date", + }, + ids.AppCertifiedSignatureProof, + "main", + false, + ); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/validate-signatures-on-record.test.ts b/tests/validate-signatures-on-record.test.ts new file mode 100644 index 00000000..3e740e4d --- /dev/null +++ b/tests/validate-signatures-on-record.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { ids } from "../generated/lexicons.js"; +import * as Activity from "../generated/types/org/hypercerts/claim/activity.js"; + +/** + * Tests that the `signatures` property works correctly on record lexicons. + * Uses activity as a representative — all 20 record lexicons share the + * same signatures property definition. + */ +describe("signatures property on records", () => { + const baseActivity = { + title: "Test Activity", + shortDescription: "A test activity for signature validation", + createdAt: "2024-06-15T12:00:00.000Z", + }; + + it("should accept a record without signatures", () => { + const result = Activity.validateMain({ + $type: ids.OrgHypercertsClaimActivity, + ...baseActivity, + }); + expect(result.success).toBe(true); + }); + + it("should accept a record with an inline signature", () => { + const result = Activity.validateMain({ + $type: ids.OrgHypercertsClaimActivity, + ...baseActivity, + signatures: [ + { + $type: "app.certified.signature.inline", + signatureType: "ES256K", + signature: new Uint8Array([1, 2, 3, 4]), + key: "did:plc:platform123#signing", + }, + ], + }); + expect(result.success).toBe(true); + }); + + it("should accept a record with a remote attestation (strongRef)", () => { + const result = Activity.validateMain({ + $type: ids.OrgHypercertsClaimActivity, + ...baseActivity, + signatures: [ + { + $type: "com.atproto.repo.strongRef", + uri: "at://did:plc:verifier/app.certified.signature.proof/3k2abc", + cid: "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae", + }, + ], + }); + expect(result.success).toBe(true); + }); + + it("should accept a record with both inline and remote signatures", () => { + const result = Activity.validateMain({ + $type: ids.OrgHypercertsClaimActivity, + ...baseActivity, + signatures: [ + { + $type: "app.certified.signature.inline", + signature: new Uint8Array([0xde, 0xad]), + key: "did:plc:signer1#key-1", + }, + { + $type: "com.atproto.repo.strongRef", + uri: "at://did:plc:verifier/app.certified.signature.proof/abc123", + cid: "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae", + }, + { + $type: "app.certified.signature.inline", + signatureType: "Ed25519", + signature: new Uint8Array([0xca, 0xfe]), + key: "did:plc:signer2#key-2", + }, + ], + }); + expect(result.success).toBe(true); + }); + + it("should accept an empty signatures array", () => { + const result = Activity.validateMain({ + $type: ids.OrgHypercertsClaimActivity, + ...baseActivity, + signatures: [], + }); + expect(result.success).toBe(true); + }); +}); From d20188009e70d3877fefb96fe9e91607a0b26691 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 13:40:49 +0000 Subject: [PATCH 02/11] refactor: align signature lexicons with ATProto Attestation Specification Tighten the signature lexicons added in this PR to conform strictly to the ATProtocol Attestation Specification, rather than carrying speculative extensions inherited from the original design draft. - Remove signatureType from app.certified.signature.inline. The spec has no such field; the signing curve is canonically derived from the multicodec prefix on the verification method's publicKeyMultibase. A separate tag was redundant in the happy path and a downgrade-attack vector in the adversarial path (could disagree with the multicodec). - Drop Ed25519 from the documented algorithm story. EdDSA isn't excluded by normative MUST/SHALL language in the spec but the spec's signing primitive is ECDSA + BIP-0062 low-S and the multicodec->curve table only covers 0xE701 (K-256) and 0x1200 (P-256). Ed25519-signed records would be schema-valid but unverifiable by spec-conforming verifiers. - Remove maxLength: 100 cap on app.certified.signature.defs#list. No rationale was ever recorded for the bound and the spec does not restrict array length. - Update lexicon descriptions to use spec terminology: 36-byte CIDv1, canonical DAG-CBOR, BIP-0062 low-S, \$sig repository binding. - Rewrite README signatures section to claim conformance explicitly, drop the now-stale JOSE algorithm-identifiers table, add a brief signing/verification procedure summary, and explain replay defense via CID + \$sig.repository. - Update changeset language from "inspired by" to "conforms to". - Drop signatureType usages from tests and remove the now-meaningless open-knownValues test case. - Regenerate SCHEMAS.md. --- .changeset/add-signature-support.md | 18 ++--- README.md | 42 ++++++---- SCHEMAS.md | 80 ++++++++++++++++---- lexicons/app/certified/signature/defs.json | 7 +- lexicons/app/certified/signature/inline.json | 12 +-- lexicons/app/certified/signature/proof.json | 4 +- tests/validate-signature-inline.test.ts | 35 +-------- tests/validate-signatures-on-record.test.ts | 2 - 8 files changed, 111 insertions(+), 89 deletions(-) diff --git a/.changeset/add-signature-support.md b/.changeset/add-signature-support.md index 6706c90e..18e560a5 100644 --- a/.changeset/add-signature-support.md +++ b/.changeset/add-signature-support.md @@ -4,13 +4,13 @@ Add cryptographic signature support to all lexicons -Adds optional cryptographic signature support to all record lexicons, enabling platform attestation and verification that records were created through trusted services. This is inspired by the [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r) by Nick Gerakines. +Adds optional cryptographic signature support to all record lexicons, enabling platform attestation and verification that records were created through trusted services. The on-the-wire shape, signing procedure, and verification procedure all conform to Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r). **New lexicons:** -- `app.certified.signature.inline` - Inline cryptographic signature object with JOSE algorithm identifiers (ES256, ES256K, Ed25519) -- `app.certified.signature.defs` - Shared type definitions for signatures, providing the `#list` array def (a union of inline signatures and strongRefs to remote proofs) that is referenced from record lexicons -- `app.certified.signature.proof` - Remote attestation proof record containing the CID of attested content +- `app.certified.signature.inline` - Inline cryptographic signature object. Two required fields only: `signature` (ECDSA bytes, low-S per BIP-0062, signing the record's CID) and `key` (DID verification method reference). The signing curve is determined by the multicodec prefix on the verification method's `publicKeyMultibase`, so no separate algorithm-tag field is carried on the signature itself. +- `app.certified.signature.defs` - Shared type definitions for signatures, providing the `#list` array def (open union of inline signatures and strongRefs to remote proofs) that is referenced from record lexicons. +- `app.certified.signature.proof` - Remote attestation proof record containing the CID of the attested content (computed with the spec's `$sig`-repository binding). **Changes to existing lexicons:** @@ -42,11 +42,7 @@ Adds optional cryptographic signature support to all record lexicons, enabling p This is a non-breaking extension - signatures are optional on all records. Two signature patterns are supported: -1. **Inline signatures** - Embedded directly in the record via `app.certified.signature.inline` -2. **Remote attestations** - References to proof records in other repositories via `com.atproto.repo.strongRef` +1. **Inline signatures** - Embedded directly in the record via `app.certified.signature.inline`. +2. **Remote attestations** - References to proof records in other repositories via `com.atproto.repo.strongRef`. -Signature algorithm identifiers use [JOSE format](https://www.iana.org/assignments/jose/jose.xhtml) per ATProto convention: - -- `ES256K` - secp256k1 (Ethereum/Bitcoin curve, ATProto default) -- `ES256` - P-256 (NIST curve, WebCrypto compatible) -- `Ed25519` - EdDSA +The signing curve (P-256 / K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`, per the ATProtocol Attestation Specification. ECDSA with the low-S variant per BIP-0062 is mandatory. diff --git a/README.md b/README.md index 3f07c060..65012643 100644 --- a/README.md +++ b/README.md @@ -583,10 +583,12 @@ const attachment = { Nearly all record lexicons support optional cryptographic signatures via the `signatures` property. This enables platform attestation and verification that records were created through trusted services. (`app.certified.link.evm` is the sole exception — see the [Signatures table](#signatures-appcertifiedsignature) above for why.) -Signatures support two patterns: +The on-the-wire shape, signing procedure, and verification procedure all conform to Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r). In particular, signatures sign the **CID** of the record (not its raw bytes), and CID generation injects a temporary `$sig` object carrying the housing repository's DID so that signatures cannot be replayed across content versions or across repositories. -1. **Inline signatures**: Embedded directly in the record -2. **Remote attestations**: References to proof records in other repositories (via `strongRef`) +Two patterns are supported: + +1. **Inline signatures**: Embedded directly in the record via `app.certified.signature.inline`. +2. **Remote attestations**: References to `app.certified.signature.proof` records in other repositories, via `com.atproto.repo.strongRef`. ```typescript import { ACTIVITY_NSID } from "@hypercerts-org/lexicon"; @@ -601,13 +603,12 @@ const signedActivity = { // Inline signature (embedded) { $type: "app.certified.signature.inline", - signatureType: "ES256K", // JOSE algorithm: secp256k1 signature: new Uint8Array([ - /* signature bytes */ + /* ECDSA signature bytes (low-S per BIP-0062) over the record's CID */ ]), key: "did:plc:platform123#signing", // DID verification method }, - // Remote attestation (reference to proof in another repo) + // Remote attestation (reference to a proof record in another repo) { $type: "com.atproto.repo.strongRef", uri: "at://did:plc:verifier/app.certified.signature.proof/abc123", @@ -617,15 +618,28 @@ const signedActivity = { }; ``` -#### Signature Algorithm Identifiers +#### Signing Algorithm + +The spec mandates **ECDSA** with the low-S variant per BIP-0062. The signing curve is determined by the multicodec prefix of the verification method's `publicKeyMultibase` in the signer's DID document: + +| Multicodec prefix | Curve | JOSE alg | +| ----------------- | ----------------- | -------- | +| `0xE701` | secp256k1 (K-256) | `ES256K` | +| `0x1200` | P-256 (secp256r1) | `ES256` | + +There is deliberately no `signatureType` field on the inline signature: the algorithm is canonically derived from the resolved key, and a separate tag would risk disagreeing with the multicodec. + +#### Signing & Verification Procedure (Summary) + +To **sign** a record: -The `signatureType` property uses [JOSE algorithm identifiers](https://www.iana.org/assignments/jose/jose.xhtml): +1. Take the record without its `signatures` field. +2. Insert a temporary `$sig` object containing `$type` (the attestation type identifier) and `repository` (the housing repo's DID). +3. Encode the result as canonical DAG-CBOR (sorted keys by CBOR byte length then lexically, definite-length items, shortest integer encodings). +4. SHA-256 hash to produce a 36-byte CIDv1 (`0x01 0x71 0x12 0x20` + 32-byte hash). +5. ECDSA-sign those 36 bytes (low-S) with the private key matching the verification method named in `key`. -| Value | Curve | Description | -| --------- | --------- | --------------------------------------- | -| `ES256` | P-256 | NIST curve, WebCrypto compatible | -| `ES256K` | secp256k1 | Ethereum/Bitcoin curve, ATProto default | -| `Ed25519` | Ed25519 | EdDSA, increasingly popular | +To **verify**, reconstruct the same CID using the housing repo's DID, resolve `key` to a public key via the DID document, and check the ECDSA signature against the CID bytes. #### Remote Attestation Proofs @@ -636,7 +650,7 @@ import { SIGNATURE_PROOF_NSID } from "@hypercerts-org/lexicon"; const proof = { $type: SIGNATURE_PROOF_NSID, - cid: "bafy...", // CID of the attested content + cid: "bafy...", // CID of the attested content (computed as above) note: "Verified by platform quality assurance process", createdAt: new Date().toISOString(), }; diff --git a/SCHEMAS.md b/SCHEMAS.md index 6e4f1501..1041fe64 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -29,6 +29,7 @@ Hypercerts-specific lexicons for tracking impact work and claims. | `locations` | `ref[]` | ❌ | An array of strong references to the location where activity was performed. The record referenced must conform with the lexicon app.certified.location. | maxLength: 1000 | | `rights` | `ref` | ❌ | A strong reference to the rights that this hypercert has. The record referenced must conform with the lexicon org.hypercerts.claim.rights. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | #### Defs @@ -83,6 +84,7 @@ A free-form string describing the work scope for simple or legacy scopes. | `startDate` | `string` | ❌ | When this contribution started. Should fall within the parent hypercert's timeframe. | | | `endDate` | `string` | ❌ | When this contribution finished. Should fall within the parent hypercert's timeframe. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -94,12 +96,13 @@ A free-form string describing the work scope for simple or legacy scopes. #### Properties -| Property | Type | Required | Description | Comments | -| ------------- | -------- | -------- | ------------------------------------------------------------------ | --------------- | -| `identifier` | `string` | ❌ | DID (did:plc:...) or URI to a social profile of the contributor. | maxLength: 2048 | -| `displayName` | `string` | ❌ | Human-readable name for the contributor as it should appear in UI. | maxLength: 100 | -| `image` | `union` | ❌ | The contributor visual representation as a URI or image blob. | | -| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| Property | Type | Required | Description | Comments | +| ------------- | -------- | -------- | --------------------------------------------------------------------- | --------------- | +| `identifier` | `string` | ❌ | DID (did:plc:...) or URI to a social profile of the contributor. | maxLength: 2048 | +| `displayName` | `string` | ❌ | Human-readable name for the contributor as it should appear in UI. | maxLength: 100 | +| `image` | `union` | ❌ | The contributor visual representation as a URI or image blob. | | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -118,6 +121,7 @@ A free-form string describing the work scope for simple or legacy scopes. | `rightsDescription` | `string` | ✅ | Detailed explanation of the rights holders' permissions, restrictions, and conditions | maxLength: 10000, maxGraphemes: 1000 | | `attachment` | `union` | ❌ | An attachment to define the rights further, e.g. a legal document. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -141,6 +145,7 @@ A free-form string describing the work scope for simple or legacy scopes. | `items` | `ref[]` | ❌ | Array of items in this collection with optional weights. | maxLength: 1000 | | `location` | `ref` | ❌ | A strong reference to the location where this collection's activities were performed. The record referenced must conform with the lexicon app.certified.location. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | #### Defs @@ -170,6 +175,7 @@ An item in a collection, with an identifier and optional weight. | `acknowledged` | `boolean` | ✅ | Whether the relationship is acknowledged (true) or rejected (false). | | | `comment` | `string` | ❌ | Optional plain-text comment providing additional context or reasoning. | maxLength: 10000, maxGraphemes: 1000 | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -192,6 +198,7 @@ An item in a collection, with an identifier and optional weight. | `description` | `union` | ❌ | Long-form description of the attachment. An inline string for plain text or markdown, a Leaflet linear document for rich-text content, or a strong reference to an external description record. | | | `location` | `ref` | ❌ | A strong reference to the location where this attachment's subject matter occurred. The record referenced must conform with the lexicon app.certified.location. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -213,6 +220,7 @@ An item in a collection, with an identifier and optional weight. | `score` | `ref` | ❌ | Overall score for an evaluation on a numeric scale. | | | `location` | `ref` | ❌ | An optional reference for georeferenced evaluations. The record referenced must conform with the lexicon app.certified.location. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | #### Defs @@ -252,6 +260,7 @@ Overall score for an evaluation on a numeric scale. | `comment` | `string` | ❌ | Short comment of this measurement, suitable for previews and list views. Rich text annotations may be provided via `commentFacets`. | maxLength: 3000, maxGraphemes: 300 | | `commentFacets` | `ref[]` | ❌ | Rich text annotations for `comment` (mentions, URLs, hashtags, etc). | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -276,6 +285,7 @@ Overall score for an evaluation on a numeric scale. | `notes` | `string` | ❌ | Optional notes or additional context for this funding receipt. | maxLength: 500 | | `occurredAt` | `string` | ❌ | Timestamp when the payment occurred. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this receipt record was created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | #### Defs @@ -325,6 +335,7 @@ A free-text string value (e.g. a display name, wallet address, or other identifi | `sameAs` | `string[]` | ❌ | URIs to semantically equivalent concepts in external ontologies or taxonomies (e.g., Wikidata QIDs, ENVO terms, SDG targets). Used for interoperability, not as documentation. | maxLength: 20 | | `referenceDocument` | `union` | ❌ | Link to a governance or reference document where this work scope tag is defined and further explained. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -349,6 +360,7 @@ Certified lexicons are common/shared lexicons that can be used across multiple p | `name` | `string` | ❌ | Human-readable name for this location (e.g. 'Golden Gate Park', 'San Francisco Bay Area') | maxLength: 1000, maxGraphemes: 100 | | `description` | `string` | ❌ | Additional context about this location, such as its significance to the work or specific boundaries | maxLength: 2000, maxGraphemes: 500 | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | #### Defs @@ -378,6 +390,7 @@ A location represented as a string, e.g. coordinates or a small GeoJSON string. | `description` | `string` | ❌ | Optional short statement describing what the badge represents. | maxLength: 5000, maxGraphemes: 500 | | `allowedIssuers` | `ref[]` | ❌ | Optional allowlist of DIDs allowed to issue this badge. If omitted, anyone may issue it. | maxLength: 100 | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -389,13 +402,14 @@ A location represented as a string, e.g. coordinates or a small GeoJSON string. #### Properties -| Property | Type | Required | Description | Comments | -| ----------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `badge` | `ref` | ✅ | Strong reference to the badge definition at the time of award. The record referenced must conform with the lexicon app.certified.badge.definition. | | -| `subject` | `union` | ✅ | Entity the badge award is for (either an account DID or any specific AT Protocol record), e.g. a user, a project, or a specific activity claim. | | -| `note` | `string` | ❌ | Optional statement explaining the reason for this badge award. | maxLength: 500 | -| `url` | `string` | ❌ | Optional URL the badge award links to. | maxLength: 2048 | -| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| Property | Type | Required | Description | Comments | +| ------------ | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| `badge` | `ref` | ✅ | Strong reference to the badge definition at the time of award. The record referenced must conform with the lexicon app.certified.badge.definition. | | +| `subject` | `union` | ✅ | Entity the badge award is for (either an account DID or any specific AT Protocol record), e.g. a user, a project, or a specific activity claim. | | +| `note` | `string` | ❌ | Optional statement explaining the reason for this badge award. | maxLength: 500 | +| `url` | `string` | ❌ | Optional URL the badge award links to. | maxLength: 2048 | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -413,6 +427,7 @@ A location represented as a string, e.g. coordinates or a small GeoJSON string. | `response` | `string` | ✅ | The recipient’s response for the badge (accepted or rejected). | Known values: `accepted`, `rejected` | | `weight` | `string` | ❌ | Optional relative weight for accepted badges, assigned by the recipient. | maxLength: 50 | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -433,6 +448,7 @@ A location represented as a string, e.g. coordinates or a small GeoJSON string. | `longDescription` | `union` | ❌ | Long-form description of the organization, such as its mission, history, or detailed project narrative. An inline string for plain text or markdown, a Leaflet linear document record embedded directly, or a strong reference to an existing document record. | | | `visibility` | `string` | ❌ | Controls whether the organization or project is publicly discoverable on platforms that honor this setting. | Known values: `public`, `unlisted` | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | #### Defs @@ -464,6 +480,7 @@ A labeled URL reference. | `avatar` | `union` | ❌ | Small image to be displayed next to posts from account. AKA, 'profile picture' | | | `banner` | `union` | ❌ | Larger horizontal image to display behind profile view. | | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- @@ -522,6 +539,41 @@ The EIP-712 typed data message that was signed by the wallet. Contains the field --- +### `app.certified.signature.defs` + +**Description:** Common type definitions for cryptographic signatures attached to records, per the ATProtocol Attestation Specification. + +--- + +### `app.certified.signature.inline` + +**Description:** Inline attestation signature embedded directly in a record. + +#### Properties + +| Property | Type | Required | Description | Comments | +| ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `signature` | `bytes` | ✅ | ECDSA signature bytes (raw r,s) over the 36-byte CID of the record. Low-S variant per BIP-0062 is mandatory. | | +| `key` | `string` | ✅ | Full DID verification method reference (format: did:{method}:{identifier}#{fragment}). Identifies the signer and the specific key used; the key's multicodec prefix determines the signing curve. | maxLength: 512 | + +--- + +### `app.certified.signature.proof` + +**Description:** Remote attestation proof containing the CID of the content being attested. + +**Key:** `tid` + +#### Properties + +| Property | Type | Required | Description | Comments | +| ----------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `cid` | `string` | ✅ | CID of the attested content, computed per the ATProtocol Attestation Specification: encode the record with the `signatures` field removed and a temporary `$sig` object inserted (containing `$type` and the housing `repository` DID) as canonical DAG-CBOR, then SHA-256 hash to produce a 36-byte CIDv1. | | +| `note` | `string` | ❌ | Optional note explaining the attestation purpose or context. | maxLength: 500 | +| `createdAt` | `string` | ❌ | Client-declared timestamp when this proof was created. | | + +--- + ## Type Definitions Common type definitions used across all protocols. @@ -684,6 +736,7 @@ Specifies the sub-string range a facet feature applies to. Start index is inclus | `config` | `ref` | ❌ | Visual configuration for a hyperboard's background, colors, and layout. | | | `contributorConfigs` | `ref[]` | ❌ | Per-contributor configuration entries for this board. | maxLength: 1000 | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | #### Defs @@ -738,6 +791,7 @@ Configuration for a specific contributor within a board. Values serve as fallbac | `hoverIframeUrl` | `string` | ❌ | Default hover iframe URL for this user across boards. | maxLength: 2048 | | `url` | `string` | ❌ | Default click-through link URL for this user across boards. | maxLength: 2048 | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- diff --git a/lexicons/app/certified/signature/defs.json b/lexicons/app/certified/signature/defs.json index 450a64a6..26a9106d 100644 --- a/lexicons/app/certified/signature/defs.json +++ b/lexicons/app/certified/signature/defs.json @@ -1,16 +1,15 @@ { "lexicon": 1, "id": "app.certified.signature.defs", - "description": "Common type definitions for cryptographic signatures attached to records.", + "description": "Common type definitions for cryptographic signatures attached to records, per the ATProtocol Attestation Specification.", "defs": { "list": { "type": "array", - "description": "Reusable array of cryptographic signatures attesting to a record's content. Uses an open union to support inline signatures and strong references to remote attestation proof records.", + "description": "Reusable array of cryptographic signatures attesting to a record's content. Open union of inline signatures and strong references to remote attestation proof records.", "items": { "type": "union", "refs": ["app.certified.signature.inline", "com.atproto.repo.strongRef"] - }, - "maxLength": 100 + } } } } diff --git a/lexicons/app/certified/signature/inline.json b/lexicons/app/certified/signature/inline.json index c4947517..6659b28f 100644 --- a/lexicons/app/certified/signature/inline.json +++ b/lexicons/app/certified/signature/inline.json @@ -1,26 +1,20 @@ { "lexicon": 1, "id": "app.certified.signature.inline", - "description": "An inline cryptographic signature for attesting to record content. The signature is generated by signing the CID of the record content (with repository binding via $sig metadata during CID generation).", + "description": "An inline cryptographic signature attesting to a record's content. Conforms to the ATProtocol Attestation Specification: the signed input is the 36-byte CIDv1 (dag-cbor + SHA-256) of the record with the `signatures` field removed and a temporary `$sig` metadata object (containing `$type` and the housing repository DID) inserted before canonical DAG-CBOR encoding. ECDSA with the low-S variant per BIP-0062 is required; the curve (P-256 or K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`.", "defs": { "main": { "type": "object", "description": "Inline attestation signature embedded directly in a record.", "required": ["signature", "key"], "properties": { - "signatureType": { - "type": "string", - "description": "The signature algorithm identifier (JOSE format). ES256K for secp256k1 (Ethereum/Bitcoin), ES256 for P-256 (NIST/WebCrypto), Ed25519 for EdDSA. Optional but recommended for explicit validation.", - "knownValues": ["ES256", "ES256K", "Ed25519"], - "maxLength": 32 - }, "signature": { "type": "bytes", - "description": "The cryptographic signature bytes over the CID. ECDSA signatures must use the low-S variant per BIP-0062." + "description": "ECDSA signature bytes (raw r,s) over the 36-byte CID of the record. Low-S variant per BIP-0062 is mandatory." }, "key": { "type": "string", - "description": "Full DID verification method reference (format: did:{method}:{identifier}#{fragment}). Identifies the signer and specific key used.", + "description": "Full DID verification method reference (format: did:{method}:{identifier}#{fragment}). Identifies the signer and the specific key used; the key's multicodec prefix determines the signing curve.", "maxLength": 512 } } diff --git a/lexicons/app/certified/signature/proof.json b/lexicons/app/certified/signature/proof.json index 9c2cc726..fa207606 100644 --- a/lexicons/app/certified/signature/proof.json +++ b/lexicons/app/certified/signature/proof.json @@ -1,7 +1,7 @@ { "lexicon": 1, "id": "app.certified.signature.proof", - "description": "A remote attestation proof record containing the CID of attested content. This record lives in the attestor's repository and can be referenced via strongRef from the attested record's signatures array.", + "description": "A remote attestation proof record holding the CID of attested content. Lives in the attestor's repository and is referenced from the attested record's `signatures` array via `com.atproto.repo.strongRef`. Conforms to the ATProtocol Attestation Specification.", "defs": { "main": { "type": "record", @@ -14,7 +14,7 @@ "cid": { "type": "string", "format": "cid", - "description": "CID of the attested content (record without signatures field, with $sig metadata including repository DID)." + "description": "CID of the attested content, computed per the ATProtocol Attestation Specification: encode the record with the `signatures` field removed and a temporary `$sig` object inserted (containing `$type` and the housing `repository` DID) as canonical DAG-CBOR, then SHA-256 hash to produce a 36-byte CIDv1." }, "note": { "type": "string", diff --git a/tests/validate-signature-inline.test.ts b/tests/validate-signature-inline.test.ts index f6061e3e..8072d9cd 100644 --- a/tests/validate-signature-inline.test.ts +++ b/tests/validate-signature-inline.test.ts @@ -3,51 +3,18 @@ import { validate, ids } from "../generated/lexicons.js"; import * as SignatureInline from "../generated/types/app/certified/signature/inline.js"; describe("app.certified.signature.inline", () => { - it("should accept a valid inline signature with all fields", () => { + it("should accept a valid inline signature", () => { const result = SignatureInline.validateMain({ $type: "app.certified.signature.inline", - signatureType: "ES256K", signature: new Uint8Array([1, 2, 3, 4]), key: "did:plc:abc123#signing", }); expect(result.success).toBe(true); if (result.success) { expect(result.value.key).toBe("did:plc:abc123#signing"); - expect(result.value.signatureType).toBe("ES256K"); } }); - it("should accept a signature without optional signatureType", () => { - const result = SignatureInline.validateMain({ - $type: "app.certified.signature.inline", - signature: new Uint8Array([1, 2, 3, 4]), - key: "did:plc:abc123#signing", - }); - expect(result.success).toBe(true); - }); - - it("should accept all known algorithm identifiers", () => { - for (const alg of ["ES256", "ES256K", "Ed25519"]) { - const result = SignatureInline.validateMain({ - $type: "app.certified.signature.inline", - signatureType: alg, - signature: new Uint8Array([0xde, 0xad]), - key: "did:plc:test#key-1", - }); - expect(result.success).toBe(true); - } - }); - - it("should accept unknown algorithm identifiers (knownValues is open)", () => { - const result = SignatureInline.validateMain({ - $type: "app.certified.signature.inline", - signatureType: "PS256", - signature: new Uint8Array([0xca, 0xfe]), - key: "did:web:example.com#key-1", - }); - expect(result.success).toBe(true); - }); - it("should reject when required field 'signature' is missing", () => { const result = validate( { diff --git a/tests/validate-signatures-on-record.test.ts b/tests/validate-signatures-on-record.test.ts index 3e740e4d..b9b86ef7 100644 --- a/tests/validate-signatures-on-record.test.ts +++ b/tests/validate-signatures-on-record.test.ts @@ -29,7 +29,6 @@ describe("signatures property on records", () => { signatures: [ { $type: "app.certified.signature.inline", - signatureType: "ES256K", signature: new Uint8Array([1, 2, 3, 4]), key: "did:plc:platform123#signing", }, @@ -70,7 +69,6 @@ describe("signatures property on records", () => { }, { $type: "app.certified.signature.inline", - signatureType: "Ed25519", signature: new Uint8Array([0xca, 0xfe]), key: "did:plc:signer2#key-2", }, From 84d599898c567c5b4615547d3291bed8d565e812 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 13:49:43 +0000 Subject: [PATCH 03/11] test: rename validate-signatures-on-record to validate-signature-defs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align with the per-lexicon naming convention in AGENTS.md ("one file per lexicon, named validate-.test.ts"). The tests cover the array def at app.certified.signature.defs#list (open union of inline + strongRef, optional/empty/mixed array semantics), so signature-defs is the lexicon under test. Activity is just the representative carrier — all 19 record lexicons share the same def. Addresses CodeRabbit feedback on PR #170. --- ...on-record.test.ts => validate-signature-defs.test.ts} | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) rename tests/{validate-signatures-on-record.test.ts => validate-signature-defs.test.ts} (87%) diff --git a/tests/validate-signatures-on-record.test.ts b/tests/validate-signature-defs.test.ts similarity index 87% rename from tests/validate-signatures-on-record.test.ts rename to tests/validate-signature-defs.test.ts index b9b86ef7..c7ce3d43 100644 --- a/tests/validate-signatures-on-record.test.ts +++ b/tests/validate-signature-defs.test.ts @@ -3,11 +3,12 @@ import { ids } from "../generated/lexicons.js"; import * as Activity from "../generated/types/org/hypercerts/claim/activity.js"; /** - * Tests that the `signatures` property works correctly on record lexicons. - * Uses activity as a representative — all 20 record lexicons share the - * same signatures property definition. + * Tests the `app.certified.signature.defs#list` array def — specifically + * its union semantics (inline signature + strongRef) and array-level + * behavior (optional, empty, mixed). Uses activity as a representative + * carrier; all 19 record lexicons reference the same def. */ -describe("signatures property on records", () => { +describe("app.certified.signature.defs#list (via activity carrier)", () => { const baseActivity = { title: "Test Activity", shortDescription: "A test activity for signature validation", From 801704f4cdcec9bd3074e7c39aa0b89b63fe0d68 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 14:17:10 +0000 Subject: [PATCH 04/11] docs: clarify test-naming convention allows judgment for cross-cutting tests The "one file per lexicon" rule is a default, not a strict requirement. When a test exercises behavior that spans multiple lexicons or code files (shared *.defs lexicons, cross-cutting union semantics, etc.), pick the clearest name for what's under test rather than duplicating near-identical tests across every carrier. Cite validate-signature-defs.test.ts as an illustrative example. Surfaced by CodeRabbit feedback on PR #170. --- AGENTS.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7e5b436e..07566ed2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,10 +294,25 @@ npm run check ### Writing Tests -Tests live in `tests/` with one file per lexicon, named +Tests live in `tests/` and by default use one file per lexicon, named `validate-.test.ts` (e.g. `validate-link-evm.test.ts`, `validate-rights.test.ts`). +This is a convention, not a strict rule. When a test genuinely +exercises behavior that spans multiple lexicons or code files — +shared `*.defs` lexicons referenced from many records, cross-cutting +union semantics, validation behavior that surfaces only in +composition, etc. — pick the clearest, most concise name that +describes what's actually under test, even if that file doesn't map +1:1 to a single lexicon. Don't duplicate near-identical tests across +every carrier just to satisfy the naming convention. + +For example, `validate-signature-defs.test.ts` exercises +`app.certified.signature.defs#list` (union of inline + strongRef, +array semantics) via a representative record carrier, rather than +spawning 19 near-identical files for every record lexicon that +references the def. + The generated code provides two ways to validate records: - **`validate()` from `generated/lexicons.js`** — generic, untyped. From e0a2191a26b05bb2c0ccdab551782e4f67307618 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 14:39:31 +0000 Subject: [PATCH 05/11] feat: restructure inline def into signature.defs#inline and extend signatures to all records Two coupled changes: 1. Move inline signature object from its own lexicon (app.certified.signature.inline) into signature.defs#inline. Aligns with the earlier signature.list -> signature.defs#list move: non-record helper defs belong in the *.defs lexicon, not as standalone lexicons. Matches ATProto convention (e.g. app.bsky.embed.defs, app.bsky.richtext.facet#mention). 2. Add the signatures property to graph.follow and link.evm, bringing all 21 record lexicons under the signature umbrella. On link.evm specifically: the original PR deliberately excluded this record on the rationale that its EIP-712 proof field already provides integrity. That conflated two orthogonal trust statements. The EIP-712 proof proves wallet consent (the EVM key holder agreed to be linked to the DID). The signatures array proves record provenance (e.g. that a platform UI minted the record, defending against replay of harvested EIP-712 signatures). Both are useful and they do not conflict, so signatures applies here too. Test changes: - Rename tests/validate-signature-inline.test.ts to validate-signature-defs-inline.test.ts (the lexicon under test is now signature.defs). - Switch to the generated validateInline() helper instead of the generic validate() + cast, removing an `as unknown` smell. - Update inline $type strings from app.certified.signature.inline to app.certified.signature.defs#inline throughout. Regenerate SCHEMAS.md. --- .changeset/add-signature-support.md | 13 ++++--- README.md | 22 +++++------ SCHEMAS.md | 38 +++++++++---------- lexicons/app/certified/graph/follow.json | 5 +++ lexicons/app/certified/link/evm.json | 5 +++ lexicons/app/certified/signature/defs.json | 21 +++++++++- lexicons/app/certified/signature/inline.json | 23 ----------- ...=> validate-signature-defs-inline.test.ts} | 18 ++++----- tests/validate-signature-defs.test.ts | 8 ++-- 9 files changed, 80 insertions(+), 73 deletions(-) delete mode 100644 lexicons/app/certified/signature/inline.json rename tests/{validate-signature-inline.test.ts => validate-signature-defs-inline.test.ts} (66%) diff --git a/.changeset/add-signature-support.md b/.changeset/add-signature-support.md index 18e560a5..f617fbde 100644 --- a/.changeset/add-signature-support.md +++ b/.changeset/add-signature-support.md @@ -8,13 +8,14 @@ Adds optional cryptographic signature support to all record lexicons, enabling p **New lexicons:** -- `app.certified.signature.inline` - Inline cryptographic signature object. Two required fields only: `signature` (ECDSA bytes, low-S per BIP-0062, signing the record's CID) and `key` (DID verification method reference). The signing curve is determined by the multicodec prefix on the verification method's `publicKeyMultibase`, so no separate algorithm-tag field is carried on the signature itself. -- `app.certified.signature.defs` - Shared type definitions for signatures, providing the `#list` array def (open union of inline signatures and strongRefs to remote proofs) that is referenced from record lexicons. +- `app.certified.signature.defs` - Shared type definitions for signatures. Provides: + - `#list` - open union array of inline signatures and `com.atproto.repo.strongRef` references to remote proofs + - `#inline` - inline signature object with two required fields: `signature` (ECDSA bytes, low-S per BIP-0062, signing the record's CID) and `key` (DID verification method reference). The signing curve is determined by the multicodec prefix on the verification method's `publicKeyMultibase`, so no separate algorithm-tag field is carried on the signature itself. - `app.certified.signature.proof` - Remote attestation proof record containing the CID of the attested content (computed with the spec's `$sig`-repository binding). **Changes to existing lexicons:** -19 record lexicons now include an optional `signatures` property (a ref to `app.certified.signature.defs#list`) placed directly on the record with no wrapper object: +All 21 record lexicons now include an optional `signatures` property (a ref to `app.certified.signature.defs#list`) placed directly on the record with no wrapper object: - `org.hypercerts.claim.activity` - `org.hypercerts.claim.contribution` @@ -35,14 +36,16 @@ Adds optional cryptographic signature support to all record lexicons, enabling p - `app.certified.actor.profile` - `app.certified.actor.organization` - `app.certified.location` +- `app.certified.graph.follow` +- `app.certified.link.evm` -`app.certified.link.evm` is deliberately excluded: it already carries its own EIP-712 wallet-ownership proof in the `proof` field, which is the integrity mechanism for its semantic DID ↔ wallet claim. Adding a second, incompatible signing mechanism on the same record would provide no additional trust and invite confusion. +Note on `app.certified.link.evm`: this record carries two orthogonal integrity primitives. The EIP-712 `proof` field proves wallet consent (the EVM key holder agreed to be linked to the DID). The `signatures` array proves record provenance (e.g. that a platform UI minted the record, defending against replay of harvested EIP-712 signatures). Both are useful and they do not conflict. **Design notes:** This is a non-breaking extension - signatures are optional on all records. Two signature patterns are supported: -1. **Inline signatures** - Embedded directly in the record via `app.certified.signature.inline`. +1. **Inline signatures** - Embedded directly in the record via `app.certified.signature.defs#inline`. 2. **Remote attestations** - References to proof records in other repositories via `com.atproto.repo.strongRef`. The signing curve (P-256 / K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`, per the ATProtocol Attestation Specification. ECDSA with the low-S variant per BIP-0062 is mandatory. diff --git a/README.md b/README.md index 65012643..f0915330 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,7 @@ CERTIFIED ─ shared lexicons (certified.app) actor/organization (org metadata) badge/response ──► badge/award ──► badge/definition graph/follow ────────────► account DID (social follow) - signature/inline (embedded cryptographic signature) - signature/defs (shared signatures array def) + signature/defs (shared #list and #inline defs) signature/proof (remote attestation proof record) ``` @@ -283,15 +282,14 @@ await agent.api.com.atproto.repo.createRecord({ ### Signatures (`app.certified.signature.*`) -| Lexicon | NSID | Description | -| -------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Inline Signature** | `app.certified.signature.inline` | An inline cryptographic signature for attesting to record content. Uses JOSE algorithm identifiers (ES256, ES256K, Ed25519). | -| **Shared Defs** | `app.certified.signature.defs` | Shared type definitions for signatures. Provides the `#list` array def (a union of inline signatures and strongRefs) referenced by the `signatures` property on records. | -| **Proof** | `app.certified.signature.proof` | Remote attestation proof record containing the CID of attested content. Lives in the attestor's repository and can be referenced via strongRef. | +| Lexicon | NSID | Description | +| --------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Shared Defs** | `app.certified.signature.defs` | Shared type definitions for signatures. Provides the `#list` array def (a union of inline signatures and strongRefs) and the `#inline` object def (the inline signature shape), all referenced by the `signatures` property on records. | +| **Proof** | `app.certified.signature.proof` | Remote attestation proof record containing the CID of attested content. Lives in the attestor's repository and can be referenced via strongRef. | -Nearly all record lexicons include an optional `signatures` property (a ref to `app.certified.signature.defs#list`) enabling cryptographic attestations. See [Cryptographic Signatures](#cryptographic-signatures) for usage details. +All record lexicons include an optional `signatures` property (a ref to `app.certified.signature.defs#list`) enabling cryptographic attestations. See [Cryptographic Signatures](#cryptographic-signatures) for usage details. -`app.certified.link.evm` is the one exception: it already carries its own EIP-712 wallet-ownership proof in the `proof` field, which is the integrity mechanism for its semantic claim (DID ↔ wallet). Adding an `app.certified.signature.defs#list` on top would introduce a second, incompatible signing mechanism on the same record and no additional trust, so it is deliberately omitted. +Note on `app.certified.link.evm`: it carries _two_ orthogonal integrity primitives. The EIP-712 `proof` field proves wallet consent (the EVM key holder agreed to be linked to the DID). The `signatures` array proves record provenance (e.g. that a platform UI minted the record, defending against replay of harvested EIP-712 signatures). Both are useful and they do not conflict. > **Full property tables** → [SCHEMAS.md](SCHEMAS.md) @@ -581,13 +579,13 @@ const attachment = { ### Cryptographic Signatures -Nearly all record lexicons support optional cryptographic signatures via the `signatures` property. This enables platform attestation and verification that records were created through trusted services. (`app.certified.link.evm` is the sole exception — see the [Signatures table](#signatures-appcertifiedsignature) above for why.) +All record lexicons support optional cryptographic signatures via the `signatures` property. This enables platform attestation and verification that records were created through trusted services. The on-the-wire shape, signing procedure, and verification procedure all conform to Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r). In particular, signatures sign the **CID** of the record (not its raw bytes), and CID generation injects a temporary `$sig` object carrying the housing repository's DID so that signatures cannot be replayed across content versions or across repositories. Two patterns are supported: -1. **Inline signatures**: Embedded directly in the record via `app.certified.signature.inline`. +1. **Inline signatures**: Embedded directly in the record via `app.certified.signature.defs#inline`. 2. **Remote attestations**: References to `app.certified.signature.proof` records in other repositories, via `com.atproto.repo.strongRef`. ```typescript @@ -602,7 +600,7 @@ const signedActivity = { signatures: [ // Inline signature (embedded) { - $type: "app.certified.signature.inline", + $type: "app.certified.signature.defs#inline", signature: new Uint8Array([ /* ECDSA signature bytes (low-S per BIP-0062) over the record's CID */ ]), diff --git a/SCHEMAS.md b/SCHEMAS.md index 1041fe64..ef615444 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -492,11 +492,12 @@ A labeled URL reference. #### Properties -| Property | Type | Required | Description | -| ----------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `subject` | `string` | ✅ | DID of the account being followed. | -| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | -| `via` | `ref` | ❌ | Optional strong reference to a record that mediated this follow (e.g. a starter pack or other curated list). Mirrors the optional `via` field on app.bsky.graph.follow; the referenced record may conform with any lexicon. | +| Property | Type | Required | Description | +| ------------ | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `subject` | `string` | ✅ | DID of the account being followed. | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | +| `via` | `ref` | ❌ | Optional strong reference to a record that mediated this follow (e.g. a starter pack or other curated list). Mirrors the optional `via` field on app.bsky.graph.follow; the referenced record may conform with any lexicon. | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | --- @@ -508,11 +509,12 @@ A labeled URL reference. #### Properties -| Property | Type | Required | Description | Comments | -| ----------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `address` | `string` | ✅ | EVM wallet address (0x-prefixed, with EIP-55 checksum recommended). | maxLength: 42 | -| `proof` | `union` | ✅ | Cryptographic proof of wallet ownership. The union is open to allow future proof methods (e.g. ERC-1271, ERC-6492). Each variant bundles its signature with the corresponding message format. | | -| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| Property | Type | Required | Description | Comments | +| ------------ | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `address` | `string` | ✅ | EVM wallet address (0x-prefixed, with EIP-55 checksum recommended). | maxLength: 42 | +| `proof` | `union` | ✅ | Cryptographic proof of wallet ownership. The union is open to allow future proof methods (e.g. ERC-1271, ERC-6492). Each variant bundles its signature with the corresponding message format. | | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. The EIP-712 `proof` field proves wallet consent (orthogonal trust statement); `signatures` proves record provenance (e.g. that a platform UI minted this record). | | #### Defs @@ -543,18 +545,16 @@ The EIP-712 typed data message that was signed by the wallet. Contains the field **Description:** Common type definitions for cryptographic signatures attached to records, per the ATProtocol Attestation Specification. ---- - -### `app.certified.signature.inline` +#### Defs -**Description:** Inline attestation signature embedded directly in a record. +##### `app.certified.signature.defs#inline` -#### Properties +Inline attestation signature embedded directly in a record. Conforms to the ATProtocol Attestation Specification: the signed input is the 36-byte CIDv1 (dag-cbor + SHA-256) of the record with the `signatures` field removed and a temporary `$sig` metadata object (containing `$type` and the housing repository DID) inserted before canonical DAG-CBOR encoding. ECDSA with the low-S variant per BIP-0062 is required; the curve (P-256 or K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`. -| Property | Type | Required | Description | Comments | -| ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `signature` | `bytes` | ✅ | ECDSA signature bytes (raw r,s) over the 36-byte CID of the record. Low-S variant per BIP-0062 is mandatory. | | -| `key` | `string` | ✅ | Full DID verification method reference (format: did:{method}:{identifier}#{fragment}). Identifies the signer and the specific key used; the key's multicodec prefix determines the signing curve. | maxLength: 512 | +| Property | Type | Required | Description | +| ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `signature` | `bytes` | ✅ | ECDSA signature bytes (raw r,s) over the 36-byte CID of the record. Low-S variant per BIP-0062 is mandatory. | +| `key` | `string` | ✅ | Full DID verification method reference (format: did:{method}:{identifier}#{fragment}). Identifies the signer and the specific key used; the key's multicodec prefix determines the signing curve. | --- diff --git a/lexicons/app/certified/graph/follow.json b/lexicons/app/certified/graph/follow.json index 7bfc41f0..4fe43f3b 100644 --- a/lexicons/app/certified/graph/follow.json +++ b/lexicons/app/certified/graph/follow.json @@ -24,6 +24,11 @@ "type": "ref", "ref": "com.atproto.repo.strongRef", "description": "Optional strong reference to a record that mediated this follow (e.g. a starter pack or other curated list). Mirrors the optional `via` field on app.bsky.graph.follow; the referenced record may conform with any lexicon." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." } } } diff --git a/lexicons/app/certified/link/evm.json b/lexicons/app/certified/link/evm.json index f22427e9..e2906f9e 100644 --- a/lexicons/app/certified/link/evm.json +++ b/lexicons/app/certified/link/evm.json @@ -25,6 +25,11 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this record was originally created." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content. The EIP-712 `proof` field proves wallet consent (orthogonal trust statement); `signatures` proves record provenance (e.g. that a platform UI minted this record)." } } } diff --git a/lexicons/app/certified/signature/defs.json b/lexicons/app/certified/signature/defs.json index 26a9106d..32aec45f 100644 --- a/lexicons/app/certified/signature/defs.json +++ b/lexicons/app/certified/signature/defs.json @@ -8,7 +8,26 @@ "description": "Reusable array of cryptographic signatures attesting to a record's content. Open union of inline signatures and strong references to remote attestation proof records.", "items": { "type": "union", - "refs": ["app.certified.signature.inline", "com.atproto.repo.strongRef"] + "refs": [ + "app.certified.signature.defs#inline", + "com.atproto.repo.strongRef" + ] + } + }, + "inline": { + "type": "object", + "description": "Inline attestation signature embedded directly in a record. Conforms to the ATProtocol Attestation Specification: the signed input is the 36-byte CIDv1 (dag-cbor + SHA-256) of the record with the `signatures` field removed and a temporary `$sig` metadata object (containing `$type` and the housing repository DID) inserted before canonical DAG-CBOR encoding. ECDSA with the low-S variant per BIP-0062 is required; the curve (P-256 or K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`.", + "required": ["signature", "key"], + "properties": { + "signature": { + "type": "bytes", + "description": "ECDSA signature bytes (raw r,s) over the 36-byte CID of the record. Low-S variant per BIP-0062 is mandatory." + }, + "key": { + "type": "string", + "description": "Full DID verification method reference (format: did:{method}:{identifier}#{fragment}). Identifies the signer and the specific key used; the key's multicodec prefix determines the signing curve.", + "maxLength": 512 + } } } } diff --git a/lexicons/app/certified/signature/inline.json b/lexicons/app/certified/signature/inline.json deleted file mode 100644 index 6659b28f..00000000 --- a/lexicons/app/certified/signature/inline.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "lexicon": 1, - "id": "app.certified.signature.inline", - "description": "An inline cryptographic signature attesting to a record's content. Conforms to the ATProtocol Attestation Specification: the signed input is the 36-byte CIDv1 (dag-cbor + SHA-256) of the record with the `signatures` field removed and a temporary `$sig` metadata object (containing `$type` and the housing repository DID) inserted before canonical DAG-CBOR encoding. ECDSA with the low-S variant per BIP-0062 is required; the curve (P-256 or K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`.", - "defs": { - "main": { - "type": "object", - "description": "Inline attestation signature embedded directly in a record.", - "required": ["signature", "key"], - "properties": { - "signature": { - "type": "bytes", - "description": "ECDSA signature bytes (raw r,s) over the 36-byte CID of the record. Low-S variant per BIP-0062 is mandatory." - }, - "key": { - "type": "string", - "description": "Full DID verification method reference (format: did:{method}:{identifier}#{fragment}). Identifies the signer and the specific key used; the key's multicodec prefix determines the signing curve.", - "maxLength": 512 - } - } - } - } -} diff --git a/tests/validate-signature-inline.test.ts b/tests/validate-signature-defs-inline.test.ts similarity index 66% rename from tests/validate-signature-inline.test.ts rename to tests/validate-signature-defs-inline.test.ts index 8072d9cd..7e2eaa2c 100644 --- a/tests/validate-signature-inline.test.ts +++ b/tests/validate-signature-defs-inline.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from "vitest"; -import { validate, ids } from "../generated/lexicons.js"; -import * as SignatureInline from "../generated/types/app/certified/signature/inline.js"; +import { validate } from "../generated/lexicons.js"; +import { validateInline } from "../generated/types/app/certified/signature/defs.js"; -describe("app.certified.signature.inline", () => { +describe("app.certified.signature.defs#inline", () => { it("should accept a valid inline signature", () => { - const result = SignatureInline.validateMain({ - $type: "app.certified.signature.inline", + const result = validateInline({ + $type: "app.certified.signature.defs#inline", signature: new Uint8Array([1, 2, 3, 4]), key: "did:plc:abc123#signing", }); @@ -20,8 +20,8 @@ describe("app.certified.signature.inline", () => { { key: "did:plc:abc123#signing", }, - ids.AppCertifiedSignatureInline, - "main", + "app.certified.signature.defs", + "inline", false, ); expect(result.success).toBe(false); @@ -32,8 +32,8 @@ describe("app.certified.signature.inline", () => { { signature: new Uint8Array([1, 2, 3]), }, - ids.AppCertifiedSignatureInline, - "main", + "app.certified.signature.defs", + "inline", false, ); expect(result.success).toBe(false); diff --git a/tests/validate-signature-defs.test.ts b/tests/validate-signature-defs.test.ts index c7ce3d43..3570064d 100644 --- a/tests/validate-signature-defs.test.ts +++ b/tests/validate-signature-defs.test.ts @@ -6,7 +6,7 @@ import * as Activity from "../generated/types/org/hypercerts/claim/activity.js"; * Tests the `app.certified.signature.defs#list` array def — specifically * its union semantics (inline signature + strongRef) and array-level * behavior (optional, empty, mixed). Uses activity as a representative - * carrier; all 19 record lexicons reference the same def. + * carrier; all 21 record lexicons reference the same def. */ describe("app.certified.signature.defs#list (via activity carrier)", () => { const baseActivity = { @@ -29,7 +29,7 @@ describe("app.certified.signature.defs#list (via activity carrier)", () => { ...baseActivity, signatures: [ { - $type: "app.certified.signature.inline", + $type: "app.certified.signature.defs#inline", signature: new Uint8Array([1, 2, 3, 4]), key: "did:plc:platform123#signing", }, @@ -59,7 +59,7 @@ describe("app.certified.signature.defs#list (via activity carrier)", () => { ...baseActivity, signatures: [ { - $type: "app.certified.signature.inline", + $type: "app.certified.signature.defs#inline", signature: new Uint8Array([0xde, 0xad]), key: "did:plc:signer1#key-1", }, @@ -69,7 +69,7 @@ describe("app.certified.signature.defs#list (via activity carrier)", () => { cid: "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae", }, { - $type: "app.certified.signature.inline", + $type: "app.certified.signature.defs#inline", signature: new Uint8Array([0xca, 0xfe]), key: "did:plc:signer2#key-2", }, From 968a6443aead2e7c23a4a619071b40280ed9fc52 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 14:41:18 +0000 Subject: [PATCH 06/11] docs(erd): omit signature lexicons from ERD with explanatory comment The signatures property is uniform across all ~20 record lexicons, so drawing the relationships individually would just be a thicket of identical "signatures" edges without communicating any additional structure. Replace the partial activity-only depiction with a comment explaining the deliberate omission; signature lexicons remain documented in SCHEMAS.md and README.md. --- ERD.puml | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/ERD.puml b/ERD.puml index 8ed494c6..01304e9c 100644 --- a/ERD.puml +++ b/ERD.puml @@ -240,23 +240,16 @@ dataclass rights { !endif } -' app.certified.signature.inline -dataclass signatureInline { - !if (SHOW_FIELDS == "true") - signatureType? - signature - key - !endif -} - -' app.certified.signature.proof -dataclass signatureProof { - !if (SHOW_FIELDS == "true") - cid - note? - createdAt? - !endif -} +' Signature-related lexicons (app.certified.signature.defs#inline, +' app.certified.signature.defs#list, app.certified.signature.proof) +' are deliberately omitted from this diagram. +' +' Every record lexicon carries an optional `signatures` array referencing +' app.certified.signature.defs#list, so the relationship is uniform across +' all ~20 record entities. Drawing those arrows would add a thicket of +' identical "signatures" edges from every dataclass on the diagram without +' communicating any additional structure. The signature-related lexicons +' are documented in SCHEMAS.md and README.md instead. ' app.certified.link.evm dataclass linkEvm { @@ -432,15 +425,7 @@ follow::subject --> contributorEntity : follows ' This screws up the layout 'badgeAward::subject --[norank]-> collection -' Signature relationships -' Nearly all record lexicons have an optional `signatures` array (ref -' to app.certified.signature.defs#list) containing a union of -' signatureInline objects and strongRefs to signatureProof records. -' Only activity is shown here to avoid cluttering the diagram; the -' pattern is identical for all record lexicons except -' app.certified.link.evm, which carries its own EIP-712 wallet -' ownership proof and deliberately omits the signatures property. -activity ..> signatureInline : signatures -activity ..> signatureProof : "signatures (strongRef)" +' Signature relationships are intentionally not drawn here -- see the +' comment near the top of the file (before linkEvm) for rationale. @enduml From 9b6e8835f6ef436e698ac2c691a28009027d7b7f Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 14:49:42 +0000 Subject: [PATCH 07/11] docs(skill): add signature coverage to building-with-hypercerts-lexicons Bring the agent-facing skill guide in line with the rest of the signature work on this PR. Adds: - Signatures subsection in Lexicon Overview covering signature.defs and signature.proof - Note in the EVM Link row that link.evm can additionally carry signatures[] for record provenance on top of EIP-712 wallet consent - Updated Relationship Map listing signature/defs and signature/proof, with a note explaining why the per-record signatures arrows aren't drawn - "Attaching Cryptographic Signatures" example showing mixed inline + remote-attestation usage, plus a short signing-procedure summary pointing at the ATProto Attestation Specification - "Creating a Remote Attestation Proof" example for the proof record --- .../SKILL.md | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 7f5228fb..42521cf9 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -246,9 +246,18 @@ if (result.success) { | **Badge Definition** | `app.certified.badge.definition` | Defines a badge with type, title, icon, optional issuer allowlist | | **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity | | **Badge Response** | `app.certified.badge.response` | Recipient accepts or rejects a badge award | -| **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID to EVM wallet link via EIP-712 signature | +| **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID to EVM wallet link via EIP-712 signature. Can additionally carry `signatures[]` for record provenance | | **Follow** | `app.certified.graph.follow` | Social-graph follow: declares the author follows another account by DID. Schema-compatible with `app.bsky.graph.follow` | +### Signatures — cryptographic attestation + +| Lexicon | NSID | Purpose | +| ------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Signature Defs** | `app.certified.signature.defs` | Shared type definitions. Provides `#list` (open union array of inline signatures and strongRefs to remote proofs) and `#inline` (the inline signature object shape). Referenced by the `signatures` property on every record lexicon. | +| **Signature Proof** | `app.certified.signature.proof` | Remote attestation proof record holding the CID of attested content. Lives in the attestor's repository and is referenced from the attested record via `com.atproto.repo.strongRef`. | + +All 21 record lexicons carry an optional `signatures` property (a ref to `app.certified.signature.defs#list`). The on-the-wire shape, signing procedure, and verification procedure conform to Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r): the signed input is the CID of the record (not its raw bytes), and CID generation injects a `$sig` object carrying the housing repository's DID so signatures cannot be replayed across content versions or across repositories. ECDSA with the low-S variant per BIP-0062 is required; the signing curve (P-256 or K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`. + ## Relationship Map ```text @@ -285,6 +294,12 @@ CERTIFIED actor/organization (org metadata) badge/response ──> badge/award ──> badge/definition graph/follow ───────────> account DID (social follow) + signature/defs (shared #list and #inline defs) + signature/proof (remote attestation proof record) + + Every record lexicon also carries an optional `signatures` array + referencing app.certified.signature.defs#list (not drawn — the + arrow would fan out identically from every record). ``` Every arrow is a `strongRef` or union reference stored on AT Protocol. @@ -483,6 +498,66 @@ const receipt = { }; ``` +### Attaching Cryptographic Signatures + +Any record can carry an optional `signatures` array, enabling platform +attestation and verification that records were created through trusted +services. Two patterns are supported in any combination: + +1. **Inline signatures** — embedded directly in the record via + `app.certified.signature.defs#inline`. +2. **Remote attestations** — references to + `app.certified.signature.proof` records in other repositories via + `com.atproto.repo.strongRef`. + +```typescript +import { ACTIVITY_NSID } from "@hypercerts-org/lexicon"; + +const signedActivity = { + $type: ACTIVITY_NSID, + title: "Verified Reforestation Project", + shortDescription: "Planted 1,000 trees in partnership with local community", + createdAt: new Date().toISOString(), + signatures: [ + // Inline signature (embedded) + { + $type: "app.certified.signature.defs#inline", + signature: new Uint8Array([ + /* ECDSA signature bytes (low-S per BIP-0062) over the record's CID */ + ]), + key: "did:plc:platform123#signing", // DID verification method + }, + // Remote attestation (reference to a proof record in another repo) + { + $type: "com.atproto.repo.strongRef", + uri: "at://did:plc:verifier/app.certified.signature.proof/abc123", + cid: "bafy...", + }, + ], +}; +``` + +To produce the signature bytes: take the record without its +`signatures` field, insert a temporary `$sig` object containing +`$type` and the housing repository's `repository` DID, encode as +canonical DAG-CBOR, SHA-256 hash to a 36-byte CIDv1, then ECDSA-sign +those 36 bytes (low-S). See the +[ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r) +for full procedure and verification details. + +### Creating a Remote Attestation Proof + +```typescript +import { SIGNATURE_PROOF_NSID } from "@hypercerts-org/lexicon"; + +const proof = { + $type: SIGNATURE_PROOF_NSID, + cid: "bafy...", // CID of the attested content (computed as above) + note: "Verified by platform quality assurance process", + createdAt: new Date().toISOString(), +}; +``` + ## Key Concepts ### strongRef From f929c892380086b11d3c7abaf6aaeb5960e148be Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 14:53:51 +0000 Subject: [PATCH 08/11] docs: point primary spec links at the canonical Tangled URL The Leaflet URL is a useful background blog post but not the normative specification document. Update all doc references so the primary link is the canonical spec at tangled.org and the blog post appears as a secondary "see also" link. Affects README, changeset, and the building-with-hypercerts-lexicons skill. --- .agents/skills/building-with-hypercerts-lexicons/SKILL.md | 8 +++++--- .changeset/add-signature-support.md | 2 +- README.md | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 42521cf9..cf0a0833 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -256,7 +256,7 @@ if (result.success) { | **Signature Defs** | `app.certified.signature.defs` | Shared type definitions. Provides `#list` (open union array of inline signatures and strongRefs to remote proofs) and `#inline` (the inline signature object shape). Referenced by the `signatures` property on every record lexicon. | | **Signature Proof** | `app.certified.signature.proof` | Remote attestation proof record holding the CID of attested content. Lives in the attestor's repository and is referenced from the attested record via `com.atproto.repo.strongRef`. | -All 21 record lexicons carry an optional `signatures` property (a ref to `app.certified.signature.defs#list`). The on-the-wire shape, signing procedure, and verification procedure conform to Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r): the signed input is the CID of the record (not its raw bytes), and CID generation injects a `$sig` object carrying the housing repository's DID so signatures cannot be replayed across content versions or across repositories. ECDSA with the low-S variant per BIP-0062 is required; the signing curve (P-256 or K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`. +All 21 record lexicons carry an optional `signatures` property (a ref to `app.certified.signature.defs#list`). The on-the-wire shape, signing procedure, and verification procedure conform to Nick Gerakines' [ATProtocol Attestation Specification](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22) (see also the [accompanying blog post](https://ngerakines.leaflet.pub/3m3idxul5hc2r)): the signed input is the CID of the record (not its raw bytes), and CID generation injects a `$sig` object carrying the housing repository's DID so signatures cannot be replayed across content versions or across repositories. ECDSA with the low-S variant per BIP-0062 is required; the signing curve (P-256 or K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`. ## Relationship Map @@ -542,8 +542,10 @@ To produce the signature bytes: take the record without its `$type` and the housing repository's `repository` DID, encode as canonical DAG-CBOR, SHA-256 hash to a 36-byte CIDv1, then ECDSA-sign those 36 bytes (low-S). See the -[ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r) -for full procedure and verification details. +[ATProtocol Attestation Specification](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22) +for full procedure and verification details, and the +[accompanying blog post](https://ngerakines.leaflet.pub/3m3idxul5hc2r) +for background and motivation. ### Creating a Remote Attestation Proof diff --git a/.changeset/add-signature-support.md b/.changeset/add-signature-support.md index f617fbde..0febc81f 100644 --- a/.changeset/add-signature-support.md +++ b/.changeset/add-signature-support.md @@ -4,7 +4,7 @@ Add cryptographic signature support to all lexicons -Adds optional cryptographic signature support to all record lexicons, enabling platform attestation and verification that records were created through trusted services. The on-the-wire shape, signing procedure, and verification procedure all conform to Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r). +Adds optional cryptographic signature support to all record lexicons, enabling platform attestation and verification that records were created through trusted services. The on-the-wire shape, signing procedure, and verification procedure all conform to Nick Gerakines' [ATProtocol Attestation Specification](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22) (see also the [accompanying blog post](https://ngerakines.leaflet.pub/3m3idxul5hc2r)). **New lexicons:** diff --git a/README.md b/README.md index f0915330..cfedaef6 100644 --- a/README.md +++ b/README.md @@ -581,7 +581,7 @@ const attachment = { All record lexicons support optional cryptographic signatures via the `signatures` property. This enables platform attestation and verification that records were created through trusted services. -The on-the-wire shape, signing procedure, and verification procedure all conform to Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r). In particular, signatures sign the **CID** of the record (not its raw bytes), and CID generation injects a temporary `$sig` object carrying the housing repository's DID so that signatures cannot be replayed across content versions or across repositories. +The on-the-wire shape, signing procedure, and verification procedure all conform to Nick Gerakines' [ATProtocol Attestation Specification](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22) (see also the [accompanying blog post](https://ngerakines.leaflet.pub/3m3idxul5hc2r)). In particular, signatures sign the **CID** of the record (not its raw bytes), and CID generation injects a temporary `$sig` object carrying the housing repository's DID so that signatures cannot be replayed across content versions or across repositories. Two patterns are supported: @@ -654,7 +654,7 @@ const proof = { }; ``` -For the full specification, see Nick Gerakines' [ATProtocol Attestation Specification](https://ngerakines.leaflet.pub/3m3idxul5hc2r). +For the full specification, see Nick Gerakines' [ATProtocol Attestation Specification](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22). For background and motivation, see the [accompanying blog post](https://ngerakines.leaflet.pub/3m3idxul5hc2r). ## Development From 59174ea002ef4f114f4b6ba8877519682f287cfc Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 15:10:29 +0000 Subject: [PATCH 09/11] docs: restructure signing example to build signature before embedding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the cryptographic signatures example presented a record with signatures already embedded, using a placeholder comment for the ECDSA bytes and explaining the signing procedure only afterward. That reads backwards: a developer wanting to attach a signature needs to see how the bytes are produced first. Rewrite both README and SKILL.md so the example walks step by step: 1. Build the inline signature object — compute the spec-defined CID over the record (insert temporary \$sig with repository DID, encode canonical DAG-CBOR, hash to 36-byte CIDv1) and ECDSA-sign with @atproto/crypto's Secp256k1Keypair.sign() which enforces low-S automatically. 2. Build a remote attestation strongRef (optional). 3. Attach either or both to the record via spread. Uses canonical ATProto + IPLD libraries (@atproto/crypto, @ipld/dag-cbor, multiformats) rather than placeholder pseudocode. multiformats is already a runtime dep of this package; the others are shown as consumer imports. --- .../SKILL.md | 93 ++++++++++++---- README.md | 103 ++++++++++++------ 2 files changed, 139 insertions(+), 57 deletions(-) diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index cf0a0833..5687622a 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -510,40 +510,85 @@ services. Two patterns are supported in any combination: `app.certified.signature.proof` records in other repositories via `com.atproto.repo.strongRef`. +**Step 1: build an inline signature.** Compute the spec-defined CID +over the record-to-be-signed, then ECDSA-sign it with the platform's +key. Uses [`@atproto/crypto`](https://www.npmjs.com/package/@atproto/crypto) +(ATProto's signing primitives — handles low-S automatically), +[`@ipld/dag-cbor`](https://www.npmjs.com/package/@ipld/dag-cbor) for +canonical encoding, and [`multiformats`](https://www.npmjs.com/package/multiformats) +for CID construction: + ```typescript +import { Secp256k1Keypair } from "@atproto/crypto"; +import * as dagCbor from "@ipld/dag-cbor"; +import { CID } from "multiformats/cid"; +import { sha256 } from "multiformats/hashes/sha2"; +import * as raw from "multiformats/codecs/raw"; import { ACTIVITY_NSID } from "@hypercerts-org/lexicon"; -const signedActivity = { +// 1. The record we want to sign (without the signatures field). +const recordToSign = { $type: ACTIVITY_NSID, title: "Verified Reforestation Project", shortDescription: "Planted 1,000 trees in partnership with local community", - createdAt: new Date().toISOString(), - signatures: [ - // Inline signature (embedded) - { - $type: "app.certified.signature.defs#inline", - signature: new Uint8Array([ - /* ECDSA signature bytes (low-S per BIP-0062) over the record's CID */ - ]), - key: "did:plc:platform123#signing", // DID verification method - }, - // Remote attestation (reference to a proof record in another repo) - { - $type: "com.atproto.repo.strongRef", - uri: "at://did:plc:verifier/app.certified.signature.proof/abc123", - cid: "bafy...", - }, - ], + createdAt: "2026-05-21T12:00:00.000Z", +}; + +// 2. Insert temporary $sig metadata: attestation type + housing repo DID. +// This binds the signature to a specific repository, preventing +// cross-repo replay. +const platformDid = "did:plc:platform123"; +const cborInput = { + ...recordToSign, + $sig: { + $type: "app.certified.signature.defs#inline", + repository: platformDid, + }, +}; + +// 3. Encode canonically as DAG-CBOR, then build the 36-byte CIDv1 +// (dag-cbor codec 0x71 + SHA-256 multihash). +const cborBytes = dagCbor.encode(cborInput); +const hash = await sha256.digest(cborBytes); +const cid = CID.createV1(dagCbor.code, hash); +const cidBytes = cid.bytes; // 36 bytes: 0x01 0x71 0x12 0x20 + 32-byte hash + +// 4. ECDSA-sign the CID bytes. Keypair.sign() applies the standard +// ECDSA hash-then-sign convention and enforces low-S per BIP-0062. +const keypair = await Secp256k1Keypair.create({ exportable: false }); +const signatureBytes = await keypair.sign(cidBytes); + +const inlineSignature = { + $type: "app.certified.signature.defs#inline" as const, + signature: signatureBytes, + key: `${platformDid}#signing`, // DID verification method reference +}; +``` + +**Step 2: build a remote attestation reference** (optional — only if +the attestation lives in another repo's proof record): + +```typescript +const remoteAttestation = { + $type: "com.atproto.repo.strongRef" as const, + uri: "at://did:plc:verifier/app.certified.signature.proof/abc123", + cid: "bafy...", // CID of the proof record being referenced +}; +``` + +**Step 3: attach to the record.** Both shapes can coexist in the same +array; consumers verify each entry independently. + +```typescript +const signedActivity = { + ...recordToSign, + signatures: [inlineSignature, remoteAttestation], }; ``` -To produce the signature bytes: take the record without its -`signatures` field, insert a temporary `$sig` object containing -`$type` and the housing repository's `repository` DID, encode as -canonical DAG-CBOR, SHA-256 hash to a 36-byte CIDv1, then ECDSA-sign -those 36 bytes (low-S). See the +See the [ATProtocol Attestation Specification](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22) -for full procedure and verification details, and the +for the full procedure and verification rules, and the [accompanying blog post](https://ngerakines.leaflet.pub/3m3idxul5hc2r) for background and motivation. diff --git a/README.md b/README.md index cfedaef6..ff69bb4b 100644 --- a/README.md +++ b/README.md @@ -588,54 +588,91 @@ Two patterns are supported: 1. **Inline signatures**: Embedded directly in the record via `app.certified.signature.defs#inline`. 2. **Remote attestations**: References to `app.certified.signature.proof` records in other repositories, via `com.atproto.repo.strongRef`. +#### Signing Algorithm + +The spec mandates **ECDSA** with the low-S variant per BIP-0062. The signing curve is determined by the multicodec prefix of the verification method's `publicKeyMultibase` in the signer's DID document: + +| Multicodec prefix | Curve | JOSE alg | +| ----------------- | ----------------- | -------- | +| `0xE701` | secp256k1 (K-256) | `ES256K` | +| `0x1200` | P-256 (secp256r1) | `ES256` | + +There is deliberately no `signatureType` field on the inline signature: the algorithm is canonically derived from the resolved key, and a separate tag would risk disagreeing with the multicodec. + +#### Step 1: Build an Inline Signature + +Compute the spec-defined CID over the record-to-be-signed, then ECDSA-sign it with the platform's key. Uses [`@atproto/crypto`](https://www.npmjs.com/package/@atproto/crypto) (ATProto's signing primitives — handles low-S automatically), [`@ipld/dag-cbor`](https://www.npmjs.com/package/@ipld/dag-cbor) for canonical encoding, and [`multiformats`](https://www.npmjs.com/package/multiformats) for CID construction: + ```typescript +import { Secp256k1Keypair } from "@atproto/crypto"; +import * as dagCbor from "@ipld/dag-cbor"; +import { CID } from "multiformats/cid"; +import { sha256 } from "multiformats/hashes/sha2"; import { ACTIVITY_NSID } from "@hypercerts-org/lexicon"; -// Activity with inline signature -const signedActivity = { +// 1. The record we want to sign (without the signatures field). +const recordToSign = { $type: ACTIVITY_NSID, title: "Verified Reforestation Project", shortDescription: "Planted 1,000 trees in partnership with local community", - createdAt: new Date().toISOString(), - signatures: [ - // Inline signature (embedded) - { - $type: "app.certified.signature.defs#inline", - signature: new Uint8Array([ - /* ECDSA signature bytes (low-S per BIP-0062) over the record's CID */ - ]), - key: "did:plc:platform123#signing", // DID verification method - }, - // Remote attestation (reference to a proof record in another repo) - { - $type: "com.atproto.repo.strongRef", - uri: "at://did:plc:verifier/app.certified.signature.proof/abc123", - cid: "bafy...", - }, - ], + createdAt: "2026-05-21T12:00:00.000Z", +}; + +// 2. Insert temporary $sig metadata: attestation type + housing repo DID. +// This binds the signature to a specific repository, preventing +// cross-repo replay. +const platformDid = "did:plc:platform123"; +const cborInput = { + ...recordToSign, + $sig: { + $type: "app.certified.signature.defs#inline", + repository: platformDid, + }, +}; + +// 3. Encode canonically as DAG-CBOR, then build the 36-byte CIDv1 +// (dag-cbor codec 0x71 + SHA-256 multihash). +const cborBytes = dagCbor.encode(cborInput); +const hash = await sha256.digest(cborBytes); +const cid = CID.createV1(dagCbor.code, hash); +const cidBytes = cid.bytes; // 36 bytes: 0x01 0x71 0x12 0x20 + 32-byte hash + +// 4. ECDSA-sign the CID bytes. Keypair.sign() applies the standard +// ECDSA hash-then-sign convention and enforces low-S per BIP-0062. +const keypair = await Secp256k1Keypair.create({ exportable: false }); +const signatureBytes = await keypair.sign(cidBytes); + +const inlineSignature = { + $type: "app.certified.signature.defs#inline" as const, + signature: signatureBytes, + key: `${platformDid}#signing`, // DID verification method reference }; ``` -#### Signing Algorithm +#### Step 2: Build a Remote Attestation Reference (Optional) -The spec mandates **ECDSA** with the low-S variant per BIP-0062. The signing curve is determined by the multicodec prefix of the verification method's `publicKeyMultibase` in the signer's DID document: +Only if the attestation lives in another repo's proof record: -| Multicodec prefix | Curve | JOSE alg | -| ----------------- | ----------------- | -------- | -| `0xE701` | secp256k1 (K-256) | `ES256K` | -| `0x1200` | P-256 (secp256r1) | `ES256` | +```typescript +const remoteAttestation = { + $type: "com.atproto.repo.strongRef" as const, + uri: "at://did:plc:verifier/app.certified.signature.proof/abc123", + cid: "bafy...", // CID of the proof record being referenced +}; +``` -There is deliberately no `signatureType` field on the inline signature: the algorithm is canonically derived from the resolved key, and a separate tag would risk disagreeing with the multicodec. +#### Step 3: Attach to the Record -#### Signing & Verification Procedure (Summary) +Both shapes can coexist in the same array; consumers verify each entry independently. -To **sign** a record: +```typescript +const signedActivity = { + ...recordToSign, + signatures: [inlineSignature, remoteAttestation], +}; +``` -1. Take the record without its `signatures` field. -2. Insert a temporary `$sig` object containing `$type` (the attestation type identifier) and `repository` (the housing repo's DID). -3. Encode the result as canonical DAG-CBOR (sorted keys by CBOR byte length then lexically, definite-length items, shortest integer encodings). -4. SHA-256 hash to produce a 36-byte CIDv1 (`0x01 0x71 0x12 0x20` + 32-byte hash). -5. ECDSA-sign those 36 bytes (low-S) with the private key matching the verification method named in `key`. +#### Verifying a Signature To **verify**, reconstruct the same CID using the housing repo's DID, resolve `key` to a public key via the DID document, and check the ECDSA signature against the CID bytes. From cea3f887373e1dabede6bd39fb6f9ab043208a02 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 15:16:33 +0000 Subject: [PATCH 10/11] test: extract and execute signing-example code blocks from docs Defends against docs/test divergence by making the docs themselves the test source. The new test reads README.md and the building-with-hypercerts-lexicons skill, finds the "Cryptographic Signatures" section in each, extracts every fenced TypeScript code block under it, concatenates them into one ESM module (deduping imports and appending synthetic exports for the identifiers we want to inspect), writes the result to tests/extracted/ (gitignored), and dynamically imports. For each source file, asserts: - Extraction + execution succeeds without throwing - The produced CID is a 36-byte CIDv1 with the dag-cbor/SHA-256 prefix - Secp256k1Keypair.sign() returns 64 raw r,s bytes - The inline signature has the correct \$type and references the signature bytes - The remote-attestation strongRef has the correct \$type and at:// URI - Both signatures attach to the final record in order - The resulting record (with a syntactically valid CID substituted for the "bafy..." placeholder) passes lexicon validation - The produced signature verifies via @atproto/crypto verifySignature Adds @atproto/crypto and @ipld/dag-cbor to devDependencies (only used by this test; not exposed to package consumers). --- .gitignore | 1 + package-lock.json | 55 ++++++ package.json | 2 + tests/validate-signing-example.test.ts | 256 +++++++++++++++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 tests/validate-signing-example.test.ts diff --git a/.gitignore b/.gitignore index d9a21854..bb8ffb47 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ generated/ pnpm-lock.yaml /tmp/ +tests/extracted/ lexicons.md # Dolt database files (added by bd init) diff --git a/package-lock.json b/package-lock.json index cdb0e747..42dd41eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "multiformats": "^13.3.6" }, "devDependencies": { + "@atproto/crypto": "^0.5.0", "@atproto/lex-cli": "^0.9.5", "@atproto/xrpc": "^0.7.5", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@eslint/js": "^9.39.2", + "@ipld/dag-cbor": "^10.0.1", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", @@ -57,6 +59,31 @@ "zod": "^3.23.8" } }, + "node_modules/@atproto/crypto": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.5.0.tgz", + "integrity": "sha512-HBfq6z+tzkiBzPhHOvRFkQU3inTQIUJAkb1UEyGx+pdPTnFFtnfIoo9Kr1ZV/FzMIgnHOA8H2UJC3dxxFl1eVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.7.0", + "@noble/hashes": "^1.6.1", + "uint8arrays": "^5.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/crypto/node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, "node_modules/@atproto/lex-cli": { "version": "0.9.6", "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.6.tgz", @@ -1216,6 +1243,24 @@ } } }, + "node_modules/@ipld/dag-cbor": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@ipld/dag-cbor/-/dag-cbor-10.0.1.tgz", + "integrity": "sha512-nF07iiZPqduSXyMxc0jGANArRHFa9hjMQpQlgLOV2O/3xI1CNb5sXhvbmigbMiz5owSGi0Oq10VtauupMojuiw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "dependencies": { + "cborg": "^5.0.1", + "multiformats": "^14.0.0" + } + }, + "node_modules/@ipld/dag-cbor/node_modules/multiformats": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.0.tgz", + "integrity": "sha512-iWK1RrAS58p2NDfeZFuSUSv3ZPewTIhsGbh/5NgeGGJwJmRljLxGtjRR3nkn+loG3zl+IrfR/W1590QnrSK+Gg==", + "dev": true, + "license": "Apache-2.0 OR MIT" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2442,6 +2487,16 @@ "node": ">=6" } }, + "node_modules/cborg": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-5.1.1.tgz", + "integrity": "sha512-BDbSRIp6XrQXkTc7g+DN0RB9RrDPTUfals2ecWUlt3juPLjbAvy/V72mJcXY0Ehu0Dq/3WpNCOCT68HUTbW+lw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cborg": "lib/bin.js" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", diff --git a/package.json b/package.json index a35e9657..a7f065f1 100644 --- a/package.json +++ b/package.json @@ -69,11 +69,13 @@ "multiformats": "^13.3.6" }, "devDependencies": { + "@atproto/crypto": "^0.5.0", "@atproto/lex-cli": "^0.9.5", "@atproto/xrpc": "^0.7.5", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@eslint/js": "^9.39.2", + "@ipld/dag-cbor": "^10.0.1", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", diff --git a/tests/validate-signing-example.test.ts b/tests/validate-signing-example.test.ts new file mode 100644 index 00000000..4e7e3126 --- /dev/null +++ b/tests/validate-signing-example.test.ts @@ -0,0 +1,256 @@ +/** + * End-to-end execution of the cryptographic-signature example in + * README.md and the building-with-hypercerts-lexicons skill. + * + * The docs ARE the test source. Each markdown file's "Cryptographic + * Signatures" section contains a sequence of TypeScript code blocks + * that together build an inline signature, a remote-attestation + * strongRef, and the final signed record. This test extracts those + * blocks, concatenates them into a single ESM module, dynamically + * imports it, and verifies: + * + * 1. The example runs end-to-end without throwing + * 2. The produced inlineSignature has the correct shape and bytes + * 3. The produced signedActivity is lexicon-valid + * 4. The produced signature actually verifies via @atproto/crypto + * + * If the docs example drifts (renamed API, broken procedure, removed + * lexicon ref, etc.), this test fails. If the test logic needs to + * change, the docs are what to edit — not the test fixture. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, writeFileSync, mkdirSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; +import { verifySignature } from "@atproto/crypto"; +import * as Activity from "../generated/types/org/hypercerts/claim/activity.js"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const EXTRACT_DIR = resolve(ROOT, "tests/extracted"); + +interface ExtractedExample { + signedActivity: { signatures: unknown[] }; + inlineSignature: { + $type: string; + signature: Uint8Array; + key: string; + }; + remoteAttestation: { + $type: string; + uri: string; + cid: string; + }; + cidBytes: Uint8Array; + signatureBytes: Uint8Array; + keypair: { did(): string }; +} + +/** + * Extract all TypeScript code blocks under a heading whose text + * matches `sectionPattern` (and any nested subheadings until the next + * heading at the same or shallower level). + */ +function extractSectionBlocks( + markdown: string, + sectionPattern: RegExp, +): string[] { + const lines = markdown.split("\n"); + const blocks: string[] = []; + let inSection = false; + let sectionLevel = 0; + let inFence = false; + let fenceLang = ""; + let current: string[] = []; + + for (const line of lines) { + const headingMatch = line.match(/^(#+)\s+(.*)$/); + if (headingMatch && !inFence) { + const level = headingMatch[1].length; + const text = headingMatch[2]; + if (!inSection) { + if (sectionPattern.test(text)) { + inSection = true; + sectionLevel = level; + } + } else if (level <= sectionLevel) { + inSection = false; + } + continue; + } + if (!inSection) continue; + + const fenceMatch = line.match(/^```(\w*)/); + if (fenceMatch) { + if (!inFence) { + inFence = true; + fenceLang = fenceMatch[1].toLowerCase(); + current = []; + } else { + if (/^(ts|tsx|typescript)$/.test(fenceLang)) { + blocks.push(current.join("\n")); + } + inFence = false; + fenceLang = ""; + } + continue; + } + if (inFence) current.push(line); + } + return blocks; +} + +/** + * Concatenate the extracted blocks into one ESM module, hoisting and + * deduping `import` statements, then appending an `export` clause + * exposing the identifiers we want the test to inspect. + */ +function buildModuleSource(blocks: string[]): string { + const importLines = new Set(); + const bodyChunks: string[] = []; + + for (const block of blocks) { + const blockLines = block.split("\n"); + const body: string[] = []; + let i = 0; + // Imports may span multiple lines if a `{ ... }` import is broken + // across them, so accumulate until a line ends with a quote+semi. + while (i < blockLines.length) { + const line = blockLines[i]; + if (/^\s*import\b/.test(line)) { + let stmt = line; + while (!/["'];?\s*$/.test(stmt) && i + 1 < blockLines.length) { + i += 1; + stmt += "\n" + blockLines[i]; + } + importLines.add(stmt.trim()); + i += 1; + continue; + } + body.push(line); + i += 1; + } + bodyChunks.push(body.join("\n")); + } + + return [ + "// AUTO-GENERATED from documentation code blocks.", + "// Do not edit; edit the source markdown instead.", + "/* eslint-disable */", + ...Array.from(importLines), + "", + bodyChunks.join("\n\n"), + "", + "export {", + " signedActivity,", + " inlineSignature,", + " remoteAttestation,", + " cidBytes,", + " signatureBytes,", + " keypair,", + "};", + "", + ].join("\n"); +} + +async function loadExampleFrom( + relPath: string, + outputBasename: string, +): Promise { + const markdown = readFileSync(resolve(ROOT, relPath), "utf-8"); + const blocks = extractSectionBlocks(markdown, /Cryptographic Signatures/i); + if (blocks.length < 3) { + throw new Error( + `Expected at least 3 TypeScript blocks under "Cryptographic Signatures" in ${relPath}, found ${blocks.length}`, + ); + } + const source = buildModuleSource(blocks); + mkdirSync(EXTRACT_DIR, { recursive: true }); + const outPath = resolve(EXTRACT_DIR, `${outputBasename}.ts`); + writeFileSync(outPath, source, "utf-8"); + return (await import(pathToFileURL(outPath).href)) as ExtractedExample; +} + +const SOURCES: Array<[label: string, relPath: string, basename: string]> = [ + [ + "SKILL.md", + ".agents/skills/building-with-hypercerts-lexicons/SKILL.md", + "signing-example-skill", + ], + ["README.md", "README.md", "signing-example-readme"], +]; + +for (const [label, relPath, basename] of SOURCES) { + describe(`signing example extracted from ${label}`, () => { + let example: ExtractedExample; + + it("extracts and runs the documented signing procedure without throwing", async () => { + example = await loadExampleFrom(relPath, basename); + expect(example).toBeDefined(); + }); + + it("produces a 36-byte CIDv1 with the dag-cbor/SHA-256 prefix", () => { + expect(example.cidBytes).toBeInstanceOf(Uint8Array); + expect(example.cidBytes.length).toBe(36); + // CIDv1 + dag-cbor codec + SHA-256 multihash + 32-byte digest length + expect(Array.from(example.cidBytes.slice(0, 4))).toEqual([ + 0x01, 0x71, 0x12, 0x20, + ]); + }); + + it("produces 64 raw r,s bytes from Secp256k1Keypair.sign()", () => { + expect(example.signatureBytes).toBeInstanceOf(Uint8Array); + expect(example.signatureBytes.length).toBe(64); + }); + + it("builds an inline signature with the correct $type and shape", () => { + expect(example.inlineSignature.$type).toBe( + "app.certified.signature.defs#inline", + ); + expect(example.inlineSignature.signature).toBe(example.signatureBytes); + expect(example.inlineSignature.key).toMatch(/^did:[^:]+:.+#.+$/); + }); + + it("builds a remote-attestation strongRef", () => { + expect(example.remoteAttestation.$type).toBe( + "com.atproto.repo.strongRef", + ); + expect(example.remoteAttestation.uri).toMatch(/^at:\/\//); + }); + + it("attaches both signatures to the final record", () => { + expect(example.signedActivity.signatures).toHaveLength(2); + expect(example.signedActivity.signatures[0]).toBe( + example.inlineSignature, + ); + expect(example.signedActivity.signatures[1]).toBe( + example.remoteAttestation, + ); + }); + + it("produces a record that passes lexicon validation", () => { + // Replace the placeholder remote-attestation strongRef CID with a + // syntactically-valid CID so the record can be validated; the + // example uses "bafy..." which the lexicon validator rejects. + const VALID_PROOF_CID = + "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae"; + const validatable = { + ...example.signedActivity, + signatures: [ + example.inlineSignature, + { ...example.remoteAttestation, cid: VALID_PROOF_CID }, + ], + }; + const result = Activity.validateMain(validatable); + expect(result.success).toBe(true); + }); + + it("produced signature verifies against the signing public key", async () => { + const ok = await verifySignature( + example.keypair.did(), + example.cidBytes, + example.signatureBytes, + ); + expect(ok).toBe(true); + }); + }); +} From efa9b4fe723f45a4d02e4c2af69da92cbd1cfc77 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 21 May 2026 15:18:07 +0000 Subject: [PATCH 11/11] docs(changeset): drop duplicate signing-curve summary Line 51 restated what line 13's #inline description already says about ECDSA + low-S + multicodec-derived curve. Addresses review comment on PR #170. --- .changeset/add-signature-support.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/add-signature-support.md b/.changeset/add-signature-support.md index 0febc81f..a8c615eb 100644 --- a/.changeset/add-signature-support.md +++ b/.changeset/add-signature-support.md @@ -47,5 +47,3 @@ This is a non-breaking extension - signatures are optional on all records. Two s 1. **Inline signatures** - Embedded directly in the record via `app.certified.signature.defs#inline`. 2. **Remote attestations** - References to proof records in other repositories via `com.atproto.repo.strongRef`. - -The signing curve (P-256 / K-256) is determined by the multicodec prefix of the verification method's `publicKeyMultibase`, per the ATProtocol Attestation Specification. ECDSA with the low-S variant per BIP-0062 is mandatory.