NIP-nip-59 Final
NIP-59: Gift Wraps
Hide sender metadata in encrypted messages. Privacy layer for Nostr DMs using throwaway keys.
| Type | Nostr Implementation Possibility |
| Number | nip-59 |
| Status | Final |
| Original | https://github.com/nostr-protocol/nips/blob/master/59.md |
NIP-59: Gift Wraps
Status: Final
NIP-59 defines a privacy layer for Nostr events. By “gift wrapping” messages, you hide who sent them and when—even from relays.
The Problem
Even with NIP-44 encryption, regular DMs leak metadata:
{
"kind": 4,
"pubkey": "alice-pubkey", // ← Sender visible!
"created_at": 1234567890, // ← Timestamp visible!
"tags": [["p", "bob-pubkey"]], // ← Recipient visible!
"content": "encrypted..."
}
Anyone (including relays) can see:
- Who sent the message
- Who received it
- When it was sent
- Communication patterns
Gift Wrap Structure
NIP-59 uses three layers:
┌────────────────────────────────────────────────────┐
│ Gift Wrap (kind 1059) │
│ - pubkey: random throwaway key │
│ - created_at: randomized timestamp │
│ - tags: [["p", recipient]] │
│ - content: encrypted seal │
│ ┌────────────────────────────────────────────────┐ │
│ │ Seal (kind 13) │ │
│ │ - pubkey: real sender │ │
│ │ - content: encrypted rumor │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Rumor (unsigned kind 14) │ │ │
│ │ │ - pubkey: real sender │ │ │
│ │ │ - content: actual message │ │ │
│ │ │ - tags: [["p", recipient]] │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
Event Kinds
| Kind | Name | Purpose |
|---|---|---|
| 13 | Seal | Encrypted rumor, signed by sender |
| 14 | Rumor | Unsigned event containing actual message |
| 1059 | Gift Wrap | Outer envelope signed by throwaway key |
Creating a Gift-Wrapped Message
Step 1: Create the Rumor (Unsigned)
def create_rumor(sender_pubkey: str, recipient_pubkey: str, message: str) -> dict:
"""Create an unsigned event (rumor)."""
return {
"pubkey": sender_pubkey,
"created_at": int(time.time()),
"kind": 14, # Private DM
"tags": [["p", recipient_pubkey]],
"content": message
}
# Note: NO id or sig fields!
Step 2: Create the Seal
def create_seal(rumor: dict, sender_privkey: str, recipient_pubkey: str) -> dict:
"""Encrypt rumor and create seal event."""
# Encrypt rumor to recipient using NIP-44
conv_key = get_conversation_key(sender_privkey, recipient_pubkey)
encrypted_rumor = nip44_encrypt(json.dumps(rumor), conv_key)
sender_pubkey = derive_pubkey(sender_privkey)
seal = {
"pubkey": sender_pubkey,
"created_at": randomize_timestamp(), # Hide timing
"kind": 13,
"tags": [],
"content": encrypted_rumor
}
return sign_event(seal, sender_privkey)
Step 3: Create the Gift Wrap
import secrets
def create_gift_wrap(seal: dict, recipient_pubkey: str) -> dict:
"""Wrap seal in throwaway key envelope."""
# Generate throwaway keypair
throwaway_privkey = secrets.token_bytes(32).hex()
throwaway_pubkey = derive_pubkey(throwaway_privkey)
# Encrypt seal to recipient using NIP-44
conv_key = get_conversation_key(throwaway_privkey, recipient_pubkey)
encrypted_seal = nip44_encrypt(json.dumps(seal), conv_key)
wrap = {
"pubkey": throwaway_pubkey, # Throwaway, not sender!
"created_at": randomize_timestamp(),
"kind": 1059,
"tags": [["p", recipient_pubkey]],
"content": encrypted_seal
}
return sign_event(wrap, throwaway_privkey)
def randomize_timestamp() -> int:
"""Add random noise to timestamp (±48 hours)."""
now = int(time.time())
noise = secrets.randbelow(172800) - 86400 # ±1 day
return now + noise
Complete Flow
def send_private_message(
sender_privkey: str,
recipient_pubkey: str,
message: str
) -> dict:
"""Send a fully private message using NIP-59."""
sender_pubkey = derive_pubkey(sender_privkey)
# 1. Create rumor (unsigned)
rumor = create_rumor(sender_pubkey, recipient_pubkey, message)
# 2. Create seal (encrypts rumor)
seal = create_seal(rumor, sender_privkey, recipient_pubkey)
# 3. Create gift wrap (hides sender)
wrap = create_gift_wrap(seal, recipient_pubkey)
return wrap
Receiving Gift-Wrapped Messages
Unwrap Process
def unwrap_gift(event: dict, recipient_privkey: str) -> tuple[dict, str]:
"""Unwrap a gift-wrapped message."""
if event["kind"] != 1059:
raise ValueError("Not a gift wrap")
recipient_pubkey = derive_pubkey(recipient_privkey)
# 1. Decrypt gift wrap to get seal
wrap_conv_key = get_conversation_key(recipient_privkey, event["pubkey"])
seal_json = nip44_decrypt(event["content"], wrap_conv_key)
seal = json.loads(seal_json)
# 2. Verify seal signature
if not verify_event(seal):
raise ValueError("Invalid seal signature")
sender_pubkey = seal["pubkey"]
# 3. Decrypt seal to get rumor
seal_conv_key = get_conversation_key(recipient_privkey, sender_pubkey)
rumor_json = nip44_decrypt(seal["content"], seal_conv_key)
rumor = json.loads(rumor_json)
# 4. Verify rumor author matches seal
if rumor["pubkey"] != sender_pubkey:
raise ValueError("Sender mismatch")
return rumor, sender_pubkey
Subscribe to Gift Wraps
async def receive_private_messages(my_pubkey: str):
"""Subscribe to incoming gift-wrapped messages."""
filter = {
"kinds": [1059],
"#p": [my_pubkey]
}
async for event in subscribe(RELAYS, filter):
try:
rumor, sender = unwrap_gift(event, MY_PRIVATE_KEY)
print(f"From {sender[:16]}...: {rumor['content']}")
except Exception as e:
print(f"Failed to unwrap: {e}")
JavaScript Implementation
import { nip44, nip59, generateSecretKey, getPublicKey } from 'nostr-tools';
async function sendPrivateMessage(senderSK, recipientPK, message) {
// Create rumor
const rumor = {
kind: 14,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', recipientPK]],
content: message
};
// Create seal
const seal = nip59.createSeal(rumor, senderSK, recipientPK);
// Create gift wrap
const wrap = nip59.createWrap(seal, recipientPK);
return wrap; // Publish this
}
async function unwrapMessage(event, recipientSK) {
// Unwrap returns the rumor and sender pubkey
const { rumor, sender } = await nip59.unwrap(event, recipientSK);
return { message: rumor.content, sender };
}
Privacy Properties
What’s Hidden
| Information | Regular DM | Gift Wrap |
|---|---|---|
| Message content | ✅ Hidden | ✅ Hidden |
| Sender identity | ❌ Visible | ✅ Hidden |
| Exact timestamp | ❌ Visible | ✅ Hidden (±48h) |
| Communication pattern | ❌ Visible | ✅ Hidden |
What’s Still Visible
- Recipient pubkey (needed for routing)
- Rough timing (within ±48h)
- Message size (approximate)
- That some message was sent
Security Considerations
Throwaway Keys
- Generate fresh keys for each message
- Never reuse throwaway keys
- Discard immediately after use
Timestamp Randomization
- Add significant noise (±48h recommended)
- Don’t use exact creation time
- Prevents timing correlation
Relay Trust
Gift wraps still route through relays. Relays see:
- Recipient pubkey
- Approximate timing
- Encrypted content
They don’t see sender identity.
Machine-Readable Summary
{
"nip": 59,
"title": "Gift Wraps",
"status": "final",
"defines": [
"rumor-format",
"seal-format",
"gift-wrap-format",
"privacy-layer"
],
"event_kinds": {
"rumor": 14,
"seal": 13,
"gift_wrap": 1059
},
"privacy_properties": [
"sender-hidden",
"timestamp-randomized",
"throwaway-keys"
],
"encryption": "nip-44",
"related": [
"/learn/nostr/dm",
"/learn/nostr/specs/nip-44",
"/learn/nostr/security"
]
}