diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 7f5228f..5687622 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://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 ```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,113 @@ 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`. + +**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"; + +// 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: "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], +}; +``` + +See the +[ATProtocol Attestation Specification](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22) +for the full procedure and verification rules, and the +[accompanying blog post](https://ngerakines.leaflet.pub/3m3idxul5hc2r) +for background and motivation. + +### 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 diff --git a/.changeset/add-signature-support.md b/.changeset/add-signature-support.md new file mode 100644 index 0000000..a8c615e --- /dev/null +++ b/.changeset/add-signature-support.md @@ -0,0 +1,49 @@ +--- +"@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. 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:** + +- `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:** + +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` +- `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.graph.follow` +- `app.certified.link.evm` + +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.defs#inline`. +2. **Remote attestations** - References to proof records in other repositories via `com.atproto.repo.strongRef`. diff --git a/.gitignore b/.gitignore index d9a2185..bb8ffb4 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/AGENTS.md b/AGENTS.md index 7e5b436..07566ed 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. diff --git a/ERD.puml b/ERD.puml index 130db6c..01304e9 100644 --- a/ERD.puml +++ b/ERD.puml @@ -240,6 +240,26 @@ dataclass rights { !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 { + !if (SHOW_FIELDS == "true") + address + proof + createdAt + !endif +} + ' org.hypercerts.collection dataclass collection { !if (SHOW_FIELDS == "true") @@ -405,4 +425,7 @@ follow::subject --> contributorEntity : follows ' This screws up the layout 'badgeAward::subject --[norank]-> collection +' Signature relationships are intentionally not drawn here -- see the +' comment near the top of the file (before linkEvm) for rationale. + @enduml diff --git a/README.md b/README.md index 831dd34..ff69bb4 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ CERTIFIED ─ shared lexicons (certified.app) 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 arrow (`►`) is a `strongRef` or union reference stored on the @@ -278,6 +280,17 @@ 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 | +| --------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **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. | + +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. + +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) ## Schema Conventions @@ -564,6 +577,122 @@ const attachment = { }; ``` +### Cryptographic Signatures + +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://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: + +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"; + +// 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: "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], +}; +``` + +#### 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. + +#### 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 (computed as above) + note: "Verified by platform quality assurance process", + createdAt: new Date().toISOString(), +}; +``` + +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 ### Commands diff --git a/SCHEMAS.md b/SCHEMAS.md index 6e4f150..ef61544 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. | | --- @@ -475,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. | --- @@ -491,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 @@ -522,6 +541,39 @@ 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. + +#### Defs + +##### `app.certified.signature.defs#inline` + +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 | +| ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | + +--- + +### `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/actor/organization.json b/lexicons/app/certified/actor/organization.json index fbea68f..c736416 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 c5a9894..e9252f1 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 20854eb..ed6c08d 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 809f82a..8659242 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 168ab7b..1ae27ac 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/graph/follow.json b/lexicons/app/certified/graph/follow.json index 7bfc41f..4fe43f3 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 f22427e..e2906f9 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/location.json b/lexicons/app/certified/location.json index 853a6b3..644def3 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 0000000..32aec45 --- /dev/null +++ b/lexicons/app/certified/signature/defs.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "app.certified.signature.defs", + "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. Open union of inline signatures and strong references to remote attestation proof records.", + "items": { + "type": "union", + "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/proof.json b/lexicons/app/certified/signature/proof.json new file mode 100644 index 0000000..fa20760 --- /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 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", + "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, 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", + "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 6eaac83..c4fba9f 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 d0af7f8..3c4f323 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 86c4182..7991aae 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 791a367..edafe6e 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 b3bd775..e6dd1c6 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 f242f46..30f3c8f 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 ba974b9..bde6c5a 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 b412254..8fde4b8 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 ee18819..b10c141 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 3f18463..17aef90 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 08d8d49..3b3fb19 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 fb2806c..e0522e7 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 c1260ca..346d99f 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 f007988..42dd41e 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", @@ -32,7 +34,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": { @@ -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", @@ -3740,9 +3795,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 +4672,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 +4690,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 3460ea8..a7f065f 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", @@ -88,7 +90,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-defs-inline.test.ts b/tests/validate-signature-defs-inline.test.ts new file mode 100644 index 0000000..7e2eaa2 --- /dev/null +++ b/tests/validate-signature-defs-inline.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { validate } from "../generated/lexicons.js"; +import { validateInline } from "../generated/types/app/certified/signature/defs.js"; + +describe("app.certified.signature.defs#inline", () => { + it("should accept a valid inline signature", () => { + const result = validateInline({ + $type: "app.certified.signature.defs#inline", + 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"); + } + }); + + it("should reject when required field 'signature' is missing", () => { + const result = validate( + { + key: "did:plc:abc123#signing", + }, + "app.certified.signature.defs", + "inline", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject when required field 'key' is missing", () => { + const result = validate( + { + signature: new Uint8Array([1, 2, 3]), + }, + "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 new file mode 100644 index 0000000..3570064 --- /dev/null +++ b/tests/validate-signature-defs.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { ids } from "../generated/lexicons.js"; +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 21 record lexicons reference the same def. + */ +describe("app.certified.signature.defs#list (via activity carrier)", () => { + 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.defs#inline", + 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.defs#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.defs#inline", + 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); + }); +}); diff --git a/tests/validate-signature-proof.test.ts b/tests/validate-signature-proof.test.ts new file mode 100644 index 0000000..ff75623 --- /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-signing-example.test.ts b/tests/validate-signing-example.test.ts new file mode 100644 index 0000000..4e7e312 --- /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); + }); + }); +}