nostr-veil
v0.29.0
Published
Privacy layer for Nostr reputation systems. Groups collectively score trustworthiness using ring signatures -- scores are verifiable but contributors are unidentifiable.
Maintainers
Readme
nostr-veil
Trust scores you can verify without seeing who contributed them.
A privacy layer for Nostr reputation systems. A group of people can collectively score someone's trustworthiness, and anyone can verify the result -- but nobody can tell which group members actually contributed. Built for abuse reporting, whistleblowing, journalism, and anonymous peer review.
New here? The use-case overview covers what nostr-veil is, the problem it solves, and practical things you can build on it today.
Key concepts
If you're new to this space, here's what the jargon means:
| Term | Plain English | |------|--------------| | Web of Trust (WoT) | Instead of a central authority deciding who's trustworthy, people vouch for each other. Your reputation is built from the opinions of people who know you. | | NIP-85 | A Nostr standard for publishing trust scores (e.g. "this person has 800 followers and a rank of 74"). Think of it as a shared format for reputation data. | | Ring signature | A cryptographic technique where someone signs a message on behalf of a group. A verifier can confirm "someone in this group signed this" but cannot tell who. Like a sealed ballot -- you can count the votes but not trace them. | | LSAG | Linkable Spontaneous Anonymous Group signature -- a specific type of ring signature that also detects duplicates. If the same person tries to vote twice, the system catches it, without revealing who they are. | | Trust circle | The group of people who collectively produce a trust score. Each member contributes anonymously; the results are combined (median by default). |
The trust trilemma
Today's Nostr trust scores (NIP-85) ask you to pick two:
| Property | Standard NIP-85 | With nostr-veil | |-------------|:---:|:---:| | Verifiable | ✓ | ✓ | | Private | ✗ | ✓ | | Portable | ✓ | ✓ |
Anyone who can see an ordinary score can see who published it, and multi-party reputation usually creates a visible contributor graph. That works fine for benign social signals. It fails the moment the subject matter is sensitive -- abuse reporting, whistleblowing, political dissent. The people who need reputation systems most are the ones who cannot afford to be identified.
nostr-veil solves all three for group assertions. The output is a standard NIP-85 event that NIP-85-aware apps can display. Apps that understand nostr-veil can go further and verify the cryptographic proofs that back the scores.
Architecture
graph TB
subgraph Consumers["Nostr Clients"]
any["Any NIP-85 client<br/>reads standard assertions"]
veil["Veil-aware client<br/>verifies ring proofs"]
end
subgraph Veil["nostr-veil"]
proof["nostr-veil/proof<br/>Ring signatures<br/>Anonymous contributions<br/>Threshold aggregation"]
nip85["nostr-veil/nip85<br/>Trust score events<br/>Provider declarations<br/>Builders · Parsers · Filters"]
end
subgraph Crypto["Cryptographic Primitives"]
ringsig["@forgesworn/ring-sig<br/>Ring signature engine"]
noble["@noble/curves + hashes<br/>Cryptographic primitives"]
end
subgraph Companions["Companion Libraries"]
nsec["nsec-tree<br/>sub-identity derivation"]
canary["canary-kit<br/>coercion detection"]
signet["signet<br/>identity verification"]
end
any -->|"read"| nip85
veil -->|"verify"| proof
proof -->|"builds on"| nip85
proof --> ringsig
nip85 --> noble
nsec -.->|"personas for"| proof
canary -.->|"liveness for"| proof
style any fill:#3b82f6,color:#fff
style veil fill:#d97706,color:#000
style proof fill:#d97706,color:#000
style nip85 fill:#0d9488,color:#fff
style ringsig fill:#8b5cf6,color:#fff
style noble fill:#8b5cf6,color:#fff
style nsec fill:#374151,color:#e5e7eb
style canary fill:#374151,color:#e5e7eb
style signet fill:#374151,color:#e5e7ebQuick start
npm install nostr-veilimport { createTrustCircle, contributeAssertion, aggregateContributions, verifyProof } from 'nostr-veil'
// 1. Define the circle (three anonymous members)
const circle = createTrustCircle([alicePubkey, bobPubkey, carolPubkey])
// 2. Each member contributes independently -- their identity is hidden inside the ring
const alice = contributeAssertion(circle, subjectPubkey, { followers: 820, rank: 74 }, alicePrivkey, 0)
const bob = contributeAssertion(circle, subjectPubkey, { followers: 900, rank: 80 }, bobPrivkey, 1)
// 3. Aggregate into a standard NIP-85 kind 30382 user assertion
const assertion = aggregateContributions(circle, subjectPubkey, [alice, bob])
// 4. Any client verifies -- two distinct members agreed, no names attached
const result = verifyProof(assertion)
// { valid: true, circleSize: 3, threshold: 2, distinctSigners: 2, errors: [] }Important: memberIndex must match the member's position in the sorted pubkey array. createTrustCircle sorts pubkeys lexicographically -- the index you pass to contributeAssertion must reflect that sorted order, not the order you passed to createTrustCircle. Use circle.members.indexOf(myPubkey) to find the correct index.
The resulting assertion is a plain EventTemplate you sign and publish like any other Nostr event.
API reference
nostr-veil/nip85 -- NIP-85 foundation
| Export | Description |
|--------|-------------|
| buildUserAssertion(pubkey, metrics) | Build a NIP-85 kind 30382 user assertion event template |
| buildEventAssertion(eventId, metrics) | Build a NIP-85 kind 30383 event assertion |
| buildAddressableAssertion(address, metrics) | Build a NIP-85 kind 30384 addressable event assertion |
| buildIdentifierAssertion(identifier, kTag, metrics) | Build a NIP-85 kind 30385 NIP-73/external identifier assertion |
| buildProviderDeclaration(providers, encryptedContent?) | Build a NIP-85 kind 10040 trusted service provider declaration |
| parseAssertion(event) | Parse a raw event into a ParsedAssertion |
| parseProviderDeclaration(event, decryptFn?) | Parse a kind 10040 provider declaration into ParsedProvider[] (supports optional NIP-44 decryption) |
| validateAssertion(event, options?) | Validate a NIP-85 assertion -- pass { strict: true } for kind-specific metric checks and optional subject-hint validation |
| validateAssertionStrict(event) | Strict NIP-85 assertion validation shortcut |
| validateProviderDeclarationStrict(event) | Strict kind 10040 provider declaration validation for plaintext tags; encrypted declarations are accepted when tags are omitted |
| assertionFilter({ kind, subject?, provider? }) | Build a relay query filter for assertions |
| providerFilter(pubkey) | Build a relay query filter for a provider declaration |
| NIP85_KINDS, describeNip85Kind(kind) | Kind constants and labels for the NIP-85 trusted assertion kinds |
nostr-veil/proof -- Ring-signature proof layer
| Export | Description |
|--------|-------------|
| createTrustCircle(memberPubkeys, options?) | Create a trust circle from an array of pubkeys; pass { scope } to federate circles for cross-circle deduplication |
| contributeAssertion(circle, subject, metrics, privateKey, memberIndex, options?) | Produce an anonymous ring-signed Contribution; v1 by default, v2 when { proofVersion: 'v2', kind, subjectTag, subjectTagValue } is supplied |
| contributeEventAssertion(circle, eventId, metrics, privateKey, memberIndex, options?) | Produce a contribution for a kind 30383 event assertion |
| contributeAddressableAssertion(circle, address, metrics, privateKey, memberIndex, options?) | Produce a contribution for a kind 30384 addressable assertion |
| contributeIdentifierAssertion(circle, identifier, kTag, metrics, privateKey, memberIndex, options?) | Produce a contribution for a kind 30385 identifier assertion |
| aggregateContributions(circle, subject, contributions, options?) | Aggregate user-score contributions into a kind 30382 NIP-85 event with veil tags (default aggregation: median) |
| aggregateEventContributions(circle, eventId, contributions, options?) | Aggregate into a kind 30383 event assertion with d/e tags |
| aggregateAddressableContributions(circle, address, contributions, options?) | Aggregate into a kind 30384 addressable assertion with d/a tags |
| aggregateIdentifierContributions(circle, identifier, kTag, contributions, options?) | Aggregate into a kind 30385 identifier assertion with d/k tags |
| verifyProof(event, options?) | Verify ring signatures, threshold metadata, signed metric aggregation, and optional proof-version requirements |
| verifyFederation(events, options?) | Verify several scoped events together and count distinct contributors across circles (cross-circle deduplication) |
| canonicalMessage(circleId, subject, metrics) | Compute the canonical message signed by contributors |
| canonicalMessageV2(circleId, subject, metrics, context) | Compute the opt-in v2 canonical message that binds kind and subject hint tag |
| computeCircleId(sortedPubkeys) | Compute the deterministic circle ID (SHA-256 of colon-joined pubkeys) |
nostr-veil/profiles -- safer deployment profiles
| Export | Description |
|--------|-------------|
| USE_CASE_PROFILES | Built-in machine-readable profiles for the documented use cases, including proof claims, limitations, required controls, and recommended actions |
| USE_CASE_PROFILE_BY_ID | Lookup table keyed by use-case slug |
| validateUseCaseProfileDefinition(profile) | Check custom profiles for NIP-85 route mismatches, unsupported metrics, missing safety metadata, and overclaiming warnings before production use |
| verifyUseCaseProfile(events, profile, options) | Verify NIP-85 syntax, proof v2, subject binding, threshold, freshness, accepted circles, and federation policy |
| createCircleManifest(options) | Build a machine-readable circle manifest with member list, allowed profiles, expiry, revocation, and supersession metadata |
| verifyCircleManifest(manifest, options?) | Verify that a circle manifest matches its members and deployment constraints |
| createDeploymentPolicy(profile, options) | Build a fail-closed deployment policy with accepted circles, expected subject, metric bounds, freshness, threshold, signature requirements, and optional companion evidence requirements |
| verifyDeploymentPolicy(events, policy, options?) | Verify a profile plus deployment-specific controls and supplied companion evidence before acting on a score |
| createSignedDeploymentBundle(policy, options) | Sign a deployment policy and its manifests as trusted operator configuration |
| verifyDeploymentBundle(events, bundle, options?) | Verify a signed bundle from trusted publishers, then verify the bundled deployment policy |
| verifyProductionDeployment(events, bundle, options?) | One-call production gate that also requires an expiring bundle and signed relay-fetched events by default |
| createProductionDecisionReport(result), verifyProductionDeploymentReport(events, bundle, options?) | Produce an audit/UI-ready decision report with recommended action, issue remediation, and control status |
| createAdmissionChallenge(options), createAdmissionPresentation(challenge, privateKey, options?) | Build and sign a pubkey-bound relay/community admission challenge outside the NIP-85 assertion |
| verifyAdmissionPresentation(presentation, challenge, options?), verifyAdmissionRequest(events, bundle, challenge, presentation, options?) | Verify the admission handshake plus the existing signed kind 30382 vouch and deployment bundle |
| VerificationIssue, VerificationIssueCode | Stable machine-readable issue codes returned alongside human-readable errors |
| explainVerificationIssue(issueOrCode), remediationForIssue(issueOrCode) | Turn stable issue codes into concrete operator or verifier remediation guidance |
| CompanionEvidence, CompanionEvidenceRequirement | Types for external resolver, provenance, service, and list checks that a deployment policy can require alongside the cryptographic proof |
| packageReleaseCompanionEvidenceRequirements(subject), collectPackageReleaseCompanionEvidence(options), resolvePackageReleaseCompanionEvidence(options) | Require package provenance, SBOM, and vulnerability-feed evidence; collect from registry/SBOM/OSV-style observations or resolve already-collected observations |
| nip05DomainCompanionEvidenceRequirements(subject), collectNip05DomainCompanionEvidence(options), resolveNip05DomainCompanionEvidence(options) | Require NIP-05 resolution, HTTPS probe, and DNS-owner evidence; collect from NIP-05/HTTPS/DNS observations or resolve already-collected observations |
| listLabelerCompanionEvidenceRequirements(subject), collectListLabelerCompanionEvidence(options), resolveListLabelerCompanionEvidence(options) | Require list revision, sample-review, and correction-channel evidence; collect from relay/list/correction observations or resolve already-collected observations |
| fetchNpmPackageVersionEvidence(), fetchOsvVulnerabilityReport(), normaliseSbomEvidence(), fetchNip05DocumentEvidence(), probeHttpsService(), fetchAddressableEventFromRelay(), probeCorrectionChannel() | Lower-level adapters for building custom evidence collection pipelines |
| canonicalRelaySubject, canonicalServiceSubject, canonicalNip05Subject, canonicalDomainSubject, canonicalLnurlpSubject, canonicalNip96Subject, canonicalNpmPackageSubject, canonicalPackageDigestSubject, canonicalGitRepositorySubject, canonicalGithubRepositorySubject, canonicalMaintainerSubject | Canonical subject helpers for common real-world identifiers |
| canonicalPubkeySubject, canonicalEventSubject, canonicalAddressSubject | Canonical subject helpers for Nostr-native subjects |
The built-in profiles include safety metadata for production UX and agentic
integrations: proofClaims, proofLimitations, requiredControls, and
recommendedActions. Use those fields to show what the proof actually supports
and which real-world checks must still happen outside nostr-veil. If you define
a custom profile, run validateUseCaseProfileDefinition() in tests or CI before
shipping it; warnings are where the profile may be technically valid but
underspecified for real-world decisions.
Use companionEvidence when a supported profile depends on facts outside the
NIP-85 event. For example, package policies can require npm-provenance,
sbom, and vulnerability-feed; provider policies can require
nip05-resolution, https-probe, and dns-owner-check; list policies can
require list-revision-fetch, sample-review, and correction-channel.
Missing, failed, stale, or wrong-subject evidence fails closed without changing
the underlying NIP-85 assertion. Prefer the collector helpers when the library
can fetch or normalise the observation for you, and the resolver helpers when
your application already has registry, service, or list observations from its
own infrastructure. Avoid hand-written pass records in production.
Signing utility (root export)
| Export | Description |
|--------|-------------|
| signEvent(template, privateKey) | Sign an unsigned event template with BIP-340 Schnorr -- returns a complete SignedEvent |
| verifySignedEvent(event) | Verify a signed Nostr event id and BIP-340 signature after fetching from an untrusted relay |
| computeEventId(event) | Compute the NIP-01 event ID (SHA-256 of canonical serialisation) |
How it works
Each member of a trust circle independently submits their scores. Under the hood, each submission is wrapped in a ring signature -- a cryptographic envelope that proves "a member of this group signed this" without revealing which member.
The published event carries extra tags on top of the standard NIP-85 format:
veil-ring-- the full list of circle members (the group who could have contributed)veil-threshold-- how many members actually contributed vs. total circle sizeveil-agg-- which aggregate function produced the metric tags (median by default)veil-sig(one per contributor) -- the ring signature and a duplicate-detection tokenveil-version-- present only for opt-in proof v2 eventsveil-scope-- present only for a federated circle; see Cross-circle deduplication
A verifier calls verifyProof, which:
- Checks each ring signature is valid (a real member signed it)
- Checks the duplicate-detection tokens are all different (nobody voted twice)
- Confirms the threshold metadata matches the ring and distinct signatures
- Confirms the published metric tags match the aggregate of the signed contributions
At no point does verification require knowing which member produced which signature. The group membership is public. The identities of the actual contributors are not. The signed metric values are public inside the proof, so nostr-veil hides contributor identity, not the fact that an anonymous contributor submitted a particular score.
By default, verification expects the same median aggregation used by aggregateContributions. If you pass a custom aggregateFn to aggregateContributions, pass the same function to verifyProof.
Proof v2
The original proof format remains the default. It signs the circle ID, the d-tag subject, and numeric metrics, and it uses veil:v1:<scope-or-circleId>:<subject> as the LSAG election ID.
Proof v2 is additive and opt-in. It signs the same data plus the NIP-85 assertion kind and the subject hint tag/value (p, e, a, or k), and derives the key image from that semantic context. That prevents a valid contribution for one assertion class being replayed as another when a subject string is reused.
import { contributeEventAssertion, aggregateEventContributions, verifyProof } from 'nostr-veil'
const contribution = contributeEventAssertion(
circle,
eventId,
{ rank: 90 },
alicePrivkey,
circle.members.indexOf(alicePubkey),
{ proofVersion: 'v2' },
)
const assertion = aggregateEventContributions(circle, eventId, [contribution], { proofVersion: 'v2' })
const result = verifyProof(assertion, { requireProofVersion: 'v2' })Old v1 events still verify without changes. Use requireProofVersion: 'v2' only when your application policy wants to reject legacy proofs for that workflow.
Threat model
nostr-veil is a privacy layer for already-defined trust circles. It gives you:
- Anonymous membership proof: each contribution was signed by some member of the public ring, without revealing which member.
- Duplicate detection: one member cannot contribute twice within the same circle/scope and subject without reusing the same LSAG key image.
- Signed metric integrity:
verifyProofchecks the published metric tags against the aggregate of the signed contribution messages. - Optional semantic binding: proof v2 binds the assertion kind and subject hint tag into the signed message and election ID.
- Standard NIP-85 output: non-veil clients can still read the aggregate score.
The boundary is deliberately narrow. Pair each limitation with the right application control:
- The ring membership list is public; use a cover set large enough for the risk and avoid circles that reveal vulnerable participants by membership alone.
- Anonymous metric values are public so anyone can recompute the aggregate; publish only metrics your workflow is comfortable exposing.
- Network, timing, relay, and collector metadata are outside the proof; use batching, transport privacy, and careful logging for sensitive collection.
- Circle quality is social policy; publish admission, rotation, Sybil, and conflict rules for circles that drive real decisions.
- A shared federation
scopeintentionally reveals cross-circle overlap by key image; use scoped federation only when deduplication is worth that signal.
For sensitive deployments, combine nostr-veil with careful collection, transport privacy, key hygiene, and a clear policy for how circles are admitted and rotated.
Production verifiers should prefer verifyProductionDeployment() over manually
composing lower-level checks. It returns the existing human-readable errors
plus stable issues[].code values such as bundle.signer_untrusted,
event.signature_invalid, circle.unaccepted, and metric.below_min. Use
explainVerificationIssue() or remediationForIssue() to show the exact
operator action for each failure. For application UI, audit logs, and support
runbooks, verifyProductionDeploymentReport() returns the same decision with a
recommended action, expanded issue remediations, and pass/fail/not-checked
status for the production controls.
Use cases
The shipped primitives support more than user trust scores. See docs/use-cases.md for the implementation field guide: one worked page per use case, subject formats, helper functions, metrics, proof-v2 verification, and the operational controls needed beyond the proof.
Supported today:
- User reputation and abuse reporting with NIP-85 kind 30382 user assertions.
- Source corroboration and peer review, using NIP-85 kind 30382 for pubkeys or kind 30385 for NIP-73/external identifiers.
- Event and claim verification with NIP-85 kind 30383 event assertions via
aggregateEventContributions. - Article, long-form note, research, grant, and proposal review with NIP-85 kind 30384 addressable event assertions via
aggregateAddressableContributions. - Relay, service, vendor, marketplace, package, release, maintainer, NIP-05, and domain reputation with NIP-85 kind 30385 identifier assertions via
aggregateIdentifierContributions. - Verifier and issuer legitimacy, so communities can decide which personhood, residency, credential, or witness verifiers they accept without exposing every reviewer.
- Community list, labeler, and moderation-list reputation where users can compare curation sources without mapping every reviewer.
- Federated moderation where scoped circles count overlapping contributors once, not once per circle.
- Privacy-preserving onboarding where an already-trusted circle can vouch for a new account without naming the individual vouchees.
- Relay or community admission gate building blocks, using a standard NIP-85 kind 30382 vouch plus the additive admission challenge helpers.
Future profiles:
- Anonymous credential or attestation co-signing, once endorsement event formats define subject, expiry, revocation, and presentation rules.
- Full anonymous relay or community admission, once credential/session continuity, revocation, and transport privacy semantics are defined.
nostr-veil proves that distinct keys from a public trust circle contributed. Trust-circle formation, Sybil policy, revocation, and anonymous access control are separate application-layer decisions.
Cross-circle deduplication
By default every trust circle is cryptographically isolated. A contributor's duplicate-detection token -- the LSAG key image -- is derived from the circle itself, so the same person contributing to two different circles produces two unrelated tokens. Nobody can tell the circles share a member.
That isolation is the right default, but it caps a trust score at one circle. To count honestly across a federation of circles all assessing the same subject, give them a shared scope:
// Two community moderation circles, one federation
const circleA = createTrustCircle(membersA, { scope: 'community-moderators' })
const circleB = createTrustCircle(membersB, { scope: 'community-moderators' })Scopes are lowercase slugs: letters, digits, dot, hyphen, and underscore.
Circles sharing a scope derive the key image from the scope rather than the circle, so a contributor who appears in several of them produces the same token in each. verifyFederation gathers events from across the federation, verifies each one, and counts the distinct contributors -- matched by key image, never by identity:
import { verifyFederation } from 'nostr-veil'
// Aggregated events from every circle in the federation, all about the same subject
const result = verifyFederation([circleAEvent, circleBEvent, circleCEvent])
// { valid: true, scope: 'community-moderators', circleCount: 3,
// totalSignatures: 7, distinctSigners: 5, ... }
// distinctSigners < totalSignatures: two contributors signed in more than one circleA scoped event carries a veil-scope tag, which both verifyProof and verifyFederation read automatically. verifyFederation rejects the federation if the events disagree on subject or scope, or if any event is unscoped -- an isolated circle's key images cannot be matched across circles. Circles created without a scope behave exactly as before: no tag, fully isolated.
Trade-off. A shared scope is what enables cross-circle counting, and equally what makes cross-circle membership overlap observable: anyone collecting the events can see that one contributor signed in several circles, still without learning who. Use a scope when federated counting is worth that signal, and omit it otherwise.
Companion projects
nostr-veil is one layer of a broader identity and trust stack:
- @forgesworn/ring-sig -- The ring signature engine (the core cryptography)
- nsec-tree -- Generate separate anonymous identities from a single master key
- canary-kit -- Detect when someone is being coerced (duress signals)
- signet -- Decentralised identity verification for Nostr
- dominion -- Epoch-based encrypted content access control
Each project is independently maintained and published. nostr-veil focuses solely on anonymous trust assertions.
Further reading
- IMPACT.md -- Problem statement and ecosystem impact
- docs/use-cases.md -- Concrete implementation map with individual worked use-case pages
- CONTRIBUTING.md -- Setup, testing, and PR guidelines
- SECURITY.md -- Vulnerability reporting and cryptographic scope
- llms.txt -- Machine-readable project summary for LLMs
- CLAUDE.md -- AI agent instructions for contributing
Part of the ForgeSworn Toolkit
ForgeSworn builds open-source cryptographic identity, payments, and coordination tools for Nostr.
| Library | What it does | |---------|-------------| | nsec-tree | Deterministic sub-identity derivation | | ring-sig | Ring signature engine (core cryptography) | | range-proof | Pedersen commitment range proofs | | canary-kit | Coercion-resistant spoken verification | | spoken-token | Human-speakable verification tokens | | toll-booth | L402 payment middleware | | geohash-kit | Geohash toolkit with polygon coverage | | nostr-attestations | NIP-VA verifiable attestations | | dominion | Epoch-based encrypted access control | | nostr-veil | Privacy-preserving Web of Trust |
Licence
MIT
