Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
39d7547
feat: add cryptographic signature support to record lexicons
aspiers Apr 7, 2026
1c793db
refactor: align signature lexicons with ATProto Attestation Specifica…
aspiers May 21, 2026
a85808f
test: rename validate-signatures-on-record to validate-signature-defs
aspiers May 21, 2026
cb358a6
docs: clarify test-naming convention allows judgment for cross-cuttin…
aspiers May 21, 2026
bfb50d0
feat: restructure inline def into signature.defs#inline and extend si…
aspiers May 21, 2026
0384dad
docs(erd): omit signature lexicons from ERD with explanatory comment
aspiers May 21, 2026
5133898
docs(skill): add signature coverage to building-with-hypercerts-lexicons
aspiers May 21, 2026
da06ce0
docs: point primary spec links at the canonical Tangled URL
aspiers May 21, 2026
3627bc7
docs: restructure signing example to build signature before embedding
aspiers May 21, 2026
d51670d
test: extract and execute signing-example code blocks from docs
aspiers May 21, 2026
a6f7659
docs(changeset): drop duplicate signing-curve summary
aspiers May 21, 2026
867d2d0
fix review feedback on signing example and CI node version
Copilot May 26, 2026
717d7c0
refine signing example review fixes
Copilot May 26, 2026
e52b432
chore(eslint): ignore .git-worktrees/
aspiers Jun 24, 2026
38fde95
docs(changeset): condense signing-support summary and note doc updates
aspiers Jun 24, 2026
bf5c0dd
docs(signing): add key-management callout and persistence step
aspiers Jun 24, 2026
cb4cd1f
chore: regenerate SCHEMAS.md and ignore .git-worktrees/ in prettier
aspiers Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 158 additions & 1 deletion .agents/skills/building-with-hypercerts-lexicons/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -483,6 +498,148 @@ 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`.

> **Key management.** The platform signing keypair is a long-lived
> identity, not a per-record artefact. It MUST be generated once,
> persisted in secure storage (HSM, KMS, sealed secret, etc.), and
> reused across every signed record so that the public key resolved
> from the `key` DID-URL stays stable for verifiers. Generating a
> fresh keypair per signature (the "throwaway key" anti-pattern)
> defeats verification: nobody can confirm that two records came from
> the same signer, and the `did:key:` in `key` will not match
> anything any verifier has previously seen or pinned.

**Step 1: load (or create on first run) the platform signing
keypair.** Use `exportable: true` so the private key bytes can be
written to your secret store, then reload them on every subsequent
process start. The example below performs an in-memory round trip via
`export()` / `import()` to demonstrate the API surface — in
production, replace the round trip with real reads and writes against
your KMS / sealed-secret store:

```typescript
import { Secp256k1Keypair } from "@atproto/crypto";

// First-run bootstrap: generate, export, persist.
const fresh = await Secp256k1Keypair.create({ exportable: true });
const privateKeyBytes = await fresh.export(); // 32 raw bytes
// e.g. await kms.putSecret("hypercerts/platform-signing-key", privateKeyBytes);

// Every subsequent process load: read bytes from the store and rehydrate.
// e.g. const privateKeyBytes = await kms.getSecret("hypercerts/platform-signing-key");
const keypair = await Secp256k1Keypair.import(privateKeyBytes);
```

**Step 2: 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 * 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 with the persisted keypair from Step 1.
// Keypair.sign() applies the standard ECDSA hash-then-sign convention
// and enforces low-S per BIP-0062.
const signerDid = keypair.did();
if (!signerDid.startsWith("did:key:")) {
throw new Error(`Expected did:key signer, got ${signerDid}`);
}
const signerKey = `${signerDid}#${signerDid.slice("did:key:".length)}`;
const signatureBytes = await keypair.sign(cidBytes);

const inlineSignature = {
$type: "app.certified.signature.defs#inline" as const,
signature: signatureBytes,
key: signerKey, // DID verification method reference for the keypair above
};
```

**Step 3: 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 4: 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
Expand Down
16 changes: 16 additions & 0 deletions .changeset/add-signature-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@hypercerts-org/lexicon": minor
---

Add optional cryptographic signature support to all 21 record lexicons.

**Non-breaking:** signatures are optional on every record.

- New `app.certified.signature.defs` with `#list` (open union of inline signatures and `com.atproto.repo.strongRef` references) and `#inline` (raw ECDSA `(r,s)` bytes plus a DID verification method reference; signing curve derived from the verification method's multicodec prefix).
- New `app.certified.signature.proof` record for remote attestations.
- Every record lexicon gains an optional `signatures` property referencing `#list`.
- `README.md`, `SCHEMAS.md`, and the `building-with-hypercerts-lexicons` agent skill all gain a "Cryptographic Signatures" section with a worked end-to-end signing example.

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)).

On `app.certified.link.evm` the existing EIP-712 `proof` field (wallet consent) and the new `signatures` array (record provenance) are complementary and do not conflict.
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
node-version: "22"
cache: "npm"

- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
cache: "npm"
- run: npm ci
- name: Check for changeset
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:

- uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
cache: "npm"
# registry-url is required for npm Trusted Publishers
registry-url: "https://registry.npmjs.org"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
node-version: "22"
cache: "npm"

- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
node-version: "22"
cache: "npm"

- name: Install dependencies
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist/
generated/
pnpm-lock.yaml
/tmp/
tests/extracted/
lexicons.md

# Dolt database files (added by bd init)
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ tmp/
.codex/
.gemini/
.beads/
.git-worktrees/
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<lexicon-slug>.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.
Expand Down
23 changes: 23 additions & 0 deletions ERD.puml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Comment thread
aspiers marked this conversation as resolved.
' org.hypercerts.collection
dataclass collection {
!if (SHOW_FIELDS == "true")
Expand Down Expand Up @@ -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
Loading