draft optional author:vitorpamplona author:kieran author:paulmillr author:staab
This NIP defines a protocol for encapsulating any nostr event. This makes it possible to obscure almost all metadata for a given event, perform collaborative signing, and more.
This NIP relies on NIP-44's versioned encryption algorithms.
This protocol uses three main concepts to protect the transmission of a target event: rumors, seals, and gift wraps.
- A
rumoris a regular nostr event, but is not signed. This means that if it is leaked, it cannot be verified. - A
rumoris serialized to JSON, encrypted, and placed in thecontentfield of aseal. Thesealis then signed by the author of the note. The only information publicly available on asealis who signed it, but not what was said. - A
sealis serialized to JSON, encrypted, and placed in thecontentfield of agift wrap.
This allows the isolation of concerns across layers:
- A rumor carries the content but is unsigned, which means if leaked it will be rejected by relays and clients, and can't be authenticated.
- A seal identifies the author without revealing the content or the recipient.
- A gift wrap can add metadata (recipient, tags, a different author) without revealing the true author.
A rumor is the same thing as an unsigned event. Any event kind can be made a rumor by removing the signature.
A seal is a kind:13 event that wraps a rumor with the sender's regular key. The seal is always encrypted to a receiver's pubkey but there is no p tag pointing to the receiver. There is no way to know who the rumor is for without the receiver's or the sender's private key. The only public information in this event is who is signing it.
{
"id": "<id>",
"pubkey": "<real author's pubkey>",
"content": "<encrypted rumor>",
"kind": 13,
"created_at": 1686840217,
"tags": [],
"sig": "<real author's pubkey signature>"
}Tags MUST must always be empty in a kind:13. The inner event MUST always be unsigned.
A gift wrap event is a kind:1059 event that wraps any other event. tags MUST include a single p tag containing the recipient's public key.
The goal is to hide the sender's information, the metadata, and the content of the original event from the public. The only public information is the receiver's public key.
{
"id": "<id>",
"pubkey": "<random, one-time-use pubkey>",
"content": "<encrypted kind 13>",
"kind": 1059,
"created_at": 1686840217,
"tags": [["p", "<Receiver>"]],
"sig": "<random, one-time-use pubkey signature>"
}Encryption is done following NIP-44 on the JSON-encoded event. Place the the encryption payload in the .content of the wrapper event (either a seal or a gift wrap).
Because ephemeral keys are often used to sign gift wraps, deletion of 1059 events MAY be requested by the recipient specified in the wrapper's p tag as well as by the author.
If a rumor is intended for more than one party, or if the author wants to retain an encrypted copy, a single rumor may be wrapped multiple times, with each wrapper addressed to a different recipient.
The canonical created_at time belongs to the rumor. All other timestamps SHOULD be tweaked to thwart time-analysis attacks. Note that some relays don't serve events dated in the future, so all timestamps SHOULD be in the past.
Relays may choose not to store gift wrapped events due to them not being publicly useful. Clients MAY choose to attach a certain amount of proof-of-work to the wrapper event per NIP-13 in a bid to demonstrate that the event is not spam or a denial-of-service attack.
To protect recipient metadata, relays SHOULD only serve kind 1059 events intended for the marked recipient. When possible, clients should only send wrapped events to read relays for the recipient that implement AUTH, and refuse to serve wrapped events to non-recipients.
To reduce the ability of attackers to infer information about messages based on size, clients MAY choose to pad a wrapped message (either a rumor or a seal, but preferably a seal) with randomly generated data using a padding tag.
Let's send a wrapped kind 1 message between two parties asking "Are you going to the party tonight?"
- Author private key:
0beebd062ec8735f4243466049d7747ef5d6594ee838de147f8aab842b15e273 - Recipient private key:
e108399bd8424357a710b606ae0c13166d853d327e47a6e5e038197346bdbf45 - Ephemeral wrapper key:
4f02eac59266002db5801adc5270700ca69d5b8f761d8732fab2fbf233c90cbd
Create a kind 1 event with the message, the receivers, and any other tags you want, signed by the author. Do not sign the event.
{
"kind": 1,
"created_at": 1691518405,
"pubkey": "611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9",
"content": "Are you going to the party tonight?",
"tags": [["p", "166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99"]],
"id": "90297600a3b91c41a8a044993efb7f0b568e5944020bfee9c50ad7dae0d8c729"
}Encrypt the JSON-encoded rumor with a conversation key derived using the author's private key and the recipient's public key. Place the result in the content field of a kind 13 seal event. Sign it with the author's key.
{
"content": "{\"ciphertext\":\"f5Lfq6a9laLW9PWuYatTTnrGOSHcGJUPnb5OQ/gEEVjtDnrOzDB9VFKdUkoImEMiO6Ddu+HYn0R3hUtqgTRppOjs4zNFTLNAP/3hkN3hAaKjvUzBVYVmU1xrBS749kUhCt8v/8l/tBeen9BYB4s1luAUqdS10h8jwM+NqE8aYrv9sEU9AaLuErbI1hwq0ZUEGl5unfxHpv+QjINbrPYs1x6bGUU7D416Lp0gV6fr/wD/JLkceb/quAE2UkgzVRS4gauWL8/PyaPz3332h+vH/tMWOQd2no4sDG3ftXPO8y0zXs5p/NvcPa2hIXsOsNOPzOfCFvBk7Rfv9JjwCN0K/LztDHZOzgryELAjcm+mDWPeYBw+PYWfwayFkDbbLCsQJ1KLYRBDS7BEMBOLdd1v1vF4J196tGaN\",\"nonce\":\"s6DBwU6gq9h14+9hRwnts1qZ6BdyPgHn\",\"v\":1}",
"created_at": 1691700420,
"id": "b9285c5405442937c3aeca7e91235a7a235573b46d37196c967a2fb73fd8ec21",
"kind": 13,
"pubkey": "611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9",
"sig": "596932eb3223cbfe0225e41c0f535a9d4c62cac891880749cfa16f89b3e5874fa14ab169d094615f9746ea39e6785e8933916c2c9ef09f36bf615458fb0b8681",
"tags": [],
}Encrypt the JSON-encoded kind 13 event with your ephemeral, single-use random key. Place the result in the content field of a kind 1059. Add a single p tag containing the recipient's public key. Sign the gift wrap using the random key generated in the previous step.
{
"content": "{\"ciphertext\":\"ta6EnLlYt2IJ+3DXZmxh55DofUbCan1lwtNB1BpU16zDqXw/j7YPSne6nx7HCDbZwAN83x7kc+GgTf89iuL6Q+i6LFcriEkHKB3EDYu/BH7O++tCXkCmi8ovN5xh5+LdD6FDYLRuO9hDzcKgDHZZNLU43ga+3m8Aij5pkDomIcjETGDkLQs0wlXF2wS05I8QYtpXBFDLae9oRS44CopMaMue8/TS/cz43CRrIlVzHKPtHaUIZ/smd3vHNV01DQpY2d/uHhhXRY5qd91b1ci9NtbzRfePl0qDkOUr9HB5Sm+XrDiWrQVWV9YeuZVSc7fR7WIG16DI40Kf1m42I7sbgoiSK12wV6G8ud+kgR+2MCRcmnSUgXQ+3wFg5Gmp7WlIclhmfklTzPcrAl20FKnlpJm3vZ/S4gSW9Viq4/Hz4dAEbh0aGt/QHn/+GyFgmwy2nVwo7GyX21j/Ji3EEkGokj6yxn+e/XeK7WcN5Jl1hFYHEP6IBfGnM+aQVzXnC24Fm5tTuyXLtcvOyahWno4wAChAntC4Q4KCRnMASEunFhtDfIH4+OTISeCZLraOZv5uWfzFl3mm3AMPCvWeGtay9QgkYyBbA9OUxJBTV8jo00TFCGO5cedtSRslv9c0zfBbEkpleEtVqYr4BswAYujwUtcF5iY8RxeB+LiouYga6H61XQ6Pec9vrGlL4jWF3ZwG2vwz9US/fN8E8y1VFNVUg+ZAc3Uh+Yq8ScPQYV9eBTW8hzRzYn5HKFgsh0OXzWpXtseIkMuyg4kWPwKxbQYJH9YuhT3eJbpdW38lVRwwRd3cB3udhDhllhs5Y105DgbowDe4J13Bg+ILRkjLwabv7PvcpptO4mwXg6SE4ZGA16pGBnAg2IzsecuyGHwnByufSxgJADUEt+y+6lz1Z2pPxmFY8FGMvDtWT2avdUM4ogRccDyqC0VduuxnKggdlqSuU855YWApC2zldd4/KzyL8WgkVUUHm6J5bclok2cRuRD8roV9f/Z4e7UvEIZEvhnr02srH64WeIN2LZPGX06oeZ0JCQ4IQBTQJ8n4t0/m49ThZ3ImCWaHJ0VTex7/7VN+rdBj8pAEKls71mm+XXtSmzzueA==\",\"nonce\":\"mFFHMKOsppp7b7KcgZ8eGqA8ojw3xSrU\",\"v\":1}",
"created_at": 1691635525,
"id": "48caeb1cfb163d0d7a2569bcbf97fbb2e104aa65106da362848bd072029b2a47",
"kind": 1059,
"pubkey": "18b1a75918f1f2c90c23da616bce317d36e348bcf5f7ba55e75949319210c87c",
"sig": "bf5d009011e0c83f0376ed3d896f1f0876594b1f76b5dd99430c099ef69abe2eeba995b1aa00dbde67144190fe1d340b5a1ef7208b85025a77f0aa70941ca3a8",
"tags": [["p", "166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99"]],
}Broadcast the kind 1059 event to the recipient's relays only. Delete all the other events.
import type {UnsignedEvent, Event} from "nostr-tools"
import {getPublicKey, getEventHash, getSignature} from "nostr-tools"
import {encrypt, decrypt, getSharedSecret} from "./nip44"
export const now = (drift = 0) =>
Math.round(Date.now() / 1000 - Math.random() * Math.pow(10, drift))
export const createRumor = event => {
if (event.sig) {
throw new Error("Rumor must not have a signature")
}
const rumor = {
created_at: now(),
content: "",
tags: [],
...event,
} as any
rumor.id = getEventHash(rumor)
return rumor as UnsignedEvent & {id: string}
}
export const createSeal = (authorPrivkey: string, recipientPubkey, rumor) => {
const key = getSharedSecret(authorPrivkey, recipientPubkey)
const content = encrypt(key, JSON.stringify(rumor))
const seal = {
content,
kind: 13,
created_at: now(5),
pubkey: getPublicKey(authorPrivkey),
tags: [],
} as any
seal.id = getEventHash(seal)
seal.sig = getSignature(seal, authorPrivkey)
return seal as Event
}
export const createWrap = (wrapperPrivkey, recipientPubkey, seal, tags = []) => {
const key = getSharedSecret(wrapperPrivkey, recipientPubkey)
const content = encrypt(key, JSON.stringify(seal))
const wrap = {
tags,
content,
kind: 1059,
created_at: now(5),
pubkey: getPublicKey(wrapperPrivkey),
} as any
wrap.id = getEventHash(wrap)
wrap.sig = getSignature(wrap, wrapperPrivkey)
return wrap as Event
}
export const wrap = (authorPrivkey, recipientPubkey, wrapperPrivkey, event, tags = []) => {
const rumor = createRumor(event)
const seal = createSeal(authorPrivkey, recipientPubkey, rumor)
const wrap = createWrap(wrapperPrivkey, recipientPubkey, seal, tags)
return wrap
}
export const unwrap = (recipientPrivkey, wrap) => {
const wrapKey = getSharedSecret(recipientPrivkey, wrap.pubkey)
const seal = JSON.parse(decrypt(wrapKey, wrap.content))
const sealKey = getSharedSecret(recipientPrivkey, seal.pubkey)
const rumor = JSON.parse(decrypt(sealKey, seal.content))
return {wrap, seal, rumor}
}
// Test case using the above example
const authorPrivateKey = `0beebd062ec8735f4243466049d7747ef5d6594ee838de147f8aab842b15e273`
const recipientPrivateKey = `e108399bd8424357a710b606ae0c13166d853d327e47a6e5e038197346bdbf45`
const ephemeralPrivateKey = `4f02eac59266002db5801adc5270700ca69d5b8f761d8732fab2fbf233c90cbd`
const rumor = {
"kind": 1,
"created_at": 1691518405,
"pubkey": getPublicKey(authorPrivateKey),
"content": "Are you going to the party tonight?",
"tags": [["p", getPublicKey(recipientPrivateKey)]],
}
rumor.id = getEventHash(rumor)
const seal = createSeal(
authorPrivateKey,
getPublicKey(recipientPrivateKey),
rumor
)
const wrap = createWrap(
ephemeralPrivateKey,
getPublicKey(recipientPrivateKey),
seal,
[["p", getPublicKey(recipientPrivateKey)]]
)
const unwrappedSeal = JSON.parse(decrypt(
getSharedSecret(recipientPrivateKey, wrap.pubkey),
JSON.parse(wrap.content)
))
const unwrappedRumor = JSON.parse(decrypt(
getSharedSecret(recipientPrivateKey, unwrappedSeal.pubkey),
JSON.parse(unwrappedSeal.content)
))