NIP-XX: Encrypted File Sync

Specifies: kind 30800 kind 30801 kind 30802

NIP-XX: Encrypted File Sync

draft optional

This NIP defines a protocol for syncing encrypted files (such as notes, documents, and their attachments) across devices using Nostr relays. It enables local-first applications to provide end-to-end encrypted, decentralized sync without requiring centralized servers.

Motivation

Users increasingly want to own their data while still having it accessible across devices. Existing solutions either require trusting a central server or lack encryption. This NIP provides:

  • End-to-end encryption: Relays store encrypted blobs they cannot read
  • Local-first: Works offline, syncs when connected
  • Decentralized: No single point of failure, user chooses relays
  • Interoperable: Any client implementing this NIP can sync the same data

Overview

The protocol uses three event kinds:

Kind Description
30800 Encrypted file content (self-encrypted)
30801 Encrypted vault/collection index (self-encrypted)
30802 Shared document (encrypted to recipient)

All sensitive data (file contents, paths, names, structure) is encrypted using NIP-44 encryption. Kinds 30800 and 30801 use self-encryption (to the user’s own public key), while kind 30802 encrypts to a specific recipient’s public key for document sharing.

Event Kinds

Kind 30800: Encrypted File

A parameterized replaceable event containing an encrypted file.

{
  "kind": 30800,
  "pubkey": "<user-pubkey>",
  "created_at": <unix-timestamp>,
  "tags": [
    ["d", "<random-uuid>"],
    ["encrypted", "nip44"]
  ],
  "content": "<NIP-44 encrypted payload>",
  "sig": "<signature>"
}

Tags

  • d (REQUIRED): A random UUID (v4) that uniquely identifies this file. Using a random identifier prevents correlation attacks that could reveal file paths or structure.
  • encrypted (REQUIRED): The encryption scheme used. Currently only nip44 is defined.

Encrypted Content Structure

After decrypting the content field using NIP-44, the plaintext is a JSON object:

{
  "path": "<relative-file-path>",
  "content": "<file-content>",
  "checksum": "<sha256-hex>",
  "version": <integer>,
  "modified": <unix-timestamp>,
  "previousEventId": "<event-id-or-null>",
  "contentType": "<mime-type>",
  "metadata": {
    <application-specific-metadata>
  },
  "attachments": [
    {
      "name": "<filename>",
      "blossom": "<sha256-hash>",
      "key": "<encryption-key-hex>",
      "size": <bytes>,
      "contentType": "<mime-type>"
    }
  ]
}
Fields
Field Type Required Description
path string Yes Relative path within the vault (e.g., /folder/note.md)
content string Yes The file content (typically UTF-8 text)
checksum string Yes SHA-256 hash of content (hex-encoded) for conflict detection
version integer Yes Monotonically increasing version number, starting at 1
modified integer Yes Unix timestamp of last modification
previousEventId string No Event ID of the previous version (for version history)
contentType string No MIME type (default: text/markdown)
metadata object No Application-specific metadata (e.g., YAML frontmatter)
attachments array No Referenced binary attachments stored on Blossom servers
Attachments

Binary files (images, PDFs, etc.) are stored separately on Blossom-compatible servers. Each attachment entry contains:

  • name: Original filename
  • blossom: SHA-256 hash of the encrypted blob (used as Blossom identifier)
  • key: Symmetric encryption key (hex) used to encrypt the blob
  • size: Size in bytes of the original (unencrypted) file
  • contentType: MIME type of the original file

Clients MUST encrypt attachments client-side before uploading to Blossom servers.

Kind 30801: Encrypted Vault Index

A parameterized replaceable event containing the index of a vault (collection of files).

{
  "kind": 30801,
  "pubkey": "<user-pubkey>",
  "created_at": <unix-timestamp>,
  "tags": [
    ["d", "<random-uuid>"],
    ["encrypted", "nip44"]
  ],
  "content": "<NIP-44 encrypted payload>",
  "sig": "<signature>"
}

Tags

  • d (REQUIRED): A random UUID (v4) that uniquely identifies this vault.
  • encrypted (REQUIRED): The encryption scheme used.

Encrypted Content Structure

{
  "name": "<vault-name>",
  "description": "<optional-description>",
  "created": <unix-timestamp>,
  "files": [
    {
      "eventId": "<kind-30800-event-id>",
      "d": "<d-tag-of-file-event>",
      "path": "<relative-path>",
      "checksum": "<sha256-hex>",
      "version": <integer>,
      "modified": <unix-timestamp>
    }
  ],
  "deleted": [
    {
      "path": "<relative-path>",
      "deletedAt": <unix-timestamp>,
      "lastEventId": "<event-id>"
    }
  ],
  "settings": {
    <vault-specific-settings>
  }
}
Fields
Field Type Required Description
name string Yes Human-readable vault name
description string No Optional vault description
created integer Yes Unix timestamp of vault creation
files array Yes Array of file entries in this vault
deleted array No Array of deleted file entries (tombstones)
settings object No Vault-specific settings
File Entry Fields
Field Type Description
eventId string Event ID of the kind 30800 event
d string The d tag value of the file event
path string Relative path for quick lookup
checksum string SHA-256 of content for conflict detection
version integer Current version number
modified integer Last modification timestamp
Deleted Entry Fields
Field Type Description
path string Path of the deleted file
deletedAt integer Unix timestamp of deletion
lastEventId string Event ID of the last version before deletion

Kind 30802: Shared Document

A parameterized replaceable event containing a document shared with another user. Unlike kinds 30800/30801, this event encrypts content to the recipient’s public key, allowing secure document sharing between users.

{
  "kind": 30802,
  "pubkey": "<sender-pubkey>",
  "created_at": <unix-timestamp>,
  "tags": [
    ["d", "<random-uuid>"],
    ["p", "<recipient-pubkey>"],
    ["title", "<document-title>"],
    ["encrypted", "nip44"]
  ],
  "content": "<NIP-44 encrypted payload>",
  "sig": "<signature>"
}

Tags

  • d (REQUIRED): A random UUID (v4) that uniquely identifies this shared document.
  • p (REQUIRED): The recipient’s public key (hex). This allows the recipient to query for documents shared with them.
  • title (OPTIONAL): Cleartext document title for notification purposes. May be omitted for privacy.
  • encrypted (REQUIRED): The encryption scheme used. Currently only nip44 is defined.

Encrypted Content Structure

After decrypting the content field using NIP-44 with the shared conversation key between sender and recipient:

{
  "title": "<document-title>",
  "content": "<markdown-content>",
  "path": "<original-file-path>",
  "sharedAt": <unix-timestamp>,
  "sharedBy": {
    "pubkey": "<sender-pubkey>",
    "name": "<sender-display-name>",
    "picture": "<sender-avatar-url>"
  },
  "metadata": {
    <application-specific-metadata>
  }
}
Fields
Field Type Required Description
title string Yes Document title (typically filename without extension)
content string Yes The document content (typically Markdown)
path string No Original file path (for reference, not used on import)
sharedAt integer Yes Unix timestamp when the document was shared
sharedBy object Yes Information about the sender
sharedBy.pubkey string Yes Sender’s public key (hex)
sharedBy.name string No Sender’s display name
sharedBy.picture string No Sender’s avatar URL
metadata object No Application-specific metadata

Querying Shared Documents

Documents shared with me:

REQ: {"kinds": [30802], "#p": ["<my-pubkey>"]}

Documents I’ve shared:

REQ: {"kinds": [30802], "authors": ["<my-pubkey>"]}

Revoking a Share

To revoke a shared document, publish a new version of the same event (same d tag) with empty content or a deletion marker:

{
  "kind": 30802,
  "tags": [
    ["d", "<same-uuid>"],
    ["p", "<recipient-pubkey>"],
    ["deleted", "true"]
  ],
  "content": ""
}

Alternatively, use NIP-09 deletion events to request relays remove the original event.

Sharing Flow

  1. Share: Sender encrypts document content to recipient’s pubkey using NIP-44
  2. Publish: Sender publishes kind 30802 event
  3. Notify (optional): Sender sends NIP-17 DM to notify recipient
  4. Discover: Recipient queries for kind 30802 events with their pubkey in p tag
  5. Decrypt: Recipient decrypts content using shared conversation key
  6. Import: Recipient optionally imports document to their vault

One-Time Snapshot

Kind 30802 represents a snapshot of the document at the time of sharing. It is not a live sync:

  • Updates to the original file do NOT update the shared document
  • Recipient receives a copy they can import and modify independently
  • To share an updated version, create a new kind 30802 event (new d tag)

This design keeps the protocol simple and avoids complex permission management.

Encryption

Self-Encryption

All content is encrypted using NIP-44 to the user’s own public key:

conversation_key = nip44_get_conversation_key(private_key, public_key)
encrypted = nip44_encrypt(plaintext, conversation_key)
decrypted = nip44_decrypt(encrypted, conversation_key)

This ensures:

  • Only the key holder can decrypt the content
  • Relays cannot read file contents, names, or structure
  • No need to manage recipient keys

What Remains Visible

Even with encryption, the following metadata is visible to relays:

  • User’s public key (author)
  • Event timestamps
  • Event kinds (30800, 30801)
  • Number of events
  • Event sizes

This is unavoidable given Nostr’s architecture but reveals minimal information about the actual content.

Sync Protocol

Initial Sync (New Device)

  1. Fetch vault indices: Query all kind 30801 events for the user’s pubkey
  2. Decrypt indices: Decrypt each vault index to discover available vaults
  3. User selection: Present vaults to user, let them choose which to sync
  4. Fetch files: For selected vault(s), fetch kind 30800 events by event ID
  5. Decrypt and write: Decrypt each file and write to local storage
REQ: {"kinds": [30801], "authors": ["<pubkey>"]}
→ Receive vault index events
→ Decrypt each, present to user

REQ: {"ids": ["<event-id-1>", "<event-id-2>", ...]}
→ Receive file events
→ Decrypt each, write to disk

Ongoing Sync

  1. Subscribe: Open subscription for kinds 30800, 30801 from user’s pubkey
  2. Process updates: On new events, decrypt and apply changes
  3. Publish changes: On local edits, encrypt and publish
REQ: {"kinds": [30800, 30801], "authors": ["<pubkey>"], "since": <last-sync>}

Publishing Changes

When a file is created or modified:

  1. Encrypt the file content using NIP-44
  2. Publish kind 30800 event (new d tag for new files, same d tag for updates)
  3. Update the vault index (kind 30801) with new file entry
  4. Publish updated vault index

When a file is deleted:

  1. Add entry to deleted array in vault index
  2. Remove from files array
  3. Publish updated vault index
  4. Optionally publish empty kind 30800 to “delete” the event (relay-dependent)

Conflict Resolution

Conflicts occur when both local and remote have changes to the same file.

Detection:

local.checksum ≠ remote.checksum AND
local.version ≠ remote.version - 1

Resolution strategies:

  1. Last-write-wins: Use event with latest created_at
  2. Manual merge: Present conflict UI to user
  3. Keep both: Rename conflicting file (e.g., note (conflict).md)

Clients SHOULD implement at least one strategy and MAY let users configure their preference.

Version History

The previousEventId field enables version history:

v3 (current) → v2 → v1 → null
     │           │      │
  event-c    event-b  event-a

Clients MAY implement version history viewing and restoration by following this chain.

Relay Considerations

  • Support for parameterized replaceable events (NIP-33)
  • Reasonable event size limits (files can be large)
  • Event retention (don’t delete old events too aggressively)

Multi-Relay Strategy

Clients SHOULD publish to multiple relays for redundancy:

  1. Publish to all configured relays
  2. Consider an event “confirmed” when received by at least 2 relays
  3. On fetch, query multiple relays and deduplicate by event ID

Blossom Integration

Binary attachments use Blossom servers:

Upload Flow

  1. Generate random symmetric key
  2. Encrypt file with symmetric key (e.g., AES-256-GCM)
  3. Compute SHA-256 of encrypted blob
  4. Upload to Blossom server(s)
  5. Store hash and key in file’s attachments array

Download Flow

  1. Read blossom hash and key from attachment entry
  2. Fetch encrypted blob from Blossom server
  3. Decrypt using stored key
  4. Verify decrypted content

Blossom Authentication

Use NIP-98 HTTP Auth for authenticated uploads:

Authorization: Nostr <base64-encoded-kind-27235-event>

Privacy Considerations

Metadata Leakage

This NIP minimizes metadata leakage by:

  • Using random d tags (no path correlation)
  • Encrypting vault names and file paths
  • Encrypting file structure and relationships
  • Not using cleartext tags for filtering

Remaining Risks

  • Timing analysis: Event timestamps may reveal activity patterns
  • Size analysis: File sizes might be fingerprinted
  • Relay logging: Relays see IP addresses and request patterns

Users requiring stronger privacy should consider:

  • Using Tor for relay connections
  • Padding files to uniform sizes
  • Adding random delays to sync operations

Implementation Notes

  • nostr-tools: Event creation, signing, NIP-44 encryption
  • @noble/hashes: SHA-256, cryptographic primitives
  • @noble/ciphers: AES-GCM for attachment encryption

Event ID Stability

When updating a file, reuse the same d tag to ensure the event is replaceable. Generate a new d tag only for new files.

Checksum Calculation

import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';

function calculateChecksum(content: string): string {
  const bytes = new TextEncoder().encode(content);
  return bytesToHex(sha256(bytes));
}

Example: Creating a File Event

import { finalizeEvent, nip44 } from 'nostr-tools';
import { v4 as uuidv4 } from 'uuid';

async function createFileEvent(
  privateKey: Uint8Array,
  publicKey: string,
  path: string,
  content: string,
  version: number,
  previousEventId?: string
) {
  const conversationKey = nip44.v2.utils.getConversationKey(privateKey, publicKey);

  const payload = JSON.stringify({
    path,
    content,
    checksum: calculateChecksum(content),
    version,
    modified: Math.floor(Date.now() / 1000),
    previousEventId: previousEventId || null,
    contentType: 'text/markdown'
  });

  const encrypted = nip44.v2.encrypt(payload, conversationKey);

  const event = finalizeEvent({
    kind: 30800,
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ['d', uuidv4()],
      ['encrypted', 'nip44']
    ],
    content: encrypted
  }, privateKey);

  return event;
}

Example: Sharing a Document

import { finalizeEvent, nip44 } from 'nostr-tools';
import { v4 as uuidv4 } from 'uuid';

async function shareDocument(
  senderPrivateKey: Uint8Array,
  senderPubkey: string,
  senderName: string,
  recipientPubkey: string,
  title: string,
  content: string,
  originalPath?: string
) {
  // Get conversation key between sender and recipient
  const conversationKey = nip44.v2.utils.getConversationKey(
    senderPrivateKey,
    recipientPubkey
  );

  const payload = JSON.stringify({
    title,
    content,
    path: originalPath || null,
    sharedAt: Math.floor(Date.now() / 1000),
    sharedBy: {
      pubkey: senderPubkey,
      name: senderName,
    }
  });

  const encrypted = nip44.v2.encrypt(payload, conversationKey);

  const event = finalizeEvent({
    kind: 30802,
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ['d', uuidv4()],
      ['p', recipientPubkey],
      ['title', title],
      ['encrypted', 'nip44']
    ],
    content: encrypted
  }, senderPrivateKey);

  return event;
}

Test Vectors

File Event

Private key (hex): 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

Input:

{
  "path": "/notes/hello.md",
  "content": "# Hello World\n\nThis is a test note.",
  "version": 1
}

Expected checksum: a3c25e6e5d1a8b3f... (SHA-256 of content)

Vault Index Event

Input:

{
  "name": "My Notes",
  "files": [
    {
      "eventId": "abc123...",
      "d": "550e8400-e29b-41d4-a716-446655440000",
      "path": "/notes/hello.md",
      "checksum": "a3c25e6e5d1a8b3f...",
      "version": 1,
      "modified": 1705234567
    }
  ]
}

Shared Document Event

Sender private key (hex): 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef Recipient public key (hex): fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210

Input:

{
  "title": "Meeting Notes",
  "content": "# Meeting Notes\n\nDiscussed project timeline...",
  "sharedAt": 1705234567,
  "sharedBy": {
    "pubkey": "abc123...",
    "name": "Alice"
  }
}

Expected tags:

[
  ["d", "<random-uuid>"],
  ["p", "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"],
  ["title", "Meeting Notes"],
  ["encrypted", "nip44"]
]

References


Looking for comments…

Searching Nostr relays. This may take a moment the first time this article is opened.