# nostr-attestations

[![npm](https://img.shields.io/npm/v/nostr-attestations)](https://www.npmjs.com/package/nostr-attestations)
[![CI](https://github.com/forgesworn/nostr-attestations/actions/workflows/ci.yml/badge.svg)](https://github.com/forgesworn/nostr-attestations/actions/workflows/ci.yml)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/TheCryptoDonkey?logo=githubsponsors&color=ea4aaa&label=Sponsor)](https://github.com/sponsors/TheCryptoDonkey)

One Nostr event kind for all attestations -- credentials, endorsements, vouches, provenance, and licensing. Supports both direct attestation and assertion-first patterns within a single kind.

**Nostr:** [`npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2`](https://njump.me/npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2)

## Install

```
npm install nostr-attestations
```

## Quick Start

```typescript
import { createAttestation, TYPES } from 'nostr-attestations'

// Create an unsigned attestation event template
const event = createAttestation({
  type: TYPES.CREDENTIAL,
  identifier: '<subject-pubkey>', // d-tag segment: names this attestation
  subject: '<subject-pubkey>',    // p-tag: who the attestation is about
  summary: 'Professional credential verified',
  expiration: 1735689600,
  tags: [['profession', 'attorney'], ['jurisdiction', 'US-NY']],
  content: JSON.stringify({ proof: '...' }),
})
// => { kind: 31000, tags: [...], content: '...' }
// Sign with your preferred library and publish to relays
```

No relay client, no signing library, no crypto. Bring your own. Works with nostr-tools, any Nostr SDK, or bare WebSocket code.

### Assertion-First Pattern

Attest to someone else's claim -- the individual makes an assertion, you verify it:

```typescript
import { createAttestation } from 'nostr-attestations'

// Attest to a first-person assertion event
const event = createAttestation({
  assertion: { id: '<assertion-event-id>', relay: 'wss://relay.example.com' },
  subject: '<subject-pubkey>',
  content: 'Identity verified in person',
})
// type is inferred from the referenced assertion -- no type tag needed
```

### Temporal Context

Record when the attested event actually happened, separate from when the attestation was published:

```typescript
const event = createAttestation({
  type: TYPES.ENDORSEMENT,
  subject: '<service-provider-pubkey>',
  occurredAt: 1710900000,  // service completed last Tuesday
  summary: 'Completed frontend work ahead of schedule',
})
// created_at = when attestation published, occurred_at = when it happened
```

### NIP-32 Labels

All attestations include [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) labels for relay-side discoverability. Typed attestations also include a type label:

```json
["L", "attestation"]
["l", "credential", "attestation"]
```

This enables queries like `{"#L": ["attestation"]}` (all attestations) or `{"#l": ["credential"]}` (by type).

## Why This?

Nostr has several ways to label or badge identities, but none designed for verifiable attestations. [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) badges are display-only -- no expiry, no revocation, no structured claims. [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) covers social graph metrics, not arbitrary claims. [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) labels are lightweight but not individually replaceable per subject.

nostr-attestations uses **one kind (31000) with a `type` tag** instead of inventing a new event kind for every attestation use case. Credentials, endorsements, vouches, licensing, provenance, and revocations all share the same event structure. New attestation types need zero protocol changes -- just define a new `type` value.

Every attestation is **self-describing** -- the `type` tag and [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) labels mean a client can render a meaningful card, and a relay can filter by attestation type, without fetching any referenced events first.

It supports two attestation patterns within the same kind:
- **Direct attestation** -- the attestor defines the type and makes a claim about a subject
- **Assertion-first** -- the subject makes their own claim, and the attestor references and verifies it

The base layer is **semantically neutral** -- it carries attestations but does not interpret them. Flows like attestation requests, trust lists, and attestor discovery belong at the application layer, not in the protocol. Application profiles (identity verification, professional licensing, service reputation) are built downstream, not in the library.

## Revocation

```typescript
import { createRevocation, isRevoked, parseAttestation } from 'nostr-attestations'

// Revoke a previously issued attestation
const revocation = createRevocation({
  type: 'credential',
  identifier: '<subject-pubkey>',
  subject: '<subject-pubkey>',
  reason: 'licence-expired',
  effective: 1704067200,
})
// Publish -- addressable event semantics replace the original

// Check if a fetched event is revoked
const revoked = isRevoked(event) // true if ["status", "revoked"] tag present
```

## Parsing

```typescript
import { parseAttestation } from 'nostr-attestations'

const attestation = parseAttestation(event) // returns null for non-attestation events
if (!attestation) throw new Error('Not a valid attestation')
// {
//   kind: 31000,
//   type: 'credential',          // "assertion" for assertion-only attestations
//   pubkey: '<attestor-pubkey>',
//   createdAt: 1700000000,
//   identifier: '<subject-pubkey>',
//   subject: '<subject-pubkey>',
//   assertionId: null,            // event ID if assertion-first
//   assertionAddress: null,       // addressable coord if assertion-first
//   assertionRelay: null,         // relay hint for assertion
//   summary: 'Professional credential verified',
//   expiration: 1735689600,
//   validFrom: null,
//   validTo: null,                // validity window end
//   request: null,                // what prompted this attestation
//   schema: null,                 // machine-readable schema URI
//   occurredAt: null,             // when the attested event happened
//   revoked: false,
//   reason: null,
//   tags: [...],
//   content: '...',
// }
```

## Validation

```typescript
import { validateAttestation, isValid } from 'nostr-attestations'

// Structural correctness (d-tag, type, assertion refs, etc.)
const result = validateAttestation(event)
if (!result.valid) console.error(result.errors)

// Temporal validity (revocation, expiration, validity window)
const validity = isValid(event)
if (!validity.valid) console.warn(validity.reason)
// reason: 'revoked' | 'expired' | 'not-yet-active' | 'claim-expired'
```

## API Reference

### Builders

| Function | Signature | Returns |
|----------|-----------|---------|
| `createAttestation` | `(params: AttestationParams) => EventTemplate` | Unsigned attestation event |
| `createRevocation` | `(params: RevocationParams) => EventTemplate` | Unsigned revocation event |

### Parsers

| Function | Signature | Returns |
|----------|-----------|---------|
| `parseAttestation` | `(event: NostrEvent) => Attestation \| null` | Typed attestation data, or `null` for non-attestation events |
| `isRevoked` | `(event: NostrEvent) => boolean` | True if event has `["status", "revoked"]` |

### Validators

| Function | Signature | Returns |
|----------|-----------|---------|
| `validateAttestation` | `(event: NostrEvent) => ValidationResult` | `{ valid: boolean, errors: string[] }` |

### Validity

| Function | Signature | Returns |
|----------|-----------|---------|
| `isValid` | `(event: NostrEvent, now?: number) => ValidityResult` | `{ valid: boolean, reason?: string }` |

### Filters

| Function | Signature | Returns |
|----------|-----------|---------|
| `attestationFilter` | `(params: FilterParams) => NostrFilter` | Relay query filter |
| `revocationFilter` | `(type, identifier)` or `({ assertionId \| assertionAddress })` | Revocation check filter |
| `buildDTag` | `(type: string, identifier: string) => string` | `"type:identifier"` string |
| `buildAssertionDTag` | `(ref: string) => string` | `"assertion:ref"` string |
| `parseDTag` | `(dTag: string) => { type: string; identifier: string } \| null` | Parsed d-tag (type is `"assertion"` for assertion-only) |

### Constants

| Export | Value | Description |
|--------|-------|-------------|
| `ATTESTATION_KIND` | `31000` | NIP-VA event kind |
| `TYPES` | `{ CREDENTIAL, ENDORSEMENT, VOUCH, VERIFIER, PROVENANCE }` | Well-known type constants |

### Types

`AssertionRef`, `AttestationParams`, `RevocationParams`, `Attestation`, `ValidationResult`, `ValidityResult`, `FilterParams`, `NostrFilter`, `NostrEvent`, `EventTemplate` -- all exported from the package root and from `nostr-attestations/types` (zero-runtime import).

## Test Vectors

`vectors/attestations.json` contains 20 frozen conformance test vectors covering the full range of attestation types (credential, endorsement, vouch, verifier, provenance) and states (active, revoked, self-attestation). Any conformant implementation must produce identical parse results from these inputs. The vectors are pinned -- if tests against them fail, the implementation is broken, not the vector.

## Attested on Nostr

This library's authorship is claimed on Nostr using the very protocol it implements -- NIP-VA eating its own dog food.

A self-attestation alone only proves that the holder of a private key *claims* authorship -- not that the claim is true. The real value comes from **third-party attestations**: other pubkeys independently publishing `type: endorsement` events that reference this repo.

But counting endorsements isn't enough either -- anyone can create 50 throwaway npubs and endorse themselves. What matters is **who** endorses, not how many. A single endorsement from a pubkey with a verified NIP-05 domain, a history of notes, and followers you recognise is worth more than a thousand from anonymous keys. This is a web of trust, not a vote count. When verifying, ask: do I know this endorser? Do people I trust follow them? That's how you resist Sybil attacks without a centralised authority.

All verification uses [nak](https://github.com/fiatjaf/nak) (the Nostr Army Knife). Install with `go install github.com/fiatjaf/nak@latest` or `brew install fiatjaf/tap/nak`.

**1. GitHub → Nostr** -- this README claims `npub1mgv...` (see header above)

**2. Nostr → GitHub** -- the repo announcement points back here:

```bash
nak req -k 30617 \
  -a $(nak decode npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2) \
  -d nostr-attestations \
  wss://relay.damus.io
# Look for the "web" tag → github.com/forgesworn/nostr-attestations
```

**3. Verify the authorship claim** -- signed by the same key:

```bash
nak req -k 31000 \
  -a $(nak decode npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2) \
  -d authorship:nostr-attestations \
  wss://relay.damus.io 2>/dev/null | nak verify \
  && echo "✓ Signature valid"
```

**4. Check for third-party endorsements:**

```bash
nak req -k 31000 \
  -t a=30617:da19f1cd34beca44be74da4b306d9d1dd86b6343cef94ce22c49c6f59816e5bd:nostr-attestations \
  wss://relay.damus.io
# Returns all attestations referencing this repo -- filter by pubkey or type client-side
```

Same npub on both sides -- you'd need to control both GitHub and the private key to fake it. Third-party endorsements add independent signatures that can't be faked by one person.

**Endorse it yourself:**

```bash
nak event -k 31000 \
  --prompt-sec \
  -d endorsement:da19f1cd34beca44be74da4b306d9d1dd86b6343cef94ce22c49c6f59816e5bd \
  -t type=endorsement \
  -p da19f1cd34beca44be74da4b306d9d1dd86b6343cef94ce22c49c6f59816e5bd \
  -t a=30617:da19f1cd34beca44be74da4b306d9d1dd86b6343cef94ce22c49c6f59816e5bd:nostr-attestations \
  -t summary="Reviewed and endorsed nostr-attestations" \
  wss://relay.damus.io wss://nos.lol wss://relay.nostr.band
```

## NIP-VA

Full protocol specification: [NIP-VA.md](./NIP-VA.md) | [NostrHub](https://nostrhub.io/npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2)

## Part of the ForgeSworn Toolkit

[ForgeSworn](https://forgesworn.dev) builds open-source cryptographic identity, payments, and coordination tools for Nostr.

| Library | What it does |
|---------|-------------|
| [nsec-tree](https://github.com/forgesworn/nsec-tree) | Deterministic sub-identity derivation |
| [ring-sig](https://github.com/forgesworn/ring-sig) | SAG/LSAG ring signatures on secp256k1 |
| [range-proof](https://github.com/forgesworn/range-proof) | Pedersen commitment range proofs |
| [canary-kit](https://github.com/forgesworn/canary-kit) | Coercion-resistant spoken verification |
| [spoken-token](https://github.com/forgesworn/spoken-token) | Human-speakable verification tokens |
| [toll-booth](https://github.com/forgesworn/toll-booth) | L402 payment middleware |
| [geohash-kit](https://github.com/forgesworn/geohash-kit) | Geohash toolkit with polygon coverage |
| [nostr-attestations](https://github.com/forgesworn/nostr-attestations) | NIP-VA verifiable attestations |
| [dominion](https://github.com/forgesworn/dominion) | Epoch-based encrypted access control |
| [nostr-veil](https://github.com/forgesworn/nostr-veil) | Privacy-preserving Web of Trust |

## Licence

MIT
