Menu
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

KindNamePurpose
13SealEncrypted rumor, signed by sender
14RumorUnsigned event containing actual message
1059Gift WrapOuter 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

InformationRegular DMGift 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"
  ]
}