{"id":"0a6960c2141682ae885dd48fb632811582e6b01a5650469c362b7f0e26b4e370","pubkey":"da19f1cd34beca44be74da4b306d9d1dd86b6343cef94ce22c49c6f59816e5bd","created_at":1774618462,"kind":30817,"tags":[["title","NIP-VEIL: Anonymous Trust Assertions"],["k","composition-guide"],["d","nip-veil"]],"content":"NIP-XX\n======\n\nAnonymous Trust Assertions (Veil)\n----------------------------------\n\n`draft` `optional`\n\nAuthors: [forgesworn](https://github.com/forgesworn)\n\nThis NIP extends [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) trusted assertion events with three tags that enable ring-signature-backed anonymous endorsements. Circle members contribute signed metrics without revealing which members contributed. The result is a standard NIP-85 event that non-aware clients process normally, while Veil-aware clients additionally verify the cryptographic proofs.\n\n## Motivation\n\nNIP-85 provides a foundation for trusted assertions on Nostr. A reputation service publishes metrics (follower count, activity score, zap history) about a subject, and clients display those metrics alongside profiles. The problem is attribution: every NIP-85 assertion is signed by a single pubkey, making it trivially linkable to the service that produced it.\n\nThis creates three failure modes:\n\n1. **Retaliation risk.** A provider who gives an honest low score to a well-connected subject risks social or economic retaliation. The subject knows exactly who rated them.\n2. **Cartel pressure.** Dominant reputation services can be pressured to inflate scores for favoured subjects. There is no way for a group to produce a collective assessment without revealing individual contributions.\n3. **Single point of trust.** Consumers must trust one service's assessment. There is no mechanism for multiple independent observers to contribute to a single assertion while maintaining plausible deniability.\n\nVeil solves these by allowing a defined group (a \"trust circle\") to contribute metrics anonymously via LSAG ring signatures. The circle membership is public -- anyone can see who COULD have contributed -- but which members actually did contribute is cryptographically hidden.\n\n### Why not just use NIP-85?\n\nNIP-85 handles the common case well: a reputation service with a known pubkey publishes assertions, and clients weigh those assertions by the service's own reputation. Veil is not a replacement. It is a supplementary layer for situations where individual attribution is harmful:\n\n- Peer review among competing service providers\n- Whistleblower-style trust revocations within professional groups\n- Collective endorsements from industry bodies where individual votes must remain private\n- Any scenario where the credibility of the assertion depends on group membership rather than individual identity\n\nStandard NIP-85 remains the right choice for public, attributable reputation services.\n\n### Why not NIP-32 (Labels)?\n\nNIP-32 label events allow any pubkey to attach labels to any content. While labels can express sentiment or categorisation, they have no threshold concept, no ring membership, and fully expose the labeller's identity. They do not provide anonymity or collective assessment.\n\n### Why not NIP-44 encrypted assertions?\n\nNIP-44 encrypted assertions hide content from relays but not from the designated recipient. The subject still knows exactly who rated them once they decrypt. Veil provides a fundamentally different property: even the subject, with full access to the event and unlimited computation, cannot determine which circle members contributed. This is unconditional anonymity, not just transport-layer encryption.\n\nAdditionally, NIP-44 encrypted assertions break the public verifiability that makes NIP-85 useful. Anyone should be able to verify a trust assertion without needing decryption keys. Veil assertions are fully public and fully verifiable -- what they hide is the individual contributors, not the collective result.\n\n## Overview\n\n\n![Anonymous Trust Assertion Flow](https://raw.githubusercontent.com/forgesworn/nip-drafts/main/images/veil-1.png)\n\n```mermaid\nsequenceDiagram\n    participant M1 as Circle Member 1\n    participant M2 as Circle Member 2\n    participant M3 as Circle Member 3\n    participant Agg as Aggregator\n    participant R as Relay\n    participant V as Verifier\n\n    note over M1,M3: Trust Circle (3 members, all pubkeys public)\n\n    M1->>M1: Sign canonical message with LSAG\n    M1->>Agg: Contribution (LSAG signature + key image + metrics)\n\n    M2->>M2: Sign canonical message with LSAG\n    M2->>Agg: Contribution (LSAG signature + key image + metrics)\n\n    note over M3: Member 3 does NOT contribute\n\n    Agg->>Agg: Verify all LSAG signatures\n    Agg->>Agg: Check key image uniqueness\n    Agg->>Agg: Aggregate metrics (median)\n    Agg->>Agg: Build NIP-85 event + veil-* tags\n\n    Agg->>R: Publish kind 30382 event\n\n    V->>R: REQ kind 30382\n    R->>V: Event with veil-ring, veil-threshold, veil-sig tags\n    V->>V: Extract ring from veil-ring\n    V->>V: Reconstruct LSAG signatures\n    V->>V: Verify each signature against ring\n    V->>V: Confirm key image uniqueness\n    V->>V: Check threshold met (2/3)\n    note over V: Valid assertion, anonymous contributors\n```\n\n### Terminology\n\n| Term | Definition |\n|------|-----------|\n| **Trust circle** | A fixed set of pubkeys whose members may contribute to anonymous assertions |\n| **Circle ID** | SHA-256 hash of the colon-joined sorted member pubkeys -- uniquely identifies a circle |\n| **Contribution** | A single member's LSAG-signed metrics for a specific subject |\n| **Key image** | An LSAG-derived value that is unique per signer per election, enabling double-sign detection without revealing the signer |\n| **Election ID** | A domain separator string binding an LSAG signature to a specific circle and subject, preventing signature transplant. (The term originates from the ring-signature literature where LSAG is used for anonymous voting.) |\n| **Ring** | The ordered set of public keys against which an LSAG signature is verified |\n| **Aggregator** | The entity (any circle member or delegate) that collects contributions, verifies signatures, and publishes the combined event |\n\n## Tags\n\nThis NIP introduces three new tags that are added to standard NIP-85 assertion events (kinds 30382--30385).\n\n### `veil-ring`\n\nContains the public keys of all trust circle members.\n\n```json\n[\"veil-ring\", \"<pk1>\", \"<pk2>\", ..., \"<pkN>\"]\n```\n\nEach element after the tag name is a 64-character lowercase hex x-only public key. The keys MUST be sorted lexicographically. Minimum 2 members, maximum 1000.\n\n**Example (3-member circle):**\n\n```json\n[\"veil-ring\",\n  \"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\n  \"4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766\",\n  \"531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337\"\n]\n```\n\nThe ring defines who COULD have contributed, without revealing who DID.\n\n### `veil-threshold`\n\nDeclares how many circle members actually contributed.\n\n```json\n[\"veil-threshold\", \"<count>\", \"<total>\"]\n```\n\n| Element | Description |\n|---------|-------------|\n| `count` | Number of actual contributors (matches the number of `veil-sig` tags) |\n| `total` | Total circle size (matches the number of pubkeys in `veil-ring`) |\n\n**Example (2 of 3 members contributed):**\n\n```json\n[\"veil-threshold\", \"2\", \"3\"]\n```\n\n### `veil-sig`\n\nOne tag per contributor, carrying the LSAG signature data and key image.\n\n```json\n[\"veil-sig\", \"<json-signature>\", \"<key-image>\"]\n```\n\n| Element | Description |\n|---------|-------------|\n| `json-signature` | JSON object containing `c0`, `electionId`, `message`, and `responses[]` (LSAG signature components). Keys are sorted alphabetically. The ring is NOT included -- it is in the `veil-ring` tag to avoid duplication. |\n| `key-image` | Hex-encoded key image. Unique per signer per election. Used to detect double-signing. |\n\n**Example:**\n\n```json\n[\"veil-sig\",\n  \"{\\\"c0\\\":\\\"a1b2...\\\",\\\"electionId\\\":\\\"veil:v1:beadfb...:1b84c5...\\\",\\\"message\\\":\\\"{\\\\\\\"circleId\\\\\\\":\\\\\\\"beadfb...\\\\\\\",\\\\\\\"metrics\\\\\\\":{\\\\\\\"rank\\\\\\\":85},\\\\\\\"subject\\\\\\\":\\\\\\\"1b84c5...\\\\\\\"}\\\",\\\"responses\\\":[\\\"d4e5...\\\",\\\"f6a7...\\\",\\\"b8c9...\\\"]}\",\n  \"c0d1e2f3...\"\n]\n```\n\nThe JSON signature object contains these fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `c0` | string | Initial challenge value of the LSAG ring signature |\n| `electionId` | string | Binds the signature to this specific circle + subject |\n| `message` | string | The canonical message that was signed |\n| `responses` | string[] | Response values for each ring member position |\n| `domain` | string (optional) | LSAG domain separator, if used |\n\n## Tag Reference\n\n| Tag | Status | Description |\n|-----|--------|-------------|\n| `d` | REQUIRED | Subject identifier (inherited from NIP-85) |\n| `p` | REQUIRED | Subject pubkey (inherited from NIP-85) |\n| `veil-ring` | REQUIRED | Sorted x-only hex pubkeys of all circle members |\n| `veil-threshold` | REQUIRED | `[\"veil-threshold\", \"<count>\", \"<total>\"]` |\n| `veil-sig` | REQUIRED | One per contributor: `[\"veil-sig\", \"<json>\", \"<key-image>\"]` |\n| `expiration` | RECOMMENDED | NIP-40 expiration timestamp |\n| Metric tags | OPTIONAL | Standard NIP-85 metric tags (`rank`, `followers`, etc.) |\n\n## Circle Construction\n\n### Circle ID\n\nThe circle ID is a SHA-256 hash of the sorted member pubkeys joined with colons:\n\n```\ncircleId = SHA-256(sorted_pubkeys.join(':'))\n```\n\n**Example:**\n\nGiven pubkeys (already sorted):\n- `1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f`\n- `4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766`\n- `531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337`\n\nInput string:\n```\n1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f:4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766:531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337\n```\n\nCircle ID: `beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2`\n\n### Rules\n\n- Members MUST be sorted lexicographically before hashing\n- Each pubkey MUST be a 64-character lowercase hex string (x-only format)\n- Duplicate pubkeys MUST be rejected\n- Minimum 2 members, maximum 1000\n\n## Canonical Message Format\n\nEach contributor signs a deterministic JSON string:\n\n```json\n{\"circleId\":\"<circle-id>\",\"metrics\":{\"<key1>\":<val1>,...},\"subject\":\"<subject>\"}\n```\n\nConstruction rules:\n\n1. Top-level keys MUST be sorted alphabetically: `circleId`, `metrics`, `subject`\n2. Metric keys MUST be sorted alphabetically within the `metrics` object\n3. All metric values MUST be finite numbers (reject NaN, Infinity, -Infinity)\n4. No whitespace in the serialised JSON (deterministic serialisation)\n5. Keys at every nesting level MUST be sorted alphabetically\n\n**Example:**\n\n```json\n{\"circleId\":\"beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2\",\"metrics\":{\"followers\":1200,\"rank\":85},\"subject\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\"}\n```\n\n## Election ID\n\nFormat: `veil:v1:<circleId>:<subject>`\n\n**Example:**\n\n```\nveil:v1:beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2:1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\n```\n\nThe election ID binds an LSAG signature to a specific circle and subject combination. This prevents signature transplant attacks -- a valid signature for one subject cannot be moved to an event about a different subject. Verifiers MUST check that each signature's `electionId` matches the expected pattern derived from the `veil-ring` and `d` tag.\n\nThe `v1` component is a version identifier. Future protocol versions MAY define new election ID formats with different prefixes.\n\n## Contribution and Aggregation\n\n### Contributing\n\nEach contributing member:\n\n1. Constructs the canonical message from the circle ID, subject, and their metrics\n2. Constructs the election ID from the circle ID and subject\n3. Signs the message using LSAG with the full ring (all circle members' pubkeys), their private key, and the election ID\n4. Sends the contribution (signature, key image, metrics) to the aggregator\n\n### Aggregating\n\nThe aggregator:\n\n1. Verifies all LSAG signatures against the ring before proceeding\n2. Checks that all key images are unique (no double-signing)\n3. Aggregates the contributed metrics using a deterministic function (RECOMMENDED: median)\n4. Constructs a standard NIP-85 event with the aggregated metrics as tags\n5. Adds the three `veil-*` tags\n6. Signs and publishes the event\n\n```mermaid\nflowchart TD\n    A[Receive contributions] --> B{All LSAG signatures valid?}\n    B -- No --> X[Reject: invalid signature]\n    B -- Yes --> C{All key images unique?}\n    C -- No --> Y[Reject: double-signing detected]\n    C -- Yes --> D{All electionIds match expected?}\n    D -- No --> Z[Reject: electionId mismatch]\n    D -- Yes --> E[Aggregate metrics]\n    E --> F[Build NIP-85 event with metric tags]\n    F --> G[Add veil-ring tag]\n    G --> H[Add veil-threshold tag]\n    H --> I[Add veil-sig tags]\n    I --> J[Sign and publish]\n```\n\n### Metric Aggregation\n\nImplementations SHOULD use the median as the default aggregation function. The median is resistant to outlier manipulation -- a single dishonest contributor cannot skew the result beyond their own contribution.\n\nFor each metric key present in any contribution:\n\n1. Collect all values for that key across contributions\n2. Sort the values numerically\n3. Return the middle value (or the rounded mean of the two middle values for even-length arrays)\n\nImplementations MAY support alternative aggregation functions (mean, trimmed mean, mode) but MUST document which function was used. The choice of function is not encoded in the event -- verifiers trust the aggregator's stated methodology or verify by re-aggregating from the raw contributions if available out of band.\n\n## Verification\n\nVerifying a Veil-enhanced NIP-85 event:\n\n1. Extract the `d` tag. If absent, reject the event. (The `d` tag is inherited from NIP-85 as REQUIRED; without it, election ID binding cannot protect against signature transplant.)\n2. Extract the `veil-ring` tag. Validate that all entries are 64-character lowercase hex strings.\n3. Validate ring size is between 2 and 1000 (inclusive).\n4. Validate that the pubkeys in `veil-ring` are in strict lexicographic order. Reject if not. (Implementations MUST NOT silently re-sort the ring, as this would mask malformed events.)\n5. Extract the `veil-threshold` tag. If absent, reject the event. Parse `count` and `total`. Verify `total` matches the ring size. Verify `count` is between 1 and `total` (inclusive).\n6. Compute the expected circle ID: `SHA-256(ring_members.join(':'))`.\n7. Compute the expected election ID (domain separator): `veil:v1:<circleId>:<d-tag-value>`.\n8. For each `veil-sig` tag:\n    - a. Parse the JSON signature data from element 1.\n    - b. Extract the key image from element 2.\n    - c. Reconstruct the full LSAG signature object by adding `ring` (from `veil-ring`) and `keyImage` (from element 2) to the parsed JSON.\n    - d. Verify the LSAG signature.\n    - e. Verify the `electionId` field matches the expected election ID. Missing `electionId` MUST be treated as failure.\n    - f. Verify that the `circleId` and `subject` in the `message` field match the derived circle ID and `d` tag value respectively.\n    - g. Check the key image has not appeared in a previous `veil-sig` tag within this event.\n9. Count valid signatures. The event is valid if `valid_signatures >= threshold_count`.\n\n```mermaid\nflowchart TD\n    S[Start verification] --> A[Extract veil-ring tag]\n    A --> B{Ring size 2..1000?}\n    B -- No --> FAIL1[INVALID: ring size out of bounds]\n    B -- Yes --> C[Extract veil-threshold tag]\n    C --> D{total == ring size?}\n    D -- No --> FAIL2[INVALID: threshold mismatch]\n    D -- Yes --> E[Compute circleId and expected electionId]\n    E --> F[For each veil-sig tag]\n    F --> G[Parse JSON signature + key image]\n    G --> H[Reconstruct full LSAG signature]\n    H --> I{LSAG signature valid?}\n    I -- No --> SKIP[Skip this signature, record error]\n    I -- Yes --> J{electionId matches expected?}\n    J -- No --> SKIP\n    J -- Yes --> K{Key image unique?}\n    K -- No --> SKIP\n    K -- Yes --> L[Count as valid]\n    L --> M{More veil-sig tags?}\n    SKIP --> M\n    M -- Yes --> F\n    M -- No --> N{valid count >= threshold?}\n    N -- No --> FAIL3[INVALID: threshold not met]\n    N -- Yes --> PASS[VALID]\n```\n\n## NIP-40 Expiration\n\nEvents SHOULD include a [NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md) `expiration` tag. Trust scores without a TTL are a social engineering vector -- stale endorsements can misrepresent current trust. A score from two years ago carries different weight than one from yesterday, but without expiration there is no protocol-level signal of staleness.\n\n**Example:**\n\n```json\n[\"expiration\", \"1735689600\"]\n```\n\nClients SHOULD treat expired Veil assertions as informational only, not as current trust signals.\n\n## Backwards Compatibility\n\nVeil-enhanced events are standard NIP-85 assertion events. The three `veil-*` tags are additional metadata:\n\n- **NIP-85 clients** that are not Veil-aware see a normal assertion event with metric tags (`rank`, `followers`, etc.). The `veil-ring`, `veil-threshold`, and `veil-sig` tags are unrecognised and ignored per standard Nostr tag behaviour.\n- **Veil-aware clients** additionally extract the `veil-*` tags, verify the ring signatures, and display the circle size and threshold alongside the metrics.\n\nNo new event kinds are introduced. No changes to relay software are required.\n\n## REQ Filter Examples\n\nVeil events use standard NIP-85 kinds, so existing relay filters work without modification.\n\n**Fetch all user assertions for a subject:**\n\n```json\n[\"REQ\", \"veil-1\", {\"kinds\": [30382], \"#d\": [\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\"]}]\n```\n\n**Fetch assertions from a specific aggregator:**\n\n```json\n[\"REQ\", \"veil-2\", {\"kinds\": [30382], \"authors\": [\"<aggregator-pubkey>\"]}]\n```\n\n**Fetch event assertions:**\n\n```json\n[\"REQ\", \"veil-3\", {\"kinds\": [30383], \"#d\": [\"<event-id>\"]}]\n```\n\nClients SHOULD post-filter results to distinguish Veil-enhanced events (presence of `veil-ring` tag) from standard NIP-85 events.\n\n## Validation Rules\n\n| ID | Rule |\n|----|------|\n| V-VL-01 | The `veil-ring` tag MUST contain at least 2 and at most 1000 pubkey elements (after the tag name). |\n| V-VL-02 | Every pubkey in `veil-ring` MUST be a 64-character lowercase hex string. |\n| V-VL-03 | Pubkeys in `veil-ring` MUST be sorted lexicographically. Implementations MUST reject unsorted rings. |\n| V-VL-04 | Pubkeys in `veil-ring` MUST be unique. Duplicate pubkeys MUST cause rejection. |\n| V-VL-05 | The `veil-threshold` tag MUST be present and MUST have exactly 3 elements: tag name, count, total. If absent, the event MUST be rejected. |\n| V-VL-06 | The `total` in `veil-threshold` MUST equal the number of pubkeys in `veil-ring`. |\n| V-VL-07 | The `count` in `veil-threshold` MUST be a positive integer between 1 and `total` (inclusive). |\n| V-VL-08 | The number of `veil-sig` tags MUST equal the `count` in `veil-threshold`. |\n| V-VL-09 | The number of `veil-sig` tags MUST NOT exceed the ring size. |\n| V-VL-10 | Each `veil-sig` tag MUST have exactly 3 elements: tag name, JSON signature, key image. |\n| V-VL-11 | The JSON signature in each `veil-sig` MUST contain `c0`, `electionId`, `message`, and `responses` fields. |\n| V-VL-12 | The `electionId` in each signature MUST match `veil:v1:<circleId>:<d-tag-value>` where `circleId` is derived from the `veil-ring` pubkeys. |\n| V-VL-13 | Each LSAG signature MUST verify against the ring from `veil-ring`. |\n| V-VL-14 | All key images across `veil-sig` tags within a single event MUST be unique. |\n| V-VL-15 | The `message` field in each signature MUST be valid deterministic JSON with alphabetically sorted keys at every nesting level. |\n| V-VL-16 | All metric values in the canonical message MUST be finite numbers. |\n| V-VL-17 | The `d` tag MUST be present. Events without a `d` tag MUST be rejected. |\n| V-VL-18 | The `circleId` and `subject` in the `message` field of each `veil-sig` MUST match the derived circle ID and `d` tag value respectively. |\n\n## Security Considerations\n\n### Unconditional anonymity\n\nLSAG ring signatures provide information-theoretic anonymity. Even with unlimited computational resources, an observer cannot determine which ring member produced a given signature. This is strictly stronger than computational anonymity (where a sufficiently powerful adversary could break the scheme).\n\n### Key image linkability\n\nKey images are deterministic per signer per election ID. This means:\n\n- Within a single event, duplicate key images reveal double-signing (this is the intended use).\n- Across events with the same election ID, the same signer produces the same key image. This is by design -- it prevents a member from contributing twice to the same assertion.\n- Across events with different election IDs (different subjects or circles), key images are unlinkable. Contributing to an assertion about subject A does not reveal whether the same member contributed to an assertion about subject B.\n\n### Circle membership visibility\n\nThe ring is embedded in the event. Anyone can see the full list of circle members. What is hidden is which members contributed. Applications where membership itself is sensitive SHOULD consider whether the `veil-ring` tag leaks unacceptable information.\n\n### Signature transplant prevention\n\nThe election ID (`veil:v1:<circleId>:<subject>`) binds each signature to a specific circle and subject. A valid signature for subject A cannot be placed into an event about subject B -- the election ID will not match, and verification fails. Implementations MUST reject signatures with missing `electionId` fields, as stripping the field is the simplest bypass attempt.\n\n### Maximum ring size\n\nThe maximum ring size of 1000 prevents denial-of-service via oversized rings. LSAG verification is linear in ring size -- each additional member adds one scalar multiplication. Implementations SHOULD set appropriate timeouts for verification of large rings.\n\n### Aggregator trust\n\nThe aggregator sees all contributions before publishing. A malicious aggregator could selectively omit contributions or refuse to publish. This is mitigated by:\n\n- Any circle member can act as aggregator\n- Contributors can verify the published event contains their signature\n- The aggregator cannot forge signatures (LSAG unforgeability)\n\nThe aggregator cannot learn which member produced which contribution (the contributions arrive with ring signatures, not identity-linked signatures).\n\n### Kind 10040 collision\n\nNIP-85 assigns kind 10040 for provider declarations, but NIP-03 (OpenTimestamps) already uses kind 10040. This is an upstream NIP-85 issue, not introduced by this NIP. Implementations SHOULD be aware of this collision when querying for kind 10040 events.\n\n### NIP-40 expiration and stale scores\n\nTrust assertions without expiration can be replayed indefinitely. A circle that collectively rates a subject highly today may hold a different view next year. Without expiration, the stale assertion remains on relays and appears current. Implementations SHOULD set expiration timestamps and clients SHOULD visually distinguish expired assertions.\n\n## Test Vectors\n\nThe following test vectors use these deterministic private keys:\n\n| Key | Private key (hex) | Public key (x-only hex) |\n|-----|-------------------|-------------------------|\n| A | `0101010101010101010101010101010101010101010101010101010101010101` | `1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f` |\n| B | `0202020202020202020202020202020202020202020202020202020202020202` | `4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766` |\n| C | `0303030303030303030303030303030303030303030303030303030303030303` | `531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337` |\n\n**Sorted ring:** A, B, C (already in lexicographic order).\n\n**Circle ID:** `beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2`\n\n**Subject (d-tag):** `1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f` (key A's pubkey)\n\n**Election ID:** `veil:v1:beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2:1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f`\n\n### Vector 1: Valid event structure (3 members, 2 contributors)\n\nMembers A and B each contribute metrics for subject A. Member C does not contribute. The aggregator verifies both LSAG signatures, confirms unique key images, computes the median of the contributed metrics, and publishes.\n\nNote: LSAG signatures are non-deterministic (they include random nonces), so the `veil-sig` values below are structural placeholders. Independent verification of Vector 1 should focus on the circle ID, election ID, and canonical message (see \"Canonical Message Vector\" below). The invalid vectors (2 and 3) demonstrate rejection behaviour using the structural format.\n\n```json\n{\n  \"kind\": 30382,\n  \"pubkey\": \"<aggregator-pubkey>\",\n  \"created_at\": 1711234567,\n  \"tags\": [\n    [\"d\", \"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\"],\n    [\"p\", \"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\"],\n    [\"rank\", \"83\"],\n    [\"followers\", \"1150\"],\n    [\"veil-ring\",\n      \"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\n      \"4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766\",\n      \"531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337\"\n    ],\n    [\"veil-threshold\", \"2\", \"3\"],\n    [\"veil-sig\", \"<valid-lsag-json-signature-from-member-A>\", \"<key-image-A>\"],\n    [\"veil-sig\", \"<valid-lsag-json-signature-from-member-B>\", \"<key-image-B>\"],\n    [\"expiration\", \"1742870400\"]\n  ],\n  \"content\": \"\",\n  \"id\": \"<32-byte-hex>\",\n  \"sig\": \"<64-byte-hex>\"\n}\n```\n\nA conforming implementation MUST:\n- Verify both LSAG signatures against the 3-member ring\n- Confirm both `electionId` values equal `veil:v1:beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2:1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f`\n- Confirm the two key images are distinct\n- Report `valid: true`, `circleSize: 3`, `threshold: 2`, `distinctSigners: 2`\n\n### Vector 2: Invalid event (transplanted signature)\n\nAn attacker takes a valid LSAG signature from an event about subject A and places it in an event about subject B. The `electionId` in the signature still references subject A:\n\n```json\n{\n  \"kind\": 30382,\n  \"pubkey\": \"<attacker-pubkey>\",\n  \"created_at\": 1711234567,\n  \"tags\": [\n    [\"d\", \"4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766\"],\n    [\"p\", \"4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766\"],\n    [\"rank\", \"83\"],\n    [\"veil-ring\",\n      \"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\n      \"4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766\",\n      \"531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337\"\n    ],\n    [\"veil-threshold\", \"1\", \"3\"],\n    [\"veil-sig\",\n      \"{\\\"c0\\\":\\\"...\\\",\\\"electionId\\\":\\\"veil:v1:beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2:1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\\\",\\\"message\\\":\\\"...\\\",\\\"responses\\\":[\\\"...\\\",\\\"...\\\",\\\"...\\\"]}\",\n      \"<key-image>\"\n    ]\n  ],\n  \"content\": \"\",\n  \"id\": \"<32-byte-hex>\",\n  \"sig\": \"<64-byte-hex>\"\n}\n```\n\nThe `d` tag references key B's pubkey, but the `electionId` in the signature references key A's pubkey. A conforming implementation MUST:\n- Compute the expected election ID as `veil:v1:<circleId>:4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766`\n- Detect the mismatch with the transplanted signature's `electionId`\n- Report `valid: false` with error `\"Signature at index 0 electionId mismatch\"`\n\n### Vector 3: Invalid event (duplicate key images)\n\nAn attacker duplicates a valid `veil-sig` tag to inflate the contributor count:\n\n```json\n{\n  \"kind\": 30382,\n  \"pubkey\": \"<attacker-pubkey>\",\n  \"created_at\": 1711234567,\n  \"tags\": [\n    [\"d\", \"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\"],\n    [\"p\", \"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\"],\n    [\"rank\", \"85\"],\n    [\"veil-ring\",\n      \"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\n      \"4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766\",\n      \"531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337\"\n    ],\n    [\"veil-threshold\", \"2\", \"3\"],\n    [\"veil-sig\", \"<valid-lsag-json-signature>\", \"<key-image-X>\"],\n    [\"veil-sig\", \"<same-valid-lsag-json-signature>\", \"<key-image-X>\"]\n  ],\n  \"content\": \"\",\n  \"id\": \"<32-byte-hex>\",\n  \"sig\": \"<64-byte-hex>\"\n}\n```\n\nBoth `veil-sig` tags have the same key image (`<key-image-X>`). A conforming implementation MUST:\n- Accept the first signature (valid LSAG, valid election ID, first occurrence of key image)\n- Reject the second signature due to duplicate key image\n- Count only 1 valid signature against a threshold of 2\n- Report `valid: false` with error `\"Duplicate key image at index 1\"`\n\n### Canonical Message Vector\n\nImplementations MUST produce this exact canonical message for the test circle and subject:\n\n| Parameter | Value |\n|-----------|-------|\n| Circle ID | `beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2` |\n| Subject | `1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f` |\n| Metrics | `{\"followers\": 1200, \"rank\": 85}` |\n\nExpected canonical message (no whitespace, sorted keys at all levels):\n\n```\n{\"circleId\":\"beadfbfe37bae31a7e2ba78c9d1565f2cb52903fdea6b98f424e0656fa7cd0d2\",\"metrics\":{\"followers\":1200,\"rank\":85},\"subject\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\"}\n```\n\n## Dependencies\n\n| NIP | Usage |\n|-----|-------|\n| [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) | Basic protocol flow, event serialisation |\n| [NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md) | Expiration timestamps for assertion TTL |\n| [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) | Trusted assertion event kinds (30382--30385, 10040) and metric tag conventions |\n\n## Reference Implementations\n\n| Implementation | Language | Purpose |\n|----------------|----------|---------|\n| [nostr-veil](https://github.com/forgesworn/nostr-veil) | TypeScript | Circle construction, LSAG contribution, aggregation, verification |\n| [@forgesworn/ring-sig](https://github.com/forgesworn/ring-sig) | TypeScript | SAG/LSAG ring signatures on secp256k1 |","sig":"b2277b49ad94ea1beb8ad4b182eca933cb7a4c75b969062c1a43aabb33722149fa59ba9f7046fa5aff945f83fe4cc0a33b205300d2185f3ea86402067fdb2b01"}