| title | Security Model | ||||
|---|---|---|---|---|---|
| description | Authentication, encryption, and access control architecture. | ||||
| category | explanation | ||||
| tags |
|
||||
| difficulty | beginner | ||||
| last-updated | 2026-01-16 |
Authentication, encryption, and access control architecture.
The platform implements multiple security layers:
- Identity — Cryptographic key-based authentication
- Transport — WebSocket Secure (WSS)
- Access Control — Whitelist and cohort-based permissions
- Encryption — End-to-end for private messages
- Integrity — Schnorr signatures on all events
┌─────────────────────────────────────────────────────────┐
│ USER DEVICE (Fully Trusted) │
│ ├── Private keys │
│ ├── Decrypted messages │
│ ├── Session data │
│ └── Local database │
└───────────────────────────┬─────────────────────────────┘
│ WSS (TLS 1.3)
┌───────────────────────────▼─────────────────────────────┐
│ RELAY (Partially Trusted) │
│ ├── Can read channel messages (non-E2E) │
│ ├── Cannot read DM content (encrypted) │
│ ├── Enforces access control │
│ └── May be compromised │
└───────────────────────────┬─────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────┐
│ DATABASE (Untrusted for Sensitive Data) │
│ ├── Stores encrypted DMs as opaque blobs │
│ ├── Stores plaintext channel messages │
│ └── Indexes and metadata │
└─────────────────────────────────────────────────────────┘
Users authenticate via cryptographic key pairs, not passwords:
flowchart LR
subgraph KeyGen["Key Generation"]
Entropy[Random Entropy]
BIP39[BIP-39 Mnemonic]
Seed[512-bit Seed]
Keys[Key Pair]
end
Entropy --> BIP39
BIP39 --> Seed
Seed --> Keys
Implementation:
// src/lib/services/auth/keygen.ts
import { generateMnemonic, mnemonicToSeed } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';
import { bytesToHex } from '@noble/hashes/utils';
export function generateKeys(): {
mnemonic: string;
privkey: string;
pubkey: string;
} {
// 1. Generate BIP-39 mnemonic (12 words = 128 bits entropy)
const mnemonic = generateMnemonic(wordlist, 128);
// 2. Derive seed
const seed = mnemonicToSeed(mnemonic);
// 3. Derive key using NIP-06 path
const hdKey = HDKey.fromMasterSeed(seed);
const derived = hdKey.derive("m/44'/1237'/0'/0/0");
const privkey = bytesToHex(derived.privateKey!);
const pubkey = getPublicKey(privkey);
return { mnemonic, privkey, pubkey };
}
export function recoverFromMnemonic(mnemonic: string): {
privkey: string;
pubkey: string;
} {
const seed = mnemonicToSeed(mnemonic);
const hdKey = HDKey.fromMasterSeed(seed);
const derived = hdKey.derive("m/44'/1237'/0'/0/0");
const privkey = bytesToHex(derived.privateKey!);
const pubkey = getPublicKey(privkey);
return { privkey, pubkey };
}sequenceDiagram
participant Client
participant Relay
Client->>Relay: Connect (WSS)
Relay->>Client: AUTH challenge (random string)
Client->>Client: Sign challenge with privkey
Client->>Relay: AUTH response (signed event)
Relay->>Relay: Verify signature
Relay->>Relay: Check pubkey in whitelist
alt Authorised
Relay->>Client: OK (authenticated)
else Not Authorised
Relay->>Client: NOTICE (not whitelisted)
Relay->>Client: Close connection
end
Implementation:
// src/lib/services/ndk/auth.ts
import type { NDKAuthPolicy } from '@nostr-dev-kit/ndk';
export const authPolicy: NDKAuthPolicy = async (relay, challenge) => {
const $auth = get(auth);
if (!$auth.privkey) {
throw new Error('Not authenticated');
}
// Create NIP-42 auth event
const event = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['challenge', challenge]
],
content: ''
};
// Sign with user's key
const signedEvent = finalizeEvent(event, $auth.privkey);
return signedEvent;
};Only whitelisted public keys can access the relay:
// Relay-side whitelist check (conceptual)
function isAuthorised(pubkey: string): boolean {
// Check if pubkey is in whitelist
return whitelist.has(pubkey);
}
function canAccessChannel(pubkey: string, channelId: string): boolean {
// Check membership + cohort
const user = users.get(pubkey);
const channel = channels.get(channelId);
if (!user || !channel) return false;
// Check cohort match
return user.cohorts.includes(channel.cohort);
}flowchart TD
User[User with Cohorts]
User -->|cohort: moomaa-tribe| Z1[Minimoonoir Zone]
User -->|cohort: business| Z2[DreamLab Zone]
User -->|cohort: family| Z3[Family Zone]
User -->|cohort: both| Z1
User -->|cohort: both| Z2
| Cohort | Zones Accessible |
|---|---|
moomaa-tribe |
Minimoonoir |
business |
DreamLab |
family |
Family |
both |
Minimoonoir + DreamLab |
Standard channel messages are not end-to-end encrypted:
- Relay can read content for moderation
- Protected by NIP-42 authentication
- Access controlled by membership
DMs use NIP-17 + NIP-59 for full encryption:
flowchart TB
subgraph Encryption["Encryption Layers"]
M[Message Content]
R[Rumor - kind 14]
S[Seal - kind 13]
G[Gift Wrap - kind 1059]
end
M -->|NIP-44 encrypt| R
R -->|Wrap + encrypt| S
S -->|Random key + encrypt| G
NIP-44 Encryption:
// Uses XChaCha20-Poly1305
import { nip44 } from 'nostr-tools';
function encryptMessage(
plaintext: string,
senderPrivkey: Uint8Array,
recipientPubkey: string
): string {
// Derive shared secret via ECDH
const conversationKey = nip44.getConversationKey(
senderPrivkey,
recipientPubkey
);
// Encrypt with padding
return nip44.encrypt(plaintext, conversationKey);
}| Property | Channel Messages | Private Messages |
|---|---|---|
| Content Encrypted | No | Yes (NIP-44) |
| Sender Hidden | No | Yes (random wrapper) |
| Timestamp Hidden | No | Yes (randomised) |
| Forward Secrecy | N/A | No |
| Relay Can Read | Yes | No |
// src/lib/services/auth/storage.ts
import { openDB } from 'idb';
const DB_NAME = 'nostr-keys';
const STORE_NAME = 'keys';
export async function storeEncryptedKey(
pubkey: string,
encryptedPrivkey: string
): Promise<void> {
const db = await openDB(DB_NAME, 1, {
upgrade(db) {
db.createObjectStore(STORE_NAME, { keyPath: 'pubkey' });
}
});
await db.put(STORE_NAME, { pubkey, encryptedPrivkey });
}
export async function getEncryptedKey(pubkey: string): Promise<string | null> {
const db = await openDB(DB_NAME, 1);
const result = await db.get(STORE_NAME, pubkey);
return result?.encryptedPrivkey || null;
}// Encrypt private key with user passphrase
export async function encryptPrivkey(
privkey: string,
passphrase: string
): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(privkey);
// Derive key from passphrase
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
data
);
// Return salt + iv + ciphertext as base64
return btoa(String.fromCharCode(
...salt, ...iv, ...new Uint8Array(encrypted)
));
}All Nostr events are signed with Schnorr signatures (BIP-340):
interface SignedEvent {
id: string; // SHA256(serialised event)
pubkey: string; // Author's public key
sig: string; // Schnorr signature of id
// ...
}Verification:
import { verifyEvent } from 'nostr-tools';
function isValidEvent(event: NostrEvent): boolean {
// Verify signature matches pubkey and content
return verifyEvent(event);
}- Never log private keys
- Use secure random generation
- Validate all inputs
- Sanitise displayed content (XSS prevention)
- Implement CSRF protection
- HTTPS/WSS only
- Content Security Policy headers
- Rate limiting enabled
- CORS properly configured
- Security headers (HSTS, X-Frame-Options, etc.)
- Private keys never leave device
- Session data encrypted at rest
- Clear sensitive data on logout
- Implement key rotation support
| Threat | Mitigation |
|---|---|
| Key Theft | Encrypted storage, passphrase protection |
| MITM Attack | TLS 1.3, certificate pinning |
| Relay Compromise | E2E encryption for DMs |
| Replay Attack | Timestamp validation |
| Impersonation | Signature verification |
| XSS | Content sanitisation, CSP |
| CSRF | SameSite cookies, tokens |
- NIP Protocol Reference — NIP-42, NIP-44 specs
- DM Implementation — E2E encryption details
- User Privacy Guide — User-facing privacy info