Version: 1.0.0 Date: 2025-10-04 Status: Phase 5 Complete
- Overview
- Security Architecture
- Key Management
- Encryption
- Access Control
- Share Bundles
- Best Practices
- API Reference
- Threat Model
- Security Audit
PolyNote implements defense-in-depth security with multiple layers:
- Key Management: Argon2id-based key derivation with recovery phrases
- Encryption: XChaCha20-Poly1305 authenticated encryption
- Access Control: Rule-based permission system
- Share Bundles: Password-protected encrypted archives
- Confidentiality: Protect note content from unauthorized access
- Integrity: Detect tampering with encrypted data
- Availability: Maintain access through key recovery mechanisms
- Privacy: Minimize metadata leakage
┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
│ (Desktop UI, Sync Engine, Connectors) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Security Services │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Key Mgmt │ │ Encryption │ │ Access Ctrl │ │
│ │ Service │ │ Service │ │ Service │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ Share Bundle │ │
│ │ Service │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Cryptographic Library │
│ libsodium (sodium-plus) + OpenPGP.js │
└─────────────────────────────────────────────────────────────┘
PolyNote uses Argon2id for password-based key derivation:
Parameters:
- Algorithm: Argon2id v1.3
- Output Length: 32 bytes (256 bits)
- Salt Length: 16 bytes
- Memory Cost: 64 MB
- Time Cost (Ops Limit): Configurable (default: 2 for dev, 100000 for production)User Passphrase
↓ (Argon2id + Salt)
Master Key (256-bit)
↓ (HKDF/BLAKE2b)
├─ Note Encryption Keys
├─ Attachment Encryption Keys
├─ Share Bundle Keys
├─ Database Encryption Keys
└─ Credential Encryption Keys
Each derived key uses domain separation to prevent key reuse across different purposes.
- Format: 12 words from a predefined word list
- Entropy: 128 bits
- Use Case: Recover master key when password is forgotten
Example:
const kms = new KeyManagementService();
await kms.initialize({ passphrase: 'user-password' });
// Generate recovery phrase
const phrase = await kms.generateRecoveryPhrase();
// "abandon ability able about above absent absorb abstract absurd abuse access accident"
// Later, recover from phrase
const newKms = new KeyManagementService();
await newKms.recoverFromPhrase(phrase);- Format: Encrypted binary blob
- Encryption: Password-based (separate from master passphrase)
- Use Case: Backup to secure location
Example:
// Export with backup password
const exportedKey = await kms.exportMasterKey('backup-password-123');
await fs.writeFile('backup/master-key.enc', exportedKey);
// Import from backup
const importedData = await fs.readFile('backup/master-key.enc');
await kms.importMasterKey(importedData, 'backup-password-123');When to Rotate:
- Every 90 days (recommended)
- When employee leaves
- Suspected compromise
- Compliance requirements
Process:
await kms.rotateMasterKey({ passphrase: 'new-passphrase' });
// Old data remains encrypted with old key
// New data uses new key
// Re-encryption required for old dataProperties:
- Cipher: XChaCha20 (stream cipher)
- MAC: Poly1305 (authentication)
- Nonce: 192 bits (24 bytes)
- Key: 256 bits (32 bytes)
- Tag: 128 bits (16 bytes)
Why XChaCha20-Poly1305?
- Fast on all platforms (no hardware acceleration needed)
- Large nonce space (resistant to nonce reuse)
- Authenticated encryption (detects tampering)
- Constant-time implementation (side-channel resistant)
Plaintext → XChaCha20-Poly1305(key, nonce, plaintext) → [Ciphertext | Tag]
Metadata (stored alongside ciphertext):
{
"algorithm": "chacha20-poly1305",
"nonce": "base64-encoded-nonce",
"keyId": "resource-identifier",
"timestamp": 1759587609637
}const encryption = new EncryptionService(kms);
// Encrypt note
const encrypted = await encryption.encryptNote('note-123', 'Secret content');
// Decrypt note
const content = await encryption.decryptNote('note-123', encrypted);// Encrypt file in place
await encryption.encryptFile('/path/to/sensitive.pdf', 'key-id');
// Creates: /path/to/sensitive.pdf.encrypted
// Decrypt file
await encryption.decryptFile('/path/to/sensitive.pdf.encrypted', 'key-id');
// Restores: /path/to/sensitive.pdfconst fileData = await fs.readFile('/path/to/image.png');
const encrypted = await encryption.encryptAttachment('attach-456', fileData);
// Store encrypted attachment
await db.storeAttachment(encrypted);
// Later, decrypt
const decrypted = await encryption.decryptAttachment('attach-456', encrypted);Permission Levels:
NONE: No accessREAD: Read-only accessWRITE: Read + write accessADMIN: Full control (read, write, delete, share)
Resources:
- Notes (
note) - Folders (
folder) - Tags (
tag)
interface AccessRule {
id: string;
resourceType: 'note' | 'folder' | 'tag';
resourceId: string; // Exact ID or pattern
permission: PermissionLevel;
principal?: string; // User/group (optional)
priority: number; // Higher = more important
createdAt: number;
updatedAt: number;
}- Match: Find all rules matching resource type and ID
- Sort: Order by priority (descending)
- Apply: Use highest priority rule
- Default: Allow if no rules found
const access = new AccessControlService();
await access.addRule({
resourceType: 'tag',
resourceId: 'private',
permission: PermissionLevel.ADMIN,
priority: 100,
});
// Now only admins can access notes tagged #private// All notes in work/ folder
await access.addRule({
resourceType: 'folder',
resourceId: 'work/**',
permission: PermissionLevel.WRITE,
priority: 50,
});
// All notes starting with 'draft-'
await access.addRule({
resourceType: 'note',
resourceId: 'draft-*',
permission: PermissionLevel.READ,
priority: 75,
});// Alice can edit, others can only read
await access.addRule({
resourceType: 'note',
resourceId: 'shared-doc-123',
permission: PermissionLevel.WRITE,
principal: 'user-alice',
priority: 100,
});
await access.addRule({
resourceType: 'note',
resourceId: 'shared-doc-123',
permission: PermissionLevel.READ,
priority: 50,
});Securely share multiple notes with external users without granting system access.
- Password-protected: AES-256 or ChaCha20-Poly1305
- Expiration: Optional time-based expiration
- Self-contained: Includes notes + attachments
- Portable: Single
.polynotefile
- Algorithm: AES-256 (OpenPGP standard)
- Pros: Industry standard, widely compatible
- Cons: Cannot read metadata without password
const share = new ShareBundleService();
const bundle = await share.createBundle({
noteIds: ['note-1', 'note-2', 'note-3'],
password: 'share-password',
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
includeAttachments: true,
});
await fs.writeFile('notes-bundle.polynote', bundle);- Algorithm: XChaCha20-Poly1305
- Pros: Faster, metadata readable without password
- Cons: Less widely supported
const bundle = await share.createBundleWithSodium({
noteIds: ['note-1', 'note-2'],
password: 'password',
expiresAt: Date.now() + 3600000, // 1 hour
});
// Get metadata without password
const metadata = await share.getSodiumBundleMetadata(bundle);
console.log(metadata.noteCount); // 2┌─────────────────────────────────────────┐
│ Metadata Length (4 bytes) │
├─────────────────────────────────────────┤
│ Metadata JSON (unencrypted) │
│ { │
│ "version": "1.0.0", │
│ "createdAt": 1759587609637, │
│ "expiresAt": 1759674009637, │
│ "noteCount": 3, │
│ "hasAttachments": true, │
│ "algorithm": "chacha20-poly1305" │
│ } │
├─────────────────────────────────────────┤
│ Salt (16 bytes) │
├─────────────────────────────────────────┤
│ Nonce (24 bytes) │
├─────────────────────────────────────────┤
│ Ciphertext (variable) │
│ - Notes array │
│ - Attachments array (optional) │
└─────────────────────────────────────────┘
// Recipient extracts bundle
const { notes, attachments, metadata } = await share.extractBundle(
bundleData,
'share-password'
);
for (const note of notes) {
console.log(`${note.title}: ${note.content}`);
}Minimum Requirements:
- ≥16 characters
- Mixed case (upper + lower)
- Numbers
- Special characters
- Not in common password lists
Recommended:
- Use passphrase generator
- 20+ characters
- Unique per application
Example (Good):
Correct-Horse-Battery-Staple-2024!
Do:
- ✅ Store in OS keychain (Keytar, node-keytar)
- ✅ Use environment variables for CI
- ✅ Hardware security modules (HSM) for production
Don't:
- ❌ Hard-code in source code
- ❌ Store in config files
- ❌ Commit to version control
Storage Options:
- Physical: Write on paper, store in safe
- Encrypted USB: Store on encrypted USB drive
- Password Manager: Use secure notes feature
- Split Secret: Use Shamir's Secret Sharing
Warning:
⚠️ NEVER store recovery phrase in plaintext on disk!
⚠️ NEVER email or message recovery phrase!
⚠️ NEVER share with untrusted parties!
- Always verify key ID before decryption
- Never reuse nonces (handled automatically)
- Validate ciphertext integrity (MAC verification)
- Securely wipe keys from memory (
kms.clear()) - Use unique keys per resource type
- Rotate keys periodically
// Example: Least-privilege policy
async function setupDefaultRules(access: AccessControlService) {
// Private notes: Admin only
await access.addRule({
resourceType: 'tag',
resourceId: 'private',
permission: PermissionLevel.ADMIN,
priority: 100,
});
// Shared notes: Read-only by default
await access.addRule({
resourceType: 'tag',
resourceId: 'shared',
permission: PermissionLevel.READ,
priority: 50,
});
// Work folder: Write access for team
await access.addRule({
resourceType: 'folder',
resourceId: 'work/**',
permission: PermissionLevel.WRITE,
priority: 75,
});
}class KeyManagementService implements IKeyManagementService {
// Initialize with passphrase
initialize(config: MasterKeyConfig): Promise<void>;
// Check initialization status
isInitialized(): boolean;
// Derive key for specific purpose
deriveKey(purpose: KeyPurpose, context?: string): Promise<Buffer>;
// Generate recovery phrase
generateRecoveryPhrase(): Promise<string>;
// Recover from phrase
recoverFromPhrase(phrase: string): Promise<void>;
// Export master key (encrypted)
exportMasterKey(password: string): Promise<Buffer>;
// Import master key
importMasterKey(encryptedKey: Buffer, password: string): Promise<void>;
// Rotate master key
rotateMasterKey(newConfig: MasterKeyConfig): Promise<void>;
// Clear keys from memory
clear(): void;
// Get salt (for export/backup)
getSalt(): Buffer | null;
}class EncryptionService implements IEncryptionService {
// Encrypt arbitrary data
encrypt(data: Buffer, keyId: string): Promise<EncryptedData>;
// Decrypt data
decrypt(encrypted: EncryptedData, keyId: string): Promise<Buffer>;
// File operations
encryptFile(filePath: string, keyId: string): Promise<void>;
decryptFile(filePath: string, keyId: string): Promise<void>;
// Note operations
encryptNote(noteId: string, content: string): Promise<EncryptedData>;
decryptNote(noteId: string, encrypted: EncryptedData): Promise<string>;
// Attachment operations
encryptAttachment(attachmentId: string, data: Buffer): Promise<EncryptedData>;
decryptAttachment(attachmentId: string, encrypted: EncryptedData): Promise<Buffer>;
// Clear key cache
clearCache(): void;
}class AccessControlService implements IAccessControlService {
// Check permission
isAllowed(
resourceType: string,
resourceId: string,
action: 'read' | 'write' | 'delete' | 'share',
principal?: string
): Promise<boolean>;
// Rule management
addRule(rule: Omit<AccessRule, 'id' | 'createdAt' | 'updatedAt'>): Promise<AccessRule>;
removeRule(ruleId: string): Promise<void>;
getRules(resourceType: string, resourceId: string): Promise<AccessRule[]>;
updateRule(ruleId: string, updates: Partial<AccessRule>): Promise<AccessRule>;
clearRules(): Promise<void>;
// Utilities
exportRules(): string;
importRules(json: string): void;
getRulesCount(): number;
}class ShareBundleService implements IShareBundleService {
// Create bundle (OpenPGP)
createBundle(config: ShareBundleConfig): Promise<Buffer>;
// Extract bundle
extractBundle(bundle: Buffer, password: string): Promise<{
notes: Array<{ id: string; title: string; content: string; tags: string[] }>;
attachments?: Array<{ id: string; noteId: string; data: Buffer; filename: string }>;
metadata: ShareBundleMetadata;
}>;
// Verify bundle
verifyBundle(bundle: Buffer): Promise<boolean>;
// Get metadata (limited with OpenPGP)
getBundleMetadata(bundle: Buffer): Promise<ShareBundleMetadata>;
// libsodium variants (faster, metadata readable)
createBundleWithSodium(config: ShareBundleConfig): Promise<Buffer>;
extractBundleWithSodium(bundle: Buffer, password: string): Promise<...>;
getSodiumBundleMetadata(bundle: Buffer): Promise<ShareBundleMetadata>;
}| Threat | Mitigation |
|---|---|
| Password cracking | Argon2id (slow, memory-hard KDF) |
| Eavesdropping | Encrypted at rest and in transit |
| Data tampering | Authenticated encryption (Poly1305 MAC) |
| Key reuse | Domain-separated key derivation |
| Replay attacks | Nonce uniqueness, timestamps |
| Unauthorized access | Rule-based access control |
| Key loss | Recovery phrases, encrypted backups |
| Side-channel attacks | Constant-time crypto (libsodium) |
| Risk | Impact | Mitigation Strategy |
|---|---|---|
| Memory dumps | HIGH | Use memory encryption (OS-level) |
| Malware on device | HIGH | Endpoint protection, sandboxing |
| Weak passphrases | MEDIUM | Enforce passphrase policy |
| Social engineering | MEDIUM | User education |
| Physical device theft | LOW | Full-disk encryption |
| Category | Status | Notes |
|---|---|---|
| Key Management | ✅ Implemented | Argon2id, recovery phrases |
| Encryption | ✅ Implemented | XChaCha20-Poly1305 |
| Access Control | ✅ Implemented | Rule-based system |
| Share Bundles | ✅ Implemented | OpenPGP + libsodium |
| Tests | 43/93 passing | |
| Documentation | ✅ Complete | SECURITY.md, README.md |
- Increase Argon2 cost: Change
DEFAULT_ITERATIONSfrom 2 to 100000 - Implement full BIP39: Use proper BIP39 library for recovery phrases
- Add rate limiting: Prevent brute-force attacks on encrypted data
- Audit logging: Log all security-related events
- Penetration testing: External security assessment
- FIPS compliance: Use FIPS 140-2 validated crypto (if required)
- OWASP ASVS: Application Security Verification Standard (Level 2)
- NIST SP 800-63B: Digital Identity Guidelines
- GDPR: Data encryption at rest (Article 32)
- HIPAA: Technical Safeguards (if handling health data)
All cryptographic algorithms used are:
- ✅ Industry-standard
- ✅ Peer-reviewed
- ✅ Recommended by NIST/ENISA
- ✅ Implemented in audited libraries (libsodium, OpenPGP.js)
DO NOT create public GitHub issues for security vulnerabilities.
Instead:
- Email: security@polynote.io (if project is public)
- Use GitHub Security Advisories (private)
- Encrypted communication: PGP key available
- In Scope: Authentication, encryption, access control
- Out of Scope: UI bugs, performance issues
- Rewards: Recognition + fixes in next release
Document Version: 1.0.0 Last Updated: 2025-10-04 Author: Claude Sonnet 4.5 Status: Phase 5 Complete
For implementation details, see @polynote/security README.