148 lines
4.3 KiB
Markdown
148 lines
4.3 KiB
Markdown
---
|
|||
|
|
description: E2E encrypted records and group encryption for the Syntropy Institute portal
|
||
|
|
match:
|
||
|
|
- encrypt
|
||
|
|
- decrypt
|
||
|
|
- record
|
||
|
|
- NIP-44
|
||
|
|
- group key
|
||
|
|
- channel
|
||
|
|
- community
|
||
|
|
---
|
||
|
|
|
||
|
|
# Encrypted Records Skill
|
||
|
|
|
||
|
|
## When to Use
|
||
|
|
When working with E2E encrypted client records, community posts, or group key management.
|
||
|
|
|
||
|
|
## NIP-44 Encryption (Practitioner <-> Client)
|
||
|
|
```typescript
|
||
|
|
import { nip44 } from 'nostr-tools';
|
||
|
|
|
||
|
|
// Encrypt record for a specific client
|
||
|
|
function encryptRecord(
|
||
|
|
practitionerSecretKey: Uint8Array,
|
||
|
|
clientPubKey: string,
|
||
|
|
recordData: object
|
||
|
|
): string {
|
||
|
|
const plaintext = JSON.stringify(recordData);
|
||
|
|
const conversationKey = nip44.v2.utils.getConversationKey(practitionerSecretKey, clientPubKey);
|
||
|
|
return nip44.v2.encrypt(plaintext, conversationKey);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decrypt record (client side)
|
||
|
|
function decryptRecord(
|
||
|
|
clientSecretKey: Uint8Array,
|
||
|
|
practitionerPubKey: string,
|
||
|
|
encryptedData: string
|
||
|
|
): object {
|
||
|
|
const conversationKey = nip44.v2.utils.getConversationKey(clientSecretKey, practitionerPubKey);
|
||
|
|
const plaintext = nip44.v2.decrypt(encryptedData, conversationKey);
|
||
|
|
return JSON.parse(plaintext);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Group Encryption (Community Channels)
|
||
|
|
|
||
|
|
### Creating a Channel with Group Key
|
||
|
|
```typescript
|
||
|
|
// Generate a symmetric group key for the channel
|
||
|
|
function generateGroupKey(): Uint8Array {
|
||
|
|
return crypto.getRandomValues(new Uint8Array(32));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Encrypt group key for a specific member using NIP-44
|
||
|
|
function wrapGroupKeyForMember(
|
||
|
|
adminSecretKey: Uint8Array,
|
||
|
|
memberPubKey: string,
|
||
|
|
groupKey: Uint8Array
|
||
|
|
): string {
|
||
|
|
const conversationKey = nip44.v2.utils.getConversationKey(adminSecretKey, memberPubKey);
|
||
|
|
return nip44.v2.encrypt(
|
||
|
|
btoa(String.fromCharCode(...groupKey)),
|
||
|
|
conversationKey
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Member unwraps their group key
|
||
|
|
function unwrapGroupKey(
|
||
|
|
memberSecretKey: Uint8Array,
|
||
|
|
adminPubKey: string,
|
||
|
|
wrappedKey: string
|
||
|
|
): Uint8Array {
|
||
|
|
const conversationKey = nip44.v2.utils.getConversationKey(memberSecretKey, adminPubKey);
|
||
|
|
const decoded = nip44.v2.decrypt(wrappedKey, conversationKey);
|
||
|
|
return Uint8Array.from(atob(decoded), c => c.charCodeAt(0));
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Encrypting Community Posts with Group Key
|
||
|
|
```typescript
|
||
|
|
// Encrypt post content with the channel's symmetric group key
|
||
|
|
async function encryptPostWithGroupKey(
|
||
|
|
groupKey: Uint8Array,
|
||
|
|
content: string
|
||
|
|
): Promise<string> {
|
||
|
|
const key = await crypto.subtle.importKey(
|
||
|
|
'raw', groupKey, 'AES-GCM', false, ['encrypt']
|
||
|
|
);
|
||
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
|
|
const encoded = new TextEncoder().encode(content);
|
||
|
|
const ciphertext = await crypto.subtle.encrypt(
|
||
|
|
{ name: 'AES-GCM', iv }, key, encoded
|
||
|
|
);
|
||
|
|
const combined = new Uint8Array([...iv, ...new Uint8Array(ciphertext)]);
|
||
|
|
return btoa(String.fromCharCode(...combined));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decrypt post content
|
||
|
|
async function decryptPostWithGroupKey(
|
||
|
|
groupKey: Uint8Array,
|
||
|
|
encrypted: string
|
||
|
|
): Promise<string> {
|
||
|
|
const combined = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
|
||
|
|
const iv = combined.slice(0, 12);
|
||
|
|
const ciphertext = combined.slice(12);
|
||
|
|
const key = await crypto.subtle.importKey(
|
||
|
|
'raw', groupKey, 'AES-GCM', false, ['decrypt']
|
||
|
|
);
|
||
|
|
const decrypted = await crypto.subtle.decrypt(
|
||
|
|
{ name: 'AES-GCM', iv }, key, ciphertext
|
||
|
|
);
|
||
|
|
return new TextDecoder().decode(decrypted);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Key Rotation (when member removed)
|
||
|
|
```typescript
|
||
|
|
// When a member is removed from a channel:
|
||
|
|
// 1. Generate new group key
|
||
|
|
// 2. Re-wrap for all remaining members
|
||
|
|
// 3. Update ChannelMember.encryptedGroupKey for each
|
||
|
|
// 4. New posts use new key; old posts remain readable with old key
|
||
|
|
// (store key version/epoch on each post)
|
||
|
|
```
|
||
|
|
|
||
|
|
## Record Types
|
||
|
|
```typescript
|
||
|
|
interface ClientRecord {
|
||
|
|
type: 'session_notes' | 'assessment' | 'treatment_plan';
|
||
|
|
title: string;
|
||
|
|
content: string; // Rich text or structured data
|
||
|
|
attachments?: {
|
||
|
|
name: string;
|
||
|
|
mimeType: string;
|
||
|
|
encryptedData: string; // Each file encrypted separately
|
||
|
|
}[];
|
||
|
|
createdAt: string; // ISO timestamp
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Rules
|
||
|
|
- ALL encryption happens client-side — server stores only encrypted blobs
|
||
|
|
- Use NIP-44 v2 (NOT NIP-04 — it's deprecated and has known weaknesses)
|
||
|
|
- Group keys: AES-256-GCM with random IVs
|
||
|
|
- Include key version/epoch on group-encrypted content for rotation support
|
||
|
|
- Never log or expose plaintext content on the server
|
||
|
|
- Media files are encrypted individually before upload
|