{"id":"29f0dd8d679d5ea7e9a061fe012b7e232795095b1de5c7ec87041183cf6d9444","pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","created_at":1781537714,"kind":30817,"tags":[["d","nip-xx-encrypted-file-sync"],["title","NIP-EF: Encrypted File Sync"],["alt","Nostr Implementation Possibility: NIP-EF: Encrypted File Sync"],["k","30800","Encrypted File"],["k","30801","Encrypted Vault Index"],["k","30802","Shared Document"],["client","NostrHub"]],"content":"# NIP-XX: Encrypted File Sync\n\n`draft` `optional`\n\nThis 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.\n\n## Motivation\n\nUsers 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:\n\n- **End-to-end encryption**: Relays store encrypted blobs they cannot read\n- **Local-first**: Works offline, syncs when connected\n- **Decentralized**: No single point of failure, user chooses relays\n- **Interoperable**: Any client implementing this NIP can sync the same data\n\n## Overview\n\nThe protocol uses three event kinds:\n\n| Kind | Description |\n|------|-------------|\n| `30800` | Encrypted file content (self-encrypted) |\n| `30801` | Encrypted vault/collection index (self-encrypted) |\n| `30802` | Shared document (encrypted to recipient) |\n\nAll 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.\n\n## Event Kinds\n\n### Kind 30800: Encrypted File\n\nA parameterized replaceable event containing an encrypted file.\n\n```json\n{\n  \"kind\": 30800,\n  \"pubkey\": \"<user-pubkey>\",\n  \"created_at\": <unix-timestamp>,\n  \"tags\": [\n    [\"d\", \"<random-uuid>\"],\n    [\"encrypted\", \"nip44\"]\n  ],\n  \"content\": \"<NIP-44 encrypted payload>\",\n  \"sig\": \"<signature>\"\n}\n```\n\n#### Tags\n\n- `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.\n- `encrypted` (REQUIRED): The encryption scheme used. Currently only `nip44` is defined.\n\n#### Encrypted Content Structure\n\nAfter decrypting the `content` field using NIP-44, the plaintext is a JSON object:\n\n```json\n{\n  \"path\": \"<relative-file-path>\",\n  \"content\": \"<file-content>\",\n  \"checksum\": \"<sha256-hex>\",\n  \"version\": <integer>,\n  \"modified\": <unix-timestamp>,\n  \"previousEventId\": \"<event-id-or-null>\",\n  \"contentType\": \"<mime-type>\",\n  \"metadata\": {\n    <application-specific-metadata>\n  },\n  \"attachments\": [\n    {\n      \"name\": \"<filename>\",\n      \"blossom\": \"<sha256-hash>\",\n      \"key\": \"<encryption-key-hex>\",\n      \"size\": <bytes>,\n      \"contentType\": \"<mime-type>\"\n    }\n  ]\n}\n```\n\n##### Fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `path` | string | Yes | Relative path within the vault (e.g., `/folder/note.md`) |\n| `content` | string | Yes | The file content (typically UTF-8 text) |\n| `checksum` | string | Yes | SHA-256 hash of `content` (hex-encoded) for conflict detection |\n| `version` | integer | Yes | Monotonically increasing version number, starting at 1 |\n| `modified` | integer | Yes | Unix timestamp of last modification |\n| `previousEventId` | string | No | Event ID of the previous version (for version history) |\n| `contentType` | string | No | MIME type (default: `text/markdown`) |\n| `metadata` | object | No | Application-specific metadata (e.g., YAML frontmatter) |\n| `attachments` | array | No | Referenced binary attachments stored on Blossom servers |\n\n##### Attachments\n\nBinary files (images, PDFs, etc.) are stored separately on Blossom-compatible servers. Each attachment entry contains:\n\n- `name`: Original filename\n- `blossom`: SHA-256 hash of the **encrypted** blob (used as Blossom identifier)\n- `key`: Symmetric encryption key (hex) used to encrypt the blob\n- `size`: Size in bytes of the original (unencrypted) file\n- `contentType`: MIME type of the original file\n\nClients MUST encrypt attachments client-side before uploading to Blossom servers.\n\n### Kind 30801: Encrypted Vault Index\n\nA parameterized replaceable event containing the index of a vault (collection of files).\n\n```json\n{\n  \"kind\": 30801,\n  \"pubkey\": \"<user-pubkey>\",\n  \"created_at\": <unix-timestamp>,\n  \"tags\": [\n    [\"d\", \"<random-uuid>\"],\n    [\"encrypted\", \"nip44\"]\n  ],\n  \"content\": \"<NIP-44 encrypted payload>\",\n  \"sig\": \"<signature>\"\n}\n```\n\n#### Tags\n\n- `d` (REQUIRED): A random UUID (v4) that uniquely identifies this vault.\n- `encrypted` (REQUIRED): The encryption scheme used.\n\n#### Encrypted Content Structure\n\n```json\n{\n  \"name\": \"<vault-name>\",\n  \"description\": \"<optional-description>\",\n  \"created\": <unix-timestamp>,\n  \"files\": [\n    {\n      \"eventId\": \"<kind-30800-event-id>\",\n      \"d\": \"<d-tag-of-file-event>\",\n      \"path\": \"<relative-path>\",\n      \"checksum\": \"<sha256-hex>\",\n      \"version\": <integer>,\n      \"modified\": <unix-timestamp>\n    }\n  ],\n  \"deleted\": [\n    {\n      \"path\": \"<relative-path>\",\n      \"deletedAt\": <unix-timestamp>,\n      \"lastEventId\": \"<event-id>\"\n    }\n  ],\n  \"settings\": {\n    <vault-specific-settings>\n  }\n}\n```\n\n##### Fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `name` | string | Yes | Human-readable vault name |\n| `description` | string | No | Optional vault description |\n| `created` | integer | Yes | Unix timestamp of vault creation |\n| `files` | array | Yes | Array of file entries in this vault |\n| `deleted` | array | No | Array of deleted file entries (tombstones) |\n| `settings` | object | No | Vault-specific settings |\n\n##### File Entry Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `eventId` | string | Event ID of the kind 30800 event |\n| `d` | string | The `d` tag value of the file event |\n| `path` | string | Relative path for quick lookup |\n| `checksum` | string | SHA-256 of content for conflict detection |\n| `version` | integer | Current version number |\n| `modified` | integer | Last modification timestamp |\n\n##### Deleted Entry Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `path` | string | Path of the deleted file |\n| `deletedAt` | integer | Unix timestamp of deletion |\n| `lastEventId` | string | Event ID of the last version before deletion |\n\n### Kind 30802: Shared Document\n\nA 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.\n\n```json\n{\n  \"kind\": 30802,\n  \"pubkey\": \"<sender-pubkey>\",\n  \"created_at\": <unix-timestamp>,\n  \"tags\": [\n    [\"d\", \"<random-uuid>\"],\n    [\"p\", \"<recipient-pubkey>\"],\n    [\"title\", \"<document-title>\"],\n    [\"encrypted\", \"nip44\"]\n  ],\n  \"content\": \"<NIP-44 encrypted payload>\",\n  \"sig\": \"<signature>\"\n}\n```\n\n#### Tags\n\n- `d` (REQUIRED): A random UUID (v4) that uniquely identifies this shared document.\n- `p` (REQUIRED): The recipient's public key (hex). This allows the recipient to query for documents shared with them.\n- `title` (OPTIONAL): Cleartext document title for notification purposes. May be omitted for privacy.\n- `encrypted` (REQUIRED): The encryption scheme used. Currently only `nip44` is defined.\n\n#### Encrypted Content Structure\n\nAfter decrypting the `content` field using NIP-44 with the shared conversation key between sender and recipient:\n\n```json\n{\n  \"title\": \"<document-title>\",\n  \"content\": \"<markdown-content>\",\n  \"path\": \"<original-file-path>\",\n  \"sharedAt\": <unix-timestamp>,\n  \"sharedBy\": {\n    \"pubkey\": \"<sender-pubkey>\",\n    \"name\": \"<sender-display-name>\",\n    \"picture\": \"<sender-avatar-url>\"\n  },\n  \"metadata\": {\n    <application-specific-metadata>\n  }\n}\n```\n\n##### Fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `title` | string | Yes | Document title (typically filename without extension) |\n| `content` | string | Yes | The document content (typically Markdown) |\n| `path` | string | No | Original file path (for reference, not used on import) |\n| `sharedAt` | integer | Yes | Unix timestamp when the document was shared |\n| `sharedBy` | object | Yes | Information about the sender |\n| `sharedBy.pubkey` | string | Yes | Sender's public key (hex) |\n| `sharedBy.name` | string | No | Sender's display name |\n| `sharedBy.picture` | string | No | Sender's avatar URL |\n| `metadata` | object | No | Application-specific metadata |\n\n#### Querying Shared Documents\n\n**Documents shared with me:**\n```\nREQ: {\"kinds\": [30802], \"#p\": [\"<my-pubkey>\"]}\n```\n\n**Documents I've shared:**\n```\nREQ: {\"kinds\": [30802], \"authors\": [\"<my-pubkey>\"]}\n```\n\n#### Revoking a Share\n\nTo revoke a shared document, publish a new version of the same event (same `d` tag) with empty content or a deletion marker:\n\n```json\n{\n  \"kind\": 30802,\n  \"tags\": [\n    [\"d\", \"<same-uuid>\"],\n    [\"p\", \"<recipient-pubkey>\"],\n    [\"deleted\", \"true\"]\n  ],\n  \"content\": \"\"\n}\n```\n\nAlternatively, use NIP-09 deletion events to request relays remove the original event.\n\n#### Sharing Flow\n\n1. **Share**: Sender encrypts document content to recipient's pubkey using NIP-44\n2. **Publish**: Sender publishes kind 30802 event\n3. **Notify** (optional): Sender sends NIP-17 DM to notify recipient\n4. **Discover**: Recipient queries for kind 30802 events with their pubkey in `p` tag\n5. **Decrypt**: Recipient decrypts content using shared conversation key\n6. **Import**: Recipient optionally imports document to their vault\n\n#### One-Time Snapshot\n\nKind 30802 represents a **snapshot** of the document at the time of sharing. It is not a live sync:\n\n- Updates to the original file do NOT update the shared document\n- Recipient receives a copy they can import and modify independently\n- To share an updated version, create a new kind 30802 event (new `d` tag)\n\nThis design keeps the protocol simple and avoids complex permission management.\n\n## Encryption\n\n### Self-Encryption\n\nAll content is encrypted using NIP-44 to the user's own public key:\n\n```\nconversation_key = nip44_get_conversation_key(private_key, public_key)\nencrypted = nip44_encrypt(plaintext, conversation_key)\ndecrypted = nip44_decrypt(encrypted, conversation_key)\n```\n\nThis ensures:\n- Only the key holder can decrypt the content\n- Relays cannot read file contents, names, or structure\n- No need to manage recipient keys\n\n### What Remains Visible\n\nEven with encryption, the following metadata is visible to relays:\n\n- User's public key (author)\n- Event timestamps\n- Event kinds (30800, 30801)\n- Number of events\n- Event sizes\n\nThis is unavoidable given Nostr's architecture but reveals minimal information about the actual content.\n\n## Sync Protocol\n\n### Initial Sync (New Device)\n\n1. **Fetch vault indices**: Query all kind 30801 events for the user's pubkey\n2. **Decrypt indices**: Decrypt each vault index to discover available vaults\n3. **User selection**: Present vaults to user, let them choose which to sync\n4. **Fetch files**: For selected vault(s), fetch kind 30800 events by event ID\n5. **Decrypt and write**: Decrypt each file and write to local storage\n\n```\nREQ: {\"kinds\": [30801], \"authors\": [\"<pubkey>\"]}\n→ Receive vault index events\n→ Decrypt each, present to user\n\nREQ: {\"ids\": [\"<event-id-1>\", \"<event-id-2>\", ...]}\n→ Receive file events\n→ Decrypt each, write to disk\n```\n\n### Ongoing Sync\n\n1. **Subscribe**: Open subscription for kinds 30800, 30801 from user's pubkey\n2. **Process updates**: On new events, decrypt and apply changes\n3. **Publish changes**: On local edits, encrypt and publish\n\n```\nREQ: {\"kinds\": [30800, 30801], \"authors\": [\"<pubkey>\"], \"since\": <last-sync>}\n```\n\n### Publishing Changes\n\nWhen a file is created or modified:\n\n1. Encrypt the file content using NIP-44\n2. Publish kind 30800 event (new `d` tag for new files, same `d` tag for updates)\n3. Update the vault index (kind 30801) with new file entry\n4. Publish updated vault index\n\nWhen a file is deleted:\n\n1. Add entry to `deleted` array in vault index\n2. Remove from `files` array\n3. Publish updated vault index\n4. Optionally publish empty kind 30800 to \"delete\" the event (relay-dependent)\n\n### Conflict Resolution\n\nConflicts occur when both local and remote have changes to the same file.\n\n**Detection:**\n```\nlocal.checksum ≠ remote.checksum AND\nlocal.version ≠ remote.version - 1\n```\n\n**Resolution strategies:**\n\n1. **Last-write-wins**: Use event with latest `created_at`\n2. **Manual merge**: Present conflict UI to user\n3. **Keep both**: Rename conflicting file (e.g., `note (conflict).md`)\n\nClients SHOULD implement at least one strategy and MAY let users configure their preference.\n\n### Version History\n\nThe `previousEventId` field enables version history:\n\n```\nv3 (current) → v2 → v1 → null\n     │           │      │\n  event-c    event-b  event-a\n```\n\nClients MAY implement version history viewing and restoration by following this chain.\n\n## Relay Considerations\n\n### Recommended Relay Features\n\n- Support for parameterized replaceable events (NIP-33)\n- Reasonable event size limits (files can be large)\n- Event retention (don't delete old events too aggressively)\n\n### Multi-Relay Strategy\n\nClients SHOULD publish to multiple relays for redundancy:\n\n1. Publish to all configured relays\n2. Consider an event \"confirmed\" when received by at least 2 relays\n3. On fetch, query multiple relays and deduplicate by event ID\n\n## Blossom Integration\n\nBinary attachments use [Blossom](https://github.com/hzrd149/blossom) servers:\n\n### Upload Flow\n\n1. Generate random symmetric key\n2. Encrypt file with symmetric key (e.g., AES-256-GCM)\n3. Compute SHA-256 of encrypted blob\n4. Upload to Blossom server(s)\n5. Store hash and key in file's `attachments` array\n\n### Download Flow\n\n1. Read `blossom` hash and `key` from attachment entry\n2. Fetch encrypted blob from Blossom server\n3. Decrypt using stored key\n4. Verify decrypted content\n\n### Blossom Authentication\n\nUse NIP-98 HTTP Auth for authenticated uploads:\n\n```\nAuthorization: Nostr <base64-encoded-kind-27235-event>\n```\n\n## Privacy Considerations\n\n### Metadata Leakage\n\nThis NIP minimizes metadata leakage by:\n\n- Using random `d` tags (no path correlation)\n- Encrypting vault names and file paths\n- Encrypting file structure and relationships\n- Not using cleartext tags for filtering\n\n### Remaining Risks\n\n- **Timing analysis**: Event timestamps may reveal activity patterns\n- **Size analysis**: File sizes might be fingerprinted\n- **Relay logging**: Relays see IP addresses and request patterns\n\nUsers requiring stronger privacy should consider:\n\n- Using Tor for relay connections\n- Padding files to uniform sizes\n- Adding random delays to sync operations\n\n## Implementation Notes\n\n### Recommended Libraries\n\n- **nostr-tools**: Event creation, signing, NIP-44 encryption\n- **@noble/hashes**: SHA-256, cryptographic primitives\n- **@noble/ciphers**: AES-GCM for attachment encryption\n\n### Event ID Stability\n\nWhen updating a file, reuse the same `d` tag to ensure the event is replaceable. Generate a new `d` tag only for new files.\n\n### Checksum Calculation\n\n```javascript\nimport { sha256 } from '@noble/hashes/sha256';\nimport { bytesToHex } from '@noble/hashes/utils';\n\nfunction calculateChecksum(content: string): string {\n  const bytes = new TextEncoder().encode(content);\n  return bytesToHex(sha256(bytes));\n}\n```\n\n### Example: Creating a File Event\n\n```javascript\nimport { finalizeEvent, nip44 } from 'nostr-tools';\nimport { v4 as uuidv4 } from 'uuid';\n\nasync function createFileEvent(\n  privateKey: Uint8Array,\n  publicKey: string,\n  path: string,\n  content: string,\n  version: number,\n  previousEventId?: string\n) {\n  const conversationKey = nip44.v2.utils.getConversationKey(privateKey, publicKey);\n\n  const payload = JSON.stringify({\n    path,\n    content,\n    checksum: calculateChecksum(content),\n    version,\n    modified: Math.floor(Date.now() / 1000),\n    previousEventId: previousEventId || null,\n    contentType: 'text/markdown'\n  });\n\n  const encrypted = nip44.v2.encrypt(payload, conversationKey);\n\n  const event = finalizeEvent({\n    kind: 30800,\n    created_at: Math.floor(Date.now() / 1000),\n    tags: [\n      ['d', uuidv4()],\n      ['encrypted', 'nip44']\n    ],\n    content: encrypted\n  }, privateKey);\n\n  return event;\n}\n```\n\n### Example: Sharing a Document\n\n```javascript\nimport { finalizeEvent, nip44 } from 'nostr-tools';\nimport { v4 as uuidv4 } from 'uuid';\n\nasync function shareDocument(\n  senderPrivateKey: Uint8Array,\n  senderPubkey: string,\n  senderName: string,\n  recipientPubkey: string,\n  title: string,\n  content: string,\n  originalPath?: string\n) {\n  // Get conversation key between sender and recipient\n  const conversationKey = nip44.v2.utils.getConversationKey(\n    senderPrivateKey,\n    recipientPubkey\n  );\n\n  const payload = JSON.stringify({\n    title,\n    content,\n    path: originalPath || null,\n    sharedAt: Math.floor(Date.now() / 1000),\n    sharedBy: {\n      pubkey: senderPubkey,\n      name: senderName,\n    }\n  });\n\n  const encrypted = nip44.v2.encrypt(payload, conversationKey);\n\n  const event = finalizeEvent({\n    kind: 30802,\n    created_at: Math.floor(Date.now() / 1000),\n    tags: [\n      ['d', uuidv4()],\n      ['p', recipientPubkey],\n      ['title', title],\n      ['encrypted', 'nip44']\n    ],\n    content: encrypted\n  }, senderPrivateKey);\n\n  return event;\n}\n```\n\n## Test Vectors\n\n### File Event\n\nPrivate key (hex): `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef`\n\nInput:\n```json\n{\n  \"path\": \"/notes/hello.md\",\n  \"content\": \"# Hello World\\n\\nThis is a test note.\",\n  \"version\": 1\n}\n```\n\nExpected checksum: `a3c25e6e5d1a8b3f...` (SHA-256 of content)\n\n### Vault Index Event\n\nInput:\n```json\n{\n  \"name\": \"My Notes\",\n  \"files\": [\n    {\n      \"eventId\": \"abc123...\",\n      \"d\": \"550e8400-e29b-41d4-a716-446655440000\",\n      \"path\": \"/notes/hello.md\",\n      \"checksum\": \"a3c25e6e5d1a8b3f...\",\n      \"version\": 1,\n      \"modified\": 1705234567\n    }\n  ]\n}\n```\n\n### Shared Document Event\n\nSender private key (hex): `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef`\nRecipient public key (hex): `fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210`\n\nInput:\n```json\n{\n  \"title\": \"Meeting Notes\",\n  \"content\": \"# Meeting Notes\\n\\nDiscussed project timeline...\",\n  \"sharedAt\": 1705234567,\n  \"sharedBy\": {\n    \"pubkey\": \"abc123...\",\n    \"name\": \"Alice\"\n  }\n}\n```\n\nExpected tags:\n```json\n[\n  [\"d\", \"<random-uuid>\"],\n  [\"p\", \"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"],\n  [\"title\", \"Meeting Notes\"],\n  [\"encrypted\", \"nip44\"]\n]\n```\n\n## References\n\n- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md)\n- [NIP-05: Mapping Nostr keys to DNS-based internet identifiers](https://github.com/nostr-protocol/nips/blob/master/05.md)\n- [NIP-09: Event Deletion Request](https://github.com/nostr-protocol/nips/blob/master/09.md)\n- [NIP-17: Private Direct Messages](https://github.com/nostr-protocol/nips/blob/master/17.md)\n- [NIP-33: Parameterized Replaceable Events](https://github.com/nostr-protocol/nips/blob/master/33.md)\n- [NIP-44: Encrypted Payloads](https://github.com/nostr-protocol/nips/blob/master/44.md)\n- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md)\n- [Blossom: Blobs Stored Simply on Mediaservers](https://github.com/hzrd149/blossom)","sig":"0f55db6d89ba2df9ce984bddc315a57dabfbe888b92594b2c3160a3e6d8162a8b1af9a967679b49af2e173d6d93c5e1442fd31b6f7649c3d07032c00c0b60be5"}