Skip to content

Commit 2401562

Browse files
vveerrggclaude
andcommitted
feat: add NIP-44 settings encryption, upgrade to nostr-crypto-utils v0.5.1
Add opt-in NIP-44 (ChaCha20 + HMAC) encryption for settings stored via Nostr DMs. SettingsManager now accepts an options-based constructor with encryptionVersion and privateKey. Legacy 2-arg constructor defaults to NIP-04 for backward compatibility. Graceful fallback on read allows smooth migration from NIP-04 to NIP-44. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent efe8e2c commit 2401562

5 files changed

Lines changed: 166 additions & 15 deletions

File tree

package-lock.json

Lines changed: 27 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"@scure/base": "^1.2.6",
5555
"@simplewebauthn/browser": "^8.3.7",
5656
"@simplewebauthn/server": "^8.3.5",
57-
"nostr-crypto-utils": "^0.4.15"
57+
"nostr-crypto-utils": "^0.5.1"
5858
},
5959
"devDependencies": {
6060
"@eslint/js": "^10.0.1",

src/settings/settings-manager.ts

Lines changed: 126 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,67 @@
1-
import { NostrService, NostrEvent } from '../types/nostr';
2-
import { Settings } from '../types/settings';
1+
import { nip44 } from 'nostr-crypto-utils';
2+
import { hexToBytes } from 'nostr-crypto-utils';
3+
import type { NostrService, NostrEvent, EncryptionVersion } from '../types/nostr';
4+
import type { Settings } from '../types/settings';
5+
6+
/**
7+
* Options for SettingsManager construction.
8+
*/
9+
export interface SettingsManagerOptions {
10+
/** The NostrService implementation for sending/querying DMs */
11+
nostrService: NostrService;
12+
/** The user's hex-encoded public key */
13+
userPubkey: string;
14+
/**
15+
* The user's hex-encoded private key.
16+
* Required when encryptionVersion is 'nip44' for deriving conversation keys.
17+
* When using 'nip04', the consuming application handles encryption in sendDirectMessage.
18+
*/
19+
privateKey?: string;
20+
/**
21+
* Encryption version to use for settings DMs.
22+
* - 'nip04': Legacy NIP-04 encryption (default). Encryption is handled by the
23+
* consuming application's NostrService.sendDirectMessage implementation.
24+
* - 'nip44': Modern NIP-44 encryption. SettingsManager encrypts/decrypts content
25+
* directly using the provided privateKey before passing to NostrService.
26+
*/
27+
encryptionVersion?: EncryptionVersion;
28+
}
329

430
export class SettingsManager {
531
private nostrService: NostrService;
632
private userPubkey: string;
33+
private privateKey: string | undefined;
34+
private encryptionVersion: EncryptionVersion;
735
private cachedSettings: Settings | null = null;
836

9-
constructor(nostrService: NostrService, userPubkey: string) {
10-
this.nostrService = nostrService;
11-
this.userPubkey = userPubkey;
37+
constructor(options: SettingsManagerOptions);
38+
/**
39+
* @deprecated Use the options object constructor instead.
40+
* Retained for backward compatibility.
41+
*/
42+
constructor(nostrService: NostrService, userPubkey: string);
43+
constructor(
44+
optionsOrService: SettingsManagerOptions | NostrService,
45+
userPubkey?: string
46+
) {
47+
if (userPubkey !== undefined) {
48+
// Legacy two-argument constructor
49+
this.nostrService = optionsOrService as NostrService;
50+
this.userPubkey = userPubkey;
51+
this.encryptionVersion = 'nip04';
52+
} else {
53+
const opts = optionsOrService as SettingsManagerOptions;
54+
this.nostrService = opts.nostrService;
55+
this.userPubkey = opts.userPubkey;
56+
this.privateKey = opts.privateKey;
57+
this.encryptionVersion = opts.encryptionVersion ?? 'nip04';
58+
59+
if (this.encryptionVersion === 'nip44' && !this.privateKey) {
60+
throw new Error(
61+
'privateKey is required when using NIP-44 encryption'
62+
);
63+
}
64+
}
1265
}
1366

1467
/**
@@ -24,6 +77,51 @@ export class SettingsManager {
2477
};
2578
}
2679

80+
/**
81+
* Encrypt content using NIP-44.
82+
* @param plaintext - The plaintext content to encrypt
83+
* @returns Base64-encoded NIP-44 encrypted payload
84+
*/
85+
private encryptNip44(plaintext: string): string {
86+
if (!this.privateKey) {
87+
throw new Error('privateKey is required for NIP-44 encryption');
88+
}
89+
const privkeyBytes = hexToBytes(this.privateKey);
90+
const conversationKey = nip44.getConversationKey(privkeyBytes, this.userPubkey);
91+
return nip44.encrypt(plaintext, conversationKey);
92+
}
93+
94+
/**
95+
* Decrypt content using NIP-44.
96+
* @param payload - Base64-encoded NIP-44 encrypted payload
97+
* @returns Decrypted plaintext string
98+
*/
99+
private decryptNip44(payload: string): string {
100+
if (!this.privateKey) {
101+
throw new Error('privateKey is required for NIP-44 decryption');
102+
}
103+
const privkeyBytes = hexToBytes(this.privateKey);
104+
const conversationKey = nip44.getConversationKey(privkeyBytes, this.userPubkey);
105+
return nip44.decrypt(payload, conversationKey);
106+
}
107+
108+
/**
109+
* Attempt to decrypt event content, trying NIP-44 first if configured,
110+
* then falling back to treating content as plaintext JSON (for NIP-04,
111+
* where the NostrService handles decryption before returning events).
112+
*/
113+
private decryptContent(event: NostrEvent): string {
114+
if (this.encryptionVersion === 'nip44' && this.privateKey) {
115+
try {
116+
return this.decryptNip44(event.content);
117+
} catch {
118+
// Fall through to try as plain JSON (may be a legacy NIP-04 event
119+
// already decrypted by the NostrService)
120+
}
121+
}
122+
return event.content;
123+
}
124+
27125
/**
28126
* Get settings from Nostr DMs
29127
*/
@@ -35,11 +133,12 @@ export class SettingsManager {
35133
kinds: [4], // kind 4 is for encrypted direct messages
36134
limit: 100
37135
});
38-
136+
39137
// Filter for settings DMs
40138
const settingsDMs = events.filter((event: NostrEvent) => {
41139
try {
42-
const content = JSON.parse(event.content);
140+
const decrypted = this.decryptContent(event);
141+
const content = JSON.parse(decrypted);
43142
return content.type === 'settings';
44143
} catch {
45144
return false;
@@ -51,7 +150,8 @@ export class SettingsManager {
51150
}
52151

53152
// Get latest settings
54-
const latestSettings = JSON.parse(settingsDMs[0].content);
153+
const decrypted = this.decryptContent(settingsDMs[0]);
154+
const latestSettings = JSON.parse(decrypted);
55155
return latestSettings.data;
56156
} catch (error) {
57157
console.error('Error getting settings from DMs:', error);
@@ -78,7 +178,10 @@ export class SettingsManager {
78178
}
79179

80180
/**
81-
* Save settings to DM
181+
* Save settings to DM.
182+
* When using NIP-44, content is encrypted before passing to NostrService.
183+
* When using NIP-04, content is passed as plaintext JSON and the
184+
* NostrService.sendDirectMessage implementation handles encryption.
82185
*/
83186
async saveSettings(settings: Settings): Promise<void> {
84187
const message = {
@@ -87,7 +190,13 @@ export class SettingsManager {
87190
timestamp: Date.now()
88191
};
89192

90-
await this.nostrService.sendDirectMessage(this.userPubkey, JSON.stringify(message));
193+
let content = JSON.stringify(message);
194+
195+
if (this.encryptionVersion === 'nip44') {
196+
content = this.encryptNip44(content);
197+
}
198+
199+
await this.nostrService.sendDirectMessage(this.userPubkey, content);
91200
this.cachedSettings = settings;
92201
}
93202

@@ -100,4 +209,11 @@ export class SettingsManager {
100209
await this.saveSettings(updated);
101210
return updated;
102211
}
212+
213+
/**
214+
* Get the current encryption version
215+
*/
216+
getEncryptionVersion(): EncryptionVersion {
217+
return this.encryptionVersion;
218+
}
103219
}

src/types/nostr.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export interface NostrFilter {
1818
limit?: number;
1919
}
2020

21+
/**
22+
* Encryption version for Nostr direct messages.
23+
* - 'nip04': Legacy NIP-04 AES-CBC encryption (kind 4). Default for backward compat.
24+
* - 'nip44': Modern NIP-44 ChaCha20+HMAC encryption (kind 44). Opt-in.
25+
*/
26+
export type EncryptionVersion = 'nip04' | 'nip44';
27+
2128
export interface NostrService {
2229
sendDirectMessage(pubkey: string, content: string): Promise<NostrEvent>;
2330
queryEvents(filter: NostrFilter): Promise<NostrEvent[]>;

src/types/settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
* Settings types for the nostr-biometric-login-service
33
*/
44

5+
import type { EncryptionVersion } from './nostr';
6+
57
export interface Settings {
68
npub: string;
79
relays: string[];
810
magicLinkExpiry: number;
911
biometricEnabled: boolean;
1012
sessionDuration: number;
1113
lastLogin?: number;
14+
/** Encryption version used for settings DMs. Defaults to 'nip04'. */
15+
encryptionVersion?: EncryptionVersion;
1216
}
1317

1418
export interface SettingsUpdate {
@@ -17,6 +21,7 @@ export interface SettingsUpdate {
1721
magicLinkExpiry?: number;
1822
biometricEnabled?: boolean;
1923
sessionDuration?: number;
24+
encryptionVersion?: EncryptionVersion;
2025
}
2126

2227
export interface SessionState {

0 commit comments

Comments
 (0)